
El problema de las actualizaciones perdidas
Imagina dos operadores editando el mismo producto a la vez. Ambos cargan el formulario con stock 100. El operador A descuenta 5 unidades y guarda (stock 95). El operador B, que sigue viendo 100, descuenta 3 y guarda (stock 97). El descuento de A se ha perdido. La base de datos refleja 97 cuando el valor correcto era 92.
Este patrón se conoce como lost update y es endémico en aplicaciones web sin control de concurrencia. JPA ofrece dos estrategias complementarias para evitarlo: locking optimista y locking pesimista. La diferencia esencial es cuándo se detecta el conflicto.
graph TD
A[Dos transacciones leen fila v=1] --> B{Estrategia}
B -->|Optimista| C[Ambas leen sin bloqueo]
C --> D[Tx1 hace UPDATE WHERE v=1 -> v=2]
D --> E[Tx2 hace UPDATE WHERE v=1 -> 0 filas]
E --> F[OptimisticLockingFailureException]
B -->|Pesimista| G[Tx1 hace SELECT FOR UPDATE]
G --> H[Tx2 espera al commit de Tx1]
H --> I[Tx2 lee valor actualizado y procede]
El locking optimista asume que el conflicto es raro y deja que las transacciones avancen sin bloqueo, detectando la colisión en el commit. El locking pesimista asume que el conflicto es frecuente y serializa el acceso a la fila desde el primer SELECT.
Locking optimista con @Version
Sintaxis básica
Añadir una columna version a la entidad y anotarla con @Version:
@Entity
@Getter
@Setter
public class Producto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String nombre;
private Integer stock;
private BigDecimal precio;
@Version
private Long version;
}
JPA gestiona la columna de forma automática. Cada UPDATE incluye WHERE version = ? con el valor leído y SET version = version + 1. Si la base de datos reporta cero filas afectadas, JPA lanza OptimisticLockingFailureException.
El SQL generado es comparable a este:
UPDATE producto
SET nombre = ?, stock = ?, precio = ?, version = 2
WHERE id = ? AND version = 1;
El tipo recomendado es
LongoInteger. JPA también aceptaTimestamppara casos donde la versión es semánticamente una fecha (por ejemplo, sincronización con sistemas externos), pero el incremento numérico es más eficiente y menos propenso a colisiones por resolución de reloj.
Comportamiento al detectar conflicto
@Service
@RequiredArgsConstructor
public class ProductoService {
private final ProductoRepository repository;
@Transactional
public Producto actualizarPrecio(Long id, BigDecimal nuevoPrecio) {
var producto = repository.findById(id).orElseThrow();
producto.setPrecio(nuevoPrecio);
return repository.save(producto);
}
}
Si dos llamadas a actualizarPrecio ocurren a la vez con el mismo id, una hará commit con éxito (version pasa de 1 a 2) y la otra lanzará ObjectOptimisticLockingFailureException en el flush. Esta excepción extiende de OptimisticLockingFailureException y de DataAccessException, así que se captura cómodamente en el GlobalExceptionHandler.
Manejo y reintento con Spring Retry
La estrategia idiomática es reintentar la operación: volver a leer la entidad, aplicar la modificación y persistir. Spring Retry lo automatiza:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
@SpringBootApplication
@EnableRetry
public class TiendaApplication { }
@Service
@RequiredArgsConstructor
public class StockService {
private final ProductoRepository repository;
@Retryable(
retryFor = OptimisticLockingFailureException.class,
maxAttempts = 4,
backoff = @Backoff(delay = 50, multiplier = 2.0, random = true)
)
@Transactional
public void descontar(Long productoId, int unidades) {
var producto = repository.findById(productoId).orElseThrow();
if (producto.getStock() < unidades) {
throw new StockInsuficienteException(productoId);
}
producto.setStock(producto.getStock() - unidades);
repository.save(producto);
}
@Recover
public void recuperar(OptimisticLockingFailureException ex, Long productoId, int unidades) {
log.warn("No se pudo descontar {} unidades de {} tras 4 intentos", unidades, productoId);
throw new ContencionExcesivaException(productoId, ex);
}
}
Puntos clave de esta configuración:
@Retryableenvuelve el método con AOP. La transacción se abre en cada intento, así que la entidad se relee siempre.backoffconmultiplier = 2.0yrandom = trueintroduce jitter exponencial, evitando que dos clientes en colisión reintenten al mismo tiempo y choquen otra vez.@Recoverdefine el fallback cuando se agotan los intentos. Lanzar una excepción de dominio permite que el cliente reciba un 409 Conflict en lugar de un 500.
El
@Retryabledebe estar en un bean distinto al que invoca el método. Si el método se llama porthis.descontar(...)desde otro método del mismo servicio, el proxy de Spring no se aplica y el reintento se ignora silenciosamente.
Cuándo usar locking optimista
Es la estrategia por defecto cuando la contención esperada es baja: edición de perfil de usuario, modificación de un pedido por un único operador, actualización de configuración. La penalización es nula en el caso feliz (cero bloqueos) y solo cuesta cuando hay colisión real.
Locking pesimista con @Lock
Cuando la contención es alta o el coste del reintento es prohibitivo (por ejemplo, una operación que llama a un sistema externo), el locking pesimista bloquea la fila desde el primer SELECT.
Variantes de @Lock
Spring Data JPA permite anotar métodos del repositorio:
public interface ProductoRepository extends JpaRepository<Producto, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Producto p WHERE p.id = :id")
Optional<Producto> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT p FROM Producto p WHERE p.sku = :sku")
Optional<Producto> findBySkuShared(@Param("sku") String sku);
}
PESSIMISTIC_WRITE se traduce a SELECT ... FOR UPDATE en PostgreSQL, MySQL y Oracle. Adquiere un lock exclusivo: ninguna otra transacción puede leer con lock ni actualizar la fila hasta el commit o rollback.
PESSIMISTIC_READ se traduce a SELECT ... FOR SHARE en PostgreSQL y MySQL. Adquiere un lock compartido: otros lectores con PESSIMISTIC_READ también pueden leer, pero ningún escritor puede modificar la fila hasta que todos los lectores liberen.
SELECT FOR UPDATE en la práctica
@Service
@RequiredArgsConstructor
public class ReservaStockService {
private final ProductoRepository repository;
private final ReservaRepository reservaRepository;
@Transactional
public Reserva reservar(Long productoId, int unidades, Long clienteId) {
var producto = repository.findByIdForUpdate(productoId)
.orElseThrow(() -> new ProductoNoEncontradoException(productoId));
if (producto.getStock() < unidades) {
throw new StockInsuficienteException(productoId);
}
producto.setStock(producto.getStock() - unidades);
var reserva = new Reserva(productoId, clienteId, unidades, LocalDateTime.now());
return reservaRepository.save(reserva);
}
}
Mientras la transacción esté abierta, ninguna otra transacción puede tocar esa fila de producto. Otra petición concurrente para el mismo producto espera hasta el commit o rollback. Esto elimina el problema del lost update sin reintentos.
El método debe ser
@Transactional. Sin transacción, el lock se libera al cerrar la sesión Hibernate inmediatamente después del SELECT y la garantía desaparece.
Timeouts de lock
Para evitar que un cliente espere indefinidamente, configurar un timeout:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("SELECT p FROM Producto p WHERE p.id = :id")
Optional<Producto> findByIdForUpdateWithTimeout(@Param("id") Long id);
Si el lock no se obtiene en 3 segundos, lanza LockTimeoutException. El servicio puede capturarla y devolver un 503 Service Unavailable con Retry-After para indicar al cliente que reintente.
PostgreSQL respeta el hint vía SET LOCAL lock_timeout. MySQL InnoDB lo respeta vía innodb_lock_wait_timeout global o por sesión. Verifica el dialecto antes de confiar en el hint.
Prevención de deadlocks
Si la transacción A bloquea la fila X y luego intenta bloquear Y, mientras la transacción B bloquea Y y luego intenta X, ambas se quedan esperando para siempre. Esto es un deadlock. La base de datos lo detecta y aborta una de las dos con un error específico.
La defensa es adquirir los locks en un orden consistente en todo el código. Para una transferencia bancaria entre dos cuentas:
@Service
@RequiredArgsConstructor
public class TransferenciaService {
private final CuentaRepository repository;
@Transactional
public void transferir(Long origenId, Long destinoId, BigDecimal cantidad) {
var primero = Math.min(origenId, destinoId);
var segundo = Math.max(origenId, destinoId);
var cuentaPrimero = repository.findByIdForUpdate(primero).orElseThrow();
var cuentaSegundo = repository.findByIdForUpdate(segundo).orElseThrow();
var origen = primero.equals(origenId) ? cuentaPrimero : cuentaSegundo;
var destino = primero.equals(origenId) ? cuentaSegundo : cuentaPrimero;
if (origen.getSaldo().compareTo(cantidad) < 0) {
throw new SaldoInsuficienteException(origenId);
}
origen.setSaldo(origen.getSaldo().subtract(cantidad));
destino.setSaldo(destino.getSaldo().add(cantidad));
}
}
Como ambas transacciones piden primero el id menor y luego el mayor, nunca se produce ciclo de espera. El deadlock es imposible por construcción.
Casos prácticos
Carrito de compra concurrente
El usuario añade un producto desde dos pestañas distintas a la vez. Sin protección, el carrito puede acabar con líneas duplicadas o cantidades incorrectas. Locking optimista con @Version en la entidad Carrito y reintento automático resuelve el caso con coste mínimo.
Reserva de stock en venta flash
Un producto con 10 unidades recibe 1000 peticiones concurrentes en 30 segundos. Aquí la contención es altísima: el optimista llevaría a tasas de retry del 99% y degradación severa. La opción correcta es pesimista con cola: cada petición hace SELECT ... FOR UPDATE, espera su turno, valida stock, descuenta y commitea.
Transferencias bancarias
Doble write con invariante de saldo total. El pesimista con orden consistente evita lost updates y deadlocks. Es el caso de libro para PESSIMISTIC_WRITE.
Edición de un documento colaborativo
Si dos editores tocan campos distintos de la misma entidad, el optimista con merge a nivel de campo (vía @DynamicUpdate de Hibernate) permite que ambos cambios coexistan. Si tocan el mismo campo, gana el último que commitee y el otro recibe el conflicto para resolución manual.
Optimista vs pesimista: tabla decisiva
| Criterio | Optimista (@Version) | Pesimista (@Lock) | |----------|----------------------|-------------------| | Contención esperada | Baja | Alta | | Sobrecoste sin colisión | Cero | Lock por cada SELECT | | Reintentos | Sí (Spring Retry) | No | | Riesgo de deadlock | Inexistente | Real, requiere orden | | Adecuado para batch largo | No (más conflictos) | Sí | | Coste del retry | Bajo si la lógica es CPU | Inviable si llama a APIs externas |
Buenas prácticas
- Por defecto, optimista. Solo cambiar a pesimista cuando midas tasas de retry inaceptables.
@Versionen toda entidad mutable desde el día uno. Añadirla más tarde requiere migración de schema y batch para inicializar valores.- Spring Retry con jitter exponencial para optimista. Sin jitter, dos clientes en colisión chocan otra vez en el primer reintento.
- Orden consistente de locks para pesimista. Documenta el criterio (id ascendente, ruta canónica) en comentarios sobre el método.
- Timeouts en locks pesimistas para no bloquear hilos del servidor indefinidamente. Mejor un 503 con Retry-After que un thread pool agotado.
- No mezclar locks pesimistas con llamadas a APIs externas dentro de la transacción. La latencia de la API multiplica el tiempo del lock y dispara la contención.
- Métricas de locking vía Micrometer: contar
OptimisticLockingFailureExceptionyLockTimeoutExceptionpor endpoint. Un pico de retries indica un hot path que necesita rediseño.
Locking optimista y pesimista no son alternativas excluyentes: la mayoría de aplicaciones usan optimista por defecto y reservan pesimista para los puntos críticos de contención. Conocer dónde aplica cada uno es la diferencia entre un sistema que escala y uno que se cae al primer pico de tráfico.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Aplicar @Version para detectar actualizaciones perdidas. Capturar OptimisticLockingFailureException y reintentar con Spring Retry. Usar @Lock con PESSIMISTIC_READ y PESSIMISTIC_WRITE en repositorios JPA. Diferenciar el comportamiento contra PostgreSQL y MySQL. Prevenir deadlocks ordenando los recursos de forma consistente. Decidir cuándo usar cada estrategia según contención esperada.