ब्लॉग पर वापस
Tutorial11 मिनट पढ़ें

FiveM खिलाड़ी डेटा संग्रहण: MySQL, KVP और स्टेट बैग्स

FiveM खिलाड़ी डेटा संग्रहीत करने का सही तरीका चुनें। MySQL, KVP, state bags और फ्रेमवर्क पैटर्न प्रदर्शन और विश्वसनीयता को ध्यान में रखते हुए तुलना करें।

Agency Scripts

Agency Scripts के संस्थापक और प्रमुख डेवलपर

सही स्टोरेज रणनीति चुनना

प्लेयर डेटा स्थिरता FiveM सर्वर विकास के सबसे महत्वपूर्ण पहलुओं में से एक है। एक खिलाड़ी के बारे में हर जानकारी, उनके नकद बैलेंस और नौकरी असाइनमेंट से लेकर उनके चरित्र की उपस्थिति और कौशल स्तर तक, सर्वर पुनः आरंभ और खिलाड़ी डिसकनेक्शन के बाद भी बनी रहनी चाहिए। FiveM कई स्टोरेज मैकेनिज्म प्रदान करता है, जो विभिन्न उपयोग मामलों के लिए उपयुक्त हैं। oxmysql जैसी लाइब्रेरीज़ के माध्यम से MySQL डेटाबेस संरचित डेटा के लिए रिलेशनल स्टोरेज प्रदान करते हैं जिसे खिलाड़ियों के बीच क्वेरी किया जाना आवश्यक है। की-वैल्यू पेयर (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

सभी पाँच क्वेरी एक साथ फायर करने पर, कुल लोड समय सबसे धीमी एकल क्वेरी की अवधि बन जाता है बजाय पाँचों के योग के। एक अच्छी तरह से इंडेक्स्ड डेटाबेस पर, यह आमतौर पर खिलाड़ी लोडिंग समय को कई सौ मिलीसेकंड से घटाकर 100ms से कम कर देता है। हमेशा उस स्थिति को संभालें जहाँ खिलाड़ी रिकॉर्ड मौजूद न हो, जो पहली कनेक्शन या चरित्र वाइप के बाद होता है।

बैच्ड राइट्स के साथ डेटा सहेजना

हर बदलाव पर खिलाड़ी डेटा सहेजना व्यर्थ है और अनावश्यक डेटाबेस लोड बनाता है। इसके बजाय, एक डर्टी-फ्लैग सिस्टम लागू करें जो यह चिह्नित करता है कि कौन से डेटा डोमेन बदले हैं और उन्हें नियमित अंतराल पर डेटाबेस में फ्लश करता है। यह बैचिंग दृष्टिकोण लिखने के ऑपरेशनों की संख्या को नाटकीय रूप से कम करता है जबकि यह सुनिश्चित करता है कि डेटा इतनी बार सहेजा जाए कि क्रैश पर नुकसान न्यूनतम हो। यहाँ एक व्यावहारिक सेव मैनेजर है:

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)

प्रत्येक डोमेन, जैसे पैसा, नौकरी, या पद, स्वतंत्र रूप से सहेजा जाता है ताकि खिलाड़ी के नकद बैलेंस में बदलाव उनके पूरे कैरेक्टर डेटा को पुनःलिखित न करे। यह चयनात्मक सहेजना रेस कंडीशंस के जोखिम को भी कम करता है जहां दो स्क्रिप्ट्स एक ही समय में खिलाड़ी ऑब्जेक्ट के विभिन्न हिस्सों को संशोधित करते हैं और एक दूसरे के परिवर्तनों को अधिलेखित कर देता है।

रीयल-टाइम सिंक के लिए स्टेट बैग्स का उपयोग

स्टेट बैग FiveM-नेटिव मैकेनिज्म हैं जो सर्वर और क्लाइंट के बीच डेटा सिंक्रनाइज़ेशन के लिए कस्टम इवेंट लिखे बिना काम करते हैं। वे प्रतिक्रियाशील गुणों की तरह काम करते हैं: जब सर्वर स्टेट बैग मान सेट करता है, तो सभी सब्सक्राइब किए गए क्लाइंट्स स्वचालित रूप से अपडेट प्राप्त करते हैं। यह उन्हें उन डेटा के लिए आदर्श बनाता है जिन्हें अन्य खिलाड़ियों को वास्तविक समय में देखना होता है, जैसे कि सिर के ऊपर दिखाए गए नौकरी शीर्षक, ड्यूटी स्थिति, या कस्टम खिलाड़ी शीर्षक। स्टेट बैग्स को एंटिटीज़ (खिलाड़ी, वाहन, वस्तुएं) या ग्लोबली सेट किया जा सकता है। यहां बताया गया है कि खिलाड़ी स्टेट बैग्स का प्रभावी ढंग से उपयोग कैसे करें:

-- 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 बड़े डेटासेट या खिलाड़ी-विशिष्ट डेटा के लिए उपयुक्त नहीं है जिसे क्वेरी करने की आवश्यकता होती है क्योंकि इसमें इंडेक्सिंग या खोज क्षमता नहीं है। इसे डेटाबेस के बजाय एक कॉन्फ़िगरेशन स्टोर के रूप में सोचें। एक सामान्य पैटर्न महंगे डेटाबेस क्वेरी के परिणामों को TTL के साथ KVP में कैश करना है, कैश्ड मान लौटाने से पहले टाइमस्टैम्प की जांच करना।

डेटा माइग्रेशन और स्कीमा अपडेट्स

जैसे-जैसे आपका सर्वर विकसित होता है, आपको मौजूदा खिलाड़ी डेटा खोए बिना डेटाबेस स्कीमा अपडेट करने की आवश्यकता होगी। एक माइग्रेशन सिस्टम बनाएं जो ट्रैक करे कि कौन से स्कीमा परिवर्तन लागू किए गए हैं और सर्वर स्टार्टअप पर लंबित माइग्रेशन चलाए। माइग्रेशन इतिहास को एक समर्पित तालिका में संग्रहित करें ताकि आप ठीक देख सकें कि कौन से परिवर्तन कब लागू हुए। प्रत्येक माइग्रेशन को 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 से जुड़ें।