Hay un reflejo casi universal cuando una consulta va lenta: crear un índice. Y muchas veces es la respuesta equivocada, porque el índice no puede arreglar una consulta que está pidiendo trabajo innecesario. Una consulta que trae 200 columnas para mostrar 3, que filtra después de agrupar, que envuelve la columna filtrada en una función o que se ejecuta 200 veces desde el bucle de la aplicación no tiene un problema de índices: tiene un problema de escritura, y ningún CREATE INDEX lo va a resolver.

Esta lección es el catálogo de esas técnicas. Primero, ocho reglas de escritura con su antes y su después, varias de las cuales cierran promesas de los módulos 2, 4 y 7. Después, cómo funcionan las estadísticas que alimentan al planificador y qué ocurre cuando se desfasan. Y al final, la parte que más veces salva el día: qué hacer cuando el problema no está en la consulta, empezando por el antipatrón más caro y más frecuente de todos, el N+1. El orden de intervención, que conviene tener grabado: primero se reescribe la consulta, después se indexa, después se cambia la arquitectura, y solo al final se compra hardware. Va de lo barato y reversible a lo caro y permanente.

Contenido

  1. Ocho reglas de escritura
  2. Sargabilidad, la regla que gobierna a las demás
  3. Estadísticas y el planificador
  4. Estadísticas extendidas para columnas correlacionadas
  5. Cuándo el problema no es la consulta: el N+1
  6. Escalones siguientes cuando ya no basta
  7. Tabla de diagnóstico: síntoma → causa probable → qué probar
  8. Errores Comunes y Consejos
  9. Ejercicios
  10. Conclusión

  1. Ocho reglas de escritura

Regla 1: no pidas columnas que no vas a usar

-- ⚠️ INCORRECTA
SELECT * FROM productos AS p WHERE p.categoria_id = 4;

-- ✅ CORRECTA
SELECT p.id, p.nombre, p.precio FROM productos AS p WHERE p.categoria_id = 4;
id nombre precio
14 Infusión de manzanilla ecológica 20 uds 3.25
15 Té verde matcha ceremonial 30 g 22.00
16 Kombucha de jengibre 750 ml 4.95
17 Zumo de naranja prensado en frío 1 L 5.40

Tres motivos, en orden de importancia. Impide el Index Only Scan: si existiera un índice (categoria_id) INCLUDE (nombre, precio), la segunda versión se resolvería sin tocar la tabla, mientras que la primera obliga a ir a buscar las nueve columnas fila a fila. Manda menos datos por la red: una columna TEXT de 4 kB que nadie muestra, por mil filas, son 4 MB tirados. Y es frágil: un SELECT * cambia de forma cuando alguien añade una columna.

Regla 2: filtra lo antes posible — WHERE antes que HAVING

Cierra el hilo abierto en 04-06. Las dos consultas dan lo mismo:

-- ⚠️ INCORRECTA: agrupa los 15 clientes y luego tira 2 grupos
SELECT c.pais, COUNT(*) AS clientes FROM clientes AS c GROUP BY c.pais HAVING c.pais = 'España';

-- ✅ CORRECTA: descarta 4 filas antes de agrupar
SELECT c.pais, COUNT(*) AS clientes FROM clientes AS c WHERE c.pais = 'España' GROUP BY c.pais;
pais clientes
España 11

La diferencia es de orden lógico (módulo 2): WHERE se aplica antes de agrupar, HAVING después. La primera versión construye tres grupos y descarta dos; con quince clientes da igual, con quince millones el HashAggregate procesa el triple de datos para nada. Y además, la condición del WHERE sí puede aprovechar un índice; la del HAVING, nunca.

La regla: HAVING es exclusivamente para condiciones sobre agregados (HAVING COUNT(*) > 1). Si la condición se puede escribir sin agregado, va en el WHERE.

Regla 3: la columna filtrada, siempre desnuda

-- ⚠️ INCORRECTA: EXTRACT sobre la columna anula cualquier índice
SELECT COUNT(*) AS pedidos_2025 FROM pedidos AS pe
WHERE EXTRACT(YEAR FROM pe.fecha_pedido) = 2025;

