Pular para o conteúdo
Samuel Mattos
Voltar

O join que o MySQL reescreveu sem me avisar

Atualizado em:

Um endpoint de relatório começou a devolver 502. Na véspera tinha subido uma alteração naquela mesma view: um LEFT JOIN novo, para trazer o nome de um tipo de cancelamento. Coincidência boa demais. Abri a branch da alteração antes de abrir o EXPLAIN.

Era o join errado. Ele fazia eq_ref numa chave primária — o acesso mais barato que existe, uma linha por linha da tabela externa. Custo desprezível, e nenhuma relação com o que estava acontecendo. A causa real estava na view havia meses, esperando volume suficiente para aparecer.

O que o EXPLAIN mostrou

A view tem duas CTEs que puxam respostas de formulário. A tabela de respostas é polimórfica: guarda a que entidade cada resposta pertence em duas colunas, o tipo e o id. E o id, por ser polimórfico, é varchar(255).

A CTE só seleciona essa coluna. Quem cruza é a view, lá fora, contra a chave primária da tabela de registros — essa sim bigint unsigned:

-- a CTE devolve owner_id como varchar(255)...
WITH answers AS (
    SELECT fa.owner_id, fa.answers
    FROM form_answers fa
    WHERE fa.owner_type IN ('record', 'customer')
)
-- ...e a view cruza com r.id, que é bigint unsigned
SELECT ...
FROM records r
LEFT JOIN answers a ON a.owner_id = r.id

No plano de execução, a condição não aparecia como escrita. Aparecia assim:

cast(a.owner_id as double) = cast(r.id as double)

Os dois lados. Convertidos. Em toda linha.

Essa é a regra de comparação do MySQL, e não é bug: está escrita no manual. Fora de uma lista curta de casos especiais, os argumentos são comparados como números de ponto flutuante de dupla precisão — e comparação entre string e número cai exatamente aí.

A consequência vem duas frases depois, na mesma página, e é o que eu deveria ter lido antes: “For comparisons of a string column with a number, MySQL cannot use an index on the column to look up the value quickly.” A razão que a documentação dá é que muitas strings distintas convertem para o mesmo número — '1', ' 1' e '1a' viram todas 1. No meu caso a conversão não ficou só do lado da string: a expressão inteira deixou de ser comparação de coluna, e os dois índices saíram de jogo.

Sem índice aplicável ao ON, o resto do plano é consequência. A documentação de hash join descreve o critério: o MySQL usa hash join em toda query cujo join tem condição de igualdade e “in which there are no indexes that can be applied to any join conditions”. O otimizador não escolheu a estratégia cara — foi a única que sobrou.

Os números do plano, em produção:

Métrica do planoValor
query_cost60,3 bilhões
rows_produced na CTE574 bilhões
data_read_per_join543 TB
Estratégia de acessohash join, mais temporary_table e filesort no topo

Do lado de fora do banco, isso era um relatório que levava 16 segundos para responder — quando respondia. Nas requisições em que não respondia, o 502 era o timeout chegando primeiro, e teve cliente que simplesmente não conseguiu extrair o relatório dele.

O suspeito que chegou junto

A alteração da véspera não causou nada — e ainda assim gastei a primeira parte da investigação nela, porque ela tinha a única coisa que eu olhei antes do plano: a data. O acoplamento entre “mudou agora” e “quebrou agora” é forte demais para ser ignorado, e fraco demais para ser evidência. O plano de execução chega em dois minutos e responde sozinho.

O id que parecia não ser numérico

Antes de escrever o fix eu precisava responder uma coisa: a coluna é varchar porque precisa ser, ou só porque foi criada assim?

A primeira olhada na tabela empurrou para a resposta errada. Havia UUID ali, bem visível. Só que o UUID era a chave primária da tabela de respostas — a identidade da própria resposta —, não a coluna do join. É o tipo de detalhe que a leitura rápida junta e a conferência separa.

