09-04 terminó con una pregunta abierta: si SERIALIZABLE no es la respuesta para confirmar un pedido, ¿cuál lo es? Y con una observación sin explicar: cuando la sesión B intentaba actualizar una fila que A ya había tocado, B se quedaba esperando. Esta lección explica qué la retenía, y con ello llega la herramienta que los sistemas de comercio electrónico usan de verdad para que dos clientes no compren la misma unidad: el bloqueo.

Verás los bloqueos de fila implícitos que ya estabas provocando sin saberlo; la familia completa de SELECT ... FOR UPDATE y el patrón leer-modificar-escribir seguro, que es la solución correcta a la actualización perdida; SKIP LOCKED para repartir una cola de pedidos entre varios operarios sin pisarse; el bloqueo optimista frente al pesimista; los bloqueos de tabla, qué bloquea un ALTER TABLE y por qué tenía que existir CREATE INDEX CONCURRENTLY; y los interbloqueos, con su demostración, su mensaje real, cómo los resuelve el motor y las reglas para no provocarlos. Todo reproducible con dos terminales psql.

Contenido

  1. Bloqueos de fila implícitos
  2. La familia SELECT ... FOR ...
  3. El patrón leer-modificar-escribir seguro
  4. SKIP LOCKED: la cola de trabajos
  5. NOWAIT: fallar rápido
  6. Bloqueo optimista frente a pesimista
  7. Bloqueos de tabla, ALTER TABLE y CREATE INDEX CONCURRENTLY
  8. Interbloqueos
  9. Diagnóstico: quién bloquea a quién
  10. Los cuatro tiempos de espera
  11. Secuencias, ROLLBACK y los números de factura
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión del módulo

  1. Bloqueos de fila implícitos

No hace falta pedir un bloqueo: todo UPDATE y todo DELETE bloquean la fila que tocan hasta el final de la transacción. Retomemos el té matcha (producto 15, stock 40), con dos terminales:

Instante Sesión A Sesión B
t1 BEGIN;
t2 UPDATE productos SET stock = stock - 1 WHERE id = 15;UPDATE 1
t3 BEGIN;
t4 SELECT stock FROM productos WHERE id = 15;40, al instante
t5 UPDATE productos SET stock = stock - 1 WHERE id = 15;se queda esperando
t6 COMMIT;
t7 UPDATE 1 — se desbloquea sola
t8 SELECT stock FROM productos WHERE id = 15;38
t9 COMMIT;

Cuatro lecciones en nueve instantes:

  • t4: leer no espera. Es MVCC (09-02): los lectores no se bloquean con los escritores. B lee la versión antigua y sigue trabajando.
  • t5: escribir sí espera. La terminal de B se queda colgada, sin mensaje ni cursor. No es un cuelgue: está en cola.
  • t7: el bloqueo dura hasta el final de la transacción de A, no hasta el final de su UPDATE. Por eso una transacción larga es una transacción dañina (09-01).
  • t8: sale 38, no 39. Al desbloquearse, PostgreSQL releyó la fila ya actualizada y recalculó stock - 1 sobre 39. Es exactamente lo que explicó 09-04 sobre por qué la forma relativa es segura.

Y si A hubiera hecho ROLLBACK en t6, B habría continuado igualmente, pero calculando sobre 40 y dejando el stock en 39. En ambos casos, el resultado es correcto.

  1. La familia SELECT ... FOR ...

A veces necesitas bloquear una fila antes de escribirla, porque entre la lectura y la escritura hay una decisión que tomar. Para eso están los bloqueos explícitos, cuatro y ordenados de más fuerte a más débil:

Cláusula Qué significa La adquiere automáticamente
FOR UPDATE "Voy a modificar o borrar esta fila" UPDATE que toca columnas de clave, DELETE
FOR NO KEY UPDATE "Voy a modificarla, pero no su clave" UPDATE que no toca columnas de clave
FOR SHARE "Voy a leerla y necesito que nadie la cambie"
FOR KEY SHARE "Necesito que su clave siga existiendo" Comprobación de una clave foránea

Y la tabla de compatibilidad: ✗ significa que el segundo espera al primero.

El que llega ↓ / ya concedido → KEY SHARE SHARE NO KEY UPDATE UPDATE
FOR KEY SHARE
FOR SHARE
FOR NO KEY UPDATE
FOR UPDATE

Esa tabla, que parece burocracia, resuelve un problema real y muy frecuente: FOR KEY SHARE es lo que toma PostgreSQL al comprobar una clave foránea. Gracias a que es compatible con FOR NO KEY UPDATE, insertar una línea del pedido 21 (que necesita comprobar que el pedido 21 existe) no espera a que otra sesión actualice el estado de ese pedido. Antes de PostgreSQL 9.3 sí esperaba, y era una fuente clásica de bloqueos en cascada.

