Índices y optimización de consultas
"El índice correcto puede hacer que una consulta de 30 segundos tarde 30 milisegundos. El índice incorrecto puede hacer que INSERT tarde 10 veces más."
Qué vas a aprender en este capítulo
La tabla de pedidos de La Esquina tiene 10,000 filas y la consulta SELECT * FROM pedidos WHERE mesa_id = 5 tarda 3 segundos. Con un índice, tardaría 2 milisegundos. Este capítulo explica cómo funcionan los índices internamente, cuándo crearlos y cuándo evitarlos.
2.1 ¿Por qué son necesarios los índices?
💡 Intuición
Un libro sin índice: si buscás "ACID" tenés que leer todas las páginas. Un libro con índice: vas directo a la página correcta.
La base de datos sin índice hace un full table scan: lee todas las filas una por una para encontrar las que coinciden. Con 10 filas es instantáneo. Con 10 millones de filas, es catastrófico.
📐 Fundamento
Qué hace un índice:
Un índice es una estructura de datos auxiliar que mantiene los valores de una o más columnas ordenados, junto con un puntero a la fila correspondiente en la tabla principal (heap).
Sin índice:
Buscar pedidos de mesa 5:
→ Leer fila 1: mesa=3 ✗
→ Leer fila 2: mesa=1 ✗
→ Leer fila 3: mesa=5 ✓
→ Leer fila 4: mesa=2 ✗
... continuar hasta el final → O(n) operaciones
Con índice en mesa_id:
Buscar pedidos de mesa 5 en el índice B-Tree:
→ Navegar el árbol hasta encontrar mesa=5 → O(log n) operaciones
→ Seguir puntero directamente a las filas correspondientes
Crear y eliminar índices:
-- Índice simple
CREATE INDEX idx_pedidos_mesa ON pedidos (mesa_id);
-- Índice compuesto (útil cuando se filtra por varios campos juntos)
CREATE INDEX idx_pedidos_mesa_fecha ON pedidos (mesa_id, fecha);
-- Índice único (también garantiza unicidad)
CREATE UNIQUE INDEX idx_usuarios_email ON usuarios (email);
-- Eliminar
DROP INDEX idx_pedidos_mesa;
-- Ver índices existentes
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'pedidos';
2.2 Estructura B-Tree
💡 Intuición
El tipo de índice más común es el B-Tree (Árbol B balanceado). Pensalo como un árbol de decisiones: en cada nodo, el motor decide si ir a la izquierda o a la derecha hasta llegar a la hoja con el valor buscado.
Lo importante: el árbol siempre está balanceado — todas las hojas están a la misma profundidad. Eso garantiza que buscar cualquier valor siempre tarda lo mismo: O(log n).
📐 Fundamento
Estructura de un B-Tree:
[50]
/ \
[20, 35] [70, 90]
/ | | \ / | | \
[...] [...] [...] ← hojas con punteros a filas
- Cada nodo interno contiene valores que sirven de guía para la búsqueda.
- Cada hoja contiene los valores reales + punteros (TID — Tuple ID) a las filas.
- Buscar: O(log n). Insertar: O(log n) + posible rebalanceo.
El B-Tree soporta:
- Búsquedas exactas:
WHERE mesa_id = 5 - Rangos:
WHERE fecha BETWEEN '2026-01-01' AND '2026-12-31' - Ordenamiento:
ORDER BY mesa_id(el índice ya está ordenado) - Prefijos en strings:
WHERE nombre LIKE 'Mar%'(pero NOLIKE '%ar%')
Otros tipos de índices:
| Tipo | Cuándo usarlo | Ejemplo |
|---|---|---|
| B-Tree | Default, búsquedas exactas y rangos | mesa_id = 5, fecha > '...' |
| Hash | Solo igualdad exacta, muy rápido | email = 'x@x.com' |
| GIN | Arrays, JSONB, texto completo | tags @> ARRAY['vegano'] |
| GiST | Geometría, rangos, similitud | Coordenadas geográficas |
| BRIN | Tablas muy grandes con datos correlacionados por inserción | Series temporales |
-- Índice hash (solo igualdad, PostgreSQL)
CREATE INDEX idx_usuarios_email_hash ON usuarios USING HASH (email);
-- Índice GIN para búsqueda en JSONB
CREATE INDEX idx_pedidos_metadata ON pedidos USING GIN (metadata);
2.3 EXPLAIN ANALYZE — leer el plan de ejecución
💡 Intuición
EXPLAIN le pregunta al motor: "¿Cómo vas a ejecutar esta consulta?". EXPLAIN ANALYZE lo ejecuta de verdad y mide el tiempo real. Es la herramienta número uno para diagnosticar consultas lentas.
📐 Fundamento
Sintaxis:
EXPLAIN ANALYZE
SELECT * FROM pedidos WHERE mesa_id = 5;
Salida típica sin índice:
Seq Scan on pedidos (cost=0.00..245.00 rows=12 width=64)
(actual time=0.015..18.432 rows=12 loops=1)
Filter: (mesa_id = 5)
Rows Removed by Filter: 9988
Planning Time: 0.12 ms
Execution Time: 18.45 ms
Salida con índice:
Index Scan using idx_pedidos_mesa on pedidos
(cost=0.29..12.41 rows=12 width=64)
(actual time=0.025..0.089 rows=12 loops=1)
Index Cond: (mesa_id = 5)
Planning Time: 0.21 ms
Execution Time: 0.11 ms
Qué leer en la salida:
| Campo | Significado |
|---|---|
Seq Scan |
Full table scan — puede ser un problema |
Index Scan |
Usa el índice — buena señal |
cost=X..Y |
Estimación del planificador (no real) |
actual time=X..Y |
Tiempo real en milisegundos |
rows=N |
Filas procesadas |
Rows Removed by Filter |
Cuántas filas se descartaron — alto = considerar índice |
El planificador puede ignorar el índice cuando:
- La tabla tiene muy pocas filas (el full scan es más rápido).
- La selectividad es baja: si el 80% de las filas tienen
mesa_id = 5, un índice no ayuda. - Las estadísticas están desactualizadas — correr
ANALYZE tablapara actualizar.
-- Actualizar estadísticas
ANALYZE pedidos;
-- Ver estadísticas de una columna
SELECT n_distinct, correlation
FROM pg_stats
WHERE tablename = 'pedidos' AND attname = 'mesa_id';
2.4 Estrategias de optimización
💡 Intuición
No todo problema de rendimiento se resuelve con un índice. A veces el problema es la consulta misma: hacer 100 queries en un loop cuando una sola consulta con JOIN haría lo mismo.
Regla de oro: medir primero, optimizar después. EXPLAIN ANALYZE antes de crear cualquier índice.
📐 Fundamento
Problemas comunes y soluciones:
1. N+1 queries (el problema más frecuente):
# MAL: 1 query para obtener pedidos + 1 por cada pedido para obtener el mozo
pedidos = db.query("SELECT * FROM pedidos WHERE mesa_id = 5") # 1 query
for p in pedidos:
mozo = db.query(f"SELECT * FROM mozos WHERE id = {p.mozo_id}") # N queries
# BIEN: 1 sola query con JOIN
pedidos = db.query("""
SELECT p.*, m.nombre as mozo_nombre
FROM pedidos p
JOIN mozos m ON p.mozo_id = m.id
WHERE p.mesa_id = 5
""")
2. SELECT * cuando no se necesitan todas las columnas:
-- MAL: trae todas las columnas, incluyendo texto grande
SELECT * FROM platillos WHERE categoria = 'pupusa';
-- BIEN: solo las columnas necesarias
SELECT id, nombre, precio FROM platillos WHERE categoria = 'pupusa';
3. Índice compuesto — orden importa:
-- Si la query filtra por mesa_id Y fecha:
WHERE mesa_id = 5 AND fecha > '2026-01-01'
-- Este índice es efectivo (mesa_id primero, porque es el filtro más selectivo):
CREATE INDEX idx_pedidos_mesa_fecha ON pedidos (mesa_id, fecha);
-- Este NO sirve para la query anterior (orden equivocado):
CREATE INDEX idx_pedidos_fecha_mesa ON pedidos (fecha, mesa_id);
-- (sí sirve para queries que filtran solo por fecha)
4. Índices parciales — cuando no se necesita indexar todo:
-- Solo indexar pedidos activos (el 5% de la tabla)
-- Los pedidos cerrados rara vez se consultan
CREATE INDEX idx_pedidos_activos ON pedidos (mesa_id)
WHERE estado = 'ABIERTO';
-- Mucho más pequeño y rápido que un índice completo
Cuándo NO crear un índice:
- Tablas muy pequeñas (< 1,000 filas): el full scan es más rápido.
- Columnas con baja cardinalidad:
estadocon solo 3 valores posibles. - Tablas con muchos INSERT/UPDATE: cada escritura actualiza todos los índices.
- La columna ya es cubierta por otro índice compuesto.
🛠️ En la práctica
Diagnóstico de La Esquina: consulta lenta de reportes
El dueño de La Esquina nota que el reporte de ventas diario tarda 45 segundos. La consulta es:
SELECT
p.nombre,
SUM(ip.cantidad) as total_vendido,
SUM(ip.precio_unit * ip.cantidad) as ingresos
FROM items_pedido ip
JOIN platillos p ON ip.platillo_id = p.id
JOIN pedidos ped ON ip.pedido_id = ped.id
WHERE ped.fecha >= '2026-05-01' AND ped.fecha < '2026-06-01'
GROUP BY p.id, p.nombre
ORDER BY ingresos DESC;
Paso 1: EXPLAIN ANALYZE
Hash Join (cost=1250..8900 rows=50000 width=64)
(actual time=12500..44890 rows=50000 loops=1)
→ Seq Scan on pedidos (actual time=0.1..8200 rows=450000 loops=1)
Filter: (fecha >= '2026-05-01' AND fecha < '2026-06-01')
Rows Removed by Filter: 9549800
El problema: Seq Scan en pedidos con 10 millones de filas, descartando 9.5 millones.
Paso 2: crear índice sobre fecha
CREATE INDEX idx_pedidos_fecha ON pedidos (fecha);
ANALYZE pedidos;
Resultado: La misma consulta ahora tarda 0.8 segundos.
2.5 Ejercicios
✏️ Ejercicio 2.1 — Elegir el índice correcto
Para cada consulta, indicá qué índice crearías y por qué:
a. SELECT * FROM usuarios WHERE email = 'ana@example.com'
b. SELECT * FROM pedidos WHERE fecha BETWEEN '2026-01-01' AND '2026-12-31' AND estado = 'CERRADO'
c. SELECT * FROM platillos WHERE nombre LIKE '%pupusa%'
d. SELECT * FROM pedidos ORDER BY fecha DESC LIMIT 20
Solución
a. Índice en email — búsqueda de igualdad exacta. B-Tree o Hash (Hash si solo se usa para igualdad). Además, si el email es único, UNIQUE INDEX.
b. Índice compuesto en (estado, fecha) — el planificador filtrará primero por estado (más selectivo si hay pocos estados posibles) y luego por rango de fecha. O (fecha, estado) si la mayoría de los pedidos son de un estado específico.
c. No hay índice B-Tree que ayude con LIKE '%pupusa%' (prefijo no fijo). Opciones: índice GIN con pg_trgm para búsqueda de texto completo, o usar tsvector con búsqueda full-text.
d. Índice en fecha — el índice ya está ordenado, el motor puede leer los últimos 20 sin ordenar toda la tabla.
✏️ Ejercicio 2.2 — Interpretar EXPLAIN
Interpretá esta salida de EXPLAIN ANALYZE e identificá el problema principal:
Seq Scan on items_pedido (cost=0.00..98000.00 rows=850000 width=32)
(actual time=0.1..12400.0 rows=850000 loops=1)
Filter: (precio_unit > 0)
Rows Removed by Filter: 150
Planning Time: 0.5 ms
Execution Time: 12400.5 ms
¿Qué índice crearías? ¿Tiene sentido crearlo?
Solución
Problema: Full scan de 850,000 filas para encontrar las que tienen precio_unit > 0. Sólo 150 filas fueron descartadas, lo que significa que casi todas las filas satisfacen la condición.
¿Tiene sentido un índice? No. Si el 99.98% de las filas tienen precio_unit > 0, la selectividad es mínima. Un índice traería casi todas las filas de todas formas — el full scan es más eficiente porque lee las páginas de disco secuencialmente.
Solución real: El problema no es el índice — es que se están buscando 850,000 filas. La consulta probablemente necesita un WHERE pedido_id = ? o un filtro de fecha para reducir el conjunto antes.
Lección: Un índice sobre una columna con baja selectividad (pocos valores distintos o la mayoría de filas satisfacen el filtro) no ayuda.
2.6 Para profundizar
- Use The Index, Luke (
use-the-index-luke.com) — el mejor recurso gratuito sobre índices en SQL. - Kleppmann, Designing Data-Intensive Applications, cap. 3 — estructuras de almacenamiento.
- PostgreSQL docs: Performance Tips — official guide.
- Siguiente: Replicación y sharding.
Definiciones nuevas: índice, B-Tree, Hash index, GIN, full table scan, seq scan, index scan, EXPLAIN ANALYZE, query planner, selectividad, cardinalidad, índice compuesto, índice parcial, N+1 problem, covering index.