StackPractices
intermediate Por Mathias Paulenko

Checklist de Optimización de Consultas SQL

Checklist de trabajo para afinar consultas SQL lentas: lee el plan de EXPLAIN, corrige índices, elimina llamadas N+1, optimiza joins y paginación, y audita el slow query log — con ejemplos ejecutables para PostgreSQL y MySQL.

Overview

Cuando una consulta va lenta, la tentación es empezar a cambiar cosas al azar — un índice aquí, reescribir un join allá — y cruzar los dedos. Este checklist es lo contrario: un recorrido de arriba abajo por los sospechosos habituales, en el orden en que conviene revisarlos. Empieza por el plan (sección 1), porque una consulta que no has perfilado es una consulta sobre la que estás adivinando. Después índices, patrones N+1, joins, paginación, y al final la infraestructura — pooling y caché — antes de culpar a la base de datos.

Cada sección te dice qué ejecutar, cómo se ve un mal resultado y cuál suele ser la solución. Los ejemplos cubren PostgreSQL y MySQL; los conceptos se transfieren a cualquier motor relacional. Ten a mano la guía de Optimización de Consultas SQL para la teoría de fondo — este archivo es la hoja de triage.

1. EXPLAIN Plan Analysis

Empieza siempre aquí. EXPLAIN muestra la estrategia del planificador; EXPLAIN ANALYZE muestra lo que realmente pasó. La diferencia importa — las estimaciones de filas pueden desviarse órdenes de magnitud cuando las estadísticas de la tabla están viejas.

1.1 PostgreSQL EXPLAIN

-- EXPLAIN básico (plan estimado, no ejecuta la consulta)
EXPLAIN SELECT * FROM orders WHERE customer_id = 123;

-- EXPLAIN con estadísticas reales de ejecución (sí la ejecuta)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 123;

-- Timing detallado + salida legible por máquina
EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT JSON)
SELECT * FROM orders WHERE customer_id = 123;

1.2 MySQL EXPLAIN

-- EXPLAIN básico
EXPLAIN SELECT * FROM orders WHERE customer_id = 123;

-- EXPLAIN ANALYZE (MySQL 8.0+)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 123;

-- SHOW WARNINGS revela la consulta reescrita que MySQL ejecutó de verdad
EXPLAIN SELECT * FROM orders WHERE customer_id = 123;
SHOW WARNINGS;

1.3 Red Flags en el Plan

Estos tipos de escaneo casi siempre significan “te falta un índice” — compruébalo contra los conteos de filas reales antes de lanzar el CREATE INDEX:

Tipo de scanSignificadoSolución
Seq Scan (Postgres)Escaneo completo de la tabla, sin índiceÍndice en la columna del filtro
ALL (MySQL)Escaneo completo de la tablaÍndice en la columna del filtro
FilesortPaso extra de ordenación tras recuperarÍndice que coincida con el ORDER BY
TemporaryTabla temporal materializadaReescribe el GROUP BY o indexa
Nested Loop en tablas grandesJoin O(n·m)Índice en la columna del join
Filas examinadas ≫ devueltasBaja selectividadÍndice mejor/compuesto

1.4 Métricas que Vigilar

Métrica (PostgreSQL)BienOjoMal
Seq Scan rows< 100100–10.000> 10.000
Sort rows< 1.0001.000–50.000> 50.000
Hash Build rows< 10.00010.000–100.000> 100.000
Buffers shared hit> 90 %50–90 %< 50 %
Execution time< 10ms10–100ms> 100ms

Si el plan se ve bien pero las estimaciones de filas de ANALYZE están muy lejos, las estadísticas están viejas — ejecuta ANALYZE orders; (y mantén sano el autovacuum) antes de añadir índices para compensar un planificador que trabaja con datos malos.

2. Index Strategy

Los índices son la solución a la mayoría de lecturas lentas — y un impuesto sobre cada escritura. El checklist de abajo cubre el ciclo de auditoría: verifica qué se usa, añade lo que falta, quita lo que es peso muerto.

2.1 Index Audit Checklist

- [ ] Revisa los índices existentes en la tabla
  - PostgreSQL: \d orders o SELECT * FROM pg_indexes WHERE tablename = 'orders';
  - MySQL: SHOW INDEX FROM orders;

- [ ] Verifica que la consulta realmente usa un índice
  - Ejecuta EXPLAIN — busca Index Scan, no Seq Scan
  - Índice en columnas del WHERE
  - Índice en columnas del JOIN
  - Índice en columnas del ORDER BY

- [ ] Añade los índices que falten (ejemplos en 2.2)
  - De una columna para igualdad simple
  - Compuesto para filtros multi-columna
  - Índice parcial para un subconjunto habitual
  - Covering index para evitar lecturas de la tabla