-- ✅ CORRECTA: rango de fechas, la columna aparece sola
SELECT COUNT(*) AS pedidos_2025 FROM pedidos AS pe
WHERE pe.fecha_pedido >= '2025-01-01' AND pe.fecha_pedido < '2026-01-01';
pedidos_2025
16

Los mismos 16 pedidos de 2025, y la segunda versión puede usar un índice sobre fecha_pedido. Es el ejemplo canónico de la sección 2. Fíjate en el detalle del límite superior: < '2026-01-01', no <= '2025-12-31'. Con un DATE son equivalentes, pero si mañana la columna pasa a TIMESTAMP, el <= perdería todo lo ocurrido el 31 de diciembre después de medianoche. El patrón >= inicio AND < siguiente_inicio es correcto siempre; acostúmbrate a él.

Lo mismo con las conversiones de tipo —WHERE pe.fecha_pedido::text LIKE '2025-03%' convierte la columna, así que anula el índice, y hay que escribirlo como el rango >= '2025-03-01' AND < '2025-04-01'— y con los cálculos: WHERE p.precio * 1.21 > 15 no usa índice; WHERE p.precio > 15 / 1.21 sí, porque el cálculo está del lado de la constante.

Regla 4: LIMIT con ORDER BY sobre columna indexada

SELECT p.id, p.nombre, p.precio
FROM productos AS p
ORDER BY p.precio DESC
LIMIT 5;
id nombre precio
15 Té verde matcha ceremonial 30 g 22.00
6 Crema facial de aloe vera 50 ml 18.90
20 Cápsulas de espirulina 120 uds 16.40
8 Aceite corporal de almendras 200 ml 14.25
13 Velas de cera de soja (pack 2) 13.75

Sin índice sobre precio, el motor tiene que examinar todas las filas para saber cuáles son las cinco mayores. Con muchas filas usa el top-N heapsort que viste en 02-05, que evita ordenarlas todas pero sigue teniendo que leerlas todas. Con un índice sobre precio, lee cinco entradas desde el final del árbol y para: coste independiente del tamaño de la tabla. Es una de las mejores relaciones esfuerzo/beneficio que existen, porque los "top 10" están en todas las pantallas de inicio de todas las aplicaciones.

Regla 5: EXISTS, no COUNT(*) > 0

Retomando 07-03:

-- ⚠️ INCORRECTA: cuenta las 5 líneas del producto 1 para saber que hay alguna
SELECT p.id, p.nombre FROM productos AS p
WHERE (SELECT COUNT(*) FROM lineas_pedido AS lp WHERE lp.producto_id = p.id) > 0;

-- ✅ CORRECTA: se detiene en la primera coincidencia
SELECT p.id, p.nombre FROM productos AS p
WHERE EXISTS (SELECT 1 FROM lineas_pedido AS lp WHERE lp.producto_id = p.id);

Los mismos 17 productos vendidos. COUNT(*) obliga a recorrer todas las líneas de cada producto; EXISTS cortocircuita en la primera. Con 5 líneas la diferencia es nula; con 40.000 ventas de un producto, es leerlas todas frente a leer una. Y hay premio de plan: EXISTS se convierte en un semi-join (07-05), mientras que la subconsulta con COUNT casi nunca se transforma.

Regla 6: UNION ALL cuando no puede haber duplicados

-- ⚠️ INCORRECTA si sabes que los conjuntos son disjuntos
SELECT id, fecha_pedido FROM pedidos WHERE fecha_pedido <  '2026-01-01'
UNION
SELECT id, fecha_pedido FROM pedidos WHERE fecha_pedido >= '2026-01-01';

-- ✅ CORRECTA
SELECT id, fecha_pedido FROM pedidos WHERE fecha_pedido <  '2026-01-01'
UNION ALL
SELECT id, fecha_pedido FROM pedidos WHERE fecha_pedido >= '2026-01-01';

Las mismas 20 filas (16 de 2025 + 4 de 2026), porque un pedido no puede estar a ambos lados de una fecha. Pero UNION elimina duplicados, y eso obliga al motor a ordenar las 20 filas o a construir una tabla hash con todas ellas antes de devolver nada; UNION ALL las concatena y ya está. La regla: UNION ALL por defecto, UNION solo cuando de verdad necesites deduplicar. La deduplicación gratis no existe.

