Esta es la lección que llevamos anunciando desde el módulo 5. Cuando en 05-04 discutíamos si desnormalizar el esquema de BiblioRed, la regla operativa era tajante: los índices son lo primero que hay que probar antes de desnormalizar, y no se puede decidir una desnormalización sin haber leído antes un plan de ejecución. Aquí aprendemos a hacer las dos cosas.
El problema concreto ya tiene nombre y tiene número. El listado de préstamos vencidos por sucursal —el que el personal de mostrador abre cada mañana para llamar a los socios que se retrasan— tarda catorce segundos. En el portátil de desarrollo, con 3.000 préstamos de prueba, tardaba 30 milisegundos. En producción, con 2.841.077 filas en prestamos, tarda catorce segundos y la persona que atiende tiene a un socio delante mirándola.
Catorce segundos es un número interesante porque está en el peor sitio posible: es demasiado para trabajar y demasiado poco para que alguien lo declare una avería. Sencillamente, "el sistema va lento". Y de esa frase salen todos los rediseños innecesarios del mundo.
En esta lección veremos qué es un índice y cómo consigue convertir millones de comparaciones en cuatro lecturas de disco; qué cuesta un índice, porque no son gratis; los tipos que existen y cuándo usar cada uno; qué se indexa y qué no; cómo se lee un plan de ejecución línea a línea; los antipatrones que anulan un índice sin que nadie se dé cuenta; y el caso completo de esos catorce segundos, con su diagnóstico, su solución y su medición. Al final tendremos también el orden de intervención correcto ante una consulta lenta, que es lo que cierra el círculo abierto en 05-04.
Contenido
- Qué es un índice: dos analogías que sirven
- Cómo funciona un B-tree
- Los números: cuántas lecturas ahorra de verdad
- El coste de un índice: espacio, escrituras y planificación
CREATE INDEX: sintaxis, índices únicos yCONCURRENTLY- Índices compuestos y la regla del prefijo más a la izquierda
- Índices parciales
- Índices de expresión
- Panorama de tipos de índice en PostgreSQL
- Qué se indexa y qué no
- Leer un plan de ejecución:
EXPLAINyEXPLAIN ANALYZE - Los nodos que aparecen una y otra vez
- Coste, estimaciones y la señal de alarma
- Las estadísticas y
ANALYZE - Caso completo: los catorce segundos del listado de vencidos
- Antipatrones que anulan un índice
- Cómo se optimiza de verdad: el orden de intervención
- Mantenimiento e índices fuera de PostgreSQL
- Qué es un índice: dos analogías que sirven
Definición. Un índice es una estructura de datos auxiliar, mantenida automáticamente por el gestor, que permite localizar las filas que cumplen una condición sin recorrer la tabla entera.
Dos analogías, y las dos son de una biblioteca, que en BiblioRed viene muy a mano.
La primera: el índice de un libro. Para saber en qué páginas se habla de "normalización" en un manual de 900 páginas, no lees las 900. Vas al índice analítico del final, buscas la palabra —que está ordenada alfabéticamente, así que la encuentras en segundos— y lees "normalización: 412, 418-431, 507". Tres datos que te llevan directamente a lo que buscas.
Los tres elementos de esa analogía son exactamente los de un índice de base de datos:
| En el libro | En la base de datos |
|---|---|
| La palabra buscada | La clave del índice (el valor de la columna indexada) |
| El número de página | El puntero a la fila (en PostgreSQL, el ctid: página y posición) |
| Que el índice esté ordenado | La estructura ordenada que permite la búsqueda rápida |
| Que el índice ocupe 30 páginas más | El espacio en disco que cuesta el índice |
| Que haya que rehacerlo si se reedita el libro | El coste de mantenimiento en cada escritura |
La segunda: la propia biblioteca. Los 40.000 ejemplares de BiblioRed están colocados en las estanterías en un orden concreto —por materia y signatura—. Ese orden es un índice: permite encontrar un libro de historia sin recorrer las cuatro sucursales. Pero fíjate en que solo hay un orden físico posible: los libros no pueden estar simultáneamente ordenados por materia, por autor y por año.
Por eso las bibliotecas tienen, además, un catálogo: fichas ordenadas por autor, otras por título, otras por materia. Cada catálogo es un índice adicional que no cambia dónde está el libro, solo añade una forma más de encontrarlo. Y cada uno hay que actualizarlo cuando entra un ejemplar nuevo.
Esa es exactamente la situación de una tabla: los datos están en un orden (el que impuso el INSERT), y cada índice es un catálogo adicional que ofrece otra forma de llegar a ellos, con su coste de espacio y de mantenimiento.
- Cómo funciona un B-tree
El 95 % de los índices que crearás son B-tree (árbol B, en su variante B+). Es el tipo por omisión en PostgreSQL y en prácticamente todos los gestores. Entender cómo funciona explica casi todo su comportamiento.
Un B-tree es un árbol equilibrado en el que:
- Cada nodo contiene claves ordenadas y punteros.
- Los nodos internos solo sirven para dirigir la búsqueda: dicen "si buscas algo menor que X, ve por aquí".
- Los nodos hoja contienen las claves y los punteros a las filas reales.
- Las hojas están encadenadas entre sí, lo que permite recorridos por rango sin volver a subir.
- El árbol está equilibrado: todas las hojas están a la misma profundidad, así que toda búsqueda cuesta lo mismo.
Un índice sobre prestamos(fecha_devolucion_prevista), dibujado a escala reducida:
graph TD
R["<b>Raíz</b><br/>2023-04-01 | 2025-02-01"]
I1["<b>Interno</b><br/>2021-06-01 | 2022-09-01"]
I2["<b>Interno</b><br/>2023-11-01 | 2024-07-01"]
I3["<b>Interno</b><br/>2025-09-01 | 2026-05-01"]
H1["Hoja<br/>...2021-05-30 → ctid"]
H2["Hoja<br/>2022-09-01...2023-03-31 → ctid"]
H3["Hoja<br/>2023-11-01...2024-06-30 → ctid"]
H4["Hoja<br/>2024-07-01...2025-01-31 → ctid"]
H5["Hoja<br/>2025-09-01...2026-04-30 → ctid"]
H6["Hoja<br/>2026-05-01... → ctid"]
R --> I1
R --> I2
R --> I3
I1 --> H1
I1 --> H2
I2 --> H3
I2 --> H4
I3 --> H5
I3 --> H6
H1 -.-> H2
H2 -.-> H3
H3 -.-> H4
H4 -.-> H5
H5 -.-> H6
Para buscar fecha_devolucion_prevista = '2024-03-15':
- En la raíz: 2024-03-15 está entre 2023-04-01 y 2025-02-01 → baja por el nodo interno del centro.
- En el interno: está entre 2023-11-01 y 2024-07-01 → baja por la hoja correspondiente.
- En la hoja: busca la clave exacta y obtiene el
ctidde la fila. - Lee esa página de la tabla.
Cuatro accesos. Sobre 2,8 millones de filas.
Por qué es logarítmico
Cada nodo de un B-tree ocupa una página de disco —8 KB en PostgreSQL— y en esa página caben muchas claves. Con una fecha (8 bytes) más un puntero (6 bytes), en 8 KB caben del orden de 500 entradas por nodo. Ese número se llama factor de ramificación.
| Nivel | Nodos | Filas direccionables (factor 500) |
|---|---|---|
| 0 (raíz) | 1 | 500 |
| 1 | 500 | 250.000 |
| 2 | 250.000 | 125.000.000 |
| 3 | 125.000.000 | 62.500.000.000 |
Con tres niveles se direccionan 125 millones de filas. Los 2,8 millones de prestamos caben cómodamente en tres niveles, y en la práctica los niveles superiores están permanentemente en la caché de memoria (el gestor de buffers de 01-04), así que la búsqueda real cuesta una o dos lecturas de disco, no cuatro.
La fórmula: el número de niveles crece con el logaritmo en base 500 del número de filas. Multiplicar los datos por 500 añade un solo nivel. Esa es la razón por la que un índice sigue siendo rápido cuando la tabla crece: no escala con el número de filas, escala con su logaritmo.
- Los números: cuántas lecturas ahorra de verdad
Pongamos cifras a prestamos, con 2.841.077 filas y unos 120 bytes por fila.
SELECT pg_size_pretty(pg_relation_size('prestamos')) AS tabla,
(pg_relation_size('prestamos') / 8192) AS paginas;| Estrategia | Páginas leídas | Tiempo aproximado |
|---|---|---|
Recorrido secuencial (Seq Scan) |
52.736 | Segundos |
| Búsqueda por índice B-tree | 3-4 | Menos de un milisegundo |
La diferencia no es de un 20 %: es de cuatro órdenes de magnitud. Esa es la razón por la que la primera reacción ante una consulta lenta debe ser mirar si hay un índice, y no rediseñar el esquema.
Pero hay una letra pequeña fundamental, y explica muchas decisiones aparentemente extrañas del planificador:
Un índice solo compensa si la consulta devuelve una fracción pequeña de la tabla. Si va a devolver el 40 % de las filas, es más rápido recorrer la tabla entera secuencialmente que hacer un millón de saltos aleatorios.
La razón es física: leer 52.736 páginas seguidas aprovecha la lectura anticipada del sistema y del disco; leer 400.000 páginas salteadas no aprovecha nada. El punto de equilibrio en PostgreSQL suele estar entre el 5 % y el 10 % de la tabla, y lo decide el planificador con las estadísticas del apartado 14.
Por eso verás planes con Seq Scan sobre tablas indexadas y pensarás que el índice "no se usa". A menudo es que el planificador ha calculado, con razón, que no vale la pena.
- El coste de un índice: espacio, escrituras y planificación
Aquí está el motivo por el que la respuesta a "¿indexo esta columna?" no es siempre que sí.
Espacio
SELECT indexrelname AS indice,
pg_size_pretty(pg_relation_size(indexrelid)) AS tamano
FROM pg_stat_user_indexes
WHERE relname = 'prestamos'
ORDER BY pg_relation_size(indexrelid) DESC;indice | tamano ---------------------------------+-------- prestamos_pkey | 61 MB idx_prestamos_socio | 61 MB idx_prestamos_ejemplar | 61 MB idx_prestamos_fecha_prestamo | 61 MB
Cuatro índices sobre una tabla de 412 MB suman 244 MB: más de la mitad del tamaño de los datos. En una base de datos con muchos índices es habitual que los índices ocupen más que las tablas. Eso no solo cuesta disco: cuesta memoria caché, que es un recurso mucho más escaso, y cada índice compite con los datos por ella.
Escrituras
Este es el coste que de verdad importa.
| Operación | Trabajo sin índices | Trabajo con 4 índices |
|---|---|---|
INSERT |
Escribir 1 fila | Escribir 1 fila + insertar en 4 árboles |
UPDATE de una columna no indexada |
Escribir la nueva versión | Igual, si cabe en la misma página (optimización HOT) |
UPDATE de una columna indexada |
Escribir la nueva versión | + actualizar los índices afectados |
DELETE |
Marcar la fila | + marcar entradas en 4 árboles |
Una medición típica sobre una carga de 100.000 préstamos:
-- Con la tabla sin índices adicionales
INSERT INTO prestamos_carga SELECT * FROM prestamos LIMIT 100000;Más del triple. Y prestamos es una tabla que se escribe constantemente durante el horario de mostrador.
De aquí sale la práctica estándar de las cargas masivas: eliminar los índices, cargar, recrearlos. Recrear un índice de cero sobre datos ya presentes es mucho más rápido que mantenerlo fila a fila.
Planificación
Cada índice adicional es una alternativa más que el planificador debe evaluar. Con dos o tres índices es imperceptible; con quince en la misma tabla, el tiempo de planificación empieza a notarse en consultas que se ejecutan miles de veces por minuto.
La regla
Un índice no es gratis. Se crea para una consulta concreta que se ejecuta con frecuencia suficiente para justificar su coste de escritura, y se elimina cuando esa consulta deja de existir.
Y para saber si sobra, PostgreSQL lleva la cuenta:
SELECT indexrelname AS indice, idx_scan AS veces_usado,
pg_size_pretty(pg_relation_size(indexrelid)) AS tamano
FROM pg_stat_user_indexes
WHERE relname = 'prestamos'
ORDER BY idx_scan;indice | veces_usado | tamano ---------------------------------+-------------+-------- idx_prestamos_fecha_prestamo | 0 | 61 MB idx_prestamos_ejemplar | 118402 | 61 MB idx_prestamos_socio | 2044991 | 61 MB prestamos_pkey | 8811207 | 61 MB
Ese idx_prestamos_fecha_prestamo con idx_scan = 0 es un índice que solo cuesta: 61 MB de disco, un árbol que mantener en cada INSERT, y cero beneficio. Candidato claro a DROP INDEX, previa comprobación de que las estadísticas cubren un periodo representativo (no lo elimines el día después de reiniciar el servidor, ni sin mirar si lo usa el informe anual).
CREATE INDEX: sintaxis, índices únicos y CONCURRENTLY
CREATE INDEX: sintaxis, índices únicos y CONCURRENTLYLa forma básica:
Convención de nombres: idx_<tabla>_<columnas>. No es obligatoria —PostgreSQL genera un nombre si no lo das— pero un índice sin nombre reconocible es un índice que nadie se atreverá a borrar dentro de tres años.
Índices únicos
Aquí conviene aclarar una relación que confunde a mucha gente, y que enlaza con las restricciones de 04-04:
Toda restricción
UNIQUEy todaPRIMARY KEYse implementan internamente con un índice único. Al declarar la restricción, el índice se crea solo.
Restricción UNIQUE |
Índice único | |
|---|---|---|
| Cómo se declara | ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (col) |
CREATE UNIQUE INDEX ... ON t (col) |
| ¿Crea un índice? | Sí, automáticamente | Es el índice |
| ¿Puede referenciarla una clave ajena? | Sí | No |
| ¿Admite expresiones? | No | Sí (lower(email)) |
¿Admite condición WHERE? |
No | Sí (índice parcial) |
| Visibilidad | Aparece como restricción del esquema | Aparece como índice |
La recomendación: usa la restricción UNIQUE cuando expreses una regla de negocio —queda documentada en el esquema, y otras tablas pueden referenciarla— y el índice único solo cuando necesites lo que la restricción no puede dar: expresiones o condiciones parciales.
CONCURRENTLY
Crear un índice sobre una tabla en producción tiene un problema serio:
Esta instrucción toma un bloqueo SHARE sobre prestamos durante toda su ejecución. Con 2,8 millones de filas puede tardar un minuto, y durante ese minuto nadie puede escribir en la tabla: los cuatro mostradores se quedan parados. Es el mecanismo de bloqueo de la lección 06-02 en acción.
La alternativa:
Tarda bastante más —hace dos pasadas sobre la tabla— pero no bloquea las escrituras. Sus condiciones:
- No puede ejecutarse dentro de una transacción explícita (recuerda las excepciones al DDL transaccional de 06-01).
- Si falla a mitad, deja un índice inválido que hay que eliminar y rehacer. Detectarlos:
En producción, siempre CONCURRENTLY. En un entorno de desarrollo o en una ventana de mantenimiento, la forma normal es más rápida.
- Índices compuestos y la regla del prefijo más a la izquierda
Un índice puede abarcar varias columnas:
Y aquí aparece la regla que más confusión genera de toda la lección.
Regla del prefijo más a la izquierda. Un índice compuesto sobre
(A, B, C)puede usarse para consultas que filtren porA, porA y B, o porA, B y C. No puede usarse eficazmente para consultas que filtren solo porB, solo porC, o porB y C.
Por qué
Porque el índice está ordenado primero por A, y dentro de cada valor de A, por B. Es exactamente el orden de un listín telefónico ordenado por apellido y luego por nombre:
- Buscar "Alsina, Marta" es inmediato.
- Buscar todos los "Alsina" es inmediato.
- Buscar todas las "Marta" del listín obliga a recorrerlo entero, porque las Martas están dispersas por todas las páginas.
Demostración
-- Usa el índice: filtra por el prefijo (socio_id)
EXPLAIN (COSTS OFF)
SELECT * FROM prestamos WHERE socio_id = 14;QUERY PLAN ----------------------------------------------------------- Index Scan using idx_prestamos_socio_fecha on prestamos Index Cond: (socio_id = 14)
-- Usa el índice: prefijo completo
EXPLAIN (COSTS OFF)
SELECT * FROM prestamos WHERE socio_id = 14 AND fecha_prestamo = '2026-07-20';QUERY PLAN ------------------------------------------------------------------- Index Scan using idx_prestamos_socio_fecha on prestamos Index Cond: ((socio_id = 14) AND (fecha_prestamo = '2026-07-20'))
-- NO usa el índice como se espera: falta la primera columna
EXPLAIN (COSTS OFF)
SELECT * FROM prestamos WHERE fecha_prestamo = '2026-07-20';QUERY PLAN --------------------------------------------------- Seq Scan on prestamos Filter: (fecha_prestamo = '2026-07-20')
Un matiz honesto: PostgreSQL puede usar un índice compuesto sin el prefijo mediante un recorrido completo del índice (index scan sin condición de inicio), si el índice es mucho más pequeño que la tabla. Pero es un recurso desesperado y mucho más lento que un índice adecuado. La regla práctica sigue siendo válida.
Cómo ordenar las columnas
| Criterio | Regla |
|---|---|
| Igualdad antes que rango | Las columnas con = van primero; la de <, > o BETWEEN, al final |
| Selectividad | A igualdad de lo anterior, primero la más selectiva (la que más filas descarta) |
| Compartir prefijo | Si dos consultas comparten prefijo, un solo índice sirve para las dos |
Ejemplo aplicado. Estas dos consultas de BiblioRed:
SELECT * FROM prestamos WHERE socio_id = 14;
SELECT * FROM prestamos WHERE socio_id = 14 AND fecha_prestamo >= '2026-01-01';Un solo índice (socio_id, fecha_prestamo) sirve para ambas: la igualdad primero, el rango después. Crear además un índice sobre (socio_id) sería redundante y solo costaría.
Índices con columnas incluidas
PostgreSQL permite añadir columnas al índice que no participan en la búsqueda pero sí en el resultado:
CREATE INDEX idx_prestamos_socio_inc
ON prestamos (socio_id) INCLUDE (fecha_prestamo, fecha_devolucion_prevista);Sirve para conseguir un Index Only Scan (apartado 12): si todas las columnas que la consulta necesita están en el índice, no hace falta leer la tabla. Es una optimización notable en consultas muy repetidas.
- Índices parciales
Índice parcial. Índice que solo incluye las filas que cumplen una condición
WHERE. Es más pequeño, más rápido y más barato de mantener.
Es probablemente la característica de PostgreSQL más infrautilizada, y es exactamente lo que BiblioRed necesita.
Observa los números:
SELECT count(*) AS total,
count(*) FILTER (WHERE fecha_devolucion IS NULL) AS abiertos
FROM prestamos;De 2,8 millones de préstamos, solo 42.017 están abiertos: el 1,5 %. Y todas las consultas del mostrador —préstamos vigentes de un socio, vencidos por sucursal, avisos de devolución— filtran por fecha_devolucion IS NULL. Los otros 2,8 millones de filas son historia que nadie consulta en el día a día.
CREATE INDEX idx_prestamos_abiertos
ON prestamos (fecha_devolucion_prevista)
WHERE fecha_devolucion IS NULL;Comparación de tamaños:
SELECT indexrelname AS indice, pg_size_pretty(pg_relation_size(indexrelid)) AS tamano
FROM pg_stat_user_indexes WHERE relname = 'prestamos'
AND indexrelname IN ('idx_prestamos_fecha','idx_prestamos_abiertos');indice | tamano -------------------------+-------- idx_prestamos_fecha | 61 MB idx_prestamos_abiertos | 992 kB
61 MB frente a menos de 1 MB. Un índice 60 veces menor que:
- cabe entero en la caché de memoria y se consulta prácticamente sin tocar el disco;
- solo se actualiza cuando se crea un préstamo o se devuelve, no en cada modificación de filas históricas;
- y responde a la consulta caliente igual de bien.
Condición para que se use
PostgreSQL solo usará el índice parcial si puede demostrar que la consulta implica su condición. La condición del índice debe aparecer en el WHERE de forma reconocible:
-- SÍ lo usa
SELECT * FROM prestamos
WHERE fecha_devolucion IS NULL AND fecha_devolucion_prevista < CURRENT_DATE;
-- NO lo usa: el planificador no puede saber que estas filas son las mismas
SELECT * FROM prestamos
WHERE fecha_devolucion_prevista < CURRENT_DATE;Otros índices parciales útiles en BiblioRed
-- Solo socios activos: el 91 % de las consultas del mostrador
CREATE INDEX idx_socios_activos ON socios (apellidos, nombre) WHERE activo;
-- Solo multas pendientes de cobro: unas 300 de 48.000
CREATE INDEX idx_multas_pendientes ON multas (socio_id) WHERE estado = 'pendiente';
-- Solo eventos publicados y futuros
CREATE INDEX idx_eventos_publicados ON eventos (inicio) WHERE publicado AND estado = 'programado';Regla mental: si una consulta frecuente lleva siempre el mismo filtro sobre un estado, una bandera o un IS NULL, ese filtro debería estar en la definición del índice, no solo en la consulta.
- Índices de expresión
Un índice normal sobre titulo no sirve para buscar LOWER(titulo), porque el índice guarda los títulos como están escritos y la consulta pregunta por otra cosa. La solución es indexar la expresión:
Y la consulta debe usar exactamente la misma expresión:
EXPLAIN (COSTS OFF)
SELECT material_id, titulo FROM materiales WHERE lower(titulo) = 'el mapa del tiempo';QUERY PLAN ------------------------------------------------------------------ Index Scan using idx_materiales_titulo_lower on materiales Index Cond: (lower(titulo) = 'el mapa del tiempo'::text)
Otros casos habituales:
-- Búsqueda de socios por año de alta
CREATE INDEX idx_socios_anio_alta ON socios (extract(year FROM fecha_alta));
-- Correo normalizado y único, insensible a mayúsculas
CREATE UNIQUE INDEX idx_socios_email_unico ON socios (lower(email));
-- Duración de un evento, si se consulta a menudo
CREATE INDEX idx_eventos_duracion ON eventos ((fin - inicio));Ojo a los paréntesis dobles del último ejemplo: cuando la expresión no es una llamada a función, hay que envolverla en paréntesis propios.
Y una advertencia: la función debe ser inmutable (IMMUTABLE), es decir, devolver siempre lo mismo para la misma entrada. Por eso no se puede indexar now() ni una función que dependa de la configuración regional de la sesión:
En el apartado 16 veremos la cara oscura de esto: una función aplicada en el WHERE sobre una columna indexada anula el índice, y es uno de los antipatrones más frecuentes.
- Panorama de tipos de índice en PostgreSQL
B-tree resuelve casi todo, pero conviene saber qué hay disponible y para qué:
| Tipo | Operadores que acelera | Casos de uso típicos | En BiblioRed |
|---|---|---|---|
| B-tree (por omisión) | =, <, <=, >, >=, BETWEEN, IN, LIKE 'texto%', ORDER BY |
Casi todo | Todos los que hemos creado |
| Hash | Solo = |
Igualdad exacta sobre valores largos | Rara vez; B-tree hace lo mismo y más |
| GIN | @>, ?, @@ |
jsonb, arrays, búsqueda de texto completo |
Buscador del catálogo por palabras del título y resumen |
| GiST | &&, <@, <->, solapes |
Rangos, geometrías, vecinos más próximos | El EXCLUDE USING gist que evita solapes de eventos en una sala (04-04) |
| SP-GiST | Particiones no equilibradas | Datos jerárquicos, direcciones IP, texto con prefijos | No aplica |
| BRIN | Rangos sobre datos físicamente ordenados | Tablas enormes con correlación entre orden físico y valor | prestamos(fecha_prestamo): las filas se insertan en orden cronológico |
GIN para el buscador del catálogo
El catálogo web de BiblioRed permite buscar por palabras del título y del resumen. Con LIKE '%palabra%' no hay índice que valga (apartado 16). Con búsqueda de texto completo, sí:
ALTER TABLE materiales
ADD COLUMN busqueda tsvector
GENERATED ALWAYS AS (
to_tsvector('spanish', coalesce(titulo,'') || ' ' || coalesce(resumen,''))
) STORED;
CREATE INDEX idx_materiales_busqueda ON materiales USING gin (busqueda);SELECT material_id, titulo
FROM materiales
WHERE busqueda @@ websearch_to_tsquery('spanish', 'mapa tiempo'); material_id | titulo
-------------+-----------------------
907 | El mapa del tiempo
1482 | El tiempo de los mapasLa columna generada mantiene el vector actualizado sin disparadores —es la técnica de 04-04— y el índice GIN lo hace consultable en milisegundos.
BRIN para tablas enormes ordenadas por fecha
Un índice BRIN no guarda una entrada por fila, sino un resumen por bloque de páginas: el valor mínimo y el máximo de cada rango. Es diminuto, y funciona bien cuando el orden físico de las filas se corresponde con el valor de la columna, que es justo lo que ocurre con una tabla de historial donde las filas se insertan cronológicamente.
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE indexrelname LIKE 'idx_prestamos_fecha%';indexrelname | pg_size_pretty ----------------------------+---------------- idx_prestamos_fecha | 61 MB idx_prestamos_fecha_brin | 48 kB
61 MB frente a 48 kB. A cambio, es menos preciso: descarta bloques enteros, no filas, así que después hay que filtrar. Para el informe anual de préstamos por trimestre es perfecto; para buscar un préstamo concreto, no.
Regla: si no sabes cuál elegir, es B-tree. Los demás tipos responden a necesidades muy concretas y se reconocen porque B-tree no puede acelerar el operador que necesitas.
- Qué se indexa y qué no
Se indexa
| Caso | Por qué |
|---|---|
| Claves primarias | Ya está hecho automáticamente |
| Claves ajenas | PostgreSQL NO las indexa solo. Ver abajo |
Columnas usadas en WHERE con frecuencia |
Es el caso canónico |
Columnas usadas en JOIN |
Cada JOIN es una búsqueda repetida |
Columnas usadas en ORDER BY sobre muchas filas |
Un índice evita la ordenación |
| Columnas con alta cardinalidad | Muchos valores distintos = alta selectividad |
La trampa de las claves ajenas
Esto sorprende a casi todo el mundo, y es una de las causas más frecuentes de lentitud inexplicable:
PostgreSQL crea un índice automáticamente para la
PRIMARY KEYy para las restriccionesUNIQUE, pero NO para las claves ajenas. El índice está en el lado referenciado (la clave primaria del padre), no en el lado que referencia.
Consecuencias de no indexar prestamos.socio_id:
- Cada
JOINconsociosrecorreprestamosentera. - Cada
DELETEoUPDATEde la clave ensociosrecorreprestamosentera para comprobar la integridad referencial de 02-06. Borrar un socio en una tabla de 2,8 millones de filas puede tardar segundos.
Encontrar las claves ajenas sin índice:
SELECT c.conrelid::regclass AS tabla,
a.attname AS columna
FROM pg_constraint c
JOIN unnest(c.conkey) AS k(attnum) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid
AND i.indkey[0] = k.attnum
);tabla | columna ------------------+--------------- inscripciones | socio_id telefonos_socio | socio_id participaciones | ponente_id pagos | multa_id
Cuatro claves ajenas sin índice en BiblioRed. Las cuatro son candidatas inmediatas.
Regla práctica: indexa todas las claves ajenas, salvo que hayas comprobado que la tabla hija es pequeña y no se usa en JOIN.
No se indexa
| Caso | Por qué |
|---|---|
| Columnas de baja cardinalidad | Ver abajo |
| Tablas muy pequeñas (menos de ~1.000 filas) | El recorrido secuencial cabe en memoria y es más rápido |
| Columnas que casi nunca se filtran | Solo cuestan |
| Tablas con muchísima escritura y poca lectura | El coste de mantenimiento domina |
Por qué indexar activo suele ser inútil
socios.activo es un booleano. De 12.000 socios, 10.900 están activos: el 91 %.
CREATE INDEX idx_socios_activo ON socios (activo);
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM socios WHERE activo = true;QUERY PLAN ----------------------------------------------------------------- Seq Scan on socios (actual time=0.011..2.884 rows=10900 loops=1) Filter: activo Rows Removed by Filter: 1100 Planning Time: 0.114 ms Execution Time: 3.402 ms
El planificador ignora el índice, y hace bien: recuperar el 91 % de las filas por índice significa saltar por casi toda la tabla en orden aleatorio, lo que es más lento que leerla de corrido. El índice existe, ocupa espacio, se mantiene en cada escritura y no se usa nunca.
La misma columna con la selectividad invertida sí sirve, y por eso el índice parcial es la respuesta:
-- Los 1.100 socios inactivos sí son una fracción pequeña
CREATE INDEX idx_socios_inactivos ON socios (apellidos) WHERE NOT activo;
-- O, mejor aún: indexar lo que sí se busca, restringido a los activos
CREATE INDEX idx_socios_activos_nombre ON socios (apellidos, nombre) WHERE activo;Esta segunda forma es la buena. La columna activo no aporta selectividad, pero restringe el índice a las filas interesantes, y las columnas indexadas son las que de verdad se buscan.
- Leer un plan de ejecución:
EXPLAIN y EXPLAIN ANALYZE
EXPLAIN y EXPLAIN ANALYZEEl plan de ejecución es la estrategia que el optimizador —esa caja del diagrama de 01-04— ha elegido para responder a la consulta. Leerlo es la habilidad central de esta lección: sin ella, optimizar es adivinar.
| Instrucción | Ejecuta la consulta | Da tiempos reales | Riesgo |
|---|---|---|---|
EXPLAIN consulta |
No | No | Ninguno |
EXPLAIN ANALYZE consulta |
Sí | Sí | Ejecuta también UPDATE/DELETE |
Aviso importante.
EXPLAIN ANALYZEsobre unDELETEborra las filas. Para analizar una instrucción de escritura sin efectos, envuélvela en una transacción y deshazla:BEGIN; EXPLAIN ANALYZE DELETE FROM prestamos WHERE prestamo_id = 88301; ROLLBACK;
La forma completa que conviene usar siempre:
| Opción | Qué añade |
|---|---|
ANALYZE |
Ejecuta y muestra tiempos y filas reales |
BUFFERS |
Páginas leídas de caché y de disco. Muy informativo |
VERBOSE |
Columnas de salida de cada nodo |
COSTS OFF |
Oculta los costes; útil para comparar planes sin ruido |
SETTINGS |
Parámetros no estándar que afectan al plan |
Cómo se lee el árbol
Un plan es un árbol de nodos, y se lee de dentro hacia fuera y de abajo arriba:
- Cada
->marca un nivel de anidamiento. - Los nodos más indentados se ejecutan primero.
- Cada nodo consume las filas que producen sus hijos y entrega filas a su padre.
- La primera línea es el último paso: lo que se devuelve al cliente.
Sort ← 5.º y último: ordena el resultado
-> Hash Join ← 4.º: combina los dos lados
-> Seq Scan on a ← 1.º: recorre a
-> Hash ← 3.º: construye la tabla hash
-> Seq Scan b ← 2.º: recorre b
- Los nodos que aparecen una y otra vez
Nodos de acceso a datos
| Nodo | Qué hace | Cuándo es buena señal | Cuándo es mala |
|---|---|---|---|
Seq Scan |
Lee la tabla entera | Tabla pequeña, o se necesita gran parte de ella | Tabla grande + pocas filas devueltas: falta un índice |
Index Scan |
Recorre el índice y va a la tabla por cada coincidencia | Pocas filas | Muchas filas: mejor Seq Scan |
Index Only Scan |
Responde solo con el índice, sin tocar la tabla | Siempre. Es lo mejor que puede pasar | — |
Bitmap Heap Scan |
Recoge los ctid del índice, los ordena y lee la tabla en orden físico |
Cantidad intermedia de filas | — |
Bitmap Index Scan |
Hijo del anterior: construye el mapa de bits | — | — |
Tid Scan |
Acceso directo por ctid |
Raro | — |
El Bitmap Heap Scan merece explicación porque desconcierta. Cuando la consulta va a devolver, digamos, 40.000 filas de 2,8 millones, un Index Scan haría 40.000 saltos aleatorios por el disco. El bitmap resuelve el problema en dos fases: primero recoge del índice todas las direcciones, luego las ordena por posición física y lee la tabla de principio a fin visitando solo las páginas necesarias. Es el punto intermedio entre índice y recorrido secuencial, y verlo suele significar que el planificador está haciendo lo correcto.
Nodos de combinación
| Nodo | Cómo funciona | Bueno cuando |
|---|---|---|
Nested Loop |
Por cada fila del lado externo, busca en el interno | El lado externo tiene pocas filas y el interno tiene índice |
Hash Join |
Construye una tabla hash con el lado pequeño y recorre el grande | Ambos lados grandes, sin índice útil, y el pequeño cabe en memoria |
Merge Join |
Recorre los dos lados ordenados a la vez | Ambos ya vienen ordenados por la clave de unión |
La señal de alarma más común: un Nested Loop cuyo lado externo devuelve muchas más filas de las estimadas. Si el planificador esperaba 5 y hay 50.000, ejecutará 50.000 búsquedas en lugar de 5. Es la causa número uno de consultas que "de pronto" pasan de milisegundos a minutos.
Nodos de procesamiento
| Nodo | Qué hace | A vigilar |
|---|---|---|
Sort |
Ordena | Sort Method: external merge Disk: ... significa que no cabía en memoria |
Aggregate / HashAggregate / GroupAggregate |
Agrupa y calcula (el GROUP BY de 02-05) |
HashAggregate con Disk indica falta de memoria |
Limit |
Corta el resultado | — |
Materialize |
Guarda un resultado intermedio para reutilizarlo | — |
Gather / Gather Merge |
Reúne resultados de trabajadores paralelos | Indica ejecución en paralelo |
Memoize |
Cachea resultados de un Nested Loop repetitivo |
Buena señal |
Cuando veas external merge Disk: 84320kB, la ordenación se ha ido a disco. A menudo se arregla subiendo work_mem para esa consulta:
- Coste, estimaciones y la señal de alarma
Cada nodo lleva dos bloques de números:
Seq Scan on prestamos p (cost=0.00..403820.46 rows=41960 width=20)
(actual time=0.048..13755.902 rows=42017 loops=1)| Elemento | Significado |
|---|---|
cost=0.00..403820.46 |
Coste estimado: primer número, coste de devolver la primera fila; segundo, de devolverlas todas |
rows=41960 |
Filas que el planificador estima que devolverá el nodo |
width=20 |
Bytes medios por fila |
actual time=0.048..13755.902 |
Milisegundos reales hasta la primera fila y hasta la última |
rows=42017 |
Filas reales devueltas |
loops=1 |
Cuántas veces se ejecutó este nodo |
Tres advertencias imprescindibles sobre el coste:
- El coste no son milisegundos. Es una unidad arbitraria en la que 1,0 equivale, por convenio, a leer una página secuencialmente. Sirve para comparar planes entre sí, no para predecir tiempo.
- Los costes son acumulativos: el de un nodo incluye el de sus hijos. El coste total de la consulta es el de la primera línea.
- Con
loops > 1, los tiempos y las filas son POR ITERACIÓN. Un nodo conactual time=0.012..0.014 rows=1 loops=42017no tardó 0,014 ms: tardó unos 590 ms en total. Es el error de lectura más frecuente y el que hace que la gente busque el problema en el sitio equivocado.
La señal de alarma
Compara siempre
rows=estimado conrows=real en cada nodo. Una diferencia de más de un orden de magnitud es el diagnóstico más valioso que da un plan de ejecución.
Cuando el planificador estima 5 filas y hay 50.000, todas sus decisiones posteriores están mal fundadas: eligió Nested Loop porque creía que iba a iterar cinco veces. Y el plan no es malo por el algoritmo, es malo por la información.
| Diferencia | Interpretación | Qué hacer |
|---|---|---|
| Estimado ≈ real | El planificador está informado. Si va lento, es otra cosa | Buscar en otro sitio |
| Estimado ≪ real | Estadísticas desactualizadas, o correlación entre columnas | ANALYZE, estadísticas extendidas |
| Estimado ≫ real | Igual, en sentido contrario | Lo mismo |
Diferencia solo en un nodo con JOIN |
Correlación entre columnas que el planificador no conoce | CREATE STATISTICS |
- Las estadísticas y
ANALYZE
ANALYZEEl planificador no mira los datos: mira un resumen estadístico de los datos. Si el resumen está mal, el plan está mal.
SELECT attname AS columna, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'prestamos' AND attname IN ('socio_id','fecha_devolucion'); columna | n_distinct | most_common_vals | most_common_freqs
------------------+------------+---------------------------+--------------------
socio_id | 11842 | {14,15,16,882,1204} | {0.0031,0.0028,...}
fecha_devolucion | -0 | |Qué guarda PostgreSQL de cada columna:
| Dato | Para qué sirve |
|---|---|
n_distinct |
Cuántos valores distintos (negativo = proporción sobre el total) |
most_common_vals / most_common_freqs |
Los valores más frecuentes y su proporción |
| Histograma | Cómo se reparte el resto de valores |
null_frac |
Proporción de nulos |
correlation |
Cuánto se parece el orden físico al orden lógico. Determina si un BRIN sirve |
Cuándo se actualizan
El proceso autovacuum ejecuta ANALYZE automáticamente cuando una tabla acumula suficientes cambios (por omisión, un 10 % de las filas). Hay que forzarlo a mano en tres situaciones:
- Después de una carga masiva. Las estadísticas son de antes de cargar.
- Después de crear un índice de expresión. Necesita estadísticas propias de la expresión.
- Antes de medir un plan, para no diagnosticar sobre información obsoleta.
Si una columna tiene una distribución muy irregular, se le puede pedir más detalle:
El valor por omisión es 100 (100 valores frecuentes y 100 cubetas de histograma). Subirlo mejora las estimaciones y encarece un poco el ANALYZE.
Estadísticas extendidas: cuando las columnas están relacionadas
El planificador supone que las columnas son independientes. Cuando no lo son, se equivoca por mucho. Ejemplo real de BiblioRed: ejemplares.sucursal_id y ejemplares.estado no son independientes —la sucursal Este es pequeña y presta poco, así que casi todos sus ejemplares están disponibles—.
CREATE STATISTICS stat_ejemplares_suc_estado (dependencies, ndistinct)
ON sucursal_id, estado FROM ejemplares;
ANALYZE ejemplares;Es una herramienta poco conocida y muy eficaz cuando el síntoma es "el estimado y el real se separan solo cuando filtro por dos columnas a la vez".
- Caso completo: los catorce segundos del listado de vencidos
Vamos al problema real, de principio a fin.
La consulta
SELECT s.nombre AS sucursal,
so.apellidos, so.nombre,
m.titulo,
p.fecha_devolucion_prevista,
CURRENT_DATE - p.fecha_devolucion_prevista AS dias_retraso
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN sucursales s ON s.sucursal_id = e.sucursal_id
JOIN socios so ON so.socio_id = p.socio_id
JOIN materiales m ON m.material_id = e.material_id
WHERE p.fecha_devolucion IS NULL
AND p.fecha_devolucion_prevista < CURRENT_DATE
AND e.sucursal_id = 1
ORDER BY p.fecha_devolucion_prevista;Paso 1: medir
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------
Sort (cost=418902.55..418913.03 rows=4192 width=98) (actual time=14023.881..14024.107 rows=312 loops=1)
Sort Key: p.fecha_devolucion_prevista
Sort Method: quicksort Memory: 76kB
-> Hash Join (cost=3894.12..418650.22 rows=4192 width=98) (actual time=142.905..14022.318 rows=312 loops=1)
Hash Cond: (p.socio_id = so.socio_id)
-> Hash Join (cost=2810.00..417555.44 rows=4192 width=64) (actual time=118.774..13998.002 rows=312 loops=1)
Hash Cond: (e.material_id = m.material_id)
-> Hash Join (cost=1204.00..415938.20 rows=4192 width=28) (actual time=32.118..13911.440 rows=312 loops=1)
Hash Cond: (p.ejemplar_id = e.ejemplar_id)
-> Seq Scan on prestamos p (cost=0.00..403820.46 rows=41960 width=20)
(actual time=0.048..13755.902 rows=42017 loops=1)
Filter: ((fecha_devolucion IS NULL) AND (fecha_devolucion_prevista < CURRENT_DATE))
Rows Removed by Filter: 2799060
Buffers: shared hit=1284 read=51452
-> Hash (cost=1079.00..1079.00 rows=9998 width=12) (actual time=31.702..31.703 rows=9998 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 558kB
-> Seq Scan on ejemplares e (cost=0.00..1079.00 rows=9998 width=12)
(actual time=0.017..29.114 rows=9998 loops=1)
Filter: (sucursal_id = 1)
Rows Removed by Filter: 30002
Planning Time: 1.204 ms
Execution Time: 14025.663 msPaso 2: leerlo línea a línea
Execution Time: 14025.663 ms — El número que hay que bajar. 14 segundos.
Seq Scan on prestamos p ... (actual time=0.048..13755.902 rows=42017 loops=1) — Aquí está el 98 % del tiempo. El nodo tarda 13,7 de los 14 segundos él solo. Todo lo demás es ruido.
Rows Removed by Filter: 2799060 — La línea más elocuente del plan. PostgreSQL ha leído 2.841.077 filas y ha descartado 2.799.060. Ha hecho el 98,5 % del trabajo para nada.
Buffers: shared hit=1284 read=51452 — 51.452 páginas leídas de disco (read) y solo 1.284 encontradas en caché (hit). Son 402 MB de disco por una consulta que devuelve 312 filas.
rows=41960 estimado frente a rows=42017 real — Aquí no hay problema de estadísticas: la estimación es excelente. El planificador sabía perfectamente lo que hacía; eligió Seq Scan porque no tenía ninguna alternativa. No hay ningún índice que sirva.
Seq Scan on ejemplares e ... Rows Removed by Filter: 30002 — El segundo problema, mucho menor: recorre los 40.000 ejemplares para quedarse con los 9.998 de la sucursal 1. Son 29 ms, no es el drama, pero también falta un índice ahí.
Sort Method: quicksort Memory: 76kB — La ordenación es irrelevante: 312 filas en memoria.
Hash Join — Correctos. Con 42.017 filas por un lado y 9.998 por otro, sin índices, el hash es la elección adecuada.
Paso 3: diagnóstico
Un diagnóstico se escribe en una frase:
La consulta recorre los 2,8 millones de préstamos para quedarse con 42.017 abiertos y vencidos —el 1,5 %—, porque no existe ningún índice sobre
fecha_devolucionni sobrefecha_devolucion_prevista. Secundariamente, recorre los 40.000 ejemplares por falta de índice ensucursal_id.
Fíjate en lo que no es el problema: no son los cuatro JOIN, no es el esquema normalizado, no es el ORDER BY, no es "la tabla es muy grande". Es un índice que falta.
Paso 4: los índices
-- El índice caliente: parcial sobre los préstamos abiertos
CREATE INDEX CONCURRENTLY idx_prestamos_abiertos
ON prestamos (fecha_devolucion_prevista)
WHERE fecha_devolucion IS NULL;
-- Clave ajena sin indexar, además del filtro por sucursal
CREATE INDEX CONCURRENTLY idx_ejemplares_sucursal
ON ejemplares (sucursal_id);
ANALYZE prestamos;
ANALYZE ejemplares;¿Por qué parcial? Porque las consultas del mostrador siempre llevan fecha_devolucion IS NULL, y así el índice pasa de 61 MB a menos de 1 MB, cabe entero en memoria y solo se toca al prestar y al devolver, no en cada modificación de las filas históricas.
Paso 5: medir de nuevo
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Sort (cost=4218.66..4229.14 rows=4192 width=98) (actual time=38.902..38.941 rows=312 loops=1)
Sort Key: p.fecha_devolucion_prevista
Sort Method: quicksort Memory: 76kB
-> Hash Join (cost=1912.44..3966.33 rows=4192 width=98) (actual time=12.401..38.114 rows=312 loops=1)
Hash Cond: (p.socio_id = so.socio_id)
-> Hash Join (cost=828.32..2871.55 rows=4192 width=64) (actual time=7.882..33.220 rows=312 loops=1)
Hash Cond: (e.material_id = m.material_id)
-> Nested Loop (cost=0.71..2033.18 rows=4192 width=28) (actual time=0.094..27.556 rows=312 loops=1)
-> Index Scan using idx_prestamos_abiertos on prestamos p
(cost=0.29..912.44 rows=41960 width=20) (actual time=0.041..9.882 rows=42017 loops=1)
Index Cond: (fecha_devolucion_prevista < CURRENT_DATE)
Buffers: shared hit=118 read=6
-> Index Scan using ejemplares_pkey on ejemplares e
(cost=0.42..0.42 rows=1 width=12) (actual time=0.000..0.000 rows=0 loops=42017)
Index Cond: (ejemplar_id = p.ejemplar_id)
Filter: (sucursal_id = 1)
Rows Removed by Filter: 1
Planning Time: 1.882 ms
Execution Time: 39.204 msPaso 6: comparar y comentar
| Métrica | Antes | Después | Mejora |
|---|---|---|---|
| Tiempo de ejecución | 14.025,663 ms | 39,204 ms | ×358 |
| Páginas leídas de disco | 51.452 | 6 | ×8.575 |
| Filas descartadas por filtro | 2.799.060 | 42.017 | ×67 |
| Nodo dominante | Seq Scan 13,7 s |
Index Scan 9,9 ms |
— |
Catorce segundos convertidos en cuarenta milisegundos. Sin tocar el esquema. Sin desnormalizar. Sin una sola tabla nueva. Dos CREATE INDEX.
Dos observaciones sobre el plan nuevo:
- El
Hash Joinse ha convertido enNested Loop. Al abaratarse el acceso aprestamos, el planificador ha cambiado de estrategia: ahora recorre los 42.017 préstamos abiertos y, por cada uno, busca su ejemplar por clave primaria. Es un ejemplo perfecto de que crear un índice no solo acelera un nodo: cambia el plan entero. loops=42017en el nodo deejemplares. Recuerda el apartado 13: eseactual time=0.000..0.000es por iteración. El nodo se ejecuta 42.017 veces, y su contribución real son unos 17 ms de los 39. Está bien, pero es el sitio donde miraríamos si hiciera falta seguir bajando.
¿Y si aún no bastara?
Supongamos que el mostrador necesitara bajar de 40 ms —lo cual es dudoso, pero sirve para cerrar el círculo de 05-04—. El siguiente paso no sería crear una tabla de resumen. Sería observar que la consulta filtra por sucursal y que la sucursal está en ejemplares, no en prestamos, lo que obliga a recorrer los 42.017 préstamos de las cuatro sucursales para quedarse con los de una.
La solución sería duplicar sucursal_id en prestamos —la técnica 3 de 05-04, "duplicar un atributo para evitar un JOIN"— e indexar (sucursal_id, fecha_devolucion_prevista) WHERE fecha_devolucion IS NULL. Eso llevaría la consulta al orden del milisegundo.
Y fíjate en el orden en que hemos llegado hasta ahí: medir, índice, medir otra vez, y solo entonces plantear tocar el esquema, con el número exacto de lo que se gana. Ese es exactamente el procedimiento que 05-04 exigía y que el apartado siguiente formaliza.
- Antipatrones que anulan un índice
Existe el índice, es el correcto, y aun así el plan muestra Seq Scan. Casi siempre es uno de estos cinco.
- Función sobre la columna en el
WHERE
WHERE-- MAL: el índice sobre fecha_prestamo no sirve
SELECT * FROM prestamos WHERE extract(year FROM fecha_prestamo) = 2026;Seq Scan on prestamos (actual time=0.031..1204.882 rows=118402 loops=1) Filter: (EXTRACT(year FROM fecha_prestamo) = '2026'::numeric)
El índice guarda fechas; la consulta pregunta por años. Son cosas distintas.
-- BIEN: reescribir como rango sobre la columna desnuda
SELECT * FROM prestamos
WHERE fecha_prestamo >= DATE '2026-01-01'
AND fecha_prestamo < DATE '2027-01-01';Index Scan using idx_prestamos_fecha_prestamo on prestamos (actual time=0.038..44.112 rows=118402 loops=1) Index Cond: ((fecha_prestamo >= '2026-01-01') AND (fecha_prestamo < '2027-01-01'))
La alternativa, si la reescritura no fuera posible, es un índice de expresión sobre extract(year FROM fecha_prestamo). Pero reescribir es casi siempre mejor: el rango sirve además para cualquier otro periodo.
Variante muy frecuente del mismo error:
-- MAL
WHERE upper(apellidos) = 'ALSINA'
-- BIEN (con índice de expresión sobre lower(apellidos))
WHERE lower(apellidos) = 'alsina'Nota el matiz: aquí la función no desaparece, pero coincide con la del índice, y por eso funciona.
LIKE con comodín al principio
LIKE con comodín al principioUn B-tree ordena por el principio de la cadena. Buscar por lo que hay en medio obliga a mirarlo todo, igual que buscar en un diccionario todas las palabras que contengan "apa".
-- BIEN si buscas por prefijo: el índice sí sirve
SELECT * FROM materiales WHERE titulo LIKE 'El mapa%';
-- BIEN para búsqueda por palabras: texto completo con GIN
SELECT * FROM materiales WHERE busqueda @@ websearch_to_tsquery('spanish', 'mapa');
-- BIEN para búsqueda por subcadena arbitraria: índice de trigramas
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_materiales_titulo_trgm ON materiales USING gin (titulo gin_trgm_ops);
SELECT * FROM materiales WHERE titulo ILIKE '%mapa%';Un detalle importante para el español: para que LIKE 'El mapa%' use el índice cuando la base de datos no está en la colación C, hay que crear el índice con la clase de operadores adecuada:
- Comparar tipos distintos
-- MAL: socio_id es INTEGER y se compara con texto
SELECT * FROM prestamos WHERE socio_id::text = '14';Es la misma trampa del antipatrón 1 disfrazada: socio_id::text es una función sobre la columna.
Ocurre mucho con controladores mal configurados que envían todos los parámetros como texto, y con columnas que guardan números en VARCHAR —motivo adicional para elegir bien los tipos, como decía 04-04—.
OR mal planteado
OR mal planteado-- MAL: un OR entre columnas distintas suele impedir el uso de índices
SELECT * FROM socios WHERE email = '[email protected]' OR socio_id = 14;Seq Scan on socios Filter: ((email = '[email protected]'::text) OR (socio_id = 14))
-- BIEN: dos consultas indexadas unidas
SELECT * FROM socios WHERE email = '[email protected]'
UNION
SELECT * FROM socios WHERE socio_id = 14; HashAggregate (actual time=0.084..0.086 rows=1 loops=1)
-> Append
-> Index Scan using idx_socios_email on socios
-> Index Scan using socios_pkey on socios socios_1PostgreSQL a veces resuelve el OR con un BitmapOr sobre dos índices, y entonces no hace falta reescribir. Cuando no lo hace, el UNION es la salida.
Caso relacionado, y muy habitual:
-- MAL: el filtro opcional que desactiva el índice
SELECT * FROM prestamos WHERE (:sucursal IS NULL OR sucursal_id = :sucursal);
-- BIEN: construir la consulta con o sin la condición según el parámetro
SELECT * innecesario
SELECT * innecesario-- Con SELECT *, hay que ir a la tabla por cada fila del índice
SELECT * FROM prestamos WHERE socio_id = 14;Index Scan using idx_prestamos_socio_fecha on prestamos (actual time=0.028..0.312 rows=41 loops=1) Buffers: shared hit=44
-- Pidiendo solo lo que está en el índice: Index Only Scan
SELECT socio_id, fecha_prestamo FROM prestamos WHERE socio_id = 14;Index Only Scan using idx_prestamos_socio_fecha on prestamos (actual time=0.019..0.041 rows=41 loops=1) Heap Fetches: 0 Buffers: shared hit=4
44 páginas frente a 4, y Heap Fetches: 0 confirma que no se ha tocado la tabla. Además, SELECT * transfiere columnas que nadie usa, rompe las aplicaciones cuando alguien añade una columna, y hace ilegible qué necesita realmente cada consulta.
Resumen de antipatrones
| Antipatrón | Reescritura |
|---|---|
WHERE f(col) = x |
WHERE col BETWEEN ... AND ..., o índice de expresión |
LIKE '%x%' |
Texto completo con GIN, o trigramas |
WHERE col::text = '14' |
WHERE col = 14 |
WHERE a = 1 OR b = 2 |
UNION de dos consultas, o comprobar que hay BitmapOr |
SELECT * |
Enumerar columnas; buscar el Index Only Scan |
- Cómo se optimiza de verdad: el orden de intervención
Aquí cerramos el círculo abierto en 05-04.
Regla 1: medir antes de tocar
No se optimiza lo que no se ha medido. Encontrar la consulta culpable, no la sospechosa. La herramienta es la extensión pg_stat_statements:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT round(total_exec_time::numeric, 0) AS ms_total,
calls,
round(mean_exec_time::numeric, 2) AS ms_media,
left(query, 55) AS consulta
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5; ms_total | calls | ms_media | consulta
-----------+--------+----------+--------------------------------------------------------
8412088 | 602 | 13973.57 | SELECT s.nombre AS sucursal, so.apellidos, so.nombre, m
1204882 | 884102 | 1.36 | SELECT * FROM socios WHERE socio_id = $1
402118 | 12048 | 33.38 | SELECT count(*) FROM prestamos WHERE socio_id = $1 AND
88214 | 44 | 2005.00 | SELECT e.sucursal_id, count(*) FROM prestamos p JOIN ej
12408 | 4012 | 3.09 | UPDATE ejemplares SET estado = $1 WHERE ejemplar_id = $Regla 2: atacar la consulta más costosa, no la más fea
Mira la tabla anterior con atención, porque encierra la lección entera de este apartado:
| Consulta | Media | Llamadas | Tiempo total | ¿Optimizar? |
|---|---|---|---|---|
| Listado de vencidos | 13.973 ms | 602 | 8.412 s | Sí: es el 82 % del tiempo |
SELECT * FROM socios WHERE socio_id = $1 |
1,36 ms | 884.102 | 1.204 s | Sí, aunque parezca rápida |
| Informe trimestral | 2.005 ms | 44 | 88 s | No: 44 ejecuciones al año |
La segunda fila es la que sorprende. Una consulta de 1,36 ms parece perfecta, pero se ejecuta 884.102 veces y suma veinte minutos de servidor. Bajarla a 0,4 ms ahorraría más que optimizar el informe trimestral cuarenta veces.
Y la tercera enseña lo contrario: un informe de dos segundos parece un escándalo, pero se ejecuta 44 veces al año. Optimizarlo es tiempo perdido, por muy feo que sea su SQL.
La métrica que decide es
total_exec_time, nomean_exec_time. Coste total = coste unitario × frecuencia.
Regla 3: el orden de intervención
De menos invasiva a más. No pases al siguiente escalón sin haber agotado el anterior y sin haber medido.
| Orden | Intervención | Reversible | Riesgo para los datos | Ganancia típica |
|---|---|---|---|---|
| 1 | Reescribir la consulta (quitar SELECT *, eliminar antipatrones, evitar subconsultas correlacionadas) |
Sí | Ninguno | De ×1 a ×100 |
| 2 | Actualizar estadísticas (ANALYZE, estadísticas extendidas) |
Sí | Ninguno | Variable; a veces enorme |
| 3 | Crear un índice (parcial, compuesto o de expresión) | Sí, DROP INDEX |
Ninguno | De ×10 a ×1000 |
| 4 | Ajustar la configuración (work_mem, shared_buffers, effective_cache_size) |
Sí | Ninguno | De ×1 a ×5 |
| 5 | Cambiar el esquema (tipos, columnas, particionado) | Difícilmente | Bajo | Variable |
| 6 | Desnormalizar (columna calculada, tabla de resumen, vista materializada) | No en la práctica | Sí: inconsistencia | Alta |
| 7 | Caché fuera de la base de datos (Redis, como en 03-02) | Sí | Sí: datos obsoletos | Muy alta |
Los cuatro primeros escalones no tocan los datos, no pueden introducir inconsistencia y se deshacen en un minuto. Del quinto en adelante, cada uno añade una obligación permanente de mantenimiento.
Y el caso de esta lección lo confirma: catorce segundos resueltos en el escalón 3, con dos instrucciones reversibles. La tentación de saltar directamente al 6 —"hagamos una tabla de préstamos vencidos que se actualice cada noche"— habría costado un disparador, un proceso nocturno, un riesgo de descuadre, datos con horas de antigüedad y una discusión eterna sobre por qué el listado no incluye el préstamo que acaba de vencer. Todo eso para ser más lento que un índice parcial de 992 kB.
Sobre particionado y replicación
Dos técnicas que a veces se proponen como solución a una consulta lenta y que casi nunca lo son:
- El particionado divide una tabla grande en fragmentos por rango o por lista. Ayuda con el mantenimiento (borrar un año entero es eliminar una partición) y con consultas que filtran por la clave de partición. No sustituye a un índice.
- La replicación reparte la carga de lectura entre varios servidores, y es la técnica que vimos en 03-01. No hace más rápida una consulta: permite ejecutar más consultas a la vez. Enviar el informe mensual a una réplica de solo lectura es excelente idea; no hará que tarde menos.
Ninguna de las dos arregla una consulta lenta por falta de índice. Solo multiplican el hardware necesario para seguir haciéndolo mal.
- Mantenimiento e índices fuera de PostgreSQL
Índices hinchados y REINDEX
Con el MVCC de 06-02, los índices también acumulan entradas muertas y se hinchan. Un índice hinchado ocupa más de lo que debería y su búsqueda toca más páginas.
Estimar el problema:
SELECT indexrelname AS indice,
pg_size_pretty(pg_relation_size(indexrelid)) AS tamano,
idx_scan AS usos
FROM pg_stat_user_indexes
WHERE relname = 'prestamos';Reconstruir:
El CONCURRENTLY es esencial: sin él, REINDEX bloquea las escrituras de la tabla, con el efecto de 06-02 sobre los cuatro mostradores.
Ojo: reconstruir índices no es una tarea rutinaria. Se hace cuando se ha comprobado hinchazón, no "por si acaso".
Índices en MongoDB
El concepto es idéntico; cambia la sintaxis.
// Índice simple
db.prestamos.createIndex({ socio_id: 1 })
// Compuesto: la regla del prefijo más a la izquierda es LA MISMA
db.prestamos.createIndex({ socio_id: 1, fecha_prestamo: -1 })
// Parcial: el equivalente exacto de nuestro índice caliente
db.prestamos.createIndex(
{ fecha_devolucion_prevista: 1 },
{ partialFilterExpression: { fecha_devolucion: null } }
)
// Único
db.socios.createIndex({ email: 1 }, { unique: true })
// Ver los índices existentes
db.prestamos.getIndexes()Y el equivalente de EXPLAIN ANALYZE:
{
queryPlanner: { winningPlan: { stage: "IXSCAN", indexName: "socio_id_1" } },
executionStats: {
nReturned: 41,
totalKeysExamined: 41,
totalDocsExamined: 41,
executionTimeMillis: 0
}
}Lo que hay que mirar es la misma idea que en PostgreSQL: COLLSCAN en lugar de IXSCAN es el Seq Scan de MongoDB, y totalDocsExamined mucho mayor que nReturned es el equivalente de Rows Removed by Filter.
La conclusión importante: los índices no son una peculiaridad relacional. Son la respuesta universal al problema de encontrar pocos elementos entre muchos, y todos los sistemas de datos, sin excepción, tienen su versión.
Errores Comunes y Consejos
Indexarlo todo "por si acaso". Cada índice cuesta espacio, escrituras, caché y tiempo de planificación. Revisa idx_scan en pg_stat_user_indexes y elimina los que llevan meses sin usarse.
No indexar las claves ajenas. PostgreSQL no lo hace por ti. Es la causa más frecuente de JOIN lentos y de borrados que tardan segundos. Ejecuta la consulta de detección del apartado 10 sobre tu base de datos hoy mismo.
Crear el índice en producción sin CONCURRENTLY. Bloquea las escrituras durante toda la creación. En una tabla grande, es una parada de servicio autoinfligida.
Confundir cost con milisegundos. El coste es una unidad interna para comparar planes. El tiempo está en actual time, y solo con ANALYZE.
Olvidar que con loops > 1 los tiempos son por iteración. Multiplica siempre por loops antes de decidir que un nodo es inocente.
Optimizar la consulta más fea en lugar de la más costosa. Ordena por total_exec_time en pg_stat_statements y ataca lo de arriba, aunque su SQL sea impecable.
Envolver la columna en una función y esperar que el índice funcione. WHERE extract(year FROM fecha) = 2026 no usa el índice sobre fecha. Reescribe como rango.
Usar EXPLAIN ANALYZE sobre un UPDATE o un DELETE en producción sin transacción. Lo ejecuta de verdad. BEGIN; ... ROLLBACK;.
Medir con datos de desarrollo. Los 3.000 préstamos de tu portátil caben en memoria y hacen que todos los planes parezcan buenos. Los planes solo son significativos con volumen y estadísticas realistas.
Crear un índice y no medir después. A veces el planificador sigue sin usarlo —por selectividad, por tipos o por estadísticas— y te quedas con el coste sin el beneficio. Vuelve a ejecutar EXPLAIN ANALYZE siempre.
Consejo final: escribe el número. "Antes 14.025 ms, después 39 ms, con idx_prestamos_abiertos." Esa frase en el registro de cambios vale más que cualquier explicación, permite comprobar dentro de un año si el índice sigue haciendo falta, y es lo único que convierte una intuición en una decisión de ingeniería.
Ejercicios
Ejercicio 1: Diseñar los índices de tres consultas
Estas tres consultas son las más ejecutadas del catálogo web y del mostrador de BiblioRed. Para cada una, indica qué índice crearías, por qué en ese orden de columnas, y si sería parcial o no.
-- (a) Historial de préstamos de un socio, del más reciente al más antiguo
SELECT prestamo_id, ejemplar_id, fecha_prestamo, fecha_devolucion
FROM prestamos
WHERE socio_id = 14
ORDER BY fecha_prestamo DESC
LIMIT 20;
-- (b) Ejemplares disponibles de un material en una sucursal
SELECT ejemplar_id, codigo
FROM ejemplares
WHERE material_id = 907 AND sucursal_id = 2 AND estado = 'disponible';
-- (c) Multas pendientes de un socio
SELECT multa_id, motivo, importe, fecha_emision
FROM multas
WHERE socio_id = 14 AND estado = 'pendiente'
ORDER BY fecha_emision;Ejercicio 2: Diagnosticar un plan
Interpreta este plan de ejecución. Indica: cuál es el nodo problemático, cuánto tiempo real consume, cuál es el diagnóstico y qué intervención propondrías (con el escalón del apartado 17 al que corresponde).
Nested Loop (cost=0.42..8902.18 rows=12 width=64) (actual time=0.088..9214.552 rows=41 loops=1)
-> Seq Scan on socios so (cost=0.00..284.00 rows=12 width=28)
(actual time=0.021..4.118 rows=41 loops=1)
Filter: (lower(apellidos) = 'alsina'::text)
Rows Removed by Filter: 11959
-> Index Scan using idx_prestamos_socio on prestamos p
(cost=0.42..718.02 rows=1 width=36) (actual time=224.402..224.622 rows=1 loops=41)
Index Cond: (socio_id = so.socio_id)
Filter: (fecha_devolucion IS NULL)
Rows Removed by Filter: 218
Planning Time: 0.412 ms
Execution Time: 9215.104 msEjercicio 3: Reescribir tres consultas que anulan sus índices
Estas tres consultas de BiblioRed no usan ningún índice pese a que existen los índices adecuados. Identifica el antipatrón de cada una y reescríbela. Los índices disponibles son: prestamos(fecha_prestamo), socios(lower(email)), materiales(titulo text_pattern_ops) y materiales USING gin (busqueda).
-- (a)
SELECT count(*) FROM prestamos WHERE to_char(fecha_prestamo, 'YYYY-MM') = '2026-07';
-- (b)
SELECT socio_id, nombre FROM socios WHERE email = '[email protected]';
-- (c)
SELECT material_id, titulo FROM materiales WHERE titulo LIKE '%tiempo%';Soluciones
Solución 1
(a) Historial de préstamos de un socio
- Orden de columnas:
socio_idprimero porque es la condición de igualdad y es muy selectiva (11.842 valores distintos sobre 2,8 millones de filas).fecha_prestamodespués porque interviene en elORDER BY. DESCen el índice: permite que elORDER BY ... DESCse resuelva recorriendo el índice en su orden natural, eliminando el nodoSort. ConLIMIT 20, el gestor lee 20 entradas y para.- No parcial: es un historial, y consulta explícitamente préstamos ya devueltos. Restringirlo a los abiertos rompería el caso de uso.
Verificación esperada:
Limit (actual time=0.028..0.041 rows=20 loops=1)
-> Index Scan using idx_prestamos_socio_fecha on prestamos (actual time=0.026..0.038 rows=20 loops=1)
Index Cond: (socio_id = 14)Sin nodo Sort: es la señal de que el orden del índice se ha aprovechado.
(b) Ejemplares disponibles de un material en una sucursal
CREATE INDEX idx_ejemplares_material_sucursal
ON ejemplares (material_id, sucursal_id)
WHERE estado = 'disponible';- Orden:
material_idprimero por ser mucho más selectivo (miles de materiales frente a cuatro sucursales). Con solo cuatro sucursales,sucursal_idapenas descarta filas y no debe ir delante. - Parcial sobre
estado = 'disponible': es la condición fija de la consulta caliente del catálogo web, yestadoes de baja cardinalidad, así que como columna indexada sería inútil (apartado 10). Como condición del índice es perfecta: lo reduce a los ejemplares realmente prestables y evita mantenerlo cuando cambian los estados de los demás. - No es necesario incluir
estadoentre las columnas: la condición del índice ya lo garantiza.
(c) Multas pendientes de un socio
CREATE INDEX idx_multas_socio_pendientes
ON multas (socio_id, fecha_emision)
WHERE estado = 'pendiente';- Orden:
socio_id(igualdad, selectivo) yfecha_emision(para elORDER BY). - Parcial: de las 48.000 multas históricas solo unas 300 están pendientes. El índice pasa de megabytes a kilobytes y solo se toca al emitir o cobrar una multa.
- Podría añadirse
INCLUDE (motivo, importe)para conseguir unIndex Only Scan, ya que la consulta solo pide esas columnas. Con 300 filas la ganancia es marginal, pero es el razonamiento correcto.
Solución 2
Nodo problemático: el Index Scan using idx_prestamos_socio on prestamos p.
Tiempo real consumido: aquí está la trampa. El nodo muestra actual time=224.402..224.622 con loops=41. Esos 224 ms son por iteración, así que el total es 41 × 224,6 ≈ 9.209 ms, es decir, prácticamente los 9.215 ms de toda la consulta. Quien lea "224 ms" y lo dé por aceptable buscará el problema donde no está.
Diagnóstico: el índice idx_prestamos_socio se está usando para la condición de unión, pero el filtro fecha_devolucion IS NULL se aplica después, sobre las filas ya recuperadas de la tabla. La línea Rows Removed by Filter: 218 lo dice: por cada uno de los 41 socios se leen 219 préstamos de la tabla para quedarse con 1. Son 8.979 accesos a páginas, la mayoría a disco.
Hay un segundo problema, menor: el Seq Scan on socios con lower(apellidos) recorre los 12.000 socios (4 ms). No es el drama, pero delata que falta un índice de expresión.
Intervención propuesta, escalón 3 del apartado 17 (crear un índice):
-- Principal: índice parcial que incorpora el filtro al propio índice
CREATE INDEX CONCURRENTLY idx_prestamos_socio_abiertos
ON prestamos (socio_id)
WHERE fecha_devolucion IS NULL;
-- Secundario: índice de expresión para la búsqueda por apellidos
CREATE INDEX CONCURRENTLY idx_socios_apellidos_lower
ON socios (lower(apellidos));
ANALYZE prestamos;
ANALYZE socios;Con el índice parcial, cada iteración del bucle devuelve directamente el préstamo abierto sin leer los 218 devueltos. El nodo pasaría de 224 ms a microsegundos, y la consulta entera al orden de un milisegundo.
Observación adicional: también se aprecia que la estimación (rows=12) se queda corta frente a la realidad (rows=41) en el Seq Scan on socios. No es grave aquí, pero si la diferencia creciera, el Nested Loop dejaría de ser la elección adecuada. Un ANALYZE tras crear el índice de expresión mejorará también esa estimación.
Solución 3
(a) Función sobre la columna (antipatrón 1)
to_char(fecha_prestamo, 'YYYY-MM') convierte la fecha en texto, así que el índice sobre fecha_prestamo no puede intervenir.
SELECT count(*)
FROM prestamos
WHERE fecha_prestamo >= DATE '2026-07-01'
AND fecha_prestamo < DATE '2026-08-01';Nota sobre el límite superior: se usa < '2026-08-01' y no <= '2026-07-31'. Si la columna fuera TIMESTAMP en vez de DATE, <= '2026-07-31' dejaría fuera todo el último día a partir de las 00:00:01. El rango medio abierto es correcto en ambos casos, y es una costumbre que evita errores silenciosos.
(b) Índice de expresión con expresión distinta (variante del antipatrón 1)
El índice es sobre lower(email), pero la consulta compara email tal cual, y además con el valor en mayúsculas. No coinciden.
SELECT socio_id, nombre
FROM socios
WHERE lower(email) = lower('[email protected]');O, mejor todavía, normalizando el valor en la aplicación antes de enviarlo:
SELECT socio_id, nombre FROM socios WHERE lower(email) = '[email protected]';La expresión del WHERE debe ser idéntica a la del índice. Es la regla que hace funcionar los índices de expresión, y la que los rompe cuando se olvida.
(c) LIKE con comodín inicial (antipatrón 2)
El índice text_pattern_ops sirve para prefijos (LIKE 'tiempo%'), no para subcadenas.
-- Opción preferible: búsqueda de texto completo con el índice GIN
SELECT material_id, titulo
FROM materiales
WHERE busqueda @@ websearch_to_tsquery('spanish', 'tiempo'); Bitmap Heap Scan on materiales (actual time=0.184..0.402 rows=38 loops=1)
Recheck Cond: (busqueda @@ websearch_to_tsquery('spanish', 'tiempo'))
-> Bitmap Index Scan on idx_materiales_busqueda (actual time=0.121..0.121 rows=38 loops=1)Ventaja añadida: la búsqueda de texto completo aplica lematización en español, así que "tiempo" encuentra también "tiempos", y no encuentra falsos positivos como "contiempo" que sí devolvería LIKE '%tiempo%'.
Si de verdad hiciera falta la subcadena literal, la alternativa es un índice de trigramas:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_materiales_titulo_trgm ON materiales USING gin (titulo gin_trgm_ops);
-- Ahora sí:
SELECT material_id, titulo FROM materiales WHERE titulo ILIKE '%tiempo%';Conclusión
Los catorce segundos ya no existen. Y lo importante no es que ahora sean cuarenta milisegundos, sino cómo hemos llegado hasta ahí: midiendo, leyendo un plan de ejecución, encontrando la línea que decía Rows Removed by Filter: 2799060, y creando dos índices reversibles. Sin tocar el esquema, sin desnormalizar, sin una tabla nueva y sin una discusión de arquitectura.
Hemos entendido el mecanismo desde dentro. Un B-tree con un factor de ramificación de unas 500 entradas por nodo direcciona 125 millones de filas en tres niveles, y esa es la razón matemática de que un índice siga siendo rápido cuando la tabla crece: no escala con el número de filas, escala con su logaritmo. También hemos visto el otro lado, que se olvida más: un índice cuesta espacio, cuesta caché, triplica el tiempo de una carga masiva y, si nadie lo usa, solo cuesta. Por eso idx_scan = 0 en pg_stat_user_indexes es una invitación a DROP INDEX.
Hemos recorrido las herramientas: los índices únicos y su relación exacta con las restricciones UNIQUE de 04-04; los compuestos y la regla del prefijo más a la izquierda, que se entiende de una vez para siempre con el listín ordenado por apellido y nombre; los parciales, que en BiblioRed convirtieron 61 MB en 992 kB porque solo el 1,5 % de los préstamos está abierto; los de expresión, con la condición de que el WHERE repita la expresión exacta; y el panorama de GIN para el buscador del catálogo, GiST para los solapes de eventos que ya conocíamos de 04-04, y BRIN para el historial ordenado por fecha, con sus 48 kB frente a 61 MB.
Hemos aprendido a leer un plan de abajo arriba y de dentro afuera; a distinguir Seq Scan de Index Scan, de Index Only Scan y de Bitmap Heap Scan; a reconocer cuándo un Nested Loop es una buena idea y cuándo es una catástrofe; a no confundir el coste con los milisegundos; a multiplicar por loops antes de absolver a un nodo; y sobre todo a mirar la señal de alarma más valiosa de todas: la distancia entre las filas estimadas y las reales, que casi siempre apunta a estadísticas viejas o a columnas correlacionadas.
Y hemos formalizado el método. Medir con pg_stat_statements y atacar la consulta con más tiempo total, no la de peor aspecto —esa consulta de 1,36 ms que se ejecuta 884.102 veces cuesta más que el informe trimestral de dos segundos—. Después, el orden de intervención: reescribir → estadísticas → índice → configuración → esquema → desnormalizar → caché, sin saltarse escalones y midiendo entre uno y otro. Los cuatro primeros no pueden estropear los datos; del quinto en adelante, cada uno añade una obligación permanente. Este es exactamente el compromiso que adquirimos en 05-04, y ahora ya tenemos las herramientas para cumplirlo.
Queda una última cosa, y es la que separa una base de datos de un accidente esperando a ocurrir.
El sistema de BiblioRed es ahora correcto —el esquema está normalizado y las transacciones garantizan que las operaciones ocurren enteras— y es rápido. Pero sigue habiendo dos preguntas de la concejalía de Vallmar sin responder desde el primer día de este módulo: quién puede consultar los teléfonos y los correos de los 12.000 socios, y qué pasaría exactamente si el disco muriera esta noche.
Ninguna de las dos se arregla con un índice. La lección 06-04, Seguridad, Permisos y Copias de Seguridad, cierra el módulo y el bloque teórico del curso: la autenticación y los roles de PostgreSQL, con pg_hba.conf y por qué el método trust no debe salir de tu portátil; la autorización con GRANT y REVOKE, y el principio de mínimo privilegio aplicado a tres roles concretos de BiblioRed —mostrador, dirección y aplicación web—; las vistas y la seguridad a nivel de fila para que cada sucursal vea solo lo suyo; la inyección SQL, cómo se produce en el buscador del catálogo y por qué las consultas parametrizadas son la única defensa que funciona; el cifrado en tránsito y en reposo, y el tratamiento correcto de las contraseñas; los datos personales de los socios, con lo que la técnica puede aportar y lo que corresponde a un profesional de compliance; y el bloque de copias de seguridad, donde el registro WAL que estudiamos en 06-01 reaparecerá convertido en la herramienta que permite recuperar la base de datos en el instante anterior al desastre.
Fundamentos de Bases de Datos
Módulo 1: Introducción a las Bases de Datos
- Conceptos Básicos de Bases de Datos
- Tipos de Bases de Datos
- Historia y Evolución de las Bases de Datos
- Sistemas Gestores de Bases de Datos y Arquitectura
Módulo 2: Bases de Datos Relacionales
- Modelo Relacional
- Lenguaje SQL
- Operaciones Básicas en SQL
- Consultas Multitabla: JOIN y Subconsultas
- Agregación y Agrupación de Datos
- Integridad Referencial
Módulo 3: Bases de Datos No Relacionales
- Introducción a NoSQL
- Tipos de Bases de Datos NoSQL
- Modelado de Datos en NoSQL
- Comparación entre Bases de Datos Relacionales y No Relacionales
Módulo 4: Diseño de Esquemas
- Principios de Diseño de Esquemas
- Diagramas Entidad-Relación (ER)
- Transformación de Diagramas ER a Esquemas Relacionales
- Tipos de Datos y Restricciones
Módulo 5: Normalización
Módulo 6: Transacciones, Rendimiento y Seguridad
- Transacciones y Propiedades ACID
- Concurrencia y Niveles de Aislamiento
- Índices y Optimización de Consultas
- Seguridad, Permisos y Copias de Seguridad
Módulo 7: Ejercicios Prácticos
- Ejercicios de SQL
- Ejercicios de Diseño de Esquemas
- Ejercicios de Normalización
- Ejercicios de Consultas Avanzadas y Transacciones
Módulo 8: Casos de Estudio
- Caso de Estudio: Base de Datos Relacional
- Caso de Estudio: Base de Datos No Relacional
- Caso de Estudio: Persistencia Políglota
