De las cuatro letras de ACID, tres son innegociables y una tiene un mando de volumen: el aislamiento. Puedes pedir la ilusión perfecta de que eres el único usuario —y pagarla con menos concurrencia y transacciones que abortan— o relajarla a cambio de rendimiento, aceptando que ocurran ciertas cosas raras. Esas cosas raras tienen nombre desde 1992, son cuatro, y esta lección las demuestra una a una en dos sesiones.

Después vendrán los cuatro niveles del estándar SQL con la tabla clásica de qué anomalía permite cada uno; y a continuación lo que casi ningún curso cuenta: esa tabla no describe a PostgreSQL. PostgreSQL no implementa READ UNCOMMITTED, y su REPEATABLE READ ya impide los fantasmas, cosa que el estándar no exige. Terminaremos con el error 40001, el más importante del módulo, y con su consecuencia práctica: si subes de nivel, tu aplicación tiene que saber reintentar.

Todos los ejemplos son reproducibles con dos terminales psql sobre la base recién recargada, en el formato de dos sesiones de 09-01.

Contenido

  1. Lectura sucia
  2. Lectura no repetible
  3. Lectura fantasma
  4. Actualización perdida y anomalía de serialización
  5. Los cuatro niveles del estándar SQL
  6. Lo que hace PostgreSQL de verdad
  7. El nivel por omisión de cada motor
  8. Cómo se fija y cómo se consulta el nivel
  9. READ COMMITTED frente a REPEATABLE READ en dos sesiones
  10. SERIALIZABLE y el error 40001
  11. El patrón de reintento con retroceso exponencial
  12. Qué nivel elegir
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. Lectura sucia

Lectura sucia (dirty read): una transacción lee datos que otra ha escrito y todavía no ha confirmado, y que pueden desaparecer con un ROLLBACK.

Instante Sesión A Sesión B
t1 BEGIN;
t2 UPDATE productos SET precio = 99.00 WHERE id = 15;UPDATE 1
t3 SELECT precio FROM productos WHERE id = 15;
t4 ROLLBACK;

En un motor que permitiera la lectura sucia, en t3 la sesión B vería 99.00 — un precio que nunca ha existido para nadie, porque en t4 se deshace. Si B fuera el proceso que genera el fichero del comparador de precios, TiendaVerde habría publicado un té matcha a 99 €.

En PostgreSQL, en cambio, B ve 22.00. Siempre. En cualquier nivel de aislamiento, incluido el que se llama READ UNCOMMITTED:

-- Sesión B, en t3
BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SELECT id, nombre, precio FROM productos WHERE id = 15;
COMMIT;
id nombre precio
15 Té verde matcha ceremonial 30 g 22.00

Y no es que PostgreSQL "se esfuerce" en evitarlo: con MVCC la lectura sucia es imposible por construcción. La versión nueva de la fila lleva un xmin de una transacción no confirmada, y la regla de visibilidad de 09-02 la descarta sin más. No hay código que quitar ni nivel que bajar.

Nota de dialecto: la lectura sucia sí existe, y se usa. SQL Server implementa READ UNCOMMITTED de verdad, y su famosa pista WITH (NOLOCK) es exactamente eso: leer sin respetar bloqueos ni confirmaciones. Se pone en informes para no bloquear a nadie, y a cambio pueden verse filas no confirmadas, filas duplicadas y filas ausentes en el mismo recorrido. MySQL/InnoDB también lo implementa. PostgreSQL, Oracle y SQLite, no.

  1. Lectura no repetible

Lectura no repetible (non-repeatable read): la misma consulta, sobre la misma fila, devuelve valores distintos dentro de una misma transacción, porque otra la modificó y confirmó por medio.

El caso de TiendaVerde: el analista está calculando el informe de márgenes y, a mitad, el responsable de compras sube el precio del matcha.

Instante Sesión A — analista (READ COMMITTED) Sesión B — compras
t1 BEGIN;
t2 SELECT precio FROM productos WHERE id = 15;22.00
t3 UPDATE productos SET precio = 24.00 WHERE id = 15; COMMIT;
t4 SELECT precio FROM productos WHERE id = 15;24.00
t5 COMMIT;

Dos lecturas de la misma fila dentro de la misma transacción, dos valores distintos. Si el informe suma importes en t2 y calcula porcentajes en t4, los porcentajes no cuadrarán con los importes y nadie sabrá por qué.

