Wróć do bloga
Tutorial10 min czytania

Zapisywanie danych graczy w FiveM: MySQL, KVP i State Bags

Wybierz właściwy sposób przechowywania danych graczy FiveM. MySQL, KVP, state bags i wzorce frameworków porównane z myślą o wydajności oraz niezawodności.

Agency Scripts

Założyciel i główny deweloper w Agency Scripts

Wybór właściwej strategii przechowywania danych

Trwałość danych graczy to jeden z najbardziej kluczowych aspektów tworzenia serwerów FiveM. Każda informacja o graczu, od stanu konta i przypisanej pracy po wygląd postaci i poziomy umiejętności, musi przetrwać restarty serwera oraz rozłączenia graczy. FiveM oferuje kilka mechanizmów przechowywania danych, z których każdy odpowiada innym zastosowaniom. Bazy danych MySQL za pośrednictwem bibliotek takich jak oxmysql zapewniają relacyjne przechowywanie ustrukturyzowanych danych, które muszą być odpytywane pomiędzy graczami. Magazyn Key-Value Pair (KVP) oferuje szybką, lokalną trwałość dla ustawień na poziomie serwera. State bags umożliwiają synchronizację danych w czasie rzeczywistym między serwerem a klientem bez ręcznej obsługi eventów. Najlepsze serwery łączą wszystkie trzy podejścia: MySQL do stałych rekordów, KVP do buforowania konfiguracji oraz state bags do danych sesji na żywo, które muszą widzieć inni gracze.

Projektowanie schematu bazy danych dla danych graczy

Dobrze zaprojektowany schemat bazy danych rozdziela odpowiedzialności na logiczne tabele, zamiast wrzucać wszystko do pojedynczej kolumny JSON. Choć przechowywanie wszystkich danych gracza jako obiektu JSON bywa kuszące, podejście to niezwykle utrudnia wykonywanie zapytań, indeksowanie i debugowanie. Zamiast tego użyj dedykowanych tabel dla odrębnych obszarów danych. Główna tabela graczy zawiera pola tożsamości i uwierzytelniania, natomiast powiązane tabele przechowują dane o pracy, konta bankowe, ekwipunek oraz metadane postaci. Oto znormalizowany schemat podstawowych danych gracza:

CREATE TABLE IF NOT EXISTS players (
    citizenid VARCHAR(50) PRIMARY KEY,
    license VARCHAR(60) NOT NULL,
    name VARCHAR(50) NOT NULL,
    money TEXT DEFAULT '{"cash":500,"bank":5000,"crypto":0}',
    charinfo TEXT DEFAULT '{}',
    job TEXT DEFAULT '{}',
    gang TEXT DEFAULT '{}',
    position TEXT DEFAULT '{"x":-269.4,"y":-955.3,"z":31.2,"heading":205.8}',
    metadata TEXT DEFAULT '{}',
    last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_license (license)
);

CREATE TABLE IF NOT EXISTS player_skills (
    id INT AUTO_INCREMENT PRIMARY KEY,
    citizenid VARCHAR(50) NOT NULL,
    skill_name VARCHAR(50) NOT NULL,
    skill_level INT DEFAULT 0,
    experience INT DEFAULT 0,
    UNIQUE KEY unique_skill (citizenid, skill_name),
    FOREIGN KEY (citizenid) REFERENCES players(citizenid) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS player_contacts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    citizenid VARCHAR(50) NOT NULL,
    contact_name VARCHAR(50) NOT NULL,
    contact_number VARCHAR(20) NOT NULL,
    contact_iban VARCHAR(50) DEFAULT NULL,
    INDEX idx_owner (citizenid),
    FOREIGN KEY (citizenid) REFERENCES players(citizenid) ON DELETE CASCADE
);

Ten ON DELETE CASCADE ograniczenie foreign key zapewnia, że po usunięciu postaci wszystkie powiązane rekordy w tabelach podrzędnych są automatycznie usuwane, co zapobiega powstawaniu osieroconych danych. last_updated znacznik czasu z ON UPDATE CURRENT_TIMESTAMP zapewnia wbudowany dziennik audytu pokazujący, kiedy rekord gracza był ostatnio modyfikowany, co jest nieocenione przy debugowaniu zgłoszeń o utracie danych.

Wydajne ładowanie danych przy połączeniu gracza

