العودة إلى المدونة
Tutorial10 دقيقة قراءة

تخزين بيانات اللاعب في FiveM: MySQL، KVP وحقائب الحالة

اختر الطريقة الصحيحة لتخزين بيانات لاعبي FiveM. مقارنة بين MySQL، KVP، state bags وأنماط الأُطُر مع مراعاة الأداء والموثوقية.

Agency Scripts

المؤسس والمطور الرئيسي في Agency Scripts

اختيار استراتيجية التخزين المناسبة

ثبات بيانات اللاعب هو أحد الجوانب الأكثر أهمية في تطوير خوادم FiveM. يجب أن تبقى كل معلومة عن اللاعب، من رصيد النقود وتعيين الوظيفة إلى مظهر الشخصية ومستويات المهارات، محفوظة عبر إعادة تشغيل الخادم وانفصال اللاعب. يوفر FiveM عدة آليات تخزين، كل منها مناسب لحالات استخدام مختلفة. توفر قواعد بيانات MySQL عبر مكتبات مثل oxmysql تخزينًا علائقيًا للبيانات المهيكلة التي تحتاج إلى الاستعلام عبر اللاعبين. يوفر تخزين أزواج المفتاح والقيمة (KVP) ثباتًا محليًا سريعًا لإعدادات مستوى الخادم. تتيح حقائب الحالة مزامنة بيانات في الوقت الحقيقي بين الخادم والعميل دون الحاجة إلى معالجة أحداث يدوية. أفضل الخوادم تجمع بين هذه الطرق الثلاث، باستخدام MySQL للسجلات الدائمة، وKVP لتخزين التهيئة مؤقتًا، وحقائب الحالة لبيانات الجلسة الحية التي يحتاج اللاعبون الآخرون لرؤيتها.

تصميم مخطط قاعدة البيانات لبيانات اللاعبين

مخطط قاعدة بيانات مصمم جيدًا يفصل الاهتمامات إلى جداول منطقية بدلاً من تخزين كل شيء في عمود JSON واحد. رغم أن تخزين كل بيانات اللاعب ككتلة JSON مغرٍ، إلا أن هذا النهج يجعل الاستعلام، الفهرسة، وتصحيح الأخطاء صعبًا للغاية. بدلاً من ذلك، استخدم جداول مخصصة لمجالات بيانات مميزة. جدول اللاعب الأساسي يحتوي على حقول الهوية والمصادقة، بينما الجداول المرتبطة تحتوي على بيانات الوظيفة، الحسابات البنكية، الجرد، وبيانات التعريف الخاصة بالشخصية. إليك مخططًا منظمًا لبيانات اللاعب الأساسية:

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

ال ON DELETE CASCADE قيد المفتاح الأجنبي يضمن أنه عند حذف شخصية، يتم تنظيف جميع السجلات المرتبطة في الجداول الفرعية تلقائيًا، مما يمنع البيانات اليتيمة. ال last_updated طابع زمني مع ON UPDATE CURRENT_TIMESTAMP يوفر سجل تدقيق مدمج يظهر متى تم تعديل سجل كل لاعب آخر مرة، وهو أمر لا يقدر بثمن لتصحيح تقارير فقدان البيانات.

تحميل البيانات بكفاءة عند اتصال اللاعب

عندما يتصل اللاعب بالخادم، تحتاج إلى تحميل جميع بياناته من قاعدة البيانات وملء كائن اللاعب في الذاكرة. المفتاح هو تجميع استعلامات قاعدة البيانات بدلاً من تنفيذها واحدة تلو الأخرى. قد يحتاج اللاعب المتصل إلى بيانات من خمسة جداول أو أكثر، وتنفيذ خمسة استعلامات متتالية يضيف تأخيراً كبيراً لشاشة التحميل. استخدم استعلام متعدد العبارات واحد أو نفذ الاستعلامات بالتوازي باستخدام الوعود. إليك نمط تحميل محسن:

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

عن طريق إطلاق جميع الاستعلامات الخمسة في وقت واحد، يصبح وقت التحميل الكلي مدة أبطأ استعلام منفرد بدلاً من مجموع الخمسة. على قاعدة بيانات مفهرسة جيدًا، يقلل هذا عادةً وقت تحميل اللاعب من عدة مئات من المللي ثانية إلى أقل من 100 مللي ثانية. تعامل دائمًا مع الحالة التي لا يوجد فيها سجل للاعب، والتي تحدث عند الاتصال الأول أو بعد مسح شخصية.

حفظ البيانات بعمليات كتابة مجمعة

حفظ بيانات اللاعب عند كل تغيير هو مضيعة ويخلق حملاً غير ضروري على قاعدة البيانات. بدلاً من ذلك، نفذ نظام علامة متسخة يحدد المجالات التي تغيرت ويقوم بتفريغها إلى قاعدة البيانات على فترات منتظمة. هذا النهج المجمّع يقلل بشكل كبير من عدد عمليات الكتابة مع ضمان حفظ البيانات بشكل متكرر بما يكفي لتقليل الخسارة عند الأعطال. إليك مدير حفظ عملي:

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)

يتم حفظ كل مجال، مثل المال أو الوظيفة أو المنصب، بشكل مستقل بحيث لا يؤدي تغيير رصيد اللاعب النقدي إلى إعادة كتابة بيانات شخصيته بالكامل. يقلل هذا الحفظ الانتقائي أيضًا من خطر ظروف التنافس حيث يقوم نصان بتعديل أجزاء مختلفة من كائن اللاعب في نفس الوقت ويقوم أحدهما بالكتابة فوق تغييرات الآخر.

