Trilha 12 · Bancos de dados

JOINs: juntando tabelas

A normalização espalhou os dados por várias tabelas de propósito. O JOIN é como remontá-los na hora da consulta — e é a operação em que a maioria dos bugs sutis de relatório nasce. Não porque a sintaxe seja difícil, mas porque é fácil não perceber quais linhas você acabou de descartar, ou multiplicar.

① Intuição

A pergunta é o que fazer com quem não tem par

Imagine duas listas: os clientes cadastrados e os pedidos feitos. Juntá-las é fácil enquanto todo pedido tem um cliente e todo cliente tem um pedido. A questão interessante são as sobras: o cliente que nunca comprou, e o pedido cujo cliente foi apagado.

É só isso que diferencia os tipos de JOIN. INNER descarta as duas sobras. LEFT preserva as sobras da esquerda, preenchendo com NULL. RIGHT preserva as da direita. FULL preserva as duas. E CROSS nem tenta casar: combina todo mundo com todo mundo.

A pergunta que evita a maior parte dos erros: "quero mesmo perder as linhas que não têm correspondência?". Um INNER JOIN entre pedidos e cupons de desconto silenciosamente joga fora todos os pedidos sem cupom — e o faturamento do relatório fica menor sem que nada acuse erro. O resultado não parece quebrado; parece só... menor. É o tipo de bug que sobrevive meses.
② Visualização interativa

Os cinco tipos, e como o banco os executa

Na primeira aba, troque o tipo de JOIN e veja quais linhas de cada tabela sobrevivem e onde aparecem NULLs — repare no cliente sem pedidos e no pedido órfão. Na segunda, compare o custo dos três algoritmos de execução conforme as tabelas crescem.

A normalização espalhou os dados por várias tabelas. O JOIN é como remontá-los — e o tipo de join decide o que acontece com as linhas que não encontram par.

clientes (esquerda)
idnome
1Ana
2Beto
3Carla
4Davi
pedidos (direita)
idcliente_idvalor
1011250
102190
1033400
104970
SELECT c.nome, p.id, p.valor
FROM clientes c
INNER JOIN pedidos p ON p.cliente_id = c.id
resultado — 3 linha(s)
c.nomep.idp.valor
Ana101250
Ana10290
Carla103400
Só as combinações que casam dos DOIS lados. Beto e Davi somem (não têm pedidos) e o pedido 104 também (aponta para um cliente inexistente).
③ Explicação técnica

Os tipos, e a pegadinha do LEFT JOIN

-- INNER JOIN: só o que casa dos dois lados (o padrão)
SELECT c.nome, p.valor
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.id;

-- LEFT JOIN: tudo da esquerda; NULL onde não casou
SELECT c.nome, p.valor
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id;

-- O truque do "anti-join": quem NÃO tem correspondência.
-- LEFT JOIN + IS NULL responde "clientes que nunca compraram".
SELECT c.nome
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
WHERE p.id IS NULL;

-- ⚠ A pegadinha mais comum com LEFT JOIN:
-- pôr a condição da tabela da direita no WHERE em vez do ON.

-- ✗ Isto vira um INNER JOIN disfarçado: as linhas sem pedido
--   têm p.valor = NULL, e "NULL > 100" nunca é verdadeiro.
FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id
WHERE p.valor > 100

-- ✓ Assim as linhas sem pedido sobrevivem, com NULL:
FROM clientes c LEFT JOIN pedidos p
     ON p.cliente_id = c.id AND p.valor > 100

-- Regra: no LEFT JOIN, filtro sobre a tabela da DIREITA vai no ON.
-- No WHERE, ele descarta as linhas que o LEFT JOIN preservou.

Como o banco executa um JOIN

-- O SQL diz O QUE. O otimizador escolhe COMO.
-- Para o mesmo JOIN, há três estratégias principais:

-- 1. NESTED LOOP — O(n × m)
--    para cada linha da esquerda, varre a direita
--    Bom quando: uma tabela é pequena, OU existe índice do lado
--    interno (aí cada varredura vira uma busca: O(n · log m))

-- 2. HASH JOIN — O(n + m)
--    fase 1 (build):  hash table da tabela MENOR, na memória
--    fase 2 (probe):  varre a maior, consultando a hash
--    Bom quando: tabelas grandes, join por IGUALDADE, sem índice útil
--    Limite: a tabela menor precisa caber na memória de trabalho.
--    Se não couber, o banco particiona em disco (grace hash join)
--    e o custo dispara — é o que se vê como "spill to disk" no EXPLAIN.

-- 3. MERGE JOIN — O(n + m), se já ordenados
--    dois ponteiros avançando em paralelo, como no merge sort
--    Bom quando: as entradas já vêm ordenadas (de um índice)
--    Usa pouquíssima memória e funciona com <, >, não só com =

-- Você não escolhe. Mas vê a escolha no plano:
EXPLAIN ANALYZE
SELECT ... FROM a JOIN b ON ...;
--   Hash Join  (cost=... rows=... actual time=...)
--     -> Seq Scan on b
--     -> Hash
--          -> Seq Scan on a
Por que a ordem do JOIN importa (e por que você não deve se preocupar com ela). Juntar cinco tabelas admite dezenas de ordens possíveis, e a diferença entre a melhor e a pior pode ser de horas. O otimizador escolhe usando estatísticas — histogramas sobre a distribuição dos valores, atualizados pelo ANALYZE. Quando uma consulta fica misteriosamente lenta depois de uma carga grande de dados, a causa mais comum não é o SQL: são estatísticas desatualizadas levando o otimizador a estimar mil linhas onde há dez milhões.

