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

NotaEn una frase

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).

  1. EXPLAIN la query lenta — Postgres te dice su plan: “voy a hacer Seq Scan” (malo, lee todo) o “voy a hacer Index Scan” (bueno).
  2. Crear el índice que convierte el scan en búsqueda directa.
  3. Verificar con EXPLAIN otra 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/OFFSET vs cursor — por qué ?page=500 degrada
  • U SELECT *: traer 15 columnas para usar 4 también es performance

21.4 La explicación visual

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.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 ms

Rows 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.04ms

Paso 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 request

La 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 discarding

La 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 ANALYZE con 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 CONCURRENTLY en 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

  1. Escribe el EXPLAIN para WHERE user_id = 7 AND done = FALSE ORDER BY due_date y diseña el índice compuesto ideal.
  2. Reescribe el N+1 de “listar usuarios con su conteo de tareas abiertas” en una sola query.
  3. ¿Por qué el cursor (created_at, id) necesita dos columnas y no solo created_at?
  1. 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);
    Igualdad (user_id, done) primero, la de orden (due_date) al final — el índice ya sale ordenado.
  2. 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;
    Una query con LEFT JOIN+COUNT en vez de “usuarios” + una query de conteo por cada uno.
  3. Porque created_at puede 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í.

21.12 Lo que deberías saber hacer ahora