- [ ] Elimina índices sin uso
  - PostgreSQL: SELECT * FROM pg_stat_user_indexes WHERE idx_scan = 0;
  - MySQL: SELECT * FROM sys.schema_unused_indexes;
  - Cada índice sin uso sigue costando escrituras y espacio

- [ ] Revisa la selectividad del índice
  - Alta selectividad (muchos valores distintos): buen candidato
  - Baja selectividad (booleano, enum de estado): normalmente saltar
  - SELECT COUNT(DISTINCT col)::float / COUNT(*) FROM orders;
  - Selectividad > 0.3 es un candidato decente

2.2 Index Creation Examples

-- Índice compuesto: columnas de igualdad primero, rango al final — el orden importa
CREATE INDEX idx_orders_customer_status_date
  ON orders(customer_id, status, created_at);

-- Índice parcial PostgreSQL: solo las filas que filtrarás de verdad
CREATE INDEX idx_orders_active_customer
  ON orders(customer_id)
  WHERE status = 'active';

-- Covering index PostgreSQL: las columnas INCLUDE se almacenan, no se buscan
CREATE INDEX idx_orders_customer_cover
  ON orders(customer_id)
  INCLUDE (total, status, created_at);

-- Índice compuesto MySQL
CREATE INDEX idx_orders_customer_status_date
  ON orders(customer_id, status, created_at);

-- Índice funcional MySQL (8.0+)
CREATE INDEX idx_orders_lower_email
  ON orders((LOWER(email)));

-- Índice de expresión PostgreSQL
CREATE INDEX idx_orders_lower_email
  ON orders(LOWER(email));

Y no indexar las FKs a ciegas: PostgreSQL no indexa automáticamente el lado referenciador de una clave foránea, una causa clásica de joins lentos y deletes en cascada eternos.

3. N+1 Query Detection

El patrón N+1 es silencioso en un test de consulta única — solo aparece como cientos de roundtrips con datos reales. Una consulta trae una lista; el código dispara luego una consulta por fila para el dato relacionado.

3.1 El Patrón

Problema:
  1 consulta para traer 100 pedidos
  100 consultas para traer el nombre de cada cliente
  Total: 101 consultas

Solución:
  1 consulta con JOIN, o 2 consultas con IN (...) por lotes

3.2 Detección

-- PostgreSQL: registra cada consulta durante una ventana controlada
ALTER SYSTEM SET log_min_duration_statement = 0;
SELECT pg_reload_conf();

-- MySQL: captura todo brevemente
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0;

-- En el log, busca la misma forma de consulta repetida
-- con parámetros distintos — ese es tu N+1

No dejes long_query_time = 0 encendido — inundar el log de un servidor de producción es una buena forma de crear la avería que intentabas depurar. Restáuralo tras la prueba.

3.3 Cómo Arreglarlo

-- Antes: 1 consulta + N búsquedas (generadas por el ORM)
-- Query 1: SELECT * FROM orders WHERE status = 'active';
-- Query 2..101: SELECT * FROM customers WHERE id = ?;

-- Después: un solo JOIN
SELECT
  o.id,
  o.total,
  o.created_at,
  c.name AS customer_name,
  c.email AS customer_email
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'active';

-- O por lotes cuando el JOIN no encaja (p. ej. servicios distintos)
SELECT id, customer_id, total FROM orders WHERE status = 'active';
SELECT id, name, email FROM customers WHERE id IN (1, 2, 3, 4, 5, ...100);

3.4 ORM-Specific Fixes

# Django: select_related (JOIN) para FK, prefetch_related para M2M/reverso
# Antes (N+1):
orders = Order.objects.filter(status='active')
for order in orders:
    print(order.customer.name)  # una consulta extra por pedido

# Después (un solo JOIN):
orders = Order.objects.filter(status='active').select_related('customer')
for order in orders:
    print(order.customer.name)  # sin consultas extra

# Después (por lotes con prefetch_related para FK reverso):
customers = Customer.objects.prefetch_related('order_set').all()
for customer in customers:
    for order in customer.order_set.all():  # sin consultas extra
        print(order.total)
# Rails: includes / preload / eager_load
# Antes (N+1):
orders = Order.where(status: 'active')
orders.each { |o| puts o.customer.name }

# Después:
orders = Order.includes(:customer).where(status: 'active')
orders.each { |o| puts o.customer.name }

4. Join Optimization

Los joins son donde las buenas consultas van a morir — normalmente por una columna de join sin indexar o un filtro aplicado demasiado tarde. Revisa esto antes de reescribir la consulta entera.

4.1 Join Checklist

- [ ] Verifica que las columnas del join están indexadas en ambos lados
  - PostgreSQL no indexa las FKs automáticamente — revisa \d child_table