Gdy gracz łączy się z serwerem, musisz załadować wszystkie jego dane z bazy i wypełnić obiekt gracza w pamięci podręcznej. Kluczem jest łączenie zapytań do bazy w pakiety zamiast wykonywania ich pojedynczo. Łączący się gracz może potrzebować danych z pięciu lub więcej tabel, a wykonanie pięciu kolejnych zapytań dodaje znaczne opóźnienie do ekranu ładowania. Użyj pojedynczego zapytania wieloinstrukcyjnego (multi-statement) lub wykonuj zapytania równolegle za pomocą obietnic (promises). Oto zoptymalizowany wzorzec ładowania:

function LoadPlayerData(citizenid, callback)
    local queries = {
        {query = 'SELECT * FROM players WHERE citizenid = ?', values = {citizenid}},
        {query = 'SELECT * FROM player_vehicles WHERE citizenid = ?', values = {citizenid}},
        {query = 'SELECT * FROM player_skills WHERE citizenid = ?', values = {citizenid}},
        {query = 'SELECT * FROM player_contacts WHERE citizenid = ?', values = {citizenid}},
        {query = 'SELECT * FROM player_houses WHERE citizenid = ?', values = {citizenid}},
    }

    local results = {}
    local completed = 0
    local total = #queries

    for i, q in ipairs(queries) do
        MySQL.query(q.query, q.values, function(result)
            results[i] = result
            completed = completed + 1
            if completed == total then
                -- All queries finished, build player object
                local playerData = BuildPlayerObject(results)
                callback(playerData)
            end
        end)
    end
end

function BuildPlayerObject(results)
    local coreData = results[1] and results[1][1]
    if not coreData then return nil end

    return {
        citizenid = coreData.citizenid,
        money = json.decode(coreData.money),
        charinfo = json.decode(coreData.charinfo),
        job = json.decode(coreData.job),
        position = json.decode(coreData.position),
        metadata = json.decode(coreData.metadata),
        vehicles = results[2] or {},
        skills = results[3] or {},
        contacts = results[4] or {},
        houses = results[5] or {},
    }
end

Dzięki jednoczesnemu wykonaniu wszystkich pięciu zapytań całkowity czas ładowania staje się czasem trwania najwolniejszego pojedynczego zapytania, a nie sumą wszystkich pięciu. W dobrze zaindeksowanej bazie danych zazwyczaj skraca to czas ładowania gracza z kilkuset milisekund do poniżej 100ms. Zawsze obsługuj przypadek, w którym rekord gracza nie istnieje, co zdarza się przy pierwszym połączeniu lub po wipe postaci.

Zapisywanie danych za pomocą zapisu wsadowego

Zapisywanie danych gracza przy każdej zmianie jest nieefektywne i generuje niepotrzebne obciążenie bazy danych. Zamiast tego zaimplementuj system dirty-flag, który oznacza zmienione obszary danych i wysyła je do bazy danych w regularnych odstępach czasu. Takie podejście wsadowe drastycznie zmniejsza liczbę operacji zapisu, zapewniając jednocześnie wystarczająco częsty zapis danych, aby zminimalizować straty w razie awarii. Oto praktyczny save manager:

local SaveManager = {
    dirty = {},     -- tracks which players have unsaved changes
    interval = 60,  -- seconds between auto-saves
}

function SaveManager:MarkDirty(citizenid, domain)
    if not self.dirty[citizenid] then
        self.dirty[citizenid] = {}
    end
    self.dirty[citizenid][domain] = true
end

function SaveManager:SavePlayer(citizenid)
    local Player = QBCore.Functions.GetPlayerByCitizenId(citizenid)
    if not Player then
        self.dirty[citizenid] = nil
        return
    end

    local domains = self.dirty[citizenid]
    if not domains then return end

    local pd = Player.PlayerData

    if domains.money then
        MySQL.update('UPDATE players SET money = ? WHERE citizenid = ?',
            {json.encode(pd.money), citizenid})
    end

    if domains.job then
        MySQL.update('UPDATE players SET job = ? WHERE citizenid = ?',
            {json.encode(pd.job), citizenid})
    end

    if domains.position then
        MySQL.update('UPDATE players SET position = ? WHERE citizenid = ?',
            {json.encode(pd.position), citizenid})
    end

    if domains.metadata then
        MySQL.update('UPDATE players SET metadata = ? WHERE citizenid = ?',
            {json.encode(pd.metadata), citizenid})
    end

    self.dirty[citizenid] = nil
end

-- Auto-save loop
CreateThread(function()
    while true do
        Wait(SaveManager.interval * 1000)
        for citizenid, _ in pairs(SaveManager.dirty) do
            SaveManager:SavePlayer(citizenid)
        end
    end
end)