استخدام حقائب الحالة للمزامنة في الوقت الحقيقي

State bags هي آلية أصلية في FiveM لمزامنة البيانات بين الخادم والعميل دون كتابة أحداث مخصصة. تعمل كخصائص تفاعلية: عندما يحدد الخادم قيمة في State Bag، يتلقى جميع العملاء المشتركين التحديث تلقائياً. هذا يجعلها مثالية للبيانات التي يحتاج اللاعبون الآخرون لرؤيتها في الوقت الحقيقي، مثل عناوين الوظائف المعروضة فوق الرؤوس، حالة الخدمة، أو ألقاب اللاعبين المخصصة. يمكن تعيين State Bags على الكيانات (اللاعبين، المركبات، الكائنات) أو بشكل عام. إليك كيفية استخدام State Bags للاعبين بفعالية:

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

المعامل الثاني لـ state:set يتحكم في التكرار. تعيينه إلى true يتم تكراره لجميع العملاء، بينما false يتم التكرار فقط إلى العميل المالك. استخدم false لبيانات حساسة مثل أرصدة البنوك التي لا يجب أن يراها اللاعبون الآخرون. تستمر حقائب الحالة طوال مدة جلسة اللاعب لكنها لا تُحفظ في قاعدة البيانات، لذا فهي تكمل تخزين MySQL بدلاً من استبداله.

تخزين KVP لتكوين الخادم

توفير تخزين أزواج المفتاح والقيمة هو آلية حفظ سريعة قائمة على الملفات مدمجة في FiveM. على عكس MySQL، عمليات KVP متزامنة ولا تتطلب مكالمات شبكة، مما يجعلها سريعة جدًا لقراءة وكتابة قطع صغيرة من البيانات. يتم تخزين KVP لكل مورد، مما يعني أن لكل مورد مساحة أسماء مفاتيح معزولة خاصة به. هذا يجعل KVP مثاليًا لتخزين تكوين الموارد، وتخزين بيانات مرجعية يتم الوصول إليها بشكل متكرر، أو حفظ إعدادات على مستوى الخادم لا تنتمي إلى قاعدة بيانات علائقية:

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

طبقة التخزين المؤقت تمنع عمليات قراءة القرص المتكررة للقيم التي يتم الوصول إليها بشكل متكرر. KVP غير مناسب لمجموعات البيانات الكبيرة أو بيانات اللاعبين التي تحتاج إلى استعلام لأنها لا تحتوي على فهرسة أو قدرة بحث. فكر فيها كمخزن تكوين بدلاً من قاعدة بيانات. أحد الأنماط الشائعة هو تخزين نتائج استعلامات قاعدة البيانات المكلفة في KVP مع TTL، مع التحقق من الطابع الزمني قبل إرجاع القيمة المخزنة مؤقتًا.

ترحيل البيانات وتحديثات المخطط

مع تطور خادمك، ستحتاج إلى تحديث مخططات قاعدة البيانات دون فقدان بيانات اللاعبين الحالية. أنشئ نظام ترحيل يتتبع التغييرات التي تم تطبيقها ويشغل الترحيلات المعلقة عند بدء تشغيل الخادم. خزّن سجل الترحيل في جدول مخصص لتتمكن من رؤية التغييرات التي تم تطبيقها ومتى. يجب أن يكون كل ترحيل idempotent، مما يعني أنه يمكن تشغيله بأمان عدة مرات دون التسبب في أخطاء:

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

اختبر دائمًا عمليات الترحيل على نسخة تطوير من قاعدة بياناتك قبل تطبيقها على الإنتاج. بالنسبة للجداول الكبيرة التي تحتوي على ملايين الصفوف، ALTER TABLE يمكن للعمليات قفل الجدول لفترات طويلة. في تلك الحالات، فكر في إنشاء جدول جديد بالمخطط المطلوب، ونسخ البيانات على دفعات، ثم تبديل أسماء الجداول خلال نافذة صيانة.

استراتيجيات النسخ الاحتياطي واستعادة البيانات

لا تكتمل استراتيجية الاستمرارية بدون خطة نسخ احتياطي قوية. يجب أن تعمل النسخ الاحتياطية التلقائية لـ MySQL يوميًا على الأقل، مع تخزين ملفات النسخ الاحتياطي على خادم منفصل أو خدمة تخزين سحابية. استخدم mysqldump مع --single-transaction العلم لجداول InnoDB لإنشاء نسخ احتياطية متسقة بدون قفل قاعدة البيانات. بالإضافة إلى النسخ الاحتياطية الكاملة، نفذ سجل معاملات يسجل كل تغيير بيانات هام حتى تتمكن من إعادة بناء حالة اللاعب في أي نقطة زمنية. هذا لا يقدر بثمن عندما يبلغ اللاعب عن فقدان عنصر أو عند اكتشاف استغلال وتحتاج إلى التراجع عن الحسابات المتأثرة. احتفظ بالنسخ الاحتياطية لمدة لا تقل عن 30 يومًا مع سياسة تدوير تحافظ على النسخ اليومية للأسبوع الحالي، والنسخ الأسبوعية للشهر الحالي، والنسخ الشهرية بعد ذلك. اختبر إجراء الاستعادة بانتظام عن طريق تشغيل خادم اختبار من نسخة احتياطية للتحقق من اكتمال البيانات وتشغيل الخادم بشكل صحيح، لأن النسخة الاحتياطية التي لم تختبرها أبدًا هي نسخة لا يمكنك الوثوق بها.

جاهز للبدء؟

احصل على السكربتات من متجرنا، أو انضم إلى Discord للدعم والتحديثات ونظرة على ما هو قادم.