Regla 7: DISTINCT no es un parche para un JOIN mal planteado

Cierra 02-04 y confirma lo dicho en 07-05:

-- ⚠️ INCORRECTA: genera 47 filas y descarta 30
SELECT DISTINCT p.id, p.nombre FROM productos AS p
JOIN lineas_pedido AS lp ON lp.producto_id = p.id;

-- ✅ CORRECTA: devuelve 17 desde el principio
SELECT p.id, p.nombre FROM productos AS p
WHERE EXISTS (SELECT 1 FROM lineas_pedido AS lp WHERE lp.producto_id = p.id);

17 productos en los dos casos, pero el primero produce 47 filas —una por línea de pedido, con el aceite de oliva repetido cinco veces— y luego las deduplica: trabajo hecho para deshacerlo. Un DISTINCT en tu consulta es una señal de diagnóstico: pregúntate qué JOIN está multiplicando filas y si de verdad lo necesitabas. Cuando el DISTINCT es legítimo —"la lista de ciudades donde tenemos clientes o empleados", 11 ciudades de 23 filas— no hay nada que corregir.

Regla 8: paginación por keyset, no por OFFSET grande

Cierra 02-06. Las dos devuelven la página cuarta:

-- ⚠️ INCORRECTA con offsets grandes
SELECT id, fecha_pedido FROM pedidos ORDER BY id LIMIT 5 OFFSET 15;

-- ✅ CORRECTA: recuerda dónde se quedó la página anterior
SELECT id, fecha_pedido FROM pedidos WHERE id > 15 ORDER BY id LIMIT 5;
id fecha_pedido
16 2025-12-19
17 2026-01-13
18 2026-01-27
19 2026-02-09
20 2026-02-21

El problema del OFFSET es que no salta: lee y descarta. Para servir la página 1.000 con 20 elementos, el motor lee 20.000 filas y tira 19.980.

OFFSET Keyset
Coste de la página N Crece linealmente con N Constante
Aprovecha el índice Solo para ordenar Para ordenar y para posicionarse
Saltar a "la página 500" ✅ Directo ❌ Hay que encadenar
Filas duplicadas o perdidas si alguien inserta No
Uso típico Paginadores numerados Scroll infinito, API, exportaciones

Con orden compuesto, el keyset usa la comparación de tuplas —WHERE (fecha_pedido, id) < ('2026-01-13', 17) ORDER BY fecha_pedido DESC, id DESC LIMIT 5—, que es lo que hacen por dentro los cursores de las APIs modernas.

  1. Sargabilidad, la regla que gobierna a las demás

Las reglas 3, 4 y 8 son la misma idea con tres disfraces, y esa idea tiene nombre: sargabilidad. Viene de SARG, Search ARGument.

Una condición es sargable cuando el motor puede traducirla a "sitúate en un punto del índice y avanza". En la práctica: la columna aparece sola a un lado de la comparación, sin funciones, sin cálculos y sin conversiones de tipo.

Condición ¿Sargable? Reescritura sargable
EXTRACT(YEAR FROM fecha_pedido) = 2025 fecha_pedido >= '2025-01-01' AND fecha_pedido < '2026-01-01'
LOWER(email) = '[email protected]' Índice sobre LOWER(email) (08-02), o normalizar al guardar
nombre LIKE '%aceite%' pg_trgm + GIN (08-03)
nombre LIKE 'Aceite%', cantidad BETWEEN 2 AND 5
precio * 1.21 > 15 precio > 15 / 1.21
fecha_pedido::text LIKE '2025%' Rango de fechas
id + 0 = 7 id = 7
estado <> 'entregado' ⚠️ Técnicamente sí, inútil en la práctica estado IN ('pendiente','pagado','enviado','cancelado'), o índice parcial

Interiorizar esta palabra te ahorra memorizar la lista: cada vez que escribas un WHERE, mira si la columna está desnuda. Si no lo está, tienes tres salidas, en este orden de preferencia: reescribir la condición, indexar la expresión, o normalizar el dato al guardarlo (por ejemplo, guardar el email ya en minúsculas y ahorrarte el LOWER para siempre).

  1. Estadísticas y el planificador

