Bloga Geri Dön
Tutorial9 dk. okuma

FiveM Oyuncu Verisi Depolama: MySQL, KVP ve State Bags

FiveM oyuncu verilerini depolamanın doğru yolunu seçin. Performans ve güvenilirlik göz önünde bulundurularak karşılaştırılan MySQL, KVP, state bag'ler ve framework kalıpları.

Agency Scripts

Agency Scripts Kurucusu ve Baş Geliştiricisi

Doğru Depolama Stratejisini Seçmek

Oyuncu verilerinin kalıcılığı, FiveM sunucu geliştirmenin en kritik yönlerinden biridir. Nakit bakiyesi ve meslek atamasından karakter görünümüne ve yetenek seviyelerine kadar bir oyuncu hakkındaki her bilgi parçasının, sunucu yeniden başlatmalarında ve oyuncu bağlantı kesilmelerinde korunması gerekir. FiveM, her biri farklı kullanım alanlarına uygun çeşitli depolama mekanizmaları sunar. oxmysql gibi kütüphaneler aracılığıyla kullanılan MySQL veritabanları, oyuncular arasında sorgulanması gereken yapılandırılmış veriler için ilişkisel depolama sağlar. Key-Value Pair (KVP) depolaması, sunucu düzeyindeki ayarlar için hızlı yerel kalıcılık sunar. State bag'ler, manuel event yönetimine gerek kalmadan sunucu ile istemci arasında gerçek zamanlı veri senkronizasyonu sağlar. En iyi sunucular her üç yaklaşımı da birleştirir: kalıcı kayıtlar için MySQL, yapılandırma önbelleğe alma için KVP ve diğer oyuncuların görmesi gereken canlı oturum verileri için state bag'leri kullanır.

Oyuncu Verileri İçin Veri Tabanı Şeması Tasarımı

İyi tasarlanmış bir veritabanı şeması, her şeyi tek bir JSON sütununa yığmak yerine sorumlulukları mantıksal tablolara ayırır. Tüm oyuncu verilerini bir JSON bloğu olarak depolamak cazip gelse de, bu yaklaşım sorgulamayı, dizinlemeyi ve hata ayıklamayı son derece zorlaştırır. Bunun yerine, farklı veri alanları için özel tablolar kullanın. Temel oyuncu tablosu kimlik ve kimlik doğrulama alanlarını tutarken, ilişkili tablolar meslek verilerini, banka hesaplarını, envanteri ve karakter meta verilerini barındırır. İşte temel oyuncu verileri için normalleştirilmiş bir şema:

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

The ON DELETE CASCADE foreign key kısıtlaması, bir karakter silindiğinde alt tablolardaki ilişkili tüm kayıtların otomatik olarak temizlenmesini sağlayarak sahipsiz verileri önler. last_updated şununla zaman damgası: ON UPDATE CURRENT_TIMESTAMP her oyuncu kaydının en son ne zaman değiştirildiğini gösteren yerleşik bir denetim geçmişi sunar, bu da veri kaybı raporlarını ayıklamak için son derece değerlidir.

Oyuncu Bağlantısında Verimli Veri Yükleme

Bir oyuncu sunucuya bağlandığında, tüm verilerini veritabanından yüklemeniz ve bellekteki oyuncu nesnesine yerleştirmeniz gerekir. Buradaki kilit nokta, veritabanı sorgularını tek tek yürütmek yerine toplu olarak çalıştırmaktır. Bağlanan bir oyuncu beş veya daha fazla tablodan veriye ihtiyaç duyabilir ve art arda beş sorgu yapmak yükleme ekranına ciddi bir gecikme ekler. Tek bir çoklu ifade sorgusu kullanın veya promise'ler kullanarak sorguları paralel olarak yürütün. İşte optimize edilmiş bir yükleme modeli:

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

Beş sorgunun tümü aynı anda çalıştırıldığında toplam yükleme süresi, beşinin toplamı yerine en yavaş olan tek sorgunun süresi kadar olur. İyi indekslenmiş bir veritabanında bu, oyuncu yükleme süresini genellikle birkaç yüz milisaniyeden 100 ms altına düşürür. İlk bağlantıda veya karakter silme (wipe) işleminden sonra meydana gelen oyuncu kaydının bulunmaması durumunu mutlaka ele alın.

Toplu Yazma İşlemleriyle Veri Kaydetme

Her değişiklikte oyuncu verilerini kaydetmek israftır ve gereksiz veritabanı yükü oluşturur. Bunun yerine, hangi veri alanlarının değiştiğini işaretleyen ve bunları düzenli aralıklarla veritabanına aktaran bir dirty-flag sistemi uygulayın. Bu toplu işleme yaklaşımı, yazma işlemlerinin sayısını önemli ölçüde azaltırken verilerin çökmelerdeki kaybı en aza indirecek sıklıkta kaydedilmesini de sağlar. İşte pratik bir kaydetme yöneticisi:

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)

Para, meslek veya konum gibi her alan bağımsız olarak kaydedilir; böylece bir oyuncunun nakit bakiyesini değiştirmek tüm karakter verilerinin yeniden yazılmasını tetiklemez. Bu seçici kaydetme yöntemi, iki scriptin aynı anda oyuncu nesnesinin farklı bölümlerini değiştirdiği ve birinin diğerinin değişikliklerinin üzerine yazdığı race condition riskini de azaltır.

Gerçek Zamanlı Senkronizasyon İçin State Bag Kullanımı

