Chaque combat joué sur Leek Wars est enregistré. Les actions tour par tour, le déroulé complet, tout est stocké pour que vous puissiez rejouer n'importe quel affrontement, même très ancien. Sur douze ans, ça représente 47,7 millions de combats et une grosse partie de la base de données.
Le format historique était du JSON stocké en texte, à peine compressé par Postgres. Ça marchait, mais c'était lourd. Au point que, faute de place, les années 2014 à 2022 avaient dû être sorties de la base il y a environ un an. Elles dormaient sous forme de dumps inertes sur le disque : 83 Go qui ne servaient à rien et que personne ne pouvait consulter. Seules quatre années restaient interrogeables.
Il était temps de s'en occuper. Avec deux objectifs : récupérer de la place, et surtout remettre ces archives en ligne.
J'ai testé pas mal de pistes, y compris un format binaire sur mesure. Contre toute attente, c'est une approche assez classique qui a gagné : zstd niveau 19 avec un dictionnaire entraîné.
Le point clé, c'est le dictionnaire. Les combats se ressemblent énormément entre eux : mêmes codes d'action, mêmes structures qui reviennent d'un combat à l'autre. Un dictionnaire de 256 Ko entraîné sur un échantillon réel capture cette redondance partagée, ce qu'une compression valeur par valeur ne peut pas faire. J'en ai entraîné un pour les actions, un autre pour le rapport de combat.
Ça change aussi la nature de ce qu'on stocke. Jusqu'ici, les actions et le rapport vivaient dans des colonnes texte : du JSON lisible, que Postgres compressait dans son coin avec son algorithme maison (pglz, honnête mais daté, et surtout sans dictionnaire possible). Maintenant, ce sont des colonnes binaires qui contiennent directement la sortie zstd, et on dit à Postgres de ne surtout pas recompresser par-dessus : les octets sont stockés tels quels, c'est déjà fait, et mieux. La décompression se fait à la volée quand un combat est demandé. Rien ne change côté joueur : le combat s'affiche exactement pareil, il pèse juste beaucoup moins lourd.
Petite subtilité d'implémentation : deux moteurs différents manipulent ces données. Le générateur de combats, en Java, écrit désormais chaque nouveau combat directement compressé, au moment où il se termine. Et l'API du site, en PHP, décompresse quand vous ouvrez un combat. Les deux utilisent zstd avec exactement les mêmes dictionnaires, chacun via sa propre bibliothèque, et doivent produire des résultats parfaitement interchangeables. La décompression prend moins d'une milliseconde, invisible dans le temps de chargement. Et l'ancien format texte reste lisible : pendant toute la migration, les deux formats ont cohabité dans les mêmes tables sans que personne ne s'en aperçoive.
Les ratios mesurés sur de vrais combats tournent autour de 5 à 7x sur les actions et 6 à 8x sur le rapport. Sur le disque, ça fait à peu près 2x de gain par rapport à ce que Postgres compressait déjà tout seul, ce qui est le vrai comparable. L'exemple le plus parlant, c'est l'année 2025 : passée de 46 Go à 26 Go.
Un compresseur comme zstd fait au fond une chose toute simple : repérer des répétitions. Quand il lit deux fois la même séquence, il garde la première et remplace la seconde par une référence, du genre « recopie les 30 octets vus un peu plus haut ». Plus il trouve de répétitions, plus le résultat est petit. Le niveau de compression (1 à 22 pour zstd) règle l'acharnement de la recherche : au niveau 19, il explore énormément de candidats pour trouver les meilleures références. C'est lent à compresser, mais ça ne change rien à la décompression, qui reste quasi instantanée : un combat se décompresse en moins d'une milliseconde. Et le bench m'a montré une falaise intéressante : entre le niveau 19 et le niveau 12, on gagne 18x en vitesse pour seulement 8% de taille en plus. Ici le stockage prime, donc j'ai gardé le 19.
Le problème, c'est qu'un compresseur commence toujours à froid. Il ne sait rien du fichier qu'il ouvre, il apprend les motifs au fur et à mesure de la lecture. Sur un gros fichier, aucune importance : les premiers kilo-octets servent d'échauffement et tout le reste en profite. Mais un combat, c'est un petit document de quelques kilo-octets. Le temps que zstd comprenne à quoi ressemble un combat, le combat est déjà fini. Et comme chaque combat est compressé séparément, ce coût d'apprentissage est payé 47,7 millions de fois.
C'est là que le dictionnaire entre en jeu. On donne des milliers de combats réels à un entraîneur, qui en extrait les motifs les plus fréquents : les codes d'action qui reviennent sans arrêt, les structures JSON, les enchaînements typiques d'un tour de jeu. Tout ça est rangé dans un fichier de 256 Ko. Ensuite, chaque combat est compressé « avec le dictionnaire en tête » : dès le premier octet, zstd peut faire référence à ces motifs connus, comme s'il avait déjà lu des milliers de combats avant celui-là. Le dictionnaire n'est stocké qu'une seule fois, et tous les combats en profitent.
C'est d'ailleurs pour ça que mon format binaire sur mesure a perdu le match. J'avais beau encoder chaque valeur au bit près, je ne captais que la redondance à l'intérieur d'un combat. zstd avec dictionnaire capte la redondance entre les combats, et c'est là que se cache l'essentiel du gain.
« Avant », c'est la taille que l'année occupait dans la base au format texte (pour les archives réimportées, c'est leur taille une fois restaurées, juste avant compression). « Après », c'est la même année une fois compressée et compactée.
| Année | Combats | Avant (texte) | Après (zstd) | Gain | |---|---|---|---|---| | 2014 | 4,11 M | 13 Go | 7,4 Go | −43% | | 2015 | 6,87 M | 29 Go | 16 Go | −45% | | 2016 | 4,30 M | 22 Go | 12 Go | −45% | | 2017 | 4,00 M | 25 Go | 14 Go | −44% | | 2018 | 3,81 M | 23 Go | 13 Go | −43% | | 2019 | 2,63 M | 17 Go | 10 Go | −41% | | 2020 | 2,67 M | 19 Go | 11 Go | −42% | | 2021 | 2,80 M | 30 Go | 16 Go | −47% | | 2022 | 3,00 M | 27 Go | 15 Go | −44% | | 2023 | 3,00 M | 28 Go | 16 Go | −43% | | 2024 | 4,00 M | 42 Go | 24 Go | −43% | | 2025 | 4,48 M | 46 Go | 26 Go | −43% | | 2026 | 2,01 M | 18 Go | 19 Go* | — | | Total | 47,7 M | ≈ 339 Go | 199 Go | ≈ −41% |
\* 2026 est l'année en cours : la table a été compressée sur place, sans reconstruction, et continue de grandir chaque jour avec les nouveaux combats. La place libérée est réutilisée au fur et à mesure, le gain est là mais invisible dans la taille.
La partie délicate, c'était de faire tourner tout ça sur la prod sans jamais couper le service. Chaque année a suivi le même rituel : réimporter l'archive, compresser combat par combat en vérifiant que la décompression redonne bien l'original à l'octet près, recopier vers une table compacte, comparer méticuleusement l'ancienne et la nouvelle, puis basculer. Le tout depuis un conteneur à part, isolé du jeu, pour que la migration ne puisse jamais gêner les combats en cours.
Les grosses années ont surtout demandé de la patience. 2015 à elle seule, c'est presque 7 millions de combats et une dizaine d'heures de compression.
Les combats étaient le gros morceau, mais tant que j'avais les mains dans le moteur, j'ai fait le tour du disque.
Les notifications. 70 millions de lignes, 14 Go. Ici la compression ne sert à rien : chaque notification ne pèse que quelques dizaines d'octets, et compresser des valeurs aussi petites les ferait plutôt gonfler. Par contre, les index avaient de quoi faire. L'index sur la date pesait 1,78 Go ; remplacé par un index BRIN, qui indexe des plages de blocs au lieu de chaque ligne (parfait pour une table où les dates arrivent naturellement dans l'ordre), il pèse désormais... 216 Ko. Oui, kilo-octets. La clé primaire, gonflée par des années d'insertions, est passée de 2,5 à 1,5 Go après reconstruction. Bilan : 14 → 11 Go, sans verrou ni coupure.
La chasse au bloat. Les index Postgres se fragmentent avec le temps : à force d'insertions et de suppressions, leurs pages se vident à moitié mais gardent leur place sur le disque. J'ai scanné toute la base à la recherche des index les moins denses et reconstruit les pires, en ligne, sans bloquer personne. Environ 3,5 Go récupérés.
Le reste. Des source maps JavaScript qui s'accumulaient build après build (7 Go récupérés en réduisant la rétention), les vieux dumps devenus redondants après chaque réimport, et une vingtaine de Go de conteneurs et d'images Docker morts.
Et pour que ça dure, deux tâches automatiques tournent désormais sur le serveur : l'une reconstruit tous les trimestres les index qui regonflent, l'autre nettoie chaque semaine ce qui s'accumule. Le ménage se fait tout seul maintenant.
Au final, les treize années sont compressées : 47,7 millions de combats pour 199 Go. La place regagnée a permis de tout réimporter, donc si vous voulez retrouver un de vos combats de 2014, il est de nouveau consultable.
Le tout sans perdre un seul combat au passage, et sans interruption du jeu. Ça ne se voit pas à l'écran, mais ça améliore le confort de tout le monde : on prend soin du serveur adoré, il nous le rend bien :)
Impossible de charger les données du jeu.
Vérifiez votre connexion et réessayez.