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 plano | Valor |
|---|---|
query_cost | 60,3 bilhões |
rows_produced na CTE | 574 bilhões |
data_read_per_join | 543 TB |
| Estratégia de acesso | hash 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.
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
| Antes | Depois | |
|---|---|---|
| Tempo de resposta do relatório | 16 segundos, quando respondia | menos de 2 segundos |
| Acesso às CTEs | hash join com cast(... as double) | ref via chave automática |
| Topo do plano | temporary_table + filesort | sem tabela temporária |
query_cost | 60,3 bilhões | 39 milhões |
Uma linha de CAST, e o cliente voltou a extrair o relatório.
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.
EXPLAIN FORMAT=JSONem vez deEXPLAIN, e procurar literalmente porcast(na condição do join. É a assinatura, e não aparece noEXPLAINtradicional.- Comparar o tipo declarado dos dois lados de todo join que atravessa uma
coluna polimórfica. Divergência de tipo é dívida silenciosa: o
varcharentra por convenção do ORM, não por decisão de ninguém.