Escolhendo a estratégia de armazenamento correta
A persistência dos dados do jogador é um dos aspectos mais críticos do desenvolvimento do servidor FiveM. Cada informação sobre um jogador, desde o saldo de dinheiro e atribuição de trabalho até a aparência do personagem e níveis de habilidade, precisa sobreviver às reinicializações do servidor e às desconexões do jogador. FiveM oferece vários mecanismos de armazenamento, cada um adequado para diferentes casos de uso. Os bancos de dados MySQL por meio de bibliotecas como oxmysql fornecem armazenamento relacional para dados estruturados que precisam ser consultados entre os jogadores. O armazenamento de pares de valores-chave (KVP) oferece persistência local rápida para configurações em nível de servidor. Os sacos de estado permitem a sincronização de dados em tempo real entre o servidor e o cliente sem manipulação manual de eventos. Os melhores servidores combinam todas as três abordagens, usando MySQL para registros permanentes, KVP para cache de configuração e sacos de estado para dados de sessão ao vivo que outros jogadores precisam ver.
Design de esquema de banco de dados para dados do jogador
Um esquema de banco de dados bem projetado separa as preocupações em tabelas lógicas, em vez de despejar tudo em uma única coluna JSON. Embora seja tentador armazenar todos os dados do jogador como um blob JSON, essa abordagem torna a consulta, a indexação e a depuração extremamente difíceis. Em vez disso, use tabelas dedicadas para domínios de dados distintos. A tabela principal do jogador contém campos de identidade e autenticação, enquanto as tabelas relacionadas contêm dados de trabalho, contas bancárias, inventário e metadados de personagens. Aqui está um esquema normalizado para os dados principais do jogador:
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
);A restrição de chave estrangeira ON DELETE CASCADE garante que quando um caractere é excluído, todos os registros associados nas tabelas filhas sejam automaticamente limpos, evitando dados órfãos. O carimbo de data/hora last_updated com ON UPDATE CURRENT_TIMESTAMP fornece uma trilha de auditoria integrada que mostra quando o registro de cada jogador foi modificado pela última vez, o que é inestimável para depurar relatórios de perda de dados.
Carregamento eficiente de dados no Player Connect
Quando um player se conecta ao servidor, você precisa carregar todos os seus dados do banco de dados e preencher o objeto player na memória. A chave é consultar o banco de dados em lote, em vez de executá-las uma de cada vez. Um jogador conectado pode precisar de dados de cinco ou mais mesas, e fazer cinco consultas sequenciais adiciona latência significativa à tela de carregamento. Use uma única consulta com múltiplas instruções ou execute consultas em paralelo usando promessas. Aqui está um padrão de carregamento otimizado:
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 {},
}
endAo disparar todas as cinco consultas simultaneamente, o tempo total de carregamento torna-se a duração da consulta única mais lenta, em vez da soma de todas as cinco. Em um banco de dados bem indexado, isso normalmente reduz o tempo de carregamento do jogador de várias centenas de milissegundos para menos de 100 ms. Sempre lide com o caso em que o registro do jogador não existe, o que acontece na primeira conexão ou após a limpeza do personagem.
Salvando dados com gravações em lote
Salvar os dados do jogador em cada alteração é um desperdício e cria uma carga desnecessária no banco de dados. Em vez disso, implemente um sistema de sinalização suja que marque quais domínios de dados foram alterados e os libere no banco de dados em intervalos regulares. Essa abordagem em lote reduz drasticamente o número de operações de gravação e ainda garante que os dados sejam salvos com frequência suficiente para minimizar perdas em caso de falhas. Aqui está um gerenciador de salvamento prático:
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 domínio, como dinheiro, trabalho ou posição, é salvo de forma independente para que a alteração do saldo de dinheiro de um jogador não acione a reescrita de todos os dados do personagem. Esse salvamento seletivo também reduz o risco de condições de corrida em que dois scripts modificam partes diferentes do objeto do jogador simultaneamente e um substitui as alterações do outro.
Usando State Bags para sincronização em tempo real
State bags são um mecanismo nativo do FiveM para sincronizar dados entre o servidor e o cliente sem gravar eventos personalizados. Eles funcionam como propriedades reativas: quando o servidor define um valor de estado, todos os clientes inscritos recebem automaticamente a atualização. Isso os torna ideais para dados que outros jogadores precisam ver em tempo real, como cargos exibidos acima das cabeças, status de serviço ou títulos de jogadores personalizados. As bolsas de estado podem ser definidas em entidades (jogadores, veículos, objetos) ou globalmente. Aqui está como usar os sacos de estado do jogador de forma 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)O segundo parâmetro de state:set controla a replicação. Defini-lo como true replica para todos os clientes, enquanto false replica apenas para o cliente proprietário. Use false para dados confidenciais, como saldos bancários, que outros jogadores não deveriam ver. Os sacos de estado persistem durante a sessão do player, mas não são salvos no banco de dados, portanto, complementam o armazenamento do MySQL em vez de substituí-lo.
Armazenamento KVP para configuração de servidor
O armazenamento de pares de valores-chave fornece um mecanismo de persistência rápido baseado em arquivo integrado ao FiveM. Ao contrário do MySQL, as operações KVP são síncronas e não requerem chamadas de rede, tornando-as extremamente rápidas para leitura e gravação de pequenos dados. O KVP é armazenado por recurso, o que significa que cada recurso tem seu próprio namespace de chave isolado. Isso torna o KVP ideal para armazenar configurações de recursos, armazenar em cache dados de referência acessados com frequência ou persistir configurações em todo o servidor que não pertencem a um banco de dados 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)A camada de cache evita leituras repetidas de disco para valores acessados com frequência. O KVP não é adequado para grandes conjuntos de dados ou dados específicos do jogador que precisam ser consultados porque não há indexação ou capacidade de pesquisa. Pense nisso como um armazenamento de configuração em vez de um banco de dados. Um padrão comum é armazenar em cache os resultados de consultas caras ao banco de dados no KVP com um TTL, verificando o carimbo de data/hora antes de retornar o valor armazenado em cache.
Migração de dados e atualizações de esquema
À medida que o seu servidor evolui, você precisará atualizar os esquemas do banco de dados sem perder os dados existentes dos jogadores. Crie um sistema de migração que rastreie quais alterações de esquema foram aplicadas e execute migrações pendentes na inicialização do servidor. Armazene o histórico de migração em uma tabela dedicada para ver exatamente quais alterações foram aplicadas e quando. Cada migração deve ser idempotente, o que significa que pode ser executada várias vezes com segurança sem causar erros:
-- 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)Sempre teste as migrações em uma cópia de desenvolvimento do seu banco de dados antes de aplicá-las na produção. Para tabelas grandes com milhões de linhas, as operações ALTER TABLE podem bloquear a tabela por longos períodos. Nesses casos, considere criar uma nova tabela com o esquema desejado, copiar dados em lotes e depois trocar os nomes das tabelas durante uma janela de manutenção.
Estratégias de Backup e Recuperação de Dados
Nenhuma estratégia de persistência está completa sem um plano de backup robusto. Os backups automatizados do MySQL devem ser executados pelo menos diariamente, com os arquivos de backup armazenados em um servidor separado ou serviço de armazenamento em nuvem. Use mysqldump com o sinalizador --single-transaction para tabelas InnoDB para criar backups consistentes sem bloquear o banco de dados. Além dos backups completos, implemente um log de transações que registre todas as alterações significativas nos dados para que você possa reconstruir o estado de um jogador a qualquer momento. Isso é inestimável quando um jogador relata perda de item ou quando uma exploração é descoberta e você precisa reverter contas afetadas. Mantenha backups por pelo menos 30 dias com uma política de rotação que mantém backups diários para a semana atual, backups semanais para o mês atual e backups mensais além disso. Teste seu procedimento de restauração regularmente ativando um servidor de teste a partir de um backup para verificar se os dados estão completos e se o servidor inicia corretamente, porque um backup que você nunca testou é um backup no qual você não pode confiar.

