Tu consulta va lenta y casi nunca es culpa de la base de datos
Cuando una pantalla tarda cuatro segundos en cargar, la reacción habitual es mirar el tamaño del servidor. Casi siempre el problema es otro: la pantalla lanza cuarenta y una consultas donde bastaba una o hace una sola que recorre entera una tabla porque le falta un índice. Ninguna de las dos se arregla pagando más máquina.
Lo primero: medir antes de adivinar
Antes de tocar nada hay que saber qué consulta es lenta y cuántas veces se ejecuta. Sin ese dato, optimizar es tirar a ciegas.
Dos cosas, en este orden:
- Registro de consultas lentas. Postgres puede anotar toda consulta que pase de un umbral. Se pone el umbral en algo bajo —200 ms— durante un rato y se mira qué sale.
- Contar consultas por petición. Casi todos los marcos de trabajo tienen forma de contarlas. Si una pantalla lanza más de diez, ahí está el problema. El índice puede esperar.
Ese segundo paso es el que más veces se salta y es el que más veces resuelve el caso.
El N+1, el caso más común
El patrón es siempre el mismo. Pides una lista y por cada elemento de la lista el código pide algo más:
SELECT * FROM facturas WHERE organizacion_id = 12; -- 1 consulta, 40 filas
-- y luego, dentro del bucle que pinta cada fila:
SELECT * FROM clientes WHERE id = 331; -- 40 consultas más
Cuarenta y una consultas para una pantalla. Cada una es rapidísima —medio milisegundo—, así que en el registro de lentas no aparece ninguna. Lo que se nota es la suma: cuarenta y un viajes de ida y vuelta a la base de datos. Ahí el coste es la latencia de red repetida cuarenta veces. Las consultas en sí son baratas.
Ninguna de las 41 consultas de arriba es lenta: cada una tarda medio milisegundo. Lo que se nota es la suma de los viajes.
Por qué aparece tanto: los ORM lo ocultan. factura.cliente.nombre parece un acceso a una
propiedad y es una consulta. El código se lee perfectamente y el problema no se ve leyendo, solo
midiendo.
Cómo se arregla: se piden los datos relacionados de una vez, con un JOIN o con una segunda
consulta que traiga todos los clientes de esas cuarenta facturas. Dos consultas en lugar de
cuarenta y una. Todos los ORM tienen forma de hacerlo; en la mayoría se llama eager loading.
Lo que no arregla: poner caché encima. La caché tapa el síntoma y el problema reaparece en cuanto el dato cambia. Encima te añade un problema de invalidación que antes no tenías.
Los índices y por qué no se ponen a todo
El segundo caso más común: una consulta que recorre la tabla entera porque no hay por dónde buscar. Con mil filas no se nota. Con doscientas mil, sí.
La regla corta: índice en lo que aparece en WHERE, en JOIN y en ORDER BY, empezando por
las columnas que más filtran.
Tres cosas que casi nadie tiene en cuenta:
- El orden importa en un índice compuesto. Un índice sobre
(organizacion_id, fecha)sirve para filtrar por organización y también para filtrar por organización y ordenar por fecha. No sirve para filtrar solo por fecha. - Una función sobre la columna anula el índice. Si consultas por el resultado de aplicar una función a la columna, el índice normal deja de usarse: hace falta un índice sobre esa expresión.
- Cada índice tiene un coste de escritura. Todo
INSERTy todoUPDATEactualizan también cada índice de la tabla. Indexarlo todo hace que las lecturas vuelen y que las escrituras se arrastren.
Por eso los índices se ponen sobre consultas que se han medido y ninguno «por si acaso».
Leer el plan de ejecución sin ser experto
EXPLAIN ANALYZE delante de la consulta te dice qué va a hacer Postgres y cuánto tarda de verdad.
Tiene fama de ilegible, pero para diagnosticar basta con mirar tres cosas:
Seq Scansobre una tabla grande. Está recorriendo la tabla entera. Si va acompañado de un filtro que descarta casi todo, falta un índice.- La diferencia entre filas estimadas y filas reales. Si Postgres esperaba 10 y encontró 40.000, sus estadísticas están desfasadas y todas sus decisiones posteriores parten de un dato falso. Se arregla actualizando las estadísticas de la tabla.
Nested Loopcon muchas iteraciones. Suele ser el N+1 escrito en SQL.
No hace falta entender el resto del árbol. Con esas tres señales se localiza casi todo.
Lo que sigue siendo lento aunque hagas esto
COUNT(*)sobre tablas grandes. Contar filas obliga a recorrerlas. Si solo lo necesitas para paginar, casi siempre vale una estimación o un «hay más resultados» en lugar del total exacto.OFFSETalto.OFFSET 10000obliga a leer y descartar diez mil filas. Los listados largos se paginan por cursor y el número de página queda para los cortos.LIKE '%algo%'. El comodín al principio impide usar el índice. Para buscar texto de verdad hace falta un índice de búsqueda de texto.- Consultas dentro de una transacción larga. Parecen lentas porque están esperando a otra cosa. Ahí el problema es el bloqueo.
Cuándo esto no merece la pena
Si tu tabla tiene mil filas y va a tener mil filas dentro de dos años, no optimices nada. Un
Seq Scan sobre mil filas es más rápido que la lectura del índice y Postgres lo sabe: por eso a
veces ignora un índice que acabas de crear.
El umbral práctico: cuando una tabla pasa de unas decenas de miles de filas y aparece en una pantalla que se usa a diario. Antes de eso, el tiempo se aprovecha mejor en otra parte.
Y una recomendación contra el reflejo habitual: antes de cambiar de tecnología, mide. La mayoría de las migraciones a otra base de datos «porque no escalaba» resuelven un N+1 por el camino y le atribuyen la mejora al motor nuevo.
Vecinly es multi-tenant, con filtros por organización en cada consulta, que es justo donde el índice compuesto decide si una pantalla tarda 80 ms o 4 segundos. Está en su ficha de caso y cómo se decide todo esto en la fase de diseño está en SaaS a medida.