El planificador no adivina: estima. Y estima a partir de unas estadísticas que PostgreSQL guarda sobre cada tabla y cada columna: el número de filas y de páginas, la fracción de nulos, el número de valores distintos (la cardinalidad de 08-03), los valores más frecuentes con su frecuencia, y un histograma que reparte el resto en tramos para estimar rangos. Con eso responde a la pregunta clave: "¿cuántas filas devolverá WHERE estado = 'entregado'?". Si estima 14 de 20 (70 %), elige Seq Scan. Si estima 1 de 2.000.000, elige Index Scan. Toda la calidad del plan depende de que esa estimación sea razonable.

SELECT attname       AS columna,
       n_distinct    AS valores_distintos,
       null_frac     AS fraccion_nulos,
       most_common_vals AS mas_frecuentes
FROM pg_stats
WHERE tablename = 'pedidos' AND attname IN ('estado', 'empleado_id');
columna valores_distintos fraccion_nulos mas_frecuentes
estado 5 0 {entregado,enviado,pagado,cancelado,pendiente}
empleado_id 3 0.5 {4,5,6}

Ahí está, en dos filas, lo que el planificador sabe de pedidos: que estado tiene 5 valores y que la mitad de los empleado_id son nulos (los diez pedidos web de 01-06).

Cómo se recogen y cuándo se desfasan

Las recoge ANALYZE tabla; cuando tú lo lanzas, autovacuum cuando una tabla acumula cierto porcentaje de cambios, y VACUUM ANALYZE tabla; a la vez que limpia. El problema aparece cuando se quedan viejas, y hay tres situaciones clásicas:

  1. Justo después de una carga masiva. Insertas 5 millones de filas; hasta que autovacuum pase, el planificador sigue creyendo que la tabla tiene 100 y elegirá Nested Loop sobre millones de filas. Tras cualquier carga grande, lanza ANALYZE a mano.
  2. Después de una migración que cambia la distribución de una columna (05-06).
  3. En tablas con crecimiento rápido, donde el umbral de autovacuum llega tarde.

El síntoma es inconfundible y lo verás en 08-05: una divergencia enorme entre las filas estimadas y las reales en el plan de ejecución. Y cuando el problema no es que estén viejas sino que la muestra se queda corta, se sube el detalle —default_statistics_target, que por omisión vale 100 y controla cuántos valores frecuentes y cuántos tramos de histograma se guardan—:

ANALYZE pedidos;                                                 -- una tabla
ALTER TABLE pedidos ALTER COLUMN estado SET STATISTICS 500;      -- más detalle, solo esa columna
ANALYZE pedidos;                                                 -- imprescindible después

Subirlo mejora las estimaciones en columnas con distribuciones muy desiguales a cambio de un ANALYZE más lento y un planificador algo más lento. Súbelo por columna, nunca de forma global "por si acaso", y solo cuando un plan te haya demostrado que la estimación está mal.

  1. Estadísticas extendidas para columnas correlacionadas

Hay un fallo de estimación que ninguna cantidad de muestreo arregla: el planificador supone que las columnas son independientes, y en el mundo real casi nunca lo son. En TiendaVerde, categoria_id y proveedor_id están claramente relacionadas: la cosmética viene de Maison Nature y de Verde Atlántico, la alimentación de Huerta del Turia y BioSierra.

SELECT COUNT(DISTINCT categoria_id) AS categorias,
       COUNT(DISTINCT proveedor_id) AS proveedores,
       (SELECT COUNT(*) FROM (SELECT DISTINCT categoria_id, proveedor_id FROM productos) AS x)
                                    AS combinaciones_reales
FROM productos;
categorias proveedores combinaciones_reales
6 5 12

Seis por cinco son treinta combinaciones posibles, pero solo existen doce. Ante WHERE categoria_id = 2 AND proveedor_id = 4, el planificador multiplica selectividades como si fueran independientes: 1/6 × 1/5 = 1/30, y estima menos de una fila. La realidad son 3 productos. Con 20 filas es irrelevante; con 20 millones, una subestimación así hace que el motor elija un Nested Loop donde hacía falta un Hash Join, y la consulta pasa de segundos a horas.

