Zurück zum Blog
Tutorial10 Min. Lesezeit

FiveM-Spielerdaten speichern: MySQL, KVP und State Bags

Finde die passende Methode, um FiveM-Spielerdaten zu speichern. Vergleich von MySQL, KVP, State Bags und Frameworks mit Blick auf Leistung und Zuverlässigkeit.

Agency Scripts

Gründer und leitender Entwickler bei Agency Scripts

Die richtige Speicherstrategie auswählen

Die dauerhafte Speicherung von Spielerdaten gehört zu den wichtigsten Aspekten der FiveM-Serverentwicklung. Alle Informationen zu einem Spieler, vom Bargeld und Job bis hin zum Aussehen des Charakters und den Fähigkeiten, müssen Serverneustarts und Verbindungsabbrüche überstehen. FiveM bietet verschiedene Speichermechanismen, die jeweils für unterschiedliche Anwendungsfälle geeignet sind. MySQL-Datenbanken, die über Bibliotheken wie oxmysql angesprochen werden, bieten relationale Speicherung für strukturierte Daten, die spielerübergreifend abgefragt werden müssen. Key-Value-Pair-Speicherung (KVP) ermöglicht eine schnelle lokale Speicherung von Servereinstellungen. State Bags synchronisieren Daten in Echtzeit zwischen Server und Client, ohne dass du Events manuell verwalten musst. Die besten Server kombinieren alle drei Ansätze: MySQL für dauerhafte Datensätze, KVP für die Konfigurationszwischenspeicherung und State Bags für Live-Sitzungsdaten, die andere Spieler sehen müssen.

Datenbankschema für Spielerdaten entwerfen

Ein gut entworfenes Datenbankschema unterteilt Daten in logisch getrennte Tabellen, statt alles in einer einzigen JSON-Spalte abzulegen. Es mag verlockend sein, sämtliche Spielerdaten als JSON-Blob zu speichern, aber dadurch werden Abfragen, Indizierung und Fehlerbehebung extrem schwierig. Verwende stattdessen separate Tabellen für unterschiedliche Datenbereiche. Die zentrale Spielertabelle enthält Identitäts- und Authentifizierungsfelder, während verknüpfte Tabellen Jobdaten, Bankkonten, Inventar und Charakter-Metadaten enthalten. Hier ist ein normalisiertes Schema für zentrale Spielerdaten:

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
);

Die ON DELETE CASCADE Der Fremdschlüssel stellt sicher, dass beim Löschen eines Charakters alle zugehörigen Datensätze in untergeordneten Tabellen automatisch bereinigt werden. So bleiben keine verwaisten Daten zurück. Der last_updated Zeitstempel mit ON UPDATE CURRENT_TIMESTAMP bietet eine integrierte Änderungshistorie, aus der hervorgeht, wann jeder Spielerdatensatz zuletzt geändert wurde. Das ist bei Meldungen über Datenverlust äußerst hilfreich.

Daten beim Verbinden eines Spielers effizient laden

Wenn sich ein Spieler mit dem Server verbindet, musst du alle seine Daten aus der Datenbank laden und das In-Memory-Spielerobjekt befüllen. Entscheidend ist, Datenbankabfragen zu bündeln, statt sie nacheinander auszuführen. Für einen Spieler werden möglicherweise Daten aus fünf oder mehr Tabellen benötigt. Fünf sequenzielle Abfragen sorgen für eine erhebliche Verzögerung auf dem Ladebildschirm. Verwende eine einzelne Abfrage mit mehreren Anweisungen oder führe die Abfragen parallel mithilfe von Promises aus. Hier ist ein optimiertes Lademuster:

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

Wenn du alle fünf Abfragen gleichzeitig startest, entspricht die Gesamtladezeit der Dauer der langsamsten Einzelabfrage, statt der Summe aller fünf. Bei einer gut indizierten Datenbank sinkt die Ladezeit für Spieler dadurch normalerweise von mehreren hundert Millisekunden auf unter 100ms. Berücksichtige immer den Fall, dass der Spielerdatensatz nicht existiert. Das passiert bei der ersten Verbindung oder nachdem ein Charakter zurückgesetzt wurde.

Daten mit gebündelten Schreibvorgängen speichern

Spielerdaten bei jeder Änderung zu speichern, ist ineffizient und erzeugt unnötige Datenbanklast. Verwende stattdessen ein Dirty-Flag-System, das Änderungen an einzelnen Datenbereichen markiert und diese regelmäßig in die Datenbank schreibt. Durch dieses Bündeln sinkt die Anzahl der Schreibvorgänge erheblich, während die Daten weiterhin oft genug gespeichert werden, um Verluste bei Abstürzen möglichst gering zu halten. Hier ist ein praktischer 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)

Jeder Datenbereich, etwa Geld, Job oder Position, wird unabhängig gespeichert. So löst eine Änderung des Bargeldbestands eines Spielers nicht das erneute Schreiben seiner gesamten Charakterdaten aus. Dieses gezielte Speichern verringert außerdem das Risiko von Race Conditions, bei denen zwei Scripts gleichzeitig verschiedene Teile des Spielerobjekts ändern und eines die Änderungen des anderen überschreibt.

State Bags für die Echtzeit-Synchronisierung verwenden

