1. Dónde aparece el conflicto optimista
Cuando por primera vez te encuentras con una excepción de bloqueo optimista, el cerebro a menudo espera una mecánica simple: «he cambiado el campo → Hibernate comprueba la versión inmediatamente → si hay conflicto, entonces justo aquí y falla». Pero Hibernate no es JDBC disfrazado. Vive en el modelo de «los cambios se acumulan en la memoria y llegan a la BD más tarde». Por lo tanto, el conflicto no tiene por qué (y la mayoría de las veces no puede) manifestarse en el momento de setAvailableQty() o de llamar a un método de dominio.
La versión ya existe, pero por sí sola aún no comprueba nada. Mientras los cambios vivan solo en el objeto gestionado, la base de datos no tiene con qué comparar. Por eso ahora es importante capturar el punto donde Hibernate hace real el conflicto por primera vez.
Imagina que InventoryItem item es tu copia de trabajo de un documento. Puedes reescribir el texto diez veces. Hasta que no pulses «Guardar», el servidor (la BD) no sabe en absoluto que has cambiado algo. Hibernate se comporta de manera similar: modificas el objeto gestionado, Hibernate lo marca como sucio (dirty) y recuerda que al sincronizar habrá que hacer un UPDATE. Pero la comprobación de la versión es parte del SQL, y el SQL aparece solo cuando se activa el ciclo de flush.
Esto se ve claramente en nuestro proyecto transversal Commerce Persistence Lab, si tienes en cuenta que reserve(qty) es solo aritmética en memoria:
public void reserve(int qty) {
// Método de dominio: solo cambiamos el estado del objeto en memoria, aquí no se escribe nada en la BD
availableQty -= qty; // por ahora solo es memoria
reservedQty += qty; // la BD aún no participa
}
Hasta flush() esto parece casi «Java normal». Y precisamente por eso dentro de un mismo método puedes hacer un cambio, ejecutar diez líneas más de lógica de negocio, logs y cálculos… y luego recibir de repente la excepción al final de la transacción. Hibernate calló honestamente porque simplemente no tenía nada que comprobar: la versión se comprueba en la base de datos, y la base de datos «todavía no existía».
UPDATE con versión y comprobación de versión
Ahora fijemos la mecánica de la forma más terrenal posible: el bloqueo optimista con @Version no es «un hilo en segundo plano que compara versiones» ni «un SELECT adicional antes de la actualización». Es un UPDATE normal, en el que la versión participa en el WHERE. Si el número de filas actualizadas resulta ser 0, Hibernate concluye: «mi copia está desactualizada» y lanza una excepción de bloqueo optimista. Esto se describe directamente en la documentación de Hibernate: la comprobación de la versión se incluye en el WHERE, y la ausencia de filas actualizadas se interpreta como un conflicto, tras lo cual Hibernate notifica la OptimisticLockException.
Simplificando, el SQL se ve así (pseudo-SQL, la forma es más importante que los detalles):
-- La versión participa en el WHERE: si la versión está desactualizada, la fila no se encuentra y la actualización afecta a 0 filas
update inventory_item
set available_qty = ?, reserved_qty = ?, version = ?
where id = ? and version = ?
Idea clave: el conflicto ocurre no porque «las versiones no coincidieron en Java», sino porque en la BD ya hay otra versión, y tu where version = valor_antiguo ha dejado de encontrar la fila.
En este punto es útil recordar otra verdad técnica de la vida: el campo de versión no son datos de negocio y normalmente tiene tipos simples (int/long, Timestamp, etc.). Hibernate y JPA esperan precisamente esos tipos para @Version. Es decir, la versión es un contador técnico de «actualidad de la fila», no una «versión del producto» ni una «revisión del documento» en el sentido de negocio.
Y aquí está la pregunta clave: ¿cuándo se ejecuta este UPDATE? Respuesta: cuando se ejecuta flush().
flush() como «momento de la verdad»
La palabra flush debe entenderse literalmente: es «vaciar los cambios acumulados de la memoria a la base de datos». El contexto de persistencia se comporta como una caché de escritura diferida (write-behind cache): los cambios se aplican primero a los objetos y se envían a la base de datos en bloque durante flush(). Es entonces cuando Hibernate convierte tus cambios en INSERT/UPDATE/DELETE. Y es entonces cuando aparece el UPDATE con versión, por lo tanto, es entonces cuando puede aparecer el conflicto optimista.
Una trampa adicional para principiantes es que el flush puede ocurrir no solo «al final». Por defecto, flush normalmente se ejecuta al hacer commit de la transacción, pero puede ocurrir antes — por ejemplo, antes de una consulta que potencialmente depende de cambios no vaciados, o al llamar explícitamente a flush(). Esto también está documentado oficialmente: el flush por defecto ocurre al hacer commit y a veces antes de las consultas, así como al llamar directamente a flush().
Aquí es más importante una regla simple de observación:
El conflicto optimista está «vinculado» al lugar donde realmente se envía el SQL. Y el SQL se envía en el flush.
Para no tenerlo como una abstracción, aquí hay una pequeña tabla-recordatorio: dónde exactamente puede «aparecer» el conflicto si tienes @Version.
| Qué hace tu código | Qué hace Hibernate | Dónde verás el conflicto |
|---|---|---|
| Modificas campos de una entidad gestionada | Marca la entidad como dirty, aún no envía SQL | Todavía no hay conflicto (y no puede haberlo) |
Llamas a |
Envía UPDATE ... where ... version=? | Justo en esta línea |
| Ejecutas una consulta JPQL/Criteria en modo flush AUTO | Puede hacer un auto-flush antes de la consulta | Al ejecutar la consulta (parece «no allí») |
El método finaliza |
Spring hace commit, Hibernate hace flush | Al salir del método / después del return |
Toda la magia del bloqueo optimista se reduce a que «en el momento adecuado» tu UPDATE se vuelve condicional, y la base de datos dice: «las filas no se actualizaron». Eso es todo.
### commit en Spring y error después de return
En la práctica, el efecto más desagradable del bloqueo optimista se ve así: escribes un método de servicio, «aparentemente» lo hace todo, incluso devuelves true, y luego la aplicación igualmente falla con una excepción de bloqueo optimista. Y no es porque Spring no te quiera. Es porque el commit de la transacción no ocurre dentro de tu método, sino alrededor de él, en el código de infraestructura de Spring.
Si usas @Transactional en un método de servicio, la imagen real es más o menos así: Spring abre la transacción antes de llamar al método, te cede el control, y cuando el método retorna — Spring intenta ejecutar el commit. Y commit casi siempre incluye flush() (en el modo normal de funcionamiento). Por eso el conflicto optimista puede «vivir» no en medio del método, sino en el borde de salida del mismo.
Aquí hay un pequeño esquema que cura bien la ilusión de «pero yo tengo un try/catch dentro del método, ¿por qué no se captura?»:
sequenceDiagram
participant C as Caller
participant P as Spring Tx Proxy
participant S as InventoryService
participant EM as EntityManager/Hibernate
participant DB as PostgreSQL
C->>P: "reserve(itemId, qty)"
P->>EM: "iniciar tx + vincular contexto de persistencia"
P->>S: "llamar cuerpo del método"
S->>EM: "find + cambiar campos (dirty)"
S-->>P: "return (¡todavía no había SQL!)"
P->>EM: "flush (SQL UPDATE con versión)"
EM->>DB: "UPDATE ... WHERE version=?"
DB-->>EM: "0 filas actualizadas -> conflicto"
EM-->>P: "OptimisticLockException"
P-->>C: "la excepción se propaga"
Esto es lo que se llama «límite del servicio» en el contexto del bloqueo optimista: si no fuerzas el flush, el momento de la comprobación de la versión en el flujo estándar a menudo ocurre después de tu return.
De aquí se deduce una conclusión bastante práctica, que muchos asimilan solo tras un par de informes de errores extraños: si quieres manejar el conflicto optimista «dentro» del método, debes hacer que el conflicto aparezca dentro del método. Y eso significa acercar flush() al lugar donde realmente te importa el resultado.
2. Gestión del conflicto en el servicio
flush() explícito en el servicio
El flush() explícito es como un botón de «guardar ahora mismo, no en algún momento después». No es un «acelerador» ni una «buena práctica siempre», sino una forma de hacer el comportamiento predecible: tú mismo eliges el momento en que Hibernate debe ir a la BD y comprobar que el registro realmente pasa. En términos de bloqueo optimista, esto significa: quieres ver el conflicto allí donde aún puedes tomar una decisión con sentido.
En nuestro proyecto, la variante más mínima se ve así: después de cambiar los stocks, haces flush() para que el UPDATE con versión se ejecute ahora mismo.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryService {
private final EntityManager entityManager;
public InventoryService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional
public void reserveAndFailFast(Long itemId, int qty) {
// find() devuelve una entidad gestionada: los cambios viven en el contexto de persistencia, no en la BD
entityManager.find(InventoryItem.class, itemId).reserve(qty);
// IMPORTANTE: justo aquí Hibernate enviará el SQL y la versión se comprobará realmente en el WHERE
entityManager.flush(); // el conflicto de versión aparecerá aquí
}
}
El sentido de este flush() no es manejar elegantemente el conflicto dentro de la misma transacción. Simplemente hace observable el momento de la comprobación de la versión: el SQL se envía justo aquí, y la versión obsoleta sale a la luz en esta línea. Si ocurre el conflicto, el intento actual ya se considera fallido; a continuación se necesita un nuevo ciclo completo de lectura y escritura, no un return false desde esta misma unidad de trabajo.
Un contrato de servicio robusto se construye sobre este lugar observable: un intento transaccional no oculta nada, y el código externo ya decide si devolver el conflicto como resultado o iniciar un nuevo intento.
Ahora comparemos con cómo se ve la trampa de «lo capturaré con try/catch, pero no lo capturaré» — cuando no haces flush y esperas que el conflicto se manifieste durante el cambio de campos:
import jakarta.persistence.EntityManager;
import jakarta.persistence.OptimisticLockException;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public boolean tryReserveNoFlush(EntityManager em, Long id, int qty) {
try {
// La entidad se vuelve dirty, pero el SQL aún no se enviará
em.find(InventoryItem.class, id).reserve(qty);
// Dentro del método todo «exitoso»: commit/flush ocurrirá después, fuera del cuerpo de este método
return true; // el commit será después, no aquí
} catch (OptimisticLockException ex) {
// A menudo no llegarás aquí: la excepción llegará ya en el commit a nivel del proxy de Spring
return false; // a menudo no llegarás aquí
}
}
Esto no es un «error de Java». Es precisamente el límite de la transacción: commit (y por tanto flush()) se ejecutará después del retorno del método, por lo que tu catch puede no dispararse nunca: la excepción saltará desde fuera, a nivel del proxy.
Y un detalle práctico más: se puede llamar a flush no solo a través de EntityManager. En Spring Data JPA existe el método flush() en JpaRepository, así como saveAndFlush(), que en esencia hacen «guardar y sincronizar inmediatamente». Con EntityManager la mecánica se ve directamente. Pero es útil saber que flush no es un ritual secreto: tiene una API oficial tanto en Hibernate como en JPA y en Spring Data.
Contexto de persistencia tras OptimisticLockException
Cuando se produce una excepción de bloqueo optimista, es fácil pensar: «Bueno, intentaré de nuevo en la misma transacción, mi objeto sigue en la memoria». Es una intuición muy peligrosa. Después de una excepción relacionada con el trabajo con la base de datos, tu contexto de persistencia deja de ser un reflejo fiable de la realidad: no puedes garantizar que el estado en memoria y en la BD estén sincronizados. Hibernate advierte directamente que el contexto de persistencia es «frágil»: tras una excepción, es mejor considerar la sesión como no utilizable y desecharla, abriendo una nueva si se necesita continuar.
En Spring esto normalmente se «resuelve» porque la excepción provoca un rollback de la transacción y simplemente sales al exterior. Pero el problema aparece cuando el desarrollador empieza a capturar la excepción y continuar haciendo algo con los mismos objetos gestionados. En ese momento es como si arreglaras el coche mientras circula por la carretera, y además a tientas.
Si lo formulamos de la forma más práctica para el día del bloqueo optimista, resulta una regla de hierro: después de un conflicto optimista, el intento actual de la operación de negocio se considera fallido, y no se puede continuar «como si nada». Incluso si planeas repetir el intento (o tomar otra decisión), esto casi siempre significa un nuevo inicio: nueva transacción, nueva lectura de datos, nuevo cálculo.
Nótese que esto no es «filosofía complicada». Es una higiene de ingeniería muy simple: si la herramienta dijo «he trabajado con datos obsoletos», no hay que fingir que los datos obsoletos de repente se han vuelto frescos.
Conflicto optimista y validación de negocio
En un escenario de negocio real, los problemas vienen en pares. Para el inventario esto es especialmente notorio: puede haber un error de negocio legítimo «no hay suficiente cantidad disponible» y un conflicto optimista técnico «alguien ya ha cambiado la fila mientras pensabas». Y si los mezclas en un mismo saco, obtienes la situación clásica de «a veces devolvemos “stock insuficiente”, a veces caemos con 500, a veces no se sabe qué».
El modelo de pensamiento correcto es: la validación de negocio vive en tu código y comprueba el estado del objeto, mientras que el bloqueo optimista comprueba la actualidad de ese estado en el momento de la escritura. Es decir, puedes pasar la validación porque en el momento de la lectura el stock era suficiente, y aun así recibir un conflicto porque una transacción paralela ya ha reservado parte del producto.
Mini-ilustración en código, sin intentar «manejar todo perfectamente», solo para ver dos mundos diferentes:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void reserve(EntityManager em, Long itemId, int qty) {
// Leemos la entidad: obtenemos una «instantánea» del estado en el momento de la lectura
InventoryItem item = em.find(InventoryItem.class, itemId);
if (item.getAvailableQty() < qty) { // error de negocio
throw new IllegalStateException("Not enough stock");
}
// Aquí solo modificamos campos en memoria
item.reserve(qty); // cambio en memoria
// Aquí aparece el SQL y, junto con él, la comprobación de versión en la BD
em.flush(); // aquí puede haber un conflicto optimista
}
Observa el «eje temporal»: la comprobación de availableQty ocurre sobre la copia de datos que leíste. Y la comprobación de versión ocurre en la BD durante flush(). Por lo tanto, una arquitectura correcta casi siempre implica que estos dos resultados se distinguen. E incluso si aún no estás diseñando errores de dominio «bonitos», al menos debes entender que el conflicto optimista no es «no nos alcanzó el stock», sino «trabajamos con una copia desactualizada».
Otro punto sutil: si no haces flush en el método, el conflicto optimista puede llegar ya en el commit, es decir, después de que hayas pasado la validación de negocio y, posiblemente, hayas realizado otras acciones. Por eso el flush explícito a veces es útil no como «manejo», sino como «hacer que el resultado de la operación sea determinado lo antes posible».
3. Errores típicos del bloqueo optimista
Cuando empiezas a trabajar con el bloqueo optimista, los errores no suelen parecer «el compilador se queja», sino «la lógica parece correcta, pero a veces todo se rompe». Este es el tipo de problema más traicionero: genera supersticiones en lugar de pensamiento de ingeniería. Por eso es útil conocer de antemano algunas trampas típicas.
Error n.º 1: esperar el conflicto en el momento de modificar el campo.
Si el desarrollador piensa que item.reserve(qty) ya debería «comprobar la versión», empezará a poner try/catch alrededor de los métodos de dominio y se sorprenderá de que la excepción llegue más tarde. El modelo correcto es recordar que la versión se comprueba mediante una condición SQL, y el SQL aparece en el flush. Por lo tanto, el conflicto está «vinculado» al ciclo de flush, no al setter.
Error n.º 2: capturar OptimisticLockException dentro de un método @Transactional sin flush() explícito.
Esta es una trampa sutil pero muy común. La persona escribe un try/catch en el servicio y espera capturar el conflicto. Pero el commit de la transacción se ejecuta después del return, en el código de infraestructura de Spring. Como resultado, el catch puede no dispararse en absoluto, y la excepción irá al código llamante. Si necesitas capturar el conflicto dentro del método, debes hacer un flush dentro del método para que el conflicto ocurra allí mismo.
Error n.º 3: continuar trabajando con los mismos objetos gestionados después de un conflicto optimista.
Después de un UPDATE conflictivo, tu contexto de persistencia ya no garantiza la sincronización con la BD. Hibernate considera esa sesión como «frágil», y después de la excepción hay que desecharla; de lo contrario, empiezas a construir decisiones de negocio sobre un estado obsoleto.
En Spring, el estilo correcto suele reducirse a que el conflicto significa «el intento actual ha fallado», y luego o sales al exterior o inicias un nuevo intento en un nuevo contexto.
Error n.º 4: confundir el conflicto optimista con un error de negocio.
Para el inventario esto se manifiesta así: «no hay suficiente stock» y «conflicto optimista» son dos resultados diferentes. Si los empaquetas como si fueran lo mismo (por ejemplo, simplemente RuntimeException("reserve failed")), pierdes el sentido. La validación de negocio responde a la pregunta «¿se puede reservar según las reglas del dominio?», mientras que el bloqueo optimista responde a «¿tu copia de datos es actual en el momento de la escritura?». Son causas diferentes y, en consecuencia, diferentes reacciones del sistema.
Error n.º 5: tratar a flush() como una línea decorativa.
A veces se añade flush «para que funcione» sin entender que cambia el comportamiento: el SQL se enviará antes, el conflicto se hará visible antes, pueden aparecer viajes adicionales de ida y vuelta a la base de datos. Flush es una palanca consciente. No es ni malo ni bueno, simplemente hace visible la realidad. Si entiendes para qué sirve, se convierte en tu herramienta, no en un conjuro aleatorio.
GO TO FULL VERSION