State bag'ler, özel event'ler yazmadan sunucu ile client arasında veri senkronizasyonu yapmak için FiveM'e özgü yerel bir mekanizmadır. Reaktif özellikler gibi çalışırlar: sunucu bir state bag değeri ayarladığında, abone olan tüm client'lar güncellemeyi otomatik olarak alır. Bu, baş üstünde görüntülenen meslek unvanları, mesai durumu veya özel oyuncu unvanları gibi diğer oyuncuların gerçek zamanlı olarak görmesi gereken veriler için onları ideal kılar. State bag'ler entity'ler (oyuncular, araçlar, nesneler) üzerinde veya global olarak ayarlanabilir. Oyuncu state bag'lerini etkili bir şekilde kullanmanın yolu şöyledir:

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

Şunun ikinci parametresi: state:set replikasyonu denetler. Bunu şu şekilde ayarlamak: true tüm istemcilere senkronize edilirken, false yalnızca sahibi olan client'a senkronize edilir. Şunu kullanın: false diğer oyuncuların görmemesi gereken banka bakiyeleri gibi hassas veriler için. State bags, oyuncu oturumu boyunca korunur ancak veritabanına kaydedilmez; bu nedenle MySQL depolamasının yerini almak yerine onu tamamlar.

Sunucu Yapılandırması için KVP Depolama

Key-Value Pair depolaması, FiveM'e entegre hızlı ve dosya tabanlı bir kalıcılık mekanizması sunar. MySQL'in aksine KVP işlemleri senkrondur ve ağ çağrısı gerektirmez, bu da küçük veri parçalarını okuma ve yazma konusunda onları son derece hızlı kılar. KVP her resource için ayrı depolanır, yani her resource kendi izole key namespace'ine sahiptir. Bu sayede KVP, resource konfigürasyonunu saklamak, sık erişilen referans verileri önbelleğe almak veya ilişkisel bir veritabanına ait olmayan sunucu genelindeki ayarları kalıcı hale getirmek için idealdir:

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

Önbellekleme katmanı, sık erişilen değerler için yinelenen disk okumalarını önler. İndeksleme veya arama yeteneği bulunmadığından KVP, sorgulanması gereken büyük veri kümeleri veya oyuncuya özel veriler için uygun değildir. Bunu bir veritabanından ziyade bir yapılandırma deposu olarak düşünün. Yaygın yöntemlerden biri, maliyetli veritabanı sorgularının sonuçlarını bir TTL ile KVP'de önbelleğe almak ve önbelleğe alınan değeri döndürmeden önce zaman damgasını kontrol etmektir.

Veri Taşıma ve Şema Güncellemeleri

Sunucunuz geliştikçe, mevcut oyuncu verilerini kaybetmeden veritabanı şemalarını güncellemeniz gerekecektir. Hangi şema değişikliklerinin uygulandığını takip eden ve bekleyen migrasyonları sunucu başlangıcında çalıştıran bir migrasyon sistemi oluşturun. Hangi değişikliklerin ne zaman uygulandığını tam olarak görebilmek için migrasyon geçmişini özel bir tabloda saklayın. Her migrasyon idempotent olmalıdır, yani hataya yol açmadan birden çok kez güvenle çalıştırılabilmelidir:

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

Taşıma işlemlerini canlı ortama uygulamadan önce her zaman veritabanınızın bir geliştirme kopyasında test edin. Milyonlarca satır içeren büyük tablolar için, ALTER TABLE işlemler tabloyu uzun süreler boyunca kilitleyebilir. Bu gibi durumlarda, istenen şemaya sahip yeni bir tablo oluşturmayı, verileri toplu halde kopyalamayı ve ardından bir bakım penceresi sırasında tablo adlarını değiştirmeyi düşünebilirsiniz.

Yedekleme Stratejileri ve Veri Kurtarma

Güçlü bir yedekleme planı olmadan hiçbir kalıcılık stratejisi eksiksiz sayılmaz. Otomatik MySQL yedeklemeleri en az günlük olarak çalışmalı ve yedekleme dosyaları ayrı bir sunucuda veya bulut depolama hizmetinde saklanmalıdır. Şunu kullanın: mysqldump ile --single-transaction bayrağı, veritabanını kilitlemeden tutarlı yedeklemeler oluşturur. Tam yedeklemelerin ötesinde, her önemli veri değişikliğini kaydeden bir transaction log uygulayın; böylece bir oyuncunun durumunu herhangi bir andaki haline yeniden oluşturabilirsiniz. Bu, bir oyuncu eşya kaybı bildirdiğinde veya bir exploit keşfedilip etkilenen hesapları geri almanız gerektiğinde son derece değerlidir. Geçerli hafta için günlük yedeklemeleri, geçerli ay için haftalık yedeklemeleri ve bunun ötesinde aylık yedeklemeleri koruyan bir rotasyon politikasıyla yedekleri en az 30 gün saklayın. Verilerin eksiksiz olduğunu ve sunucunun doğru şekilde başladığını doğrulamak için bir yedekten test sunucusu başlatarak geri yükleme prosedürünüzü düzenli olarak test edin, çünkü hiç test etmediğiniz bir yedek, güvenemeyeceğiniz bir yedektir.

Başlamaya Hazır mısınız?

Scriptleri mağazamızdan edinin veya destek, güncellemeler ve gelecek yeniliklere göz atmak için Discord'a katılın.