En el trabajo diario usarás FOR UPDATE en el 95 % de los casos y FOR SHARE en algún control de integridad. Los otros dos los verás en los diagnósticos, no los pedirás tú.

  1. El patrón leer-modificar-escribir seguro

Esta es la solución correcta a la actualización perdida de 09-04, y el patrón más importante de la lección:

BEGIN;
SELECT stock FROM productos WHERE id = 15 FOR UPDATE;   -- 1. leer BLOQUEANDO → 40
-- 2. decidir: aquí la aplicación aplica su lógica (descuentos, reservas, límites…)
UPDATE productos SET stock = 39 WHERE id = 15;          -- 3. escribir
COMMIT;

Y ahora las dos sesiones a la vez, el mismo escenario que perdía una venta en 09-04:

Instante Sesión A Sesión B
t1 BEGIN; SELECT stock FROM productos WHERE id = 15 FOR UPDATE;40
t2 BEGIN; SELECT stock FROM productos WHERE id = 15 FOR UPDATE;espera
t3 UPDATE productos SET stock = 39 WHERE id = 15; COMMIT;
t4 se desbloquea y lee → 39
t5 UPDATE productos SET stock = 38 WHERE id = 15; COMMIT;
id nombre stock
15 Té verde matcha ceremonial 30 g 38

Treinta y ocho. La clave está en t2: el FOR UPDATE de B espera a que A termine, y cuando pasa, B lee 39, no 40. La ventana entre leer y escribir ha desaparecido, porque durante toda ella la fila era suya.

Compáralo con las otras dos soluciones que ya conoces, porque las tres son válidas y se eligen por criterio distinto:

Solución Cuándo Coste
UPDATE ... WHERE id = 15 AND stock >= 1 (09-02) La lógica cabe en una sentencia Ninguno. Es la más rápida y la primera opción
SELECT ... FOR UPDATE + UPDATE Hay que decidir entre leer y escribir Las demás sesiones esperan. Predecible
SERIALIZABLE con reintento (09-04) El invariante abarca varias filas Abortos y bucle de reintento

Dos avisos sobre FOR UPDATE: no se puede usar con GROUP BY, DISTINCT, UNION ni funciones de ventana, porque el motor no sabría qué filas físicas bloquear; y bloquea todas las filas que devuelve la consulta, así que un SELECT * FROM productos FOR UPDATE sin WHERE bloquea el catálogo entero.

Nota de dialecto: SELECT ... FOR UPDATE existe en PostgreSQL, MySQL/InnoDB y Oracle con la misma sintaxis. SQL Server no la tiene: usa pistas de tabla, SELECT ... WITH (UPDLOCK, ROWLOCK). Y SQLite la acepta sintácticamente pero no hace nada, porque solo admite un escritor a la vez y el problema no se le plantea.

  1. SKIP LOCKED: la cola de trabajos

Añadiendo SKIP LOCKED, la consulta no espera: se salta las filas bloqueadas y sigue con las siguientes. Es exactamente lo que hace falta para repartir trabajo entre varios procesos.

El caso de TiendaVerde: varios operarios de almacén preparan los pedidos pagado —hoy son el 18 y el 19— y ninguno debe coger el mismo que otro.

-- Cada operario ejecuta esto, en su propia transacción
BEGIN;

SELECT id, cliente_id, fecha_pedido
FROM   pedidos
WHERE  estado = 'pagado'
ORDER  BY fecha_pedido
FOR UPDATE SKIP LOCKED
LIMIT  1;
Instante Sesión A — operario 1 Sesión B — operario 2
t1 BEGIN; + la consulta → coge el pedido 18 (2026-01-27)
t2 BEGIN; + la misma consulta → no espera: se salta el 18 y coge el 19
t3 UPDATE pedidos SET estado = 'enviado' WHERE id = 18; COMMIT;
t4 UPDATE pedidos SET estado = 'enviado' WHERE id = 19; COMMIT;
t5 BEGIN; + la consulta → 0 filas: no queda trabajo

En t1, la consulta de A devuelve la fila 18 | 5 | 2026-01-27; en t2, la de B devuelve 19 | 6 | 2026-02-09. Sin SKIP LOCKED, en t2 el operario 2 se habría quedado esperando al 1 para acabar cogiendo el mismo pedido que ya estaba hecho. Con él, la consulta se convierte en un repartidor de trabajo, y es la forma canónica de implementar una cola de tareas sobre una tabla —envío de correos, generación de facturas, sincronización con el marketplace— sin ninguna infraestructura adicional.

  1. NOWAIT: fallar rápido

