CodeGym /Cursos /Hibernate deep-dive /Cuándo está justificado pessimistic locking

Cuándo está justificado pessimistic locking

Hibernate deep-dive
Nivel 19, Lección 0
Disponible

1. Introducción

Si alguna vez ha trabajado en la cola de una cafetería popular, ya entiende la mitad del tema: el problema no es que la gente no sepa pedir café, sino que todos quieren lo mismo al mismo tiempo. En la escritura concurrente en la base de datos la situación es muy similar: no discutimos que la operación sea correcta, discutimos por el derecho a ejecutarla primero.

Optimistic locking es como «cada uno hace su pedido y al final el cajero verifica si el precio ha cambiado mientras pensaba». Es excelente cuando las colisiones son raras. Pero hay escenarios donde «pensar mucho tiempo» y «colisionar con frecuencia» son parte normal de la vida del sistema. Entonces a veces es más rentable no verificar el conflicto al final, sino acordar directamente con la base de datos: «esta fila ahora la toco yo, los demás esperen / no meterse».

Y aquí aparece pessimistic locking. No hace que su aplicación sea «súper fiable por defecto». Hace que su aplicación sea más disciplinada: si pide a la base de datos que bloquee una fila, está obligado a mantener ese bloqueo de forma corta y justificada. De lo contrario, no está solucionando la concurrencia, está construyendo una «cola electrónica en una oficina de atención al ciudadano», donde una persona tarda 40 minutos en rellenar un formulario y el resto se queda esperando aprendiendo zen.

2. Optimistic locking como «valor por defecto normal»

Optimistic locking a menudo se subestima porque no parece «enérgico». No da la sensación de que «ha cerrado la puerta con llave». Pero en la mayoría de los sistemas empresariales es precisamente el carácter correcto: hay conflictos, pero son raros; las operaciones son cortas; el reintento suele ser barato; y no queremos bloquear a los demás «por si acaso».

Para recordar sobre qué base estamos, veamos un InventoryItem simplificado. Aquí nos interesan tres campos: disponible, reservado y versión.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;

@Entity // Entidad JPA: el estado vive en una fila de la tabla, no solo en la memoria de la JVM
public class InventoryItem {

    @Id // Clave primaria de la fila que se actualizará concurrentemente
    private Long id;

    // Cantidades de inventario: son las que movemos al reservar
    private int availableQty;
    private int reservedQty;

    @Version // Versión para optimistic locking (Hibernate la añadirá al WHERE en el UPDATE)
    private long version;
}

El flujo optimista del servicio también parece muy «normal»: leer, verificar el invariante, modificar los campos. Sin mágicos save() a cada paso: dirty checking y flush harán su trabajo.

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional // Importante: el conflicto por @Version normalmente se manifiesta en el flush/commit de esta transacción
public void reserveOptimistically(EntityManager em, Long itemId, int qty) {
    // Leemos la entidad sin bloqueo: los competidores pueden leer lo mismo al mismo tiempo
    InventoryItem item = em.find(InventoryItem.class, itemId);

    // Invariante: no se puede reservar más de lo disponible
    if (item.getAvailableQty() < qty) {
        throw new IllegalStateException("Stock insuficiente");
    }

    // Modificamos los campos — Hibernate registrará los cambios en el flush/commit (dirty checking)
    item.setAvailableQty(item.getAvailableQty() - qty);
    item.setReservedQty(item.getReservedQty() + qty);
}

El punto clave: el conflicto no se detecta en el momento de setAvailableQty(), sino normalmente cerca del flush/commit, cuando Hibernate envía el UPDATE ... WHERE id = ? AND version = ?. Si se actualizan 0 filas, significa que la versión ya no es la misma: alguien se adelantó.

¿Por qué esto es bueno? Porque mientras no haya conflictos, nadie espera a nadie. El rendimiento es alto, las transacciones son cortas, la base de datos no «mantiene la puerta cerrada».

3. Cuando el optimistic «enferma»

La transición a pessimistic locking rara vez comienza con la frase «me encantan los bloqueos». Normalmente comienza con algo más realista: «tenemos una fila caliente en la base de datos y el sistema empieza a comportarse de forma extraña bajo carga». Es decir, no tenemos una «concurrencia» abstracta, sino un dolor concreto: muchos conflictos, muchos reintentos, muchos nervios.

Imaginemos un producto exitoso: un «cable USB‑C que no se rompe». En inventory_item hay una sola fila para él. Y en el pico de ventas, docenas de transacciones intentan hacer lo mismo: restar availableQty, añadir reservedQty. En el enfoque optimista esto se convierte en una carrera:

  • todas casi al mismo tiempo leen availableQty = 10;
  • todas casi al mismo tiempo deciden «me alcanza»;
  • en el commit, parte de las transacciones ganan, parte obtienen conflicto;
  • las perdedoras deben reintentar (si elegimos retry), lo que implica repetir SELECT, repetir cálculos, repetir tiempo en transacción.

