CodeGym /Cursos /Hibernate deep-dive /Lock timeout: NOWAIT y SKIP LOCKED

Lock timeout: NOWAIT y SKIP LOCKED

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

1. Espera de bloqueo en una fila ocupada

Cuando ya está claro qué lock mode estás solicitando a la base de datos, surge inmediatamente la siguiente pregunta práctica: ¿qué sucede si la fila necesaria ya está ocupada?

Si el pessimistic locking es una petición a la base de datos de «dame exclusividad», entonces una fila ocupada se puede imaginar como el único cajero en una tienda al que ya se ha acercado otro cliente y está contando monedas muy concentrado. Tú, como segundo cliente, puedes esperar cortésmente. Puedes darte la vuelta e irte («no voy a esperar»). O puedes ir a otro cajero, si en tu mundo existe «otro cajero» y eso no contradice el propósito de la operación. En la base de datos es más o menos igual, solo que en lugar del cajero tienes una fila concreta (row), en lugar de la cola tienes espera de bloqueo, y en lugar de «darse la vuelta e irte» tienes una excepción/error de captura del lock.

Para que sea más fácil tenerlo en mente, veamos una secuencia simplificada de dos transacciones. La primera transacción T1 captura el bloqueo de la fila inventory_item(id=42) y lo mantiene (por ejemplo, porque la lógica de negocio dentro de la transacción es demasiado larga o simplemente «así se ha dado»). La segunda transacción T2 intenta capturar la misma fila con PESSIMISTIC_WRITE.

sequenceDiagram
    participant T1 as "Tx #1"
    participant DB as PostgreSQL
    participant T2 as "Tx #2"

    T1->>DB: "SELECT ... FOR UPDATE (id=42)"
    DB-->>T1: lock granted
    Note over T1: mantenemos la transacción abierta
y el bloqueo activo T2->>DB: "SELECT ... FOR UPDATE (id=42)" Note over DB: fila ya bloqueada por T1 alt política = WAIT DB-->>T2: esperamos liberación del lock else política = NOWAIT DB-->>T2: "error 'lock not available'" else política = TIMEOUT DB-->>T2: esperamos hasta N ms, luego error end

Aquí no importa la belleza del diagrama, sino el costo práctico de la espera. En un servicio Spring Boot típico, esperar significa que estás reteniendo el hilo de procesamiento de la solicitud, la conexión JDBC del pool y la propia transacción. Es decir, una solicitud «colgada» no es solo «el usuario esperará», sino un consumo real de recursos que reduce el rendimiento y puede causar una reacción en cadena: más esperas → menos hilos libres → más timeouts en la entrada → aún más esperas. Este efecto a menudo se ve como «a veces todo va lento», aunque la raíz del problema es una fila caliente y una política de espera desafortunada.

2. Políticas de espera: WAIT, TIMEOUT, NOWAIT, SKIP LOCKED

Cuando un desarrollador por primera vez utiliza PESSIMISTIC_WRITE, tiene la tentación de pensar: «bueno, ya estoy protegido, los datos son correctos». Pero en la práctica solo has elegido la mitad de la solución. La otra mitad es el comportamiento en conflicto, cuando el bloqueo ya está ocupado. En ese momento es importante no «que funcione de alguna manera», sino que el sistema se comporte de manera predecible: que responda rápidamente «ocupado», o que espere poco tiempo, o que seleccione correctamente otras filas sin esperar. Para cada opción hay su costo y su escenario de idoneidad.

Es conveniente resumir las opciones posibles en una tabla. Hablaremos en términos de jakarta.persistence.lock.timeout, porque es la forma más directa de expresar la política de espera en nuestro Hibernate + PostgreSQL baseline.

Política de comportamiento Cómo lo pensamos humanamente Hint jakarta.persistence.lock.timeout Qué hace la BD al final
Esperar indefinidamente «Bueno, esperaré lo que sea necesario» -1 (normalmente por defecto) espera la liberación del lock hasta que la consiga o hasta que la consulta sea interrumpida por timeouts externos
Esperar limitadamente «Esperaré un poco, pero no eternamente» 200, 500, 2000 (ms) espera hasta el tiempo indicado, luego devuelve un error de captura del lock
No esperar (NOWAIT) «Si está ocupado, dímelo de inmediato, decidiré luego» 0 no espera en absoluto, error/excepción inmediata
Omitir ocupadas (SKIP LOCKED) «Si está ocupado, toma otra, eso es normal» -2 (extensión de Hibernate) no espera, devuelve solo las filas no bloqueadas

