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
- Bloqueos de fila implícitos
- La familia
SELECT ... FOR ... - El patrón leer-modificar-escribir seguro
SKIP LOCKED: la cola de trabajosNOWAIT: fallar rápido- Bloqueo optimista frente a pesimista
- Bloqueos de tabla,
ALTER TABLEyCREATE INDEX CONCURRENTLY - Interbloqueos
- Diagnóstico: quién bloquea a quién
- Los cuatro tiempos de espera
- Secuencias,
ROLLBACKy los números de factura - Errores Comunes y Consejos
- Ejercicios
- Conclusión del módulo
- 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 - 1sobre 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.
- La familia
SELECT ... FOR ...
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ú.
- 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+UPDATEHay que decidir entre leer y escribir Las demás sesiones esperan. Predecible SERIALIZABLEcon 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 UPDATEexiste 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.
SKIP LOCKED: la cola de trabajos
SKIP LOCKED: la cola de trabajosAñ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.
NOWAIT: fallar rápido
NOWAIT: fallar rápidoLa 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 |
- 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 0pasa completamente inadvertido y el cambio se pierde en silencio. Es el mismoUPDATE Nque 05-03 pedía mirar siempre, ahora convertido en el mecanismo entero. Los ORM que implementan este patrón —Hibernate con@Version, Django conselect_for_updatecomo alternativa— lanzan una excepción precisamente para que no se pueda ignorar.
- Bloqueos de tabla,
ALTER TABLE y CREATE INDEX CONCURRENTLY
ALTER TABLE y CREATE INDEX CONCURRENTLYAdemá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 TABLErequiere privilegios. Los modos por encima deROW EXCLUSIVEexigen permisos de escritura o de mantenimiento sobre la tabla, no basta conSELECT. 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.
- 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:
- 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 iden elSELECT ... FOR UPDATEque precede a las escrituras. - Transacciones cortas. Cuanto menos tiempo se sostiene un bloqueo, menor la probabilidad de cruzarse.
- 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).
- Toca las filas en un solo
UPDATEcuando puedas. UnUPDATE ... 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.
- 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;
- 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.
- Secuencias,
ROLLBACK y los números de factura
ROLLBACK y los números de facturaYa 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. UnROLLBACKno 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, unINSERTfallido 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
SELECTnormal bloquea algo. No bloquea nada, y por eso leer nunca espera. Si necesitas que la fila no cambie, tienes que pedirlo conFOR UPDATEoFOR SHARE. Y leer sinFOR UPDATEy escribir después es la actualización perdida de 09-04, ahora sin excusa. - Poner
FOR UPDATEen una consulta sinWHEREselectivo. 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 0es "alguien se te adelantó", y si no lo miras el cambio se pierde en silencio. - Lanzar un
ALTER TABLEen producción sinlock_timeout. TomaACCESS EXCLUSIVE, espera a la transacción más larga que haya y encola a todo el mundo detrás. YCREATE INDEXsinCONCURRENTLYbloquea 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 iden elSELECT ... 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 el40P01no es un error de programación: es un conflicto temporal que se reintenta, igual que el40001. - 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 LOCKEDconvierte 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.
- 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.
- Repítelo cambiando el
FOR UPDATEde B porFOR UPDATE NOWAIT. ¿Qué mensaje sale y en cuánto tiempo? - 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? - 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.
- Escribe la consulta que toma el pedido
pagadomá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. - Añade una tercera sesión con la misma consulta. ¿Qué devuelve y por qué?
- Sin
SKIP LOCKED, ¿qué habría hecho la segunda sesión? ¿Y qué habría pasado al desbloquearse, exactamente? - 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
pedidosy por qué esa columna no forma parte del esquema canónico?
Ejercicio 3
Provoca un interbloqueo y diagnostícalo.
- 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.
- Mientras las dos están esperando (antes del segundo), ejecuta desde una tercera sesión la consulta de
pg_blocking_pidsy describe lo que ves. - Reescribe las dos transacciones para que el interbloqueo sea imposible, sin usar
LOCK TABLEni cambiar el nivel de aislamiento. - 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
UPDATEyDELETEbloquea 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 - 1daba 38. - La familia
SELECT ... FOR UPDATE / FOR NO KEY UPDATE / FOR SHARE / FOR KEY SHAREcon 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— yNOWAITpara 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 TABLEtomanACCESS EXCLUSIVEy encolan detrás a toda la base (05-06), yCREATE INDEXbloquea las escrituras mientrasCREATE INDEX CONCURRENTLYno (08-02). En producción,lock_timeoutyCONCURRENTLY, siempre. - Los interbloqueos: el ciclo en el grafo de espera, el
deadlock detectedcon su40P01, 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_lockspara el detalle,pg_cancel_backendypg_terminate_backendpara cortar, y los cuatro tiempos de espera conlock_timeouta la cabeza. - Y por qué una secuencia no se deshace con un
ROLLBACK: porque hacerla transaccional convertiría cadaINSERTen 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
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
