sqlpostgresqlconcurrencylocking

Colas de tareas en SQL: SELECT ... FOR UPDATE SKIP LOCKED

Cómo hacer que una decena de workers extraigan tareas de una tabla de cola sin bloquearse entre sí, usando FOR UPDATE SKIP LOCKED en PostgreSQL.

4 min de lecturaReferencesql · postgresql · concurrency · locking · job-queue

A veces no quieres arrastrar Kafka o RabbitMQ a un proyecto solo para ejecutar una sencilla cola en segundo plano: enviar un correo, recalcular un informe, generar un PDF. Si ya tienes PostgreSQL en marcha, él mismo puede ser la cola, y hacerlo de forma fiable. Todo el truco se reduce a una sola cláusula: FOR UPDATE SKIP LOCKED. Permite que diez workers extraigan tareas de una única tabla en paralelo sin pisarse entre sí y sin acumularse detrás de los bloqueos.

Veámoslo con una tabla jobs vinculada a pedidos.

El problema: dos workers cogen la misma tarea

Empecemos con una tabla de cola. Cada fila es una tarea, por ejemplo «enviar la confirmación del pedido».

CREATE TABLE jobs (
  id          bigserial PRIMARY KEY,
  order_id    bigint      NOT NULL,
  status      text        NOT NULL DEFAULT 'pending',  -- pending | processing | done | failed
  run_after   timestamptz NOT NULL DEFAULT now(),
  attempts    int         NOT NULL DEFAULT 0,
  locked_by   text,
  created_at  timestamptz NOT NULL DEFAULT now()
);

INSERT INTO jobs (order_id) VALUES (1001), (1002), (1003);

Un worker ingenuo hace esto:

-- broken with multiple workers
SELECT id FROM jobs WHERE status = 'pending' ORDER BY id LIMIT 1;
-- ...then a separate UPDATE ... SET status = 'processing'

Entre el SELECT y el UPDATE, un segundo worker lee la misma fila. Resultado: dos workers procesan el pedido 1001 y el correo se envía dos veces. Esta es la clásica condición de carrera.

La solución: FOR UPDATE SKIP LOCKED

PostgreSQL puede tomar un bloqueo a nivel de fila dentro del propio SELECT. Añade FOR UPDATE y la fila queda bloqueada hasta que termina la transacción. Añade SKIP LOCKED y un worker competidor no esperará por una fila bloqueada: simplemente la salta y coge la siguiente libre.

BEGIN;

SELECT id, order_id
FROM jobs
WHERE status = 'pending'
  AND run_after <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;

-- the worker sees, say, id = 1 and claims it
UPDATE jobs
SET status = 'processing', locked_by = 'worker-7', attempts = attempts + 1
WHERE id = 1;

COMMIT;

Paso a paso:

  • FOR UPDATE bloquea las filas seleccionadas hasta el COMMIT/ROLLBACK.
  • SKIP LOCKED excluye las filas ya bloqueadas por otras transacciones, sin esperar.
  • LIMIT 1 coge exactamente una tarea (también puedes coger un lote, ver más abajo).
  • ORDER BY id define el orden; sin él la cola se vuelve impredecible.

Ejecuta este bloque en dos sesiones a la vez: la primera obtiene id = 1, la segunda obtiene al instante id = 2. Nadie espera por nadie.

Reclamar un lote en una sola sentencia

La transacción «SELECT y luego UPDATE» funciona, pero es más limpio hacerlo en una sola sentencia con un CTE, y además puedes reclamar varias tareas a la vez para un worker por lotes:

WITH picked AS (
  SELECT id
  FROM jobs
  WHERE status = 'pending'
    AND run_after <= now()
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
UPDATE jobs j
SET status = 'processing',
    locked_by = 'worker-7',
    attempts  = j.attempts + 1
FROM picked
WHERE j.id = picked.id
RETURNING j.id, j.order_id;

RETURNING entrega de inmediato al worker las tareas reclamadas: id y order_id para procesar. El bloqueo dura solo lo que esta transacción; tras el COMMIT las filas ya están marcadas como processing, así que otros workers las saltan de todos modos en el siguiente sondeo.

Cuando el trabajo está hecho, cierra la tarea:

UPDATE jobs SET status = 'done', locked_by = NULL WHERE id = 1;

Y si una tarea falla, vuelve a encolarla con un retardo para que se reintente más tarde:

UPDATE jobs
SET status = 'pending',
    run_after = now() + interval '30 seconds'
WHERE id = 1 AND attempts < 5;

Índices y rendimiento

Sin un índice, SKIP LOCKED sigue escaneando la tabla y choca una y otra vez con filas bloqueadas. Añade un índice parcial con la forma exacta de la consulta de reclamación:

CREATE INDEX idx_jobs_pending
ON jobs (run_after, id)
WHERE status = 'pending';

El índice cubre el filtro (status = 'pending', run_after <= now()) y la ordenación (id), y al ser parcial se mantiene pequeño: las tareas terminadas nunca entran en él.

Errores comunes

  • SKIP LOCKED no tiene sentido sin una transacción. El bloqueo vive exactamente hasta que termina la transacción. Ejecuta SELECT ... FOR UPDATE en modo autocommit y la fila se libera de inmediato, de modo que un worker paralelo la coge. Mantén la reclamación y la marca processing en una sola transacción (o en una única sentencia con CTE).
  • No mantengas la transacción abierta durante el trabajo en sí. Si el worker tarda un minuto generando un PDF, no dejes el BEGIN abierto todo ese tiempo. Reclama la tarea, cámbiala a processing, haz COMMIT y solo entonces ejecuta el trabajo lento. De lo contrario, una sola transacción atascada paraliza el VACUUM y acumula bloqueos.
  • SKIP LOCKED debilita el ordenamiento. Si la primera tarea de la fila está bloqueada, un worker coge la siguiente. No se garantiza un FIFO estricto: está bien para una cola de tareas, pero no confíes en un orden exacto.
  • MySQL y otros motores. SKIP LOCKED existe en MySQL 8.0+ (la sintaxis FOR UPDATE SKIP LOCKED es casi idéntica) y en Oracle. ClickHouse, en cambio, es columnar y no está pensado para colas con bloqueo de filas: para una cola OLTP recurre a PostgreSQL o MySQL, no a un motor analítico.

Con esto basta para ejecutar una cola fiable que maneje cientos de tareas por segundo sin un broker aparte. Cuando llegues a ese techo, entonces empieza a pensar en Kafka.

Practica con ejercicios reales

Resuelve ejercicios en el entrenador de SQL con corrección instantánea y pistas.

Abrir el entrenador