Une nouvelle approche de conception de moteur de serveur de jeu en ligne
Existe-t-il une architecture de serveur de jeu : à la fois distribuée, et aussi simple à écrire qu’un programme monothread ; avec des données poussées automatiquement, des API cachées dont on n’a pas à se soucier, pour se concentrer uniquement sur la logique métier ?
Origine
Les serveurs de jeu étaient principalement développés en C à leurs débuts, le cycle était lent et le débogage… intéressant. Plus tard, nous sommes passés à LuaJIT, nous étions assez radicaux, nous avons fait la transition dès 2009. Après tout, les premiers jeux en ligne accordaient beaucoup d’importance aux performances, ce n’est que plus tard, avec les progrès technologiques, que les performances sont devenues moins critiques.
En améliorant constamment les fonctionnalités et en refactorisant, nous avons réalisé que le code restait lourd à produire, ne suivant pas le rythme des besoins d’itération de plus en plus rapides. De nombreuses fonctionnalités ne pouvaient pas être expérimentées et testées immédiatement. Pour s’améliorer, il fallait repenser conceptuellement le serveur de jeu.
Le serveur comme base de données (Schéma comme API)
La structure traditionnelle était généralement : le serveur de jeu reçoit les commandes du client, la logique du serveur manipule la base de données, puis renvoie les données mises à jour au client. Ce processus introduit lui-même de la complexité, alors pourquoi ne pas simplement cacher la base de données, ou plutôt les fusionner en un seul élément — le serveur de jeu est la base de données.
Cette idée est en fait assez naturelle. Depuis longtemps, les jeux en ligne n’ont pas vraiment aimé les bases de données relationnelles : soit ils utilisaient directement un simple MemoryMapping pour des performances maximales, soit des bases NoSQL comme Redis. On dit souvent que “la meilleure base de données est toujours une base de données sur mesure”, alors pourquoi ne pas concevoir directement le serveur comme une interface de base de données spécialement conçue pour les clients de jeu. Bien sûr, le backend reste responsable de la persistance via une véritable base de données, mais les opérations sur la base de données sont complètement cachées, gérées automatiquement en interne.
Appelons désormais le serveur de jeu db, et la véritable base de données backend backend.
- Tout d’abord, le client de jeu peut directement effectuer des requêtes d’abonnement (
select ... where ...) surdb. Toute modification des données dans le périmètre de l’abonnement sera automatiquement poussée pardb, y compris les événementson_insert/on_update/on_delete, etc. - Les tables doivent avoir des permissions simples au niveau des lignes, permettant au client de ne consulter que les données qui le concernent. Mais certaines tables doivent pouvoir être définies avec des permissions
guest, par exemple l’état du nombre de joueurs en ligne sur le serveur, permettant même aux clients non connectés de les consulter. - Le client ne peut que s’abonner, il n’a pas de droits d’écriture ; l’écriture se fait via des fonctions
db, c’est-à-dire la logique traditionnelle du serveur de jeu. Puisqu’il s’agit d’undb, on pourrait aussi appeler cela des procédures stockées. Sauf que ces procédures stockées se concentrent sur des calculs en mémoire avec état, ce que les procédures stockées des bases de données n’ont généralement pas. - Les opérations de lecture/écriture dans le code logique de
dbsont encapsulées dans une transaction, puis automatiquement validées versbackend. En cas de conflit de transaction, une nouvelle tentative est effectuée automatiquement. Ainsi, lors de l’écriture du code, on n’a pas à se soucier des conditions de concurrence, et le déploiement est plus flexible.
graph TD;
subgraph "Serveur de jeu (DB)"
DB1["Abonnement"];
DB2["Logique"];
end
Client["Client de jeu"]<--Flux de données d'abonnement-->DB1;
Client--Call-->DB2;
subgraph "Backend"
DB1<--Lecture seule (abonnement)-->Réplique;
DB2--Lecture/écriture transactionnelle-->Master;
DB2--Lecture transactionnelle-->Réplique;
end
Sous cette forme, que ce soit côté client ou serveur, l’écriture sera très confortable.
Le client n’a qu’à s’abonner aux données, et grâce à la programmation réactive, il peut automatiquement gérer les objets dans l’UI ou la scène, tout en étant largement découplé de la logique serveur.
Le côté serveur peut se contenter d’écrire la véritable logique, sans plus avoir à se soucier de l’écriture, de la mise à jour et de la diffusion des données.
Compromis sur les caractéristiques
Nous devons également équilibrer le triangle impossible (variante) : performance, flexibilité et cohérence des données. Améliorer l’un entraîne généralement une baisse des autres.
Performance
On peut adopter la structure distribuée classique avec équilibrage de charge des Web App, dans ce cas la performance est principalement limitée par la base de données backend. Si on utilise un Redis performant, ses avantages et inconvénients sont les suivants :
- Il intègre un
MQ, pas besoin d’en ajouter une couche. - On peut faire de la montée en charge verticale pour améliorer les performances, mais cela implique des compromis sur la cohérence des données.
- La maintenance et la visualisation des données ne sont pas pratiques, mais aujourd’hui on peut compter sur l’
IApour aider à résoudre cela.
Flexibilité
La flexibilité inclut les aspects de panne et de maintenance :
- On peut réaliser une haute disponibilité approximative, en cas d’erreur serveur, le client se reconnecte de manière transparente.
- Ouverture d’un serveur en un clic, facilitant l’ajout ou la réduction dynamique de ressources.
- La panne d’un serveur unique ne doit pas affecter le cluster, ce qui nécessite une bonne cohérence des données pour éviter les opérations de restauration/réparation de données en cas de panne.
- L’ajout/réduction dynamique de ressources du
backenddoit également être pris en compte.
Cohérence des données
Les jeux ont des exigences élevées en matière de cohérence des données. Comment résoudre les conditions de concurrence sur les données distribuées ? Comment gérer les données écrites à moitié en cas de crash / panne du programme ? On ne peut pas déduire l’argent du joueur sans lui donner l’objet, c’est obligatoire.
Puisque le choix de performance est la distribution, dans cette architecture, la cohérence des données se résume aux transactions. Mais les transactions WATCH + MULTI réduisent considérablement les performances de Redis et diminuent aussi la flexibilité, par exemple, elles ne peuvent pas être utilisées sous Cluster ou avec un proxy Redis.
Le choix après compromis est la méthode “verrouillage optimiste par version + transaction Lua” : ce n’est que lorsque les numéros de version correspondent parfaitement et que tous les contrôles passent que le Lua exécute l’écriture par lot, garantissant que l’écriture de multiples données soit entièrement terminée, soit entièrement échouée. Cette méthode supporte les différents Fork et architectures de Redis. Bien qu’elle utilise Lua, les tests montrent qu’elle est plus rapide que WATCH + MULTI.
Pour la flexibilité, nous avons renoncé à verrouiller les index, ce qui signifie que nous ne pouvons pas empêcher les lectures fantômes (vérifier si un utilisateur existe, s’il n’existe pas, insérer un nouvel utilisateur). Mais généralement, ce type de problème est résolu en ajoutant un index Unique. Sacrifier la cohérence des requêtes sur index apporte des gains de flexibilité et de performance très importants.
---
title: Schéma d'architecture
---
graph TD;
Client1["Client"]-->DNS;
Client2@{ shape: processes, label: "Client..."}-->DNS;
DNS["DNS à équilibrage de charge"]-->Node_A;
DNS-->Node_B;
subgraph "Node1"
Node_A["Serveur de jeu"]-->Worker_A1["Worker"];
Node_A-->Worker_A2@{ shape: processes, label: "Worker..."};
end
subgraph "Nodes..."
Node_B["Serveur de jeu..."]-->Worker_B1["Worker"];
Node_B-->Worker_B2@{ shape: processes, label: "Worker..."};
end
Worker_A1-->Backend["Backend<br/>(Redis Cluster/Réplique)"];
Worker_A2-->Backend;
Worker_B1-->Backend;
Worker_B2-->Backend;
Langage et autres considérations de performance
En réalité, Lua comparé aux langages modernes n’est pas si pratique, même beaucoup de fonctionnalités de C++11 sont plus agréables que Lua. Ajoutez à cela le faible nombre de bibliothèques Lua et une maintenance faible, l’abandon de Lua pour les serveurs de jeu est devenu une nécessité.
Rust est un bon choix : sécurité, performance, modernité, tout y est. Mais je préfère un langage dynamique, pour des raisons de flexibilité, et de préférence facile à écrire pour les humains et l’IA. Oui, la réponse est évidente : Python.
En fait, tant qu’on supporte la distribution, le goulot d’étranglement est entièrement sur Redis, la performance du langage devient moins critique. Aujourd’hui, le CPU est moins cher que l’humain ; si la maintenance du serveur est également facile, on peut encore réduire les coûts de 50% avec des serveurs à prix spot.
Python a beaucoup de bibliothèques, par exemple, la lecture/écriture de tables peut être encapsulée avec des NumPy array, le traitement et le filtrage des tableaux seront très pratiques. Prenons un exemple d’index croisé :
array = money_table.range("last_update", left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()Effectuer un second filtrage local, sélectionner toutes les lignes où money < 999, puis traiter de manière vectorisée.
Ce style de diffusion/vectorisation à la Fortran convient très bien aux modèles de traitement de données dans les jeux. La vectorisation active automatiquement le SIMD, ce qui est plusieurs fois plus rapide qu’une simple boucle for en C. Un tel encapsulement résout également le problème mentionné précédemment de la maintenance peu pratique des données Redis ; après tout, le traitement et la visualisation des données sont les points forts de Python.
L’IA des PNJ serveur, auparavant, nous l’écrivions à la main avec des arbres de comportement, des machines à états, etc. Avec Python, les possibilités sont plus grandes, on peut tout à fait adopter de véritables modèles d’IA, par exemple l’apprentissage par renforcement Q-learning, etc. : en rétropropagant la récompense future pour évaluer la valeur des moments d’action passés, on peut obtenir brutalement la meilleure logique de comportement pour le PNJ. Bien sûr, pour les Boss exigeants en termes de gameplay, plus de réglage est nécessaire, car un comportement logique ne signifie pas forcément amusant.
Goulot d’étranglement de performance de Redis et conception des structures de données
La performance de Redis limite la capacité de traitement du serveur de jeu.
Il faut d’abord séparer la charge du client (abonnement) et du serveur (transactions) par la séparation lecture/écriture. Tous les abonnements des clients passent par des répliques Redis en lecture seule, sans affecter le master Redis qui traite les transactions ; tout le code d’écriture transactionnelle du serveur s’exécute sur le master. Ce schéma est suffisant pour la plupart des jeux.
Si on considère des jeux à plus grande échelle (comme les MMO d’antan avec un seul serveur), il faut penser à l’extensibilité du master. Le master peut être étendu via plusieurs Redis formant un Cluster, mais il y a une limitation clé : les transactions ne peuvent pas traverser les serveurs Redis. Par conséquent, le moteur doit calculer les corrélations entre les tables et placer les tables corrélées sur le même Redis. Les grandes tables des anciennes bases de données relationnelles étaient souvent corrélées et difficiles à découpler, ce qui nécessite l’introduction d’une structure ECS.
En bref, il s’agit de diviser les attributs en petites tables. Par exemple, faire de l’attribut money une table nommée money, appelée composant Component, contenant uniquement les champs money et owner, puis associer (attach) via owner à l’id du player concerné, un peu comme les composants script dans Unity. Ainsi, il n’y a plus de grandes tables, et les corrélations peuvent être plus dispersées. De plus, le player (qui n’a qu’un id, n’existe pas réellement) est l’Entity (entité) dans ECS, et le code logique est le System.
---
title: Exemple de cluster de composants
---
graph TD;
subgraph "Cluster 1"
System_A-->Component1;
System_B-->Component1;
end
subgraph "Cluster 2"
System_D-->Component3;
System_D-->Component2;
System_C-->Component2;
end
Conclusion
Voilà, vous n’aurez plus jamais à écrire de verrous pénibles, à vous soucier des conflits de données, vous pourrez vous concentrer efficacement comme en écrivant du monothread ; le C dans le MVC côté client ne sera plus nécessaire non plus.
Ces conceptions restent en fait assez légères, le code nécessaire est probablement peu important, mais demande beaucoup de réflexion. Je suis actuellement en train de le développer et l’ai déjà open-source :
Heerozh/hetu, il sera utilisé dans le prochain jeu SLG, les contributions sont les bienvenues.
7d577a3