- [ ] Usa el tipo de join correcto
  - INNER JOIN: solo filas coincidentes (default, el más rápido)
  - LEFT JOIN: todas las de la izquierda + coincidencias — caro con tablas grandes a la derecha
  - CROSS JOIN: producto cartesiano — nunca por accidente

- [ ] Filtra pronto
  - El WHERE debe reducir filas antes del join
  - Lleva los filtros a subqueries o CTEs por claridad

- [ ] No selecciones lo que no necesitas
  - SELECT solo las columnas necesarias, no SELECT *
  - Los covering indexes evitan lecturas extra de la tabla

- [ ] Revisa el orden del join
  - El filtro más selectivo se aplica primero
  - El optimizador suele acertar — fuerza el orden solo con evidencia

4.2 Examples

-- Antes: une todas las filas de customers antes de filtrar
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.status = 'active' AND o.created_at > '2026-01-01';

-- Después: filtra orders primero, luego une el resultado pequeño
SELECT c.name, active_orders.total
FROM customers c
INNER JOIN (
  SELECT customer_id, total
  FROM orders
  WHERE status = 'active' AND created_at > '2026-01-01'
) active_orders ON c.id = active_orders.customer_id;

-- Lo mismo, más legible como CTE
WITH active_orders AS (
  SELECT customer_id, total
  FROM orders
  WHERE status = 'active' AND created_at > '2026-01-01'
)
SELECT c.name, ao.total
FROM customers c
INNER JOIN active_orders ao ON c.id = ao.customer_id;

5. Pagination Patterns

OFFSET parece gratis hasta la página 500 — cada página lee y descarta todas las filas anteriores. La paginación keyset (cursor) lee solo lo que devuelve.

5.1 Avoid OFFSET

-- Problema: escanea 10.020 filas, devuelve 20 — 10.000 desperdiciadas
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 10000;

-- Keyset: arranca donde terminó la última página, lee exactamente 20 filas
SELECT * FROM orders
WHERE created_at < '2026-06-15 10:30:00'
ORDER BY created_at DESC
LIMIT 20;

5.2 Keyset Implementation

