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
- Lectura sucia
- Lectura no repetible
- Lectura fantasma
- Actualización perdida y anomalía de serialización
- Los cuatro niveles del estándar SQL
- Lo que hace PostgreSQL de verdad
- El nivel por omisión de cada motor
- Cómo se fija y cómo se consulta el nivel
READ COMMITTEDfrente aREPEATABLE READen dos sesionesSERIALIZABLEy el error40001- El patrón de reintento con retroceso exponencial
- Qué nivel elegir
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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 UNCOMMITTEDde verdad, y su famosa pistaWITH (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.
- 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.
- 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. SuREPEATABLE READestá 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.
- 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; |
| 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 sí 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 - 1es seguro;SELECTy luegoSET stock = 39no lo es. Es la misma lección de 05-05 con el upsert y la de 09-02 conWHERE 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).
- 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.
- 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:
READ UNCOMMITTEDno existe. Se acepta la sintaxis por compatibilidad, pero se comporta exactamente comoREAD COMMITTED. La lectura sucia es imposible con MVCC (apartado 1).REPEATABLE READya 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.REPEATABLE READySERIALIZABLEno 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 error40001. 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.
SERIALIZABLEes 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.
- 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 READsin saberlo y, al llevarla a PostgreSQL, pasa aREAD COMMITTED: aparecen lecturas no repetibles que allí no ocurrían. Y a la inversa: código que en PostgreSQL funciona enREPEATABLE READporque 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.
- 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.
READ COMMITTED frente a REPEATABLE READ en dos sesiones
READ COMMITTED frente a REPEATABLE READ en dos sesionesToda 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; |
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.
SERIALIZABLE y el error 40001
SERIALIZABLE y el error 40001REPEATABLE 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 |
- El patrón de reintento con retroceso exponencial
Si usas
REPEATABLE READoSERIALIZABLE, 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 reintentaSeis reglas que hacen que este bucle funcione de verdad:
- Se reintenta la transacción entera, desde el
BEGIN. No se puede continuar donde se quedó: su instantánea ya no vale. - Solo se reintentan
40001y40P01(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. - 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.
- 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.
- 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
40001es la señal de que hay contención real y de que el problema es de diseño, no de configuración.
- 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 UNCOMMITTEDy suREPEATABLE READno permite fantasmas. YREAD COMMITTEDno protege de la actualización perdida: protege de la lectura sucia y de nada más. - Confundir
SELECT+UPDATEcon unUPDATErelativo.SET stock = stock - 1es seguro incluso enREAD COMMITTED; leer 40 y escribir 39 no lo es. Es la distinción más rentable de la lección. - Subir a
SERIALIZABLEsin 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
40001y40P01merecen 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 READde 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. YWITH (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.
- Reproduce el apartado 4 con el patrón
SELECT→UPDATE ... SET stock = 39enREAD COMMITTED. Comprueba que el stock final es 39 y explica qué unidad se ha perdido. - 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. - Repite el caso 1 poniendo las dos sesiones en
REPEATABLE READ. ¿Qué ocurre y cuándo exactamente? Copia el error. - ¿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".
- Reproduce el problema: sesión A en
READ COMMITTEDcuenta 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. - Identifica qué anomalía es y por qué el estándar la llama así.
- Arréglalo cambiando una sola línea del guion de A, y demuestra que ahora cuadra.
- 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.
- Escribe la transacción que descuenta 70 unidades del producto 14 comprobando antes la regla, en
SERIALIZABLE. - 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. - ¿Qué habría pasado en
REPEATABLE READ? ¿Y enREAD COMMITTED? Razona por qué ninguno de los dos detecta el problema. - 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:
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:
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 segura —SET stock = stock - 1da 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), suREPEATABLE READya impide los fantasmas y solo hay tres niveles distinguibles. - El nivel por omisión de cada motor —
READ COMMITTEDen PostgreSQL, Oracle y SQL Server;REPEATABLE READen InnoDB;SERIALIZABLEde facto en SQLite— y la trampa al migrar: el mismo nombre no significa lo mismo. - El error
40001, en sus dos formas (concurrent updateenREPEATABLE READ,read/write dependenciesenSERIALIZABLE), con suHINTdiciendo 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 COMMITTEDpor omisión;REPEATABLE READ READ ONLYpara informes;SERIALIZABLEsolo 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
- ¿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
