返回博客
Tutorial5 分钟阅读

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 提供内置审计跟踪,显示每个玩家记录最后修改时间,这对于调试数据丢失报告非常宝贵。

玩家连接时高效数据加载

当玩家连接服务器时,需要从数据库加载其所有数据并填充内存中的玩家对象。关键是批量查询数据库,而非逐条执行。玩家连接可能需五个或更多表的数据,顺序执行五次查询会显著增加加载延迟。使用单个多语句查询或通过 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)

每个领域,如金钱、职业或职位,独立保存,因此更改玩家现金余额不会触发整个角色数据重写。这种选择性保存也减少了两个脚本同时修改玩家对象不同部分时产生的竞争条件风险,避免一方覆盖另一方更改。

使用状态包实现实时同步

状态袋是 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不适合大型数据集或需查询的玩家特定数据,因为无索引或搜索功能。可视为配置存储而非数据库。一种常见模式是将昂贵数据库查询结果缓存到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 获取支持、更新以及新功能预告。