La tercera opción: ni esperar ni saltar, sino rendirse inmediatamente. SELECT stock FROM productos WHERE id = 15 FOR UPDATE NOWAIT; devuelve, si la fila está tomada, ERROR: could not obtain lock on row in relation "productos".

Modificador Si la fila está bloqueada Cuándo usarlo
(nada) Espera indefinidamente El caso normal: el trabajo hay que hacerlo
NOWAIT Error inmediato Una petición web con presupuesto de tiempo: mejor decir "inténtalo de nuevo" en 5 ms que colgar al usuario 30 segundos
SKIP LOCKED Ignora esa fila y sigue Colas de trabajo: cualquier fila libre sirve

  1. Bloqueo optimista frente a pesimista

Todo lo anterior es bloqueo pesimista: supones que habrá conflicto y reservas la fila por adelantado. La alternativa es suponer que no lo habrá y comprobarlo al escribir.

Pesimista (FOR UPDATE) Optimista (columna de versión)
Supone Que habrá conflicto Que no lo habrá
Mecanismo Bloquea la fila desde la lectura Comprueba al escribir que nadie la cambió
Las demás sesiones Esperan Siguen trabajando; alguna fallará al final
Si hay conflicto No pasa nada: se hizo cola Se pierde el trabajo y hay que rehacerlo
Requiere Transacción abierta desde la lectura Nada: funciona sin transacción abierta entre pasos
Ideal para Mucha contención sobre pocas filas: el stock Poca contención y espera humana: un formulario de edición

El bloqueo optimista se implementa con una columna de versión que se incrementa en cada escritura:

-- ⚠️ EJEMPLO DE ESTA LECCIÓN: la columna `version` NO forma parte del esquema
-- canónico de TiendaVerde (01-06). Añádela para practicar y recarga después.
ALTER TABLE productos ADD COLUMN version INTEGER NOT NULL DEFAULT 0;

El ciclo tiene tres pasos, y entre el primero y el tercero no hace falta ninguna transacción abierta — que es toda la gracia:

SELECT stock, version FROM productos WHERE id = 15;   -- 1. leer sin bloquear → 40, 0
-- 2. El usuario piensa. Puede tardar cinco minutos. Nadie espera.
UPDATE productos                                      -- 3. escribir SOLO si nadie la ha tocado
SET    stock = 39, version = version + 1
WHERE  id = 15
  AND  version = 0;                                   -- → UPDATE 1 si nadie se adelantó

Y en la sesión que llegue segunda, con la misma version = 0 leída, la respuesta es UPDATE 0.

Cero filas afectadas: esa es la señal. No hay error, no hay excepción, no hay transacción abortada: hay un contador que vale 0 y que tu código tiene que comprobar. Si vale 0, alguien se te adelantó y hay que releer, recalcular y volver a intentarlo — o mostrar al usuario "estos datos han cambiado mientras editabas".

El riesgo del optimismo: si no compruebas el número de filas afectadas, un UPDATE 0 pasa completamente inadvertido y el cambio se pierde en silencio. Es el mismo UPDATE N que 05-03 pedía mirar siempre, ahora convertido en el mecanismo entero. Los ORM que implementan este patrón —Hibernate con @Version, Django con select_for_update como alternativa— lanzan una excepción precisamente para que no se pueda ignorar.

  1. Bloqueos de tabla, ALTER TABLE y CREATE INDEX CONCURRENTLY

Además de los bloqueos de fila, hay ocho modos de bloqueo de tabla. No hace falta memorizarlos; lo que hay que saber es cuál toma cada operación y cuál choca con cuál:

Modo (de más débil a más fuerte) Lo toma Bloquea a
ACCESS SHARE SELECT Solo a ACCESS EXCLUSIVE
ROW SHARE SELECT ... FOR UPDATE / FOR SHARE EXCLUSIVE y ACCESS EXCLUSIVE
ROW EXCLUSIVE INSERT, UPDATE, DELETE, MERGE Desde SHARE hacia arriba
SHARE UPDATE EXCLUSIVE VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, algunos ALTER TABLE A sí mismo y a lo más fuerte. No bloquea lecturas ni escrituras
SHARE CREATE INDEX (sin CONCURRENTLY) Las escrituras. Las lecturas siguen
EXCLUSIVE REFRESH MATERIALIZED VIEW CONCURRENTLY Todo menos SELECT
ACCESS EXCLUSIVE La mayoría de ALTER TABLE, DROP TABLE, TRUNCATE, REINDEX, CLUSTER, VACUUM FULL Absolutamente todo, incluidos los SELECT

Y también se pueden pedir a mano, dentro de una transacción — BEGIN; LOCK TABLE productos IN SHARE MODE;COMMIT; deja hacer un recuento de inventario coherente sin que nadie escriba mientras.