Każdy obszar, taki jak pieniądze, praca czy pozycja, jest zapisywany niezależnie, dzięki czemu zmiana stanu gotówki gracza nie powoduje ponownego zapisu wszystkich danych jego postaci. Takie selektywne zapisywanie zmniejsza również ryzyko wystąpienia race condition, w którym dwa skrypty jednocześnie modyfikują różne części obiektu gracza, a jeden nadpisuje zmiany drugiego.

Używanie State Bags do synchronizacji w czasie rzeczywistym

State bagi to natywny dla FiveM mechanizm synchronizacji danych między serwerem a klientem bez pisania własnych eventów. Działają jak właściwości reaktywne: gdy serwer ustawia wartość state baga, wszyscy subskrybowani klienci automatycznie otrzymują aktualizację. Dzięki temu są idealne dla danych, które inni gracze muszą widzieć w czasie rzeczywistym, takich jak nazwy prac wyświetlane nad głowami, status służby czy niestandardowe tytuły graczy. State bagi można ustawiać na bytach (gracze, pojazdy, obiekty) lub globalnie. Oto jak efektywnie korzystać ze state bagów gracza:

-- Server side: set state bag values when player data changes
function UpdatePlayerStateBags(src, playerData)
    local player = GetPlayerPed(src)

    -- These values are visible to all clients
    Player(src).state:set('job', playerData.job.name, true)
    Player(src).state:set('jobLabel', playerData.job.label, true)
    Player(src).state:set('onDuty', playerData.job.onduty, true)
    Player(src).state:set('gangName', playerData.gang.name, true)

    -- This value is only replicated to the owning client
    Player(src).state:set('bankBalance', playerData.money.bank, false)
end

-- Client side: react to state bag changes
AddStateBagChangeHandler('onDuty', nil, function(bagName, key, value)
    local playerId = GetPlayerFromStateBagName(bagName)
    if not playerId or playerId == 0 then return end

    local playerPed = GetPlayerPed(playerId)
    if not DoesEntityExist(playerPed) then return end

    -- Update overhead display, name tags, etc.
    UpdatePlayerNameTag(playerId, value)
end)

Drugi parametr state:set kontroluje replikację. Ustawienie na true replikuje do wszystkich klientów, podczas gdy false replikuje się tylko do klienta będącego właścicielem. Użyj false dla wrażliwych danych, takich jak stan konta bankowego, których inni gracze nie powinni widzieć. State bags utrzymują się przez czas trwania sesji gracza, ale nie są zapisywane w bazie danych, więc uzupełniają przechowywanie w MySQL, zamiast je zastępować.

Pamięć KVP do konfiguracji serwera

Magazyn par klucz-wartość (KVP) zapewnia szybki, oparty na plikach mechanizm trwałości wbudowany w FiveM. W przeciwieństwie do MySQL operacje KVP są synchroniczne i nie wymagają wywołań sieciowych, dzięki czemu odczyt i zapis małych porcji danych są wyjątkowo szybkie. Dane KVP są przechowywane dla każdego resource z osobna, co oznacza, że każdy resource posiada własną, odizolowaną przestrzeń kluczy. Sprawia to, że KVP idealnie nadaje się do przechowywania konfiguracji resource'a, buforowania często używanych danych referencyjnych lub utrwalania ustawień serwerowych, które nie pasują do relacyjnej bazy danych:

-- Server-side KVP helpers
local KVPCache = {}

function GetCachedKVP(key, default)
    if KVPCache[key] ~= nil then
        return KVPCache[key]
    end

    local value = GetResourceKvpString(key)
    if value == nil or value == '' then
        KVPCache[key] = default
        return default
    end

    local decoded = json.decode(value)
    KVPCache[key] = decoded
    return decoded
end

function SetCachedKVP(key, value)
    KVPCache[key] = value
    SetResourceKvp(key, json.encode(value))
end

-- Usage examples
SetCachedKVP('server_weather', {weather = 'CLEAR', time = 12, frozen = false})
SetCachedKVP('economy_multiplier', 1.5)
SetCachedKVP('last_restart', os.time())

local weather = GetCachedKVP('server_weather', {weather = 'CLEAR', time = 12})
print('Current weather: ' .. weather.weather)

Warstwa buforowania zapobiega powtarzającym się odczytom z dysku w przypadku często używanych wartości. KVP nie nadaje się do dużych zbiorów danych ani danych specyficznych dla gracza wymagających zapytań, ponieważ nie ma indeksowania ani możliwości wyszukiwania. Pomyśl o tym raczej jak o magazynie konfiguracji niż o bazie danych. Jednym z popularnych wzorców jest buforowanie wyników kosztownych zapytań do bazy danych w KVP z TTL i sprawdzanie znacznika czasu przed zwróceniem buforowanej wartości.