La causa es exactamente la de 09-02: en READ COMMITTED, cada sentencia toma una instantánea nueva. En REPEATABLE READ la instantánea es una sola para toda la transacción, y t4 seguiría devolviendo 22.00 — lo verás en el apartado 9.

  1. Lectura fantasma

Lectura fantasma (phantom read): la misma consulta devuelve un conjunto de filas distinto, porque otra transacción insertó o borró filas que cumplen la condición.

La diferencia con la anterior es sutil pero importante: allí cambiaba el valor de una fila; aquí cambia qué filas hay.

Instante Sesión A — informe mensual (READ COMMITTED) Sesión B — un cliente comprando
t1 BEGIN;
t2 SELECT COUNT(*) FROM pedidos;20
t3 INSERT INTO pedidos (...) VALUES (6, NULL, DATE '2026-03-05', 'pagado', 'tarjeta', 4.95); COMMIT;
t4 SELECT COUNT(*) FROM pedidos;21
t5 SELECT COUNT(*) FROM lineas_pedido;47
t6 COMMIT;

El informe dirá que hay 21 pedidos y 47 líneas, y alguien pasará la tarde buscando el pedido sin líneas. Ese pedido 21 es el fantasma: apareció a mitad del informe.

El detalle que casi nadie cuenta: el estándar SQL permite fantasmas en REPEATABLE READ, pero PostgreSQL no los permite. Su REPEATABLE READ está implementado como snapshot isolation: una única instantánea para toda la transacción, y una instantánea no cambia de contenido. En PostgreSQL, t4 devolvería 20 — el mismo número que t2. Es una garantía más fuerte que la del estándar, y volveremos sobre ella en el apartado 6.

  1. Actualización perdida y anomalía de serialización

Esta es la importante, la que le puede costar dinero a TiendaVerde, y la que dejó pendiente 09-03.

Actualización perdida (lost update): dos transacciones leen el mismo valor, cada una calcula un valor nuevo a partir de él y ambas escriben. La segunda escritura borra el trabajo de la primera.

El escenario: quedan 40 unidades de té matcha y dos clientes compran una cada uno a la vez. La aplicación hace lo natural —leer el stock, restar, escribir—, que es precisamente lo que 05-05 ya señaló como incorrecto:

Instante Sesión A — cliente 1 Sesión B — cliente 2
t1 BEGIN;
t2 BEGIN;
t3 SELECT stock FROM productos WHERE id = 15;40
t4 SELECT stock FROM productos WHERE id = 15;40
t5 UPDATE productos SET stock = 39 WHERE id = 15;UPDATE 1
t6 COMMIT;
t7 UPDATE productos SET stock = 39 WHERE id = 15;UPDATE 1
t8 COMMIT;
SELECT id, nombre, stock FROM productos WHERE id = 15;
id nombre stock
15 Té verde matcha ceremonial 30 g 39

Se han vendido dos unidades y solo se ha descontado una. Ninguna sesión ha recibido un error, ningún registro ha quedado en el log, y el descuadre no aparecerá hasta el inventario físico. Con dos sesiones la pérdida es de una unidad; con un pico de tráfico en Navidad, de decenas.

Y aquí la sorpresa: la versión relativa es correcta

Cambia únicamente la forma del UPDATE:

Instante Sesión A Sesión B
t5 UPDATE productos SET stock = stock - 1 WHERE id = 15;
t6 COMMIT;
t7 UPDATE productos SET stock = stock - 1 WHERE id = 15;espera hasta t6
t8 COMMIT;
id nombre stock
15 Té verde matcha ceremonial 30 g 38

Treinta y ocho: correcto. Y no es magia. Cuando B intenta actualizar en t7 una fila que A tiene bloqueada, B se queda esperando (es el bloqueo implícito de 09-05). Al confirmar A, PostgreSQL no aplica el UPDATE de B a ciegas: vuelve a leer la fila actualizada, reevalúa el WHERE y recalcula la expresión sobre la versión nueva. Así que stock - 1 se calcula sobre 39, no sobre 40.

La regla que hay que extraer, y vale para todo el módulo: en READ COMMITTED, una sola sentencia de escritura es segura; el peligro está en leer con una sentencia y escribir con otra. SET stock = stock - 1 es seguro; SELECT y luego SET stock = 39 no lo es. Es la misma lección de 05-05 con el upsert y la de 09-02 con WHERE stock >= 1.

