flowchart LR
Q["WHERE user_id = 42"] --> S{"¿hay índice?"}
S -->|no| SS["Seq Scan:<br/>lee 10.000.000 de filas<br/>descarta 9.999.980"]
S -->|sí| IS["Index Scan:<br/>baja por el árbol<br/>directo a las 20"]
21 12. Necesitamos que la API no se ponga lenta
Con 5 filas todo es instantáneo; con 10.000, tu query «obvia» escanea la tabla
En dev tu lista de tareas vuela con 5 filas. En prod hay 500.000 y tu query “obvia” las lee todas para encontrar 20. Este capítulo enseña las tres herramientas del backend que no se pone lento: índices (el índice del libro que estás leyendo), EXPLAIN (preguntarle a la BD cómo piensa ejecutar tu query) y paginación (nunca traer todo).
21.1 El problema
SELECT * FROM tasks WHERE user_id = 42 es correcto… y lento. Sin índice, Postgres hace un sequential scan: lee la tabla entera, fila a fila, buscando las de user_id = 42. Con 10.000 filas no lo notas; con 10 millones tu endpoint tarda segundos y cada request hace un scan — la API muere de éxito. Mientras tanto, en otra parte del código hay un loop que ejecuta una query por cada tarea (el famoso N+1) y nadie lo ve porque en dev son 5.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
El N+1 es el bug de rendimiento más común: 1 query para la lista + N queries (una por ítem) para los detalles = 101 viajes para 100 tareas. Se cura con JOIN o agregación: una sola query que lo trae todo.
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
Un índice es la tabla de contenido de una tabla: sin él, buscar un usuario es leer las 10 millones de filas (seq scan); con él, es ir directo a la página. Se crea según cómo se consulta, y cada uno cuesta escrituras.
21.2 Cómo lo resuelve un equipo
El método profesional es medir, no adivinar:
Un método es una función que vive dentro de un objeto/clase: task.save() — save es un método de task. La diferencia con una función suelta: el método conoce al objeto que lo contiene (this/ self).
EXPLAINla query lenta — Postgres te dice su plan: “voy a hacer Seq Scan” (malo, lee todo) o “voy a hacer Index Scan” (bueno).- Crear el índice que convierte el scan en búsqueda directa.
- Verificar con
EXPLAINotra vez — el plan cambió o no hiciste nada.
Un índice es literalmente el índice de este libro: en vez de leer todas las páginas buscando “índice”, vas a la lista ordenada del final que apunta directo. Postgres mantiene esa estructura (un árbol B-tree) actualizada en cada INSERT/UPDATE — por eso los índices no son gratis: aceleran lecturas y encarecen escrituras.
Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.
21.3 Conceptos nuevos
- D Índices: qué son, qué aceleran, qué cuestan (cada write actualiza el árbol)
- D
EXPLAIN/EXPLAIN ANALYZE: el plan de ejecución — Seq Scan vs Index Scan - D El problema N+1: 1 query para la lista + N queries para los detalles
- D Paginación:
LIMIT/OFFSETvs cursor — por qué?page=500degrada - U
SELECT *: traer 15 columnas para usar 4 también es performance
21.4 La explicación visual
21.5 Implementación
Paso 1 — ver el crimen. EXPLAIN ANALYZE ejecuta la query de verdad y muestra el plan con tiempos:
EXPLAIN ANALYZE
SELECT * FROM tasks WHERE user_id = 42 ORDER BY created_at DESC;
-- Seq Scan on tasks (cost=0.00..18334.00 rows=20) ← reads EVERYTHING
-- Filter: (user_id = 42)
-- Rows Removed by Filter: 999980
-- Execution Time: 312.411 msRows Removed by Filter: 999980 — leyó un millón para entregar 20.
Paso 2 — el índice que falta:
CREATE INDEX idx_tasks_user_done_created
ON tasks (user_id, done, created_at DESC);Índice compuesto: columnas en el orden del query (igualdad primero, orden después). Ahora:
-- Index Scan using idx_tasks_user_done_created (rows=20)
-- Execution Time: 0.041 ms ← 312ms → 0.04msPaso 3 — el N+1 que nadie ve. Este código parece inocente:
tasks = list_tasks(user_id) # 1 query — returns 20 tasks
for t in tasks:
t.tags = get_tags_for_task(t.id) # 20 MORE queries — one PER task
# total: 21 round trips to the DB for one HTTP requestLa cura: traerlo todo en una query (JOIN o IN):
Un JOIN combina tablas por su relación: “cada tarea con el nombre de su proyecto”. Es la superpotencia relacional — en una query traes lo que en NoSQL serían varios viajes.
SELECT t.id, t.title, tg.name
FROM tasks t
LEFT JOIN task_tags tt ON tt.task_id = t.id
LEFT JOIN tags tg ON tg.id = tt.tag_id
WHERE t.user_id = $1 AND t.done = FALSE;
-- 1 query → group rows in code (3 rows per task if 3 tags)Paso 4 — paginar. ?page=1&size=20 se traduce a LIMIT 20 OFFSET 0 — bien hasta que OFFSET 10000 obliga a la BD a contar y descartar 10.000 filas. La alternativa que escala es el
Paginar es servir la lista en páginas (20 por 20) en vez de las 10 millones. LIMIT 21 + cursor = la página siguiente se pide con la última fila vista, no con “salta 40000” (offset, que se degrada).
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
Vertical: máquina más gorda (más CPU/RAM) — fácil, tiene techo. Horizontal: más máquinas — el camino de internet, pero requiere que la app no guarde estado local (por eso todo va a BD/Redis).
cursor: recuerdas dónde quedaste y sigues desde ahí:
-- page 1
SELECT public_id, title, created_at FROM tasks
WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT 20;
-- page 2: continue AFTER the last row you saw (the cursor)
SELECT public_id, title, created_at FROM tasks
WHERE user_id = $1 AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC LIMIT 20;
-- the index walks DIRECTLY to row 21 — no counting, no discardingLa API devuelve next_cursor (el created_at,id de la última fila) y el cliente lo devuelve en la siguiente llamada. Así paginan las APIs reales (Twitter, Stripe) — nunca ?page=.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Mi endpoint GET /tasks está lento en producción. Contexto: tabla tasks
con 500k filas, query actual SELECT * FROM tasks WHERE user_id=$1 AND
done=FALSE, y además hago una query por tarea para traer sus tags (N+1).
Dame: el CREATE INDEX correcto y por qué ese orden de columnas, la
query única con JOIN que elimina el N+1, cómo leer el EXPLAIN ANALYZE
antes y después, y la paginación por cursor (keyset) en vez de
OFFSET con el campo next_cursor en la respuesta. Explícame también
qué no debo indexar.
21.6 ¿Por qué no indexar todo entonces?
Lo único que necesitas llevarte: un índice es una apuesta — pagas costo de escritura y espacio a cambio de lecturas rápidas. Se indexa lo que los WHERE/JOIN/ORDER realmente consultan, se mide con EXPLAIN, y se borra lo que nadie usa. Performance no es adivinar: es preguntarle a la BD qué hizo.
El planificador estima costos con estadísticas de tus datos — por eso la misma query puede elegir Seq Scan con 50 filas (correcto: el índice cuesta más que leer todo) e Index Scan con 5 millones. Un Seq Scan en EXPLAIN no siempre es un bug — lo es cuando la tabla crece y el plan no cambia. Y sí: a veces creas el índice perfecto y Postgres lo ignora porque estima que devuelves “casi toda la tabla” — entonces el scan es honestamente más barato.
21.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| N+1 invisible | En dev son 5 filas; el ORM lo disfraza de “código limpio” | Log de queries en dev: 1 request = N queries es la alarma |
| Índice en orden equivocado | (created_at, user_id) no sirve para WHERE user_id= |
Igualdad primero, orden después — lee tu WHERE |
OFFSET 100000 en prod |
“La paginación funcionaba” | Cursor/keyset para listas que crecen |
SELECT * en endpoints de lista |
Traes password_hash y 10 columnas que no usas |
Las columnas del contrato, como en cap. 5 |
| Índices “por si acaso” | Cada INSERT/UPDATE paga todos tus índices | pg_stat_user_indexes muestra los que nadie usa |
21.8 Buenas prácticas
- Log de queries en desarrollo — ver “1 request → 21 queries” cura el N+1 antes de que nazca.
EXPLAIN ANALYZEcon datos parecidos a prod — explicar contra 5 filas miente: cualquier plan se ve rápido.- Crea índices en migraciones (cap. 11) — son parte del esquema, versionados como todo.
CREATE INDEX CONCURRENTLYen tablas grandes de prod — no bloquea escrituras mientras se construye.
EXTRA Los roadmaps piden “estructuras de datos y algoritmos” — aquí está por qué les importa a un backend, conectado con lo que acabas de ver:
- Tabla hash → así funciona un índice
HASH - Árbol de búsqueda (BST) → el B-tree de los índices normales es su primo balanceado; por eso la búsqueda es O(log n)
- Búsqueda binaria → la misma idea: descartar la mitad en cada paso
- Arreglos / listas enlazadas / pilas / colas / grafos → el lenguaje de la entrevista; las colas son literalmente los jobs del cap-21
- Recursión y ordenamientos (burbuja, selección, inserción) → ejercicio clásico; en la vida real ordena la BD con
ORDER BY+ índice
No necesitas implementar un árbol rojo-negro; necesitas reconocer qué estructura usa cada herramienta — eso es lo que hace predecible el rendimiento.
21.9 Ejercicio
- Escribe el
EXPLAINparaWHERE user_id = 7 AND done = FALSE ORDER BY due_datey diseña el índice compuesto ideal. - Reescribe el N+1 de “listar usuarios con su conteo de tareas abiertas” en una sola query.
- ¿Por qué el cursor
(created_at, id)necesita dos columnas y no solocreated_at?
- Igualdad (
EXPLAIN ANALYZE SELECT * FROM tasks WHERE user_id = 7 AND done = FALSE ORDER BY due_date; CREATE INDEX idx_tasks_user_done_due ON tasks (user_id, done, due_date);user_id,done) primero, la de orden (due_date) al final — el índice ya sale ordenado. - Una query con
SELECT u.id, u.email, COUNT(t.id) AS open_tasks FROM users u LEFT JOIN tasks t ON t.user_id = u.id AND t.done = FALSE GROUP BY u.id, u.email;LEFT JOIN+COUNTen vez de “usuarios” + una query de conteo por cada uno. - Porque
created_atpuede repetirse (dos tareas al mismo instante) — el cursor debe ser determinista:(created_at, id)es único y la paginación no se traga ni repite filas.
21.10 Mini reto
Encontrar el N+1 en este fragmento que “parece inocente”:
const projects = await listProjects(); // SELECT * FROM projects
for (const p of projects) {
p.owner = await getUser(p.owner_id); // one query per project
p.taskCount = await countTasks(p.id); // and another one
p.tags = await getTags(p.id); // and ANOTHER one
}
// 20 projects → 1 + 60 queries. In dev you never noticed.Son tres N+1 anidados: por cada proyecto hay 3 queries (owner, count, tags) → 1 + 20×3 = 61 round trips. La cura es una query con JOINs y agregación:
SELECT p.*, u.email AS owner_email,
COUNT(DISTINCT t.id) AS task_count,
array_agg(DISTINCT tg.name) AS tags
FROM projects p
JOIN users u ON u.id = p.owner_id
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN project_tags pt ON pt.project_id = p.id
LEFT JOIN tags tg ON tg.id = pt.tag_id
GROUP BY p.id, u.email;Una query, un viaje — y el log de queries en dev te lo habría mostrado el primer día.
21.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.
Una traza sigue un request a través de todo el sistema: entró por el gateway → llamó auth → consultó la BD → tardó 340ms en la query. Cuando algo anda lento, la traza dice exactamente dónde.
El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.
Una migración es un cambio versionado del esquema: un archivo que dice “crea esta columna” (up) y “bórrala” (down). Son el historial Git de la estructura de la BD — se aplican en orden y no se editan una vez aplicadas.
Un ORM (Object-Relational Mapper) traduce entre objetos del código y filas de la tabla: task.save() en vez de INSERT. Cómodo para el CRUD, peligroso si no sabes qué SQL genera (el N+1 nace ahí).
Un log es el diario del programa: qué pasó, cuándo, con qué request. Estructurado = en JSON con campos (request_id, user_id), para filtrar por máquina y no con los ojos.
Producción (prod) es el entorno real: donde están los usuarios, los datos que importan y las consecuencias. Todo lo demás — local, staging — existe para que los errores ocurran antes de llegar ahí.