Explosión de Join por Fan-Out
Los errores de cardinalidad en joins de muchos a muchos multiplican las filas antes de GROUP BY o SUM, inflando las métricas y ocultando los totales reales.
Receta
Tarjeta de referencia rápida - lista para copiar y pegar.
-- INCORRECTO: las etiquetas multiplican las líneas de pedido
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;
-- CORRECTO: agregar hechos antes de dimensiones opcionales
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;Cuándo usar esto:
- Los totales del panel no coinciden con el proveedor de pagos o la tabla de facturas.
EXPLAINmuestra un recuento de filas que explota después de agregar un nuevo join.- El ORM carga previamente múltiples asociaciones
has_manyen una sola consulta.
Ejemplo de Trabajo
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');
-- Ingresos reales: 2*50 + 1*30 = 130
SELECT SUM(quantity * unit_price) FROM order_items WHERE order_id = 1;
-- Respuesta incorrecta por fan-out: 130 * 3 etiquetas = 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;Lo que esto demuestra:
- Tres etiquetas multiplican dos líneas de pedido en seis filas unidas.
SUMsinGROUP BYsobre el join inflado cuenta en exceso según la cardinalidad de la etiqueta.- La agregación en subconsulta preserva el grano correcto del hecho.
Análisis Profundo
Cómo Funciona
- Un join multiplica las filas cuando ambos lados tienen múltiples coincidencias por clave de join (M:N o 1:N dual).
GROUP BYdespués del fan-out aún puede ser incorrecto si las columnas no agregadas provienen del lado del fan-out de forma incorrecta.COUNT(*)después del fan-out cuenta las filas unidas, no las entidades de negocio distintas.- Los ORM que generan cadenas de
LEFT JOINson una fuente común de inflación silenciosa.
Diagnóstico de Cardinalidad
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;Si joined_rows = items * tags, tienes fan-out.
Patrones Seguros
| Objetivo | Patrón |
|---|---|
| Ingresos del pedido | Agregar order_items agrupados por order_id primero |
| Lista de etiquetas distinta | string_agg(DISTINCT tag, ',') después del grano correcto |
| Filtrar pedidos por etiqueta | EXISTS (SELECT 1 FROM order_tags ...) |
| Contar pedidos con etiqueta | COUNT(DISTINCT o.order_id) con joins cuidadosos |
Trampas
- Joins 1:N encadenados - pedidos -> artículos -> envíos multiplica los artículos por los envíos por pedido. Solución: agregar primero en el grano de hecho más bajo.
DISTINCTcomo tirita -SELECT DISTINCToculta duplicados pero rompe las sumas. Solución: arreglar el grano, no usarDISTINCTen la consulta incorrecta.- Promedio en fan-out -
AVG(rating)ponderado incorrectamente cuando las reseñas se duplican por línea de artículo. Solución: subconsulta por entidad. - Auto-joins de herramientas de BI - la capa semántica crea M:N silenciosamente. Solución: definir el grano de la tabla de hechos en el modelo semántico.
- Tabla de unión faltante - M:N modelado como FK duplicados. Solución: normalizar el esquema; ver Modelado de Entidad-Relación.
Alternativas
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
| Agregación con subconsulta | Informes OLTP, pocas métricas | Muchas dimensiones - usar almacén de datos |
Filtro EXISTS | Solo filtros de etiquetas/estado | Necesitar columnas de etiqueta en SELECT |
| Vista materializada en grano de pedido | Panel repetido | Necesitar artículos en tiempo real |
| Funciones de ventana | Asignación a nivel de fila | Totales simples - la subconsulta es más clara |
Preguntas Frecuentes
¿Cómo detecto el fan-out en EXPLAIN?
Compara las filas reales de un join de bucle anidado o hash con el recuento de hechos esperado. Un multiplicador de cientos en una pequeña muestra de order_id es una señal de alerta.
¿GROUP BY order_id siempre lo arregla?
Solo si todas las columnas no agregadas seleccionadas dependen funcionalmente de order_id y los agregados usan el grano correcto. Las columnas de fan-out en SELECT aún pueden duplicar grupos incorrectamente.
¿Qué pasa con COUNT(DISTINCT)?
Funciona para contar entidades a veces, pero SUM/AVG siguen siendo incorrectos en fan-out. Arregla el grano primero.
¿Cómo causan esto los ORM?
Incluir múltiples includes en asociaciones has_many genera un join gigante. Usa consultas separadas o una estrategia de preload cuidadosa.
¿Puede el fan-out afectar DELETE?
Sí - DELETE ... USING join puede eliminar filas de hechos varias veces o perder filas. Usa claves de subconsulta.
¿Es el join LATERAL más seguro?
Puede serlo, si la subconsulta LATERAL devuelve una fila por padre en el grano correcto. Aún requiere análisis de cardinalidad.
¿Cómo pruebo en CI?
Usa datos de prueba con artículos y etiquetas conocidos; verifica que la consulta de ingresos sea 130 y no 390.
¿El modelado de almacén de datos previene esto?
Los esquemas de estrella definen explícitamente el grano del hecho; aún es posible unir incorrectamente tablas puente si se ignora el grano.
¿Qué es el grano del hecho?
La unidad más pequeña que resume una medida: línea de pedido, no línea de pedido por etiqueta.
¿Cuándo es intencional el fan-out?
Raramente en agregados. La explosión de filas para algoritmos de asignación necesita un paso de normalización explícito que divida las medidas.
Relacionados
- Modelado de Entidad-Relación - Tablas de unión M:N
- Conceptos Básicos de Escenarios de Defectos - Patrones de incidentes
- Conceptos Básicos de EXPLAIN - Lectura de recuentos de filas
- Conceptos Básicos de Normalización - Descomposición
Versiones de Stack: Esta página fue escrita para PostgreSQL 18.4 (estable 18, mantenimiento 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, y PostGIS 3.5+.