La solución son las estadísticas extendidas, disponibles desde PostgreSQL 10, con tres tipos: ndistinct captura el número real de combinaciones distintas (12, no 30); dependencies, las dependencias funcionales ("saber el proveedor casi determina la categoría"); mcv, las combinaciones concretas más frecuentes.

CREATE STATISTICS stat_productos_cat_prov (ndistinct, dependencies)
    ON categoria_id, proveedor_id FROM productos;
ANALYZE productos;

SELECT statistics_name, attnames, kinds FROM pg_stats_ext;
statistics_name attnames kinds
stat_productos_cat_prov {categoria_id,proveedor_id} {d,f}

Los candidatos típicos son los pares que "van juntos": código postal y ciudad, marca y modelo, país y moneda, categoría y proveedor. Créalas cuando un plan te muestre una estimación muy alejada de la realidad, no antes.

  1. Cuándo el problema no es la consulta: el N+1

Y ahora el más caro de todos, que ni siquiera se ve desde la base de datos. El N+1 ocurre cuando la aplicación lanza una consulta para obtener una lista y después una consulta más por cada elemento de esa lista.

1 consulta:   SELECT id, cliente_id, fecha_pedido FROM pedidos;          -- 20 filas
20 consultas: SELECT nombre, apellidos FROM clientes WHERE id = ?;       -- una por pedido
------------------------------------------------------------------------
Total: 21 consultas para pintar una tabla

Contra una consulta única:

SELECT pe.id, pe.fecha_pedido, c.nombre || ' ' || c.apellidos AS cliente
FROM pedidos AS pe JOIN clientes AS c ON c.id = pe.cliente_id ORDER BY pe.id;

Una consulta, 20 filas. El problema no es el tiempo de cada consulta —cada una tarda 0,2 ms— sino el coste fijo que se paga 21 veces: viaje de red de ida y vuelta, análisis sintáctico, planificación, ejecución, transferencia. Y escala fatal: una pantalla de 500 pedidos son 501 consultas.

N+1 (21 consultas) Un JOIN (1 consulta)
Viajes de red y planificaciones 21 1
Tiempo con 1 ms de latencia ~25 ms ~2 ms
Con 500 elementos ~520 ms ~4 ms
Con 500 elementos y 20 ms de latencia ~10 s ~25 ms

Es dificilísimo de detectar mirando la base de datos, porque cada consulta individual es rapidísima: no aparece en el ranking de consultas lentas. Aparece en pg_stat_statements como una consulta con un mean_exec_time mínimo y un número de calls disparatado — otra razón para ordenar por tiempo total. Casi siempre sale de un ORM: un bucle que recorre objetos y accede a una propiedad relacionada, disparando una consulta por vuelta sin que se vea en el código. Todos tienen el remedio (JOIN FETCH en JPA, select_related/prefetch_related en Django, includes en Rails, Include en Entity Framework); el problema es acordarse de usarlo.

Los otros dos problemas de aplicación son primos suyos. La falta de agrupación en lotes: insertar 10.000 filas con 10.000 INSERT en lugar de uno con 10.000 tuplas, o con COPY — mismo mecanismo, coste fijo multiplicado, y la razón por la que el script de 01-06 usa INSERT múltiples. Y traer filas que nunca se muestran: descargar dos millones de pedidos para que la aplicación se quede con 20. Si la pantalla muestra 20, la consulta debe pedir 20, con LIMIT y con el filtro hecho en SQL; el filtrado en memoria de la aplicación es el desperdicio más silencioso que existe.

  1. Escalones siguientes cuando ya no basta

Cuando la consulta ya está bien escrita, los índices están puestos y sigue sin ser suficiente, quedan estos escalones — de menos a más invasivo:

Técnica Cuándo aplica Coste / riesgo
Vistas materializadas Un informe caro que se consulta muchas veces y admite datos de hace unas horas Hay que refrescarlas; los datos no son instantáneos → 10-01
Tablas de resumen Agregados que se consultan constantemente (ventas por día y categoría) Hay que mantenerlas sincronizadas, con triggers (10-05) o un proceso programado
Caché de aplicación Datos que cambian poco y se leen muchísimo (el catálogo, las categorías) Invalidación: el problema difícil de verdad
Particionado Una tabla enorme con un criterio natural de corte, típicamente la fecha Cambia el DDL y las consultas deben filtrar por la clave de partición
Réplicas de lectura Muchas más lecturas que escrituras; informes que compiten con la operación Retraso de replicación: la réplica va unos milisegundos por detrás
Sharding Cuando ni una máquina ni las réplicas bastan Enorme: reparte los datos entre servidores y complica todas las consultas