Cuando la lógica no cabe en una sentencia —porque hay que consultar tres tablas, aplicar una regla y decidir— hay dos soluciones correctas, y son las dos lecciones que quedan: subir el nivel de aislamiento (apartados siguientes) o bloquear explícitamente la fila con SELECT ... FOR UPDATE (09-05).

  1. Los cuatro niveles del estándar SQL

El estándar SQL:1992 definió los niveles por las anomalías que permiten, no por cómo se implementan:

Nivel Lectura sucia Lectura no repetible Lectura fantasma
READ UNCOMMITTED Posible Posible Posible
READ COMMITTED No Posible Posible
REPEATABLE READ No No Posible
SERIALIZABLE No No No

Esta es la tabla que sale en todas las entrevistas de trabajo. Y es una definición de mínimos: dice lo que un motor puede permitir, no lo que hace. Un motor que no permita ninguna anomalía en ningún nivel cumple el estándar perfectamente.

  1. Lo que hace PostgreSQL de verdad

Nivel Lectura sucia No repetible Fantasma Actualización perdida Anomalía de serialización
READ UNCOMMITTED Imposible Posible Posible Posible Posible
READ COMMITTED (por omisión) Imposible Posible Posible Posible Posible
REPEATABLE READ Imposible No No No (aborta con 40001) Posible
SERIALIZABLE Imposible No No No No (aborta con 40001)

Tres diferencias respecto al estándar, y las tres importan:

  1. READ UNCOMMITTED no existe. Se acepta la sintaxis por compatibilidad, pero se comporta exactamente como READ COMMITTED. La lectura sucia es imposible con MVCC (apartado 1).
  2. REPEATABLE READ ya impide los fantasmas. El estándar los permite; PostgreSQL, no. Su implementación es snapshot isolation: una única instantánea para toda la transacción. Solo hay tres niveles distinguibles en la práctica.
  3. REPEATABLE READ y SERIALIZABLE no bloquean para conseguirlo: abortan. En lugar de hacerte esperar, dejan que trabajes y, si al final detectan un conflicto, matan tu transacción con el error 40001. Ese cambio de modelo es lo que obliga a reintentar, y es el apartado 11.

Y una cuarta que merece su propia línea, porque es la razón de ser del nivel más alto:

La anomalía de serialización es más general que las tres clásicas: es cualquier resultado que ninguna ejecución secuencial de las mismas transacciones podría haber producido, aunque cada una haya leído y escrito datos confirmados y distintos. SERIALIZABLE es el único nivel que la impide, y lo consigue con SSI (Serializable Snapshot Isolation): vigila las dependencias de lectura y escritura entre transacciones y aborta las que formen un ciclo peligroso.

  1. El nivel por omisión de cada motor

Motor Por omisión Notas imprescindibles
PostgreSQL READ COMMITTED READ UNCOMMITTED = READ COMMITTED. REPEATABLE READ sin fantasmas. SERIALIZABLE con SSI
MySQL / InnoDB REPEATABLE READ Evita fantasmas en las lecturas normales por instantánea, y en las de bloqueo con gap locks. Pero sus escrituras leen la última versión confirmada, no la de la instantánea: permite actualizaciones perdidas que PostgreSQL abortaría
SQL Server READ COMMITTED Con bloqueos, no con instantáneas: un lector puede bloquear a un escritor. Con READ_COMMITTED_SNAPSHOT ON pasa a un modelo tipo MVCC. Implementa READ UNCOMMITTED (WITH (NOLOCK))
Oracle READ COMMITTED No implementa REPEATABLE READ: solo tiene READ COMMITTED, SERIALIZABLE y READ ONLY. Su SERIALIZABLE es snapshot isolation, más débil que el de PostgreSQL
SQLite SERIALIZABLE de facto Un único escritor a la vez en toda la base. Aislamiento perfecto y concurrencia de escritura nula

Nota de dialecto — la trampa al migrar. El nombre del nivel no dice lo mismo en dos motores. Una aplicación escrita contra MySQL corre en REPEATABLE READ sin saberlo y, al llevarla a PostgreSQL, pasa a READ COMMITTED: aparecen lecturas no repetibles que allí no ocurrían. Y a la inversa: código que en PostgreSQL funciona en REPEATABLE READ porque el motor aborta los conflictos, en MySQL no aborta y pierde actualizaciones en silencio. Verifica siempre el nivel efectivo al migrar; es lo primero que hay que mirar.

  1. Cómo se fija y cómo se consulta el nivel