Contei as linhas: cerca de 14 mil apontando para um tipo de entidade, algumas dezenas para o outro, e zero com conteúdo não numérico. A coluna era varchar por convenção do ORM, que usa o mesmo par de colunas para qualquer relação polimórfica, e não porque algum dono tivesse id textual.

O fix

Uma linha, dentro de cada CTE:

SELECT
    CAST(fa.owner_id AS UNSIGNED) AS owner_id, 
    fa.answers
FROM form_answers fa
WHERE fa.owner_type IN ('record', 'customer')views/relatorio.sql

O ponto não é “converter para o tipo certo” — é onde converter. Aplicado na CTE, o cast materializa a coluna já como bigint unsigned. O join lá fora passa a comparar dois inteiros, sem conversão nenhuma, e volta a usar índice. Aplicado no ON do join, o resultado seria o mesmo desastre com outra roupa: expressão em cima de coluna continua matando o índice.

É seguro porque a CTE já filtra owner_type, e esses dois tipos garantem id numérico. Sem esse filtro, o CAST de uma string não numérica devolveria 0 silenciosamente e o join casaria com o registro errado — trocar timeout por dado errado é um péssimo negócio. O “silenciosamente” é literal: o manual mostra um WHERE c3 = 0 numa coluna varchar retornando todas as linhas da tabela, e avisa que “this occurs even when using strict SQL mode”.

E há um risco maior escondido nessa mesma página, que não me atingiu por sorte de escala. A comparação implícita entre string e inteiro grande é aproximada: o inteiro é convertido para ponto flutuante de dupla precisão antes de comparar, e double não representa todo inteiro de 64 bits. O exemplo é da própria documentação — '9223372036854775807' = 9223372036854775806 devolve 1. Meus ids estão muito longe de 2⁵³, então aqui a conversão só custou tempo. Numa tabela com id perto do teto do bigint, o mesmo cast implícito casa a linha errada e o relatório responde rápido. Ninguém abre EXPLAIN para investigar uma query que está rápida.

Não mexi na coluna. Alterar o tipo resolveria na origem, mas exigiria migração numa tabela grande e amarraria a relação polimórfica a donos de id numérico para sempre. O CAST na leitura resolve o caso real hoje e não fecha porta nenhuma.

O depois

AntesDepois
Tempo de resposta do relatório16 segundos, quando respondiamenos de 2 segundos
Acesso às CTEshash join com cast(... as double)ref via chave automática
Topo do planotemporary_table + filesortsem tabela temporária
query_cost60,3 bilhões39 milhões

Uma linha de CAST, e o cliente voltou a extrair o relatório.

O número que vale é o de cima

Os 16 segundos que viraram 2 são medidos; os 39 milhões de query_cost não significam quase nada em valor absoluto. O residual é ruído do estimador — a tabela derivada não tem estatística de índice, e o plano reporta rows_examined_per_scan=2 ao lado de rows_produced=80M na mesma linha. O que o custo prova é a mudança de estratégia: cast fora, índice dentro. Quem decide se o problema acabou é o relógio.

Como achar isso antes do 502

Enquanto a tabela é pequena, o hash join custa pouco e nada aparece. O problema nasce pronto e espera o volume.

O que eu passei a checar
  1. EXPLAIN FORMAT=JSON em vez de EXPLAIN, e procurar literalmente por cast( na condição do join. É a assinatura, e não aparece no EXPLAIN tradicional.
  2. Comparar o tipo declarado dos dois lados de todo join que atravessa uma coluna polimórfica. Divergência de tipo é dívida silenciosa: o varchar entra por convenção do ORM, não por decisão de ninguém.

Compartilhe este artigo:

Artigo anterior
Engenharia de contexto: transformando incidentes de produção em regras para agentes de IA
Próximo artigo
Testes lentos no WSL2: a suíte Laravel que o fsync travava