Dos aclaraciones para no crear mitología. En primer lugar, jakarta.persistence.lock.timeout no es un «temporizador en Java» que duerme con Thread.sleep(). Es una indicación a Hibernate de cómo pedirle a la base de datos que se comporte al intentar capturar un lock. En segundo lugar, SKIP LOCKED no es un estándar de JPA, sino un acuerdo de ORM/dialecto específico. En nuestro curso usamos Hibernate 7.2 + PostgreSQL, y aquí el valor -2 tiene sentido práctico.

3. Configuración de lock timeout en Hibernate/JPA

A continuación habrá muchas conversaciones sobre NOWAIT y SKIP LOCKED, pero primero hay que aprender técnicamente a «transmitir» la política de espera a Hibernate. La buena noticia es que no necesitas ninguna API mágica: basta con lo que ya existe en EntityManager. La mala noticia es que si olvidas el hint, obtendrás el comportamiento por defecto (a menudo «esperar indefinidamente»), y luego te sorprenderás de por qué tu servicio a veces «se cuelga». Por lo tanto, es mejor fijar de inmediato algunos patrones de código normales en nuestro Commerce Persistence Lab.

La forma más simple es usar la sobrecarga de EntityManager.find(...) que acepta LockModeType y un Map con propiedades/hints. Es útil para el caso puntual de «conozco el id y quiero exactamente esa fila».

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

import java.util.Map;

public class InventoryLockingSupport {

    public static Map<String, Object> lockTimeoutMs(int timeoutMs) {
        // Importante: la clave es una cadena, es muy fácil equivocarse en ella, por lo que es mejor no dispersarla por el código.
        return Map.of("jakarta.persistence.lock.timeout", timeoutMs);
    }

    public static void lockRow(EntityManager em, Long itemId, int timeoutMs) {
        // Capturamos un pessimistic lock en la fila y establecemos la política de espera mediante el hint.
        em.find(InventoryItem.class, itemId,
                LockModeType.PESSIMISTIC_WRITE,
                lockTimeoutMs(timeoutMs));
    }
}

Observa el detalle: no estamos inventando una envoltura «bonita» alrededor del lock timeout por estética, sino que simplemente reducimos la probabilidad de escribir mal la clave de cadena "jakarta.persistence.lock.timeout". En un proyecto real, un error tipográfico en esta cadena es un bug silencioso del tipo «en producción a veces todo se cuelga, pero localmente no se reproduce» (clásico del género).

La segunda vía es una consulta JPQL, cuando el lock no se necesita por id sino por una condición. Por ejemplo, en inventario a menudo queremos encontrar un InventoryItem por productId (porque en el mundo real puedes tener un solo InventoryItem por producto y piensas en términos de producto).

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

public class InventoryQueries {

    public static InventoryItem findForUpdate(EntityManager em, Long productId, Object lockHint) {
        // Buscamos la fila necesaria según la condición, luego pedimos a la BD que ponga FOR UPDATE (a través del lock mode).
        return em.createQuery("""
                select i from InventoryItem i
                where i.product.id = :productId
                """, InventoryItem.class)
            .setParameter("productId", productId) // Parámetro de consulta para no concatenar SQL con cadenas.
            .setLockMode(LockModeType.PESSIMISTIC_WRITE) // Activamos pessimistic locking en la fila seleccionada.
            .setHint("jakarta.persistence.lock.timeout", lockHint) // Política de espera: -1, 0, N ms o -2 (SKIP LOCKED).
            .getSingleResult();
    }
}

Aquí pasamos lockHint como Object para recordarnos: formalmente es solo un valor en un map, e Hibernate lo interpreta según sus reglas. Para nosotros son importantes cuatro valores concretos: -1, 0, un número positivo en milisegundos, y -2 para SKIP LOCKED.

4. NOWAIT: fail-fast en fila ocupada

NOWAIT es, en esencia, la honestidad del sistema. En lugar de obligar a la segunda solicitud a esperar en la cola, decimos: «si la fila está ocupada, no esperes, simplemente dime que el recurso no está disponible ahora». Este modo suele ser ideal para peticiones HTTP de usuario, donde es mejor obtener una respuesta rápida y clara (aunque sea negativa) que quedarse colgado varios segundos para luego igualmente caer por timeout en el API gateway o frontend. Al fin y al cabo, el usuario no tiene por qué sufrir porque dos transacciones discuten por una misma fila.

En JPA/Hibernate NOWAIT normalmente se expresa como jakarta.persistence.lock.timeout = 0. Desde el punto de vista de PostgreSQL, esto a menudo produce un SQL similar a FOR UPDATE NOWAIT. Pero lo importante no es la forma del SQL, sino el comportamiento: si la fila está ocupada, obtienes una excepción de inmediato.

