Volver al blog
Tutorial12 min de lectura

Almacenamiento de datos de jugadores en FiveM: MySQL, KVP y bolsas de estado

Elige cómo almacenar los datos de los jugadores en FiveM. Compara MySQL, KVP, bolsas de estado y patrones de framework según su rendimiento y fiabilidad.

Agency Scripts

Fundador y desarrollador principal en Agency Scripts

Elegir la estrategia de almacenamiento adecuada

La persistencia de los datos de los jugadores es uno de los aspectos más importantes del desarrollo de servidores FiveM. Toda la información de un jugador, desde su saldo de efectivo y asignación de trabajo hasta la apariencia de su personaje y sus niveles de habilidad, debe conservarse tras los reinicios del servidor y las desconexiones de los jugadores. FiveM ofrece varios mecanismos de almacenamiento, cada uno adecuado para distintos casos de uso. Las bases de datos MySQL, mediante bibliotecas como oxmysql, proporcionan almacenamiento relacional para datos estructurados que deben consultarse entre jugadores. El almacenamiento de pares clave-valor (KVP) ofrece persistencia local rápida para ajustes del servidor. Los state bags permiten sincronizar datos en tiempo real entre el servidor y el cliente sin gestionar eventos manualmente. Los mejores servidores combinan los tres enfoques: usan MySQL para registros permanentes, KVP para almacenar en caché la configuración y state bags para los datos de sesión en vivo que otros jugadores deben ver.

Diseño del esquema de base de datos para los datos de jugadores

Un esquema de base de datos bien diseñado separa las responsabilidades en tablas lógicas en lugar de volcarlo todo en una sola columna JSON. Aunque puede resultar tentador almacenar todos los datos de los jugadores como un bloque JSON, este enfoque dificulta mucho las consultas, la indexación y la depuración. En su lugar, usa tablas específicas para cada dominio de datos. La tabla principal de jugadores contiene los campos de identidad y autenticación, mientras que las tablas relacionadas contienen los datos de trabajo, las cuentas bancarias, el inventario y los metadatos del personaje. Este es un esquema normalizado para los datos principales de los jugadores:

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

El ON DELETE CASCADE La restricción de clave externa garantiza que, al eliminar un personaje, se limpien automáticamente todos los registros asociados de las tablas secundarias, lo que evita que queden datos huérfanos. El last_updated marca de tiempo con ON UPDATE CURRENT_TIMESTAMP proporciona un registro de auditoría integrado que indica cuándo se modificó por última vez cada registro de jugador, algo muy útil para depurar informes de pérdida de datos.

Carga eficiente de datos al conectar un jugador

Cuando un jugador se conecta al servidor, debes cargar todos sus datos de la base de datos y completar el objeto del jugador en memoria. La clave es agrupar las consultas a la base de datos en lugar de ejecutarlas una por una. Es posible que un jugador necesite datos de cinco tablas o más al conectarse, y hacer cinco consultas secuenciales añade una latencia considerable a la pantalla de carga. Usa una única consulta con varias instrucciones o ejecuta las consultas en paralelo mediante promises. Este es un patrón de carga optimizado:

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

Al ejecutar las cinco consultas simultáneamente, el tiempo total de carga pasa a ser el de la consulta individual más lenta, en lugar de la suma de las cinco. En una base de datos bien indexada, esto suele reducir el tiempo de carga de los jugadores de varios cientos de milisegundos a menos de 100ms. Gestiona siempre el caso en el que no exista el registro del jugador, algo que ocurre en la primera conexión o después de borrar un personaje.

Guardar datos con escrituras por lotes

Guardar los datos del jugador cada vez que se produce un cambio es un desperdicio y genera una carga innecesaria en la base de datos. En su lugar, implementa un sistema de indicadores de cambios que marque qué dominios de datos han cambiado y los guarde en la base de datos a intervalos regulares. Este enfoque por lotes reduce drásticamente el número de operaciones de escritura y, al mismo tiempo, garantiza que los datos se guarden con suficiente frecuencia para minimizar las pérdidas en caso de un cierre inesperado. Aquí tienes un administrador de guardado práctico:

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)

Cada dominio, como el dinero, el trabajo o la posición, se guarda de forma independiente, de modo que cambiar el saldo de efectivo de un jugador no provoca que se vuelvan a escribir todos los datos de su personaje. Este guardado selectivo también reduce el riesgo de condiciones de carrera, en las que dos scripts modifican distintas partes del objeto del jugador al mismo tiempo y uno sobrescribe los cambios del otro.