Los dos últimos merecen una nota. El particionado divide físicamente una tabla en trozos (por ejemplo, uno por año de fecha_pedido) para que una consulta acotada solo lea el que le toca y para poder borrar un año entero sin un DELETE masivo; es una herramienta de administración avanzada y no la trata este curso. El sharding reparte los datos entre varios servidores y es la última carta. Y una advertencia general: estos escalones no arreglan una consulta mal escrita, la esconden. Una vista materializada sobre un SELECT con un DISTINCT innecesario sigue teniendo el DISTINCT innecesario, solo que ahora en el proceso de refresco.

  1. Tabla de diagnóstico: síntoma → causa probable → qué probar

Síntoma Causa probable Qué probar
Una consulta que iba bien se ha vuelto lenta sin cambiar Estadísticas desfasadas tras un crecimiento o una carga ANALYZE tabla; y comparar el plan
Seq Scan sobre una tabla grande con un filtro muy selectivo Falta el índice, o la condición no es sargable Crear el índice; revisar funciones sobre la columna
El índice existe pero no se usa Baja selectividad, condición no sargable, expresión que no coincide, estadísticas malas EXPLAIN (08-05); comprobar cuántas filas devuelve el filtro
Muchas consultas casi idénticas y rapidísimas N+1 desde la aplicación Ordenar pg_stat_statements por calls; usar JOIN o carga anticipada
Lenta solo en la página 300 del listado OFFSET grande Paginación por keyset
Lenta desde que se añadió un ORDER BY Ordenación sin índice, Sort que se va a disco Índice que dé el orden; subir work_mem; revisar LIMIT
El INSERT va lento y antes no Demasiados índices, o índices GIN Revisar pg_stat_user_indexes y borrar los muertos
Estimaciones muy alejadas de las filas reales Columnas correlacionadas, o muestra corta CREATE STATISTICS; subir STATISTICS de la columna
Todo va lento a la vez No es la consulta: memoria, disco, bloqueos, VACUUM pendiente Métricas del sistema; bloqueos → módulo 9
DELETE de una fila que tarda segundos FK sin índice en una tabla hija con CASCADE Índice sobre la columna FK (08-01)

Errores Comunes y Consejos

  • Optimizar antes de medir. Reescribir una consulta legible por una críptica basándote en una intuición te hace perder legibilidad y, casi siempre, no ganar nada. Mide primero (08-05).
  • Poner en el HAVING lo que va en el WHERE. HAVING es solo para condiciones sobre agregados.
  • Usar <= en el límite superior de un rango de fechas. < '2026-01-01' es correcto siempre, incluso si la columna pasa a ser un TIMESTAMP.
  • Añadir DISTINCT para "arreglar" filas repetidas. El DISTINCT oculta el JOIN que sobra en lugar de quitarlo.
  • Usar UNION por costumbre. Deduplicar cuesta una ordenación o una tabla hash completas.
  • Paginar con OFFSET en una API. Además de lento, se salta y repite filas cuando alguien inserta mientras el usuario navega.
  • Olvidar ANALYZE tras una carga masiva. Es la causa número uno de "la misma consulta que ayer volaba, hoy no termina". Y no subas default_statistics_target globalmente para arreglar dos columnas: hazlo por columna.
  • Consejo: cuenta las consultas, no solo los milisegundos. Un contador de consultas por petición detecta un N+1 en cinco minutos; el ranking de consultas lentas nunca lo hará.
  • Consejo: guarda la consulta original comentada cuando la reescribas por rendimiento, con una nota de qué mejoró. Es la documentación más barata que existe.
  • Consejo: aplica las reglas en orden de coste. Reescribir es gratis y reversible; comprar hardware es caro y permanente.

Ejercicios

Ejercicio 1

Reescribe estas cuatro consultas para hacerlas sargables o más eficientes, y explica qué mejora cada cambio.