O problema N+1

# O problema N+1: o antipadrão mais caro que existe em ORMs.

# ✗ 1 consulta para os pedidos + 1 POR PEDIDO para o cliente.
#   Com 1000 pedidos, são 1001 idas ao banco. Cada uma paga a
#   latência de rede inteira — o tempo total é dominado por ela,
#   não pelo trabalho do banco.
pedidos = Pedido.objects.all()          # 1 consulta
for p in pedidos:
    print(p.cliente.nome)               # +1 consulta CADA

# ✓ Uma consulta só, com JOIN, resolvida pelo banco:
pedidos = Pedido.objects.select_related("cliente")
for p in pedidos:
    print(p.cliente.nome)               # zero consultas extras

# Equivalentes: prefetch_related (Django, para muitos-para-muitos),
# joinedload (SQLAlchemy), include (Prisma), with (Laravel).
#
# O sintoma clássico: a página está rápida em desenvolvimento
# (banco local, latência ~0) e lentíssima em produção (banco em
# outra máquina, 2 ms por ida = 2 segundos em 1000 pedidos).
# Ligue o log de SQL do ORM em desenvolvimento e conte as consultas.

Quando o JOIN multiplica linhas

-- A armadilha silenciosa: JOIN que MULTIPLICA linhas.

-- Um pedido com 3 itens. Este JOIN devolve 3 linhas por pedido:
SELECT p.id, p.frete, i.produto
FROM pedidos p JOIN itens i ON i.pedido_id = p.id;
--   1 | 20 | Teclado
--   1 | 20 | Mouse      ← o frete de 20 aparece 3 vezes
--   1 | 20 | Monitor

-- ✗ Então SOMAR o frete aqui conta 3 vezes o mesmo valor:
SELECT SUM(p.frete) FROM pedidos p JOIN itens i ...  -- ERRADO

-- ✓ Agregue ANTES de juntar (numa CTE), e junte os resultados:
WITH por_pedido AS (
    SELECT pedido_id, SUM(preco) AS total_itens
    FROM itens GROUP BY pedido_id
)
SELECT SUM(p.frete), SUM(t.total_itens)
FROM pedidos p JOIN por_pedido t ON t.pedido_id = p.id;

-- Sintoma para reconhecer em produção: um total que cresce
-- quando você adiciona um JOIN que "só ia trazer uma coluna".
-- Regra: antes de agregar, pergunte qual é a granularidade
-- de UMA linha do resultado. Se não for o que você acha, some errado.
④ Projeto para programar

Juntando de verdade

Mini projeto: volte ao banco da biblioteca da lição 12.1 e responda só com JOINs: quais livros estão emprestados agora e para quem; quais leitores nunca pegaram nada (anti-join); quantos exemplares cada livro tem; e quais livros nunca foram emprestados. Confira cada resultado contando à mão numa base pequena — especialmente os anti-joins.

Projeto principal: reproduza o problema N+1 e meça-o. Crie 1000 pedidos com clientes num SQLite ou Postgres local, escreva a versão em laço e a versão com JOIN, e cronometre as duas. Depois repita com o banco em outra máquina, ou simule latência (num Postgres local, um proxy simples que atrase 2 ms por consulta já basta). O objetivo é ver com os próprios olhos que a diferença não é de 10%, é de duas ordens de grandeza — e que ela só aparece quando há latência.

Desafio extra: implemente os três algoritmos de join em Python sobre listas de dicionários — nested loop, hash join e merge join — e meça-os com tabelas de 10 mil e 100 mil linhas. Confirme na prática o que a teoria diz: o nested loop cresce quadraticamente, o hash join ganha em tabelas grandes, e o merge join só compensa se os dados já vierem ordenados (inclua o custo da ordenação e veja o resultado mudar).

⑤ Exercícios rápidos

Teste sua intuição

Qual é a diferença entre INNER JOIN e LEFT JOIN?
Como responder "quais clientes nunca fizeram um pedido?"
Num LEFT JOIN, o que acontece se você colocar uma condição sobre a tabela da direita no WHERE em vez do ON?
Como funciona um hash join?
⑥ Aplicações no mundo real

Onde você encontra isso

🐌

O N+1 que derruba APIs

É provavelmente a causa nº 1 de endpoints lentos em aplicações com ORM. GraphQL amplifica o problema — cada campo aninhado pode virar uma consulta —, e é exatamente por isso que existe o padrão DataLoader: ele acumula os ids pedidos dentro de um mesmo tick do event loop e faz uma consulta só, em lote. É o mesmo remédio do select_related, num lugar diferente.

🏗️

Star schema em data warehouses

Bancos analíticos organizam os dados numa tabela de fatos gigante (vendas) cercada por tabelas de dimensão pequenas (produto, loja, tempo, cliente). Toda consulta é um JOIN da fato com algumas dimensões — e os motores são otimizados justamente para esse formato, com técnicas como star join e filtros bitmap que descartam a maior parte da tabela de fatos antes de lê-la.

🔎

EXPLAIN: a ferramenta que separa palpite de diagnóstico

Todo banco tem um comando que mostra o plano de execução escolhido: EXPLAIN ANALYZE no PostgreSQL, EXPLAIN FORMAT=JSON no MySQL. É ali que se vê qual algoritmo de join foi usado, quantas linhas o otimizador estimou versus quantas realmente apareceram, e se alguma etapa vazou para disco. Uma estimativa muito distante do real é quase sempre a explicação de um plano ruim.

← Anterior: SQL — consultando dados Próxima: Índices e B-trees →