State Bags sind ein in FiveM integrierter Mechanismus, um Daten zwischen Server und Client zu synchronisieren, ohne eigene Events schreiben zu müssen. Sie funktionieren wie reaktive Eigenschaften: Wenn der Server einen Wert in einer State Bag festlegt, erhalten alle angemeldeten Clients automatisch die Aktualisierung. Damit eignen sie sich besonders für Daten, die andere Spieler in Echtzeit sehen müssen, etwa Jobtitel über den Köpfen, den Dienststatus oder benutzerdefinierte Spielertitel. State Bags können für Entities wie Spieler, Fahrzeuge und Objekte oder global festgelegt werden. So verwendest du State Bags für Spieler richtig:

-- 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)

Der zweite Parameter von state:set steuert die Replikation. Wenn du ihn auf true setzt, wird der Wert an alle Clients repliziert. Mit false wird er nur an den Client des jeweiligen Spielers repliziert. Verwende false für vertrauliche Daten wie Kontostände, die andere Spieler nicht sehen sollen. State Bags bleiben während der Spielersitzung bestehen, werden aber nicht in der Datenbank gespeichert. Sie ergänzen MySQL-Speicherung also, statt sie zu ersetzen.

KVP-Speicherung für Serverkonfigurationen

Key-Value-Pair-Speicherung bietet einen schnellen, dateibasierten Persistenzmechanismus, der in FiveM integriert ist. Anders als MySQL laufen KVP-Vorgänge synchron und erfordern keine Netzwerkaufrufe. Dadurch lassen sich kleine Datenmengen besonders schnell lesen und schreiben. KVP wird pro Resource gespeichert, das heißt, jede Resource hat ihren eigenen, isolierten Schlüsselbereich. Dadurch eignet sich KVP ideal für Resource-Konfigurationen, das Caching häufig verwendeter Referenzdaten oder das Speichern serverweiter Einstellungen, die nicht in eine relationale Datenbank gehören:

-- 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)

Die Caching-Ebene verhindert wiederholte Lesezugriffe auf Werte, auf die häufig zugegriffen wird. KVP eignet sich nicht für große Datensätze oder spielerspezifische Daten, die abgefragt werden müssen, da es keine Indizierung oder Suchfunktion gibt. Betrachte es als Konfigurationsspeicher, nicht als Datenbank. Ein gängiges Muster ist, die Ergebnisse teurer Datenbankabfragen mit einer TTL in KVP zwischenzuspeichern und vor der Rückgabe des zwischengespeicherten Werts den Zeitstempel zu prüfen.

Datenmigration und Schemaaktualisierungen

Wenn dein Server wächst, musst du Datenbankschemas aktualisieren können, ohne vorhandene Spielerdaten zu verlieren. Erstelle ein Migrationssystem, das nachverfolgt, welche Schemaänderungen bereits angewendet wurden, und ausstehende Migrationen beim Serverstart ausführt. Speichere den Migrationsverlauf in einer eigenen Tabelle, damit du genau sehen kannst, welche Änderungen wann übernommen wurden. Jede Migration sollte idempotent sein, sich also sicher mehrfach ausführen lassen, ohne Fehler zu verursachen:

-- 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)

Teste Migrationen immer zuerst mit einer Entwicklungskopie deiner Datenbank, bevor du sie auf die Produktionsdatenbank anwendest. Bei großen Tabellen mit Millionen von Zeilen können ALTER TABLE Vorgänge die Tabelle für längere Zeit sperren. In solchen Fällen kannst du eine neue Tabelle mit dem gewünschten Schema erstellen, die Daten in Batches kopieren und anschließend während eines Wartungsfensters die Tabellennamen tauschen.

Sicherungsstrategien und Datenwiederherstellung

Keine Persistenzstrategie ist ohne einen robusten Sicherungsplan vollständig. Automatisierte MySQL-Backups sollten mindestens täglich erstellt und auf einem separaten Server oder in einem Cloud-Speicher abgelegt werden. Verwende mysqldump mit dem Typ --single-transaction das Flag für InnoDB-Tabellen, um konsistente Backups ohne Sperrung der Datenbank zu erstellen. Erstelle zusätzlich zu vollständigen Backups ein Transaktionsprotokoll, das jede wichtige Datenänderung aufzeichnet, damit du den Zustand eines Spielers zu jedem beliebigen Zeitpunkt wiederherstellen kannst. Das ist besonders wertvoll, wenn ein Spieler den Verlust von Gegenständen meldet oder ein Exploit entdeckt wird und du betroffene Konten zurücksetzen musst. Bewahre Backups mindestens 30 Tage lang auf und verwende eine Rotationsrichtlinie mit täglichen Backups für die aktuelle Woche, wöchentlichen Backups für den aktuellen Monat und monatlichen Backups für den Zeitraum davor. Teste dein Wiederherstellungsverfahren regelmäßig, indem du aus einem Backup einen Testserver startest und überprüfst, ob die Daten vollständig sind und der Server korrekt startet. Denn einem Backup, das du nie getestet hast, kannst du nicht vertrauen.

Bereit loszulegen?

Hol dir die Scripts in unserem Shop, oder komm auf den Discord für Support, Updates und einen Blick auf das, was kommt.