Explosão de Join Fan-Out
Erros de cardinalidade de join muitos-para-muitos multiplicam linhas antes do GROUP BY ou SUM, inflando métricas e ocultando totais reais.
Receita
Cartão de receita de referência rápida - pronto para copiar e colar.
-- ERRADO: tags expandem os itens da linha
SELECT o.order_id, SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN order_tags ot ON ot.order_id = o.order_id
GROUP BY o.order_id;
-- CERTO: agrega fatos antes de dimensões opcionais
SELECT o.order_id, agg.revenue
FROM orders o
JOIN (
SELECT order_id, SUM(quantity * unit_price) AS revenue
FROM order_items
GROUP BY order_id
) agg ON agg.order_id = o.order_id
LEFT JOIN order_tags ot ON ot.order_id = o.order_id;Quando usar isso:
- Totais do painel não concordam com o provedor de pagamento ou tabela de faturas.
EXPLAINmostra contagens de linhas explodindo após adicionar um novo join.- ORM carrega antecipadamente múltiplas associações
has_manyem uma única consulta.
Exemplo de Trabalho
BEGIN;
CREATE TABLE orders (order_id bigint PRIMARY KEY);
CREATE TABLE order_items (
order_id bigint REFERENCES orders (order_id),
product_id bigint,
quantity integer,
unit_price numeric(12,2),
PRIMARY KEY (order_id, product_id)
);
CREATE TABLE order_tags (
order_id bigint REFERENCES orders (order_id),
tag text,
PRIMARY KEY (order_id, tag)
);
INSERT INTO orders VALUES (1);
INSERT INTO order_items VALUES (1, 10, 2, 50.00), (1, 20, 1, 30.00);
INSERT INTO order_tags VALUES (1, 'gift'), (1, 'rush'), (1, 'vip');
-- Receita real: 2*50 + 1*30 = 130
SELECT SUM(quantity * unit_price) FROM order_items WHERE order_id = 1;
-- Resposta errada do fan-out: 130 * 3 tags = 390
SELECT SUM(oi.quantity * oi.unit_price)
FROM order_items oi
JOIN order_tags ot ON ot.order_id = oi.order_id
WHERE oi.order_id = 1;
COMMIT;O que isso demonstra:
- Três tags multiplicam dois itens de linha em seis linhas unidas.
SUMsemGROUP BYna junção inflada conta incorretamente pela cardinalidade da tag.- A subconsulta de agregação preserva o grão correto do fato.
Mergulho Profundo
Como Funciona
- O Join multiplica linhas quando ambos os lados têm múltiplas correspondências por chave de join (M:N ou 1:N dual).
GROUP BYapós o fan-out ainda pode estar errado se colunas não agregadas vierem do lado do fan-out incorretamente.COUNT(*)após o fan-out conta as linhas unidas, não entidades de negócios distintas.- ORMs que geram cadeias
LEFT JOINsão uma fonte comum de inflação silenciosa.
Diagnóstico de Cardinalidade
SELECT
(SELECT COUNT(*) FROM order_items WHERE order_id = 1) AS items,
(SELECT COUNT(*) FROM order_tags WHERE order_id = 1) AS tags,
(SELECT COUNT(*) FROM order_items oi JOIN order_tags ot ON ot.order_id = oi.order_id WHERE oi.order_id = 1) AS joined_rows;Se joined_rows = items * tags, você tem fan-out.
Padrões Seguros
| Objetivo | Padrão |
|---|---|
| Receita do pedido | Agrega order_items agrupados por order_id primeiro |
| Lista de tags distintas | string_agg(DISTINCT tag, ',') após o grão correto |
| Filtrar pedidos por tag | EXISTS (SELECT 1 FROM order_tags ...) |
| Contar pedidos com tag | COUNT(DISTINCT o.order_id) com joins cuidadosos |
Armadilhas
- Joins 1:N encadeados - pedidos -> itens -> remessas multiplica itens por remessas por pedido. Correção: agregue no menor grão de fato primeiro.
DISTINCTcomo curativo -SELECT DISTINCTesconde duplicatas, mas quebra somas. Correção: corrija o grão, não useDISTINCTna consulta errada.- Média em fan-out -
AVG(rating)ponderado incorretamente quando avaliações duplicam por item de linha. Correção: subconsulta por entidade. - Auto-joins de ferramenta de BI - camada semântica cria M:N silenciosamente. Correção: defina o grão da tabela de fatos no modelo semântico.
- Tabela de junção ausente - M:N modelado como FKs duplicados. Correção: normalize o esquema; veja Modelagem de Entidade-Relacionamento.
Alternativas
| Alternativa | Usar Quando | Não Usar Quando |
|---|---|---|
| Agregação de subconsulta | Relatórios OLTP, poucas métricas | Muitas dimensões - use data warehouse |
Filtro EXISTS | Apenas filtros de tag/status | Precisa de colunas de tag no SELECT |
| View materializada no grão do pedido | Painel repetido | Precisa de itens de linha em tempo real |
| Funções de janela | Alocação em nível de linha | Totais simples - subconsulta é mais clara |
FAQs
Como identificar fan-out em EXPLAIN?
Compare as linhas reais de um join de loop aninhado ou hash com a contagem de fatos esperada. Um multiplicador de centenas em uma pequena amostra de order_id é um sinal de alerta.
GROUP BY order_id sempre conserta isso?
Apenas se todas as colunas não agregadas selecionadas forem funcionalmente dependentes de order_id e os agregados usarem o grão correto. Colunas de fan-out no SELECT ainda podem duplicar grupos incorretamente.
E sobre COUNT(DISTINCT)?
Funciona para contar entidades às vezes, mas SUM/AVG ainda estão errados em fan-out. Corrija o grão primeiro.
Como ORMs causam isso?
Incluir múltiplos includes em associações has_many gera um join gigante. Use consultas separadas ou uma estratégia de preload cuidadosa.
Fan-out pode afetar DELETE?
Sim - DELETE ... USING join pode excluir linhas de fatos várias vezes ou perder linhas. Use chaves de subconsulta.
O join LATERAL é mais seguro?
Pode ser, se a subconsulta LATERAL retornar uma linha por pai no grão correto. Ainda requer análise de cardinalidade.
Como testar em CI?
Use fixtures com itens de linha e tags conhecidos; afirme que a consulta de receita é igual a 130 e não 390.
A modelagem de data warehouse previne isso?
Esquemas estrela definem o grão de fato explicitamente - ainda é possível juntar incorretamente tabelas de ponte se o grão for ignorado.
O que é o grão de fato?
A menor unidade que uma medida resume - linha de pedido, não linha de pedido multiplicada por tag.
Quando o fan-out é intencional?
Raramente em agregações. A explosão de linhas para algoritmos de alocação requer uma etapa de normalização explícita dividindo as medidas.
Relacionados
- Modelagem de Entidade-Relacionamento - Tabelas de junção M:N
- Noções Básicas de Cenários de Defeito - Padrões de incidentes
- Noções Básicas de EXPLAIN - Lendo contagens de linhas
- Noções Básicas de Normalização - Decomposição
Versões da Pilha: Esta página foi escrita para PostgreSQL 18.4 (estável 18, manutenção 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x e PostGIS 3.5+.