BEGIN ISOLATION LEVEL REPEATABLE READ;     -- para esta transacción
-- o, equivalente:
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

SHOW transaction_isolation;                -- qué nivel tengo ahora
transaction_isolation
repeatable read

Y para toda la sesión o para todo el servidor, lo de 09-03: SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL ..., ALTER ROLE ... SET default_transaction_isolation, o ese mismo parámetro en postgresql.conf.

  1. READ COMMITTED frente a REPEATABLE READ en dos sesiones

Toda la diferencia está en cuándo se toma la instantánea:

flowchart LR
    subgraph RC["<b>READ COMMITTED</b>"]
        direction TB
        R1["sentencia 1 → <b>instantánea nueva</b>"] --> R2["sentencia 2 → <b>instantánea nueva</b>"] --> R3["sentencia 3 → <b>instantánea nueva</b>"]
    end
    subgraph RR["<b>REPEATABLE READ</b> / <b>SERIALIZABLE</b>"]
        direction TB
        S0["<b>una sola instantánea</b><br/>tomada en la 1ª sentencia"] --> S1["sentencia 1"] & S2["sentencia 2"] & S3["sentencia 3"]
    end

Mismo guion, dos niveles, resultados distintos. La sesión A es el informe; la B, la operativa de la tienda.

A en READ COMMITTED (una instantánea nueva por sentencia):

Instante Sesión A — BEGIN; Sesión B
t1 SELECT precio FROM productos WHERE id = 15;22.00
t2 SELECT COUNT(*) FROM pedidos;20
t3 UPDATE productos SET precio = 24.00 WHERE id = 15;
t4 INSERT INTO pedidos (...); COMMIT;
t5 SELECT precio FROM productos WHERE id = 15;24.00 ← no repetible
t6 SELECT COUNT(*) FROM pedidos;21 ← fantasma

A en REPEATABLE READ (BEGIN ISOLATION LEVEL REPEATABLE READ;), con el guion idéntico:

Instante Sesión A Sesión B
t5 SELECT precio FROM productos WHERE id = 15;22.00
t6 SELECT COUNT(*) FROM pedidos;20

Los mismos valores que en t1 y t2. Para A, el mundo se congeló en el instante de su primera consulta, y el UPDATE y el INSERT de B no existen hasta que A confirme y empiece otra transacción. Ni lectura no repetible ni fantasma: es exactamente lo que un informe necesita.

El precio de esa foto fija aparece en cuanto A intenta escribir algo que B ya cambió:

Instante Sesión A (REPEATABLE READ) Sesión B
t1 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT stock FROM productos WHERE id = 15;40
t2 UPDATE productos SET stock = 39 WHERE id = 15; COMMIT;
t3 UPDATE productos SET stock = 39 WHERE id = 15;
ERROR:  could not serialize access due to concurrent update

La actualización perdida del apartado 4 ya no ocurre: en lugar de pisar el cambio de B en silencio, PostgreSQL mata la transacción de A. Ese es el trato de los niveles altos, y es un trato honesto: prefieres un error que puedas reintentar antes que un descuadre invisible.

  1. SERIALIZABLE y el error 40001

REPEATABLE READ protege cada fila, pero no protege relaciones entre filas. El caso típico en TiendaVerde: la regla "el stock total de la categoría Bebidas no puede bajar de 250 unidades", comprobada por dos operarios a la vez.

Instante Sesión A (SERIALIZABLE) Sesión B (SERIALIZABLE)
t1 BEGIN ISOLATION LEVEL SERIALIZABLE; BEGIN ISOLATION LEVEL SERIALIZABLE;
t2 SELECT SUM(stock) FROM productos WHERE categoria_id = 4;370
t3 SELECT SUM(stock) FROM productos WHERE categoria_id = 4;370
t4 UPDATE productos SET stock = stock - 60 WHERE id = 14; (370 − 60 = 310 ≥ 250 ✓)
t5 UPDATE productos SET stock = stock - 60 WHERE id = 16; (370 − 60 = 310 ≥ 250 ✓)
t6 COMMIT;COMMIT
t7 COMMIT;
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