A continuación, un ejemplo mínimo de «obtener un bloqueo NOWAIT». Es importante que esto ocurra dentro de una transacción, de lo contrario el lock no tiene sentido.

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

import java.util.Map;

public class InventoryNowaitLoader {

    public static InventoryItem loadNowait(EntityManager em, Long itemId) {
        try {
            return em.find(InventoryItem.class, itemId,
                    LockModeType.PESSIMISTIC_WRITE,
                    // 0 ms = NOWAIT: no esperamos en absoluto, obtenemos inmediatamente una excepción si la fila está ocupada.
                    Map.of("jakarta.persistence.lock.timeout", 0));
        } catch (LockTimeoutException ex) {
            // Esto no es «todo se ha roto», es un resultado esperado en modo NOWAIT: la fila está ocupada por otra transacción.
            throw new IllegalStateException("Inventory row is busy (NOWAIT)", ex);
        }
    }
}

En una aplicación Spring real, rara vez lanzarás IllegalStateException tal cual. Más bien lo convertirás en una excepción de dominio como InventoryBusyException o en un resultado claro «recurso ocupado». Pero el punto clave ya está: NOWAIT convierte una «congelación» en un resultado explícito.

Aquí hay un sutil efecto psicológico: cuando ves por primera vez una excepción debido a un lock, parece que el sistema «se rompe». En realidad, se está comportando correctamente. Simplemente debes pensar de antemano qué significa LOCK_BUSY en tu escenario de negocio. Por ejemplo, «inténtalo de nuevo en un segundo» o «el producto se está reservando ahora, inténtalo más tarde».

5. Lock timeout positivo: espera limitada

A veces NOWAIT es demasiado estricto. Imagina la situación: el conflicto ocurre, pero rara vez y es muy corto. Por ejemplo, una transacción mantiene el lock 50–100 ms (verifica rápidamente el stock y actualiza campos), y la segunda transacción casi siempre podría simplemente esperar un poco y completarse con éxito. En tales escenarios, es razonable establecer un lock timeout positivo: permitimos la espera, pero la limitamos estrictamente. Es como «esperaré en la cola uno o dos minutos, pero si la cola llega hasta la ciudad vecina, paso».

Desde un punto de vista técnico, sigue siendo el mismo hint jakarta.persistence.lock.timeout, solo que ahora el valor es la cantidad de milisegundos. En nuestro curso usamos a menudo números pequeños como 200–500 ms, porque muestran bien la diferencia de comportamiento: hay espera, pero no congelamientos «eternos».

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

import java.util.Map;

public class InventoryTimeoutLoader {

    public static InventoryItem loadWithTimeout(EntityManager em, Long itemId, int timeoutMs) {
        // timeoutMs son exactamente milisegundos: la consulta esperará como máximo este tiempo.
        return em.find(InventoryItem.class, itemId,
                LockModeType.PESSIMISTIC_WRITE,
                Map.of("jakarta.persistence.lock.timeout", timeoutMs));
    }
}

Lo que verás en el comportamiento. Si la fila está libre, todo como siempre. Si la fila está ocupada, la consulta esperará hasta timeoutMs y luego fallará con una excepción (a menudo el mismo LockTimeoutException o sus envolturas). Para la aplicación, esto significa: no «soportamos indefinidamente», liberamos rápidamente el hilo y la conexión, sin convertir la situación en una avalancha.

También hay un matiz práctico importante: el timeout debe estar coordinado con los timeouts externos. Si tu timeout de infraestructura HTTP (por ejemplo, a nivel de load balancer) es de 1 segundo y estableces un lock timeout de 5 segundos, entonces garantizas una mala experiencia de usuario: el usuario ya verá un error «gateway timeout», mientras que tu servicio seguirá esperando el lock y manteniendo recursos. Por lo tanto, «pongamos 10 segundos para estar seguros» casi siempre es una mala idea.

6. SKIP LOCKED: selección de filas libres

SKIP LOCKED es una herramienta muy útil, pero gusta a los desarrolladores solo hasta que la usan en un escenario incorrecto. Su propósito no es «evitar siempre las esperas», sino implementar una clase especial de tareas: procesar un conjunto de filas con workers concurrentes, donde se puede omitir una fila ocupada y tomar otra. Es un patrón típico de cola de tareas en la base de datos, procesamiento por lotes, distribución de trabajo entre varios hilos sin conflictos.

