MySQL Connection Pooling Caching : anatomie d'un bug à 0,001 % qui a cassé la production
En bref
Découvrez comment un bug à 0,001 % dans le MySQL connection pooling caching a cassé la production et comment l'éviter dans votre backend de jeu.
Votre backend renvoie des résultats vides pour des requêtes qui renvoient toujours des données. Le taux d'erreur est de 0,001 % — environ une requête sur 100 000. Votre télémétrie ne montre rien. Les traces ne montrent rien. Et le seul indice dont vous disposez est que cela ne se produit jamais lorsque la mise en cache est désactivée.
C'est exactement la situation dans laquelle s'est retrouvée l'équipe Hyperdrive de Cloudflare en faisant passer le support MySQL de son connection pooler en disponibilité générale. Le bug qu'elle a trouvé est une leçon magistrale sur la façon dont MySQL connection pooling caching peut silencieusement casser lorsque deux couches ne sont pas d'accord sur le format des octets qui circulent sur le fil. Si vous faites tourner une couche de base de données poolée et mise en cache pour votre backend de jeu — et c'est une bonne pratique — ce mode de défaillance s'applique à vous.
Cet article décortique le bug, les détails protocolaires qui l'ont rendu possible, comment le détecter et le corriger, et ce que cela implique pour l'architecture de votre backend de jeu.
Pourquoi le connection pooling existe
Établir un handshake TCP + TLS + MySQL vers une base de données prend au minimum cinq allers-retours réseau. Si votre serveur de jeu ouvre une nouvelle connexion à chaque requête, vous payez ce coût à chaque lecture de leaderboard, à chaque chargement de sauvegarde, à chaque consultation d'inventaire.
Le pooling corrige cela en maintenant des connexions chaudes ouvertes et en les prêtant. MySQL est plus favorable à cela que Postgres — Postgres crée un processus OS entier par connexion, tandis que les connexions MySQL sont comparativement bon marché — mais un jeu populaire avec des milliers de joueurs simultanés peut rapidement épuiser les limites de connexion.
Le pooling introduit cependant son propre problème : la fuite d'état de session. Si la requête du joueur A définit une variable de session et que le joueur B récupère la même connexion, vous avez un bug de correction. Les poolers résolvent cela avec un nettoyage de connexion entre les checkouts. MySQL fournit une commande légère pour exactement cela : COM_RESET_CONNECTION.
Et c'est là que vit le premier bug subtil de cette histoire.
Bug n°1 : la dérive d'encodage après les réinitialisations de connexion
L'équipe Hyperdrive a négocié utf8 dans le handshake de démarrage. Cela a fonctionné — jusqu'à ce que les joueurs commencent à utiliser des emojis dans le chat et les noms de personnages, et que les données reviennent mutilées.
Le problème : utf8 dans MySQL est un alias obsolète pour utf8mb3, qui ne prend en charge que 1 à 3 octets par point de code Unicode. L'UTF-8 complet (y compris les emojis, les extensions CJK et la plupart des écritures non latines que votre base de joueurs mondiale utilisera) nécessite jusqu'à 4 octets par point de code. La solution consiste à négocier utf8mb4 à la place.
Mais voici le piège qui attrape presque tout le monde :
-- Startup handshake: negotiate utf8mb4. All good.
-- Then, between checkouts, the pooler runs:
COM_RESET_CONNECTION;
-- Surprise: the server does NOT reset to the negotiated charset.
-- It resets to the SERVER DEFAULT, e.g. latin1.
COM_RESET_CONNECTION ne restaure pas la connexion au jeu de caractères négocié au démarrage. Il réinitialise vers le charset par défaut du serveur — qui sur de nombreuses instances MySQL est encore latin1. Après la première réinitialisation, chaque connexion poolée dégrade silencieusement vers un encodage des années 1990.
La correction : réappliquez votre configuration de session après chaque réinitialisation, pas seulement au démarrage :
COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';
Règle empirique : considérez la connexion comme entièrement non configurée après chaque réinitialisation, et réaffirmez tout ce qui compte pour vous. Tout ce que vous définissez une fois à la connexion finira par fuir.
Bug n°2 : le bug de réponse vide à 0,001 %
C'est celui qui a pris des jours à trouver. Un client à fort volume a signalé un taux faible mais constant de réponses vides pour des requêtes où des réponses non vides étaient garanties. L'erreur se produisait environ 0,001 % du temps — trop rare pour capturer de manière fiable une trace de bout en bout, trop fréquente pour être ignorée.
L'équipe a passé la télémétrie au peigne fin, augmenté les taux de trace, cherché des anomalies réseau. Rien. Le seul indice qui a tout débloqué : le bug ne se produisait que sur les instances avec la mise en cache activée.
L'architecture qui l'a rendu possible
Hyperdrive utilise une topologie divisée :
- Nœud client — tourne près du Worker, gère la mise en cache locale, transmet les requêtes.
- Nœud endpoint — tourne dans le data center le plus proche de la base de données d'origine, détient le pool de connexions réel.
Les deux nœuds utilisent le cache CDN local à leur data center. Lorsqu'ils tournent dans le même data center, ils partagent un même cache. Ce partage est un gain de performance — mais il comporte une condition cachée : les deux nœuds doivent mettre en cache exactement les mêmes octets pour la même réponse de requête.
Le décalage de protocole
Le protocole client/serveur de MySQL comporte une phase de connexion où le client et le serveur négocient des capabilities — des fonctionnalités optionnelles comme CLIENT_DEPRECATE_EOF, qui change la façon dont les result sets sont terminés :
- Sans
CLIENT_DEPRECATE_EOF: paquet EOF après les définitions de colonnes, puis un autre EOF à la fin du result set. - Avec : l'EOF de métadonnées est omis et l'EOF final devient un paquet OK.
Hyperdrive prend en charge cette capability et la négocie avec le serveur MySQL d'origine. Mais la bibliothèque cliente de l'autre côté — par exemple, mysql2 pour Node.js, qui ne prend actuellement pas en charge CLIENT_DEPRECATE_EOF — peut ne pas la supporter. Hyperdrive doit donc traduire : réécrire les result sets pour insérer des paquets EOF, reconvertir les paquets OK en EOF, afin que les octets correspondent à ce que le driver du Worker attend.
La conséquence : les octets qu'Hyperdrive renvoie au client sont dans un format différent de celui des octets qu'il reçoit du serveur d'origine.
L'hypothèse erronée
Lorsque la mise en cache côté client a été implémentée pour Postgres, l'équipe a intégré une hypothèse : vous pouvez mettre en cache exactement les octets que vous avez renvoyés au client. Pour le protocole fil de Postgres, c'était vrai. Pour MySQL, ce ne l'est pas.
Assemblez maintenant la défaillance :
- L'Endpoint traite la requête Q, met en cache les octets de réponse au format de capability Hyperdrive dans le cache partagé.
- Le Client (même data center) reçoit une requête pour Q, trouve l'entrée en cache et la sert au Worker sans traduire les capabilities — parce que les octets semblent déjà écrits pour le Worker.
- Le driver du Worker, qui n'a jamais négocié
CLIENT_DEPRECATE_EOF, interprète mal le result set terminé par OK — produisant des résultats vides, des blocages ou des désynchronisations de protocole.
Le bug ne se déclenche que lorsque le Client et l'Endpoint sont colocalisés et que l'un lit l'entrée de cache de l'autre. C'est pour cela qu'il était à 0,001 % et invisible pour un tracing ordinaire. C'est l'équivalent, au niveau du protocole fil, d'un bug de désynchronisation — deux composants ayant des hypothèses différentes sur un état partagé. Si vous avez déjà débogué une divergence d'état entre la prédiction client et l'autorité serveur, la forme de cette défaillance vous semblera familière ; c'est la même classe de problème que celle décrite dans corriger le problème de réplication RPC d'Unreal Engine qui casse vos états, mais au niveau de la couche fil de la base de données.
La correction, conceptuellement : mettre en cache au format canonique (ce que l'origine a envoyé), et traduire vers les capabilities du client demandeur au moment de la lecture. Ne mettez jamais en cache des octets post-traduction lorsque la traduction dépend d'une négociation par client.
Runbook : détecter la corruption de cache dans votre propre stack
Si vous faites tourner un pooler ou une couche de cache devant MySQL (ou tout ce qui a un protocole fil avec état), voici comment appliquer ce postmortem.
Détection
- Corrélez les erreurs avec la topologie. Le bug n'apparaissait que sur les instances où le Client et l'Endpoint partageaient un data center et un cache. Journalisez quel nœud a servi chaque réponse et si elle venait du cache. Si le taux d'erreur est corrélé aux cache hits, suspectez une inadéquation de format entre le scripteur et le lecteur.
- Traquez les erreurs rares mais constantes, pas les pics. Un taux d'erreur de 0,001 % ne déclenche jamais d'alertes. Configurez une requête dans votre suivi d'erreurs pour les occurrences répétées de la même signature, quel que soit le volume. La constance à faible taux est un signal plus fort d'un bug logique qu'un pic corrélé au trafic.
- Testez délibérément avec une asymétrie de capabilities. Connectez un client de test avec
CLIENT_DEPRECATE_EOFet un autre sans, activez la mise en cache, et martelez des requêtes identiques. La plupart des bugs de traduction de protocole apparaissent en quelques minutes sous cette asymétrie.
Correction
- Canonicalisez les entrées de cache. Stockez le format de réponse brut de l'origine ; traduisez à la lecture. C'est la correction adaptée pour tout pooler dont les clients ont des capabilities hétérogènes.
- Réaffirmez l'état de session après les réinitialisations. Comme montré ci-dessus,
COM_RESET_CONNECTIONrestaure les valeurs par défaut du serveur, pas vos paramètres négociés. - Versionnez vos clés de cache. Si vous modifiez un comportement de protocole (capabilities, encodage, framing des réponses), incluez une version de format dans la clé de cache afin que les anciennes entrées ne puissent pas être servies par le nouveau code en cours de déploiement.
# Cache key hygiene, in pseudocode
def cache_key(query_normalized: str, schema_version: int, format_version: int) -> str:
# format_version bumps whenever wire-format handling changes,
# preventing stale-format entries from poisoning new deploys
return sha256(f"{schema_version}:{format_version}:{query_normalized}")
Prévention
- Écrivez des tests de conformité du protocole fil contre deux bibliothèques clientes, pas une. L'écart
CLIENT_DEPRECATE_EOFn'était visible que parce qu'un driver Node.js populaire ne le prenait pas en charge. - Ne supposez rien quant aux « mêmes octets » entre les couches. Tout composant qui réécrit des messages ne doit pas supposer que sa sortie est octet-identique à son entrée.
- Faites des tests de charge avec le cache activé dès le premier jour. L'équipe Hyperdrive a validé la fiabilité pendant des mois avant que le bug spécifique au cache ne fasse surface — parce que l'intersection des fonctionnalités (pooling × caching × traduction de capabilities) est là où vivent les bugs rares.
Cinq bonnes pratiques pour le pooling et le caching dans les backends de jeu
- Négociez toujours
utf8mb4, jamaisutf8. Les noms de joueurs, le chat et les données de sauvegarde contiendront des caractères de 4 octets. Réaffirmez ensuiteSET NAMES utf8mb4après chaqueCOM_RESET_CONNECTION, car la réinitialisation restaure les valeurs par défaut du serveur, pas votre handshake. - Mettez en cache les données canoniques, pas les données formatées pour le client. Si une couche transforme une réponse avant de l'envoyer, les octets transformés ne doivent jamais entrer dans un cache partagé. Stockez le format d'origine ; traduisez par lecteur.
- Ajoutez des versions de format aux clés de cache. Un changement d'une ligne, qui vous évite les entrées de protocole obsolètes pendant les déploiements progressifs.
- Alertez sur la constance des erreurs, pas seulement sur le taux d'erreur. Une signature récurrente à 0,001 % sans corrélation avec le trafic est un bug logique. Un pic corrélé au trafic est un problème de capacité. Acheminez-les différemment.
- Testez les clients avec des capabilities incompatibles. La correction de votre pooler dépend de la prise en charge de chaque bibliothèque cliente avec laquelle votre équipe (ou les utilisateurs de votre SDK) pourrait se connecter.
La leçon d'architecture backend
La leçon la plus profonde ici ne concerne pas du tout MySQL. Elle concerne les systèmes en couches avec négociation par pair. Dès l'instant où vous insérez une middlebox — un pooler, un proxy, un cache — qui parle le protocole des deux côtés, vous héritez de l'obligation de traduire fidèlement et de ne mettre en cache que des formats universellement valides. Postgres fonctionnait ; MySQL a cassé ; rien dans la logique de mise en cache n'était faux, sauf une hypothèse héritée d'un protocole différent.
Pour un studio de jeux, c'est un argument solide contre le fait de développer maison sa propre couche pooler-plus-cache. L'effort d'ingénierie pour faire fonctionner le chemin heureux est peut-être d'une semaine ; l'effort d'ingénierie pour maîtriser les chemins rares — négociation de capabilities, dérive d'encodage, colocalisation de cache — est ce qui a demandé à une équipe d'infrastructure dédiée des jours d'investigation pour traquer un seul bug à 0,001 %. L'aveu de Cloudflare vaut la peine d'être répété : la différence entre la bêta et la GA était « juste 20 correctifs de bugs dans un trenchcoat ».
Si votre backend de jeu a besoin d'une persistance fiable sans que vous ayez à maintenir un pooler, un schéma d'invalidation de cache et une couche de traduction de protocole, c'est là qu'un Backend-as-a-Service justifie sa valeur. horizOn gère le Cloud Save lié au compte avec des lectures et écritures conscientes des révisions — votre client résout les conflits, et vous évitez tout le champ de mines de correction du pooled-connection-plus-cache pour les données des joueurs. Associez-le aux leaderboards et à la configuration à distance, et les problèmes de latence de requête abordés dans cet article cessent en grande partie d'être votre problème.
Prochaine étape : auditez dès aujourd'hui la configuration de votre pooler. Vérifiez trois choses — le charset négocié, si l'état de session est réaffirmé après les réinitialisations de connexion, et si votre cache stocke des octets canoniques ou traduits. Si vous ne pouvez pas répondre aux trois en dix minutes, vous avez du travail — et si vous préférez ne pas le faire du tout, jetez un œil à horizOn et voyez si son Cloud Save et ses leaderboards couvrent ce que votre couche de base de données fait actuellement à la main.
Source : Inside Hyperdrive : comment nous avons résolu un rare bug de caching MySQL chez Hyperdrive