Í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 NO LIKE '%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 tabla para 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: estado con 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

✏️ 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?


2.6 Para profundizar


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.