LOCK TABLE requiere privilegios. Los modos por encima de ROW EXCLUSIVE exigen permisos de escritura o de mantenimiento sobre la tabla, no basta con SELECT. Los privilegios y los roles son 11-03.

Y aquí se cierran dos promesas del curso. La primera es de 05-06: la mayoría de los ALTER TABLE toman ACCESS EXCLUSIVE, el modo que bloquea todo, incluidas las consultas. Por eso una migración aparentemente inocua puede tumbar un sitio: no por lo que tarde en ejecutarse, sino porque primero tiene que esperar a que terminen todas las transacciones en curso y, mientras espera, encola detrás a todo el que llegue. Un ALTER TABLE de 10 ms detrás de un informe de 40 minutos para la tienda 40 minutos. De ahí las dos reglas de 05-06: lock_timeout siempre antes de una migración, y variantes que no reescriben la tabla (ADD COLUMN con DEFAULT es instantáneo desde PostgreSQL 11; ADD CONSTRAINT ... NOT VALID seguido de VALIDATE CONSTRAINT evita el bloqueo largo).

La segunda es de 08-02: CREATE INDEX normal toma SHARE, que bloquea todas las escrituras de la tabla durante todo el tiempo que tarde en construirse — minutos u horas en una tabla grande. CREATE INDEX CONCURRENTLY toma solo SHARE UPDATE EXCLUSIVE, que no bloquea ni lecturas ni escrituras. El precio es el que explicó 09-03: recorre la tabla dos veces y espera entre pasadas a que terminen las transacciones antiguas, así que tarda más, no puede ejecutarse dentro de una transacción y, si falla, deja un índice inválido que hay que borrar a mano (se detecta con indisvalid = false en pg_index). En producción, siempre CONCURRENTLY.

  1. Interbloqueos

Un interbloqueo (deadlock) ocurre cuando dos transacciones se esperan mutuamente: A espera un recurso que tiene B, y B espera uno que tiene A. Sin intervención externa, esperarían para siempre.

La demostración de manual, con dos productos:

Instante Sesión A Sesión B
t1 BEGIN; UPDATE productos SET stock = stock - 1 WHERE id = 1;UPDATE 1
t2 BEGIN; UPDATE productos SET stock = stock - 1 WHERE id = 15;UPDATE 1
t3 UPDATE productos SET stock = stock - 1 WHERE id = 15;espera a B
t4 UPDATE productos SET stock = stock - 1 WHERE id = 1;espera a A
t5 (al cabo de ~1 segundo, una de las dos recibe el error)
flowchart LR
    A["<b>Sesión A</b><br/>tiene el bloqueo<br/>del producto 1"] -->|"espera el<br/>producto 15"| B["<b>Sesión B</b><br/>tiene el bloqueo<br/>del producto 15"]
    B -->|"espera el<br/>producto 1"| A

Ese ciclo en el grafo de espera es la definición formal del interbloqueo, y es lo que PostgreSQL busca. El mensaje real:

ERROR:  deadlock detected
DETAIL:  Process 41290 waits for ShareLock on transaction 813; blocked by process 41287.
Process 41287 waits for ShareLock on transaction 814; blocked by process 41290.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,21) in relation "productos"

Cómo lo resuelve el motor. Cada vez que una transacción lleva esperando más de deadlock_timeout (1 segundo por omisión), PostgreSQL construye el grafo de espera y busca ciclos. Si encuentra uno, elige una víctima y aborta su transacción con el código 40P01. La otra continúa y termina con normalidad. No es una configuración que haya que activar: viene puesta y funciona sola.

Las cuatro reglas para no provocarlos:

  1. Accede siempre a los recursos en el mismo orden. Es la regla de oro y resuelve el 90 % de los casos. Si todas las transacciones que tocan varios productos los recorren ordenados por id, el ciclo es imposible: quien tenga el 1 pedirá el 15, y quien no tenga el 1 estará esperándolo, sin haber tomado nada. En la práctica: ORDER BY id en el SELECT ... FOR UPDATE que precede a las escrituras.
  2. Transacciones cortas. Cuanto menos tiempo se sostiene un bloqueo, menor la probabilidad de cruzarse.
  3. Nada de interacción humana ni de llamadas externas dentro. Un usuario pensando con dos filas bloqueadas es una fábrica de interbloqueos (09-01, 09-03).
  4. Toca las filas en un solo UPDATE cuando puedas. Un UPDATE ... WHERE id IN (1, 15) bloquea en orden determinista y no deja hueco.

Y como red final: el 40P01 se reintenta igual que el 40001, con el mismo bucle de retroceso exponencial de 09-04. Por eso aquel patrón filtraba los dos códigos.

  1. Diagnóstico: quién bloquea a quién

Cuando algo "se ha quedado colgado", esta es la consulta que hay que tener guardada:

