Aprende el patrón cron + base de datos para ejecutar trabajos programados en segundo plano con reintentos, bloqueo e idempotencia, sin desplegar un sistema de colas completo.

La mayoría de las apps necesitan que ocurra trabajo más tarde o según un horario: enviar emails de seguimiento, ejecutar una comprobación de facturación nocturna, limpiar registros antiguos, reconstruir un informe o refrescar una caché.
Al principio es tentador añadir un sistema de colas completo porque parece la manera “correcta” de hacer trabajos en segundo plano. Pero las colas añaden piezas móviles: otro servicio que ejecutar, monitorizar, desplegar y depurar. Para un equipo pequeño (o un founder en solitario), ese peso extra puede ralentizarte.
La pregunta real es: ¿cómo ejecutar trabajo programado de forma fiable sin levantar más infraestructura?
Un primer intento común es simple: añadir una entrada de cron que golpee un endpoint, y que ese endpoint haga el trabajo. Funciona hasta que deja de hacerlo. Cuando tienes más de un servidor, un deploy en el momento equivocado o un job que tarda más de lo esperado, empiezas a ver fallos confusos.
El trabajo programado suele romperse de formas previsibles:
El patrón cron + base de datos es un punto intermedio. Sigues usando cron para “despertar” según un horario, pero almacenas la intención y el estado del job en la base de datos para que el sistema pueda coordinar, reintentar y registrar lo ocurrido.
Es una buena opción cuando ya tienes una sola base de datos (a menudo PostgreSQL), pocos tipos de jobs y quieres comportamiento predecible con mínima operativa. También encaja de forma natural con apps construidas rápido sobre stacks modernos (por ejemplo, React + Go + PostgreSQL).
No es buena opción cuando necesitas un rendimiento muy alto, jobs de larga duración con streaming de progreso, orden estricto entre muchos tipos de jobs o gran fan-out (miles de subtareas por minuto). En esos casos, una cola real y workers dedicados suelen compensar.
El patrón cron + base de datos ejecuta trabajo en segundo plano según un horario sin levantar todo un sistema de colas. Sigues usando cron (o cualquier scheduler), pero cron no decide qué ejecutar. Solo despierta un worker con frecuencia (una vez por minuto es común). La base de datos decide qué trabajo está pendiente y se asegura de que solo un worker tome cada job.
Piénsalo como una lista compartida en una pizarra. Cron es la persona que entra a la sala cada minuto y pregunta: “¿Alguien necesita hacer algo ahora?”. La base de datos es la pizarra que muestra qué está pendiente, qué ya está tomado y qué está hecho.
Las piezas son sencillas:
Ejemplo: quieres enviar recordatorios de factura cada mañana, refrescar una caché cada 10 minutos y limpiar sesiones antiguas por la noche. En vez de tres comandos de cron separados (cada uno con sus solapamientos y modos de fallo), guardas las entradas de job en un solo lugar. Cron arranca el mismo proceso worker. El worker le pregunta a Postgres: “¿Qué está debido ahora?” y Postgres responde permitiendo que el worker reclame de forma segura exactamente un job a la vez.
Esto escala gradualmente. Puedes empezar con un worker en un servidor. Más tarde, ejecutar cinco workers en varios servidores. El contrato se mantiene: la tabla es el contrato.
El cambio de mentalidad es simple: cron solo da la llamada de atención. La base de datos es el agente de tráfico que decide qué puede ejecutarse, registra lo sucedido y te da un historial claro cuando algo sale mal.
Este patrón funciona mejor cuando tu base de datos se convierte en la fuente de verdad de qué debe ejecutarse, cuándo debe ejecutarse y qué pasó la última vez. El esquema no es sofisticado, pero pequeños detalles (campos de lock y los índices adecuados) marcan la diferencia a medida que crece la carga.
Dos enfoques comunes:
Si esperas depurar fallos a menudo, conserva el historial. Si quieres la configuración más pequeña posible, empieza con una tabla y añade historial después.
Aquí tienes un diseño amigable con PostgreSQL. Si construyes en Go con PostgreSQL, estas columnas encajan bien con structs.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Unos detalles que ahorran problemas después:
send_invoice_emails).jsonb para poder evolucionarlo sin migraciones.Sin índices, los workers acaban escaneando demasiado. Empieza con:
(status, run_at)(locked_until)queued y failed)Estos mantienen la consulta “encontrar el siguiente job ejecutable” rápida incluso cuando la tabla crece.
El objetivo es simple: muchos workers pueden correr, pero solo uno debería coger un job específico. Si dos workers procesan la misma fila obtienes correos duplicados, cargos dobles o datos caóticos.
Un enfoque seguro es tratar el reclamo del job como una “licencia” (lease). El worker marca el job como bloqueado por una ventana corta. Si el worker se cae, la licencia expira y otro worker puede recogerlo. Para eso sirve locked_until.
Sin una licencia, un worker puede bloquear un job y nunca desbloquearlo (proceso matado, reboot, deploy fallido). Con locked_until, el job vuelve a estar disponible cuando pase el tiempo.
Una regla típica: un job puede ser reclamado cuando locked_until es NULL o locked_until <= now().
El detalle clave es reclamar el job en una sola sentencia (o en una sola transacción). Quieres que la base de datos sea el árbitro.
Aquí hay un patrón común en PostgreSQL: coger un job debido, bloquearlo y devolverlo al worker. (Este ejemplo usa una sola tabla jobs; la misma idea aplica si reclamas desde job_runs.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
AND (locked_until IS NULL OR locked_until <= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
Por qué funciona:
FOR UPDATE SKIP LOCKED permite que varios workers compitan sin bloquearse entre sí.RETURNING entrega la fila al worker que ganó la carrera.Ajusta la licencia más larga que una ejecución normal, pero lo bastante corta para que un crash se recupere rápido. Si la mayoría de jobs terminan en 10 segundos, una licencia de 2 minutos es suficiente.
Para tareas largas, renueva la licencia mientras trabajas (un heartbeat). Un enfoque simple: cada 30 segundos extiende locked_until si sigues siendo dueño del job.
WHERE id = $job_id AND locked_by = $worker_idEsa última condición importa. Evita que un worker extienda la licencia de un job que ya no posee.
Los reintentos son donde este patrón o bien transmite calma o bien se vuelve un lío ruidoso. El objetivo es simple: cuando un job falla, intentarlo de nuevo más tarde de forma explicable, medible y finita.
Empieza haciendo el estado del job explícito y finito: queued, running, succeeded, failed, dead. En la práctica, la mayoría usa failed para “falló pero se reintentará” y dead para “falló y nos rendimos”. Esa distinción evita bucles infinitos.
El conteo de intentos es la segunda barrera. Guarda attempts (cuántas veces se intentó) y max_attempts (cuántas veces permites). Cuando un worker atrapa un error, debería:
attemptsfailed si attempts < max_attempts, si no en deadrun_at para el siguiente intento (solo para failed)El backoff es la regla que decide el run_at siguiente. Escoge una, documéntala y mantenla consistente:
El jitter importa cuando una dependencia se cae y vuelve. Sin él, cientos de jobs pueden reintentar al mismo tiempo y fallar otra vez.
Guarda suficiente detalle del error para hacer los fallos visibles y depurables. No necesitas un sistema completo de logging, pero sí lo básico:
last_error (mensaje corto, seguro para mostrar en una pantalla admin)error_code o error_type (ayuda a agrupar)failed_at y next_run_atlast_stack (solo si controlas el tamaño)Una regla concreta que funciona bien: marca jobs como dead después de 10 intentos y aplica backoff exponencial con jitter. Eso mantiene los fallos transitorios reintentando y evita que jobs rotos consuman CPU para siempre.
Idempotencia significa que tu job puede ejecutarse dos veces y aún así producir el mismo resultado final. En este patrón importa porque la misma fila puede seleccionarse de nuevo tras un crash, un timeout o un reintento. Si tu job es “enviar un email de factura”, ejecutarlo dos veces no es inocuo.
Una forma práctica de pensarlo: divide cada job en (1) hacer trabajo y (2) aplicar un efecto. Quieres que el efecto ocurra una sola vez, aunque el trabajo se intente varias veces.
Una clave de idempotencia debe venir de lo que representa el job, no del intento del worker. Buenas claves son estables y fáciles de explicar, como invoice_id, user_id + day o report_name + report_date. Si dos intentos de job se refieren al mismo evento del mundo real, deberían compartir la misma clave.
Ejemplo: “Generar el informe diario de ventas para 2026-01-14” puede usar sales_report:2026-01-14. “Cobrar la factura 812” puede usar invoice_charge:812.
La barrera más simple es dejar que PostgreSQL rechace duplicados. Almacena la clave de idempotencia en un lugar indexable y añade una restricción única.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
Esto evita que existan dos filas con la misma clave al mismo tiempo. Si tu diseño permite varias filas (para historial), aplica la unicidad en una tabla de “efectos”, como sent_emails(idempotency_key) o payments(idempotency_key).
Efectos habituales a proteger:
sent_emails con clave única antes de enviar, o registra el id del proveedor una vez enviado.delivered_webhooks(event_id) y salta si ya existe.file_generated indexado por (type, date).Si trabajas sobre un stack respaldado por Postgres (por ejemplo, un backend Go + PostgreSQL), estas comprobaciones de unicidad son rápidas y fáciles de mantener cerca de los datos. La idea clave es simple: los reintentos son normales, los duplicados son opcionales.
Elige un runtime sencillo y quédate con él. El objetivo del patrón cron + base de datos es menos piezas móviles, así que un proceso pequeño en Go, Node o Python que hable con PostgreSQL suele ser suficiente.
Crear las tablas e índices. Añade una tabla jobs (más tablas de apoyo si quieres), indexa run_at y agrega un índice que ayude a tu worker a encontrar jobs disponibles rápido (por ejemplo en (status, run_at)).
Escribir una función tiny para encolar. Tu app debe insertar una fila con run_at en “now” o en un tiempo futuro. Mantén el payload pequeño y predecible (IDs y tipo de job, no blobs enormes).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running en la misma transacción.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
Procesar y finalizar. Para cada job reclamado, haz el trabajo y luego actualiza a done con finished_at. Si falla, registra un mensaje de error y muévelo de nuevo a queued con un nuevo run_at (backoff). Mantén las actualizaciones de finalización pequeñas y siempre ejecútalas, incluso si tu proceso se está cerrando.
Agregar reglas de reintento que puedas explicar. Usa una fórmula simple como run_at = now() + (attempts^2) * interval '10 seconds', y para cuando max_attempts se exceda pon status = 'dead'.
No necesitas un dashboard completo el primer día, pero sí lo suficiente para notar problemas.
Si ya estás en un stack Go + PostgreSQL, esto encaja bien con un único binario worker más cron.
Imagina una pequeña app SaaS con dos trabajos programados:
Mantenlo simple: una tabla PostgreSQL para guardar jobs y un worker que se ejecuta cada minuto (disparado por cron). El worker reclama jobs debidos, los ejecuta y registra éxito o fallo.
Puedes encolar jobs desde varios sitios:
cleanup_nightly para “hoy”.send_weekly_report para el próximo lunes del usuario.send_weekly_report que corra inmediatamente para un rango de fechas específico.El payload debe ser lo mínimo que el worker necesita. Mantenlo pequeño para que sea fácil de reintentar.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Un worker puede fallar en el peor momento: justo después de enviar el email pero antes de marcar el job como “done”. Cuando se reinicie, puede seleccionar el mismo job otra vez.
Para evitar envíos dobles, da al trabajo una clave natural de deduplicación y guárdala donde la base de datos pueda aplicarla. Para reportes semanales, una buena clave es (user_id, week_start_date). Antes de enviar, el worker registra “voy a enviar el reporte X”. Si ese registro ya existe, salta el envío.
Esto puede ser tan simple como una tabla sent_reports con restricción única en (user_id, week_start_date), o una idempotency_key única en el propio job.
Supongamos que el proveedor de email hace timeout. El job falla, así que el worker:
attemptsSi sigue fallando pasado tu límite (por ejemplo 10 intentos), márcalo como “dead” y deja de reintentar. El job o bien tiene éxito una vez, o se reintenta según un horario claro, y la idempotencia hace que el reintento sea seguro.
El patrón cron + base de datos es simple, pero pequeños errores pueden convertirlo en duplicados, trabajo atascado o cargas sorpresa. La mayoría de problemas aparecen tras el primer crash, deploy o pico de tráfico.
Los incidentes reales suelen venir de unas pocas trampas:
locked_until. Si un worker se cae después de reclamar un job, esa fila puede quedarse “en progreso” para siempre. Un timestamp de licencia permite que otro worker lo recoja más tarde.user_id, invoice_id o una clave de archivo) y recupera el resto al ejecutar.Ejemplo: envías un email semanal de factura. Si el worker hace timeout después de enviar pero antes de marcar el job como hecho, el mismo job puede reintentar y enviar un email duplicado. Eso es normal en este patrón a menos que añadas una barrera (por ejemplo, registrar un evento único “email enviado” indexado por invoice id).
Evita mezclar programación y ejecución en la misma transacción larga. Si mantienes una transacción abierta mientras haces llamadas de red, mantienes locks más tiempo del necesario y bloqueas a otros workers.
Cuidado con diferencias de reloj entre máquinas. Usa la hora de la base de datos (NOW() en PostgreSQL) como fuente de verdad para run_at y locked_until, no el reloj del servidor de la app.
Define un tiempo máximo de ejecución claro. Si un job puede tardar 30 minutos, haz la licencia más larga que eso y renuévala si hace falta. De lo contrario, otro worker puede cogerlo a mitad.
Mantén la tabla de jobs saludable. Si los jobs completados se acumulan para siempre, las consultas se ralentizan y la contención de locks sube. Elige una regla de retención simple (archivar o borrar filas antiguas) antes de que la tabla sea enorme.
Antes de desplegar este patrón, verifica lo básico. Una pequeña omisión aquí suele acabar en jobs atascados, duplicados sorpresa o un worker que machaca la base de datos.
run_at, status, attempts, locked_until y max_attempts (más last_error o similar para ver qué pasó).invoice_id).max_attempts.Si esto se cumple, el patrón cron + base de datos suele ser suficientemente estable para cargas reales.
Una vez que la checklist esté correcta, céntrate en la operación diaria.
run_at = now() y limpia el lock) y “cancelar” (mueve a un estado terminal). Ahorran tiempo en incidentes.status, run_at).Si quieres construir esto rápido, Koder.ai (koder.ai) puede ayudarte a pasar del esquema a una app Go + PostgreSQL desplegada con menos cableado manual, mientras te concentras en los locks, reintentos e idempotencia.
Si luego superas esta configuración, habrás aprendido claramente el ciclo de vida de los jobs, y esas mismas ideas se trasladan bien a un sistema de colas completo.