¿Qué hace Diseño de Colas de Trabajos en Segundo Plano?
Un sistema de trabajos en segundo plano tiene tres modos de fallo contra los que debes diseñar desde el primer día: trabajos perdidos, ejecución duplicada y reintentos descontrolados. Si eliges la primitiva incorrecta, pasarás el próximo año arreglando los tres. El consenso recurrente en la industria, a través de la documentación de BullMQ, Sidekiq, SQS y Temporal, es que 'al menos una vez' es la única semántica de entrega honesta — diseña cada manejador para que sea idempotente y deja de perseguir 'exactamente una vez'. (Caduh — Queues 101)
Salta a tu problema:
- Los trabajos se ejecutaron dos veces al reintentar → Idempotencia
- El worker falló a mitad del trabajo, el trabajo desapareció → Recuperación fiable / tiempo de espera de visibilidad
- La DLQ se está llenando, las alertas están sonando → Clasificación de errores + DLQ
- Redis se queda sin memoria durante un backlog → Higiene de memoria
- "¿Debería ser Temporal en su lugar?" → Cola vs flujo de trabajo
- Tormentas de reintentos golpean aguas abajo → Retroceso + jitter
- La concurrencia excede un límite de tasa → Concurrencia + limitador
Cuándo usar
- Diseñando un nuevo sistema de trabajos en segundo plano en Node, Ruby, Python o Go.
- Migrando desde
setTimeout/cron/ colas caseras a un broker real. - Los fallos de worker están perdiendo trabajos.
- Los reintentos en producción están causando efectos secundarios duplicados.
- Eligiendo entre BullMQ (Node + Redis), Sidekiq (Ruby + Redis), RQ / Celery / Dramatiq (Python), Asynq (Go + Redis), SQS (gestionado) o Temporal (ejecución duradera).
Capacidades principales
Elige la primitiva que coincida con el trabajo
| Tienes | Recurre a | Razón |
|---|---|---|
| Trabajos cortos con efectos secundarios (enviar email, redimensionar imagen) en Node | BullMQ | Nativo de Redis, DLQ madura, patrones de flujo, ~~5k trabajos/seg/nodo típico |
| Lo mismo en Ruby | Sidekiq (Pro para fiabilidad) | Misma forma; Pro super_fetch añade recuperación fiable mediante LMOVE (Wiki de Sidekiq) |
| Flujos de trabajo de múltiples pasos con distribución externa de API, duraciones de horas a días | Temporal | Ejecución duradera; reproduce desde el historial de eventos (Documentación de Temporal) |
| Nativo de AWS, distribución simple, no quieres ejecutar Redis | SQS + Lambda | 'Al menos una vez' gestionado; tiempo de espera de visibilidad + DLQ de primera clase (AWS SQS DLQ) |
| Aislamiento multitenencia y multirregión | Temporal o SQS | Un solo Redis no sobrevivirá limpiamente en múltiples regiones |
| Necesitas 'exactamente una vez' a través de BD + email + pagos | Ninguna — diseña para 'al menos una vez' + manejadores idempotentes | 'Exactamente una vez' dentro del broker no se extiende a través de sistemas (Caduh) |
El criterio oculto: ¿qué tan compleja es la historia de recuperación? Una cola simple de BullMQ con reintentos está bien para "enviar un recibo". Un flujo de trabajo de compra de 12 pasos que espera 24 horas por un webhook es un flujo de trabajo de Temporal, no 12 trabajos de cola encadenados.
Idempotencia
La propiedad obligatoria. Todos los documentos de las colas populares comienzan con esto. De la página de trabajos idempotentes de BullMQ: "no debería suponer una diferencia para el estado final del sistema si un trabajo se completa con éxito en su primer intento, o si falla inicialmente y tiene éxito cuando se reintenta." (BullMQ idempotent-jobs)
Patrones prácticos:
// 1. Clave de idempotencia derivada del evento de negocio, no de la cola.
// Usa el event_id ascendente si lo tienes (Stripe event.id, Shopify hook id).
const job = await queue.add(
'send-receipt',
{ orderId, amount },
{
jobId: `send-receipt:${orderId}`, // BullMQ deduplica por jobId en la cola.
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
}
);
// 2. Dentro del manejador, deduplica también en el sitio del efecto secundario.
async function processSendReceipt(job) {
const { orderId, amount } = job.data;
// Si ya enviamos, no hacer nada.
const sent = await db.queryOne(
'INSERT INTO email_sends (idempotency_key, order_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id',
[`send-receipt:${orderId}`, orderId]
);
if (!sent) return { skipped: 'already-sent' };
await emailProvider.send({ to: ..., subject: ..., body: render(amount) });
}
El jobId deduplica las adiciones mientras un duplicado aún está en cola. La restricción única de la BD deduplica a través de fallos de worker, reintentos y reproducciones. Ambos son necesarios; solo jobId es insuficiente porque BullMQ elimina los trabajos completados y la ventana de deduplicación se evapora.
Recuperación fiable y tiempo de espera de visibilidad
Cuando un worker extrae un trabajo y falla, ¿qué sucede?
| Sistema | Comportamiento predeterminado | Recuperación |
|---|---|---|
| BullMQ | El trabajo tiene un lockDuration (predeterminado 30s). El verificador de trabajos estancados vuelve a poner en cola después de la expiración (BullMQ production) | Los workers DEBEN extender el bloqueo o terminar dentro de lockDuration; de lo contrario, se ejecuta dos veces |
| Sidekiq OSS | Pop no es atómico; si el worker muere entre pop y proceso, el trabajo se pierde | Actualiza a Sidekiq Pro super_fetch (Redis 6.2+ LMOVE a una cola de trabajo privada) |
| Sidekiq Pro super_fetch | LMOVE a una lista de trabajo por proceso. El latido expira a los 60s; la verificación de huérfanos barre y vuelve a poner en cola (Wiki de Sidekiq) | Incorporado |
| SQS | Tiempo de espera de visibilidad (predeterminado 30s, máximo 12h). El mensaje reaparece después del tiempo de espera si no se elimina | Establece el tiempo de espera > latencia p99 del manejador; extiende con ChangeMessageVisibility para trabajos largos |
| Temporal | Las actividades tienen latidos + políticas de reintento. El historial del flujo de trabajo sobrevive a la muerte del worker | Reproduce desde el historial; no tienes que pensar en esto |
La trampa: lockDuration / tiempo de espera de visibilidad más corto que el p99 del manejador → el trabajo se ejecuta dos veces. Más largo que el tiempo de recuperación aceptable → el trabajo fallido espera demasiado. Mide tu p99 primero, establece el tiempo de espera a ~3x eso, y haz que el manejador envíe latidos / extienda el bloqueo para trabajos realmente largos.
Clasificación de errores y DLQ
No todos los errores deben reintentarse para siempre. De los documentos de SQS: "Establece el máximo de recepciones... y la política de redirección para enviar mensajes a la DLQ una vez que se supere el umbral." (AWS SQS DLQ)
// Clasifica antes de lanzar. Los errores permanentes NO deben reintentarse.
class PermanentError extends Error { constructor(m: string) { super(m); this.name = 'PermanentError'; } }
class TransientError extends Error { constructor(m: string) { super(m); this.name = 'TransientError'; } }
async function process(job) {
try {
const user = await db.user(job.data.userId);
if (!user) throw new PermanentError('user-not-found'); // → DLQ en el primer intento
if (user.suspended) throw new PermanentError('user-suspended');
await externalApi.call(user);
} catch (e) {
if (e instanceof PermanentError) {
// Mover a DLQ inmediatamente — reintentar no ayudará.
job.discard(); // BullMQ: evita el reintento
throw e;
}
throw e; // Red/timeout → reintentar según política de intentos
}
}
La DLQ entonces necesita:
- Una alerta por recuento creciente (no solo distinto de cero — "DLQ tiene 3 cosas para siempre" está bien; "DLQ creció 100/min" es una emergencia).
- Una herramienta de reproducción (CLI o UI de administración) para que un humano pueda arreglar el error ascendente, reproducir los trabajos fallidos y limpiar la DLQ.
- Paneles de control (ver
grafana-dashboard-builder).
Retroceso y jitter
El retroceso exponencial por sí solo no es suficiente. Si 1000 trabajos fallan todos a las 12:00:00 porque un servicio descendente tuvo un parpadeo, el retroceso exponencial simple hará que los 1000 reintenten exactamente a las 12:00:01, 12:00:03, 12:00:07 — una manada atronadora que podría mantener el servicio descendente caído. Añade jitter completo:
// Estilo AWS "jitter completo": delay = random(0, exp_backoff)
const baseDelay = Math.min(2 ** attempt * 1000, 30_000);
const delay = Math.random() * baseDelay;
Para la línea base recomendada de BullMQ, el documento going-to-production sugiere intervalos de reintento entre 1s y 20s con retryStrategy. (BullMQ production)
Concurrencia y limitador
La concurrencia por worker ejecuta N trabajos en paralelo desde un solo proceso. Múltiples workers multiplican eso. Sin un limitador a nivel de cola, no puedes limitar las llamadas agregadas a un servicio descendente:
// BullMQ — límite de tasa global en todos los workers de esta cola.
const queue = new Queue('outbound-emails', {
connection,
limiter: { max: 100, duration: 1000 }, // 100 correos/segundo en toda la flota
});
La forma correcta: muchos workers concurrentes + un limitador de cola para los límites del contrato descendente. Sidekiq tiene limitadores de tasa equivalentes; SQS usa concurrencia de Lambda o tasa por mensaje.
Higiene de memoria
El error más enfatizado en la producción de BullMQ: maxmemory-policy noeviction en la instancia de Redis. Cualquier otra cosa (allkeys-lru, volatile-lru) eliminará silenciosamente trabajos en cola bajo presión. (BullMQ production)
También:
removeOnCompleteagresivo (ej.{ age: 3600, count: 1000 }).removeOnFailaún más agresivo una vez que hayas migrado a un patrón DLQ.- Habilita la persistencia AOF de Redis —
appendfsync everyseces el punto óptimo documentado para BullMQ. (BullMQ production) maxRetriesPerRequest: nullen el worker para evitar que ioredis lance durante desconexiones transitorias. (BullMQ production)- Del lado de la cola:
enableOfflineQueue: falsepara que el productor falle rápidamente en lugar de almacenar en búfer.
Apagado graceful
process.on('SIGTERM', async () => {
await worker.close(); // dejar de aceptar; terminar trabajos en curso.
await connection.quit();
process.exit(0);
});
process.on('SIGINT', async () => { /* igual */ });
La guía de producción de BullMQ señala explícitamente: cierra los workers antes de detener el proceso; el tiempo de espera de estancamiento predeterminado es ~30s. (BullMQ production)
Decisión cola vs flujo de trabajo
Un marco útil de los documentos de Temporal: "A diferencia de las colas de mensajes que mueven datos entre servicios, Temporal orquesta procesos completos... sabe dónde estás en un flujo de trabajo, qué está completo, qué está pendiente y qué necesita reintentar." (Temporal blog)
flowchart TD
A[Trabajo en segundo plano] --> B{¿Efecto secundario único o pipeline?}
B -->|Único| C[BullMQ / Sidekiq / SQS]
B -->|Múltiples pasos| D{¿Los pasos abarcan segundos u horas+?}
D -->|Segundos| E[BullMQ flows / Sidekiq batches]
D -->|Horas+ o humano en el bucle| F[Temporal / DBOS / Restate]
C --> G{¿Necesitas aislamiento multirregión o multitenencia?}
G -->|Sí| H[SQS o Temporal — un solo clúster de Redis no servirá]
G -->|No| I[Ejecuta con clúster Redis + AOF + noeviction]
Antipatrones
Idempotencia solo mediante SET-NX de Redis
Síntoma: Durante un parpadeo o reinicio de Redis, el mismo trabajo se ejecuta dos veces y crea efectos secundarios duplicados (cobro, correo, ticket).
Diagnóstico: Redis es un caché; puede ser desalojado, reiniciado o particionado. La deduplicación SET-NX que solo vive en Redis es de esfuerzo máximo.
Solución: Clave de idempotencia con una restricción única de BD en el registro del efecto secundario (ej. email_sends.idempotency_key). La transacción de BD que crea el efecto secundario ES la primitiva de deduplicación.
lockDuration más corto que el p99 del manejador
Síntoma: Entradas de registro de trabajos estancados; el mismo trabajo procesado por dos workers. Los clientes se quejan.
Diagnóstico: El lockDuration predeterminado es 30s; el manejador a veces tarda 45s; el segundo worker lo recoge.
Solución: Mide el p99, establece lockDuration a ~3x p99, y llama a job.extendLock() dentro de manejadores largos. O divide el trabajo.
Retroceso exponencial simple (sin jitter)
Síntoma: Un parpadeo descendente se convierte en una caída descendente; 10k trabajos golpean el servicio en recuperación al unísono. Diagnóstico: Sin jitter, los reintentos están correlacionados. Solución: Retroceso con jitter completo. Limita el retardo máximo para acotar el tiempo hasta el fallo de la DLQ.
maxmemory-policy allkeys-lru en Redis
Síntoma: La cola pierde trabajos misteriosamente bajo carga. Sin errores. (BullMQ production)
Diagnóstico: Redis desalojó claves de trabajo para hacer espacio.
Solución: CONFIG SET maxmemory-policy noeviction Y verifícalo al inicio. Los workers deberían negarse a iniciar de lo contrario.
Fingir 'exactamente una vez' a través de sistemas
Síntoma: Cliente cobrado dos veces; enviado dos veces el mismo correo. El ingeniero está seguro de que "el broker es exactamente una vez". Diagnóstico: Incluso un broker FIFO/exactamente una vez no extiende su garantía a tu BD + proveedor de correo + Stripe + libro mayor. Solución: Trata la cola como 'al menos una vez'. Idempotencia en cada límite de efecto secundario.
DLQ sin herramienta de reproducción
Síntoma: "Tenemos 3.200 trabajos en la DLQ. No sabemos qué hay ahí." La DLQ se convierte en un cementerio no monitoreado. Diagnóstico: Construiste la DLQ, no construiste la herramienta del operador. Solución: Reproducir por ID, reproducir por rango de tiempo, vaciar con confirmación. CLI o UI de administración. Probado.
Trabajo de bloqueo largo en el bucle de eventos
Síntoma: El proceso del worker de BullMQ se cuelga; los latidos se detienen; el bloqueo expira; el trabajo se vuelve a ejecutar.
Diagnóstico: Trabajo ligado a CPU en el mismo proceso que el latido de la cola; el bucle de eventos se bloquea por > lockDuration.
Solución: Crea un proceso hijo o un hilo de worker para trabajo ligado a CPU. O ejecuta un procesador en sandbox (BullMQ soporta sandboxing de procesos por trabajo).
Puertas de calidad
- Prueba: prueba de caos — mata un worker a mitad del trabajo; verifica que el trabajo se vuelva a ejecutar y el efecto secundario no se duplique.
- Prueba: prueba de tormenta de reintentos — falla el servicio descendente durante 60s; verifica que el retroceso + jitter distribuya los reintentos (sin manada atronadora en las métricas).
- Cada manejador es idempotente y usa una clave de idempotencia estable derivada del evento de negocio.
- Una restricción única a nivel de BD respalda cualquier deduplicación en Redis (jobId, SET-NX).
-
lockDuration/ tiempo de espera de visibilidad ≥ 3× latencia p99 medida del manejador, Y los manejadores largos extienden el bloqueo. - Los errores permanentes (clase
PermanentErroro equivalente) omiten el reintento y van directamente a la DLQ. - Alertas de DLQ: página cuando la tasa de crecimiento > N por minuto, no solo recuento > 0.
- La herramienta de reproducción de DLQ existe y está probada (única + rango).
- Redis:
maxmemory-policy=noeviction, AOF habilitado (appendfsync everysec), verificado al inicio del worker. (BullMQ production) -
removeOnCompleteyremoveOnFailconfigurados. La métrica de recuento de trabajos se sigue engrafana-dashboard-builder.
Referencias
- BullMQ — Producción
- BullMQ — Trabajos idempotentes
- Sidekiq — Fiabilidad de la red
- AWS SQS — Colas de mensajes fallidos
- AWSSamples — Cumplimiento de 'exactamente una vez'
- Temporal — Descripción general
- Blog de Temporal — Colas vs Temporal
- Caduh — Colas 101
Entregado por curiositech/windags-skills. Edita en agents/queue-design/SKILL.md.