Episodio 2

Dos cajeros a la vez

lectura · 15 mindominio · BCPS Bankoperación · retiradas simultáneas en una cuenta conjunta

500 €, dos tarjetas

Sábado por la mañana. Ana saca 100 € en el cajero del mercado; a la misma hora, en la otra punta de la ciudad, Luis saca 200 €. Los dos son titulares de la cuenta de gastos, que tiene 500 €. Cada cajero pregunta el saldo, ve que hay de sobra y entrega los billetes. El lunes, la cuenta dice 300 €.

Deberían quedar 200. BCPS Bank acaba de regalar 100 €, y nadie ha fallado: ni la red, ni el disco, ni el código de los cajeros, que es correcto si se ejecuta solo. El problema es que no se ha ejecutado solo.

El episodio anterior terminó con la letra más escurridiza de ACID, la I de aislamiento: cada transacción se comporta como si estuviera sola. «Como si» tiene grados, y cada grado cuesta algo distinto. Hoy los tocas tú: en la simulación, tú decides el orden en que Ana y Luis dan cada paso.

Tú eres el planificador

Cada cajero lee el saldo, calcula el nuevo y lo escribe. «Paso de Ana» y «Paso de Luis» hacen avanzar a cada uno, y los dos paneles reciben los mismos pasos, cada uno con el nivel de aislamiento que elijas arriba. El tiempo corre hacia abajo; en punteado, lo que cada uno va a hacer a continuación. Prueba primero con dos retiradas, las dos leyendo antes de que ninguna escriba, y luego con el colchón.

Qué ha pasado

Read committed y el lost update

Con read committed, el nivel por defecto de PostgreSQL, Oracle y SQL Server, cada sentencia ve lo último que está confirmado cuando empieza. Nunca ve el trabajo a medias de otra transacción. Parece suficiente, y no lo es.probar en el laboratorio

Ana lee 500 €. Luis lee 500 €. Ana escribe 400 y confirma. Luis intenta escribir 300: la fila está bloqueada por Ana, así que espera; cuando Ana confirma, Luis escribe su 300 encima. La retirada de Ana se ha perdido. Es un lost update: cada transacción calcula sobre un dato que la otra está a punto de cambiar, y la segunda escritura pisa la primera. Read committed evita leer lo no confirmado; no evita calcular sobre algo que va a dejar de ser verdad.

Por eso importa dónde se resta. Si el cajero no lee el saldo y manda UPDATE … SET saldo = saldo − 100, la resta la hace la base de datos sobre el valor más reciente, después de esperar el bloqueo.probar en el laboratorio Con read committed basta. Cuando la regla cabe en una sentencia, es la protección más barata que existe.

Repeatable read: la foto del principio

Con repeatable read, la transacción entera ve la foto de la base de datos del momento en que empezó, su snapshot. Si lee la cuenta dos veces, lee lo mismo aunque otro la cambie entre medias. Y, en PostgreSQL, si intenta escribir una fila que otro ha cambiado después de su foto, aborta: «no se puede serializar el acceso por una actualización concurrente».

Eso salva a Luis. Su escritura aborta, el cajero reintenta desde el principio, lee 400 € y escribe 200. Cuadra, a cambio de un aborto y de repetir el trabajo. Un aborto por serialización no es un fallo: es la base de datos diciendo «lo que leíste ya no es verdad; vuelve a empezar». El código que usa repeatable read tiene que estar preparado para reintentar, y reintentar solo es seguro si la transacción entera se puede repetir (lo que la serie de eventos llamaba idempotencia).

El colchón y el write skew

Ana tiene activado el colchón: su cuenta de gastos puede quedar en negativo mientras la nómina lo cubra, es decir, nómina + gastos ≥ 0. Hay 400 € en la nómina y 100 en gastos. Ana saca 300 de la nómina y, a la vez, Luis saca 300 de gastos.probar en el laboratorio

Cada transacción lee las dos cuentas, ve que la suma alcanza y escribe solo la suya: Ana la nómina, Luis la de gastos. Nadie escribe una fila que el otro haya cambiado, así que repeatable read no ve nada raro. Las dos confirman y la suma queda en −100 €. Es un write skew: dos decisiones correctas por separado que juntas rompen la regla.