El reintento en sí no es grave mientras sea raro. Pero cuando el conflicto se convierte en la norma, el reintento se transforma en un «eterno retorno». Se gastan recursos no en el trabajo de negocio, sino en repetir un mismo intento fallido.

Para visualizarlo como ingeniero, es útil dibujar un pequeño diagrama temporal. Aquí T1 y T2 son dos transacciones concurrentes, ambas intentando reservar el stock restante.

sequenceDiagram
    participant T1 as Tx-1
    participant DB as PostgreSQL
    participant T2 as Tx-2

    T1->>DB: "SELECT inventory_item(id=42)"
    T2->>DB: "SELECT inventory_item(id=42)"
    T1->>DB: "UPDATE ... WHERE id=42 AND version=7"
    DB-->>T1: "OK (1 fila actualizada)"
    T2->>DB: "UPDATE ... WHERE id=42 AND version=7"
    DB-->>T2: "0 filas actualizadas -> OptimisticLockException"

En el mundo optimista esto es normal: el conflicto se detecta y se maneja. Pero si hay miles de conflictos de este tipo, surge la pregunta: ¿quizás nos conviene más hacer que el segundo ni siquiera inicie la escritura hasta que el primero termine?

Y entonces formulamos honestamente por primera vez las condiciones bajo las cuales el pessimistic locking empieza a parecer razonable. En resumen, es una combinación de:

    <> los conflictos son muchos y no son una anomalía rara, sino un estado constante;
  • el reintento es realmente costoso (en CPU/SQL/efectos secundarios/tiempo);
  • y podemos mantener el bloqueo durante un tiempo muy corto, es decir, la sección crítica es pequeña.

4. Pessimistic locking en JPA: bloqueo en la base de datos

La palabra "locking" en el mundo Java a menudo se asocia con synchronized y ReentrantLock. Es una mala asociación para el tema de ORM. Pessimistic locking en JPA/Hibernate es una consulta a la base de datos, no un bloqueo de objeto en memoria.

Es decir, no decimos: «no toquen mi InventoryItem item en el heap». Decimos a la base de datos: «por favor, bloquea la fila de la tabla inventory_item hasta el final de mi transacción, porque voy a trabajar con ella».

En JPA esto se ve muy cotidiano: simplemente añadimos LockModeType al leer:

import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;

public InventoryItem lockForUpdate(EntityManager em, Long itemId) {
    // Pedimos a la base de datos que bloquee la fila hasta el final de la transacción actual
    // (en PostgreSQL normalmente se traduce en SELECT ... FOR UPDATE)
    return em.find(InventoryItem.class, itemId, LockModeType.PESSIMISTIC_WRITE);
}

Si activa el SQL-trace, en PostgreSQL generalmente verá algo del tipo SELECT ... FOR UPDATE (las formas pueden variar, pero la idea es la misma: la fila se lee con adquisición de bloqueo).

Importante: el hecho mismo de leer con bloqueo ya es un compromiso. Gana en previsibilidad para una transacción, pero paga el precio de que otras transacciones ahora o esperan, o reciben un error, o evitan la fila (depende de la política de espera; hablaremos de ello por separado).

Si quiere una frase para recordar, que sea esta: optimistic locking es «verificar al final», pessimistic locking es «tomar el derecho a trabajar con la fila por adelantado».

5. El precio del pessimistic locking: esperas y deadlock

La idea misma del pessimistic locking suena muy tentadora, especialmente después de unos cuantos conflictos optimistas dolorosos. Pero tiene un precio, y ese precio es astuto: no siempre es visible en el entorno de desarrollo, pero se manifiesta magníficamente en producción cuando «todos llegan al mismo tiempo».

Cuando se coloca un pessimistic lock, inevitablemente se crea espera. Si una transacción mantiene un bloqueo sobre una fila, otra o espera, o recibe un rechazo, o salta la fila. Esto afecta inmediatamente a:

  • el tiempo medio de respuesta,
  • los percentiles altos de latencia (esas «raras» peticiones que de repente tardan 5 segundos),
  • el número de transacciones activas,
  • la carga sobre el pool de conexiones,
  • y, en última instancia, el rendimiento del sistema.

Hay otro efecto desagradable: el deadlock. Ocurre cuando dos transacciones adquieren recursos en diferente orden y comienzan a esperarse mutuamente. En el mundo simple de «bloqueamos una sola fila» es raro, pero tan pronto como la operación bloquea dos filas (por ejemplo, reservar dos productos o transferir stocks), el riesgo aumenta drásticamente.