Las dos leyeron 370, las dos restaron 60, y el total real habría quedado en 250… que aún cumple la regla. Cambia los 60 por 70 y el total baja a 230: las dos comprobaciones eran correctas y el resultado combinado, no. Ninguna fila se pisó —A tocó el producto 14 y B el 16—, así que REPEATABLE READ habría dejado pasar las dos. Solo SERIALIZABLE ve que A leyó lo que B escribió y viceversa y aborta a una de ellas.

Fíjate en tres cosas del error, porque son la firma de todo el nivel: el código 40001, común a todos los errores de serialización; el HINT explícito de que reintentar puede funcionar —no es un error de datos, es un conflicto de programación temporal—; y que el fallo llega en el COMMIT, cuando ya has hecho todo el trabajo.

REPEATABLE READ SERIALIZABLE
Qué vigila Que una misma fila no la escriban dos Además, las dependencias de lectura y escritura entre transacciones
Cuándo aborta En el UPDATE/DELETE conflictivo Normalmente en el COMMIT
Coste Prácticamente el de READ COMMITTED Seguimiento de predicados; más memoria y más abortos
Cuándo elegirlo Informes coherentes, procesos por lotes Invariantes que abarcan varias filas o tablas

  1. El patrón de reintento con retroceso exponencial

Si usas REPEATABLE READ o SERIALIZABLE, tu aplicación tiene que estar preparada para reintentar. No es opcional ni es un caso raro: es el modo normal de funcionamiento de esos niveles.

INTENTOS_MAX = 5
espera = 0,05 s                      # 50 ms

para intento en 1..INTENTOS_MAX:
    conexión.begin(isolation = SERIALIZABLE)
    try:
        ... la unidad de trabajo COMPLETA ...
        conexión.commit()
        salir con éxito
    except error con SQLSTATE == '40001' o '40P01':     # serialización o interbloqueo
        conexión.rollback()
        if intento == INTENTOS_MAX:
            relanzar                                     # se rinde y avisa
        dormir(espera * (1 + aleatorio(0, 0.5)))         # ← "jitter": desincroniza
        espera = espera * 2                              # 50, 100, 200, 400 ms
    except cualquier_otro_error:
        conexión.rollback()
        relanzar                                         # NO se reintenta

Seis reglas que hacen que este bucle funcione de verdad:

  1. Se reintenta la transacción entera, desde el BEGIN. No se puede continuar donde se quedó: su instantánea ya no vale.
  2. Solo se reintentan 40001 y 40P01 (interbloqueo, 09-05). Una violación de clave foránea no se arregla repitiéndola: se repetirá igual cinco veces y se perderán cinco segundos.
  3. El retroceso es exponencial y con azar. Sin la componente aleatoria (jitter), dos transacciones que chocan reintentan a la vez y vuelven a chocar, indefinidamente.
  4. La transacción debe ser idempotente, que es la propiedad de 05-03 y 09-03: si el primer intento llegó a insertar el pedido y falló después, el segundo no puede duplicarlo. Una clave única de negocio lo resuelve.
  5. Nada fuera de la base de datos dentro del bloque: reintentar cinco veces una transacción que cobra con tarjeta significa cinco cargos (09-02). Y cuenta los reintentos: una tasa creciente de 40001 es la señal de que hay contención real y de que el problema es de diseño, no de configuración.

  1. Qué nivel elegir

Caso Nivel Por qué
Consulta suelta, listado de catálogo, ficha de producto READ COMMITTED Cada sentencia ve lo último confirmado. Es lo que quieres y es lo que hay por omisión
Informe analítico de varias consultas que deben cuadrar entre sí REPEATABLE READ, mejor con READ ONLY Una foto fija de toda la base. En PostgreSQL, sin fantasmas. Y SERIALIZABLE READ ONLY DEFERRABLE (09-03) si además no quiere abortar nunca
Confirmar un pedido descontando stock READ COMMITTED + UPDATE ... WHERE stock >= n, o SELECT ... FOR UPDATE (09-05) La solución no es subir el nivel: es hacer la operación en una sentencia o bloquear la fila. Más simple y más rápido
Contador o saldo (SET total = total + n) READ COMMITTED La forma relativa ya es segura (apartado 4). Subir el nivel solo añade abortos
Invariante sobre varias filas o tablas ("el total de la categoría no baja de 250", "no más de N reservas") SERIALIZABLE con reintento Es el único caso donde el nivel más alto es la respuesta correcta, porque ninguna sentencia sola puede expresar la regla
Proceso por lotes nocturno sobre datos que nadie más toca REPEATABLE READ Coherencia gratis: sin concurrencia real no hay conflictos que abortar