SELECT pid, pg_blocking_pids(pid) AS bloqueado_por, state, wait_event_type,
       now() - query_start        AS esperando_desde,
       left(query, 50)            AS consulta
FROM   pg_stat_activity
WHERE  cardinality(pg_blocking_pids(pid)) > 0;
pid bloqueado_por state wait_event_type esperando_desde consulta
41290 {41287} active Lock 00:03:12 UPDATE productos SET stock = stock - 1 WHERE id

pg_blocking_pids() es la función clave: devuelve directamente los procesos que están bloqueando a uno dado. Con el pid culpable (41287), se mira qué está haciendo en pg_stat_activity —muy a menudo, nada: idle in transaction— y, si hace falta, se corta:

Función Efecto
SELECT pg_cancel_backend(41287); Cancela la consulta en curso. La transacción sigue viva
SELECT pg_terminate_backend(41287); Cierra la conexión entera. La transacción se deshace

Para el detalle fino existe pg_locks, que responde a "¿qué modo exacto está pidiendo y sobre qué objeto?" — útil sobre todo con los bloqueos de tabla del apartado 7:

SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM   pg_locks AS l LEFT JOIN pg_class AS c ON c.oid = l.relation
WHERE  NOT l.granted;

  1. Los cuatro tiempos de espera

Cuatro parámetros, cuatro funciones distintas, y conviene no confundirlos:

Parámetro Qué limita Valor típico
lock_timeout Lo que una sentencia espera por un bloqueo antes de fallar '2s' antes de cualquier migración. El más importante de los cuatro
statement_timeout Lo que puede durar una sentencia en total '30s' en el usuario de la aplicación web
idle_in_transaction_session_timeout Lo que una transacción puede estar abierta y ociosa '5min', siempre (09-01)
deadlock_timeout Lo que se espera antes de buscar un ciclo de interbloqueo '1s', el valor por omisión. No es un límite: bajarlo solo hace que se compruebe más a menudo
-- El preámbulo obligatorio de cualquier migración en producción
SET lock_timeout = '2s';
ALTER TABLE productos ADD COLUMN peso_kg NUMERIC(6,3);

Si la tabla está ocupada, la sentencia falla en dos segundos con ERROR: canceling statement due to lock timeout en lugar de encolar a toda la tienda detrás de ella. Se reintenta más tarde y no ha pasado nada. Sin lock_timeout, ese ALTER TABLE es una caída esperando a ocurrir.

  1. Secuencias, ROLLBACK y los números de factura

Ya lo has visto tres veces —en 05-02, en el ejercicio 2 de 09-01 y en el lote de 09-03— y ahora toca el porqué y la consecuencia:

nextval() no es transaccional. Un ROLLBACK no devuelve el valor consumido, y por eso las secuencias dejan huecos.

Y es una decisión deliberada, no un descuido. Si nextval respetara las transacciones, tendría que bloquear la secuencia hasta el COMMIT, y todas las sesiones que quisieran insertar en esa tabla se pondrían en fila india detrás de la primera: cada INSERT concurrente sería un cuello de botella. PostgreSQL cambia unicidad garantizada y máxima concurrencia por continuidad, que es el trato correcto para una clave subrogada. Y de ahí una consecuencia contundente:

No uses nunca la clave primaria como número de factura. Una numeración legal de facturas debe ser correlativa y sin huecos; una secuencia garantiza que sea única y creciente, que no es lo mismo. Un ROLLBACK, un INSERT fallido o un upsert que acaba en conflicto (05-05) abren un hueco, y ese hueco es un problema con la Agencia Tributaria, no un detalle estético.

Las tres formas de obtener una numeración sin huecos, con su precio:

Enfoque Cómo Precio
Contador en una tabla, bloqueado Una fila por serie y año; SELECT ... FOR UPDATE sobre ella, sumar 1, escribir Serializa las facturas: solo una a la vez. Aceptable, porque facturar no es el camino caliente
Asignar al emitir, no al crear El pedido tiene su id con huecos; el número de factura se asigna en un proceso posterior, ordenado y por lotes Requiere separar "pedido" de "factura", que además es lo correcto contablemente
Reservar rangos Cada proceso toma un bloque de 100 números Vuelven los huecos si un bloque no se agota. Solo válido si la ley lo permite

La primera es la habitual, y su implementación es exactamente el patrón del apartado 3:

BEGIN;
SELECT ultimo_numero FROM contadores_factura WHERE serie = 'A' AND anyo = 2026 FOR UPDATE;
UPDATE contadores_factura SET ultimo_numero = ultimo_numero + 1 WHERE serie = 'A' AND anyo = 2026;
-- ... insertar la factura con ese número ...
COMMIT;