Migracja danych i aktualizacje schematu

W miarę rozwoju serwera będziesz musiał aktualizować schematy bazy danych bez utraty istniejących danych graczy. Stwórz system migracji, który śledzi zastosowane zmiany schematu i uruchamia oczekujące migracje podczas startu serwera. Przechowuj historię migracji w dedykowanej tabeli, aby dokładnie widzieć, które zmiany zostały zastosowane i kiedy. Każda migracja powinna być idempotentna, co oznacza, że można ją bezpiecznie uruchomić wielokrotnie bez wywoływania błędów:

-- Migration system
local migrations = {
    {
        version = 1,
        name = 'add_player_skills',
        query = [[
            CREATE TABLE IF NOT EXISTS player_skills (
                id INT AUTO_INCREMENT PRIMARY KEY,
                citizenid VARCHAR(50) NOT NULL,
                skill_name VARCHAR(50) NOT NULL,
                skill_level INT DEFAULT 0,
                experience INT DEFAULT 0,
                UNIQUE KEY unique_skill (citizenid, skill_name)
            )
        ]]
    },
    {
        version = 2,
        name = 'add_metadata_column',
        query = [[
            ALTER TABLE players
            ADD COLUMN IF NOT EXISTS metadata TEXT DEFAULT '{}'
        ]]
    },
    {
        version = 3,
        name = 'add_last_updated',
        query = [[
            ALTER TABLE players
            ADD COLUMN IF NOT EXISTS last_updated
            TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
        ]]
    },
}

CreateThread(function()
    MySQL.query.await([[
        CREATE TABLE IF NOT EXISTS schema_migrations (
            version INT PRIMARY KEY,
            name VARCHAR(100),
            applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ]])

    local applied = MySQL.query.await('SELECT version FROM schema_migrations')
    local appliedSet = {}
    for _, row in ipairs(applied or {}) do
        appliedSet[row.version] = true
    end

    for _, migration in ipairs(migrations) do
        if not appliedSet[migration.version] then
            local ok, err = pcall(function()
                MySQL.query.await(migration.query)
            end)
            if ok then
                MySQL.insert('INSERT INTO schema_migrations (version, name) VALUES (?, ?)',
                    {migration.version, migration.name})
                print(('[Migrations] Applied: %s'):format(migration.name))
            else
                print(('[Migrations] Failed: %s - %s'):format(migration.name, err))
            end
        end
    end
end)

Zawsze testuj migracje na deweloperskiej kopii bazy danych przed wdrożeniem ich na produkcji. W przypadku dużych tabel z milionami wierszy, ALTER TABLE operacje mogą zablokować tabelę na dłuższy czas. W takich przypadkach rozważ utworzenie nowej tabeli z pożądanym schematem, kopiowanie danych partiami, a następnie zamianę nazw tabel podczas okna konserwacyjnego.

Strategie tworzenia kopii zapasowych i odzyskiwanie danych

Żadna strategia trwałości danych nie jest kompletna bez solidnego planu tworzenia kopii zapasowych. Automatyczne kopie zapasowe MySQL powinny być wykonywane co najmniej raz dziennie, a pliki kopii zapasowych przechowywane na osobnym serwerze lub w chmurze. Użyj mysqldump z --single-transaction flaga dla tabel InnoDB, aby tworzyć spójne kopie zapasowe bez blokowania bazy danych. Poza pełnymi kopiami zapasowymi wdróż dziennik transakcji rejestrujący każdą istotną zmianę danych, co pozwoli odtworzyć stan gracza w dowolnym momencie. Jest to bezcenne, gdy gracz zgłasza utratę przedmiotu lub gdy zostanie wykryty exploit i musisz przywrócić dotknięte konta. Przechowuj kopie zapasowe przez co najmniej 30 dni z zasadą rotacji, która utrzymuje codzienne kopie z bieżącego tygodnia, cotygodniowe kopie z bieżącego miesiąca i comiesięczne kopie w późniejszym okresie. Regularnie testuj procedurę przywracania, uruchamiając serwer testowy z kopii zapasowej, aby sprawdzić, czy dane są kompletne, a serwer uruchamia się poprawnie, ponieważ kopia zapasowa, której nigdy nie przetestowałeś, to kopia, której nie możesz ufać.

Gotowy, aby zacząć?

Kup skrypty w naszym sklepie lub dołącz do Discorda, aby uzyskać wsparcie, aktualizacje i sprawdzić nadchodzące nowości.