Usar state bags para la sincronización en tiempo real

Los state bags son un mecanismo nativo de FiveM para sincronizar datos entre el servidor y el cliente sin escribir eventos personalizados. Funcionan como propiedades reactivas: cuando el servidor establece un valor en un state bag, todos los clientes suscritos reciben automáticamente la actualización. Por eso son ideales para los datos que otros jugadores necesitan ver en tiempo real, como los cargos que aparecen sobre los personajes, el estado de servicio o los títulos personalizados de los jugadores. Los state bags se pueden establecer en entidades (jugadores, vehículos y objetos) o de forma global. Así puedes usar los state bags de los jugadores de manera eficaz:

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

El segundo parámetro de state:set controla la replicación. Si lo estableces en true se replica a todos los clientes, mientras que false solo se replica al cliente propietario. Usa false para datos sensibles, como los saldos bancarios, que otros jugadores no deberían ver. Las bolsas de estado persisten durante la sesión del jugador, pero no se guardan en la base de datos, por lo que complementan el almacenamiento de MySQL en lugar de sustituirlo.

Almacenamiento KVP para la configuración del servidor

El almacenamiento de pares clave-valor proporciona un mecanismo rápido de persistencia basado en archivos, integrado en FiveM. A diferencia de MySQL, las operaciones KVP son síncronas y no requieren llamadas de red, por lo que son extremadamente rápidas para leer y escribir pequeñas cantidades de datos. KVP se almacena por recurso, lo que significa que cada recurso tiene su propio espacio de nombres de claves aislado. Esto hace que KVP sea ideal para almacenar la configuración de los recursos, guardar en caché datos de referencia a los que se accede con frecuencia o persistir ajustes globales del servidor que no pertenecen a una base de datos relacional:

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

La capa de caché evita las lecturas repetidas del disco para los valores a los que se accede con frecuencia. KVP no es adecuado para conjuntos de datos grandes ni para datos específicos de jugadores que necesitan consultarse, ya que no dispone de funciones de indexación ni de búsqueda. Considéralo un almacén de configuración, no una base de datos. Un patrón habitual consiste en guardar en KVP los resultados de consultas costosas a la base de datos con un TTL y comprobar la marca de tiempo antes de devolver el valor almacenado en caché.

Migración de datos y actualizaciones de esquemas

A medida que tu servidor evoluciona, tendrás que actualizar los esquemas de la base de datos sin perder los datos existentes de los jugadores. Crea un sistema de migraciones que registre qué cambios de esquema se han aplicado y ejecute las migraciones pendientes al iniciar el servidor. Guarda el historial de migraciones en una tabla dedicada para que puedas ver exactamente qué cambios se aplicaron y cuándo. Cada migración debe ser idempotente, es decir, debe poder ejecutarse varias veces de forma segura y sin causar errores:

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

Prueba siempre las migraciones en una copia de desarrollo de tu base de datos antes de aplicarlas a producción. En tablas grandes con millones de filas, ALTER TABLE las operaciones pueden bloquear la tabla durante periodos prolongados. En esos casos, considera crear una tabla nueva con el esquema deseado, copiar los datos por lotes y, luego, intercambiar los nombres de las tablas durante una ventana de mantenimiento.

Estrategias de copia de seguridad y recuperación de datos

Ninguna estrategia de persistencia está completa sin un plan de copias de seguridad sólido. Las copias de seguridad automatizadas de MySQL deben ejecutarse al menos a diario y los archivos deben almacenarse en un servidor independiente o en un servicio de almacenamiento en la nube. Usa mysqldump con la marca --single-transaction para las tablas InnoDB para crear copias de seguridad coherentes sin bloquear la base de datos. Además de las copias de seguridad completas, implementa un registro de transacciones que guarde cada cambio importante en los datos para que puedas reconstruir el estado de un jugador en cualquier momento. Esto es muy útil cuando un jugador informa de la pérdida de objetos o cuando se descubre un exploit y necesitas revertir los cambios en las cuentas afectadas. Conserva las copias de seguridad durante al menos 30 días con una política de rotación que mantenga copias diarias de la semana actual, semanales del mes actual y mensuales de los meses anteriores. Prueba periódicamente el proceso de restauración iniciando un servidor de pruebas a partir de una copia de seguridad para comprobar que los datos estén completos y que el servidor se inicie correctamente, porque una copia de seguridad que nunca has probado es una copia en la que no puedes confiar.

¿Listo para empezar?

Consigue los scripts en nuestra tienda, o únete al Discord para soporte, novedades y un adelanto de lo que viene.