(La tabla contadores_factura es un ejemplo de esta lección y no forma parte del esquema canónico de TiendaVerde.) Fíjate en lo que estás haciendo: renunciar deliberadamente a la concurrencia en un punto concreto porque un requisito legal lo exige. Saber cuándo hacerlo es, en el fondo, de lo que ha ido todo el módulo.

Errores Comunes y Consejos

  • Creer que un SELECT normal bloquea algo. No bloquea nada, y por eso leer nunca espera. Si necesitas que la fila no cambie, tienes que pedirlo con FOR UPDATE o FOR SHARE. Y leer sin FOR UPDATE y escribir después es la actualización perdida de 09-04, ahora sin excusa.
  • Poner FOR UPDATE en una consulta sin WHERE selectivo. Bloqueas todas las filas que devuelve; con el catálogo entero, has parado la tienda.
  • Olvidar comprobar el número de filas afectadas con bloqueo optimista. Un UPDATE 0 es "alguien se te adelantó", y si no lo miras el cambio se pierde en silencio.
  • Lanzar un ALTER TABLE en producción sin lock_timeout. Toma ACCESS EXCLUSIVE, espera a la transacción más larga que haya y encola a todo el mundo detrás. Y CREATE INDEX sin CONCURRENTLY bloquea las escrituras durante toda la construcción.
  • Tocar varias filas en orden distinto en cada parte del código. Es la receta del interbloqueo. ORDER BY id en el SELECT ... FOR UPDATE, siempre.
  • Bajar deadlock_timeout "para que detecte antes". No es un límite de espera: solo hace que la comprobación de ciclos se ejecute más a menudo y consuma más CPU. Y el 40P01 no es un error de programación: es un conflicto temporal que se reintenta, igual que el 40001.
  • Usar la PK como número de factura. Las secuencias dejan huecos por diseño y la numeración legal no los admite.
  • Consejo: guarda la consulta de pg_blocking_pids. Es lo primero que se ejecuta cuando algo se cuelga, y ahorra media hora de conjeturas.
  • Consejo: SKIP LOCKED convierte una tabla en una cola de trabajo. Antes de montar una infraestructura de mensajería, comprueba si esto no te basta.
  • Consejo: elige pesimista donde hay contención real y optimista donde hay espera humana. Stock: FOR UPDATE. Formulario de edición: columna de versión.

Ejercicios

Con dos terminales psql y la base recién recargada.

Ejercicio 1

Resuelve la actualización perdida con bloqueo explícito y compara.

  1. Reproduce el apartado 3 en dos sesiones y comprueba que el stock final del producto 15 es 38. Anota en qué instante exacto se queda esperando la sesión B y qué valor lee al desbloquearse.
  2. Repítelo cambiando el FOR UPDATE de B por FOR UPDATE NOWAIT. ¿Qué mensaje sale y en cuánto tiempo?
  3. Repítelo con FOR UPDATE SKIP LOCKED. ¿Cuántas filas devuelve la consulta de B y por qué es un resultado peligroso en este caso concreto?
  4. Escribe las tres soluciones válidas a este problema que conoces ya (09-02, 09-04 y esta lección) y di cuál elegirías para el carrito de TiendaVerde y por qué.

Ejercicio 2

Monta la cola de preparación de pedidos del almacén.

  1. Escribe la consulta que toma el pedido pagado más antiguo sin pisar a nadie, y ejecútala en dos sesiones a la vez. Comprueba que una coge el 18 y otra el 19.
  2. Añade una tercera sesión con la misma consulta. ¿Qué devuelve y por qué?
  3. Sin SKIP LOCKED, ¿qué habría hecho la segunda sesión? ¿Y qué habría pasado al desbloquearse, exactamente?
  4. Diseña la variante que también sirva para reintentar pedidos que se quedaron a medias (un operario cuyo proceso murió). ¿Qué hace falta añadir a pedidos y por qué esa columna no forma parte del esquema canónico?

Ejercicio 3

Provoca un interbloqueo y diagnostícalo.

  1. Reproduce el apartado 8 con los productos 1 y 15. Copia el mensaje de error completo y anota qué sesión ha sido la víctima.
  2. Mientras las dos están esperando (antes del segundo), ejecuta desde una tercera sesión la consulta de pg_blocking_pids y describe lo que ves.
  3. Reescribe las dos transacciones para que el interbloqueo sea imposible, sin usar LOCK TABLE ni cambiar el nivel de aislamiento.
  4. Si aun así ocurriera bajo carga, ¿qué debería hacer la aplicación? Indica el código SQLSTATE y el patrón exacto.

Soluciones

Solución 1

