MySQL Connection Pooling Caching Misgegaan: Binnenin een 0,001%-bug die de productie brak
Kort samengevat
Ontdek hoe MySQL connection pooling caching stilletjes kan breken en leer hoe je deze zeldzame 0,001%-bug in je game-backend detecteert en verhelpt.
Je backend retourneert lege resultaatsets voor queries die altijd data retourneren. Het foutpercentage is 0,001% — ruwweg één op elke 100.000 verzoeken. Je telemetrie toont niets. Traces tonen niets. En de enige aanwijzing die je hebt, is dat het nooit gebeurt wanneer caching is uitgeschakeld.
Dat is precies de situatie waarin het Hyperdrive-team van Cloudflare zich bevond toen ze MySQL-ondersteuning in hun connection pooler naar algemene beschikbaarheid brachten. De bug die ze vonden is een masterclass in hoe MySQL connection pooling caching stilletjes kan breken wanneer twee lagen het oneens zijn over het formaat van de bytes die over de draad gaan. Als je een gepoolde, gecachte databaselaag voor je game-backend draait — en dat zou je moeten doen — dan geldt deze faalmodus ook voor jou.
Deze post ontleedt de bug, de protocoldetails die hem mogelijk maakten, hoe je hem detecteert en verhelpt, en wat het betekent voor je eigen game-backendarchitectuur.
Waarom Connection Pooling Bestaat
Het opzetten van een TCP + TLS + MySQL-handshake naar een database kost minimaal vijf netwerk-roundtrips. Als je game-server per verzoek een nieuwe verbinding opent, betaal je die kosten bij elke leaderboard-read, elke save-game-fetch, elke inventory-lookup.
Pooling lost dit op door warme verbindingen open te houden en ze uit te lenen. MySQL is hiervoor vriendelijker dan Postgres — Postgres start een volledig OS-proces per verbinding, terwijl MySQL-verbindingen relatief goedkoop zijn — maar een populaire game met duizenden gelijktijdige spelers kan verbindingslimieten toch snel uitputten.
Pooling introduceert echter een eigen probleem: session state leakage. Als de query van speler A een sessievariabele instelt en speler B dezelfde verbinding krijgt, heb je een correctheidsbug. Poolers lossen dit op met het opschonen van verbindingen tussen checkouts. MySQL biedt hiervoor een lichtgewicht commando: COM_RESET_CONNECTION.
En daar leeft de eerste subtiele bug in dit verhaal.
Bug #1: Encoding-drift na verbindingsresets
Het Hyperdrive-team onderhandelde utf8 in de startup-handshake. Dat werkte — totdat spelers emoji's in chat en karakternamen gingen gebruiken, en de data verminkt terugkwam.
Het probleem: utf8 in MySQL is een verouderd alias voor utf8mb3, dat slechts 1–3 bytes per Unicode-codepunt ondersteunt. Volledige UTF-8 (inclusief emoji, CJK-extensies en de meeste niet-Latijnse schriften die je wereldwijde spelersbasis zal gebruiken) vereist tot 4 bytes per codepunt. De fix is om in plaats daarvan utf8mb4 te onderhandelen.
Maar hier is de valkuil die bijna iedereen te pakken neemt:
-- 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 herstelt de verbinding niet naar de tekenset die je bij de startup hebt onderhandeld. Het reset naar de standaard tekenset van de server — die op veel MySQL-instanties nog steeds latin1 is. Na de eerste reset degradeert elke gepoolde verbinding stilletjes naar een encoding uit de jaren 90.
De remedie: pas je sessieconfiguratie na elke reset opnieuw toe, niet alleen bij de startup:
COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';
Vuistregel: behandel de verbinding na elke reset als volledig ongeconfigureerd en bevestig opnieuw alles wat je belangrijk vindt. Alles wat je eenmalig bij het verbinden instelt, zal uiteindelijk weglekken.
Bug #2: De 0,001%-lege-responsebug
Dit is degene die dagen kostte om te vinden. Een klant met hoog volume meldde een klein maar consistent percentage lege queryresponses waar niet-lege responses gegarandeerd waren. De fout trad ongeveer 0,001% van de tijd op — te zeldzaam om betrouwbaar een end-to-end trace te vangen, te frequent om te negeren.
Het team doorzocht telemetrie, verhoogde trace-percentages, zocht naar netwerkanomalieën. Niets. De enige aanwijzing die het oploste: de bug trad alleen op bij instanties met caching ingeschakeld.
De architectuur die het mogelijk maakte
Hyperdrive gebruikt een gesplitste topologie:
- Client node — draait dicht bij de Worker, verzorgt lokale caching, stuurt queries door.
- Endpoint node — draait in het datacenter het dichtst bij de origin-database, beheert de daadwerkelijke connection pool.
Beide nodes gebruiken de CDN-cache van hun eigen datacenter. Wanneer ze in hetzelfde datacenter draaien, delen ze één cache. Dat delen is een performancewinst — maar het heeft een verborgen voorwaarde: beide nodes moeten exact dezelfde bytes cachen voor dezelfde queryresponse.
De protocolmismatch
Het client/server-protocol van MySQL heeft een Connection Phase waarin client en server capabilities onderhandelen — optionele functies zoals CLIENT_DEPRECATE_EOF, die verandert hoe resultaatsets worden afgesloten:
- Zonder
CLIENT_DEPRECATE_EOF: EOF-packet na kolomdefinities, daarna nog een EOF aan het einde van de resultaatset. - Met deze optie: de metadata-EOF wordt weggelaten en de laatste EOF wordt een OK-packet.
Hyperdrive ondersteunt deze capability en onderhandelt deze met de origin-MySQL-server. Maar de clientbibliotheek aan de andere kant — bijvoorbeeld mysql2 voor Node.js, dat momenteel geen CLIENT_DEPRECATE_EOF ondersteunt — doet dat mogelijk niet. Dus moet Hyperdrive vertalen: resultaatsets herschrijven om EOF-packets in te voegen, OK-packets terugzetten naar EOF's, zodat de bytes overeenkomen met wat de driver van de Worker verwacht.
Het gevolg: de bytes die Hyperdrive terugschrijft naar de client hebben een ander formaat dan de bytes die het van de origin-server ontvangt.
De foutieve aanname
Toen Client-caching voor Postgres werd geïmplementeerd, bouwde het team een aanname in: je kunt exact de bytes cachen die je naar de client hebt teruggeschreven. Voor Postgres' wire-protocol was dat waar. Voor MySQL is het dat niet.
Stel nu de fout samen:
- De Endpoint bedient query Q en cacht de responsebytes in Hyperdrive-capabilityformaat in de gedeelde cache.
- De Client (zelfde datacenter) krijgt een verzoek voor Q, vindt de cache-entry en serveert die aan de Worker zonder capabilities te vertalen — omdat de bytes eruitzien alsof ze al voor de Worker zijn geschreven.
- De driver van de Worker, die nooit
CLIENT_DEPRECATE_EOFheeft onderhandeld, parseert de OK-afgesloten resultaatset verkeerd — met lege resultaten, hangs of protocol-desyncs als gevolg.
De bug treedt alleen op wanneer Client en Endpoint op dezelfde locatie staan en de een de cache-entry van de ander leest. Daarom was het 0,001% en onzichtbaar voor gewone tracing. Het is het wire-protocol-equivalent van een desync-bug — twee componenten met verschillende aannames over gedeelde staat. Als je ooit staatsdivergentie tussen clientpredictie en serverauthoriteit hebt gedebugd, zal de vorm van deze fout bekend voorkomen; het is dezelfde klasse van problemen als beschreven in het oplossen van het Unreal Engine RPC-replicatieprobleem dat je states breekt, alleen dan op de database-wire-laag.
De fix, conceptueel: cache in canoniek formaat (wat de origin stuurde) en vertaal bij het lezen naar de capabilities van de verzoekende client. Cache nooit bytes na vertaling wanneer de vertaling afhangt van per-clientonderhandeling.
Runbook: cache-corruptie detecteren in je eigen stack
Als je een pooler of cachelaag voor MySQL draait (of iets met een stateful wire-protocol), volgt hier hoe je deze postmortem toepast.
Detectie
- Correleer fouten met topologie. De bug verscheen alleen op instanties waar Client en Endpoint een datacenter en cache deelden. Log welke node elke response bediende en of die uit de cache kwam. Als het foutpercentage correleert met cache-hits, vermoed dan een formaatmismatch tussen schrijver en lezer.
- Jaag op zeldzame maar consistente fouten, niet op pieken. Een foutpercentage van 0,001% activeert nooit alerts. Stel een query in je error-tracking in voor herhaalde voorkomens van dezelfde handtekening, ongeacht volume. Consistentie op laag volume is een sterker signaal van een logica-bug dan een verkeersgecorreleerde piek.
- Test bewust met capability-asymmetrie. Verbind een testclient met
CLIENT_DEPRECATE_EOFen een zonder, schakel caching in en blijf identieke queries sturen. De meeste protocolvertalingsbugs komen binnen enkele minuten bovendrijven onder deze asymmetrie.
Remediatie
- Canonicaliseer cache-entrees. Sla het ruwe responseformaat van de origin op; vertaal bij het lezen. Dit is de juiste fix voor elke pooler waarvan clients heterogene capabilities hebben.
- Bevestig sessiestatus opnieuw na resets. Zoals hierboven getoond, herstelt
COM_RESET_CONNECTIONde serverstandaarden, niet je onderhandelde instellingen. - Voorzie je cache-sleutels van een versie. Als je protocolgedrag wijzigt (capabilities, encoding, response-framing), neem dan een formatversie op in de cache-sleutel zodat oude entries niet door nieuwe code kunnen worden geserveerd tijdens een deploy.
# 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}")
Preventie
- Schrijf wire-protocol-conformiteitstests tegen twee clientbibliotheken, niet één. De
CLIENT_DEPRECATE_EOF-kloof was alleen zichtbaar omdat een populaire Node.js-driver het niet ondersteunde. - Neem niets aan over 'dezelfde bytes' over lagen heen. Elk onderdeel dat berichten herschrijft, mag niet aannemen dat zijn output byte-identiek is aan zijn input.
- Load-test met caching ingeschakeld vanaf dag één. Het Hyperdrive-team valideerde maandenlang betrouwbaarheid voordat de cache-specifieke bug opdook — omdat de kruising van functies (pooling × caching × capability-vertaling) is waar zeldzame bugs leven.
Vijf best practices voor pooling en caching in game-backends
- Onderhandel altijd
utf8mb4, nooitutf8. Spelersnamen, chat en save-data zullen 4-byte-tekens bevatten. Bevestig daarnaSET NAMES utf8mb4opnieuw na elkeCOM_RESET_CONNECTION, omdat de reset serverstandaarden herstelt, niet je handshake. - Cache canonieke data, niet client-geformatteerde data. Als een laag een response transformeert voordat die wordt verzonden, mogen de getransformeerde bytes nooit in een gedeelde cache terechtkomen. Sla het originele formaat op; vertaal per lezer.
- Voeg formatversies toe aan cache-sleutels. Een wijziging van één regel, behoedt je voor verouderde protocolentries tijdens rolling deploys.
- Alarmeer op foutconsistentie*, niet alleen op fout*percentage.** Een terugkerende handtekening van 0,001% zonder verkeerscorrelatie is een logica-bug. Een verkeersgecorreleerde piek is een capaciteitsprobleem. Routeer ze anders.
- Test clients met mismatchende capabilities. De correctheid van je pooler hangt af van het ondersteunen van elke clientbibliotheek waarmee je team (of je SDK-gebruikers) mogelijk verbindt.
De backendarchitectuurles
De diepste les hier gaat helemaal niet over MySQL. Het gaat over gelaagde systemen met per-peer-onderhandeling. Op het moment dat je een middlebox plaatst — een pooler, een proxy, een cache — die aan beide kanten protocol spreekt, erf je de verplichting om trouw te vertalen en om alleen formaten te cachen die universeel geldig zijn. Postgres werkte; MySQL brak; er was niets mis met de caching-logica behalve één aanname die was overgeërfd van een ander protocol.
Voor een gamestudio is dit een sterk argument tegen het zelf bouwen van je eigen pooler-plus-cache-laag. De engineeringinspanning om het happy path werkend te krijgen is misschien een week; de engineeringinspanning om de zeldzame paden goed te krijgen — capability-onderhandeling, encoding-drift, cache-co-locatie — is wat een dedicated infrastructuurteam dagen van onderzoek kostte om één enkele 0,001%-bug op te sporen. Cloudflares eigen erkenning is het herhalen waard: het verschil tussen bèta en GA was "gewoon 20 bugfixes in een trenchcoat".
Als je game-backend betrouwbare persistentie nodig heeft zonder dat je zelf een pooler, een cache-invalidatieschema en een protocolvertalingslaag onderhoudt, dan is dat waar een Backend-as-a-Service zijn waarde bewijst. horizOn verzorgt accountgebonden Cloud Save met revisiebewuste reads en writes — je client lost conflicten op, en je slaat het hele mijnenveld van gepoolde-verbinding-plus-cache-correctheid voor spelersdata over. Combineer het met leaderboards en remote configuration, en de query-latentieproblemen die in deze post worden besproken, zijn grotendeels niet langer jouw probleem.
Volgende stap: audit vandaag nog je eigen poolerconfiguratie. Controleer drie dingen — de onderhandelde tekenset, of sessiestatus na verbindingsresets opnieuw wordt bevestigd, en of je cache canonieke of vertaalde bytes opslaat. Als je niet alle drie binnen tien minuten kunt beantwoorden, heb je werk te doen — en als je het liever helemaal niet doet, kijk dan eens naar horizOn en zie of de Cloud Save en leaderboards dekken wat je databaselaag momenteel met de hand doet.
Bron: Inside Hyperdrive: hoe we een zeldzame Hyperdrive MySQL-cachingbug oplosten