La regla de decisión, en una frase: empieza siempre en READ COMMITTED y sube solo cuando puedas nombrar la anomalía concreta que te está haciendo daño. Subir de nivel no es "más seguro" sin más: es cambiar un problema silencioso por un error explícito que hay que gestionar con código.

Errores Comunes y Consejos

  • Estudiarse la tabla del estándar y creer que describe a tu motor. No describe a ninguno con exactitud: PostgreSQL no tiene READ UNCOMMITTED y su REPEATABLE READ no permite fantasmas. Y READ COMMITTED no protege de la actualización perdida: protege de la lectura sucia y de nada más.
  • Confundir SELECT + UPDATE con un UPDATE relativo. SET stock = stock - 1 es seguro incluso en READ COMMITTED; leer 40 y escribir 39 no lo es. Es la distinción más rentable de la lección.
  • Subir a SERIALIZABLE sin escribir el reintento. Cambias corrupción silenciosa por caídas visibles en producción. El nivel alto sin reintento es peor que el nivel bajo.
  • Reintentar cualquier error, o reintentar sin jitter. Solo 40001 y 40P01 merecen reintento; repetir una violación de restricción es perder tiempo cinco veces seguidas. Y sin la componente aleatoria, dos transacciones sincronizadas vuelven a chocar en cada vuelta.
  • Suponer que REPEATABLE READ de MySQL es el de PostgreSQL. El de MySQL permite actualizaciones perdidas que PostgreSQL aborta; el de PostgreSQL impide fantasmas que el de MySQL solo evita con gap locks. Y WITH (NOLOCK) en SQL Server no es "ir más rápido": es lectura sucia, con filas no confirmadas, duplicadas o ausentes en el mismo recorrido.
  • Consejo: pon SHOW transaction_isolation; en tu lista de comprobaciones al depurar concurrencia; es la primera pregunta. Y para un informe, BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;: coherencia total y ni un solo bloqueo a nadie.
  • Consejo: mide los 40001. Su tasa te dice si tu diseño tiene contención real, y esa información no está en ningún otro sitio.

Ejercicios

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

Ejercicio 1

Demuestra la actualización perdida y sus dos soluciones.

  1. Reproduce el apartado 4 con el patrón SELECTUPDATE ... SET stock = 39 en READ COMMITTED. Comprueba que el stock final es 39 y explica qué unidad se ha perdido.
  2. Repítelo con UPDATE ... SET stock = stock - 1. Comprueba que el resultado es 38 y describe qué hace exactamente la sesión B en t7.
  3. Repite el caso 1 poniendo las dos sesiones en REPEATABLE READ. ¿Qué ocurre y cuándo exactamente? Copia el error.
  4. ¿Qué nivel de aislamiento harías servir en la aplicación real de TiendaVerde para confirmar un pedido, y por qué no es la respuesta subir a SERIALIZABLE?

Ejercicio 2

El analista Daniel Vercher (empleado 8) lanza el informe mensual y se queja de que "los números no cuadran entre las tablas del PDF".

  1. Reproduce el problema: sesión A en READ COMMITTED cuenta pedidos, otra sesión inserta un pedido con dos líneas y confirma, y A vuelve a contar pedidos y líneas. Muestra los números incoherentes.
  2. Identifica qué anomalía es y por qué el estándar la llama así.
  3. Arréglalo cambiando una sola línea del guion de A, y demuestra que ahora cuadra.
  4. Si el informe tarda 40 minutos, ¿qué efecto colateral tiene mantener esa transacción abierta todo ese tiempo? Relaciónalo con lo que viste en 09-02 y propón la variante de 09-03 que lo mitiga.

Ejercicio 3

TiendaVerde impone una regla nueva: la categoría Bebidas (4) nunca puede bajar de 250 unidades de stock total. Hoy tiene 370.

  1. Escribe la transacción que descuenta 70 unidades del producto 14 comprobando antes la regla, en SERIALIZABLE.
  2. Ejecútala a la vez en dos sesiones —una sobre el producto 14 (stock 180) y otra sobre el 17 (stock 90)— y muestra qué ocurre en cada COMMIT.
  3. ¿Qué habría pasado en REPEATABLE READ? ¿Y en READ COMMITTED? Razona por qué ninguno de los dos detecta el problema.
  4. Escribe el pseudocódigo de reintento que haría falta para que esta operación funcione en producción, y di por qué no se puede resolver con un CHECK.

