Esta es la lección más exigente del curso, y también la que más se parece al trabajo real. Aquí no hay SELECT de calentamiento: hay funciones de ventana, CTE recursivas, jsonb, transacciones con control de errores, cinco ejercicios de dos sesiones psql en paralelo y dos de lectura de planes de ejecución. Todo sobre BiblioRed.
Cómo trabajar esta lección. Igual que las anteriores —intentarlo antes de mirar— con una diferencia importante: necesitas dos terminales abiertos. El bloque de concurrencia no se puede leer, hay que ejecutarlo. Cada ejercicio de ese bloque indica exactamente qué teclear en la Sesión A y en la Sesión B, y en qué orden. La instrucción «no adelantes la Sesión B hasta que la A haya hecho su parte» no es un consejo: si te saltas el orden, el fenómeno que quieres observar no ocurre y creerás que el ejercicio está mal.
Abre así los dos terminales, y ponles un prompt distinguible para no confundirte:
# Terminal 1
psql -d biblioredx
\set PROMPT1 '[A] %/%R%# '
# Terminal 2
psql -d biblioredx
\set PROMPT1 '[B] %/%R%# 'Un aviso sobre los planes de ejecución: los números que verás en tu máquina no coincidirán con los de las soluciones, porque tu tabla prestamos tiene 20 filas y los planes de los ejercicios 12 y 13 corresponden a una BiblioRed en producción con 900.000. Lo que hay que aprender a leer no son los milisegundos: son la forma del plan y la relación entre las filas estimadas y las reales.
Esta lección no repite SQL básico (es 07-01) ni normalización (es 07-03).
Antes de Empezar
Parte del mismo juego de datos de la lección 07-01. Si no lo tienes cargado, vuelve allí y ejecuta el script; la comprobación de que está bien es la consulta de conteos de aquella lección (20 préstamos, 15 ejemplares, 26 inscripciones).
Sobre ese juego de datos hay que añadir tres cosas que esta lección necesita: la tabla de informes con jsonb, un árbol de categorías temáticas y una cola de avisos.
-- ============================================================
-- Complemento del módulo 7 para la lección 07-04
-- ============================================================
CREATE TABLE informes_evento (
evento_id INTEGER PRIMARY KEY REFERENCES eventos(evento_id) ON DELETE CASCADE,
asistentes_reales SMALLINT NOT NULL CHECK (asistentes_reales >= 0),
valoracion_media NUMERIC(3,2),
observaciones TEXT,
respuestas_encuesta JSONB,
fecha_redaccion DATE NOT NULL
);
INSERT INTO informes_evento VALUES
(101, 7, 4.50, 'Buen ambiente; la sala se quedó justa.', '{
"canal": "web",
"temas": ["novela histórica","club de lectura"],
"respuestas": [
{"socio_id":14,"puntuacion":5,"recomendaria":true,"comentario":"Muy buen ritmo"},
{"socio_id":11,"puntuacion":4,"recomendaria":true,"comentario":null},
{"socio_id":13,"puntuacion":5,"recomendaria":true,"comentario":"Repetiré"},
{"socio_id":12,"puntuacion":4,"recomendaria":false,"comentario":"Sala pequeña"}
]}'::jsonb, '2026-03-14'),
(102, 9, 4.00, 'Público infantil muy participativo.', '{
"canal": "papel",
"temas": ["infantil","cuentacuentos"],
"respuestas": [
{"socio_id":16,"puntuacion":5,"recomendaria":true,"comentario":"Encantados"},
{"socio_id":12,"puntuacion":3,"recomendaria":false,"comentario":"Demasiado ruido"},
{"socio_id":11,"puntuacion":4,"recomendaria":true,"comentario":null}
]}'::jsonb, '2026-04-20'),
(103, 5, 4.75, 'Grupo reducido, muy buen nivel.', '{
"canal": "web",
"temas": ["escritura","taller"],
"respuestas": [
{"socio_id":14,"puntuacion":5,"recomendaria":true,"comentario":"Excelente"},
{"socio_id":13,"puntuacion":5,"recomendaria":true,"comentario":null},
{"socio_id":15,"puntuacion":4,"recomendaria":true,"comentario":"Corto"},
{"socio_id":11,"puntuacion":5,"recomendaria":true,"comentario":"Repetiré"}
]}'::jsonb, '2026-05-12'),
(104, 8, 3.25, 'Aforo muy holgado; problemas de megafonía.', '{
"canal": "papel",
"temas": ["presentación","novela"],
"respuestas": [
{"socio_id":11,"puntuacion":3,"recomendaria":false,"comentario":"Poca gente"},
{"socio_id":12,"puntuacion":4,"recomendaria":true,"comentario":null},
{"socio_id":14,"puntuacion":4,"recomendaria":true,"comentario":null},
{"socio_id":16,"puntuacion":2,"recomendaria":false,"comentario":"No se oía"}
]}'::jsonb, '2026-06-08'),
(105, 4, 4.67, 'Sesión tranquila, buena conversación.', '{
"canal": "web",
"temas": ["novela contemporánea","club de lectura"],
"respuestas": [
{"socio_id":15,"puntuacion":5,"recomendaria":true,"comentario":"Muy buena"},
{"socio_id":16,"puntuacion":4,"recomendaria":true,"comentario":null},
{"socio_id":14,"puntuacion":5,"recomendaria":true,"comentario":null}
]}'::jsonb, '2026-07-18');
CREATE INDEX idx_informes_encuesta ON informes_evento USING gin (respuestas_encuesta);
-- Árbol de materias del catálogo
CREATE TABLE categorias_tema (
categoria_id INTEGER PRIMARY KEY,
nombre VARCHAR(60) NOT NULL,
padre_id INTEGER REFERENCES categorias_tema(categoria_id)
);
INSERT INTO categorias_tema VALUES
(1,'Ficción',NULL), (2,'Narrativa',1), (3,'Novela histórica',2),
(4,'Novela contemporánea',2), (5,'No ficción',NULL), (6,'Historia',5),
(7,'Historia contemporánea',6), (8,'Divulgación científica',5);
-- Cola de avisos a socios
CREATE TABLE avisos (
aviso_id SERIAL PRIMARY KEY,
socio_id INTEGER NOT NULL REFERENCES socios(socio_id),
tipo VARCHAR(20) NOT NULL,
mensaje TEXT NOT NULL,
creado_en TIMESTAMPTZ NOT NULL DEFAULT now(),
estado VARCHAR(12) NOT NULL DEFAULT 'pendiente',
enviado_en TIMESTAMPTZ
);
INSERT INTO avisos (socio_id, tipo, mensaje) VALUES
(13,'vencimiento','EJ-3088 venció el 26/04'),
(14,'vencimiento','EJ-3093 venció el 31/05'),
(16,'vencimiento','EJ-3087 venció el 01/07'),
(15,'reserva','Tu reserva de El mapa del tiempo está disponible'),
(14,'multa','Tienes 24,00 € pendientes'),
(11,'reserva','Tu reserva caduca el 08/08');
-- Necesaria para el ejercicio 5
CREATE UNIQUE INDEX uq_multa_prestamo_motivo ON multas (prestamo_id, motivo);Versión de PostgreSQL. Necesitas 12 o superior para todo lo de esta lección; FOR UPDATE SKIP LOCKED existe desde la 9.5 y FILTER desde la 9.4.
SQLite. Soporta funciones de ventana y CTE recursivas desde la 3.25, pero no tiene jsonb (tiene funciones json_* distintas), ni FOR UPDATE, ni SKIP LOCKED, ni niveles de aislamiento configurables: bloquea la base entera al escribir. Los bloques (b), (c) y (d) de esta lección son intrínsecamente de PostgreSQL.
Contenido
- Bloque A — Consultas avanzadas: ventanas, recursión y
jsonb(ejercicios 1-3) - Bloque B — Transacciones y control de errores (ejercicios 4-6)
- Bloque C — Concurrencia: cinco ejercicios de dos sesiones (ejercicios 7-11)
- Bloque D — Índices y planes de ejecución (ejercicios 12-13)
- Errores comunes y consejos
- Ejercicios de refuerzo
Bloque A — Consultas avanzadas
Ejercicio 1: Funciones de ventana
Dificultad: Intermedio
Enunciado. Cuatro informes que la lección 07-01 no pudo resolver porque hacían falta funciones de ventana:
- (a) El material más prestado de cada sucursal, con su número de préstamos. Una fila por sucursal. La sucursal se determina por el ejemplar prestado, y los empates se resuelven alfabéticamente por título.
- (b) El ranking completo de materiales de la sucursal Norte, mostrando en columnas separadas
ROW_NUMBER,RANKyDENSE_RANK, para ver en qué se diferencian ante un empate. - (c) Préstamos mensuales de la sucursal Centro en 2026, con la diferencia y la variación porcentual respecto al mes anterior.
- (d) La recaudación acumulada de multas, pago a pago, ordenada por fecha.
Pista. Para (a), numera dentro de cada partición y quédate con el número 1; el WHERE no puede filtrar funciones de ventana, hace falta envolver en una CTE.
Solución
-- (a) Top 1 por grupo con ROW_NUMBER
WITH conteos AS (
SELECT e.sucursal_id, m.material_id, m.titulo, count(*) AS n
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN materiales m ON m.material_id = e.material_id
GROUP BY e.sucursal_id, m.material_id, m.titulo
),
ranking AS (
SELECT c.*,
ROW_NUMBER() OVER (PARTITION BY c.sucursal_id
ORDER BY c.n DESC, c.titulo ASC) AS rn
FROM conteos c
)
SELECT s.nombre AS sucursal, r.titulo, r.n AS prestamos
FROM ranking r
JOIN sucursales s ON s.sucursal_id = r.sucursal_id
WHERE r.rn = 1
ORDER BY r.n DESC;
-- (b) Las tres funciones de ranking sobre la misma partición
SELECT m.titulo,
count(*) AS n,
ROW_NUMBER() OVER (ORDER BY count(*) DESC, m.titulo) AS row_number,
RANK() OVER (ORDER BY count(*) DESC) AS rank,
DENSE_RANK() OVER (ORDER BY count(*) DESC) AS dense_rank
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN materiales m ON m.material_id = e.material_id
WHERE e.sucursal_id = 2
GROUP BY m.material_id, m.titulo;
-- (c) LAG para comparar con el mes anterior
WITH mensual AS (
SELECT date_trunc('month', p.fecha_prestamo)::date AS mes,
count(*) AS prestamos
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
WHERE e.sucursal_id = 1
AND p.fecha_prestamo >= DATE '2026-01-01'
GROUP BY 1
)
SELECT to_char(mes, 'YYYY-MM') AS mes,
prestamos,
LAG(prestamos) OVER (ORDER BY mes) AS mes_anterior,
prestamos - LAG(prestamos) OVER (ORDER BY mes) AS diferencia,
round(100.0 * (prestamos - LAG(prestamos) OVER (ORDER BY mes))
/ NULLIF(LAG(prestamos) OVER (ORDER BY mes), 0), 1) AS variacion_pct
FROM mensual
ORDER BY mes;
-- (d) Acumulado con SUM() OVER
SELECT pg.fecha_pago,
pg.metodo,
pg.importe,
sum(pg.importe) OVER (ORDER BY pg.fecha_pago, pg.pago_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS acumulado
FROM pagos pg
ORDER BY pg.fecha_pago, pg.pago_id;Resultado esperado
(a)
| sucursal | titulo | prestamos |
|---|---|---|
| Centro | El mapa del tiempo | 3 |
| Norte | El corazón helado | 2 |
| Sur | Kafka en la orilla | 2 |
(b) Sucursal Norte:
| titulo | n | row_number | rank | dense_rank |
|---|---|---|---|---|
| El corazón helado | 2 | 1 | 1 | 1 |
| El mapa del tiempo | 2 | 2 | 1 | 1 |
| Documental: la voz de Chernóbil | 1 | 3 | 3 | 2 |
(c)
| mes | prestamos | mes_anterior | diferencia | variacion_pct |
|---|---|---|---|---|
| 2026-01 | 1 | (NULL) | (NULL) | (NULL) |
| 2026-02 | 1 | 1 | 0 | 0.0 |
| 2026-03 | 1 | 1 | 0 | 0.0 |
| 2026-04 | 3 | 1 | 2 | 200.0 |
| 2026-05 | 2 | 3 | -1 | -33.3 |
| 2026-06 | 1 | 2 | -1 | -50.0 |
| 2026-07 | 3 | 1 | 2 | 200.0 |
(d)
| fecha_pago | metodo | importe | acumulado |
|---|---|---|---|
| 2026-03-06 | efectivo | 2.20 | 2.20 |
| 2026-04-12 | tarjeta | 3.00 | 5.20 |
| 2026-05-10 | tarjeta | 4.00 | 9.20 |
| 2026-05-18 | pasarela | 2.50 | 11.70 |
Explicación. Cinco puntos, y todos son trampas reales:
La sucursal Este no aparece en (a), y es correcto: sus dos ejemplares (EJ-3085 reservado y EJ-3095 retirado) nunca han salido, así que no hay ninguna fila de prestamos que la mencione. Si el informe tuviera que mostrar las cuatro sucursales con «(ninguno)» en Este, habría que partir de sucursales con un LEFT JOIN contra la CTE. Es la misma lección del anti-join de 07-01 aplicada a ventanas.
No se puede filtrar por una función de ventana en el WHERE. WHERE ROW_NUMBER() OVER (...) = 1 da error de sintaxis, y no es un capricho: las funciones de ventana se evalúan después del WHERE y del GROUP BY, casi al final del orden lógico, junto al SELECT. Por eso hay que envolverlas en una CTE o una tabla derivada y filtrar en el nivel de fuera.
Las tres funciones de ranking hacen cosas distintas ante un empate, y (b) lo enseña con datos: «El corazón helado» y «El mapa del tiempo» tienen 2 préstamos cada uno en Norte.
| Función | Ante un empate | Números que salen |
|---|---|---|
ROW_NUMBER() |
Rompe el empate arbitrariamente | 1, 2, 3 |
RANK() |
Da el mismo número y salta | 1, 1, 3 |
DENSE_RANK() |
Da el mismo número y no salta | 1, 1, 2 |
Para un «top 1 por grupo» hay que usar ROW_NUMBER: con RANK saldrían dos filas para Norte, porque las dos empatadas tendrían rango 1. Y si de verdad quieres las dos en caso de empate, entonces RANK es la correcta. Es una decisión de negocio, no de sintaxis.
En (c), LAG se calcula sobre las filas que devuelve la consulta, no sobre el calendario. Si en algún mes no hubiera habido ningún préstamo, ese mes no aparecería y LAG compararía con el mes anterior presente, no con el inmediatamente anterior en el tiempo. Eso da comparaciones falsas. La forma robusta es generar la serie de meses y unirla por la izquierda —justo lo que hace el ejercicio 2—. Aquí no hace falta porque los siete meses tienen préstamos, pero en producción es un error muy caro.
En (d), el marco de la ventana importa. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW acumula fila a fila. El marco por defecto cuando hay ORDER BY es RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, que agrupa todas las filas con el mismo valor de orden. Si dos pagos tuvieran la misma fecha, con RANGE ambos mostrarían el mismo acumulado (el de después de sumar los dos) y con ROWS mostrarían el escalón intermedio. Por eso el ORDER BY de la ventana incluye pago_id: hace único el orden y elimina la ambigüedad.
Ejercicio 2: CTE recursivas
Dificultad: Avanzado
Enunciado. Dos usos distintos de la recursión:
- (a) El informe de ocupación diaria de julio de 2026: una fila por cada día del mes, con el número de préstamos de ese día. Los días sin préstamos deben aparecer con 0. Genera la serie de fechas con una CTE recursiva.
- (b) El árbol de materias de la tabla
categorias_tema: para cada categoría descendiente de «Ficción» (incluida ella), su nivel de profundidad y su ruta completa desde la raíz.
Pista. Una CTE recursiva tiene siempre dos partes unidas por UNION ALL: el caso base y el paso recursivo, que se refiere a la propia CTE.
Solución
-- (a) Serie de fechas + LEFT JOIN
WITH RECURSIVE dias AS (
SELECT DATE '2026-07-01' AS dia -- caso base
UNION ALL
SELECT dia + 1 -- paso recursivo
FROM dias
WHERE dia < DATE '2026-07-31' -- condición de parada: ¡obligatoria!
)
SELECT d.dia,
count(p.prestamo_id) AS prestamos
FROM dias d
LEFT JOIN prestamos p ON p.fecha_prestamo = d.dia
GROUP BY d.dia
ORDER BY d.dia;
-- (b) Recorrido de un árbol
WITH RECURSIVE arbol AS (
SELECT categoria_id, nombre, padre_id,
0 AS nivel,
nombre::text AS ruta
FROM categorias_tema
WHERE nombre = 'Ficción' -- caso base: la raíz que nos interesa
UNION ALL
SELECT c.categoria_id, c.nombre, c.padre_id,
a.nivel + 1,
a.ruta || ' > ' || c.nombre
FROM categorias_tema c
JOIN arbol a ON a.categoria_id = c.padre_id -- paso recursivo
)
SELECT nivel, categoria_id, nombre, ruta
FROM arbol
ORDER BY ruta;Resultado esperado
(a) 31 filas. Las primeras y las que tienen préstamos:
| dia | prestamos |
|---|---|
| 2026-07-01 | 0 |
| 2026-07-02 | 0 |
| 2026-07-03 | 0 |
| 2026-07-04 | 0 |
| 2026-07-05 | 1 |
| 2026-07-06 | 0 |
| ... | ... |
| 2026-07-10 | 1 |
| ... | ... |
| 2026-07-20 | 1 |
| 2026-07-22 | 1 |
| ... | ... |
| 2026-07-31 | 0 |
Total: 4 préstamos repartidos en 4 días, y 27 días a cero.
(b)
| nivel | categoria_id | nombre | ruta |
|---|---|---|---|
| 0 | 1 | Ficción | Ficción |
| 1 | 2 | Narrativa | Ficción > Narrativa |
| 2 | 4 | Novela contemporánea | Ficción > Narrativa > Novela contemporánea |
| 2 | 3 | Novela histórica | Ficción > Narrativa > Novela histórica |
Explicación. La estructura de una CTE recursiva es siempre la misma y conviene memorizarla:
WITH RECURSIVE nombre AS (
<caso base> -- no se refiere a sí misma
UNION ALL
<paso recursivo> -- SÍ se refiere a "nombre"
)El motor ejecuta el caso base, mete el resultado en una tabla de trabajo, ejecuta el paso recursivo usando solo las filas nuevas de la iteración anterior, y repite hasta que una iteración no produce ninguna fila.
La condición de parada es responsabilidad tuya. En (a), sin WHERE dia < DATE '2026-07-31' la consulta genera fechas hasta que revienta. En (b), la parada es implícita: cuando ya no hay categorías cuyo padre esté en el nivel actual, la iteración devuelve cero filas y termina. Pero si el árbol tuviera un ciclo —una categoría que fuera su propia antecesora— la consulta no terminaría nunca. Contra eso, PostgreSQL 14 y superiores tienen la cláusula CYCLE:
En versiones anteriores, el patrón manual es arrastrar un array con el camino recorrido y añadir WHERE NOT c.categoria_id = ANY(a.camino) en el paso recursivo. Es exactamente lo que faltaba en el ejercicio 3 de la lección 07-02, donde los requisitos previos de un curso podían formar un ciclo A → B → C → A.
En PostgreSQL, (a) tiene un atajo mucho mejor:
SELECT g.dia::date, count(p.prestamo_id)
FROM generate_series(DATE '2026-07-01', DATE '2026-07-31', INTERVAL '1 day') AS g(dia)
LEFT JOIN prestamos p ON p.fecha_prestamo = g.dia::date
GROUP BY g.dia ORDER BY g.dia;generate_series es más corto, más rápido y más legible. La versión recursiva está en el ejercicio porque es portable (funciona en SQLite y en SQL Server) y porque entender el mecanismo es lo que permite resolver (b), donde no hay atajo.
El LEFT JOIN es la razón de ser del ejercicio. Un informe de ocupación diaria construido con GROUP BY fecha_prestamo sobre la tabla de préstamos devolvería 4 filas, no 31. Y un gráfico dibujado con esas 4 filas presenta el mes como si hubiera habido actividad continua. Los días a cero son datos, no ausencia de datos, y la única forma de tenerlos es generar el eje temporal completo y unir por la izquierda.
Ejercicio 3: jsonb y pivote manual
Dificultad: Avanzado
Enunciado. Las encuestas de satisfacción de los eventos se guardan en informes_evento.respuestas_encuesta, de tipo jsonb. Se pide:
- (a) Listar cada evento con el canal de la encuesta y el número de respuestas recibidas.
- (b) Los informes cuyas encuestas se hicieron por web, usando el operador de contención
@>. - (c) Desplegar el array de respuestas con
jsonb_array_elementsy calcular, por evento, la puntuación media, el número de respuestas que recomendarían y el porcentaje. - (d) Un pivote manual: por evento, cuántas respuestas dieron cada puntuación (2, 3, 4 y 5), en columnas.
Pista. -> devuelve jsonb; ->> devuelve text. La diferencia importa cuando hay que comparar o convertir.
Solución
-- (a) Navegación básica: -> y ->>
SELECT ie.evento_id,
ev.titulo,
ie.respuestas_encuesta ->> 'canal' AS canal,
jsonb_array_length(ie.respuestas_encuesta -> 'respuestas') AS n_respuestas
FROM informes_evento ie
JOIN eventos ev ON ev.evento_id = ie.evento_id
ORDER BY ie.evento_id;
-- (b) Contención: @> pregunta "¿el jsonb de la izquierda contiene esto?"
SELECT evento_id, respuestas_encuesta ->> 'canal' AS canal
FROM informes_evento
WHERE respuestas_encuesta @> '{"canal":"web"}'::jsonb
ORDER BY evento_id;
-- (c) Desplegar el array a filas y agregar
WITH respuestas AS (
SELECT ie.evento_id,
(r ->> 'puntuacion')::int AS puntuacion,
(r ->> 'recomendaria')::boolean AS recomendaria
FROM informes_evento ie,
LATERAL jsonb_array_elements(ie.respuestas_encuesta -> 'respuestas') AS r
)
SELECT evento_id,
count(*) AS respuestas,
round(avg(puntuacion), 2) AS media,
count(*) FILTER (WHERE recomendaria) AS recomiendan,
round(100.0 * count(*) FILTER (WHERE recomendaria) / count(*), 1) AS pct_recomienda
FROM respuestas
GROUP BY evento_id
ORDER BY media DESC;
-- (d) Pivote manual con FILTER
WITH respuestas AS (
SELECT ie.evento_id, (r ->> 'puntuacion')::int AS puntuacion
FROM informes_evento ie,
LATERAL jsonb_array_elements(ie.respuestas_encuesta -> 'respuestas') AS r
)
SELECT evento_id,
count(*) FILTER (WHERE puntuacion = 2) AS "2",
count(*) FILTER (WHERE puntuacion = 3) AS "3",
count(*) FILTER (WHERE puntuacion = 4) AS "4",
count(*) FILTER (WHERE puntuacion = 5) AS "5",
count(*) AS total
FROM respuestas
GROUP BY evento_id
ORDER BY evento_id;Resultado esperado
(a)
| evento_id | titulo | canal | n_respuestas |
|---|---|---|---|
| 101 | Club de lectura: Los pilares de la Tierra | web | 4 |
| 102 | Cuentacuentos de primavera | papel | 3 |
| 103 | Taller de escritura creativa | web | 4 |
| 104 | Presentación: La caja de los deseos | papel | 4 |
| 105 | Club de lectura: Tokio blues | web | 3 |
(b) Eventos 101, 103 y 105.
(c)
| evento_id | respuestas | media | recomiendan | pct_recomienda |
|---|---|---|---|---|
| 103 | 4 | 4.75 | 4 | 100.0 |
| 105 | 3 | 4.67 | 3 | 100.0 |
| 101 | 4 | 4.50 | 3 | 75.0 |
| 102 | 3 | 4.00 | 2 | 66.7 |
| 104 | 4 | 3.25 | 2 | 50.0 |
(d)
| evento_id | 2 | 3 | 4 | 5 | total |
|---|---|---|---|---|---|
| 101 | 0 | 0 | 2 | 2 | 4 |
| 102 | 0 | 1 | 1 | 1 | 3 |
| 103 | 0 | 0 | 1 | 3 | 4 |
| 104 | 1 | 1 | 2 | 0 | 4 |
| 105 | 0 | 0 | 1 | 2 | 3 |
Explicación. Los cuatro apartados cubren las cuatro operaciones que se usan el 95 % de las veces con jsonb:
-> frente a ->>. respuestas_encuesta -> 'canal' devuelve "web" con comillas, porque es un valor jsonb de tipo cadena. respuestas_encuesta ->> 'canal' devuelve web, texto plano. La consecuencia práctica: WHERE respuestas_encuesta -> 'canal' = 'web' falla o no encuentra nada, porque compara un jsonb con un text. Hay que usar ->> para comparar con texto, o -> 'canal' = '"web"'::jsonb. Es el error número uno con jsonb.
@> es el operador que usa el índice GIN. WHERE respuestas_encuesta ->> 'canal' = 'web' da el mismo resultado que @> pero no puede usar el índice GIN que creamos en Antes de Empezar: un índice GIN por defecto indexa la estructura del documento y responde a los operadores de contención y existencia (@>, ?, ?|, ?&), no a extracciones. Con cinco filas da igual; con 200.000 informes, la diferencia es de tres órdenes de magnitud. Si necesitas indexar una extracción concreta, lo correcto es un índice de expresión: CREATE INDEX ... ON informes_evento ((respuestas_encuesta ->> 'canal')).
jsonb_array_elements es una función que devuelve filas, no un valor. Por eso aparece en el FROM con LATERAL, que le permite ver la columna ie.respuestas_encuesta de la fila que se está procesando. La palabra LATERAL es opcional en PostgreSQL cuando la función va en el FROM separada por coma, pero escribirla hace explícito lo que está pasando: por cada fila de informes_evento, se generan tantas filas como elementos tenga su array.
El pivote manual con FILTER. PostgreSQL no tiene la cláusula PIVOT de otros motores; se hace con un agregado condicional por columna. count(*) FILTER (WHERE puntuacion = 5) es SQL estándar y equivale a sum(CASE WHEN puntuacion = 5 THEN 1 ELSE 0 END), que es la versión portable. Ojo con la variante count(CASE WHEN puntuacion = 5 THEN 1 END): funciona porque count ignora los nulos, pero count(CASE WHEN ... THEN 1 ELSE 0 END) no funciona, porque cuenta también los ceros y devuelve siempre el total. Es un error clásico.
La limitación del pivote manual: las columnas hay que escribirlas a mano. Si mañana la encuesta admite puntuaciones del 1 al 10, hay que editar la consulta. Un pivote con número de columnas variable no es expresable en SQL puro —el número de columnas del resultado debe conocerse al analizar la consulta— y se resuelve generando el SQL desde la aplicación o devolviendo el resultado en formato largo y pivotando en la capa de presentación.
Bloque B — Transacciones y control de errores
Ejercicio 4: La transacción completa del préstamo
Dificultad: Intermedio
Enunciado. Iván Pereda (socio 15) se presenta en el mostrador de la sucursal Norte a recoger «El mapa del tiempo», que tenía reservado (reserva 502, estado activa). El ejemplar disponible es EJ-3082 (ejemplar_id 3082).
Escribe la transacción completa que:
- Comprueba que el ejemplar está realmente disponible, bloqueándolo.
- Inserta el préstamo con 21 días de plazo desde el 2 de agosto de 2026.
- Marca el ejemplar como
prestado. - Cierra la reserva como
atendida.
Debe ser atómica: si cualquier paso falla, no debe quedar nada. Y debe detectar el caso en que otra persona se haya llevado el ejemplar entre la consulta del catálogo y la pulsación del botón.
Solución
Versión interactiva en psql, para entender el flujo:
BEGIN;
-- 1) Bloquear y comprobar. FOR UPDATE impide que otra sesión lo toque
-- hasta que esta transacción termine.
SELECT ejemplar_id, codigo, estado
FROM ejemplares
WHERE ejemplar_id = 3082
FOR UPDATE;
-- Debe devolver estado = 'disponible'. Si devuelve otra cosa: ROLLBACK.
-- 2) Registrar el préstamo
INSERT INTO prestamos (prestamo_id, socio_id, ejemplar_id,
fecha_prestamo, fecha_devolucion_prevista)
VALUES (21, 15, 3082, DATE '2026-08-02', DATE '2026-08-23');
-- 3) Cambiar el estado del ejemplar, condicionado al estado esperado
UPDATE ejemplares
SET estado = 'prestado'
WHERE ejemplar_id = 3082 AND estado = 'disponible';
-- Debe decir UPDATE 1. Si dice UPDATE 0: ROLLBACK.
-- 4) Cerrar la reserva
UPDATE reservas
SET estado = 'atendida'
WHERE reserva_id = 502 AND estado = 'activa';
-- Debe decir UPDATE 1.
COMMIT;Versión de producción, con control de errores real, como función:
CREATE OR REPLACE FUNCTION registrar_prestamo(
p_socio_id INTEGER,
p_ejemplar_id INTEGER,
p_dias INTEGER DEFAULT 21
) RETURNS INTEGER AS $$
DECLARE
v_estado VARCHAR(15);
v_material_id INTEGER;
v_prestamo_id INTEGER;
v_filas INTEGER;
BEGIN
-- 1) Bloquear la fila del ejemplar y leer su estado
SELECT estado, material_id INTO v_estado, v_material_id
FROM ejemplares
WHERE ejemplar_id = p_ejemplar_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'El ejemplar % no existe', p_ejemplar_id
USING ERRCODE = 'no_data_found';
END IF;
IF v_estado <> 'disponible' THEN
RAISE EXCEPTION 'El ejemplar % no está disponible (estado: %)',
p_ejemplar_id, v_estado
USING ERRCODE = 'check_violation';
END IF;
-- 2) Registrar el préstamo
INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo,
fecha_devolucion_prevista)
VALUES (p_socio_id, p_ejemplar_id, CURRENT_DATE,
CURRENT_DATE + p_dias)
RETURNING prestamo_id INTO v_prestamo_id;
-- 3) Cambiar el estado
UPDATE ejemplares SET estado = 'prestado'
WHERE ejemplar_id = p_ejemplar_id AND estado = 'disponible';
GET DIAGNOSTICS v_filas = ROW_COUNT;
IF v_filas <> 1 THEN
RAISE EXCEPTION 'Carrera detectada al marcar el ejemplar %', p_ejemplar_id;
END IF;
-- 4) Cerrar la reserva del socio sobre ese material, si la hay
UPDATE reservas SET estado = 'atendida'
WHERE socio_id = p_socio_id AND material_id = v_material_id
AND estado = 'activa';
RETURN v_prestamo_id;
END;
$$ LANGUAGE plpgsql;Resultado esperado
BEGIN
ejemplar_id | codigo | estado
-------------+---------+------------
3082 | EJ-3082 | disponible
INSERT 0 1
UPDATE 1
UPDATE 1
COMMITY comprobando el efecto:
SELECT e.codigo, e.estado, r.estado AS estado_reserva
FROM ejemplares e
JOIN reservas r ON r.reserva_id = 502
WHERE e.ejemplar_id = 3082;| codigo | estado | estado_reserva |
|---|---|---|
| EJ-3082 | prestado | atendida |
Si otra sesión se hubiera llevado el ejemplar antes, la función lanzaría:
...y nada de la transacción quedaría: ni el préstamo, ni el cambio de estado, ni la reserva cerrada.
Explicación. Cuatro decisiones que separan esta transacción de una que funciona «casi siempre»:
FOR UPDATE en el SELECT de comprobación. Sin él, entre el SELECT que dice «disponible» y el UPDATE que lo marca como prestado hay una ventana en la que otra sesión puede hacer lo mismo. FOR UPDATE bloquea la fila hasta el final de la transacción: la segunda sesión se queda esperando en su propio SELECT ... FOR UPDATE y, cuando la primera confirma, lee el estado ya actualizado. Es el patrón de comprobar-y-actuar, y sin el bloqueo no es atómico.
El UPDATE lleva AND estado = 'disponible' aunque ya lo hayamos comprobado. Es un cinturón sobre los tirantes: convierte el UPDATE en una operación condicional cuyo número de filas afectadas nos dice si la premisa seguía siendo cierta. GET DIAGNOSTICS ... ROW_COUNT es la forma de leer ese número desde plpgsql; en psql interactivo lo ves en el UPDATE 1 / UPDATE 0.
Un UPDATE 0 no es un error. Es la trampa más habitual: si la reserva ya estaba cerrada, el paso 4 devuelve UPDATE 0 y la transacción confirma igualmente. Aquí eso es deliberado —puede no haber reserva, y no pasa nada—, pero en el paso 3 no lo es, y por eso allí sí se comprueba. Decidir explícitamente, paso a paso, si un UPDATE 0 es aceptable o es un fallo, es la mitad del trabajo de escribir una transacción.
En plpgsql, toda la función es una transacción implícita. No hay que escribir BEGIN/COMMIT dentro: si la función lanza una excepción, todo lo que haya hecho se deshace automáticamente. Lo que sí puede hacer es capturar excepciones con EXCEPTION WHEN ... THEN, y ahí conviene saber que cada bloque EXCEPTION crea un punto de guardado implícito, con su coste. Un bucle de un millón de iteraciones con un bloque EXCEPTION dentro es notablemente más lento que el mismo bucle sin él.
Ejercicio 5: SAVEPOINT en un proceso por lotes
Dificultad: Intermedio
Enunciado. El proceso nocturno del 2 de agosto de 2026 debe emitir una multa por retraso a cada préstamo vencido y sin devolver: los préstamos 9, 12 y 15. El importe es 0,20 €/día con tope de 15,00 €.
El problema: existe el índice uq_multa_prestamo_motivo sobre (prestamo_id, motivo), y algunos de esos préstamos ya tienen una multa de retraso emitida. Si el proceso se ejecuta como una transacción monolítica, el primer choque aborta el lote entero y no se emite ninguna multa.
Escribe el lote de forma que el fallo de un elemento no impida procesar los demás, usando SAVEPOINT. Al terminar, informa de cuántas se emitieron y cuántas se saltaron.
Pista. ROLLBACK TO SAVEPOINT deshace hasta el punto de guardado y deja la transacción utilizable; sin él, tras un error la transacción queda abortada y toda instrucción posterior falla.
Solución
Versión interactiva, para ver el mecanismo:
BEGIN;
SAVEPOINT sp_p9;
INSERT INTO multas (multa_id, socio_id, prestamo_id, motivo, importe, fecha_emision, estado)
VALUES (9, 13, 9, 'retraso', LEAST(98 * 0.20, 15.00), DATE '2026-08-02', 'pendiente');
-- ERROR: duplicate key value violates unique constraint "uq_multa_prestamo_motivo"
ROLLBACK TO SAVEPOINT sp_p9; -- la transacción vuelve a ser utilizable
SAVEPOINT sp_p12;
INSERT INTO multas (multa_id, socio_id, prestamo_id, motivo, importe, fecha_emision, estado)
VALUES (9, 14, 12, 'retraso', LEAST(63 * 0.20, 15.00), DATE '2026-08-02', 'pendiente');
-- INSERT 0 1
RELEASE SAVEPOINT sp_p12;
SAVEPOINT sp_p15;
INSERT INTO multas (multa_id, socio_id, prestamo_id, motivo, importe, fecha_emision, estado)
VALUES (10, 16, 15, 'retraso', LEAST(32 * 0.20, 15.00), DATE '2026-08-02', 'pendiente');
-- ERROR: duplicate key value violates unique constraint "uq_multa_prestamo_motivo"
ROLLBACK TO SAVEPOINT sp_p15;
COMMIT;Versión de producción, con el bucle y el recuento:
DO $$
DECLARE
r RECORD;
v_dias INTEGER;
v_emitidas INTEGER := 0;
v_saltadas INTEGER := 0;
BEGIN
FOR r IN
SELECT p.prestamo_id, p.socio_id,
(DATE '2026-08-02' - p.fecha_devolucion_prevista) AS dias
FROM prestamos p
WHERE p.fecha_devolucion IS NULL
AND p.fecha_devolucion_prevista < DATE '2026-08-02'
ORDER BY p.prestamo_id
LOOP
BEGIN -- bloque anidado = SAVEPOINT implícito
INSERT INTO multas (socio_id, prestamo_id, motivo, importe,
fecha_emision, estado)
VALUES (r.socio_id, r.prestamo_id, 'retraso',
LEAST(r.dias * 0.20, 15.00), DATE '2026-08-02', 'pendiente');
v_emitidas := v_emitidas + 1;
EXCEPTION
WHEN unique_violation THEN
v_saltadas := v_saltadas + 1;
RAISE NOTICE 'Préstamo %: ya tenía multa de retraso, se salta',
r.prestamo_id;
END;
END LOOP;
RAISE NOTICE 'Lote terminado: % emitidas, % saltadas', v_emitidas, v_saltadas;
END $$;Resultado esperado
NOTICE: Préstamo 9: ya tenía multa de retraso, se salta
NOTICE: Préstamo 15: ya tenía multa de retraso, se salta
NOTICE: Lote terminado: 1 emitidas, 2 saltadasConcretamente:
| prestamo_id | días de retraso | Resultado | Motivo |
|---|---|---|---|
| 9 | 98 | Saltado | Ya existe la multa 7, retraso, del socio 13 |
| 12 | 63 | Emitida, 12,60 € | Solo tenía la multa 4, de motivo perdida |
| 15 | 32 | Saltado | Ya existe la multa 8, retraso (anulada, pero ocupa el índice) |
Resultado final: 1 multa emitida, 2 saltadas, y la transacción confirma.
Comprobación:
SELECT multa_id, socio_id, prestamo_id, motivo, importe, estado
FROM multas WHERE fecha_emision = DATE '2026-08-02';| multa_id | socio_id | prestamo_id | motivo | importe | estado |
|---|---|---|---|---|---|
| 9 | 14 | 12 | retraso | 12.60 | pendiente |
Explicación. Lo que este ejercicio enseña es una propiedad de PostgreSQL que sorprende a quien viene de otros motores:
En PostgreSQL, un error dentro de una transacción la aborta entera. A partir de ese momento, toda instrucción devuelve
ERROR: current transaction is aborted, commands ignored until end of transaction block, y elCOMMITfinal se comporta como unROLLBACK.
Es decir: sin SAVEPOINT, un solo choque de clave única en el elemento número 3 de un lote de 500 tira los 500. Y no falla ruidosamente: el COMMIT responde ROLLBACK y hay que estar mirando para darse cuenta.
SAVEPOINT es el antídoto. Marca un punto al que se puede volver; ROLLBACK TO SAVEPOINT deshace solo lo hecho después y devuelve la transacción a estado utilizable. RELEASE SAVEPOINT lo descarta cuando ya no hace falta (opcional, pero conviene en lotes largos: cada punto de guardado vivo consume recursos).
En plpgsql no se escribe SAVEPOINT: se usa un bloque BEGIN ... EXCEPTION ... END. Ese bloque crea y gestiona el punto de guardado automáticamente. Es la forma idiomática y la que hay que usar.
El caso del préstamo 15 merece un comentario de diseño. Su multa previa (la 8) está en estado anulada, así que conceptualmente «no cuenta» y quizá debería poder emitirse una nueva. Pero el índice único es sobre (prestamo_id, motivo) sin más, y no distingue estados. Si el negocio quiere permitirlo, el índice correcto sería parcial:
DROP INDEX uq_multa_prestamo_motivo;
CREATE UNIQUE INDEX uq_multa_prestamo_motivo
ON multas (prestamo_id, motivo)
WHERE estado IN ('pendiente','pagada');Es el mismo patrón del índice único parcial que usamos en 07-02 para «un solo alquiler abierto por copia». Que un lote se salte un elemento por una restricción demasiado ancha es un síntoma de diseño, no solo un problema de proceso.
Ejercicio 6: Qué queda tras una secuencia de SAVEPOINT y ROLLBACK TO
Dificultad: Avanzado
Enunciado. Un operador ejecuta esta secuencia en una sola sesión. Indica, para cada instrucción numerada, si su efecto sobrevive al COMMIT final o no, y describe el estado final de las tablas afectadas. Justifica cada respuesta.
BEGIN;
INSERT INTO ponentes (ponente_id, nombre, apellidos, email, externo)
VALUES (4,'Lidia','Serna','[email protected]',TRUE); -- (1)
SAVEPOINT sp1;
UPDATE eventos SET plazas_ofertadas = 20 WHERE evento_id = 106; -- (2)
SAVEPOINT sp2;
INSERT INTO participaciones VALUES (106,4,'tallerista',400.00); -- (3)
UPDATE eventos SET estado = 'completo' WHERE evento_id = 106; -- (4)
ROLLBACK TO SAVEPOINT sp2;
INSERT INTO participaciones VALUES (107,4,'narradora',150.00); -- (5)
SAVEPOINT sp3;
DELETE FROM ponentes WHERE ponente_id = 2; -- (6)
ROLLBACK TO SAVEPOINT sp3;
UPDATE eventos SET publicado = TRUE WHERE evento_id = 107; -- (7)
COMMIT;Segunda parte: ¿qué habría pasado si se hubiera omitido el ROLLBACK TO SAVEPOINT sp3 tras la instrucción (6)?
Solución
| # | Instrucción | ¿Sobrevive? | Por qué |
|---|---|---|---|
| (1) | INSERT ponente 4 |
Sí | Ocurre antes de sp1; ningún ROLLBACK TO retrocede tan atrás |
| (2) | UPDATE plazas del 106 a 20 |
Sí | Ocurre entre sp1 y sp2. El ROLLBACK TO sp2 vuelve al punto sp2, que es posterior a esta instrucción |
| (3) | INSERT participación (106,4) |
No | Posterior a sp2, deshecha por ROLLBACK TO sp2 |
| (4) | UPDATE estado del 106 a completo |
No | Posterior a sp2, deshecha por ROLLBACK TO sp2 |
| (5) | INSERT participación (107,4) |
Sí | Posterior al ROLLBACK TO sp2 y anterior a sp3; nada la deshace |
| (6) | DELETE del ponente 2 |
No | Falla: viola la clave ajena desde participaciones (el ponente 2 participa en 102, 103 y 106). El ROLLBACK TO sp3 deja la transacción utilizable |
| (7) | UPDATE publicado del 107 |
Sí | Última instrucción, confirmada por el COMMIT |
Estado final de las tablas:
| ponente_id | nombre | apellidos |
|---|---|---|
| 1 | Rosa | Calduch |
| 2 | Aitor | Lemus |
| 3 | Delia | Marchetti |
| 4 | Lidia | Serna |
| evento_id | plazas_ofertadas | estado | publicado |
|---|---|---|---|
| 106 | 20 | abierto | t |
| 107 | 20 | programado | t |
participaciones pasa de 7 a 8 filas: se añade (107, 4, 'narradora', 150.00) y no se añade (106, 4, 'tallerista', 400.00).
Segunda parte: sin el ROLLBACK TO SAVEPOINT sp3.
DELETE FROM ponentes WHERE ponente_id = 2;
ERROR: update or delete on table "ponentes" violates foreign key constraint
"participaciones_ponente_id_fkey" on table "participaciones"
UPDATE eventos SET publicado = TRUE WHERE evento_id = 107;
ERROR: current transaction is aborted, commands ignored until end of transaction block
COMMIT;
ROLLBACKSe pierde absolutamente todo: la instrucción (7) ni se ejecuta, y el COMMIT responde literalmente ROLLBACK. El ponente 4 no se crea, las plazas del 106 siguen en 15 y la participación (107,4) no existe. Un solo error, sin punto de guardado que lo contenga, tira el trabajo entero.
Explicación. El punto que hay que fijar y que casi todo el mundo confunde la primera vez:
ROLLBACK TO SAVEPOINT spdeshace lo ocurrido después desp. Lo ocurrido antes desp—incluido lo que hay entresp1ysp2— permanece.
La instrucción (2) es la que separa a quien lo ha entendido de quien no. Está entre dos puntos de guardado, y el ROLLBACK TO sp2 vuelve al segundo, no al primero. Si el operador hubiera querido deshacerla, tendría que haber escrito ROLLBACK TO sp1.
Segundo punto: un ROLLBACK TO no destruye el punto de guardado. Tras ROLLBACK TO sp2, el punto sp2 sigue existiendo y se puede volver a él más veces. Es RELEASE SAVEPOINT lo que lo elimina. Y ROLLBACK TO sp1 invalidaría automáticamente sp2, porque es posterior.
Tercer punto, el más importante en la práctica: la respuesta ROLLBACK a un COMMIT es fácil de no ver. En un psql interactivo salta a la vista; en un cliente de aplicación que no comprueba el valor devuelto por commit(), la transacción se pierde en silencio y la aplicación cree que ha guardado. Esta es la razón por la que los ORM serios envuelven cada operación en un punto de guardado y por la que el ON_ERROR_STOP de psql (\set ON_ERROR_STOP on) debería estar activo en todo script de migración.
Bloque C — Concurrencia: ejercicios de dos sesiones
Instrucciones para todo el bloque. Ejecuta cada línea en la sesión indicada y en el orden indicado. Cuando una sesión se quede «colgada» sin devolver el prompt, es porque está esperando un bloqueo: es exactamente lo que queremos observar. Antes de cada ejercicio, asegúrate de que ninguna de las dos sesiones tiene una transacción abierta (
ROLLBACK;por si acaso).
Ejercicio 7: Reproducir una actualización perdida y arreglarla
Dificultad: Avanzado
Enunciado. El reglamento añade un recargo de 2,00 € a las multas pendientes con más de 30 días. Dos administrativos, en dos sucursales distintas, aplican el recargo a la misma multa: la número 3 (Iván Pereda, 1,60 €, pendiente).
La aplicación lo hace en dos pasos: lee el importe, le suma 2,00 en memoria y escribe el resultado.
Parte 1. Reproduce el fenómeno. ¿Cuánto debería valer la multa al final y cuánto vale?
Parte 2. Arréglalo de dos formas distintas y explica cuál prefieres.
Solución — Parte 1: la actualización perdida
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT importe FROM multas WHERE multa_id = 3; → 1.60 |
|
| 3 | BEGIN; |
|
| 4 | SELECT importe FROM multas WHERE multa_id = 3; → 1.60 |
|
| 5 | UPDATE multas SET importe = 3.60 WHERE multa_id = 3; |
|
| 6 | COMMIT; |
|
| 7 | UPDATE multas SET importe = 3.60 WHERE multa_id = 3; |
|
| 8 | COMMIT; |
|
| 9 | SELECT importe FROM multas WHERE multa_id = 3; → 3.60 |
Debería valer 5,60 € (1,60 + 2,00 + 2,00). Vale 3,60 €. Uno de los dos recargos ha desaparecido sin dejar rastro: ni error, ni aviso, ni entrada en ningún registro. Los dos administrativos vieron UPDATE 1 y creen que su trabajo está hecho.
Solución — Parte 2, opción 1: SELECT ... FOR UPDATE
Restablece el valor (UPDATE multas SET importe = 1.60 WHERE multa_id = 3;) y repite con bloqueo:
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT importe FROM multas WHERE multa_id = 3 FOR UPDATE; → 1.60 |
|
| 3 | BEGIN; |
|
| 4 | SELECT importe FROM multas WHERE multa_id = 3 FOR UPDATE; → se queda esperando |
|
| 5 | UPDATE multas SET importe = 3.60 WHERE multa_id = 3; |
(sigue esperando) |
| 6 | COMMIT; |
→ se desbloquea y devuelve 3.60 |
| 7 | UPDATE multas SET importe = 5.60 WHERE multa_id = 3; |
|
| 8 | COMMIT; |
Resultado: 5,60 €. Correcto.
Solución — Parte 2, opción 2: el UPDATE atómico
-- Sesión A -- Sesión B
UPDATE multas SET importe = importe + 2.00 UPDATE multas SET importe = importe + 2.00
WHERE multa_id = 3; WHERE multa_id = 3;Sin BEGIN explícito, cada UPDATE es su propia transacción. El segundo espera a que el primero confirme y relee la fila actualizada antes de aplicar su cambio, porque en READ COMMITTED un UPDATE bloqueado reevalúa la fila cuando se libera.
Resultado: 5,60 €. También correcto, y sin bloqueo explícito.
Resultado esperado
| Enfoque | Resultado final | Viajes a la base de datos |
|---|---|---|
| Leer, calcular, escribir (sin bloqueo) | 3,60 € — incorrecto | 2 |
SELECT ... FOR UPDATE + UPDATE |
5,60 € | 2 |
UPDATE ... SET importe = importe + 2.00 |
5,60 € | 1 |
Explicación. La actualización perdida es el fenómeno de concurrencia más traicionero porque el motor no lo considera un error. Los dos UPDATE son legítimos, los dos afectan a una fila, los dos confirman. La incoherencia está en la cabeza de la aplicación, no en la base de datos.
Cuándo usar cada solución:
- El
UPDATEatómico es siempre preferible cuando es posible. Un solo viaje, sin ventana de carrera, sin bloqueo que gestionar. La regla: si el valor nuevo se puede expresar en función del valor viejo dentro del propio SQL, hazlo así. FOR UPDATEes necesario cuando el cálculo no cabe en elUPDATE: cuando hay que consultar otras tablas, aplicar lógica de negocio compleja o decidir si actualizar. Es el caso de la transacción del préstamo del ejercicio 4.- El bloqueo optimista con columna
version(ejercicio 10) es la tercera vía, y la que conviene cuando el usuario tiene un formulario abierto en pantalla: no se puede mantener un bloqueo mientras alguien piensa.
Nota importante sobre READ COMMITTED, que es el nivel por defecto: en el paso 7 de la opción 2, el UPDATE de la sesión B no trabaja sobre la instantánea que vio al empezar; cuando el bloqueo se libera, PostgreSQL reevalúa el WHERE sobre la versión más reciente de la fila. Ese comportamiento —que no es el que un aislamiento estricto haría— es lo que salva a la opción 2, y no funciona en REPEATABLE READ: allí el segundo UPDATE abortaría con ERROR: could not serialize access due to concurrent update.
Ejercicio 8: READ COMMITTED frente a REPEATABLE READ
Dificultad: Avanzado
Enunciado. Una consulta del catálogo web lee dos veces, dentro de la misma transacción, cuántos ejemplares disponibles hay del material 902 («El mapa del tiempo»). Entre las dos lecturas, un compañero devuelve un ejemplar.
Ejecuta el escenario dos veces: primero con READ COMMITTED y después con REPEATABLE READ. Anota qué ve la Sesión A en cada lectura y explica la diferencia.
Estado de partida: del material 902 hay dos ejemplares, EJ-3081 (prestado) y EJ-3082 (disponible). Disponibles: 1.
Solución — Escenario 1: READ COMMITTED (el nivel por defecto)
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | BEGIN; (READ COMMITTED por defecto) |
|
| 2 | SELECT count(*) FROM ejemplares WHERE material_id=902 AND estado='disponible'; → 1 |
|
| 3 | UPDATE ejemplares SET estado='disponible' WHERE ejemplar_id=3081; |
|
| 4 | COMMIT; |
|
| 5 | SELECT count(*) FROM ejemplares WHERE material_id=902 AND estado='disponible'; → 2 |
|
| 6 | COMMIT; |
La Sesión A ha visto 1 y luego 2 dentro de la misma transacción: una lectura no repetible.
Solución — Escenario 2: REPEATABLE READ
Restablece el estado (UPDATE ejemplares SET estado='prestado' WHERE ejemplar_id=3081;) y repite:
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| 2 | SELECT count(*) ... ; → 1 |
|
| 3 | UPDATE ejemplares SET estado='disponible' WHERE ejemplar_id=3081; |
|
| 4 | COMMIT; |
|
| 5 | SELECT count(*) ... ; → 1 |
|
| 6 | COMMIT; |
|
| 7 | SELECT count(*) ... ; (ya fuera de la transacción) → 2 |
La Sesión A ve 1 las dos veces. Solo después del COMMIT, en una transacción nueva, ve el 2.
Resultado esperado
| Nivel | 1.ª lectura | 2.ª lectura | Fenómeno |
|---|---|---|---|
READ COMMITTED |
1 | 2 | Lectura no repetible |
REPEATABLE READ |
1 | 1 | Ninguno: instantánea estable |
Explicación. La diferencia está en cuándo se toma la instantánea del que cada transacción lee:
- En
READ COMMITTED, cada instrucción toma su propia instantánea, al empezar. Por eso la segunda consulta ve los cambios que se confirmaron entre una y otra. Es el nivel por defecto de PostgreSQL, y es el correcto para el 95 % de las aplicaciones: maximiza la concurrencia y nunca lee datos sin confirmar. - En
REPEATABLE READ, la instantánea se toma una sola vez, en la primera instrucción de la transacción, y se mantiene hasta el final. Todo lo que la transacción lea será coherente entre sí, como si el mundo se hubiera congelado.
Cuándo importa de verdad. Si el informe mensual de dirección hace ocho consultas —préstamos, socios, multas, pagos, eventos...— y se ejecuta en READ COMMITTED mientras el sistema está en uso, las ocho consultas pueden ver estados distintos de la base de datos. El total de multas puede no cuadrar con el desglose por motivo. En REPEATABLE READ eso es imposible: las ocho ven exactamente el mismo instante.
El precio. En REPEATABLE READ, si tu transacción intenta modificar una fila que otra transacción modificó y confirmó después de tu instantánea, PostgreSQL aborta la tuya con:
No es un fallo: es el contrato. La aplicación tiene que estar preparada para reintentar la transacción entera. Si no lo está, subir el nivel de aislamiento cambia un problema de datos incoherentes por un problema de errores en producción.
Nota terminológica. El estándar SQL dice que REPEATABLE READ permite lecturas fantasma (filas nuevas que aparecen en una consulta de rango). La implementación de PostgreSQL, basada en MVCC con instantáneas, no las permite: su REPEATABLE READ es más fuerte que el mínimo exigido por el estándar. Para el sesgo de escritura —el fenómeno que sí se le escapa— existe SERIALIZABLE, que es el cuarto nivel y el único que garantiza equivalencia con una ejecución en serie.
Ejercicio 9: Provocar y resolver un interbloqueo
Dificultad: Avanzado
Enunciado. Dos procesos actualizan los datos de dos socios, pero en orden distinto: el proceso A empieza por Marta Alsina (14) y sigue con Iván Pereda (15); el proceso B empieza por Iván y sigue con Marta.
Parte 1. Provoca el interbloqueo y observa qué hace PostgreSQL. Parte 2. Arréglalo sin cambiar lo que hace cada proceso.
Solución — Parte 1: el interbloqueo
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | UPDATE socios SET activo=TRUE WHERE socio_id=14; → UPDATE 1 |
|
| 3 | BEGIN; |
|
| 4 | UPDATE socios SET activo=TRUE WHERE socio_id=15; → UPDATE 1 |
|
| 5 | UPDATE socios SET activo=TRUE WHERE socio_id=15; → espera (B tiene la fila 15) |
|
| 6 | UPDATE socios SET activo=TRUE WHERE socio_id=14; → espera (A tiene la fila 14) |
|
| 7 | (al cabo de ~1 segundo, una de las dos recibe el error) |
Salida en la sesión víctima (la que PostgreSQL decida abortar):
ERROR: deadlock detected
DETAIL: Process 18422 waits for ShareLock on transaction 9931; blocked by process 18455.
Process 18455 waits for ShareLock on transaction 9930; blocked by process 18422.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,3) in relation "socios"La otra sesión se desbloquea inmediatamente y puede continuar hasta su COMMIT. La víctima queda con la transacción abortada y debe hacer ROLLBACK y reintentar.
Solución — Parte 2: ordenar los bloqueos
La causa es el orden inverso de adquisición. La solución es que todos los procesos bloqueen las filas en el mismo orden, por ejemplo por clave primaria ascendente:
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | UPDATE socios SET activo=TRUE WHERE socio_id=14; |
|
| 3 | BEGIN; |
|
| 4 | UPDATE socios SET activo=TRUE WHERE socio_id=14; → espera |
|
| 5 | UPDATE socios SET activo=TRUE WHERE socio_id=15; → UPDATE 1 |
(espera) |
| 6 | COMMIT; |
→ se desbloquea, UPDATE 1 |
| 7 | UPDATE socios SET activo=TRUE WHERE socio_id=15; → UPDATE 1 |
|
| 8 | COMMIT; |
Hay espera, pero no interbloqueo: B se queda detrás de A y ambas terminan. Y en una sola instrucción, aún mejor:
PostgreSQL bloquea las filas en el orden en que las encuentra, que es determinista para la misma consulta y el mismo plan.
Resultado esperado
| Escenario | Resultado |
|---|---|
| Orden inverso | ERROR: deadlock detected en una de las dos sesiones, tras ~1 s |
| Orden consistente | Las dos terminan; B espera a A |
| Una sola instrucción | Las dos terminan; sin ventana de interbloqueo |
Explicación. Un interbloqueo es un ciclo de esperas: A espera un recurso que tiene B, y B espera uno que tiene A. Ninguna puede avanzar y ninguna va a soltar lo que tiene.
PostgreSQL lo detecta y lo resuelve solo. Cada cierto tiempo —deadlock_timeout, un segundo por defecto— comprueba si hay un ciclo en el grafo de esperas, y si lo hay, aborta una de las transacciones para romperlo. Elige la víctima según criterios internos; no puedes predecir cuál será. Por eso el diagnóstico es siempre el mismo:
ERROR: deadlock detectedno es un fallo de la base de datos: es un fallo de la aplicación, que ha pedido bloqueos en orden incoherente. El motor solo lo ha detectado a tiempo.
Las tres reglas para no tenerlos:
- Orden consistente. Fija un criterio de ordenación —clave primaria ascendente es el más simple— y aplícalo en todo el código que bloquee varias filas. Si tu proceso lee una lista de identificadores para actualizarlos, ordénala antes.
- Transacciones cortas. Cuanto menos tiempo se sostiene un bloqueo, menor la ventana. Nunca hagas una llamada de red, ni esperes a un usuario, con una transacción abierta.
- Reintento automático. Aun con las dos reglas anteriores, un interbloqueo puede ocurrir. Toda operación transaccional importante debería envolverse en un bucle de reintento que capture el código SQLSTATE
40P01(deadlock_detected) y vuelva a intentarlo, típicamente 3 veces con espera creciente.
Nota: los interbloqueos también aparecen sin que el programador toque dos tablas. Dos INSERT en tablas relacionadas por clave ajena adquieren bloqueos sobre la fila padre, y dos procesos que insertan hijos de padres distintos en orden cruzado pueden bloquearse mutuamente. Por eso el criterio de orden debe aplicarse a las claves, no a las tablas.
Ejercicio 10: Bloqueo optimista con la columna version
Dificultad: Avanzado
Enunciado. El evento 106 («Taller de iniciación a la genealogía», 15 plazas) tiene 14 plazas cubiertas: queda una. Dos personas abren el formulario de inscripción del portal a la vez; las dos ven «1 plaza disponible» y las dos pulsan «Inscribirme» con unos segundos de diferencia.
El portal no puede mantener un bloqueo mientras el formulario está en pantalla: el usuario puede tardar minutos o irse a comer. Implementa bloqueo optimista usando la columna eventos.version.
Prepara el escenario:
UPDATE eventos SET plazas_ofertadas = 5, version = 1 WHERE evento_id = 106;
-- 106 tiene 4 plazas ocupadas (inscripciones de los socios 14, 15 y 16)
-- → con 5 ofertadas, queda exactamente 1 libreSolución
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | (el usuario abre el formulario) SELECT plazas_ofertadas, version FROM eventos WHERE evento_id=106; → 5, versión 1 |
|
| 2 | (otro usuario abre el formulario) mismo SELECT → 5, versión 1 |
|
| 3 | (pasan 4 minutos) | (pasan 5 minutos) |
| 4 | BEGIN; |
|
| 5 | UPDATE eventos SET estado='completo', version = version + 1 WHERE evento_id=106 AND version = 1; → UPDATE 1 |
|
| 6 | INSERT INTO inscripciones VALUES (106,11,DATE '2026-08-02','confirmada',0,1); |
|
| 7 | COMMIT; |
|
| 8 | BEGIN; |
|
| 9 | UPDATE eventos SET estado='completo', version = version + 1 WHERE evento_id=106 AND version = 1; → UPDATE 0 |
|
| 10 | (la aplicación detecta el 0 y aborta) ROLLBACK; |
|
| 11 | Relee: SELECT version, estado FROM eventos WHERE evento_id=106; → versión 2, completo |
La versión de aplicación, en plpgsql:
CREATE OR REPLACE FUNCTION inscribir_optimista(
p_evento_id INTEGER, p_socio_id INTEGER, p_version_vista INTEGER
) RETURNS TEXT AS $$
DECLARE
v_filas INTEGER;
v_libres INTEGER;
BEGIN
SELECT ev.plazas_ofertadas - COALESCE(sum(i.plazas_ocupadas), 0)
INTO v_libres
FROM eventos ev
LEFT JOIN inscripciones i
ON i.evento_id = ev.evento_id
AND i.estado IN ('confirmada','asistida')
WHERE ev.evento_id = p_evento_id
GROUP BY ev.plazas_ofertadas;
IF v_libres < 1 THEN
RETURN 'SIN_PLAZAS';
END IF;
UPDATE eventos
SET version = version + 1,
estado = CASE WHEN v_libres = 1 THEN 'completo' ELSE estado END
WHERE evento_id = p_evento_id
AND version = p_version_vista; -- <-- el corazón del método
GET DIAGNOSTICS v_filas = ROW_COUNT;
IF v_filas = 0 THEN
RETURN 'CONFLICTO_VERSION'; -- otro se adelantó: reintentar
END IF;
INSERT INTO inscripciones (evento_id, socio_id, fecha_inscripcion,
estado, acompanantes, plazas_ocupadas)
VALUES (p_evento_id, p_socio_id, CURRENT_DATE, 'confirmada', 0, 1);
RETURN 'OK';
END;
$$ LANGUAGE plpgsql;Resultado esperado
-- Sesión A
SELECT inscribir_optimista(106, 11, 1); --> OK
-- Sesión B, con la versión que leyó (1)
SELECT inscribir_optimista(106, 13, 1); --> CONFLICTO_VERSIONY el estado final:
| evento_id | plazas_ofertadas | version | estado | inscripciones confirmadas |
|---|---|---|---|---|
| 106 | 5 | 2 | completo | 4 (socios 14, 15, 16 y 11) |
El socio 13 no queda inscrito y la aplicación puede mostrarle un mensaje honesto: «la última plaza se acaba de ocupar».
Explicación. El bloqueo optimista resuelve un problema que el bloqueo pesimista no puede: el intervalo entre leer y escribir puede durar minutos, y mantener un bloqueo durante ese tiempo es inaceptable —bloquearía a todos los demás y, si el usuario cierra el navegador, el bloqueo se queda colgado hasta que expire la conexión.
El mecanismo tiene tres piezas y las tres son necesarias:
- Una columna
version(un entero, o untimestamp, o cualquier valor que cambie con cada modificación). - La aplicación lee y recuerda la versión que vio.
- El
UPDATEincluyeAND version = <la que vi>e incrementa la versión. Si otro se adelantó, la condición no se cumple y elUPDATEafecta a 0 filas.
Lo que hace que funcione es que UPDATE 0 no es un error: es información. Hay que comprobarlo explícitamente, y ese es el punto que más se olvida. Un UPDATE que devuelve 0 filas y no se comprueba convierte el bloqueo optimista en un adorno decorativo.
Optimista frente a pesimista:
Pesimista (FOR UPDATE) |
Optimista (version) |
|
|---|---|---|
| Cuándo | Lectura y escritura seguidas, en la misma transacción | Hay una pausa larga entre ambas (formulario, cola, API) |
| Coste sin conflicto | Un bloqueo sostenido | Ninguno |
| Coste con conflicto | Espera | Se pierde el trabajo y hay que reintentar |
| Cuándo NO usarlo | Transacciones largas o interactivas | Conflictos muy frecuentes: se reintenta sin parar |
Y el detalle final: en la Sesión B, la comprobación de plazas libres (v_libres) ya habría devuelto SIN_PLAZAS en este caso concreto, porque A confirmó antes. La comprobación de versión es la que cubre el caso peor: que las dos sesiones lleguen al UPDATE a la vez, cuando ninguna de las dos ha visto el cambio de la otra. Las dos comprobaciones no son redundantes: la primera da un mensaje mejor, la segunda es la que garantiza la corrección.
Ejercicio 11: Consumir una cola con SKIP LOCKED
Dificultad: Avanzado
Enunciado. La tabla avisos tiene 6 avisos pendientes. Dos procesos de envío se ejecutan en paralelo y cada uno debe tomar 2 avisos para procesarlos. Ningún aviso puede procesarse dos veces y ningún proceso debe quedarse esperando al otro.
Parte 1. Comprueba qué pasa con FOR UPDATE a secas.
Parte 2. Resuélvelo con FOR UPDATE SKIP LOCKED.
Solución — Parte 1: FOR UPDATE bloquea
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT aviso_id, mensaje FROM avisos WHERE estado='pendiente' ORDER BY aviso_id LIMIT 2 FOR UPDATE; → avisos 1 y 2 |
|
| 3 | BEGIN; |
|
| 4 | La misma consulta → se queda esperando |
La Sesión B se cuelga: quiere las mismas dos filas —son las dos primeras por aviso_id— y están bloqueadas. Con dos procesos, la cola es secuencial; con diez, nueve están parados. FOR UPDATE a secas convierte una cola paralela en una cola de uno.
Solución — Parte 2: SKIP LOCKED
Cierra las dos transacciones (ROLLBACK en ambas) y repite:
| Orden | Sesión A | Sesión B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT aviso_id, tipo, mensaje FROM avisos WHERE estado='pendiente' ORDER BY aviso_id LIMIT 2 FOR UPDATE SKIP LOCKED; → avisos 1 y 2 |
|
| 3 | BEGIN; |
|
| 4 | La misma consulta → avisos 3 y 4, sin esperar | |
| 5 | UPDATE avisos SET estado='enviado', enviado_en=now() WHERE aviso_id IN (1,2); |
|
| 6 | COMMIT; |
|
| 7 | UPDATE avisos SET estado='enviado', enviado_en=now() WHERE aviso_id IN (3,4); |
|
| 8 | COMMIT; |
Resultado esperado
Paso 2, Sesión A:
| aviso_id | tipo | mensaje |
|---|---|---|
| 1 | vencimiento | EJ-3088 venció el 26/04 |
| 2 | vencimiento | EJ-3093 venció el 31/05 |
Paso 4, Sesión B:
| aviso_id | tipo | mensaje |
|---|---|---|
| 3 | vencimiento | EJ-3087 venció el 01/07 |
| 4 | reserva | Tu reserva de El mapa del tiempo está disponible |
Estado final de la cola:
| estado | count |
|---|---|
| enviado | 4 |
| pendiente | 2 |
Los avisos 5 y 6 quedan para la siguiente ronda. Cero solapamiento, cero espera.
Explicación. SKIP LOCKED modifica el comportamiento del bloqueo de una forma muy concreta:
En lugar de esperar a que una fila bloqueada se libere, la salta y busca la siguiente que cumpla el
WHERE.
Eso es exactamente lo que una cola de trabajo necesita, y es la razón por la que PostgreSQL puede usarse como sistema de colas sin añadir un componente aparte.
Cuatro detalles imprescindibles del patrón:
LIMITacota el lote. Sin él, la primera sesión bloquearía toda la cola pendiente.ORDER BYda un orden de proceso. Aquí es por identificador (FIFO); podría ser por prioridad, por antigüedad o por lo que el negocio exija.- La marca de procesado va en la misma transacción. Si el
UPDATE ... SET estado='enviado'se hiciera en una transacción distinta, entre elSELECTy él habría una ventana en la que el bloqueo ya no existe y otro proceso podría coger el mismo aviso. - Lo que ocurre si el proceso muere entre el paso 4 y el 7 es lo mejor del patrón: la transacción no confirmada se deshace, los bloqueos se sueltan y los avisos vuelven a estar
pendiente. La cola se auto-repara. Si en cambio hubieras marcado los avisos como «en proceso» en una transacción aparte, una caída los dejaría atascados en ese estado para siempre y haría falta un proceso de rescate.
Cuándo NO usar SKIP LOCKED. Nunca en una consulta de negocio normal. Una consulta de saldo con SKIP LOCKED devolvería un resultado incompleto —le faltarían las filas que alguien esté modificando— sin ningún aviso. SKIP LOCKED solo tiene sentido cuando «cualquier subconjunto disponible» es una respuesta válida, y eso pasa en las colas y prácticamente en nada más.
Bloque D — Índices y planes de ejecución
Ejercicio 12: Del Seq Scan al Index Scan
Dificultad: Avanzado
Enunciado. En la BiblioRed de producción, prestamos tiene 912.000 filas y de ellas unas 3.100 están abiertas. La pantalla «mis préstamos en curso» ejecuta esta consulta y tarda casi un segundo:
EXPLAIN (ANALYZE, BUFFERS)
SELECT prestamo_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista
FROM prestamos
WHERE socio_id = 14 AND fecha_devolucion IS NULL; Seq Scan on prestamos (cost=0.00..21455.00 rows=4 width=20)
(actual time=112.338..873.912 rows=2 loops=1)
Filter: ((fecha_devolucion IS NULL) AND (socio_id = 14))
Rows Removed by Filter: 911998
Buffers: shared hit=1024 read=8431
Planning Time: 0.184 ms
Execution Time: 873.984 msSe pide: (a) diagnosticar el plan; (b) proponer el índice, justificando su forma; (c) predecir el plan resultante y estimar la mejora.
Solución
(a) Diagnóstico.
| Señal del plan | Qué significa |
|---|---|
Seq Scan on prestamos |
Se leen las 912.000 filas, una por una |
Rows Removed by Filter: 911998 |
De todo lo leído, se descarta el 99,9998 % |
rows=4 ... rows=2 |
La estimación (4) es razonable; el problema no es una estadística mala |
Buffers: read=8431 |
Se han ido a disco 8.431 bloques: unos 66 MB de lectura física |
Execution Time: 873 ms |
Casi un segundo por una consulta que devuelve 2 filas |
El diagnóstico es inequívoco: falta un índice. El motor sabe que solo hay 4 filas candidatas (estima bien), pero no tiene ninguna forma de encontrarlas sin mirarlas todas. La relación entre filas leídas y filas devueltas —456.000 a 1— es la definición de índice ausente.
(b) El índice propuesto.
Tres decisiones, y cada una tiene su razón:
- Índice parcial. La condición
fecha_devolucion IS NULLse cumple en 3.100 filas de 912.000: un 0,34 %. El índice parcial indexa solo esas y ocupa unos 80 KB frente a los ~20 MB de un índice completo sobresocio_id. Además, las filas que se cierran salen del índice automáticamente al devolverse el préstamo, así que se mantiene pequeño para siempre. socio_idcomo única columna. Dentro del índice parcial, la única discriminación que queda por hacer es el socio. Añadirfecha_devoluciona las columnas sería redundante: en el índice parcial valeNULLen todas las filas.- No es
UNIQUE. Un socio puede tener varios préstamos abiertos.
(c) El plan resultante.
Index Scan using idx_prestamos_abiertos on prestamos
(cost=0.28..12.42 rows=4 width=20) (actual time=0.021..0.028 rows=2 loops=1)
Index Cond: (socio_id = 14)
Buffers: shared hit=4
Planning Time: 0.211 ms
Execution Time: 0.049 msResultado esperado
| Métrica | Antes | Después | Factor |
|---|---|---|---|
| Nodo raíz | Seq Scan |
Index Scan |
— |
| Filas leídas | 912.000 | 2 | 456.000× |
Bloques (Buffers) |
9.455 | 4 | 2.360× |
| Tiempo de ejecución | 873,98 ms | 0,05 ms | ~17.500× |
| Tamaño del índice | — | ~80 KB | — |
Explicación. Lo importante de este ejercicio no es que un índice acelere una consulta —eso ya lo sabías desde 06-03—, sino cómo se lee el plan para llegar a esa conclusión con seguridad.
Rows Removed by Filter es la métrica reina. Es el número de filas que el motor leyó y tiró. Si es enorme comparado con las que devuelve, hay un índice esperando a ser creado. Si es pequeño, el Seq Scan puede ser la elección correcta: leer secuencialmente 500 filas es más rápido que saltar por un índice, y el planificador lo sabe.
Buffers distingue el problema de E/S del problema de CPU. shared hit son bloques que estaban en memoria; read, los que hubo que traer de disco. Aquí 8.431 lecturas físicas explican la mayor parte de los 873 ms. Un plan con muchos hit y pocos read que sigue siendo lento tiene un problema distinto (demasiadas comparaciones, una función cara, un JOIN mal elegido).
La comparación entre rows= estimadas y rows= reales es el otro diagnóstico clave. Aquí son 4 y 2: el planificador acierta, así que el problema es de acceso, no de estadísticas. Si hubiera estimado 4 y hubiera encontrado 300.000, el diagnóstico sería el contrario: estadísticas desactualizadas, y la solución ANALYZE prestamos; antes que cualquier índice.
Un aviso: después de crear el índice, ejecuta ANALYZE prestamos; y vuelve a medir. Y no crees índices «por si acaso»: cada índice ralentiza las escrituras y ocupa espacio. Un índice se justifica con un plan antes y un plan después, como en este ejercicio.
Ejercicio 13: Índices que existen y no se usan, y elección entre dos compuestos
Dificultad: Avanzado
Enunciado. Parte 1. Estas tres consultas de la BiblioRed de producción hacen Seq Scan pese a que existen los índices adecuados. Identifica el motivo de cada una y reescribe la consulta para que el índice se use. Los índices existentes son:
CREATE INDEX idx_prestamos_fecha ON prestamos (fecha_prestamo);
CREATE INDEX idx_ejemplares_codigo ON ejemplares (codigo);
CREATE INDEX idx_materiales_titulo ON materiales (titulo);-- (a)
SELECT count(*) FROM prestamos WHERE EXTRACT(YEAR FROM fecha_prestamo) = 2026;
-- (b)
SELECT * FROM ejemplares WHERE codigo::text = 'EJ-3081' || '';
-- (c)
SELECT material_id, titulo FROM materiales WHERE titulo LIKE '%Chernóbil%';Parte 2. El mostrador ejecuta constantemente estas dos consultas:
-- Q1: ejemplares disponibles de un material en una sucursal (unas 900 veces/hora)
SELECT ejemplar_id, codigo FROM ejemplares
WHERE material_id = 902 AND sucursal_id = 1 AND estado = 'disponible';
-- Q2: inventario completo de una sucursal por estado (unas 20 veces/hora)
SELECT estado, count(*) FROM ejemplares
WHERE sucursal_id = 1 GROUP BY estado;Solo puedes crear un índice compuesto. Elige entre (material_id, sucursal_id, estado) y (sucursal_id, estado, material_id) y razona con la regla del prefijo más a la izquierda.
Solución — Parte 1
(a) Función sobre la columna indexada.
EXTRACT(YEAR FROM fecha_prestamo) no es fecha_prestamo. El índice B-tree guarda fechas ordenadas; no sabe nada del año extraído. Reescritura por rango:
SELECT count(*) FROM prestamos
WHERE fecha_prestamo >= DATE '2026-01-01'
AND fecha_prestamo < DATE '2027-01-01';Ahora la condición es directamente sobre la columna y el índice sirve. La alternativa —si esta consulta fuera muy frecuente— es un índice de expresión:
...pero la reescritura por rango es preferible: sirve para cualquier intervalo, no solo para años completos.
(b) Conversión de tipo y expresión en el lado de la columna.
codigo::text fuerza una conversión y 'EJ-3081' || '' obliga a evaluar una concatenación. Lo primero es lo grave: convertir la columna la saca del índice. Reescritura:
Regla general: las transformaciones van en el lado del literal, nunca en el lado de la columna. Si el tipo del parámetro no coincide, conviértelo tú antes de pasarlo, o declara el parámetro con el tipo correcto. Este es el error que más veces aparece cuando un ORM manda un varchar a una columna integer o al revés.
(c) Comodín a la izquierda.
LIKE '%Chernóbil%' no tiene prefijo fijo. Un B-tree ordena por el principio de la cadena: sin un principio conocido, no hay rango que recorrer. LIKE 'Chernóbil%' sí lo usaría, pero cambia el significado de la consulta.
La solución correcta es un índice de otro tipo, GIN con trigramas:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_materiales_titulo_trgm
ON materiales USING gin (titulo gin_trgm_ops);
-- La consulta no cambia y ahora sí usa el índice:
SELECT material_id, titulo FROM materiales WHERE titulo ILIKE '%chernóbil%';El índice de trigramas descompone cada título en secuencias de tres caracteres y puede responder a búsquedas de subcadena en cualquier posición. Para búsqueda de texto por palabras completas, la alternativa es tsvector + GIN, que además maneja lematización y palabras vacías.
Solución — Parte 2
El índice a crear es (material_id, sucursal_id, estado). Razonamiento:
| Consulta | Con (material_id, sucursal_id, estado) |
Con (sucursal_id, estado, material_id) |
|---|---|---|
Q1 (material_id, sucursal_id, estado) |
Óptimo: las tres columnas se usan como prefijo completo. Índice recorrido hasta el final | También sirve: las tres condiciones son de igualdad, así que el orden no impide su uso |
Q2 (sucursal_id + GROUP BY estado) |
No sirve: sucursal_id es la segunda columna, y sin condición sobre material_id no hay prefijo. Seq Scan |
Óptimo: sucursal_id es prefijo, y estado viene detrás, así que las filas ya salen agrupadas |
Aquí las dos opciones son buenas para Q1 y solo la segunda es buena para Q2. Entonces, ¿por qué elegir la primera?
Porque las frecuencias son 900 frente a 20 por hora, y porque Q1 es la consulta interactiva. Q1 la ejecuta una persona esperando delante del mostrador; Q2 es un informe que puede tardar 200 ms sin que nadie se queje. Con (material_id, sucursal_id, estado), Q1 accede directamente al pequeño conjunto de ejemplares de ese material y el índice es muy selectivo desde la primera columna: hay 40.000 materiales, así que material_id = 902 reduce a un puñado de filas. Con (sucursal_id, estado, material_id), Q1 empieza por sucursal_id, que solo tiene 4 valores distintos: el primer nivel del índice apenas discrimina, y hay que recorrer una parte mucho mayor.
La regla del prefijo más a la izquierda, enunciada con precisión:
Un índice compuesto
(A, B, C)puede usarse para condiciones sobreA, sobreA, By sobreA, B, C. No puede usarse para condiciones que solo mencionenB,CoB, C.
Y la regla de diseño que se deriva: la columna más selectiva y siempre presente va primero. «Siempre presente» pesa más que «más selectiva»: un índice cuya primera columna falta en la consulta no se usa en absoluto.
Resultado esperado
| Consulta | Problema | Solución |
|---|---|---|
(a) EXTRACT(YEAR ...) |
Función sobre la columna | Reescribir como rango de fechas |
(b) codigo::text = ... |
Conversión de tipo sobre la columna | Comparar directamente, sin conversión |
(c) LIKE '%...%' |
Comodín inicial, sin prefijo | Índice GIN con pg_trgm |
| Parte 2 | Un solo índice para dos consultas | (material_id, sucursal_id, estado), priorizando Q1 |
Explicación. Los tres casos de la parte 1 comparten una única causa: el índice indexa la columna, no una expresión sobre la columna. En cuanto la condición transforma el valor de la columna —con una función, con una conversión, con una concatenación—, el motor deja de poder buscar en el índice, porque no sabe qué relación hay entre el orden de los valores originales y el de los transformados.
La forma de detectarlo en un plan es directa: si en el nodo aparece Filter: con una expresión alrededor del nombre de la columna, el índice no se está usando para eso. Si aparece Index Cond: con la columna desnuda, sí.
Sobre la parte 2, tres matices que conviene tener presentes:
- La respuesta cambia si cambian los datos. Si BiblioRed tuviera 400 sucursales en lugar de 4,
sucursal_idsería mucho más selectiva y la elección se acercaría. Las decisiones de indexación dependen de la cardinalidad real, y esa se mide (SELECT count(DISTINCT ...)), no se supone. - PostgreSQL sí puede usar un índice sin su prefijo, pero mal. Con
enable_seqscan = offverías unIndex Scanque recorre el índice entero comparando cada entrada. Es peor que elSeq Scany por eso el planificador no lo elige. «Puede usarlo» no significa «le sirve». - La regla no aplica igual a los índices GIN y BRIN, que no son árboles ordenados. La regla del prefijo más a la izquierda es una propiedad de los B-tree.
Errores Comunes y Consejos
1. Filtrar por una función de ventana en el WHERE. No se puede: se evalúan después. Envuelve en CTE o tabla derivada.
2. Confundir ROW_NUMBER, RANK y DENSE_RANK. Para un «top 1 por grupo» solo sirve ROW_NUMBER; RANK devuelve todos los empatados.
3. LAG sobre una serie con huecos. Compara con la fila anterior presente, no con el periodo anterior. Genera el eje temporal completo y une por la izquierda.
4. Olvidar la condición de parada de una CTE recursiva. Bucle infinito. Y si el grafo puede tener ciclos, usa CYCLE o arrastra el camino recorrido.
5. Comparar -> con texto. -> 'canal' = 'web' no encuentra nada. Usa ->> para texto y @> cuando quieras aprovechar el índice GIN.
6. count(CASE WHEN ... THEN 1 ELSE 0 END) en un pivote. Cuenta también los ceros. Usa FILTER o quita el ELSE 0.
7. Comprobar y actuar sin FOR UPDATE. Entre el SELECT que confirma y el UPDATE que actúa hay una ventana. Si el cálculo cabe en el propio UPDATE, hazlo atómico y no necesitarás bloqueo.
8. Ignorar un UPDATE 0. En bloqueo optimista es la señal de conflicto; en una transacción de negocio suele ser un fallo silencioso. Compruébalo siempre y decide explícitamente si es aceptable.
9. Creer que un error dentro de una transacción se puede ignorar. En PostgreSQL aborta la transacción entera y el COMMIT responde ROLLBACK. Usa SAVEPOINT (o bloques EXCEPTION en plpgsql) en todo proceso por lotes.
10. Bloquear filas en órdenes distintos. Es la causa del 90 % de los interbloqueos. Fija un orden —clave primaria ascendente— y respétalo en todo el código.
11. Subir el nivel de aislamiento sin implementar reintentos. REPEATABLE READ y SERIALIZABLE abortan transacciones legítimas por conflicto de serialización. Sin bucle de reintento, cambias datos incoherentes por errores en producción.
12. Usar SKIP LOCKED fuera de una cola. Devuelve resultados incompletos sin avisar.
13. Transformar la columna en el WHERE. EXTRACT, lower(), ::text, ||: cualquiera de ellos anula el índice. Las transformaciones van en el literal.
14. Crear índices sin medir. Cada índice ralentiza las escrituras. Un índice se justifica con un EXPLAIN ANALYZE antes y otro después.
Consejo de método para el bloque de concurrencia. Cuando una sesión se quede colgada y no sepas por qué, abre una tercera y consulta quién bloquea a quién:
SELECT pid, state, wait_event_type, wait_event,
left(query, 60) AS consulta,
pg_blocking_pids(pid) AS bloqueado_por
FROM pg_stat_activity
WHERE datname = current_database() AND state <> 'idle';pg_blocking_pids devuelve la lista de procesos que están bloqueando a cada uno. Es la herramienta que más tiempo ahorra en un incidente real de producción.
Ejercicios
Sin pistas y más exigentes.
Ejercicio A: Informe de rendimiento de eventos por sucursal
En una sola consulta, devuelve por sucursal: número de eventos celebrados, plazas ofertadas, plazas ocupadas, porcentaje de ocupación, valoración media ponderada por número de respuestas de la encuesta, y la posición de la sucursal en el ranking de valoración. Usa jsonb para las respuestas y una función de ventana para el ranking.
Ejercicio B: Transacción de devolución con recargo, resistente a la concurrencia
Escribe la función registrar_devolucion(p_ejemplar_id) que, en una única transacción: localiza el préstamo abierto de ese ejemplar bloqueándolo, anota la fecha de devolución de hoy, devuelve el ejemplar al estado disponible, y —si hay retraso— emite una multa de 0,20 €/día con tope de 15,00 €, sin duplicarla si el proceso se ejecuta dos veces. Debe fallar limpiamente si el ejemplar no tiene préstamo abierto. Explica qué pasa si dos sesiones la invocan a la vez sobre el mismo ejemplar.
Ejercicio C: Diagnóstico de un plan con Nested Loop
Diagnostica este plan de la BiblioRed de producción, di cuál es el nodo problemático, cuál es la causa raíz y qué dos intervenciones propondrías, en orden de prioridad.
HashAggregate (cost=48211.02..48214.02 rows=300 width=40)
(actual time=6841.220..6841.402 rows=4 loops=1)
Group Key: su.nombre
-> Nested Loop (cost=0.29..48196.02 rows=3000 width=32)
(actual time=0.412..6802.118 rows=418 loops=1)
-> Seq Scan on materiales m (cost=0.00..1204.00 rows=30 width=8)
(actual time=0.098..38.442 rows=127 loops=1)
Filter: (lower(titulo) ~~ '%chernóbil%'::text)
Rows Removed by Filter: 39873
-> Index Scan using idx_ejemplares_material on ejemplares e
(cost=0.29..1599.50 rows=100 width=32)
(actual time=1.204..53.210 rows=3 loops=127)
Index Cond: (material_id = m.material_id)
Planning Time: 1.882 ms
Execution Time: 6841.688 msSoluciones
Solución A
WITH ocupacion AS (
SELECT ev.evento_id, ev.sala_id, ev.plazas_ofertadas,
COALESCE(sum(i.plazas_ocupadas) FILTER (
WHERE i.estado IN ('confirmada','asistida')), 0) AS ocupadas
FROM eventos ev
LEFT JOIN inscripciones i ON i.evento_id = ev.evento_id
WHERE ev.estado = 'celebrado'
GROUP BY ev.evento_id, ev.sala_id, ev.plazas_ofertadas
),
encuestas AS (
SELECT ie.evento_id,
count(*) AS n_respuestas,
avg((r ->> 'puntuacion')::int) AS media
FROM informes_evento ie,
LATERAL jsonb_array_elements(ie.respuestas_encuesta -> 'respuestas') AS r
GROUP BY ie.evento_id
),
por_sucursal AS (
SELECT su.sucursal_id, su.nombre AS sucursal,
count(*) AS eventos,
sum(o.plazas_ofertadas) AS ofertadas,
sum(o.ocupadas) AS ocupadas,
round(100.0 * sum(o.ocupadas) / sum(o.plazas_ofertadas), 1) AS pct_ocupacion,
round(sum(e.media * e.n_respuestas) / sum(e.n_respuestas), 2) AS valoracion
FROM ocupacion o
JOIN salas sa ON sa.sala_id = o.sala_id
JOIN sucursales su ON su.sucursal_id = sa.sucursal_id
LEFT JOIN encuestas e ON e.evento_id = o.evento_id
GROUP BY su.sucursal_id, su.nombre
)
SELECT sucursal, eventos, ofertadas, ocupadas, pct_ocupacion, valoracion,
RANK() OVER (ORDER BY valoracion DESC) AS puesto
FROM por_sucursal
ORDER BY puesto;| sucursal | eventos | ofertadas | ocupadas | pct_ocupacion | valoracion | puesto |
|---|---|---|---|---|---|---|
| Sur | 1 | 8 | 5 | 62.5 | 4.75 | 1 |
| Este | 1 | 10 | 4 | 40.0 | 4.67 | 2 |
| Norte | 1 | 20 | 9 | 45.0 | 4.00 | 3 |
| Centro | 2 | 52 | 15 | 28.8 | 3.88 | 4 |
Centro sale 3,88 porque su valoración es la media ponderada de sus dos eventos: el 101 (4,50 con 4 respuestas) y el 104 (3,25 con 4 respuestas). (4.50×4 + 3.25×4) / 8 = 3.875. Si se hubiera hecho la media de las medias saldría lo mismo por casualidad —los dos tienen 4 respuestas—, pero en cuanto los tamaños difieran, la media de medias es incorrecta. Ese es el punto del ejercicio: ponderar por el número de respuestas, no promediar promedios.
Nota sobre las tres CTE: cada una agrega en su propio nivel para evitar el problema de multiplicación de filas del ejercicio 15 de 07-01. Unir inscripciones e informes_evento en el mismo JOIN haría que cada respuesta de encuesta se repitiera por cada inscripción.
Solución B
CREATE OR REPLACE FUNCTION registrar_devolucion(p_ejemplar_id INTEGER)
RETURNS TABLE (prestamo INTEGER, dias_retraso INTEGER, multa NUMERIC) AS $$
DECLARE
v_prestamo RECORD;
v_dias INTEGER;
v_importe NUMERIC(8,2) := 0;
v_multa_id INTEGER;
BEGIN
-- 1) Localizar y BLOQUEAR el préstamo abierto de ese ejemplar
SELECT p.prestamo_id, p.socio_id, p.fecha_devolucion_prevista
INTO v_prestamo
FROM prestamos p
WHERE p.ejemplar_id = p_ejemplar_id AND p.fecha_devolucion IS NULL
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'El ejemplar % no tiene ningún préstamo abierto', p_ejemplar_id
USING ERRCODE = 'no_data_found';
END IF;
-- 2) Cerrar el préstamo
UPDATE prestamos SET fecha_devolucion = CURRENT_DATE
WHERE prestamo_id = v_prestamo.prestamo_id;
-- 3) Devolver el ejemplar al circuito
UPDATE ejemplares SET estado = 'disponible'
WHERE ejemplar_id = p_ejemplar_id;
-- 4) Multa si procede, sin duplicar
v_dias := CURRENT_DATE - v_prestamo.fecha_devolucion_prevista;
IF v_dias > 0 THEN
v_importe := LEAST(v_dias * 0.20, 15.00);
INSERT INTO multas (socio_id, prestamo_id, motivo, importe,
fecha_emision, estado)
VALUES (v_prestamo.socio_id, v_prestamo.prestamo_id, 'retraso',
v_importe, CURRENT_DATE, 'pendiente')
ON CONFLICT (prestamo_id, motivo) DO NOTHING
RETURNING multa_id INTO v_multa_id;
IF v_multa_id IS NULL THEN
v_importe := 0; -- ya existía: no se duplica
END IF;
END IF;
RETURN QUERY SELECT v_prestamo.prestamo_id, GREATEST(v_dias, 0), v_importe;
END;
$$ LANGUAGE plpgsql;Qué pasa con dos sesiones simultáneas sobre el mismo ejemplar. La primera ejecuta el SELECT ... FOR UPDATE y bloquea la fila del préstamo. La segunda se queda esperando en ese mismo SELECT. Cuando la primera confirma, la segunda se desbloquea y reevalúa el WHERE sobre la versión actualizada de la fila: como fecha_devolucion ya no es NULL, la fila deja de cumplir la condición, FOUND es falso y la función lanza «no tiene ningún préstamo abierto». Es exactamente el comportamiento deseado: la segunda devolución del mismo ejemplar es un error, no una duplicación silenciosa.
El ON CONFLICT ... DO NOTHING es la segunda red de seguridad, por si el proceso se reintenta tras un fallo de red que dejó la transacción confirmada pero sin respuesta.
Solución C
Nodo problemático: el Index Scan interno del Nested Loop. Fíjate en loops=1 frente a loops=127: ese nodo se ejecuta 127 veces, una por cada fila del lado externo, y cada ejecución tarda unos 53 ms → 127 × 53 ≈ 6.700 ms, que es prácticamente todo el tiempo de la consulta.
Causa raíz: una estimación muy equivocada en el Seq Scan externo. El planificador estimó rows=30 para el filtro lower(titulo) ~~ '%chernóbil%' y encontró 127. Con 30 iteraciones previstas, el Nested Loop parecía barato; con 127 reales, sale cuatro veces más caro de lo calculado. La mala estimación es inevitable: PostgreSQL no tiene estadísticas útiles para un LIKE con comodín inicial sobre una función, y aplica una selectividad por defecto.
Dos intervenciones, en orden de prioridad:
- Índice GIN con trigramas sobre
titulo(pg_trgm). Ataca la causa raíz: convierte elSeq Scande 40.000 filas en un acceso indexado, elimina las 39.873 filas descartadas y, de paso, mejora radicalmente la estimación, porque el índice permite al planificador acotar mejor el número de filas. Con eso, elNested Looppasa a iterar sobre un conjunto pequeño y correctamente estimado. - Subir el objetivo de estadísticas de
materiales.titulo(ALTER TABLE materiales ALTER COLUMN titulo SET STATISTICS 500; ANALYZE materiales;). Es un parche complementario: no arregla elSeq Scan, pero mejora la estimación y puede llevar al planificador a elegir unHash Joinen lugar delNested Loop, que para 127 × 3 filas sería más estable.
Lo que no hay que hacer es tocar el Index Scan interno: idx_ejemplares_material funciona correctamente —3 filas por iteración, exactamente lo que debe—, y su único problema es que lo llaman 127 veces. En un Nested Loop lento, el culpable casi nunca es el nodo interno: es el número de iteraciones que le impone el externo.
Conclusión
Has cerrado el módulo con los trece ejercicios más exigentes del curso, y con ellos has practicado el repertorio completo de un profesional de bases de datos en producción: funciones de ventana para el top N por grupo, los rankings con empates y las comparaciones periodo contra periodo; CTE recursivas para generar ejes temporales sin huecos y recorrer jerarquías; jsonb con sus operadores de navegación, contención y despliegue, y el pivote manual con FILTER. Después, las transacciones de verdad: la del préstamo con su FOR UPDATE, su comprobación de filas afectadas y su control de errores; SAVEPOINT para que un lote no muera por un elemento; y el razonamiento exacto sobre qué sobrevive a una secuencia de puntos de guardado. Los cinco ejercicios de dos sesiones te han hecho ver con tus propios ojos una actualización perdida, la diferencia entre READ COMMITTED y REPEATABLE READ, un interbloqueo detectado por el motor, el bloqueo optimista salvando la última plaza y una cola consumida en paralelo con SKIP LOCKED. Y los dos últimos te han enseñado a leer un plan: Rows Removed by Filter, Buffers, loops, la distancia entre filas estimadas y reales, y por qué un índice que existe puede no servir para nada.
Si hay una idea que resume el módulo entero, es esta: en producción, los fallos que importan no dan error. Un COUNT(*) que cuenta uno donde debería contar cero, un informe que multiplica las plazas ofertadas por el número de inscritos, un recargo que desaparece porque dos administrativos lo aplicaron a la vez, un lote que confirma después de haber abortado, un índice que existe y que la consulta no usa. Ninguno de ellos aparece en un registro de errores. Todos se detectan de la misma manera: comprobando el número de filas, leyendo el plan, ejecutando el escenario en dos sesiones y desconfiando de los resultados que salen a la primera.
Con esta lección termina el módulo 7 y termina la parte del curso en la que tú escribías consultas sueltas. Lo que viene es el sistema entero. El módulo 8, Casos de Estudio, recorre tres proyectos completos de principio a fin: en 08-01 un sistema relacional con todo el ciclo —requisitos, modelo, esquema, consultas, índices y explotación—; en 08-02 un caso no relacional donde el mismo problema se modela en documentos y se comprueba qué se gana y qué se pierde; y en 08-03 la persistencia políglota, donde una sola aplicación combina un motor relacional para las transacciones, un almacén documental para el catálogo, uno clave-valor para la sesión y uno de búsqueda para el texto libre, y hay que decidir qué dato vive en cada sitio y cómo se mantienen coherentes entre sí. Después, el módulo 9 reúne los libros, cursos y herramientas con los que seguir por tu cuenta. Ya puedes cerrar el segundo terminal: en el módulo 8 volvemos a mirar el plano completo.
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
