Transacciones: Atomicidad y Concurrencia en Código Real
Otra vez PostgreSQL de verdad, compilado a WebAssembly, corriendo enteramente en tu navegador. Vas a abrir, confirmar y deshacer transacciones reales con BEGIN/COMMIT/ROLLBACK, provocar en vivo el error real de Postgres cuando una transacción queda "abortada", y reproducir con tus propias manos el problema del lost update — antes de resolverlo con FOR UPDATE.
Bienvenido
Este taller es la práctica de laboratorio de transacciones (BEGIN/COMMIT/ROLLBACK), atomicidad y concurrencia en PostgreSQL. No vuelve a explicar la teoría desde cero —asume que ya viste la guía— y en cambio te da un PostgreSQL real para ejecutar cada paso con tus propias manos.
Si todavía no viste la teoría, repasa primero Transacciones: ACID y Concurrencia antes de continuar. Este taller usa una tabla cuentas (con Ana y Beto, el mismo ejemplo de la guía) y, en el Reto final, una tabla productos para reproducir el escenario de la última unidad vendida dos veces.
Esta página corre su propia base de datos, independiente de cualquier otro taller que hayas abierto antes, y se pierde si recargás, cerrás la pestaña o navegás a otra guía. Hay un detalle extra que en este taller importa más que en los demás: todos los bloques de código comparten la misma sesión de Postgres (la misma conexión). Si un bloque abre una transacción con BEGIN y no la cierra con COMMIT o ROLLBACK, esa transacción sigue abierta y afecta a todos los bloques siguientes — vas a comprobarlo a propósito en la sección 2. Si en algún momento algo se comporta raro (por ejemplo, un error de "current transaction is aborted"), escribe ROLLBACK; en la Zona de pruebas libres para destrabar la sesión.
PGlite (el Postgres que corre esta página) es una sola conexión, así que dos transacciones nunca están activas de verdad al mismo tiempo acá. Todo lo que es real de una sola transacción (BEGIN/COMMIT/ROLLBACK, la restricción CHECK abortando una transacción, FOR UPDATE como sintaxis) lo vas a ejecutar tal cual contra Postgres real. Lo que necesita dos conexiones reales bloqueándose entre sí (que T2 quede esperando el candado de T1, o un deadlock) no se puede reproducir acá — para eso ya viste el diagrama animado de la guía teórica. En la sección 3 vas a simular el intercalado escribiendo, en orden, la misma secuencia de sentencias que producirían el problema si dos cajeros la ejecutaran en paralelo: el enunciado te va a avisar cada vez que estés simulando algo en vez de ejecutando concurrencia real.
✍️ Lo escribís vos
Cada bloque parte vacío. El enunciado te dice qué tenés que lograr — el código SQL lo escribís de cero.
🔓 Una sesión, de punta a punta
Esa misma sesión compartida es lo que te permite comprobar, con Postgres real, qué pasa si una transacción queda abierta o abortada — algo que en una app normal casi nunca se ve tan de cerca.
🧪 Simulaciones, avisadas
Cada vez que un ejercicio simule concurrencia en vez de ejecutarla de verdad, el enunciado te lo va a decir explícitamente.
Zona de pruebas libres
Este bloque queda disponible durante toda la guía. Usalo en cualquier momento para probar una idea propia, revisar el estado de una tabla con SELECT * FROM tabla;, o escribir un ROLLBACK; suelto si necesitás destrabar la sesión — sin desordenar los bloques guiados.
Prepara tu base de datos
Este bloque no es un ejercicio — ya viene resuelto. Crea una tabla, cuentas, con dos filas: Ana y Beto. Ejecútalo tal cual, una sola vez, antes de seguir.
Crea cuentas con columnas id, titular y saldo, con una restricción CHECK (saldo >= 0) — ninguna cuenta puede quedar en negativo, sin importar qué UPDATE se intente. Ana arranca con Bs 400 y Beto con Bs 100, el mismo punto de partida que usa la guía teórica.
El resultado final debería mostrarte 2 filas: Ana con 400.00 y Beto con 100.00. Si ves otra cosa, usa "🔄 Reiniciar base de datos" y vuelve a ejecutar este bloque.
1. BEGIN y COMMIT
Agrupar dos cambios para que se confirmen juntos, de forma permanente.
Escribe una transacción completa que transfiera Bs 300 de Ana a Beto: abre con BEGIN, resta 300 al saldo de Ana, suma 300 al saldo de Beto, y confirma con COMMIT. Termina con un SELECT * FROM cuentas ORDER BY id; para comprobarlo.
El SELECT final debería mostrar a Ana con Bs 100 y Beto con Bs 400. Nada de esto fue visible para ningún otro bloque mientras la transacción seguía abierta — recién quedó aplicado con el COMMIT.
1.2 — Antes de ejecutar, predice: ¿qué mostraría el SELECT si en vez de correr todo este bloque de una, hubieras dejado la transacción abierta (sin COMMIT) y hubieras corrido el SELECT en la Zona de pruebas libres? Escribe tu predicción como comentario en el editor, y después comprobalo de verdad: abre una transacción nueva, resta Bs 50 a Beto sin confirmar todavía, y ejecuta este mismo bloque.
Escribe ahí SELECT * FROM cuentas;. Como estás en la misma sesión, vas a ver el saldo de Beto ya rebajado a Bs 350 — no porque otra transacción lo vea (Postgres nunca deja ver cambios sin confirmar de otra transacción), sino porque técnicamente seguís dentro de la misma transacción que abriste en 1.2. Cuando termines de mirar, volvé acá y ejecutá el siguiente bloque para confirmarla.
2. ROLLBACK y transacciones abortadas
Deshacer a propósito, y lo que pasa cuando Postgres deshace un error por vos.
Abre una transacción, resta Bs 30 al saldo de Ana, pero en vez de confirmar, deshazla con ROLLBACK.
El saldo de Ana sigue en Bs 100, sin ningún rastro de la resta. ROLLBACK deshace absolutamente todo lo hecho desde el BEGIN — no solo lo último.
Ahora un caso distinto: un ROLLBACK que Postgres te obliga a hacer.
Ana tiene Bs 100. Abre una transacción e intenta restarle Bs 500 — más de lo que tiene. La restricción CHECK (saldo >= 0) debería rechazar el UPDATE. No escribas ROLLBACK todavía, dejá la transacción tal como quede después del error.
Debería aparecer un error que menciona violates check constraint. Hasta acá, ningún cambio se aplicó — pero la transacción sigue abierta, porque nunca llegaste a un COMMIT ni a un ROLLBACK.
Sin haber cerrado esa transacción, intenta algo completamente inocente: SELECT * FROM cuentas; — una consulta que ni siquiera toca la fila que falló.
Este SELECT también falla, con un mensaje del estilo current transaction is aborted, commands ignored until end of transaction block — exactamente el detalle real de Postgres que menciona la guía teórica: apenas una sentencia falla dentro de una transacción, toda la transacción queda abortada, y cualquier otra sentencia que intentes en ella (aunque sea válida) va a fallar de la misma forma hasta que hagas ROLLBACK.
Mientras esta transacción siga abierta y abortada, cualquier sentencia que intentes en el resto del taller (incluida la Zona de pruebas libres) va a fallar con el mismo error. Ejecuta ROLLBACK; para destrabar la sesión.
El intento de resta de Bs 500 nunca llegó a aplicarse — Atomicidad y Consistencia trabajando juntas: la restricción impidió un estado inválido, y el ROLLBACK dejó la base exactamente como estaba antes del intento.
3. Simulando un lost update
Esto es una simulación, no concurrencia real: vas a escribir, en un solo bloque y en orden, la misma secuencia de sentencias que producirían el problema si dos cajeros la ejecutaran al mismo tiempo contra la cuenta de Ana.
Ana tiene Bs 100. Dos cajeros distintos, casi al mismo tiempo, leen ese saldo (ven Bs 100 los dos) y cada uno decide retirarle Bs 80. Cada cajero calcula su propia resta en base al valor que leyó (100 − 80 = 20) y la aplica con un UPDATE que escribe ese número ya calculado — así es como muchas aplicaciones reales arman este tipo de operación, sin bloquear nada.
Escribe, en este orden, dentro de un mismo bloque: una transacción que simula al "Cajero 1" (calculó 100 − 80 = 20 y confirma), seguida de otra transacción que simula al "Cajero 2" (calculó el mismo 100 − 80 = 20, porque leyó el saldo antes de que el Cajero 1 confirmara, y también confirma). Termina con un SELECT del saldo de Ana.
El saldo final es Bs 20 — el mismo resultado que si Ana hubiera retirado Bs 80 una sola vez. En realidad se confirmaron dos retiros de Bs 80 (Bs 160 en total), pero el segundo UPDATE pisó por completo el resultado del primero sin enterarse de que ya había cambiado. El retiro del Cajero 1 quedó perdido — de ahí el nombre lost update. Ninguna restricción CHECK lo detecta, porque Bs 20 sigue siendo un número válido (≥ 0): el problema no es un valor imposible, es un valor incorrecto.
4. FOR UPDATE en la práctica
La sintaxis que, con dos conexiones reales, evita justo el problema anterior.
Con una sola conexión no hay ningún "Cajero 2" real esperando un candado — así que este ejercicio practica la sintaxis y el patrón correcto, no el bloqueo en sí. En un escenario real con dos conexiones, FOR UPDATE obliga a la segunda transacción a esperar a que la primera confirme, y recién ahí le deja leer el saldo ya actualizado — nunca el valor viejo que causó el lost update de la sección anterior. Eso es exactamente lo que anima el diagrama de la guía teórica.
Primero, reiniciá el saldo de Ana a Bs 100 (para partir de un número limpio). Después, dentro de una transacción, usa SELECT saldo FROM cuentas WHERE titular = 'Ana' FOR UPDATE; para leer y bloquear la fila, calcula el nuevo saldo restando Bs 80 a partir de ese resultado, y confirma.
El resultado (Bs 20) es el mismo que ya viste — lo que cambia es cómo se llegó ahí: el valor restado salió de una lectura hecha dentro de la misma transacción que hizo el bloqueo, no de un número calculado antes y guardado aparte. Esa diferencia es exactamente lo que, con dos conexiones reales, cierra la puerta al lost update de la sección 3.
Reto final: la última unidad, vendida dos veces
El mismo problema de la sección 3, ahora con inventario — y resuelto con el patrón de la sección 4.
productos
Este bloque ya viene resuelto. Crea productos con un CHECK (stock >= 0) y una sola fila: un póster con stock = 1.
Igual que en la sección 3: simula a dos clientes que leyeron stock = 1 casi al mismo tiempo y ambos confirman su compra calculando stock = 0. Un solo bloque, dos transacciones secuenciales (Cliente A y Cliente B), terminando con un SELECT del stock.
El stock queda en 0, pero se confirmaron dos compras: el sistema le cobró a dos clientes distintos la misma unidad. Esto es exactamente el escenario que ya viste en la pregunta de la guía teórica sobre la tienda en línea.
Reinicia el stock a 1. Después, escribe dos intentos de compra, uno después del otro, usando siempre SELECT ... FOR UPDATE antes de decidir: el primer intento debe descontar el stock (queda en 0). El segundo intento debe leer el stock ya en 0 y, como no queda unidad disponible, terminar en ROLLBACK en vez de vender.
Sin usar ROLLBACK esta vez: con el stock otra vez en 0, intenta directamente UPDATE productos SET stock = stock - 1 WHERE nombre = 'Póster edición limitada';. Debería fallar con violates check constraint — aunque el patrón con FOR UPDATE ya evita llegar a este punto, la restricción sigue ahí por si algún código en algún lugar se olvida de chequear antes de escribir.
Repaso
Preguntas cortas sobre lo que acabas de observar en vivo. Tu progreso se guarda automáticamente en este navegador.
En el ejercicio 1.1, si el sistema se hubiera caído justo después de restarle a Ana y antes de sumarle a Beto (sin COMMIT), ¿qué garantiza que ninguno de los dos cambios queda aplicado?
En este taller, si un bloque deja una transacción abierta (BEGIN sin COMMIT ni ROLLBACK), el siguiente bloque que ejecutes corre dentro de esa misma transacción, porque todos comparten la misma sesión de Postgres.
En la sección 2, el UPDATE del ejercicio 2.2 falló por la restricción CHECK. El SELECT del ejercicio 2.3, que ni siquiera tocaba esa fila, también dio error. ¿Por qué?
En la simulación de la sección 3, el Cajero 1 y el Cajero 2 calcularon el mismo saldo final (Bs 20) para la cuenta de Ana. ¿Qué reveló exactamente ese resultado?
Con dos conexiones reales (no en esta demo de una sola sesión), ¿por qué agregar FOR UPDATE al SELECT inicial evita el lost update de la sección 3?
Clasifica cada situación observada en este taller según el concepto que ilustra.
Completa según lo que ejecutaste en este taller.
1. Para abrir una transacción se usa .
2. Para confirmar sus cambios de forma permanente se usa .
3. Para deshacer todo lo hecho desde el BEGIN se usa .
4. Para leer una fila y además bloquearla contra escritura de otras transacciones se agrega al SELECT.
Une cada término con lo que hace.
Cheat Sheet del taller
| Forma | Para qué sirve |
|---|---|
BEGIN; | Abre una transacción. Nada queda confirmado hasta el COMMIT. |
COMMIT; | Confirma todos los cambios de la transacción, de forma permanente. |
ROLLBACK; | Deshace todos los cambios hechos desde el BEGIN. |
CHECK (columna >= valor) | Restricción que rechaza un cambio inválido y, si falla en plena transacción, la aborta entera. |
current transaction is aborted | Aviso de Postgres: una sentencia anterior falló en esta transacción — hace falta ROLLBACK. |
SELECT ... FOR UPDATE | Lee una fila y la bloquea contra escritura hasta que la transacción termine. |
| Lost update | Un UPDATE con datos viejos sobrescribe, sin saberlo, un cambio ya confirmado. |
| Sesión compartida (de este taller) | Todos los bloques usan la misma conexión: una transacción sin cerrar afecta a los siguientes. |
¿Sabías que...?
🔓 Una sola sesión, a propósito
A diferencia de una app real (que usa un pool de varias conexiones), este taller corre todo en una única sesión de Postgres — por eso pudiste comprobar en vivo qué pasa cuando una transacción queda abierta o abortada, algo que en producción casi nunca se ve tan de cerca.
🏦 El lost update pasa todos los días
El patrón "leer un valor en el código de la app, calcular el nuevo valor ahí, y recién después escribirlo" es extremadamente común — y es exactamente el que produce un lost update si nadie bloquea la fila entre la lectura y la escritura.
🧪 Con dos pestañas reales, sí se ve el bloqueo
Si esta misma base de datos viviera en un servidor Postgres real (no en la memoria de una pestaña), abrir dos conexiones distintas y repetir el ejercicio de la sección 4 sí mostraría a la segunda conexión quedando literalmente esperando el candado de la primera — la limitación de hoy es solo de esta demo en memoria, no de PostgreSQL.