Назад к блогу
Tutorial9 мин чтения

FiveM Player Data Storage: MySQL, KVP и State Bags

Выберите правильный способ хранения данных игроков FiveM. Сравнение MySQL, KVP, state bags и паттернов фреймворков с учётом производительности и надёжности.

Agency Scripts

Основатель и ведущий разработчик Agency Scripts

Выбор правильной стратегии хранения

Сохранение данных игрока , один из самых важных аспектов разработки сервера FiveM. Каждая информация об игроке, от баланса наличных и профессии до внешности персонажа и уровней навыков, должна сохраняться при рестартах сервера и отключениях игроков. FiveM предлагает несколько механизмов хранения, каждый подходит для разных задач. MySQL базы через библиотеки, такие как oxmysql, обеспечивают реляционное хранение структурированных данных с возможностью запросов по игрокам. Хранение в формате Key-Value Pair (KVP) обеспечивает быстрое локальное сохранение настроек сервера. State bags позволяют синхронизировать данные в реальном времени между сервером и клиентом без ручной обработки событий. Лучшие серверы комбинируют все три подхода: MySQL для постоянных записей, KVP для кеширования конфигураций и state bags для живых данных сессии, которые должны видеть другие игроки.

Проектирование схемы базы данных для данных игроков

Хорошо продуманная схема базы данных разделяет данные по логическим таблицам, а не хранит всё в одном 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 ограничение внешнего ключа гарантирует, что при удалении персонажа все связанные записи в дочерних таблицах автоматически очищаются, предотвращая появление сиротских данных. The last_updated отметка времени с ON UPDATE CURRENT_TIMESTAMP обеспечивает встроенный аудит с отображением времени последнего изменения каждой записи игрока, что незаменимо для отладки сообщений о потере данных.

Эффективная загрузка данных при подключении игрока

Когда игрок подключается к серверу, нужно загрузить все его данные из базы и заполнить объект игрока в памяти. Главное , выполнять запросы к базе пакетно, а не по одному. Игроку может понадобиться информация из пяти и более таблиц, и выполнение пяти последовательных запросов значительно увеличивает задержку на экране загрузки. Используйте один многооператорный запрос или выполняйте запросы параллельно с помощью promises. Вот оптимизированный шаблон загрузки:

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 для синхронизации в реальном времени

State bags , это нативный механизм FiveM для синхронизации данных между сервером и клиентом без написания пользовательских событий. Они работают как реактивные свойства: когда сервер устанавливает значение в state bag, все подписанные клиенты автоматически получают обновление. Это делает их идеальными для данных, которые другие игроки должны видеть в реальном времени, таких как должности над головами, статус на дежурстве или пользовательские титулы игроков. State bags могут быть установлены на сущностях (игроках, транспортных средствах, объектах) или глобально. Вот как эффективно использовать player 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 для чувствительных данных, таких как банковские балансы, которые другие игроки не должны видеть. State bags сохраняются на время сессии игрока, но не сохраняются в базе данных, поэтому они дополняют хранение в 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, проверяя временную метку перед возвратом кэшированного значения.

Миграция данных и обновления схемы

По мере развития вашего сервера вам потребуется обновлять схемы базы данных без потери существующих данных игроков. Создайте систему миграций, которая отслеживает, какие изменения схемы были применены, и выполняет ожидающие миграции при запуске сервера. Храните историю миграций в отдельной таблице, чтобы точно видеть, какие изменения и когда были применены. Каждая миграция должна быть идемпотентной, то есть её можно безопасно запускать несколько раз без ошибок:

-- 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 за поддержкой, обновлениями и анонсами.