En Commerce Persistence Lab podemos mostrar esto con un ejemplo de «tomemos varios InventoryItem adecuados y procesémoslos», no con «reservemos un producto concreto». Y esto es fundamental: si necesitas reservar exactamente el producto X, no puedes omitirlo — entonces SKIP LOCKED no es adecuado por significado.

En Hibernate (en el contexto de nuestro curso) SKIP LOCKED se expresa como jakarta.persistence.lock.timeout = -2 junto con PESSIMISTIC_WRITE.

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

import java.util.List;

public class InventorySkipLockedQuery {

    public static List<InventoryItem> pickSomeRows(EntityManager em) {
        // Tomamos un lote de filas para procesar con varios workers.
        // Importante: SKIP LOCKED cambia el significado del resultado — «las filas ocupadas simplemente no entran en la selección».
        return em.createQuery("""
                select i from InventoryItem i
                where i.availableQty > i.reservedQty
                order by i.id
                """, InventoryItem.class)
            .setLockMode(LockModeType.PESSIMISTIC_WRITE) // Pedimos que ponga FOR UPDATE en las filas seleccionadas.
            .setHint("jakarta.persistence.lock.timeout", -2) // -2 = SKIP LOCKED (extensión de Hibernate para PostgreSQL).
            .setMaxResults(5) // Tamaño del lote: tomamos pocas y liberamos rápidamente los bloqueos.
            .getResultList();
    }
}

En qué hay que fijarse. Primero, aquí es obligatorio tener order by. No es «por estética», sino para que el comportamiento sea reproducible: si ejecutas los workers varias veces, toman las filas en el mismo orden (en la medida de lo posible). Segundo, setMaxResults(5) subraya la idea de lote: tomamos un número limitado de filas y salimos rápidamente, liberando los locks. Tercero, el resultado puede ser menor que cinco, porque parte de las filas elegibles podrían estar bloqueadas por otras transacciones y simplemente «caerse» de la selección.

Si hablamos de manera directa, SKIP LOCKED no es un «NOWAIT mejorado». Es otro tipo de comportamiento: en lugar de un error, obtienes un resultado diferente de la consulta. Y si tu escenario de negocio no está preparado para que «la fila necesaria haya desaparecido de la selección», acabas de introducir un bug muy sutil.

Deadlock-thinking: orden de captura — prevención

La conversación sobre la espera de bloqueo casi inevitablemente lleva a la palabra «deadlock». Y aquí es importante no desviarse hacia un curso académico sobre aislamiento de transacciones, sino captar la idea práctica: el deadlock a menudo nace cuando dos transacciones capturan varios recursos en diferente orden. Es como la situación «tú sostienes la puerta y esperas la llave, yo sostengo la llave y espero la puerta»: ambos tienen razón, pero nadie se mueve.

Simplificado: Tx1 ha bloqueado la fila A y quiere B; Tx2 ha bloqueado la fila B y quiere A. Ambas esperan, y la base de datos tarde o temprano se ve obligada a «matar» una transacción.

flowchart LR
    T1[Tx1] -->|lock A| A[(Row A)]
    T2[Tx2] -->|lock B| B[(Row B)]
    T1 -->|wants B| B
    T2 -->|wants A| A

Por qué es importante precisamente en esta lección sobre timeout/nowait/skip locked. Porque los timeouts ayudan a no congelarse indefinidamente, pero no garantizan la ausencia de deadlock. La prevención del deadlock suele ser más simple: si necesitas capturar varias filas, hazlo en el mismo orden en todos los escenarios. La forma más simple y didáctica es ordenar por id.

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

public class LockOrderExample {

    public static void lockTwo(EntityManager em, Long leftId, Long rightId) {
        // Regla más simple: siempre capturamos los recursos en el mismo orden (por id).
        // Esto reduce la probabilidad de bloqueo mutuo (deadlock) en concurrencia.
        Long first = Math.min(leftId, rightId);
        Long second = Math.max(leftId, rightId);

        em.find(InventoryItem.class, first, LockModeType.PESSIMISTIC_WRITE); // Primero el id menor.
        em.find(InventoryItem.class, second, LockModeType.PESSIMISTIC_WRITE); // Luego el id mayor.
    }
}

Sí, parece cosa de niños, comparar dos números. Pero precisamente esa «cosa de niños» a menudo salva la producción de incidentes raros pero muy desagradables de deadlock. Aquí no hay magia: solo una regla uniforme de orden de captura de recursos.

7. Observabilidad: verificamos NOWAIT/timeout/SKIP LOCKED