Precisamente por eso el pessimistic locking no debe ser «activar y olvidar». Requiere disciplina: sección crítica corta, número mínimo de filas, orden predecible de adquisición, política de espera clara. De lo contrario, se obtiene un sistema formalmente «protegido contra conflictos», pero que en realidad se ha vuelto muy lento o inestable.

6. Criterios de ingeniería para elegir la estrategia

Nos gustaría una regla simple: «si es importante, pon un bloqueo». Pero, por desgracia, la importancia del escenario de negocio no anula la física de las bases de datos. Es más útil pensar no en «importancia», sino en qué estamos optimizando exactamente: ausencia de conflictos, tiempo de respuesta o rendimiento global.

A continuación no hay una lista de «consejos en el vacío», sino una pequeña tabla para la toma de decisiones. Es útil tenerla en mente cuando se examina un caso de uso concreto (en nuestro proyecto es la reserva de stocks).

Pregunta sobre el escenario Si la respuesta es «sí» ¿Qué sugiere?
¿Los conflictos son raros? Sí Normalmente nos quedamos con optimistic locking: más simple y barato
¿Los conflictos son frecuentes sobre la misma fila («hot row»)? Sí Aparece un candidato para pessimistic, pero solo de forma puntual
¿El reintento es costoso (muchos cálculos, mucho SQL, efectos secundarios)? Sí Pessimistic puede ser más ventajoso para no repetir el trabajo
¿La transacción bajo bloqueo será corta (milisegundos – decenas de milisegundos)? Sí Esta es la condición clave: el bloqueo no destruirá el rendimiento
¿Es necesario bloquear muchas filas o una consulta amplia? Sí Casi siempre una bandera roja: está construyendo una cola sobre media tabla
¿El usuario espera la respuesta «en tiempo real»? Sí Especial cuidado: la espera por el bloqueo puede convertirse en una pesadilla UX

Para afianzar la lógica, presentémosla también en forma de diagrama de flujo. Es intencionadamente tosco: no es teoría académica, sino una práctica de «qué mirar primero».

flowchart TD
    A[¿Hay escritura concurrente?] --> B{¿Conflictos raros?}
    B -->|Sí| C["Optimistic locking: @Version"]
    B -->|No| D{¿Reintento costoso?}
    D -->|No| C
    D -->|Sí| E{¿Podemos hacer una sección crítica corta?}
    E -->|No| C
    E -->|Sí| F[Pessimistic locking puntual]

Aquí hay un punto importante: pessimistic locking no es un «siguiente nivel» que haya que dominar y empezar a aplicar. Es un cambio hacia otro compromiso, y solo está justificado ante señales concretas: alta contención + reintento costoso + unidad de trabajo corta.

7. Caso: reserva de stocks en InventoryItem

Ahora vinculamos la filosofía a nuestro proyecto Commerce Persistence Lab. Tenemos una operación de reserva: formalizamos un pedido, verificamos que hay suficiente producto y transferimos la cantidad de «disponible» a «reservado». A nivel de una sola fila de la base de datos parece extremadamente simple, y precisamente por eso el conflicto en esa fila es muy real.

En el flujo optimista «normal» leemos InventoryItem, hacemos la comprobación, modificamos los campos. En la mayoría de los casos funciona perfectamente. Pero en un «producto caliente» con alta contención obtenemos conflictos optimistas. Si hemos elegido la estrategia de reintento, la lógica comienza a repetirse: leer de nuevo, comprobar de nuevo, intentar de nuevo. Esto puede resultar peor que «esperar su turno».

La variante pesimista en su forma mínima se ve así: en lugar del find() habitual, leemos bajo bloqueo. Observe: no hacemos el código «más complejo desde el punto de vista del negocio». Cambiamos precisamente la coordinación de la concurrencia.

import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import org.springframework.transaction.annotation.Transactional;

@Transactional // El bloqueo se mantendrá hasta el commit/rollback de esta transacción
public void reservePessimistically(EntityManager em, Long itemId, int qty) {
    // Tomamos inmediatamente el bloqueo sobre la fila: los competidores no podrán modificarla en paralelo
    InventoryItem item = em.find(InventoryItem.class, itemId, LockModeType.PESSIMISTIC_WRITE);

    // La comprobación de stock se realiza ya «bajo llave»; de lo contrario el bloqueo pierde sentido
    if (item.getAvailableQty() < qty) {
        throw new IllegalStateException("Stock insuficiente");
    }

    // Unidad de trabajo mínima bajo bloqueo: actualizamos rápido y salimos
    item.setAvailableQty(item.getAvailableQty() - qty);
    item.setReservedQty(item.getReservedQty() + qty);
}

