Todo lo que llevas leído en el módulo son reglas, y en rendimiento las reglas se equivocan. ¿De verdad usa el índice esa consulta? ¿De verdad IN (SELECT ...) se convierte en un semi-join, como prometió 07-05? ¿De verdad el problema son las estadísticas? Hay una sola forma honesta de responder, y es la herramienta que convierte este módulo entero en un método: EXPLAIN. Aquí aprenderás a leer un plan de ejecución nodo a nodo, a distinguir el plan estimado del real, a reconocer la señal que delata unas estadísticas desfasadas, y a interpretar los quince nodos que aparecen en el 95 % de los planes. Y harás la demostración central del módulo: construirás una tabla de pruebas de dos millones de filas —porque con los 20 productos de TiendaVerde no hay nada que demostrar— y verás con tus ojos la diferencia entre recorrer dos millones de filas y saltar directamente a diez.
Contenido
EXPLAINyEXPLAIN ANALYZE- Cómo se lee un plan
cost,rows,width,actual timeyloops- La señal más útil: estimado frente a real
- Los nodos que más vas a ver
- Opciones útiles de
EXPLAIN - El banco de pruebas: dos millones de filas
- La demostración: sin índice y con índice
- Y en TiendaVerde real: por qué el índice se ignora
- Verificando la promesa de 07-05
- Medir en otros motores y herramientas de visualización
- Mantenimiento:
VACUUM,ANALYZEy el bloat - Un método en seis pasos
- Errores Comunes y Consejos
- Ejercicios
- Conclusión del módulo
EXPLAIN y EXPLAIN ANALYZE
EXPLAIN y EXPLAIN ANALYZEEXPLAIN SELECT ...; -- el plan que el motor PIENSA ejecutar: solo estimaciones
EXPLAIN ANALYZE SELECT ...; -- EJECUTA la consulta: estimaciones Y medidas realesEl primero cuesta microsegundos y te dice qué camino elegiría; el segundo cuesta lo que cueste la consulta y te dice qué pasó de verdad.
⚠️ El aviso imprescindible.
EXPLAIN ANALYZEejecuta la consulta de verdad. Con unSELECTes inofensivo; con unUPDATE, unDELETEo unINSERT, modifica los datos. La forma segura de analizarlos es envolverlos en una transacción que se deshace:
El plan se muestra, las filas se borran… y el ROLLBACK lo deshace todo. BEGIN, COMMIT y ROLLBACK son el módulo 9; aquí basta con usar este patrón como una precaución obligatoria. Es la misma red de seguridad que ya viste en 05-04 antes de un DELETE sin WHERE.
- Cómo se lee un plan
Un plan es un árbol de nodos: cada uno recibe filas de sus hijos, hace algo con ellas y se las pasa a su padre. La salida de texto lo representa con sangría y flechas ->, y se lee de una forma concreta:
De dentro hacia fuera y de abajo hacia arriba. El nodo más sangrado se ejecuta primero; el de la primera línea, el último, y es el que produce el resultado final.
flowchart BT
A["<b>Seq Scan</b> on clientes<br/><i>lee 15 filas</i>"] --> C["<b>Hash Join</b><br/><i>empareja por cliente_id</i>"]
B["<b>Seq Scan</b> on pedidos<br/><i>lee 20 filas</i>"] --> H["<b>Hash</b><br/><i>tabla hash de pedidos</i>"]
H --> C
C --> S["<b>Sort</b><br/><i>ordena por fecha</i>"]
S --> L(["<b>Limit</b><br/><i>devuelve 10: resultado</i>"])
En texto, ese mismo árbol se imprime al revés: Limit arriba del todo, los dos Seq Scan al fondo. Las dos preguntas que responde la forma del árbol son siempre las mismas: ¿por dónde entra el motor a los datos? (los nodos de las hojas) y ¿cómo los combina? (los nodos de unión).
cost, rows, width, actual time y loops
cost, rows, width, actual time y loopsCada línea de un plan lleva sus números. Con EXPLAIN a secas solo hay estimaciones; con ANALYZE se añade la segunda mitad entre paréntesis:
| Campo | Qué significa |
|---|---|
cost=0.00..1.25 |
Coste de arranque .. coste total, en unidades arbitrarias del planificador (1,0 = leer una página secuencialmente). El de arranque es lo que cuesta antes de emitir la primera fila |
rows=7 (estimado) |
Filas que el planificador estima que saldrán de este nodo |
width=45 |
Ancho medio estimado de cada fila, en bytes |
actual time=0.012..0.016 |
Milisegundos hasta la primera fila .. hasta la última. Por ejecución |
rows=7 (real) |
Filas que salieron de verdad, en promedio por ejecución |
loops=1 |
Cuántas veces se ejecutó este nodo |
El coste de arranque distingue dos familias de nodos: los que emiten filas según las leen (Seq Scan, Index Scan, Nested Loop) tienen arranque ≈ 0; los que necesitan todas las filas antes de devolver la primera (Sort, Hash, HashAggregate) lo tienen alto. Eso importa mucho con LIMIT.
La trampa de
loops.actual timeyrowsson medias por ejecución, no totales. Un nodo conactual time=0.05..0.08 rows=3 loops=20000no tardó 0,08 ms: tardó 0,08 × 20.000 = 1,6 segundos, y produjo 60.000 filas. Multiplica siempre porloopsantes de decidir dónde está el problema.
Al final del plan aparecen Planning Time (lo que tardó en decidir el plan) y Execution Time (lo que tardó en ejecutarlo). Si son comparables, tienes una consulta trivial ejecutada muchísimas veces: el caso de las sentencias preparadas y, a menudo, del N+1 de 08-04.
- La señal más útil: estimado frente a real
Si solo te llevas una cosa de esta lección, que sea esta: compara
rows=estimadas conrows=reales en cada nodo. Una divergencia grande —de un orden de magnitud o más— es la causa raíz de casi todos los planes malos.
-> Seq Scan on pedidos (cost=0.00..41250.00 rows=42 width=25)
(actual time=0.03..1893.44 rows=284561 loops=1)El planificador esperaba 42 filas y salieron 284.561. Con 42 filas, encadenar un Nested Loop es la decisión perfecta; con 284.561, es una catástrofe. El plan no está mal elegido: está bien elegido para una realidad que no existe.
Las cuatro causas, en orden de frecuencia: estadísticas desfasadas tras una carga o un crecimiento, que se arreglan con ANALYZE tabla;; columnas correlacionadas que el planificador supone independientes, con CREATE STATISTICS; un predicado que no sabe estimar (expresión compleja, función propia), que hay que reescribir o indexar como expresión; y una muestra demasiado pequeña para una distribución muy desigual, con ALTER TABLE ... SET STATISTICS. Las cuatro se desarrollan en 08-04. Cuando estimado y real se parecen, el plan suele ser el mejor disponible: si aun así va lento, el problema es de índices o de volumen, no del planificador.
- Los nodos que más vas a ver
| Nodo | Qué hace | Qué suele significar verlo |
|---|---|---|
Seq Scan |
Lee la tabla entera bloque a bloque | Normal en tablas pequeñas o filtros poco selectivos; sospechoso en tablas grandes con filtro selectivo |
Index Scan |
Recorre el índice y va a la tabla por cada fila | El caso bueno cuando se devuelven pocas filas |
Index Only Scan |
Resuelve todo dentro del índice | El mejor caso. Vigila el Heap Fetches: si es alto, falta VACUUM |
Bitmap Index Scan + Bitmap Heap Scan |
El primero construye un mapa de bits de los bloques con coincidencias; el segundo los lee en orden físico, una vez cada uno | Van siempre juntos. El planificador los prefiere cuando espera muchas filas dispersas: convierte accesos aleatorios en casi secuenciales |
Nested Loop |
Por cada fila de la izquierda, busca en la derecha | Excelente si la izquierda tiene pocas filas; catastrófico si tiene millones. Mira sus loops |
Hash Join |
Construye una tabla hash con la tabla pequeña y recorre la grande | El caballo de batalla de los JOIN grandes por igualdad |
Merge Join |
Recorre las dos entradas ya ordenadas en paralelo | Aparece cuando ambas llegan ordenadas (por índice o por Sort) |
Sort / Incremental Sort |
Ordena filas; el segundo aprovecha que ya vienen parcialmente ordenadas | Si aparece Sort Method: external merge Disk, se ha ido a disco: falta índice o work_mem. Ver Incremental Sort es buena señal: el índice cubre parte del ORDER BY |
HashAggregate / GroupAggregate |
Agrupan, el primero con una tabla hash y el segundo sobre filas ya ordenadas | El GROUP BY habitual es el hash, y necesita memoria; el segundo aparece con muchos grupos o cuando ya venían ordenadas |
Limit |
Corta y detiene la ejecución | Bien colocado, hace que el resto del plan pare antes de tiempo |
Materialize |
Guarda en memoria un resultado para releerlo | Típico dentro de un Nested Loop, para no recalcular la parte derecha |
Gather / Parallel ... |
Reparte el trabajo entre procesos y recoge los resultados | Paralelismo. Workers Launched puede ser menor que Workers Planned |
Dos líneas auxiliares que conviene mirar siempre: Filter: con su Rows Removed by Filter: (filas leídas y tiradas — si son millones, falta un índice o el filtro debería estar antes) e Index Cond: (la parte de la condición que sí resolvió el índice; lo que quede en Filter es lo que hubo que comprobar fila a fila).
- Opciones útiles de
EXPLAIN
EXPLAINVan entre paréntesis y separadas por comas: EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT ...;.
| Opción | Qué añade | Cuándo usarla |
|---|---|---|
ANALYZE |
Ejecuta y mide | Siempre que puedas |
BUFFERS |
Bloques leídos: shared hit (caché), read (disco), dirtied, written |
Casi siempre: ver más abajo |
VERBOSE |
Columnas de salida, esquemas, nombres completos | Consultas con muchos alias |
SETTINGS |
Parámetros de configuración cambiados respecto al valor por omisión | Cuando el mismo SQL da planes distintos en dos servidores |
TIMING OFF / WAL |
Mide sin cronometrar cada nodo / registro de transacciones generado | Cuando el cronometraje distorsiona / analizando escrituras |
FORMAT JSON |
Salida estructurada | Para herramientas y visualizadores |
Por qué BUFFERS importa tanto: el tiempo depende de si la máquina está ocupada, de si el dato estaba en caché y de qué más se ejecuta a la vez. Los bloques leídos, no. Un plan que lee 16.250 bloques leerá 16.250 bloques hoy, mañana y en el portátil de tu compañero. En una máquina compartida —un servidor de integración, un contenedor, la nube— los bloques son la única métrica reproducible que tienes.
- El banco de pruebas: dos millones de filas
Aquí hay que ser honesto: con TiendaVerde no se puede demostrar nada de esto. Veinte productos y cuarenta y siete líneas caben en una página de disco, y el planificador hará Seq Scan siempre y con toda la razón. Enseñarte un plan inventado sobre esas tablas sería mentirte. Así que vamos a construir una tabla grande y reproducible con generate_series.
⚠️ BANCO DE PRUEBAS DEL MÓDULO 8. La tabla
pedidos_grandesno forma parte del esquema canónico de TiendaVerde (01-06). No tiene claves foráneas, no está relacionada con ninguna otra tabla y ningún módulo posterior la usa. Cuando termines la lección, bórrala:DROP TABLE pedidos_grandes;.
-- Banco de pruebas del módulo 8. NO forma parte de TiendaVerde.
-- Ocupa unos 130 MB y tarda entre 10 y 60 segundos en generarse.
DROP TABLE IF EXISTS pedidos_grandes;
CREATE TABLE pedidos_grandes (
id INTEGER PRIMARY KEY,
cliente_id INTEGER NOT NULL,
fecha_pedido DATE NOT NULL,
estado VARCHAR(20) NOT NULL,
importe NUMERIC(10,2) NOT NULL
);
INSERT INTO pedidos_grandes (id, cliente_id, fecha_pedido, estado, importe)
SELECT g,
(g % 200000) + 1, -- 200.000 clientes, 10 pedidos cada uno
DATE '2019-01-01' + (g / 782), -- 782 pedidos al día, en orden cronológico
(ARRAY['pendiente','pagado','enviado','entregado','cancelado'])[(g % 5) + 1],
ROUND((random() * 395 + 5)::numeric, 2)
FROM generate_series(1, 2000000) AS g;
-- Imprescindible: sin estadísticas, el planificador va a ciegas (08-04)
ANALYZE pedidos_grandes;SELECT pg_size_pretty(pg_relation_size('pedidos_grandes')) AS tabla,
pg_relation_size('pedidos_grandes') / 8192 AS paginas,
COUNT(*) AS filas
FROM pedidos_grandes;| tabla | paginas | filas |
|---|---|---|
| 127 MB | 16250 | 2000000 |
(Una ejecución de ejemplo. El tamaño exacto depende de la versión y de la alineación de los tipos; el orden de magnitud, no.) Frente a la única página de productos, aquí hay dieciséis mil doscientas. Ahora sí hay algo que optimizar.
- La demostración: sin índice y con índice
Empecemos sin índice: cliente_id no tiene ninguno — solo lo tiene id, por su clave primaria.
EXPLAIN (ANALYZE, BUFFERS) SELECT id, fecha_pedido, estado, importe
FROM pedidos_grandes WHERE cliente_id = 12345;Gather (cost=1000.00..14750.30 rows=10 width=25) (actual time=0.412..142.118 rows=10 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=16250
-> Parallel Seq Scan on pedidos_grandes (cost=0.00..13749.30 rows=4 width=25)
(actual time=95.204..131.502 rows=3 loops=3)
Filter: (cliente_id = 12345)
Rows Removed by Filter: 666663
Planning Time: 0.096 ms
Execution Time: 142.180 ms(Una ejecución de ejemplo: los milisegundos y los costes dependen de tu máquina. Lo que hay que leer son las proporciones y los tipos de nodo.) Tres cosas que este plan grita: Parallel Seq Scan, porque no hay índice y hay que recorrer la tabla entera —es tan grande que PostgreSQL reparte el trabajo entre tres procesos y los recoge con Gather—; Rows Removed by Filter: 666663 por trabajador, es decir dos millones de filas leídas y tiradas para quedarse con diez; y Buffers: shared hit=16250, las 16.250 páginas de la tabla, todas.
Ahora el índice, y la misma consulta:
CREATE INDEX idx_pedidos_grandes_cliente ON pedidos_grandes (cliente_id);
EXPLAIN (ANALYZE, BUFFERS) SELECT id, fecha_pedido, estado, importe
FROM pedidos_grandes WHERE cliente_id = 12345;Index Scan using idx_pedidos_grandes_cliente on pedidos_grandes
(cost=0.43..39.05 rows=10 width=25) (actual time=0.038..0.061 rows=10 loops=1)
Index Cond: (cliente_id = 12345)
Buffers: shared hit=13
Planning Time: 0.121 ms
Execution Time: 0.086 msLa comparación, que es la razón de ser del módulo entero:
| Sin índice | Con índice | Factor | |
|---|---|---|---|
| Nodo | Parallel Seq Scan + Gather (3 procesos) |
Index Scan (1 proceso) |
— |
| Coste estimado | ~14.750 | ~39 | ~380× |
| Bloques leídos | 16.250 | 13 | 1.250× |
| Filas descartadas | 2.000.000 | 0 | — |
| Tiempo de ejecución | ~142 ms | ~0,09 ms | ~1.600× |
Y fíjate en Index Cond frente a Filter: en el segundo plan no hay Filter. El índice no filtró después de leer: se posicionó directamente en las diez filas. Esa es la diferencia entre buscar y descartar.
El tercer plan: Bitmap Heap Scan
Con un filtro que devuelve bastantes filas dispersas, el planificador elige un camino intermedio:
EXPLAIN (ANALYZE, BUFFERS) SELECT id, importe FROM pedidos_grandes WHERE cliente_id BETWEEN 1000 AND 1200;Bitmap Heap Scan on pedidos_grandes (cost=45.06..7527.19 rows=2010 width=12)
(actual time=1.204..18.442 rows=2010 loops=1)
Recheck Cond: ((cliente_id >= 1000) AND (cliente_id <= 1200))
Heap Blocks: exact=1988
Buffers: shared hit=1994
-> Bitmap Index Scan on idx_pedidos_grandes_cliente (cost=0.00..44.56 rows=2010 width=0)
(actual time=0.612..0.612 rows=2010 loops=1)
Index Cond: ((cliente_id >= 1000) AND (cliente_id <= 1200))Se lee de abajo arriba: el Bitmap Index Scan recorre el índice y construye un mapa de bits de los bloques que contienen coincidencias; el Bitmap Heap Scan los lee en orden físico, una sola vez cada uno. Con 2.010 filas repartidas por casi 2.000 bloques distintos, un Index Scan haría 2.010 saltos aleatorios; el bitmap los convierte en un recorrido casi secuencial de 1.988 bloques. Es la respuesta del planificador a "muchas filas, pero no toda la tabla".
- Y en TiendaVerde real: por qué el índice se ignora
Vuelve al mundo de las 20 filas y crea el índice más razonable del catálogo, sobre productos.precio:
CREATE INDEX idx_productos_precio ON productos (precio);
EXPLAIN ANALYZE SELECT p.id, p.nombre, p.precio FROM productos AS p WHERE p.precio > 10;Seq Scan on productos p (cost=0.00..1.25 rows=7 width=45) (actual time=0.012..0.016 rows=7 loops=1) Filter: (precio > 10::numeric) Rows Removed by Filter: 13 Planning Time: 0.184 ms Execution Time: 0.031 ms
Seq Scan, con el índice recién creado y sin estrenar. Y el planificador tiene toda la razón. Mira su coste: 1,25. Se descompone así: 1 página × 1,0 (leer el único bloque de la tabla) + 20 filas × 0,01 (procesar cada una) + 20 × 0,0025 (evaluar el filtro) = 1,25. No hay nada más barato que eso, porque la tabla entera es un solo acceso.
Podemos obligarle a usar el índice para ver qué habría pasado:
SET enable_seqscan = off; -- SOLO para diagnóstico, nunca en producción
EXPLAIN ANALYZE SELECT p.id, p.nombre, p.precio FROM productos AS p WHERE p.precio > 10;
SET enable_seqscan = on;Index Scan using idx_productos_precio on productos p (cost=0.14..12.35 rows=7 width=45)
(actual time=0.031..0.041 rows=7 loops=1)
Index Cond: (precio > 10::numeric)
Planning Time: 0.211 ms
Execution Time: 0.062 msCoste 12,35 frente a 1,25: diez veces más caro. Y el tiempo real, el doble. El motivo es exactamente el de 08-01: usar el índice obliga a leer la página de metadatos, bajar por el árbol, obtener siete ctid y volver a leer la misma página de la tabla que el Seq Scan habría leído de un tirón. Tres o cuatro accesos para hacer el trabajo de uno. Tres conclusiones, que cierran la primera lección del módulo: un índice sobre una tabla que cabe en una página nunca compensa, y el planificador lo sabe; enable_seqscan = off es una herramienta de diagnóstico, no una solución, y sirve para responder a "¿qué habría hecho con el índice?" y nada más; y los índices que creaste en 08-02 no aceleran nada hoy — son correctos, están bien diseñados y serían decisivos con volumen real, pero con 20 filas no hay problema que resolver.
- Verificando la promesa de 07-05
07-05 afirmó que PostgreSQL convierte IN (SELECT ...) en un semi-join, y que por eso su rendimiento es equivalente al de un JOIN. Comprobémoslo:
EXPLAIN ANALYZE
SELECT c.id, c.nombre FROM clientes AS c WHERE c.id IN (SELECT cliente_id FROM pedidos);Hash Semi Join (cost=1.45..2.71 rows=12 width=10) (actual time=0.048..0.062 rows=12 loops=1)
Hash Cond: (c.id = pedidos.cliente_id)
-> Seq Scan on clientes c (cost=0.00..1.15 rows=15 width=10) (actual time=0.008..0.010 rows=15 loops=1)
-> Hash (cost=1.20..1.20 rows=20 width=4) (actual time=0.021..0.021 rows=20 loops=1)
-> Seq Scan on pedidos (cost=0.00..1.20 rows=20 width=4) (actual time=0.006..0.010 rows=20 loops=1)
Planning Time: 0.352 ms
Execution Time: 0.104 msHash Semi Join. Escribiste una subconsulta y el motor ejecutó un join — un join especial que se detiene en la primera coincidencia de cada fila izquierda, y por eso devuelve 12 clientes y no 20 filas. La promesa era cierta, y ahora no lo crees porque lo leíste: lo has visto. Prueba a cambiar IN por NOT IN y verás que el Semi Join desaparece y aparece un filtro con una subconsulta ejecutada aparte: la confirmación visual de por qué 07-05 desaconsejaba NOT IN.
- Medir en otros motores y herramientas de visualización
| Motor | Plan estimado | Plan real / E-S |
|---|---|---|
| PostgreSQL 16 | EXPLAIN |
EXPLAIN (ANALYZE, BUFFERS) |
| MySQL 8 | EXPLAIN, EXPLAIN FORMAT=JSON (con costes) |
EXPLAIN ANALYZE (desde 8.0.18) |
| SQLite | EXPLAIN QUERY PLAN (muy resumido) |
No hay equivalente; se mide con .timer on |
| SQL Server | Estimated Execution Plan (SET SHOWPLAN_XML ON) |
Actual Execution Plan + SET STATISTICS IO, TIME ON |
| Oracle | EXPLAIN PLAN FOR ... + DBMS_XPLAN.DISPLAY |
DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST') |
Nota de dialecto: los conceptos viajan (recorrido secuencial, acceso por índice, tipos de join, estimado frente a real), pero el vocabulario y las unidades no. El
costde PostgreSQL no es comparable con el de MySQL ni con el de Oracle. Y hay diferencias de fondo: SQL Server y MySQL/InnoDB usan índices agrupados, donde la tabla es el índice primario, así que su equivalente delIndex Scanno necesita el segundo acceso del que hablaba 08-01. ElSET STATISTICS IO ONde SQL Server es el pariente cercano deBUFFERS, y por la misma razón: cuenta lecturas lógicas, que son reproducibles.
Y cuatro herramientas que hacen legible un plan de cien líneas: explain.dalibo.com, donde pegas el plan (mejor en FORMAT JSON) y lo dibuja como árbol resaltando el nodo más caro y las estimaciones erróneas; pev2, el mismo visualizador integrable en tus propias herramientas; auto_explain, un módulo del servidor que registra automáticamente el plan de toda consulta que supere un umbral de tiempo —imprescindible para lo que solo falla en producción—; y pg_stat_statements, el ranking de qué consultas analizar (08-03).
- Mantenimiento:
VACUUM, ANALYZE y el bloat
VACUUM, ANALYZE y el bloatHay una parte del rendimiento que no depende de tus consultas. En PostgreSQL, un UPDATE no modifica la fila: escribe una versión nueva y marca la vieja como muerta. Un DELETE tampoco borra: marca. Las versiones muertas se acumulan y engordan tablas e índices sin aportar nada: es el bloat. Sus efectos son medibles: una tabla con un 60 % de espacio muerto ocupa el triple de páginas de las necesarias, así que cada Seq Scan lee el triple, la caché rinde un tercio y los Index Only Scan pierden eficacia (suben los Heap Fetches).
| Comando | Qué hace | Bloqueo |
|---|---|---|
VACUUM tabla; |
Marca el espacio muerto como reutilizable | Ninguno que impida trabajar |
ANALYZE tabla; |
Actualiza las estadísticas del planificador | Ninguno |
VACUUM FULL tabla; |
Reescribe la tabla y devuelve el espacio al sistema | Bloqueo exclusivo: nadie puede leer ni escribir |
-- Diagnóstico rápido de bloat y de estadísticas viejas
SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum, last_analyze
FROM pg_stat_user_tables WHERE schemaname = 'public' ORDER BY n_dead_tup DESC;En condiciones normales, autovacuum se encarga solo. Se vuelve un problema cuando una tabla recibe muchísimas actualizaciones, o cuando una transacción lleva horas abierta e impide limpiar filas muertas que aún podría necesitar. Y aquí llegamos al límite de este módulo: el porqué de todo esto —por qué el motor guarda varias versiones de cada fila y por qué una transacción abierta bloquea la limpieza— es el modelo MVCC, y se estudia en el módulo 9. Por ahora quédate con lo operativo: ANALYZE tras cualquier carga masiva, y n_dead_tup disparado como señal de alarma.
- Un método en seis pasos
Esto es lo que de verdad hay que llevarse del módulo. Ante una consulta lenta:
- Reproduce y mide. Consigue la consulta exacta con sus parámetros reales y ejecútala con
EXPLAIN (ANALYZE, BUFFERS). Sin medida no hay diagnóstico. - Localiza el nodo culpable. Busca el nodo con más
actual timemultiplicado porloops, y el que más bloques lee. Casi siempre es uno solo y está abajo del todo. - Compara estimado con real. Si
rowsestimadas y reales divergen mucho, el problema son las estadísticas:ANALYZE, y vuelve al paso 1. No sigas optimizando sobre un plan basado en datos falsos. - Mira qué está leyendo de más.
Seq Scansobre una tabla grande con unRows Removed by Filterenorme = falta un índice o la condición no es sargable.Sort ... Disk= falta un índice de ordenación o memoria.Nested Loopcon miles deloops= mala estimación en la rama izquierda. - Aplica UNA sola intervención y vuelve a medir. Reescribir (08-04) antes que indexar (08-02); indexar antes que cambiar la arquitectura. Un cambio cada vez, o no sabrás cuál funcionó.
- Verifica que el plan cambió, no solo que el tiempo bajó (pudo bajar por la caché). Y semanas después, comprueba en
pg_stat_user_indexesque el índice se sigue usando.
Errores Comunes y Consejos
- Lanzar
EXPLAIN ANALYZEsobre unUPDATEo unDELETEsin transacción. Lo ejecuta de verdad.BEGIN ... ROLLBACK, siempre. - Leer el plan de arriba abajo como si fuera código. Se lee de dentro afuera: el nodo más sangrado va primero. Y no olvides multiplicar por
loops: un nodo de 0,08 ms con 20.000 vueltas son 1,6 segundos, y suele ser el culpable. - Comparar el
costde dos motores, o de dos servidores con configuración distinta. Son unidades arbitrarias y relativas. - Fiarse solo del tiempo en una máquina compartida. Usa
BUFFERS: los bloques leídos son reproducibles. Y no midas una sola vez: la primera ejecución llena la caché. - Dejar
enable_seqscan = offpuesto. Es para diagnosticar, jamás para producción: obligas al planificador a elegir mal el resto de sus decisiones. Y no optimices sinANALYZEprevio: si las estimaciones están mal, todo lo que hagas encima está mal. - Consejo: guarda el plan de antes. Un fichero con el
EXPLAINprevio al cambio es la única prueba objetiva de que has mejorado algo. - Consejo: analiza con datos reales de producción. Un plan sobre 100 filas de desarrollo no dice absolutamente nada sobre 20 millones, y los problemas que solo aparecen a las tres de la mañana no se reproducen a mano: para eso está
auto_explain.
Ejercicios
Ejercicio 1
Lee este plan, tomado de un TiendaVerde de tamaño real (2 millones de pedidos, no el banco de pruebas de esta lección):
Nested Loop (cost=0.42..248301.55 rows=38 width=48) (actual time=0.055..9412.331 rows=18422 loops=1)
Buffers: shared hit=1204885
-> Seq Scan on pedidos pe (cost=0.00..41250.00 rows=38 width=25)
(actual time=0.021..1842.117 rows=18422 loops=1)
Filter: (estado = 'pendiente'::text)
Rows Removed by Filter: 1981578
-> Index Scan using clientes_pkey on clientes c (cost=0.42..5.44 rows=1 width=23)
(actual time=0.004..0.004 rows=1 loops=18422)
Index Cond: (id = pe.cliente_id)
Planning Time: 0.412 ms
Execution Time: 9421.008 ms- ¿En qué orden se ejecutan los nodos?
- ¿Cuál es el problema principal y qué señal lo delata?
- ¿Cuánto tiempo total consume realmente el
Index Scan? - Propón dos intervenciones, en el orden en que las probarías.
Ejercicio 2
Sobre pedidos_grandes ya creada e indexada por cliente_id, predice antes de ejecutar qué nodo elegirá el planificador en cada caso, y después compruébalo con EXPLAIN ANALYZE.
SELECT id FROM pedidos_grandes WHERE cliente_id = 500; -- a)
SELECT COUNT(*) FROM pedidos_grandes WHERE estado = 'entregado'; -- b)
SELECT cliente_id FROM pedidos_grandes WHERE cliente_id BETWEEN 1 AND 100; -- c)
SELECT id FROM pedidos_grandes WHERE cliente_id + 0 = 500; -- d)Ejercicio 3
Escribe la secuencia completa de comandos —incluidas las precauciones— para analizar el rendimiento de este DELETE sin modificar los datos, y di qué buscarías en el plan.
Soluciones
Solución 1
1. El orden. Primero el Seq Scan sobre pedidos (el nodo más profundo de la rama izquierda); para cada fila que produce, el Nested Loop ejecuta el Index Scan sobre clientes; el Nested Loop emite el resultado.
2. El problema es una estimación catastróficamente errónea. La señal está en el Seq Scan: rows=38 estimadas frente a rows=18422 reales, casi 500 veces más. Con 38 filas, un Nested Loop es la elección perfecta; con 18.422 se convierte en 18.422 búsquedas indexadas. Y hay dos síntomas de apoyo: Rows Removed by Filter: 1981578 (se leen dos millones de filas para quedarse con el 0,9 %, así que falta un índice sobre estado) y Buffers: shared hit=1204885, más de un millón de bloques leídos para devolver 18.422 filas.
3. El tiempo del Index Scan. actual time es por ejecución: 0,004 ms × 18.422 loops ≈ 74 ms. Es decir, el Index Scan no es el problema: de los 9.421 ms, 1.842 son el Seq Scan, 74 el Index Scan, y el resto se lo come la maquinaria de repetir el bucle 18.422 veces. Olvidar multiplicar por loops es el error de lectura más común.
4. Las intervenciones, en orden. Primero, ANALYZE pedidos; y volver a mirar el plan: es gratis, instantáneo y ataca la causa raíz — si la estimación pasa a ser correcta, el planificador cambiará solo el Nested Loop por un Hash Join y el tiempo caerá sin tocar nada más. Después, si la estimación ya era correcta, un índice para los pendientes, mejor parcial: CREATE INDEX ... ON pedidos (cliente_id) WHERE estado = 'pendiente';, porque son menos del 1 % y así el índice es minúsculo (08-02).
Solución 2
| # | Nodo esperado | Por qué |
|---|---|---|
a) cliente_id = 500 |
Index Scan |
10 filas de 2.000.000: selectividad del 0,0005 %. El caso ideal |
b) estado = 'entregado' |
Parallel Seq Scan + Gather bajo un Aggregate |
Devuelve el 20 % de la tabla; muy por encima del umbral de selectividad (08-03). Aunque hubiera índice, no lo usaría |
c) cliente_id BETWEEN 1 AND 100 |
Bitmap Index Scan + Bitmap Heap Scan, o Index Only Scan |
Unas 1.000 filas dispersas: demasiadas para saltos aleatorios uno a uno, pocas para leer la tabla entera. Y como solo se pide cliente_id, que está en el índice, es candidata a Index Only Scan |
d) cliente_id + 0 = 500 |
Parallel Seq Scan |
No es sargable (08-04): hay una operación sobre la columna filtrada, y el índice queda descartado. Mismo resultado que a), unas 1.600 veces más lento. Es el experimento más instructivo del ejercicio: mismo resultado, mismo índice disponible, y un + 0 que lo anula todo |
Solución 3
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) DELETE FROM pedidos_grandes WHERE fecha_pedido < '2020-01-01';
ROLLBACK;La precaución clave es el BEGIN ... ROLLBACK: EXPLAIN ANALYZE ejecuta el DELETE de verdad, y sin la transacción perderías las filas. Qué buscar en el plan:
- Cómo localiza las filas. Sin índice sobre
fecha_pedido, unSeq Scancon unRows Removed by Filterenorme. Con índice, unBitmap Heap Scan— y como los datos se generaron en orden cronológico, las filas a borrar están físicamente juntas, que es el mejor escenario posible. - Cuántas filas borra. El
rowsreal del nodo de borrado: unos 285.000 (365 días × 782 al día). Y la comparación estimado/real, como siempre. - Una consideración que este módulo no puede resolver: un
DELETEde 285.000 filas deja 285.000 versiones muertas queVACUUMtendrá que limpiar, y mantiene bloqueos durante toda la transacción. Por eso los borrados masivos se hacen por lotes, y por eso el particionado (08-04) es tan atractivo para los históricos: borrar un año entero es desconectar una partición, no ejecutar unDELETE.
Conclusión del módulo
Cierras el módulo 8 con el método, no solo con las reglas:
EXPLAINestima;EXPLAIN ANALYZEejecuta y mide — y por eso unUPDATEo unDELETEhay que envolverlos enBEGIN ... ROLLBACK.- Un plan es un árbol que se lee de dentro afuera.
costson unidades relativas,rowsywidthson estimaciones, yactual timeyrowsson medias por ejecución: hay que multiplicarlas porloops. - La señal más útil de todas es la divergencia entre las filas estimadas y las reales. Cuando es grande, el plan está bien elegido para una realidad que no existe, y la causa suele ser
ANALYZE. - Sabes reconocer los quince nodos habituales, desde el
Seq Scanhasta elGather, y qué significa que aparezcanRows Removed by Filter,Heap FetchesoSort Method: external merge Disk. Y sabes queBUFFERSes mejor métrica que el tiempo en cualquier máquina compartida: los bloques leídos son reproducibles. - Lo has visto medido, no contado: sobre dos millones de filas, la misma consulta pasa de 16.250 bloques y ~142 ms a 13 bloques y ~0,09 ms. Mientras que sobre los 20 productos de TiendaVerde el índice cuesta diez veces más que el
Seq Scan, y el planificador acierta al ignorarlo. Y la promesa de 07-05 queda verificada con su nombre en el plan:Hash Semi Join.
Y con esto se cierra el módulo 8. En cinco lecciones has pasado de "no sé por qué esto va lento" a tener un procedimiento: sabes qué es un B-tree y por qué 15 millones de filas caben en 3 niveles; sabes que TiendaVerde tenía once índices que nadie creó y once claves foráneas sin cubrir, porque PostgreSQL no las indexa; sabes crear índices compuestos, parciales, de expresión y cubridores, y elegir entre B-tree, hash, GIN, GiST, BRIN y SP-GiST; sabes —y esto es lo más raro de encontrar— cuándo no indexar; sabes reescribir una consulta para hacerla sargable, detectar un N+1 y actualizar las estadísticas del planificador; y sabes leer un plan de ejecución y decidir con datos.
Pero hay una suposición que hemos mantenido durante ochenta y tantas lecciones sin decirlo en voz alta: que somos el único usuario de la base de datos. Todo lo que has medido aquí supone que nadie más está leyendo ni escribiendo al mismo tiempo, que ninguna fila cambia mientras la consultas y que ningún UPDATE compite con el tuyo por la misma línea de pedido. Eso es falso en cualquier sistema real: en TiendaVerde, un cliente confirma un pedido mientras el almacén actualiza el stock del mismo producto y el analista lanza el informe mensual sobre esas mismas tablas. En el módulo 9, Transacciones, verás qué es una transacción y las cuatro garantías ACID, cómo se controlan con BEGIN, COMMIT, ROLLBACK y SAVEPOINT, qué son los niveles de aislamiento y qué anomalías permite cada uno, y cómo funcionan los bloqueos y los interbloqueos — incluido, por fin, el modelo MVCC que explica el bloat de esta lección y por qué CREATE INDEX CONCURRENTLY tenía que existir.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