Solo serializable lo evita. En PostgreSQL funciona vigilando lo que cada transacción ha leído y lo que las demás han escrito: si Ana leyó la de gastos que Luis escribió, y Luis leyó la nómina que Ana escribió, no hay ningún orden en serie que dé ese resultado, y la segunda que confirma aborta. Luis reintenta, ve 100 + 100 = 200 € y el cajero le dice que no hay saldo. Justo lo que habría pasado si hubieran ido uno detrás de otro.

Fíjate en por qué pasa: la regla del colchón abarca dos cuentas, dos agregados en el lenguaje del episodio 4 de la serie de DDD. Ninguna cuenta puede protegerla sola, porque cada una solo ve su propio saldo. Una regla que cruza agregados necesita que alguien la proteja desde fuera: serializable, un bloqueo explícito sobre las dos filas (episodio 3) o un rediseño que la meta dentro de un solo agregado.

Hay un giro más. Si Cuentas no guardara un saldo materializado y el saldo se calculara siempre sumando movimientos, dos retiradas en la misma cuenta tampoco se pisarían: cada una insertaría su propio movimiento, una fila nueva. El lost update desaparecería… y volvería como write skew, con fila fantasma incluida: las dos suman los movimientos, las dos ven que alcanza, las dos insertan.

Las otras anomalías

El estándar SQL define los niveles por las anomalías que permiten. Tres son clásicas, y las tres las evitan ya los niveles de la simulación:

Criterio

AnomalíaRead committedRepeatable readSerializable
Dirty readnonono
Non-repeatable readsínono
Lectura fantasmasíno (en PostgreSQL)no
Lost updatesíno (aborta)no (aborta)
Write skewsísíno (aborta)
Preciocasi nadaabortos si se escribe lo mismoabortos también por lo leído
  1. ¿La transacción solo lee, o escribe sin decidir en función de lo que lee?El extracto mensual, una consulta de saldo, un UPDATE … = saldo − 100.
    read committed
  2. ¿Lee una fila, decide y escribe esa misma fila?El cajero que lee el saldo y lo resta en la aplicación.
    repeatable read, o FOR UPDATE (episodio 3)
  3. ¿Lee varias filas, decide y escribe otra distinta?El colchón: una regla que abarca dos cuentas.
    serializable, o bloquear todas las filas de la regla

Serializable no es «el bueno» y read committed «el malo». Serializable con mucha contención aborta a menudo, y cada aborto es trabajo repetido; read committed es lo correcto para la inmensa mayoría de lecturas. El nivel se elige por transacción, no para toda la base de datos: el extracto mensual de Reporting no necesita lo mismo que el cajero.

En el mundo real

El mismo nombre no significa lo mismo en todas partes. En PostgreSQL, repeatable read es aislamiento por snapshot: evita fantasmas y aborta el lost update. En MySQL/InnoDB, el nivel por defecto es repeatable read, pero las lecturas normales usan la foto y las escrituras usan el último valor: el lost update del cajero sí pasa, salvo que se lea con FOR UPDATE. Oracle no tiene repeatable read: ofrece read committed y un «serializable» que en realidad es snapshot, y deja pasar el write skew.

El serializable de PostgreSQL (SSI, serializable snapshot isolation, desde la versión 9.1) es de los pocos que detectan el write skew sin bloquear las lecturas. CockroachDB y FoundationDB usan serializable por defecto. SQL Server ofrece además SNAPSHOT como nivel aparte.

El artículo que puso nombre a todo esto es «A Critique of ANSI SQL Isolation Levels» (Berenson y otros, 1995), que mostró que las definiciones del estándar no bastaban. Y Jepsen, el proyecto de Kyle Kingsbury, ha encontrado una y otra vez bases de datos que prometían un nivel y daban otro: vale la pena leer qué garantiza de verdad la tuya.

Siguiente episodio

Repeatable read y serializable dejan que las dos transacciones avancen y abortan a una al final. Hay otra forma: que la primera coja la cuenta y la segunda espere su turno. ¿Qué sale mejor, esperar o reintentar? Depende de cuánta gente haya en la cola.

Siguiente · Episodio 3 Esperar o reintentar Bloqueo pesimista frente a optimista, de dos cotitulares a un comercio en Black Friday, y el deadlock de dos transferencias cruzadas.

← Todos los episodios · Cuentas en el mapa de BCPS Bank