¿En qué se diferencia fundamentalmente del flujo optimista? En que ahora las transacciones competidoras no tienen ninguna posibilidad de «fingir que todo está bien» y obtener un conflicto al final. O esperarán, o obtendrán otro resultado de inmediato; eso ya es cuestión de la política de espera elegida. Pero el sentido ya se ve: el dolor se desplaza del commit al momento de la adquisición de la fila.

Y aquí es importante ser honesto: este código no es «mejor» ni «peor» por sí mismo. Simplemente mueve el punto del dolor. Optimistic hace el dolor «al final» (conflicto en commit), pessimistic «al principio» (espera/error en el bloqueo). Es importante elegir lo que sea más barato para su escenario concreto.

Resultados explícitos de la operación: éxito, falta de stock, ocupado
Cuando empezamos a pensar en términos de bloqueos, es útil dejar de fingir que la operación solo puede terminar con «éxito» o «error 500». La concurrencia añade resultados normales y esperables. Para el caso de reserva conviene hacerlos explícitos: reserva exitosa, stock insuficiente, fila ocupada por otra transacción; y en la rama optimista se añade además el conflicto de versión. Así la capa de servicio deja de adivinar por el stacktrace y comienza a distinguir entre un resultado de negocio y un resultado de concurrencia.

8. Disciplina del pessimistic locking: unidad de trabajo corta

El secreto de un pessimistic locking que funciona es aburrido pero férreo: bajo el bloqueo solo debe vivir una unidad de trabajo muy corta. El sentido no es «hacer lógica tranquilamente», sino adquirir rápidamente la fila, verificar el invariante ya sobre datos protegidos, modificar los campos necesarios y terminar la transacción lo antes posible. Todo lo que no protege ese invariante en concreto —cálculos largos, lecturas adicionales, llamadas externas, espera del usuario— convierte el bloqueo de un seguro en una cola.

De esto se deriva una regla simple. Antes del bloqueo hacemos comprobaciones baratas y preparamos la entrada. Bajo el bloqueo dejamos solo aquello para lo que el bloqueo es necesario: verificar el estado actual de la fila y una actualización corta. Si la operación no cabe en un pasillo tan estrecho, casi con toda seguridad el pessimistic locking costará más de lo que beneficia.

9. Errores típicos al elegir pessimistic locking

Error nº 1: considerar el pessimistic locking como un «optimistic más correcto».
Esa forma de pensar suele terminar con que se ponen bloqueos «a todo lo importante», y luego de repente resulta que el sistema se ha vuelto más lento porque ahora todas las transacciones se esperan unas a otras. Optimistic y pessimistic son dos compromisos diferentes, y la «importancia de los datos» por sí sola no elige la estrategia.

Error nº 2: aplicar pessimistic locking sin medir la frecuencia de conflictos y el coste del reintento.
Si los conflictos son raros, no está resolviendo un problema, sino su propia inquietud. Y la inquietud, como se sabe, no se indexa en PostgreSQL. Primero hay que entender: ¿realmente tiene una hot row?, ¿realmente el optimistic entra en conflicto constantemente?, ¿realmente el reintento es costoso?

Error nº 3: mantener el bloqueo más tiempo del necesario y sorprenderse por las colas.
El fallo más frecuente es adquirir el bloqueo y luego, dentro de la transacción, hacer «un poquito más de lógica»: leer datos adicionales, construir un pequeño informe, llamar a métodos externos, registrar medio mundo. Al final, las transacciones competidoras esperan, aunque el trabajo real con la fila bloqueada ocupa 5 milisegundos. El pessimistic locking solo funciona con una sección crítica corta.

Error nº 4: intentar «arreglar» un límite de transacción malo mediante un bloqueo.
A veces la transacción en la aplicación es simplemente demasiado grande y difusa, y el bloqueo parece «pegar» todo en un único escenario. Pero es autoengaño: no está corrigiendo la arquitectura, la está haciendo más dolorosa. Primero se alinea el límite de la transacción (unit of work), y solo después se añade pessimistic locking de forma puntual si realmente es necesario.

Error nº 5: bloquear más ampliamente de lo necesario y bloquear accidentalmente filas extra.
Pessimistic locking funciona bien como un bisturí: una fila, un recurso concreto, una operación corta. Si empieza a bloquear «todo lo que cubre la consulta» o a bloquear con condiciones amplias, fácilmente convierte el sistema en una cola con tiempo de espera impredecible. Eso ya no es protección contra la contención, es un freno casero.

1
Tarea
Hibernate deep-dive, nivel 19, lección 0
Bloqueada
Conflicto optimista en fila caliente
Conflicto optimista en fila caliente
1
Tarea
Hibernate deep-dive, nivel 19, lección 0
Bloqueada
Bloqueo de escritura básico para fila caliente
Bloqueo de escritura básico para fila caliente
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION