Voltar ao Blog

Cache de Pooling de Conexões MySQL deu Errado: Por Dentro de um Bug de 0,001% Que Quebrou a Produção

Publicado em 16 de outubro de 2026
Cache de Pooling de Conexões MySQL deu Errado: Por Dentro de um Bug de 0,001% Que Quebrou a Produção Gerada com a ajuda de IA

Em resumo

Descubra como um bug de 0,001% no cache de pooling de conexões MySQL quebrou a produção e como evitar falhas semelhantes no seu backend de jogos.

Seu backend retorna conjuntos de resultados vazios para consultas que sempre retornam dados. A taxa de erro é de 0,001% — aproximadamente uma em cada 100.000 requisições. Sua telemetria não mostra nada. Os traces não mostram nada. E a única pista que você tem é que isso nunca acontece quando o cache está desabilitado.

Essa é exatamente a situação em que o time do Hyperdrive da Cloudflare se encontrou ao levar o suporte a MySQL em seu connection pooler para disponibilidade geral. O bug que encontraram é uma aula magistral de como o cache de pooling de conexões MySQL pode quebrar silenciosamente quando duas camadas discordam sobre o formato dos bytes que trafegam pela rede. Se você roda uma camada de banco de dados com pooling e cache para o backend do seu jogo — e deveria — esse modo de falha se aplica a você.

Este post detalha o bug, os detalhes de protocolo que o tornaram possível, como detectá-lo e corrigi-lo, e o que isso significa para a arquitetura do backend do seu jogo.

Por Que o Pooling de Conexões Existe

Estabelecer um handshake TCP + TLS + MySQL com um banco de dados leva no mínimo cinco round trips de rede. Se o seu servidor de jogo abre uma conexão nova por requisição, você está pagando esse custo em cada leitura de leaderboard, cada busca de save-game, cada consulta de inventário.

O pooling resolve isso mantendo conexões quentes abertas e emprestando-as. MySQL é mais amigável a isso do que Postgres — Postgres cria um processo inteiro do SO por conexão, enquanto as conexões MySQL são comparativamente baratas — mas um jogo popular com milhares de jogadores simultâneos ainda pode esgotar os limites de conexão rapidamente.

O pooling introduz seu próprio problema, no entanto: vazamento de estado de sessão. Se a consulta do jogador A define uma variável de sessão e o jogador B recebe a mesma conexão, você tem um bug de corretude. Os poolers resolvem isso com limpeza de conexão entre checkouts. O MySQL fornece um comando leve exatamente para isso: COM_RESET_CONNECTION.

E é aí que mora o primeiro bug sutil desta história.

Bug #1: Deriva de Encoding Após Resets de Conexão

O time do Hyperdrive negociou utf8 no handshake inicial. Isso funcionou — até os jogadores começarem a usar emojis no chat e em nomes de personagens, e os dados voltarem corrompidos.

O problema: utf8 no MySQL é um alias obsoleto para utf8mb3, que suporta apenas 1–3 bytes por code point Unicode. UTF-8 completo (incluindo emoji, extensões CJK e a maioria dos scripts não latinos que sua base global de jogadores vai usar) exige até 4 bytes por code point. A correção é negociar utf8mb4 em vez disso.

Mas aqui está a armadilha que pega quase todo mundo:

-- 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 não restaura a conexão para o character set que você negociou no startup. Ele reseta para o charset padrão do servidor — que em muitas instâncias MySQL ainda é latin1. Após o primeiro reset, toda conexão pooled silenciosamente degrada para um encoding da era de 1990.

A remediação: reaplique sua configuração de sessão após cada reset, não apenas no startup:

COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';

Regra de ouro: trate a conexão como totalmente não configurada após cada reset e reafirme tudo o que for importante para você. Qualquer coisa que você definir uma vez no momento da conexão acabará vazando.

Bug #2: O Bug de Resposta Vazia de 0,001%

Este é o que levou dias para ser encontrado. Um cliente de alto volume relatou uma taxa pequena, mas consistente, de respostas vazias a consultas onde respostas não vazias eram garantidas. O erro ocorria cerca de 0,001% das vezes — raro demais para capturar um trace de ponta a ponta de forma confiável, frequente demais para ignorar.

O time vasculhou a telemetria, aumentou as taxas de trace, procurou anomalias de rede. Nada. A única pista que resolveu: o bug só ocorria em instâncias com cache habilitado.

A Arquitetura Que Tornou Isso Possível

O Hyperdrive usa uma topologia dividida:

  • Nó cliente — roda perto do Worker, lida com o cache local e encaminha consultas adiante.
  • Nó endpoint — roda no data center mais próximo do banco de dados de origem e mantém o pool de conexões real.

Ambos os nós usam o cache de CDN local ao seu data center. Quando rodam no mesmo data center, eles compartilham um único cache. Esse compartilhamento é uma vitória de performance — mas carrega uma pré-condição oculta: ambos os nós precisam armazenar exatamente os mesmos bytes para a mesma resposta de consulta.

A Incompatibilidade de Protocolo

O protocolo cliente/servidor do MySQL tem uma Fase de Conexão em que cliente e servidor negociam capabilities — recursos opcionais como CLIENT_DEPRECATE_EOF, que muda como os result sets são terminados:

  • Sem CLIENT_DEPRECATE_EOF: pacote EOF após as definições de coluna, depois outro EOF no final do result set.
  • Com ele: o EOF de metadados é omitido e o EOF final vira um pacote OK.

O Hyperdrive suporta essa capability e a negocia com o servidor MySQL de origem. Mas a biblioteca cliente do outro lado — digamos, mysql2 para Node.js, que atualmente não suporta CLIENT_DEPRECATE_EOF — pode não suportar. Então o Hyperdrive precisa traduzir: reescrever os result sets para inserir pacotes EOF, converter pacotes OK de volta para EOFs, para que os bytes correspondam ao que o driver do Worker espera.

A consequência: os bytes que o Hyperdrive escreve de volta para o cliente estão em um formato diferente dos bytes que ele recebe do servidor de origem.

A Premissa Falha

Quando o cache do Cliente foi implementado para Postgres, o time incorporou uma premissa: você pode armazenar exatamente os bytes que escreveu de volta para o cliente. Para o protocolo wire do Postgres, isso era verdade. Para MySQL, não é.

Agora monte a falha:

  1. O Endpoint atende a consulta Q e armazena os bytes da resposta no formato de capability do Hyperdrive no cache compartilhado.
  2. O Cliente (mesmo data center) recebe uma requisição para Q, encontra a entrada em cache e a serve para o Worker sem traduzir as capabilities — porque os bytes parecem já ter sido escritos para o Worker.
  3. O driver do Worker, que nunca negociou CLIENT_DEPRECATE_EOF, interpreta mal o result set terminado por OK — produzindo resultados vazios, hangs ou dessincronizações de protocolo.

O bug só dispara quando Cliente e Endpoint estão colocalizados e um lê a entrada de cache do outro. É por isso que era 0,001% e invisível para tracing comum. É o equivalente no protocolo wire a um bug de desync — dois componentes mantendo premissas diferentes sobre estado compartilhado. Se você já depurou divergência de estado entre predição do cliente e autoridade do servidor, a forma dessa falha vai parecer familiar; é a mesma classe de problema descrita em corrigindo o problema de replicação de RPC do Unreal Engine que quebra seus estados, só que na camada de banco de dados.

A correção, conceitualmente: armazenar em cache no formato canônico (o que a origem enviou) e traduzir para as capabilities do cliente solicitante no momento da leitura. Nunca armazene bytes pós-tradução quando a tradução depende da negociação por cliente.

Runbook: Detectando Corrupção de Cache no Seu Próprio Stack

Se você roda um pooler ou uma camada de cache na frente do MySQL (ou de qualquer coisa com protocolo wire stateful), veja como aplicar este postmortem.

Detecção

  • Correlacione erros com topologia. O bug só aparecia em instâncias onde Cliente e Endpoint compartilhavam data center e cache. Registre qual nó serviu cada resposta e se ela veio do cache. Se a taxa de erro correlaciona com cache hits, desconfie de incompatibilidade de formato entre quem escreve e quem lê.
  • Caçe erros raros, mas consistentes, não picos. Uma taxa de erro de 0,001% nunca dispara alertas. Configure uma consulta no seu error tracking para ocorrências repetidas da mesma assinatura independentemente do volume. Consistência em baixa taxa é um sinal mais forte de bug lógico do que um pico correlacionado a tráfego.
  • Teste deliberadamente com assimetria de capabilities. Conecte um cliente de teste com CLIENT_DEPRECATE_EOF e outro sem, habilite o cache e martele consultas idênticas. A maioria dos bugs de tradução de protocolo aparece em minutos sob essa assimetria.

Remediação

  • Canonicalize as entradas de cache. Armazene o formato bruto da resposta da origem; traduza na leitura. Essa é a correção correta para qualquer pooler cujos clientes tenham capabilities heterogêneas.
  • Reafirme o estado de sessão após resets. Como mostrado acima, COM_RESET_CONNECTION restaura os padrões do servidor, não suas configurações negociadas.
  • Versione suas chaves de cache. Se você mudar qualquer comportamento de protocolo (capabilities, encoding, enquadramento de resposta), inclua uma versão de formato na chave de cache para que entradas antigas não sejam servidas por código novo no meio de um 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}")

Prevenção

  • Escreva testes de conformidade de protocolo wire contra duas bibliotecas cliente, não apenas uma. A lacuna do CLIENT_DEPRECATE_EOF só ficou visível porque um driver Node.js popular não a suportava.
  • Não presuma nada sobre "os mesmos bytes" entre camadas. Qualquer componente que reescreve mensagens não deve presumir que sua saída é byte-idêntica à sua entrada.
  • Faça load test com cache habilitado desde o primeiro dia. O time do Hyperdrive validou confiabilidade por meses antes do bug específico de cache aparecer — porque a interseção de recursos (pooling × cache × tradução de capabilities) é onde bugs raros vivem.

Cinco Melhores Práticas para Pooling e Cache em Backends de Jogos

  1. Sempre negocie utf8mb4, nunca utf8. Nomes de jogadores, chat e dados de save conterão caracteres de 4 bytes. Depois, reafirme SET NAMES utf8mb4 após cada COM_RESET_CONNECTION, porque o reset restaura os padrões do servidor, não o seu handshake.
  2. Armazene em cache dados canônicos, não dados formatados para o cliente. Se qualquer camada transforma uma resposta antes de enviá-la, os bytes transformados nunca devem entrar em um cache compartilhado. Guarde o formato da origem; traduza por leitor.
  3. Adicione versões de formato às chaves de cache. Uma mudança de uma linha, evita entradas com protocolo obsoleto durante deploys contínuos.
  4. Alerte sobre consistência de erros, não apenas sobre taxa de erros. Uma assinatura recorrente de 0,001% sem correlação com tráfego é um bug lógico. Um pico correlacionado a tráfego é um problema de capacidade. Roteie-os de formas diferentes.
  5. Teste clientes com capabilities incompatíveis. A corretude do seu pooler depende de lidar com todas as bibliotecas cliente com as quais seu time (ou usuários do seu SDK) possa se conectar.

A Lição de Arquitetura de Backend

A lição mais profunda aqui não é sobre MySQL. É sobre sistemas em camadas com negociação por par. No momento em que você insere um middlebox — um pooler, um proxy, um cache — que fala o protocolo dos dois lados, você herda a obrigação de traduzir fielmente e de armazenar em cache apenas formatos universalmente válidos. Postgres funcionou; MySQL quebrou; nada na lógica de cache estava errado, exceto uma premissa herdada de um protocolo diferente.

Para um estúdio de jogos, esse é um argumento forte contra criar manualmente sua própria camada de pooler-mais-cache. O esforço de engenharia para fazer o caminho feliz funcionar é talvez uma semana; o esforço para acertar os caminhos raros — negociação de capabilities, deriva de encoding, colocalização de cache — foi o que levou um time de infraestrutura dedicado a passar dias investigando para caçar um único bug de 0,001%. A própria admissão da Cloudflare vale repetir: a diferença entre beta e GA foi "apenas 20 correções de bugs em um sobretudo".

Se o backend do seu jogo precisa de persistência confiável sem que você mantenha um pooler, um esquema de invalidação de cache e uma camada de tradução de protocolo, é aí que um Backend-as-a-Service mostra seu valor. O horizOn lida com Cloud Save vinculado à conta com leituras e escritas cientes de revisão — seu cliente resolve conflitos, e você pula todo o campo minado de corretude de conexão-pooled-mais-cache para dados de jogadores. Combine isso com leaderboards e configuração remota, e os problemas de latência de consulta discutidos neste post deixam de ser problema seu.

Próximo passo: audite a configuração do seu pooler hoje. Verifique três coisas — o charset negociado, se o estado de sessão é reafirmado após resets de conexão e se o seu cache armazena bytes canônicos ou traduzidos. Se você não conseguir responder às três em dez minutos, há trabalho a fazer — e, se preferir não fazer nada disso, dê uma olhada no horizOn e veja se o Cloud Save e os leaderboards dele cobrem o que sua camada de banco de dados faz hoje manualmente.


Fonte: Dentro do Hyperdrive: Como resolvemos um bug raro de cache MySQL do Hyperdrive