JPA - BLOQUEO OPTIMISTA VS PESIMISTA
Mi blog tiene mucha plataforma (Vault, Kubernetes, Docker) y poco backend, y eso que en el About pone "backend". Esta es la primera de una serie de Java y Spring Boot que tenía pendiente, y arranco por algo que en un tutorial parece trivial y en producción se nota tarde: la actualización perdida (lost update).
En producción he usado el bloqueo optimista en unas cuantas tablas. De vez en cuando saltaba un error de conflicto, y lo resolvíamos reintentando el mensaje de RabbitMQ que disparaba el proceso y el guardado en base de datos. Aquí te cuento cómo funciona y cómo reproducir el problema en un test.
Probado con Java 21, Spring Boot 4.1.1 (Hibernate 7.4.5), Testcontainers 2.0.5 y PostgreSQL 17. A fecha de octubre de 2026, Hibernate 8 sigue en beta (8.0.0.Beta1, con Jakarta Persistence 4.0), así que uso la línea estable 7.x; cuando salga el 8, los detalles sobre bloqueos y timeouts podrían cambiar.
El problema
Dos peticiones ingresan dinero en la misma cuenta a la vez. Cada una lee el saldo, le suma su cantidad y guarda. Si las dos leen antes de que la otra confirme, la segunda en guardar pisa el resultado de la primera. Nadie lanza una excepción y el saldo queda mal.
Antes de hablar de soluciones, reproducimos el fallo. Si no puedes provocarlo en un test, tampoco sabrás si lo has arreglado.
Las entidades
Dos entidades casi iguales: una sin protección, para ver el problema, y otra con @Version.
@Entity
public class UnprotectedAccount {
@Id
@GeneratedValue
private Long id;
private long balance;
// getters and setters
}
@Entity
public class Account {
@Id
@GeneratedValue
private Long id;
private long balance;
@Version
private long version;
// getters and setters
}
Bloqueo optimista: @Version
La idea es no bloquear nada y detectar el conflicto al guardar. Con @Version, Hibernate añade la versión al UPDATE: update account set balance=?,version=? where id=? and version=?. Si otra transacción ya cambió la fila, el where no encuentra nada, se actualizan 0 filas y salta una excepción. Según la guía de introducción de Hibernate 7.0 (sección 3.8, "Version attributes"), casi cualquier entidad que se actualice con frecuencia debería tener un atributo de versión, y es Hibernate quien lo asigna al persistir la entidad y lo actualiza en cada modificación.
@Service
public class AccountService {
private final AccountRepository repository;
public AccountService(AccountRepository repository) {
this.repository = repository;
}
@Transactional
public void depositOptimistic(Long id, long amount) {
Account account = repository.findById(id).orElseThrow();
account.setBalance(account.getBalance() + amount); // dirty checking, no save()
}
}
En Spring esa excepción llega como org.springframework.orm.ObjectOptimisticLockingFailureException, no como jakarta.persistence.OptimisticLockException: Spring traduce la excepción de JPA. Se lanza cuando Hibernate vuelca (flush) los cambios a la base de datos, y por defecto eso ocurre al confirmar la transacción, es decir, al salir del método @Transactional. Por eso un try/catch dentro del método no la atrapa; solo la verías dentro si fuerzas el volcado (flush(), saveAndFlush() o una consulta que lo dispare). Lo comprobé con un test (FlushTimingTest) con tres casos: con dirty checking y sin forzar el volcado, el try/catch de dentro no la atrapa y le llega a quien llama; con saveAndFlush() dentro del try/catch, sí la atrapa; y con una consulta antes del final, que dispara el volcado automático, también. Ojo, que atraparla dentro no te salva: la transacción queda marcada para rollback, así que el commit de fuera falla con UnexpectedRollbackException y el cambio no se guarda (también lo cubre FlushTimingTest). Hay que capturarla fuera, en quien lo llama, que es también donde va el reintento. Si usas JPA a pelo, con un EntityManager y sin la traducción de Spring, lo que recibirás es jakarta.persistence.OptimisticLockException. Y si tu código puede lanzar también la variante de Spring Data JDBC, captura mejor la superclase org.springframework.dao.OptimisticLockingFailureException, que cubre las dos.
¿Y qué haces cuando salta? Reintentar tiene sentido solo si la operación es idempotente o la relees entera. En mi caso era un mensaje de RabbitMQ: al reintentarlo, el proceso entero volvía a leer la fila ya actualizada y a guardar. Mi forma de verlo: el optimista es ideal cuando los conflictos son raros y reintentar es barato; si reintentas todo el rato, estás pagando el trabajo dos veces.
Bloqueo pesimista: SELECT ... FOR UPDATE
El enfoque contrario: bloqueas la fila al leerla y los demás esperan. En JPA es LockModeType.PESSIMISTIC_WRITE, y en Spring Data se pone con @Lock en el repositorio:
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("select a from Account a where a.id = :id")
Optional<Account> findByIdForUpdate(@Param("id") Long id);
}
@Transactional
public void depositPessimistic(Long id, long amount) {
Account account = repository.findByIdForUpdate(id).orElseThrow();
account.setBalance(account.getBalance() + amount);
}
El bloqueo solo existe dentro de una transacción activa: fuera de ella no hay nada que mantener. La pista jakarta.persistence.lock.timeout (en milisegundos) limita cuánto espera el segundo. Si pasa ese tiempo con la fila aún bloqueada, Spring lanza CannotAcquireLockException. Que se respete el timeout depende de la base de datos, así que pruébalo con la tuya.
Frente al optimista, lo que pagas es contención: transacciones esperando, riesgo de deadlocks si bloqueas varias filas en distinto orden, y conexiones del pool ocupadas mientras esperan.
La alternativa más simple: un UPDATE atómico
Antes de elegir un bloqueo, pregúntate si lo necesitas. Si lo único que haces es sumar a un saldo, basta una sentencia que lo haga en la base de datos: UPDATE account SET balance = balance + ? WHERE id = ?. No lees nada antes, así que no hay nada que pisar, y la base de datos serializa las escrituras sobre la fila. En Spring Data, un método con @Modifying y @Query. Los bloqueos hacen falta cuando lees, decides en Java con lo leído y luego escribes (por ejemplo, rechazar el ingreso según alguna regla del dominio).
El test que reproduce el problema
Lanzamos N hilos que ingresan 1 a la vez en la misma fila, y los soltamos juntos con un CountDownLatch para maximizar la colisión.
@SpringBootTest
@Testcontainers
class ConcurrentLockingTest {
private static final int THREADS = 10;
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");
@Autowired AccountService service;
@Autowired AccountRepository repository;
@Autowired UnprotectedAccountRepository unprotectedRepository;
@Autowired TransactionTemplate transactionTemplate;
@Test
void withoutProtection_someUpdateIsLost() throws Exception {
Long id = unprotectedRepository.save(new UnprotectedAccount()).getId();
runInParallel(() -> service.depositUnprotected(id, 1));
long balance = unprotectedRepository.findById(id).orElseThrow().getBalance();
assertThat(balance).isLessThan(THREADS);
}
@Test
void optimistic_conflictsAreDetected() throws Exception {
Long id = repository.save(new Account()).getId();
List<Throwable> failures = runInParallel(() -> service.depositOptimistic(id, 1));
long balance = repository.findById(id).orElseThrow().getBalance();
assertThat(balance).isEqualTo(THREADS - failures.size());
assertThat(failures).allMatch(e -> e instanceof ObjectOptimisticLockingFailureException);
}
@Test
void pessimistic_everyDepositIsApplied() throws Exception {
Long id = repository.save(new Account()).getId();
List<Throwable> failures = runInParallel(() -> service.depositPessimistic(id, 1));
assertThat(failures).isEmpty();
assertThat(repository.findById(id).orElseThrow().getBalance()).isEqualTo(THREADS);
}
@Test
void pessimistic_whenTheLockIsNotReleased_timesOut() throws Exception {
Long id = repository.save(new Account()).getId();
CountDownLatch locked = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CompletableFuture<Void> holder = CompletableFuture.runAsync(() -> transactionTemplate.executeWithoutResult(status -> {
repository.findByIdForUpdate(id).orElseThrow();
locked.countDown();
await(release);
}));
locked.await();
try {
assertThatThrownBy(() -> service.depositPessimistic(id, 1))
.isInstanceOf(CannotAcquireLockException.class);
} finally {
release.countDown();
holder.get();
}
}
}
El método runInParallel está al final, en el anexo. depositUnprotected es el mismo método que depositOptimistic, pero sobre UnprotectedAccount; lo omito por no repetir, y también el método auxiliar await, que solo envuelve latch.await() para no propagar InterruptedException.
Ojo con tres trampas de este test, que son las que más me importan:
- Los tests de concurrencia no son deterministas. En teoría el primero podría salir con 10 alguna vez sin que el código esté bien o mal. En mis pruebas con 10 hilos siempre se perdió alguna actualización, pero eso no es una garantía: si lo dejas en el repo, ejecútalo en bucle o súbele los hilos y las iteraciones.
- No uses
@Transactionalen la clase de test. Envolvería todo en una transacción y los hilos, que son otras transacciones, no verían los datos. Los datos deben estar confirmados antes de lanzar los hilos. - El motor importa. Con una base en memoria el comportamiento de los bloqueos puede ser distinto al de PostgreSQL o MySQL, y la propia guía de Hibernate avisa, en su sección de testing, de tener cuidado al probar contra una base en memoria si el código usa funciones de concurrencia como los bloqueos pesimistas. Para este tipo de test merece la pena la base real, con Testcontainers (lo cuento en otro post de esta serie).
Para cerrar
Por defecto usaría el optimista, que es lo que he usado en producción: los conflictos eran ocasionales y bastaba con reintentar el mensaje. El pesimista lo reservaría para filas calientes, donde sé que va a haber conflicto casi siempre (un contador de stock en un lanzamiento, por ejemplo). Y lo peor no es elegir mal, sino no tener un test que te enseñe el fallo.
Cuando varios actores tocan el mismo dato, la consistencia hay que diseñarla, no se da sola.
Anexo: el helper runInParallel
Es el método de la clase de test que lanza los hilos y los suelta a la vez:
private List<Throwable> runInParallel(Runnable action) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
CountDownLatch start = new CountDownLatch(1);
List<Throwable> failures = new CopyOnWriteArrayList<>();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < THREADS; i++) {
futures.add(pool.submit(() -> {
try {
start.await();
action.run();
} catch (Throwable e) {
failures.add(e);
}
}));
}
start.countDown();
for (Future<?> f : futures) f.get();
pool.shutdown();
return failures;
}
Documentación oficial
- Hibernate ORM 7.0 - Introduction (
@Version, bloqueo optimista y pesimista): https://docs.hibernate.org/orm/7.0/introduction/html_single/ - Spring Data JPA - Locking: https://docs.spring.io/spring-data/jpa/reference/jpa/locking.html
- Jakarta Persistence 3.2 (
LockModeType,jakarta.persistence.lock.timeout): https://jakarta.ee/specifications/persistence/3.2/