MySQL Connection Pooling e Caching andati storti: dentro un bug dello 0,001% che ha rotto la produzione
In breve
Scopri come il caching del connection pooling MySQL ha provocato un bug dello 0,001% in produzione e come evitarlo nella tua architettura backend.
Il tuo backend restituisce set di risultati vuoti per query che restituiscono sempre dati. Il tasso di errore è dello 0,001% — circa una richiesta su 100.000. La tua telemetria non mostra nulla. Le tracce non mostrano nulla. E l'unico indizio che hai è che non accade mai quando il caching è disabilitato.
Questa è esattamente la situazione in cui si è trovato il team di Hyperdrive di Cloudflare mentre portava il supporto MySQL del loro connection pooler a disponibilità generale. Il bug che hanno trovato è una masterclass su come il caching del connection pooling MySQL possa rompersi silenziosamente quando due layer non concordano sul formato dei byte che viaggiano sul wire. Se gestisci un livello di database in pooling e con cache per il tuo backend di gioco — e dovresti — questa modalità di errore si applica anche a te.
Questo post analizza il bug, i dettagli del protocollo che lo hanno reso possibile, come rilevarlo e rimediare, e cosa significa per la tua architettura di backend di gioco.
Perché esiste il Connection Pooling
Stabilire un handshake TCP + TLS + MySQL verso un database richiede un minimo di cinque round trip di rete. Se il tuo server di gioco apre una nuova connessione per ogni richiesta, paghi questo costo a ogni lettura della leaderboard, a ogni recupero del salvataggio, a ogni lookup dell'inventario.
Il pooling risolve il problema tenendo aperte connessioni calde e prestandole. MySQL è più adatto a questo di Postgres — Postgres genera un intero processo del sistema operativo per connessione, mentre le connessioni MySQL sono relativamente economiche — ma un gioco popolare con migliaia di giocatori concorrenti può esaurire rapidamente i limiti delle connessioni.
Il pooling introduce però un problema suo: session state leakage. Se la query del giocatore A imposta una variabile di sessione e il giocatore B ottiene la stessa connessione, hai un bug di correttezza. I pooler risolvono questo con una pulizia della connessione tra i checkout. MySQL offre un comando leggero per esattamente questo: COM_RESET_CONNECTION.
Ed è qui che vive il primo bug sottile di questa storia.
Bug #1: Deriva della codifica dopo i reset della connessione
Il team di Hyperdrive ha negoziato utf8 nell'handshake di avvio. Funzionava — finché i giocatori non hanno iniziato a usare emoji in chat e nei nomi dei personaggi, e i dati sono tornati storpi.
Il problema: utf8 in MySQL è un alias deprecato per utf8mb3, che supporta solo 1–3 byte per punto di codice Unicode. La UTF-8 completa (inclusi emoji, estensioni CJK e la maggior parte dei sistemi di scrittura non latini che userà la tua base di giocatori globale) richiede fino a 4 byte per punto di codice. La soluzione è negoziare utf8mb4.
Ma ecco la trappola che inganna quasi tutti:
-- 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 non ripristina la connessione al set di caratteri negoziato all'avvio. Ripristina il charset predefinito del server — che su molte istanze MySQL è ancora latin1. Dopo il primo reset, ogni connessione in pooling degrada silenziosamente a una codifica di epoca anni '90.
La soluzione: riapplica la tua configurazione di sessione dopo ogni reset, non solo all'avvio:
COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';
Regola pratica: considera la connessione come completamente non configurata dopo ogni reset e riafferma tutto ciò che ti interessa. Qualunque cosa imposti una volta alla connessione alla fine andrà persa.
Bug #2: Il bug dello 0,001% di risposte vuote
Questo è quello che ha richiesto giorni per essere trovato. Un cliente ad alto volume ha segnalato un tasso piccolo ma costante di risposte vuote da query dove erano garantite risposte non vuote. L'errore accadeva circa lo 0,001% delle volte — troppo raro per catturare in modo affidabile una traccia end-to-end, troppo frequente per essere ignorato.
Il team ha setacciato la telemetria, aumentato i tassi di tracing, cercato anomalie di rete. Niente. L'unico indizio che è stato risolutivo: il bug si verificava solo sulle istanze con il caching abilitato.
L'architettura che lo ha reso possibile
- Client node — gira vicino al Worker, gestisce la cache locale, inoltra le query.
- Endpoint node — gira nel data center più vicino al database di origine, mantiene il vero connection pool.
Entrambi i nodi usano la cache CDN locale al loro data center. Quando girano nello stesso data center, condividono una sola cache. Questa condivisione è un vantaggio in termini di prestazioni — ma comporta una precondizione nascosta: entrambi i nodi devono memorizzare nella cache esattamente gli stessi byte per la stessa risposta alla query.
Il disallineamento del protocollo
Il protocollo client/server di MySQL ha una fase di connessione in cui client e server negoziano capabilities — funzionalità opzionali come CLIENT_DEPRECATE_EOF, che cambiano il modo in cui i result set vengono terminati:
- Senza
CLIENT_DEPRECATE_EOF: pacchetto EOF dopo le definizioni delle colonne, poi un altro EOF alla fine del result set. - Con esso: la EOF dei metadati viene omessa e la EOF finale diventa un pacchetto OK.
Hyperdrive supporta questa capability e la negozia con il server MySQL di origine. Ma la libreria client dall'altra parte — per esempio, mysql2 per Node.js, che attualmente non supporta CLIENT_DEPRECATE_EOF — potrebbe non farlo. Quindi Hyperdrive deve tradurre: riscrivere i result set per inserire i pacchetti EOF, convertire i pacchetti OK di nuovo in EOF, così che i byte corrispondano a ciò che il driver del Worker si aspetta.
La conseguenza: i byte che Hyperdrive riscrive al client sono in un formato diverso da quelli che riceve dal server di origine.
L'assunzione errata
Quando è stato implementato il caching lato Client per Postgres, il team ha incorporato un'assunzione: puoi mettere in cache esattamente i byte che hai riscritto al client. Per il protocollo wire di Postgres, questo era vero. Per MySQL, non lo è.
Ora componi il fallimento:
- L'Endpoint processa la query Q, salva i byte della risposta in formato Hyperdrive capability nella cache condivisa.
- Il Client (stesso data center) riceve una richiesta per Q, trova la voce in cache e la serve al Worker senza tradurre le capabilities — perché i byte sembrano già scritti per il Worker.
- Il driver del Worker, che non ha mai negoziato
CLIENT_DEPRECATE_EOF, interpreta male il result set terminato con OK — producendo risultati vuoti, hang o desincronizzazioni del protocollo.
Il bug si attiva solo quando Client ed Endpoint sono nella stessa posizione e uno legge la voce in cache dell'altro. Ecco perché era lo 0,001% e invisibile ai tracing ordinari. È l'equivalente a livello di protocollo wire di un bug di desync — due componenti che hanno assunti diversi sullo stato condiviso. Se hai mai debuggato la divergenza di stato tra la predizione del client e l'autorità del server, la forma di questo fallimento ti sarà familiare; è la stessa classe di problema descritta in come risolvere il problema di replica RPC di Unreal Engine che rompe i tuoi stati, solo a livello del database wire.
La soluzione, concettualmente: mettere in cache il formato canonico (quello inviato dall'origine) e tradurre alle capabilities del client richiedente al momento della lettura. Non mettere mai in cache byte post-traduzione quando la traduzione dipende dalla negoziazione per-client.
Runbook: Rilevare la corruzione della cache nel tuo stack
Rilevamento
- Correla gli errori con la topologia. Il bug appariva solo sulle istanze in cui Client ed Endpoint condividevano data center e cache. Registra quale nodo ha servito ogni risposta e se proveniva dalla cache. Se il tasso di errore è correlato ai cache hit, sospetta un disallineamento di formato tra scrittore e lettore.
- Caccia errori rari ma costanti, non picchi. Un tasso di errore dello 0,001% non attiva mai alert. Configura una query nel tuo sistema di error tracking per occorrenze ripetute della stessa firma indipendentemente dal volume. La costanza a basso tasso è un segnale più forte di un bug logico rispetto a un picco correlato al traffico.
- Testa deliberatamente con asimmetria di capabilities. Collega un client di test con
CLIENT_DEPRECATE_EOFe uno senza, abilita la cache e martella query identiche. La maggior parte dei bug di traduzione del protocollo emerge in pochi minuti con questa asimmetria.
Rimedio
- Canonicalizza le voci di cache. Memorizza il formato di risposta grezzo dell'origine; traduci in lettura. Questa è la soluzione corretta per qualsiasi pooler i cui client abbiano capabilities eterogenee.
- Riafferma lo stato di sessione dopo i reset. Come mostrato sopra,
COM_RESET_CONNECTIONripristina i default del server, non le impostazioni negoziate. - Versiona le tue cache key. Se cambi qualsiasi comportamento del protocollo (capabilities, codifica, framing delle risposte), includi una versione del formato nella cache key così che le vecchie voci non possano essere servite dal nuovo codice durante un dispiegamento.
# 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}")
Prevenzione
- Scrivi test di conformità del protocollo wire contro due librerie client, non una. Il gap di
CLIENT_DEPRECATE_EOFera visibile solo perché un popolare driver Node.js non lo supportava. - Non dare per scontato "gli stessi byte" attraverso i layer. Qualsiasi componente che riscrive i messaggi non deve assumere che l'output sia identico byte per byte all'input.
- Fai load test con la cache abilitata fin dal primo giorno. Il team di Hyperdrive ha validato l'affidabilità per mesi prima che emergesse il bug specifico della cache — perché l'intersezione delle funzionalità (pooling × caching × traduzione delle capabilities) è il posto dove vivono i bug rari.
Cinque best practice per pooling e caching nei backend di gioco
- Negozia sempre
utf8mb4, maiutf8. Nomi giocatore, chat e dati di salvataggio conterranno caratteri a 4 byte. Poi riaffermaSET NAMES utf8mb4dopo ogniCOM_RESET_CONNECTION, perché il reset ripristina i default del server, non il tuo handshake. - Metti in cache dati canonici, non dati formattati per il client. Se qualsiasi layer trasforma una risposta prima di inviarla, i byte trasformati non devono mai entrare in una cache condivisa. Memorizza il formato dell'origine; traduci per ogni lettore.
- Aggiungi versioni del formato alle cache key. Una modifica di una riga che ti salva da voci con protocollo obsoleto durante i deploy progressivi.
- Fai alert sulla costanza degli errori, non solo sul tasso. Una firma ricorrente dello 0,001% senza correlazione col traffico è un bug logico. Un picco correlato al traffico è un problema di capacità. Trattali in modo diverso.
- Testa client con capabilities non corrispondenti. La correttezza del tuo pooler dipende dalla gestione di ogni libreria client con cui il tuo team (o gli utenti del tuo SDK) potrebbe connettersi.
La lezione per l'architettura backend
La lezione più profonda qui non riguarda MySQL. Riguarda i sistemi a layer con negoziazione per-peer. Nel momento in cui inserisci un middlebox — un pooler, un proxy, una cache — che parla il protocollo su entrambi i lati, erediti l'obbligo di tradurre fedelmente e di mettere in cache solo formati universalmente validi. Postgres funzionava; MySQL si è rotto; nessuna logica di caching era sbagliata, tranne un'assunzione ereditata da un altro protocollo.
Per uno studio di giochi, questo è un forte argomento contro l'implementazione fai-da-te del tuo layer pooler-plus-cache. Lo sforzo ingegneristico per far funzionare il percorso felice è forse di una settimana; lo sforzo per sistemare i percorsi rari — negoziazione delle capabilities, deriva della codifica, co-locazione della cache — è ciò che ha richiesto a un team infrastrutturale dedicato giorni di indagini per scovare un singolo bug dello 0,001%. L'ammissione di Cloudflare merita di essere ripetuta: la differenza tra beta e GA era "solo 20 bug fix sotto mentite spoglie".
Se il tuo backend di gioco ha bisogno di una persistenza affidabile senza che tu debba mantenere un pooler, uno schema di invalidazione della cache e un layer di traduzione del protocollo, è lì che un Backend-as-a-Service dimostra il suo valore. horizOn gestisce Cloud Save legato all'account con letture e scritture consapevoli della revisione — il tuo client risolve i conflitti e salti l'intero campo minato di correttezza del pool di connessioni più cache per i dati dei giocatori. Abbinalo a leaderboard e configurazione remota, e i problemi di latenza delle query discussi in questo post smettono in gran parte di essere un tuo problema.
Prossimo passo: esegui oggi un audit della configurazione del tuo pooler. Controlla tre cose — il charset negoziato, se lo stato di sessione viene riaffermato dopo i reset delle connessioni e se la tua cache memorizza byte canonici o tradotti. Se non riesci a rispondere a tutte e tre in dieci minuti, hai del lavoro da fare — e se preferisci non farlo affatto, dai un'occhiata a horizOn e verifica se i suoi Cloud Save e leaderboard coprono ciò che il tuo livello di database fa attualmente a mano.
Fonte: Inside Hyperdrive: come abbiamo risolto un raro bug di caching MySQL di Hyperdrive