Soluciones

Solución 1

1. Stock final 39. Se ha perdido la unidad de la sesión A: su UPDATE de t5 llegó a escribirse y a confirmarse, pero el de B lo sobrescribió con un valor absoluto calculado a partir de una lectura ya obsoleta. A vendió y no descontó.

2. Stock final 38. En t7, la sesión B intenta actualizar una fila que A tiene bloqueada, así que se queda esperando —el UPDATE no devuelve nada, la terminal se queda colgada— hasta que A hace COMMIT en t6. Entonces PostgreSQL relee la fila ya actualizada, reevalúa el WHERE y recalcula stock - 1 sobre 39, no sobre 40. La atomicidad de una sola sentencia hace el trabajo.

3. La sesión que llega segunda falla en su UPDATE, no en el COMMIT:

ERROR:  could not serialize access due to concurrent update

La actualización perdida es ahora imposible, pero a cambio hay que reintentar. Y observa el matiz: en REPEATABLE READ el conflicto se detecta al escribir, mientras que en SERIALIZABLE (ejercicio 3) suele detectarse al confirmar.

4. En la aplicación real, READ COMMITTED más una de estas dos: el UPDATE ... WHERE id = 15 AND stock >= 1 de 09-02, o el SELECT ... FOR UPDATE de 09-05. Subir a SERIALIZABLE no es la respuesta por tres motivos: funcionaría, pero obligaría a implementar el bucle de reintento en el camino más caliente de la tienda; aborta bajo carga justo cuando más pedidos hay, que es lo peor posible; y es innecesario, porque el invariante afecta a una sola fila y una sola sentencia puede expresarlo. El nivel alto se reserva para invariantes que ninguna sentencia puede expresar.

Solución 2

1. A obtiene 20 pedidos en su primera consulta y, tras el INSERT de la otra sesión, 21 pedidos y 49 líneas. El PDF diría 21 pedidos con 49 líneas si ambas cuentas se hicieran después, o 20 pedidos y 49 líneas si se hicieran a caballo del COMMIT ajeno: en cualquier caso, dos cifras tomadas de dos estados distintos del mundo.

2. Es una lectura fantasma: la misma consulta devuelve un conjunto de filas distinto porque otra transacción insertó filas que cumplen la condición. Se llama así porque las filas nuevas "aparecen" en una consulta que ya se había ejecutado, como si se materializaran de la nada.

3. La línea que cambia es el BEGIN:

BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;

Con la instantánea fija, A ve 20 pedidos y 47 líneas en todas sus consultas, empiece la sesión B lo que empiece. En PostgreSQL esto basta porque su REPEATABLE READ no permite fantasmas; en un motor que siguiera el estándar al pie de la letra haría falta SERIALIZABLE.

4. Mantener 40 minutos una transacción abierta con instantánea fija impide que VACUUM limpie ninguna versión de fila posterior a ese instante, en toda la base de datos: es el mecanismo del bloat que explicó 09-02 y el daño que anticipó 09-01. La mitigación de 09-03 es lanzar el informe como BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;: sigue reteniendo su instantánea, pero al ser de solo lectura y diferible no bloquea a nadie ni puede abortar. Y la solución de fondo es la de siempre: ejecutar los informes largos sobre una réplica (09-02).

Solución 3

1. La transacción, para el producto 14:

BEGIN ISOLATION LEVEL SERIALIZABLE;

SELECT SUM(stock) AS total_bebidas FROM productos WHERE categoria_id = 4;   -- 370
-- La aplicación comprueba: 370 - 70 = 300 >= 250  ✓
UPDATE productos SET stock = stock - 70 WHERE id = 14;

COMMIT;

2. La primera en confirmar tiene éxito. La segunda falla en el COMMIT:

ERROR:  could not serialize access due to read/write dependencies among transactions
HINT:  The transaction might succeed if retried.

Cada una comprobó 370 − 70 = 300 y las dos tenían razón por separado; juntas dejarían el total en 230, por debajo del límite. SERIALIZABLE detecta que A leyó un conjunto de filas que B modificó y viceversa —una dependencia cruzada de lectura y escritura— y cancela una de las dos.

