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',
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:
SELECT id FROM jobs WHERE status = 'pending' ORDER BY id LIMIT 1;
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;
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.
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
jobsvinculada 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
SELECTy elUPDATE, 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ñadeFOR UPDATEy la fila queda bloqueada hasta que termina la transacción. AñadeSKIP LOCKEDy 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 UPDATEbloquea las filas seleccionadas hasta elCOMMIT/ROLLBACK.SKIP LOCKEDexcluye las filas ya bloqueadas por otras transacciones, sin esperar.LIMIT 1coge exactamente una tarea (también puedes coger un lote, ver más abajo).ORDER BY iddefine 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 instanteid = 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;RETURNINGentrega de inmediato al worker las tareas reclamadas:idyorder_idpara procesar. El bloqueo dura solo lo que esta transacción; tras elCOMMITlas filas ya están marcadas comoprocessing, 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 LOCKEDsigue 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 LOCKEDno tiene sentido sin una transacción. El bloqueo vive exactamente hasta que termina la transacción. EjecutaSELECT ... FOR UPDATEen modo autocommit y la fila se libera de inmediato, de modo que un worker paralelo la coge. Mantén la reclamación y la marcaprocessingen una sola transacción (o en una única sentencia con CTE).BEGINabierto todo ese tiempo. Reclama la tarea, cámbiala aprocessing, hazCOMMITy solo entonces ejecuta el trabajo lento. De lo contrario, una sola transacción atascada paraliza elVACUUMy acumula bloqueos.SKIP LOCKEDdebilita 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.SKIP LOCKEDexiste en MySQL 8.0+ (la sintaxisFOR UPDATE SKIP LOCKEDes 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.