1. Introducción
Si alguna vez pensaste «la concurrencia es para bancos, bolsas y la NASA», tengo noticias para ti: una tienda online normal (incluso de aprendizaje) también vive en un mundo de peticiones paralelas. Mientras lees esta línea, en algún lugar cercano dos hilos ya están listos para modificar simultáneamente el mismo InventoryItem. Y aquí surge el problema: las transacciones pueden hacer tu operación atómica, pero no saben por sí solas comparar dos intentos independientes de actualización. Como resultado, «el último que escribe» puede sobrescribir silenciosamente los cambios del «anterior», y ni siquiera te enterarás por las excepciones.
Imagina que en tu sistema hay datos que representan el «estado actual»: saldos, reservas, suma, estado del pedido. Estos campos son especialmente sensibles a la situación read → modify → write. En un solo hilo todo parece lógico, pero bajo carga comienza a comportarse como un chat un viernes por la noche: el último que escribió tiene la razón (incluso si solo la tiene en su imaginación).
Y aquí es importante conectar este dolor con lo que ya sabemos sobre transacciones. Incluso si el límite de la transacción se elige correctamente y todo el read → modify → write vive dentro de un único método @Transactional, esto solo protege un intento completo. En cuanto hay dos intentos de este tipo, ya no solo se necesita un commit, sino una forma de saber si el estado no ha quedado obsoleto entre la lectura y la escritura.
2. read–modify–write ingenuo
La mayoría de los servicios CRUD se ven extremadamente inocentes. Abrimos una transacción, leemos la entidad, cambiamos un campo, salimos del método — Hibernate en flush/commit envía un UPDATE. Como ya conocemos el dirty checking, ese código incluso nos alegra: «Genial, no necesito save()». Y en un solo hilo todo es realmente correcto.
Mini-ejemplo del catálogo, donde la «concurrencia» parece no ser un problema:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameProduct(Long productId, String newName) {
// 1) Leemos el estado actual desde la BD
Product product = entityManager.find(Product.class, productId);
// 2) Cambiamos el campo en memoria (Hibernate marcará la entidad como dirty)
product.setName(newName);
// 3) En commit/flush Hibernate ejecutará el UPDATE por sí mismo (no hay save() explícito)
}
En una sola solicitud funciona perfectamente: leemos Product, cambiamos name, hacemos commit. Pero si dos administradores (o dos procesos en segundo plano) cambian simultáneamente el nombre del mismo producto, obtenemos el clásico «el último que escribe gana», y además gana en silencio, sin lucha ni advertencias.
Ahora un ejemplo del subsistema de existencias, donde el costo del error es más notable (porque el dinero y el almacén son cosas sensibles):
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void reserve(Long itemId, int qty) {
// 1) Cargamos la línea de existencias (instantánea del estado actual en el momento del SELECT)
InventoryItem item = entityManager.find(InventoryItem.class, itemId);
// 2) Hacemos read–modify–write en memoria: calculamos nuevos valores finales
item.setAvailableQty(item.getAvailableQty() - qty);
item.setReservedQty(item.getReservedQty() + qty);
// 3) En commit/flush Hibernate enviará un UPDATE con los valores finales de los campos
// Importante: aquí no hay verificación de que la fila no haya sido modificada entre el SELECT y el UPDATE
}
Mientras esto se ejecuta una vez, todo está bien. Pero en cuanto aparecen dos llamadas paralelas a reserve() para la misma línea, de repente descubrimos que la «aritmética correcta» puede dar un resultado de negocio incorrecto. Y lo más desagradable: las actualizaciones SQL se verán normales, sin errores de sintaxis, sin violaciones de restricciones, sin un solo «ups».
3. Línea de tiempo de lost update
Lost update se entiende mejor como la historia de dos personas independientes que editan el mismo documento, pero el sistema no sabe decir «oye, el documento ya cambió». Cada uno leyó honestamente la versión anterior, cada uno hizo honestamente su corrección, y ambos presionaron honestamente «guardar». Al final, se guardará la última corrección, y la primera desaparecerá tan silenciosamente que ni siquiera tendrás tiempo de ofenderte, simplemente no te enterarás.
En la base de datos, a menudo se ve así: dos transacciones comienzan casi al mismo tiempo y ambas alcanzan a leer la misma fila antes de que la primera transacción haga commit de los cambios. Luego ambas transacciones calculan el nuevo estado a partir de la misma instantánea antigua. Y este es el punto clave: el error no ocurre en la sintaxis SQL ni en el código Java, sino en la lógica «calculamos a partir de un estado desactualizado».
Es útil verlo como una línea de tiempo:
| Momento | Transacción 1 (Tx1) | Transacción 2 (Tx2) | Estado de la fila en BD |
|---|---|---|---|
| t0 | SELECT item (available=10, reserved=0) | available=10, reserved=0 | |
| t1 | SELECT item (available=10, reserved=0) | available=10, reserved=0 | |
| t2 | Tx1 calculó nuevo estado: available=8, reserved=2 | Tx2 calculó nuevo estado: available=7, reserved=3 | available=10, reserved=0 |
| t3 | UPDATE ... set available=8, reserved=2 where id=... + commit | available=8, reserved=2 | |
| t4 | UPDATE ... set available=7, reserved=3 where id=... + commit | available=7, reserved=3 |
Observa la línea final: el resultado es available=7, reserved=3. Desde el punto de vista de Tx2, «hizo todo correctamente». Pero desde el punto de vista del negocio, si teníamos dos reservas de 2 y 3, esperaríamos available=5 y reserved=5. Se perdieron los cambios de Tx1, simplemente porque Tx2 escribió un resultado calculado a partir de datos antiguos.
Y esto es precisamente lost update: una escritura posterior silenciosamente sobrescribe el resultado de una anterior, y el sistema no señala el conflicto.
4. Lost update en InventoryItem
Con existencias y reservas, lost update es especialmente claro, porque casi siempre hay invariantes y expectativas: no se puede «perder accidentalmente» una reserva, no se puede «olvidar» reducir la cantidad disponible. Pero el código se ve absolutamente cotidiano: un par de getters, un par de setters, todo muy formal y correcto.
Veamos una vez más la «matemática» que parece correcta:
// Ambos hilos (Tx1 y Tx2) leyeron el mismo valor de la BD
int loadedByTx1 = 10;
int loadedByTx2 = 10;
// Cada hilo calculó honestamente el nuevo total basado en la instantánea leída
int newQtyByTx1 = loadedByTx1 - 2; // 8 (reserva de 2)
int newQtyByTx2 = loadedByTx2 - 3; // 7 (reserva de 3)
// El problema no está en la aritmética: el problema es que ambos cálculos partieron de "10"
Ambos cálculos son correctos. El problema es que ambos partieron del mismo número 10. Si luego ambos cálculos llegan a la base de datos como «establecer valor = 8» y «establecer valor = 7», uno de los resultados inevitablemente se perderá. Esto no es un «error de cálculo», es un error de suposición: «entre la lectura y la escritura, nadie más tocó la fila».
Cómo se ve en el SQL generado
Al estilo de Hibernate (sin mecanismos especiales de control de concurrencia), la actualización a menudo se ve así:
update inventory_item
set available_qty = ?, -- valor final obtenido después del cálculo en Java
reserved_qty = ?, -- valor final obtenido después del cálculo en Java
updated_at = ? -- campo auxiliar: "cuándo se actualizó"
where id = ?; -- importante: aquí no hay verificación "la fila no cambió desde la lectura"
Este es un punto importante: Hibernate no sabe que quieres «disminuir en 2». Solo sabe que en el objeto estableciste valores finales. El dirty checking detectó cambios en los campos y generó un SQL del tipo «establecer nuevos valores». Si otra transacción entre tu SELECT y tu UPDATE ya hizo cambios, tu UPDATE igualmente se ejecutará, porque en WHERE no hay ninguna condición que diga: «actualiza solo si la fila sigue siendo la que leí».
Peligro para los campos de «estado actual»
En Commerce Persistence Lab tenemos varios tipos de datos donde «el último que escribe gana» es especialmente tóxico.
En InventoryItem, los campos availableQty y reservedQty describen el estado actual del almacén. Si una reserva sobrescribe a otra, o bien comenzarán ventas de lo que no hay, o al revés: «perderás» mercancía y empezarás a rechazar clientes, aunque el producto esté físicamente en el almacén y aburrido.
En PurchaseOrder, el estado es un flujo de estados. Si dos procesos cambian paralelamente el pedido a diferentes estados, puedes obtener un «salto hacia atrás» o una «pérdida de paso». Formalmente, la escritura es exitosa, pero el proceso de negocio se rompe.
En Product se puede discutir qué tan crítico es perder un renombrado, pero incluso allí hay cosas importantes como status (mostrar/no mostrar) o el precio, y el precio en algunos sistemas también debe ser resistente a la sobrescritura, especialmente si lo actualizan diferentes escenarios.
5. @Transactional no salva
Es muy tentador creer que si escribimos @Transactional, el sistema automáticamente se vuelve «seguro» para cambios paralelos. Este deseo es comprensible: la anotación se ve seria, y las anotaciones serias deberían salvar el mundo. Pero en la práctica, @Transactional resuelve otra tarea: garantiza que tu operación se ejecute completamente o se deshaga completamente. Proporciona atomicidad a la unidad de trabajo, pero no hace magia a nivel de «comparar dos intentos independientes de actualización y elegir el correcto».
En palabras simples: la transacción se encarga de que dentro de un único intento no haya un estado «semi-actualizado». Pero si tienes dos intentos simultáneos, la transacción no está obligada (y generalmente no puede) darse cuenta de que estás haciendo «la misma actualización semántica».
Desde el punto de vista técnico, el problema es que el clásico read–modify–write se ve así:
1) se lee la fila;
2) basándose en la lectura se calcula un nuevo valor;
3) se escribe el nuevo valor.
Si dos transacciones hacen simultáneamente este ciclo, ambas pueden comenzar con el mismo «antiguo» paso 1. La base de datos puede perfectamente permitir ambas escrituras, porque cada escritura en sí misma es correcta. Para que la base de datos «sospeche algo», necesita una condición adicional: o bien debes bloquear el recurso (pero eso ya es otra clase de soluciones), o bien debes proporcionar un criterio de actualidad del estado leído.
También es importante no confundir lost update con «lectura sucia» u otros sustos de los libros de texto sobre aislamiento. Lost update puede ocurrir en configuraciones muy normales y en la vida muy normal, porque no se trata de leer datos «no comprometidos». Se trata de que actualizas una fila basándote en un estado que ya ha quedado obsoleto en el momento de la escritura.
6. Qué considerar lost update
Un buen hábito de ingeniería: antes de agarrar una herramienta, debes nombrar claramente qué es lo que se rompió. Lost update no es «Hibernate se equivocó» ni «PostgreSQL se comporta raro». Es una situación en la que el sistema permite el siguiente escenario: dos cambios independientes de la misma fila se ejecutan secuencialmente, y la segunda actualización no verifica que sigue basándose en el estado actual.
Por lo tanto, la formulación correcta del requisito suena así: necesitamos un mecanismo que permita preguntar «¿la fila no ha cambiado desde mi lectura?». Si la respuesta es «cambió», la operación no debe sobrescribir silenciosamente los cambios de otros, sino convertirse en un resultado separado: conflicto, relectura, recálculo, otra decisión.
Y observa un matiz: no se trata de «prohibir la concurrencia». La concurrencia es algo normal; en el mundo backend ocurre incluso cuando no la pides. Se trata de no perder el significado en la concurrencia. Para las existencias, el significado a menudo es: «la reserva es una operación que debe tenerse en cuenta junto con otras reservas». Para el estado del pedido, el significado es: «la transición debe ser secuencial y verificable». Para el precio del producto, el significado puede ser: «no queremos revertir accidentalmente el precio porque dos procesos de fondo discuten quién manda hoy».
De aquí surge la siguiente pregunta de ingeniería: cómo hacer que UPDATE verifique que la fila no ha cambiado entre la lectura y la escritura. Para eso aparece la versión técnica de la fila. Ahora es importante que fijes en tu mente la causa: el código CRUD normal sin un mecanismo adicional de control de concurrencia permite la pérdida silenciosa de actualización.
7. Errores típicos con lost update
En el tema de lost update, la principal dificultad no es entender que «dos transacciones se sobrescribieron mutuamente». La principal dificultad es que este problema no grita: no hay un stacktrace rojo en el centro de la pantalla, no hay un error explícito en SQL. Por lo tanto, los errores típicos aquí casi siempre se tratan de expectativas incorrectas y diagnóstico incorrecto.
Error n.º 1: pensar que @Transactional protege automáticamente contra la sobrescritura paralela.
La transacción hace que tu operación sea atómica y correcta «internamente», pero no está obligada a comparar tu intento de actualización con otro intento paralelo. Si no agregas un mecanismo separado de control de actualidad, «last write wins» sigue siendo un resultado perfectamente legítimo.
Error n.º 2: buscar el problema solo en la sintaxis SQL y en la «corrección del UPDATE».
En lost update, el SQL suele ser ideal: UPDATE correcto, restricciones no violadas, la base de datos contenta. El problema no es que el SQL sea «malo», sino que no contiene una condición que vincule la escritura con el estado leído anteriormente.
Error n.º 3: confundir lost update con validación de negocio y errores de dominio.
El conflicto de cambios paralelos no es lo mismo que «no se puede reservar más de lo que hay». La validación responde a la pregunta «¿está permitido cambiar los datos de esta manera en absoluto?», mientras que lost update responde a la pregunta «¿no han quedado obsoletos los datos en los que basamos el cálculo?». Estas causas de fallo deben diferenciarse; de lo contrario, estarás tratando la enfermedad equivocada.
Error n.º 4: analizar solo el estado final de la fila e ignorar el momento de la lectura.
Cuando ves en la BD «available=7», es muy fácil decir «entonces alguien reservó 3». Pero en lost update es importante no solo lo que se escribió, sino qué camino se siguió para llegar a la escritura: desde qué estado inicial se leyó, cuántas veces se leyó, quién escribió después. Sin entender el momento de la lectura, no verás que los datos se perdieron.
Error n.º 5: pensar que el problema es «raro» y «no ocurre en un proyecto de aprendizaje».
En producción aparece más a menudo de lo que parece, porque la concurrencia no solo la crea el usuario, sino también las tareas en segundo plano, las integraciones, los reintentos de peticiones e incluso tus propias pruebas si se ejecutan en paralelo. Si el modelo de datos contiene «estado actual», las actualizaciones perdidas no son una excepción, sino un riesgo por defecto.
GO TO FULL VERSION