3. Ni REPEATABLE READ ni READ COMMITTED lo detectan, y por el mismo motivo: no hay ninguna fila en conflicto. A escribe el producto 14 y B el 17; son filas distintas, así que no hay bloqueo, no hay conflicto de actualización y nada que abortar. REPEATABLE READ protege filas; el invariante aquí es sobre un conjunto. En READ COMMITTED sería aún peor, porque además cada sentencia vería datos distintos. Las dos confirmarían felizmente y el total quedaría en 230.

4. El pseudocódigo es el del apartado 11: bucle de hasta cinco intentos, BEGIN ISOLATION LEVEL SERIALIZABLE, captura de 40001, rollback(), espera con retroceso exponencial y jitter, y relanzar cualquier otro error sin reintentar.

Y no se puede resolver con un CHECK por lo que explicó 09-02: un CHECK solo puede mirar las columnas de la propia fila, y esta regla necesita sumar el stock de cuatro filas de la tabla. Las alternativas reales son tres: SERIALIZABLE con reintento (la de este ejercicio); bloquear explícitamente las cuatro filas con SELECT ... FOR UPDATE antes de comprobar (09-05); o mantener el total agregado en una fila propia y protegerla con un CHECK y un trigger que la mantenga (10-05). La primera es la más limpia; la segunda, la más previsible bajo carga.

Conclusión

Ya sabes qué te puede pasar y cuánto cuesta evitarlo:

  • Las cuatro anomalías, demostradas en dos sesiones: lectura sucia (leer lo no confirmado), lectura no repetible (la misma fila cambia de valor), lectura fantasma (aparecen filas nuevas) y actualización perdida / anomalía de serialización (dos transacciones correctas por separado producen un resultado que ninguna ejecución secuencial daría).
  • Que la actualización perdida es el caso central: dos clientes compran el último matcha, los dos leen 40, los dos escriben 39, y se vende una unidad que nadie descuenta, sin error ni traza.
  • Y su contrapartida más útil de todo el módulo: en READ COMMITTED, una sola sentencia de escritura es seguraSET stock = stock - 1 da 38 porque el motor relee y recalcula— y el peligro está en leer con una sentencia y escribir con otra.
  • Los cuatro niveles del estándar con su tabla clásica… y lo que PostgreSQL hace de verdad: no implementa READ UNCOMMITTED (la lectura sucia es imposible con MVCC), su REPEATABLE READ ya impide los fantasmas y solo hay tres niveles distinguibles.
  • El nivel por omisión de cada motorREAD COMMITTED en PostgreSQL, Oracle y SQL Server; REPEATABLE READ en InnoDB; SERIALIZABLE de facto en SQLite— y la trampa al migrar: el mismo nombre no significa lo mismo.
  • El error 40001, en sus dos formas (concurrent update en REPEATABLE READ, read/write dependencies en SERIALIZABLE), con su HINT diciendo que reintentar puede funcionar. Y la consecuencia: subir de nivel obliga a escribir el bucle de reintento, con retroceso exponencial, jitter, filtrado por SQLSTATE y una unidad de trabajo idempotente.
  • Y el criterio: READ COMMITTED por omisión; REPEATABLE READ READ ONLY para informes; SERIALIZABLE solo para invariantes que abarcan varias filas y ninguna sentencia puede expresar. Sube de nivel únicamente cuando puedas nombrar la anomalía que te está haciendo daño.

Queda la otra respuesta a la actualización perdida, la que no aborta nada y la que usan de verdad los sistemas de comercio electrónico: bloquear la fila. En Manejo de concurrencia: bloqueos e interbloqueos verás por qué la sesión B se quedaba esperando en t7 y qué la retenía; el patrón leer-modificar-escribir seguro con SELECT ... FOR UPDATE y toda su familia; SKIP LOCKED para repartir una cola de pedidos entre varios procesos sin pisarse; el bloqueo optimista con columna de versión frente al pesimista; qué bloquea un ALTER TABLE y por qué tenía que existir CREATE INDEX CONCURRENTLY; y los interbloqueos: cómo se producen, cómo los detecta y resuelve el motor, cómo se diagnostican con pg_locks y pg_blocking_pids, y las reglas para no provocarlos nunca.

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