1. El stock final es 38. B se queda esperando en su propio SELECT ... FOR UPDATE, no en el UPDATE: ese es el cambio respecto al apartado 1. Al desbloquearse, tras el COMMIT de A, lee 39 — el valor ya actualizado— y calcula 38 sobre él. La ventana entre leer y escribir ha desaparecido.

2. El error es inmediato, en milisegundos: ERROR: could not obtain lock on row in relation "productos". Y es una respuesta perfectamente válida para una petición web: mejor devolver "vuelve a intentarlo" al instante que dejar al usuario mirando una rueda treinta segundos.

3. La consulta de B devuelve 0 filas, y es peligrosísimo aquí. SKIP LOCKED está diseñado para "dame cualquier fila libre"; pero B no quiere cualquier producto: quiere ese. Un resultado vacío haría que la aplicación creyera que el producto 15 no existe, o —peor— que continuara sin descontar nada. SKIP LOCKED solo tiene sentido cuando las filas son intercambiables, como en una cola de trabajo.

4. Las tres soluciones:

Solución De dónde Cuándo
UPDATE ... WHERE id = 15 AND stock >= 1 y mirar UPDATE N 09-02 La lógica cabe en una sentencia
SERIALIZABLE + bucle de reintento 09-04 El invariante abarca varias filas
SELECT ... FOR UPDATE + UPDATE Esta lección Hay que decidir entre leer y escribir

Para el carrito de TiendaVerde: la primera, y si el descuento necesita lógica intermedia (comprobar reservas, aplicar promociones), la tercera. La segunda queda descartada porque obligaría a implementar reintentos en el camino más caliente de la tienda y abortaría justo en los picos de tráfico, que es cuando peor viene.

Solución 2

1. La consulta es la del apartado 4: SELECT ... WHERE estado = 'pagado' ORDER BY fecha_pedido FOR UPDATE SKIP LOCKED LIMIT 1. La sesión A coge el pedido 18 (2026-01-27) y la B, sin esperar, el 19 (2026-02-09).

2. La tercera devuelve 0 filas, porque los dos únicos pedidos pagado de TiendaVerde están bloqueados por A y B. Es el comportamiento correcto de una cola vacía: el operario 3 no espera, ve que no hay trabajo y vuelve a preguntar más tarde.

3. Sin SKIP LOCKED, la segunda sesión se quedaría esperando a que A terminara. Y al desbloquearse ocurriría lo peor: PostgreSQL relee la fila, reevalúa el WHERE y descubre que el pedido 18 ya no cumple estado = 'pagado' (A lo pasó a enviado), así que lo descarta y devuelve… 0 filas. Ni siquiera coge el 19, porque el LIMIT 1 ya se había resuelto. El operario 2 habría esperado para nada.

4. Hace falta marcar los pedidos que alguien está preparando y desde cuándo, para poder recuperar los abandonados:

-- ⚠️ NO canónico: columnas de ejemplo de este ejercicio
ALTER TABLE pedidos ADD COLUMN preparando_desde TIMESTAMPTZ;

La consulta pasa a ser WHERE estado = 'pagado' AND (preparando_desde IS NULL OR preparando_desde < now() - INTERVAL '15 minutes'), y el proceso escribe preparando_desde = now() al tomarlo. Así, si un operario muere a mitad, su pedido vuelve a la cola a los quince minutos. No es canónico porque el esquema de 01-06 modela una tienda, no una cola de trabajo: la columna existe solo para este mecanismo y ningún otro módulo la usa.

Solución 3

1. El mensaje es el del apartado 8, con deadlock detected y el código 40P01. La víctima es la sesión que dispara la detección, es decir, la que llevaba esperando más de deadlock_timeout cuando se encontró el ciclo — normalmente la segunda en quedarse bloqueada (la B del guion). La otra continúa y confirma con normalidad.

2. Desde la tercera sesión se ven dos filas, cada una bloqueada por la otra: el pid de A tiene en bloqueado_por el pid de B, y el de B tiene el de A. Esa reciprocidad es el ciclo del grafo de espera, visible en la salida de una consulta. Es el diagnóstico más satisfactorio del módulo, y hay que ser rápido: PostgreSQL lo resuelve en aproximadamente un segundo.

3. Basta con acceder a las filas siempre en el mismo orden, por ejemplo por id ascendente: las dos sesiones ejecutan BEGIN;UPDATE ... WHERE id = 1;UPDATE ... WHERE id = 15;COMMIT;, en ese orden. Ahora el ciclo es imposible: quien consiga el producto 1 acabará consiguiendo el 15, y quien no lo consiga estará esperando sin tener nada bloqueado, así que no puede bloquear a nadie. Es la regla de oro del apartado 8. La versión general, cuando las filas se eligen en tiempo de ejecución, es ordenar la lista antes de recorrerla: SELECT ... WHERE id = ANY($1) ORDER BY id FOR UPDATE.