Cuando has añadido setHint("jakarta.persistence.lock.timeout", 0) en el código, es muy tentador exhalar y dar la tarea por resuelta. Pero Hibernate es un sistema donde «parece» y «en realidad» a veces divergen. Por lo tanto, el hábito correcto es confirmar el comportamiento mediante la observabilidad. En el curso ya tenemos un perfil de traza SQL, y es ideal: deberías ver en SQL modificadores explícitos (NOWAIT, SKIP LOCKED) o, al menos, ver claramente FOR UPDATE y luego observar el comportamiento real por tiempo y excepciones.

En el caso más simple verás algo como:

select ... from inventory_item ... for update nowait

o:

select ... from inventory_item ... for update skip locked

Incluso si la forma concreta del SQL en los logs difiere (los dialectos y el formato pueden sorprender), el comportamiento se delatará. Para NOWAIT será una excepción casi instantánea cuando la fila esté ocupada. Para un timeout positivo será una demora aproximadamente por el tiempo especificado y luego un error. Para SKIP LOCKED será ausencia de espera y un conjunto de resultados «truncado» (menos filas de las que esperabas lógicamente).

Y otro punto que a menudo se pasa por alto: tu código de negocio debe ser capaz de distinguir dos resultados similares pero fundamentalmente diferentes. «No hay suficiente stock» es una situación de negocio normal, cuando la fila está libre pero availableQty < qty. «La fila está ocupada» es una situación de concurrencia, y requiere otra reacción. Si los mezclas en un único RuntimeException("algo salió mal"), te estás cerrando la posibilidad de explicar al usuario qué está pasando.

8. Errores típicos

Error nº1: dejar el comportamiento por defecto y obtener «espera eterna».
Si utilizas PESSIMISTIC_WRITE pero no estableces ni NOWAIT ni timeout, cuando la fila esté ocupada a menudo obtendrás una espera hasta el final. En una aplicación web, esto se convierte en solicitudes colgadas, hilos ocupados, conexiones JDBC ocupadas e incómodos timeouts a nivel HTTP. El momento más insidioso es que en un entorno local puede casi no manifestarse, pero bajo carga explota.

Error nº2: usar NOWAIT y considerar la excepción como «error del sistema» en lugar de un resultado normal.
NOWAIT está diseñado precisamente para devolver rápidamente un resultado «recurso ocupado». Si has activado NOWAIT, estás obligado a pensar cómo manejar este resultado. O lo conviertes en una excepción de dominio a nivel de servicio, o devuelves un resultado comprensible. Si simplemente registras el stacktrace como «ERROR, todo perdido», obtienes ruido y pánico innecesarios donde el sistema se está comportando correctamente.

Error nº3: aplicar SKIP LOCKED en un escenario puntual de «necesito reservar exactamente este producto».
SKIP LOCKED es «elige otras filas en lugar de esperar». Si has reservado el producto A, y la segunda solicitud necesita precisamente el producto A, entonces «omitir A y tomar B» ya es otro escenario de negocio. Como resultado, o «silenciosamente» no realizas la operación necesaria, o haces una incorrecta. SKIP LOCKED es bueno para workers, colas y procesamiento por lotes, pero peligroso en operaciones impulsadas por el usuario con un id específico.

Error nº4: establecer un lock timeout grande que entre en conflicto con los timeouts HTTP.
El lock timeout debe estar coordinado con cuánto tiempo tiene derecho a vivir tu solicitud en el sistema. Si el gateway corta la solicitud a los 1 segundo, y has puesto un lock timeout de 10 segundos, entonces tu servicio continuará esperando y manteniendo recursos «hacia ninguna parte». Estas situaciones son especialmente desagradables: el usuario ya ha recibido un error, y tú aún estás sobrecargando el sistema con espera.

Error nº5: pensar que el timeout «resuelve el deadlock».
El timeout limita la espera, pero no elimina la causa del bloqueo mutuo. El deadlock se trata más con disciplina: el mismo orden de captura de filas y minimizar el tiempo durante el cual se mantiene el lock. Si no tienes orden, los timeouts solo enmascararán el problema y a veces lo convertirán en excepciones intermitentes.

1
Tarea
Hibernate deep-dive, nivel 19, lección 2
Bloqueada
Fail-fast captura de fila mediante `NOWAIT`
Fail-fast captura de fila mediante `NOWAIT`
1
Tarea
Hibernate deep-dive, nivel 19, lección 2
Bloqueada
Obtención de un lote de filas mediante `SKIP LOCKED`
Obtención de un lote de filas mediante `SKIP LOCKED`
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION