dimanche 21 juin 2009

Développer son MMORPG - Persistance!

3ème opus de la série, où l'on parlera de la partie "persistance" dans "univers persistants". Que ce soit pour définir le monde ou pour récupérer l'état du serveur après une panne ou une séance de maintenance, pouvoir compter sur sa couche de persistance est indispensable.

Base de données relationnelle? Listes de clés-valeurs? XML? Gros fichiers binaires? Une chose est sûre, il faudra bien d'une manière ou d'une autre sauver ses précieuses données. AdH a fait très tôt le choix de la base de données relationnelle (Postgresql, pour être précis, mais cela aurait pu être Mysql, ou encore Oracle si on avait les sous).

Voici quelques avantages des bases de données relationnelles:

  • Les données sont accessibles directement à partir des outils fournis avec le système. Changer une position ou réparer un inventaire, c'est juste quelques lignes de SQL.

  • Les données sont en sécurité! Ces systèmes sont stables, utilisés dans d'innombrables projets à travers le monde. Les données sont centralisées, et les outils sont fournis pour sauvegarder et restaurer la base.

  • Il existe probablement déjà une bibliothèque pour accéder à la base depuis votre langage de programmation préféré.

  • Les données sont accessibles pour d'autres applications. Il est très aisé d'extraire des statistiques pour les afficher sur une page Web, ou encore de générer des rapports sur l'activité des joueurs.

Et quelques inconvénients:
    Ecrire le schéma prend du temps, et écrire la couche relationnelle dans le code en prend également.
  • Les changements de schéma peuvent être lourds: une colonne qui change dans une table, et il faut potentiellement changer ses index, ses procédures stockées, son SQL dans le code, et enfin le code lui-même.

  • Il faut installer une base pour faire tourner le serveur, en s'assurant que les binaires sont bien à la même version que le SQL.

Ces derniers temps, l'on a beaucoup parlé des bases de données de type clé-valeur, souvent utilisées dans le Web. Très dynamiques, elles permettent d'étendre le modèle de données très simplement, et sont d'une extensibilité sans pareille. Rajouter des paramètres à ses objets devient un jeu d'enfant. Par contre, être aussi dynamique a un coût: la base peut vite devenir bordélique, et les clés et les valeurs de plus en plus complexes et difficiles à interpréter. Sauver du XML en place des valeurs peut être une bonne manière de permettre de sauver simplement des données complexes, mais on se retrouve alors à définir un schéma et donc perdre tous les avantages du dynamisme.

Enfin, reste la solution du bon vieux fichier. Facile à lire, un peu plus difficile à modifier si l'on veut juste changer un petit quelque chose au milieu, il a l'avantage de ne nécessiter l'installation d'aucun logiciel supplémentaire. Par contre, la protection des données, la définition du schéma, leur accès en dehors de l'application, tout cela est entre vos mains. Ça fait un peu beaucoup, non?

Une fois que l'on est content de sa couche de persistance, encore faut-il décider de quand y accéder. D'un côté, il est possible de tout charger en mémoire au démarrage, et de tout sauver de temps en temps (mettons toutes les 10 minutes, dans un thread séparé pour un maximum de performances). C'est rapide. Mais cela veut dire qu'il faut mapper strictement toutes les données de la couche de persistance vers les objets du code. C'est du boulot! Enfin, il faut trouver le bon équilibre pour les sauvegardes: trop souvent, et les performances souffrent. Trop rarement, et un crash inopiné zappe une bonne partie de l'avancement des joueurs, à leur grande colère. Et enfin, arrive un moment où tout ne tient plus en mémoire... De l'autre côté, l'on peut accéder la couche de persistance uniquement quand on a besoin d'une donnée, et l'écrire à chaque fois qu'elle change. On ne perd rien en cas de crash, par contre, le système peut devenir très lent.