-- a)
SELECT * FROM pedidos WHERE EXTRACT(MONTH FROM fecha_pedido) = 3
                        AND EXTRACT(YEAR FROM fecha_pedido) = 2025;
-- b)
SELECT c.pais, COUNT(*) FROM clientes c GROUP BY c.pais HAVING c.pais <> 'España';
-- c)
SELECT DISTINCT c.id, c.nombre
FROM clientes c JOIN pedidos pe ON pe.cliente_id = c.id JOIN lineas_pedido lp ON lp.pedido_id = pe.id;
-- d)
SELECT id, nombre FROM productos ORDER BY id LIMIT 5 OFFSET 15;

Ejercicio 2

La pantalla "últimos pedidos" de TiendaVerde muestra 20 pedidos con el nombre del cliente, el del comercial y el número de líneas de cada uno. El equipo informa de que tarda 4 segundos y de que el registro muestra 61 consultas por carga de página.

  1. ¿Qué antipatrón es y de dónde salen exactamente las 61 consultas?
  2. Escribe una sola consulta que devuelva todo lo necesario.
  3. ¿Qué índices ayudarían, sabiendo lo de 08-01 sobre las claves foráneas?

Ejercicio 3

Un informe nocturno que tardaba 20 segundos ha pasado a tardar 40 minutos. No ha cambiado ni el SQL ni los índices. Lo único que ha ocurrido es que anoche se cargaron 12 millones de filas históricas en pedidos.

  1. Formula la hipótesis más probable y explica el mecanismo.
  2. Di qué comprobarías, con la consulta o el comando concretos.
  3. Propón la solución inmediata y la medida preventiva.

Soluciones

Solución 1

-- a) Sargable: un único rango sobre la columna desnuda.
SELECT id, cliente_id, fecha_pedido, estado
FROM pedidos
WHERE fecha_pedido >= '2025-03-01' AND fecha_pedido < '2025-04-01';

-- b) El filtro no usa ningún agregado: va en el WHERE, antes de agrupar
SELECT c.pais, COUNT(*) AS clientes FROM clientes AS c WHERE c.pais <> 'España' GROUP BY c.pais;

-- c) Pregunta de existencia: EXISTS en lugar de dos JOIN y un DISTINCT
SELECT c.id, c.nombre FROM clientes AS c
WHERE EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.cliente_id = c.id);

-- d) Keyset en lugar de OFFSET
SELECT id, nombre FROM productos WHERE id > 15 ORDER BY id LIMIT 5;

En a) se corrigen dos cosas: el SELECT * y las dos llamadas a EXTRACT, que juntas impiden cualquier índice. En b), con tres países la ganancia es simbólica, pero el hábito es el correcto y el WHERE sí puede usar un índice. En c), el segundo JOIN con lineas_pedido no aporta ninguna columna al resultado y multiplica cada cliente por sus líneas: el JOIN+DISTINCT generaría 47 filas para devolver 12 clientes compradores; el EXISTS devuelve 12 directamente y no necesita lineas_pedido en absoluto. En d), con 20 filas da igual; con 2 millones, OFFSET 1000000 lee y descarta un millón de filas.

Solución 2

1. Es un N+1, doblado. Las 61 consultas son: 1 para la lista de pedidos, 20 para el nombre del cliente de cada uno, 20 para el del comercial y 20 para contar las líneas. 1 + 20 + 20 + 20 = 61. Cada una tarda menos de un milisegundo; lo que cuesta 4 segundos son los 61 viajes de ida y vuelta.

2. Una sola consulta. Dos decisiones que no son de rendimiento sino de corrección, y que vienen del módulo 3: LEFT JOIN con empleados, porque diez de los veinte pedidos son web y tienen empleado_id NULL (un INNER JOIN los haría desaparecer), con COALESCE para mostrar "Web"; y LEFT JOIN con lineas_pedido, para que un pedido sin líneas cuente 0 en lugar de desaparecer.

SELECT pe.id, pe.fecha_pedido,
       c.nombre || ' ' || c.apellidos AS cliente,
       COALESCE(e.nombre || ' ' || e.apellidos, 'Web') AS comercial,
       COUNT(lp.id) AS lineas