-- Página 1
SELECT id, created_at, total
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Página 2: el (created_at, id) de la última fila se convierte en el cursor
SELECT id, created_at, total
FROM orders
WHERE (created_at, id) < ('2026-06-15 10:30:00', 5000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- El índice debe coincidir exactamente con el orden de la ordenación
CREATE INDEX idx_orders_created_id
  ON orders(created_at DESC, id DESC);

La contrapartida: keyset renuncia a los saltos aleatorios de página (no hay “ir a la página 50”). Si la UI realmente necesita páginas numeradas, precomputa los límites o cachea el resultado — no hagas pagar a la base de datos el OFFSET en cada petición.

6. Connection Pooling

Las consultas lentas no siempre son consultas lentas — a veces son consultas rápidas esperando 30s por una conexión libre. Revisa el agotamiento del pool antes de culpar al planificador.

6.1 Pool Configuration

ParámetroRecomendadoPor qué
Pool size~(core × 2) + spindlesMás no es más rápido — regla clásica de dimensionado de PostgreSQL
Min idle= pool sizeMantén conexiones calientes
Max lifetime30 minEvita conexiones viejas/zombis
Connect timeout30sFalla rápido ante agotamiento
Idle timeout10 minCierra conexiones realmente ociosas
Validation querySELECT 1Chequeo de salud antes de entregar

6.2 PostgreSQL: PgBouncer

# pgBouncer — transaction pooling para apps que abren/cierran seguido
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
max_client_conn = 200
default_pool_size = 20
min_pool_size = 5
server_idle_timeout = 600
server_lifetime = 3600

pool_mode = transaction es el default correcto para la mayoría de apps — pero recuerda que rompe funciones a nivel de sesión (prepared statements entre consultas, SET de sesión). Usa modo session si dependes de eso.

6.3 Application-Level Pooling

# Configuración de pool en SQLAlchemy
engine = create_engine(
    'postgresql://user:pass@localhost/mydb',
    pool_size=20,
    max_overflow=10,
    pool_timeout=30,
    pool_recycle=1800,   # recicla antes de que el servidor mate la conexión
    pool_pre_ping=True,  # descarta conexiones muertas de forma transparente
)

7. Query Caching

Cuando la consulta ya es rápida, la ejecución más barata es la que nunca ocurre — cachea resultados antes de volver a tocar el plan.

7.1 Cache Layers

CapaTTLCuándo usarla
Aplicación5–60sLecturas calientes que toleran desfase
Redis/Memcached1–60minCompartido entre instancias
Base de datosSesión/refreshVistas materializadas, result cache
CDN1–24hRespuestas de API, datos semiestáticos

7.2 PostgreSQL Materialized Views

-- Agregación cara, calculada una sola vez
CREATE MATERIALIZED VIEW order_summary AS
SELECT
  customer_id,
  COUNT(*) AS total_orders,
  SUM(total) AS total_spent,
  MAX(created_at) AS last_order_date
FROM orders
GROUP BY customer_id;

-- Indexala como una tabla
CREATE INDEX idx_order_summary_customer
  ON order_summary(customer_id);

-- REFRESH bloquea las lecturas...
REFRESH MATERIALIZED VIEW order_summary;

-- ...salvo que refresques CONCURRENTLY (necesita un índice único)
REFRESH MATERIALIZED VIEW CONCURRENTLY order_summary;

8. Slow Query Log Triage

Los índices arreglan una consulta; el slow log encuentra las consultas que vale la pena arreglar. Actívalo, ordena por tiempo total (no por tiempo de una ejecución) y trabaja la lista desde arriba.

8.1 Activar el Log

-- PostgreSQL: registra todo lo que pase de 100ms
ALTER SYSTEM SET log_min_duration_statement = 100;
SELECT pg_reload_conf();

-- MySQL: la misma idea
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1;

8.2 Ordenar los Culpables (pg_stat_statements)

-- Instala una vez por base de datos
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Top consultas por tiempo total — el coste real, no la ejecución más lenta
SELECT
  calls,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  round(total_exec_time::numeric, 0) AS total_ms,
  left(query, 120) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Prioriza total_ms: una consulta de 50ms llamada 2.000 veces por minuto cuesta más que un informe de 5s que corre de noche. El equivalente en MySQL es performance_schema.events_statements_summary_by_digest.

See Also

Preguntas frecuentes

¿Cómo sé si una consulta necesita un índice?

Ejecuta EXPLAIN (ANALYZE) sobre ella. Seq Scan (PostgreSQL) o type: ALL (MySQL) significa que se escanean todas las filas — tolerable en una tabla de 200 filas, fatal en una de 2 millones. Mira rows: si examina miles para devolver un puñado, el índice falta o no es lo bastante selectivo. Añádelo, vuelve a correr EXPLAIN y confirma que el tipo de scan cambió.

¿Cuándo uso un índice compuesto y cuándo uno de columna simple?

Compuesto cuando filtras varias columnas juntas — WHERE customer_id = ? AND status = ? pide (customer_id, status). La regla del prefijo más a la izquierda hace que ese índice también sirva para consultas solo sobre customer_id, pero no sobre status solo. Orden: igualdad primero, rango al final. Tope de 3–4 columnas en compuestos — índices más anchos frenan cada escritura.

¿Qué diferencia hay entre EXPLAIN y EXPLAIN ANALYZE?

EXPLAIN estima el plan sin ejecutar nada; EXPLAIN ANALYZE ejecuta la consulta y reporta tiempos y filas reales. Usa EXPLAIN a secas para inspeccionar sin efectos. Para consultas de escritura, envuelve EXPLAIN ANALYZE en una transacción y haz rollback: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;.

¿Por qué mi consulta se volvió lenta tras crecer la tabla, teniendo índice?

Normalmente estadísticas viejas — el planificador elige planes contra datos que ya no existen. Ejecuta ANALYZE orders; y comprueba que el autovacuum no va rezagado. Segunda causa habitual: el índice se hinchó o la consulta cruzó un umbral donde el planificador prefiere un escaneo secuencial — EXPLAIN antes y después te dice cuál.

¿Cómo arreglo una paginación lenta?

Deja OFFSET — escanea y descarta cada fila anterior a tu página. La paginación keyset guarda los valores de la última fila y continúa desde ahí: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20. Indexa exactamente ese orden. Si los usuarios necesitan "saltar a la página 50", precomputa los límites o cachea la lista.

¿Cómo encuentro consultas lentas en producción?

Activa el slow log — log_min_duration_statement = 100 en Postgres, long_query_time = 0.1 en MySQL — y luego ordena con pg_stat_statements (o performance_schema) por tiempo total, no por ejecución individual. Una consulta de 50ms a 1.000 llamadas/minuto gana en prioridad a una de 500ms a 1/minuto. Un APM (Datadog, Sentry, New Relic) añade el contexto de traza del lado de la aplicación.

¿Cacheo resultados en Redis o afino la consulta?

Afina primero, cachea después. Cachear una consulta rota solo esconde el bug detrás de un TTL — cuando la caché expira o invalida mal, la consulta lenta vuelve. Deja la consulta razonable con EXPLAIN e índices y luego cachea para absorber picos de lectura o proteger un dashboard caliente.