Il s'agit de trouver le bon équilibre entre les deux: quelles données peuvent être chargées à la volée? Peut-on les cacher en mémoire? Quelles donnés ont besoin d'être écrites immédiatement, quelles autres peuvent attendre? Comment s'assurer que tout est synchronisé? Typiquement, on fera la distinction entre les données importantes (une transaction entre deux joueurs, un avancement de quête) et celles qui le sont moins (monstre tué, ressources prises). Personne ne se plaindra si après un crash, une créature n'est pas exactement à la même position, ou si la mine contient encore les minerais que l'on a extrait (mais attention quand même, si un joueur découvre comment faire crasher le serveur, il pourrait se fabriquer une mine infinie?).

Enfin, quand il s'agit de coder tout ça, rappelez vous que moins on en écrit, mieux on se porte. Profitez au maximum des bibliothèques, écrivez du code générique pour binder vos données le plus efficacement possible, ou, encore mieux, utilisez des couches de persistance toutes faites (Hibernate?). Et sauvegardez. Tout. Tout le temps.

1 commentaire:

Kokuma a dit…

Il est interressant de noter que le problème de mine infinie donné en exemple peut être un peu étendu. Par expérience en tant que joueur (de T4C en l'occurrence), un soucis qui revenait souvent était ce qu'on appelle la duplication. Dans ton exemple, un joueur duplique le minerai de la mine (une moitié dans sont inventaire, l'autre, de retour dans la mine après le crash) en exploitant une latence de sauvegarde entre l'inventaire et la mine. On peut le faire aussi différement : un joueur A donne son minerai à un autre joueur B. B se déconnecte, son inventaire est donc sauvegardé dans la BdD, à ce moment, on provoque un crash serveur avant que l'inventaire de A soit sauvegardé (ou on exploite un cas particulier dans lequel l'inventaire de A n'est pas sauvegardé (déco sauvage mal gérée, etc.)), et on a donc une duplication du minerais, la moitié dans l'inventaire de A, l'autre dans celui de B. Cette exploitation est beaucoup plus dommageable pour le serveur comme nous allons le voir.

Ce genre d'exploitation de bug a causé de nombreux problèmes sur certains jeux, car il est assez compliqué à déceler si ce n'est par un comportement anormal du marché (en l'occurence, le cours du minerais qui chute si les dupliqueurs décident d'innonder le marché, ou qui monte s'ils sont juste 2 ou 3 à être capable de fournir un minerais rare), ou une progression anormalement rapide de certains joueurs dans certains domaines (une grand quantité de minerais permettrait par exemple d'augmenter rapidement une éventuelle compétence de forgeron, un autre cas étant la duplication d'objets de quête donnant une récompense en XP qui donne des joueurs gagnant anormalement vite de l'XP).

Ces comportements sont difficiles à détecter de façon automatique par analyse de la base de données (rien n'empêche la présence d'un chinois ou d'un coréen (oui, c'est une caricature :) ) qui passe sont temps à massacrer du monstre pour leveler le plus vite possible qui est une anomalie statistique et pourtant parfaitement justifiée). La détection est donc un procédé subjectif supporté a fortiori par analyse manuelle de logs (souvent effectué par des GM en jeu constatant un comportement anormal, puis confirmé par analyse des dits logs).

Tout ca pour dire : les joueurs trouveront *toujours* un moyen d'exploiter à leur avantage le comportement du jeu, créant ainsi un jeu du chat et la souris entre le vil exploiteur et le gentil programmeur :) Malheureusement, c'est toujours l'exploiteur qui gagne et trouve un moyen de déjouer les protections du programmeur, mais en contre partie, ce dernier peut apprendre beaucoup de choses en matière de sécurité et d'integrité des données et contextes.

NB : Tu aurais pu citer MS SQL Server dans la liste des données qui possède également une version Express gratuite !!! Limitée à 1 CPU, 1Go de RAM et une taille de base de 4Go, mais c'est un début ![/miscrosoft]