Locking optimista y pesimista en JPA

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-jpa-locking-optimista-pesimista

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 Long o Integer. JPA también acepta Timestamp para 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:

  • @Retryable envuelve el método con AOP. La transacción se abre en cada intento, así que la entidad se relee siempre.
  • backoff con multiplier = 2.0 y random = true introduce jitter exponencial, evitando que dos clientes en colisión reintenten al mismo tiempo y choquen otra vez.
  • @Recover define 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 @Retryable debe estar en un bean distinto al que invoca el método. Si el método se llama por this.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.
  • @Version en 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 OptimisticLockingFailureException y LockTimeoutException por 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 - Autor del tutorial

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.