FROM pedidos            AS pe
JOIN clientes           AS c  ON c.id  = pe.cliente_id
LEFT JOIN empleados     AS e  ON e.id  = pe.empleado_id
LEFT JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY pe.id, pe.fecha_pedido, c.nombre, c.apellidos, e.nombre, e.apellidos
ORDER BY pe.fecha_pedido DESC
LIMIT 20;

3. Índices sobre las claves foráneas implicadas: pedidos(cliente_id), pedidos(empleado_id) —mejor parcial, WHERE empleado_id IS NOT NULL, porque la mitad son nulos— y sobre todo lineas_pedido(pedido_id), que es el que evita recorrer la tabla entera para contar. Y para el ORDER BY ... LIMIT 20, un índice sobre pedidos(fecha_pedido): con él, el motor lee 20 entradas desde el final del árbol y para.

Solución 3

1. La hipótesis: las estadísticas están desfasadas. Con 12 millones de filas nuevas y sin ANALYZE, pg_stats sigue describiendo la tabla anterior. El planificador cree que pedidos es pequeña, estima que un filtro devolverá decenas de filas cuando devolverá cientos de miles, y elige un Nested Loop —perfecto para pocas filas, catastrófico para muchas— en lugar de un Hash Join. La consulta no ha cambiado; el plan sí.

2. Qué comprobar:

SELECT relname, n_live_tup, last_analyze, last_autoanalyze
FROM pg_stat_user_tables WHERE relname = 'pedidos';

Si last_analyze es anterior a la carga y n_live_tup sigue mostrando el recuento viejo, la hipótesis está confirmada. La prueba definitiva es EXPLAIN ANALYZE sobre el informe y comparar rows estimadas con rows reales: una divergencia de varios órdenes de magnitud es la firma exacta de este problema (08-05).

3. La solución y la prevención. Inmediata: ANALYZE pedidos;, que tarda segundos y devuelve el plan bueno. Preventiva: incluir ANALYZE al final de todo proceso de carga masiva, como un paso más del guion, y no confiar en que autovacuum llegue a tiempo: sus umbrales están pensados para el goteo del día a día, no para una carga de 12 millones de filas de golpe.

Conclusión

Ya tienes el catálogo completo de lo que se puede hacer sin tocar un índice:

  • Ocho reglas de escritura: no pidas columnas de más, filtra con WHERE y no con HAVING, deja la columna desnuda en la condición, apoya LIMIT en un ORDER BY indexado, EXISTS en lugar de COUNT(*) > 0, UNION ALL por defecto, DISTINCT solo cuando es legítimo, y keyset en lugar de OFFSET grande.
  • Todas se resumen en una palabra: sargabilidad. Si la columna filtrada aparece sola, el índice puede usarse; si va envuelta en una función, un cálculo o una conversión, no.
  • El planificador estima a partir de las estadísticas, y cuando se desfasan elige planes malos con datos correctos. ANALYZE tras cada carga masiva es obligatorio; default_statistics_target se sube por columna; y las estadísticas extendidas arreglan el supuesto de independencia — en TiendaVerde, 6 × 5 = 30 combinaciones posibles de categoría y proveedor de las que solo existen 12.
  • Muchas veces el problema no está en la consulta: el N+1 convierte una pantalla en 21 o 501 consultas, cada una rapidísima y ninguna sospechosa, y no se ve desde el ranking de consultas lentas.
  • Cuando ya no basta, hay escalones: vistas materializadas (10-01), tablas de resumen, caché, particionado, réplicas de lectura y, en última instancia, sharding. Ninguno arregla una consulta mal escrita: la esconde.

Todo lo que llevas leído en el módulo tiene la misma pega: son reglas. Buenas reglas, pero reglas al fin y al cabo, y en rendimiento las reglas se equivocan. ¿De verdad está usando ese índice tu consulta? ¿De verdad el EXISTS se convierte en un semi-join, como prometió 07-05? ¿De verdad las estadísticas están mal? En la lección 08-05, Análisis del rendimiento de consultas, dejas de suponer: EXPLAIN y EXPLAIN ANALYZE, cómo se lee un plan de ejecución nodo a nodo, qué significan cost, rows, width, actual time y loops, la señal que delata unas estadísticas desfasadas, y una demostración de verdad —sobre una tabla de dos millones de filas construida para la ocasión— de lo que cambia un índice.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados