Choisir la bonne stratégie de stockage
La persistance des données des joueurs est l'un des aspects les plus critiques du développement du serveur FiveM. Chaque élément d'information sur un joueur, depuis son solde de trésorerie et son affectation de travail jusqu'à l'apparence de son personnage et ses niveaux de compétence, doit survivre aux redémarrages du serveur et aux déconnexions des joueurs. FiveM propose plusieurs mécanismes de stockage, chacun adapté à différents cas d'utilisation. Les bases de données MySQL via des bibliothèques telles que oxmysql fournissent un stockage relationnel pour les données structurées qui doivent être interrogées entre les joueurs. Le stockage par paire clé-valeur (KVP) offre une persistance locale rapide pour les paramètres au niveau du serveur. Les sacs d'état permettent la synchronisation des données en temps réel entre le serveur et le client sans gestion manuelle des événements. Les meilleurs serveurs combinent les trois approches, en utilisant MySQL pour les enregistrements permanents, KVP pour la mise en cache de la configuration et des sacs d'état pour les données de session en direct que les autres joueurs doivent voir.
Conception de schéma de base de données pour les données des joueurs
Un schéma de base de données bien conçu sépare les problèmes en tables logiques plutôt que de tout stocker dans une seule colonne JSON. Bien qu'il soit tentant de stocker toutes les données du lecteur sous forme de blob JSON, cette approche rend l'interrogation, l'indexation et le débogage extrêmement difficiles. Utilisez plutôt des tables dédiées pour des domaines de données distincts. La table principale du joueur contient les champs d'identité et d'authentification, tandis que les tables associées contiennent les données de travail, les comptes bancaires, l'inventaire et les métadonnées des personnages. Voici un schéma normalisé pour les données principales des joueurs :
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
);
Le ON DELETE CASCADE La contrainte de clé étrangère garantit que lorsqu'un caractère est supprimé, tous les enregistrements associés dans les tables enfants sont automatiquement nettoyés, évitant ainsi les données orphelines. Le last_updated horodatage avec ON UPDATE CURRENT_TIMESTAMP fournit une piste d'audit intégrée indiquant la dernière modification de chaque enregistrement de joueur, ce qui est inestimable pour le débogage des rapports de perte de données.
Chargement efficace des données sur Player Connect
Lorsqu'un joueur se connecte au serveur, tu dois charger toutes ses données depuis la base de données et remplir l'objet joueur en mémoire. La clé est de regrouper les requêtes de base de données plutôt que de les exécuter une par une. Un joueur qui se connecte peut avoir besoin de données provenant de cinq tables ou plus, et effectuer cinq requêtes séquentielles ajoute une latence importante à l'écran de chargement. Utilisez une seule requête multi-instructions ou exécutez des requêtes en parallèle à l'aide de promesses. Voici un modèle de chargement optimisé :
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
En lançant les cinq requêtes simultanément, le temps de chargement total devient la durée de la requête la plus lente plutôt que la somme des cinq. Sur une base de données bien indexée, cela réduit généralement le temps de chargement du lecteur de plusieurs centaines de millisecondes à moins de 100 ms. Gérez toujours le cas où l'enregistrement du joueur n'existe pas, ce qui se produit lors de la première connexion ou après un effacement du personnage.
Sauvegarde de données avec des scripts par lots
Sauvegarder les données des joueurs à chaque changement est un gaspillage et crée une charge inutile dans la base de données. Au lieu de cela, implémentez un système de drapeau sale qui marque les domaines de données qui ont changé et les vide dans la base de données à intervalle régulier. Cette approche par lots réduit considérablement le nombre d'opérations d'script tout en garantissant que les données sont sauvegardées suffisamment fréquemment pour minimiser les pertes en cas de crash. Voici un gestionnaire de sauvegarde pratique :
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)
Chaque domaine, tel que l'argent, le travail ou le poste, est sauvegardé indépendamment afin que la modification du solde d'argent d'un joueur ne déclenche pas une réécriture de l'intégralité des données de son personnage. Cette sauvegarde sélective réduit également le risque de conditions de concurrence où deux scripts modifient simultanément différentes parties de l'objet joueur et l'un écrase les modifications de l'autre.
Utilisation de sacs d'état pour la synchronisation en temps réel
Les sacs d'état sont un mécanisme natif FiveM permettant de synchroniser les données entre le serveur et le client sans écrire d'événements personnalisés. Ils fonctionnent comme des propriétés réactives : lorsque le serveur définit une valeur de sac d'état, tous les clients abonnés reçoivent automatiquement la mise à jour. Cela les rend idéaux pour les données que les autres joueurs ont besoin de voir en temps réel, telles que les titres de poste affichés au-dessus des têtes, le statut de service ou les titres de joueurs personnalisés. Les sacs d'état peuvent être placés sur des entités (joueurs, véhicules, objets) ou globalement. Voici comment utiliser efficacement les sacs d’état des joueurs :
-- 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)
Le deuxième paramètre de state:set contrôle la réplication. Le régler sur true se réplique sur tous les clients, tandis que false se réplique uniquement sur le client propriétaire. Utiliser false pour les données sensibles comme les soldes bancaires que les autres joueurs ne devraient pas voir. Les sacs d'état persistent pendant toute la durée de la session du joueur mais ne sont pas enregistrés dans la base de données, ils complètent donc le stockage MySQL plutôt que de le remplacer.
Stockage KVP pour la configuration du serveur
Le stockage par paires clé-valeur fournit un mécanisme de persistance rapide basé sur les fichiers, intégré au FiveM. Contrairement à MySQL, les opérations KVP sont synchrones et ne nécessitent pas d'appels réseau, ce qui les rend extrêmement rapides pour lire et écrire de petits morceaux de données. KVP est stocké par ressource, ce qui signifie que chaque ressource possède son propre espace de noms de clé isolé. Cela rend KVP idéal pour stocker la configuration des ressources, mettre en cache les données de référence fréquemment consultées ou conserver les paramètres à l'échelle du serveur qui n'appartiennent pas à une base de données relationnelle :
-- 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 couche de mise en cache empêche les lectures répétées sur le disque pour les valeurs fréquemment consultées. KVP ne convient pas aux grands ensembles de données ou aux données spécifiques aux joueurs qui nécessitent une interrogation, car il n'y a pas de capacité d'indexation ou de recherche. Considérez-le comme un magasin de configuration plutôt que comme une base de données. Un modèle courant consiste à mettre en cache les résultats de requêtes de base de données coûteuses dans KVP avec une durée de vie, en vérifiant l'horodatage avant de renvoyer la valeur mise en cache.
Migration de données et mises à jour de schémas
Au fur et à mesure que ton serveur évolue, tu devres mettre à jour les schémas de base de données sans perdre les données existantes des joueurs. Créez un système de migration qui suit les modifications de schéma qui ont été appliquées et exécute les migrations en attente au démarrage du serveur. Stockez l'historique des migrations dans un tableau dédié afin que tu puissies voir exactement quelles modifications ont été appliquées et quand. Chaque migration doit être idempotente, ce qui signifie qu'elle peut être exécutée plusieurs fois en toute sécurité sans provoquer d'erreurs :
-- 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)
Testez toujours les migrations sur une copie de développement de ton base de données avant de les appliquer en production. Pour les grandes tables comportant des millions de lignes, ALTER TABLE les opérations peuvent verrouiller la table pendant des périodes prolongées. Dans ces cas, envisagez de créer une nouvelle table avec le schéma souhaité, de copier les données par lots, puis d'échanger les noms des tables pendant une fenêtre de maintenance.
Stratégies de sauvegarde et récupération de données
Aucune stratégie de persistance n’est complète sans un plan de sauvegarde robuste. Les sauvegardes MySQL automatisées doivent être exécutées au moins quotidiennement, les fichiers de sauvegarde étant stockés sur un serveur distinct ou un service de stockage cloud. Utiliser mysqldump avec le --single-transaction indicateur pour les tables InnoDB afin de créer des sauvegardes cohérentes sans verrouiller la base de données. Au-delà des sauvegardes complètes, implémentez un journal des transactions qui enregistre chaque changement de données important afin que tu puissies reconstruire l'état d'un joueur à tout moment. Ceci est inestimable lorsqu'un joueur signale une perte d'objet ou lorsqu'un exploit est découvert et que tu dois restaurer les comptes concernés. Conservez les sauvegardes pendant au moins 30 jours avec une politique de rotation qui maintient les sauvegardes quotidiennes pour la semaine en cours, les sauvegardes hebdomadaires pour le mois en cours et les sauvegardes mensuelles au-delà. Testez régulièrement ton procédure de restauration en lançant un serveur de test à partir d'une sauvegarde pour vérifier que les données sont complètes et que le serveur démarre correctement, car une sauvegarde que tu n'avez jamais testée est une sauvegarde à laquelle tu ne pouvez pas faire confiance.