4. Debería reintentar la transacción entera. El código es 40P01 (deadlock_detected), y el patrón es exactamente el bucle de 09-04: rollback(), espera con retroceso exponencial y jitter, hasta cinco intentos, sin reintentar ningún otro tipo de error. Por eso aquel pseudocódigo filtraba '40001' y '40P01': son los dos errores del módulo que significan "inténtalo otra vez", no "lo has hecho mal".

Conclusión del módulo

Cierras la última lección con las herramientas que faltaban:

  • Todo UPDATE y DELETE bloquea su fila hasta el final de la transacción, y por eso la sesión B esperaba. Leer no espera nunca; escribir sí. Y al desbloquearse, PostgreSQL relee y recalcula, que es por qué SET stock = stock - 1 daba 38.
  • La familia SELECT ... FOR UPDATE / FOR NO KEY UPDATE / FOR SHARE / FOR KEY SHARE con su tabla de compatibilidad, y el patrón leer-modificar-escribir seguro: la solución correcta a la actualización perdida de 09-04, y la que usan de verdad las tiendas.
  • SKIP LOCKED, que convierte una consulta en un repartidor de trabajo —dos operarios, los pedidos 18 y 19, sin pisarse— y NOWAIT para fallar en milisegundos en lugar de colgar a un usuario.
  • Optimista frente a pesimista: bloquear por adelantado donde hay contención real (el stock), o comprobar al escribir con una columna de versión donde hay espera humana — mirando siempre el UPDATE 0, porque ahí no hay error que te avise.
  • Los bloqueos de tabla y sus ocho modos, con las dos promesas que cierran: la mayoría de los ALTER TABLE toman ACCESS EXCLUSIVE y encolan detrás a toda la base (05-06), y CREATE INDEX bloquea las escrituras mientras CREATE INDEX CONCURRENTLY no (08-02). En producción, lock_timeout y CONCURRENTLY, siempre.
  • Los interbloqueos: el ciclo en el grafo de espera, el deadlock detected con su 40P01, la víctima elegida por el motor al cabo de un segundo, y la regla de oro que los hace imposibles — acceder siempre a los recursos en el mismo orden.
  • El diagnóstico: pg_blocking_pids() como primera consulta ante un cuelgue, pg_locks para el detalle, pg_cancel_backend y pg_terminate_backend para cortar, y los cuatro tiempos de espera con lock_timeout a la cabeza.
  • Y por qué una secuencia no se deshace con un ROLLBACK: porque hacerla transaccional convertiría cada INSERT en un cuello de botella. De ahí los huecos, y de ahí la regla: la clave primaria no es un número de factura.

Y con esto se cierra el módulo 9. En cinco lecciones has pasado de "hay una sentencia y la ejecuto" a entender el sistema entero: sabes que cada sentencia suelta ya es una transacción y que confirmar un pedido son cuatro operaciones que deben ir juntas; conoces las cuatro garantías ACID una a una, con el WAL que hace que un COMMIT sobreviva a un corte de corriente y con MVCC, que explica por fin las filas muertas, el bloat y el VACUUM que quedaron pendientes en el módulo 8; manejas el TCL completo, incluidos los SAVEPOINT que rescatan un lote de una carga fallida; sabes nombrar las cuatro anomalías de concurrencia, has visto perderse una venta de té matcha en dos sesiones y sabes que PostgreSQL no implementa READ UNCOMMITTED y su REPEATABLE READ no permite fantasmas; y sabes elegir entre una sentencia condicional, un FOR UPDATE y un SERIALIZABLE con reintento sabiendo qué pagas en cada caso.

Ya sabes leer (módulos 2 a 4 y 7), escribir (módulo 5), transformar (módulo 6), optimizar (módulo 8) y coordinar (este). Lo que falta no es una capacidad nueva: es el arsenal que hace mantenible todo lo anterior, y que separa una consulta que funciona de un sistema con el que se puede vivir. En el módulo 10, Avanzado, verás las vistas, para dar nombre a una consulta compleja y dejar de copiarla por todas partes; las CTE con WITH, que convierten una subconsulta anidada de cuarenta líneas en pasos legibles y permiten escribir consultas recursivas para recorrer la jerarquía de empleados o la cadena de referidos de TiendaVerde; las funciones de ventana, con las que se calculan rankings, medias móviles y acumulados sin perder el detalle; los procedimientos almacenados, donde acabará viviendo la confirmación de pedido que has construido en 09-03; los triggers, que aplican automáticamente esas reglas de negocio que 09-02 dejó fuera del alcance de un CHECK; y el tipo JSON, para lo que no cabe en un esquema fijo. Es la caja de herramientas de quien ya no está aprendiendo SQL, sino usándolo.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados