TL;DR
- Vas a entender la diferencia entre at-least-once, at-most-once y exactly-once en colas de trabajo.
- Vas a saber calcular un visibility timeout correcto para que un job lento no se procese dos veces.
- Vas a poder escribir un job idempotente con una clave de deduplicación en Redis o Postgres.
- Vas a configurar reintentos con backoff exponencial y una dead-letter queue en BullMQ y SQS.
- Vas a poder elegir entre SQS, RabbitMQ, Redis/BullMQ y Kafka según tu volumen y caso de uso.
- Vas a saber diagnosticar una cola atascada revisando profundidad, mensajes en vuelo y la DLQ.
- Vas a conocer el patrón outbox para evitar el problema de doble escritura entre tu base de datos y la cola.
Un job se ejecuta dos veces y tu sistema le cobra dos veces al mismo cliente. El código del worker no tiene ningún bug: el problema está en cómo diseñaste la cola.
Las colas de trabajo (job queues) desacoplan quién pide un trabajo de quién lo ejecuta, pero esconden problemas difíciles: qué pasa si el worker se cae a mitad de proceso, cómo evitar procesar el mismo mensaje dos veces y qué hacer con un job que falla siempre. Acá está cada pieza explicada con código real, desde el productor hasta la dead-letter queue.
Qué es una cola de trabajo y por qué importa
Una cola de trabajo (job queue) es una pieza de infraestructura que recibe una tarea, la guarda y la entrega a un worker cuando hay capacidad para procesarla. La diferencia con llamar a una función directamente es que el que pide el trabajo (el productor) no espera a que termine: encola el mensaje y sigue. Quien lo procesa (el consumidor o worker) puede estar en otro proceso, otro servidor, o escalarse de forma independiente.
El caso típico: un usuario sube un video en tu app. Procesarlo (transcodificar, generar miniaturas, extraer subtítulos) puede tardar minutos. Si lo hacés dentro del mismo request HTTP, el usuario espera frente a una pantalla en blanco y cualquier timeout del navegador o del load balancer corta la conexión a mitad de camino. Con una cola de trabajo, el request HTTP responde en milisegundos y el trabajo pesado ocurre en background, en un worker que podés escalar horizontalmente según la carga.
Hasta acá suena simple. Lo difícil aparece cuando algo falla a mitad de camino: el worker se cae, la red se corta, el mensaje se entrega dos veces o nunca se resuelve. Ahí es donde la mayoría de las implementaciones caseras de colas de trabajo se rompen.
Un productor encola, un broker guarda, uno o varios workers procesan.
Cómo funciona una cola de trabajo por dentro
Productor, broker y consumidor
Toda cola de trabajo tiene tres roles. El productor crea el mensaje (el job) y lo envía al broker: por ejemplo, tu API cuando alguien sube un archivo. El broker es el sistema que guarda el mensaje de forma durable hasta que alguien lo procese: puede ser Redis, RabbitMQ, Amazon SQS o incluso una tabla de Postgres. El consumidor (worker) pide mensajes al broker, los procesa y le confirma que terminó.
Esa confirmación se llama ack (acknowledgment). Es la pieza central de todo el sistema: si el worker no manda el ack, el broker asume que el trabajo no se completó y lo vuelve a entregar. Ese único mecanismo, entregar de nuevo cuando no hay ack, es la raíz de casi todos los problemas difíciles de las colas de trabajo.
flowchart TD
A["Productor (API)"] --> B["Cola / broker"]
B --> C["Worker"]
C --> D[("Base de datos")]
C -. "falla repetidamente" .-> E["Dead-letter queue"]
Reintentos y semánticas de entrega
Cuando un mensaje se reintenta, existen tres garantías posibles. At-most-once: el mensaje se entrega una vez o ninguna, nunca se duplica, pero se puede perder si el worker se cae antes de procesarlo. At-least-once: el mensaje se entrega una o más veces, nunca se pierde, pero puede duplicarse. Exactly-once: el mensaje se procesa exactamente una vez, sin pérdidas ni duplicados.
La mayoría de los sistemas de colas en producción (SQS, RabbitMQ, BullMQ sobre Redis) ofrecen at-least-once por defecto porque es la garantía más fácil de sostener: para no perder nunca un mensaje, el broker prefiere reenviarlo de más antes que arriesgarse a perderlo. El costo es que tu worker va a recibir el mismo job dos veces alguna vez, y tiene que estar preparado para eso.
💭 Clave: exactly-once real en un sistema distribuido no existe sin coordinación adicional. Lo que sí existe, y alcanza en la práctica, es at-least-once entrega más procesamiento idempotente: el resultado neto es "efectivamente una vez".
Visibility timeout: la carrera contra el reloj
Cuando un worker toma un mensaje de la cola, la mayoría de los brokers no lo borran de inmediato: lo ocultan por un tiempo determinado, el visibility timeout, y esperan el ack. Si el ack llega antes de que expire ese tiempo, el mensaje se borra. Si no llega (el worker se cayó, tardó demasiado, perdió la conexión), el mensaje vuelve a quedar visible para que otro worker lo tome.
El error más común es fijar un visibility timeout más corto que el tiempo real de procesamiento. Si tu job de transcodificar video tarda 90 segundos pero el timeout es de 30, un segundo worker va a tomar el mismo mensaje mientras el primero todavía lo está procesando: los dos van a transcodificar el mismo video en paralelo y probablemente vas a terminar con dos cargos, dos emails o dos filas duplicadas en tu base de datos.
sequenceDiagram
participant P as Productor
participant Q as Cola
participant W as Worker
P->>Q: encola job (id=42)
Q->>W: entrega job, inicia visibility timeout
Note over W: procesando (puede tardar)
W-->>Q: ack, borra el mensaje
Q-->>P: job completado
⚠️ Ojo: el visibility timeout se calcula en base al peor caso de procesamiento, no al promedio. Si tu p99 de procesamiento es 90 segundos, un timeout de 30 va a duplicar trabajo constantemente.
Idempotencia: la defensa real contra los duplicados
Como at-least-once garantiza que un job puede llegar más de una vez, la única defensa confiable es hacer que procesarlo dos veces produzca el mismo resultado que procesarlo una sola vez. Eso es idempotencia: la propiedad de que aplicar una operación varias veces tiene el mismo efecto que aplicarla una.
En la práctica se implementa con una clave de deduplicación: antes de ejecutar el efecto secundario (cobrar, enviar un email, escribir una fila), el worker verifica si esa clave ya fue procesada. Si Redis o tu base de datos confirman que sí, el worker descarta el job sin volver a ejecutar el efecto. Esa clave suele construirse con el ID del job más algún dato del negocio, nunca con un timestamp: dos reintentos del mismo job no deberían generar dos claves distintas.
Dead-letter queues: el último recurso
No todos los jobs se pueden reintentar para siempre. Un mensaje corrupto, un payload con un bug de validación o una integración externa caída de forma permanente van a fallar en cada intento. Sin un límite, ese mensaje se reintenta infinitamente y consume workers sin producir nada útil: se lo conoce como mensaje envenenado (poison message).
La solución es fijar un número máximo de intentos (max receive count) y, al superarlo, mover el mensaje a una dead-letter queue (DLQ): una cola separada donde el mensaje queda visible para inspección manual en vez de seguir consumiendo ciclos de reintento. La DLQ no es un cementerio: es una bandeja de entrada que alguien tiene que revisar.
stateDiagram-v2
[*] --> Visible
Visible --> EnVuelo: worker lo toma
EnVuelo --> Visible: timeout expira sin ack
EnVuelo --> Completado: ack recibido
EnVuelo --> DLQ: excede maxReceiveCount
Completado --> [*]
DLQ --> [*]
Ejemplos prácticos: de cero a un worker con reintentos e idempotencia
Vamos a construir esto de manera progresiva con BullMQ, una librería de colas sobre Redis para Node.js. Primero el mínimo indispensable: encolar y procesar un job.
// producer.js: encola un job minimo
import { Queue } from 'bullmq';
const emailQueue = new Queue('emails', {
connection: { host: '127.0.0.1', port: 6379 },
});
await emailQueue.add('send-welcome', {
userId: 1042,
email: 'ana@example.com',
});
Esto crea una cola llamada emails en Redis y encola un job. BullMQ serializa el payload a JSON, le asigna un ID único y lo deja disponible para que un worker lo tome. Todavía no hay reintentos configurados ni idempotencia: si este job se procesa dos veces, se envían dos emails.
Ahora la versión realista: reintentos con backoff exponencial, idempotencia con Redis y manejo del evento de fallo definitivo.
// worker.js: procesa jobs con reintentos e idempotencia
import { Worker } from 'bullmq';
import Redis from 'ioredis';
const redis = new Redis();
const worker = new Worker('emails', async (job) => {
const dedupeKey = `email:sent:${job.data.userId}:${job.data.email}`;
const isFirstTime = await redis.set(dedupeKey, '1', 'EX', 86400, 'NX');
if (!isFirstTime) {
return { skipped: true }; // ya se proceso este job antes
}
await sendWelcomeEmail(job.data.email);
}, {
connection: { host: '127.0.0.1', port: 6379 },
concurrency: 5,
});
worker.on('failed', (job, err) => {
console.error(`job ${job.id} fallo en intento ${job.attemptsMade}: ${err.message}`);
});
redis.set(..., 'NX') solo escribe la clave si no existe, de forma atómica. Eso convierte el "verificar y marcar" en una sola operación, sin condición de carrera entre dos workers que reciban el mismo job casi al mismo tiempo. La expiración de 24 horas evita que la clave crezca para siempre en Redis.
Para que los reintentos con backoff y el límite de intentos entren en juego, hay que configurarlos al encolar el job, no al procesarlo:
await emailQueue.add('send-welcome', payload, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: true,
removeOnFail: false, // los jobs fallidos quedan visibles para inspeccionar
});
Con attempts: 5 y backoff exponencial de 1 segundo, BullMQ reintenta a 1s, 2s, 4s, 8s y 16s antes de marcar el job como fallido definitivamente. removeOnFail: false deja esos jobs fallidos en la cola de failed, que funciona como dead-letter queue: podés inspeccionarlos con emailQueue.getFailed() y reintentarlos a mano una vez que arreglás la causa.
Un dashboard de colas expone tres números clave: pendientes, en vuelo y fallidos.
Cómo empezar: monta una cola local en minutos
Para probar todo lo anterior en tu máquina necesitás Redis corriendo y las dos librerías de Node.
docker run -d --name redis-queue -p 6379:6379 redis:7
npm install bullmq ioredis
Con Redis arriba, corré el archivo producer.js de arriba para encolar un job y después worker.js en otra terminal para procesarlo. BullMQ crea automáticamente las estructuras de datos que necesita en Redis (listas y sorted sets) la primera vez que usás la cola: no hace falta migración ni configuración adicional.
Verificar que funciona
Para confirmar que los jobs se están procesando y que la DLQ (la cola de fallidos) está vacía, podés consultar los contadores directamente:
const counts = await emailQueue.getJobCounts('active', 'completed', 'failed', 'delayed');
console.log(counts); // { active: 0, completed: 12, failed: 0, delayed: 0 }
Si usás Amazon SQS en vez de Redis, el equivalente es consultar los atributos de la cola por CLI:
aws sqs get-queue-attributes \
--queue-url $DLQ_URL \
--attribute-names ApproximateNumberOfMessages
Y si usás RabbitMQ, rabbitmqctl list_queues name messages messages_unacknowledged muestra en una línea cuántos mensajes están pendientes y cuántos están en vuelo sin confirmar.
Casos de uso reales
Las colas de trabajo aparecen en casi cualquier sistema que separa responder rápido de hacer el trabajo pesado. Los casos más comunes:
- Emails y notificaciones transaccionales: enviar el email de bienvenida o el push de "tu pedido salió" sin bloquear el request que los dispara.
- Webhooks de pagos: procesar la confirmación de un pago de Stripe o Mercado Pago de forma asíncrona, con reintentos si tu servidor estuvo caído cuando llegó el webhook.
- Procesamiento de medios: transcodificar video, generar miniaturas o extraer metadata de imágenes subidas por usuarios.
- Generación de reportes y exports: armar un CSV o PDF grande sin mantener abierta una conexión HTTP de varios minutos.
- Sincronización con sistemas externos: mandar datos a un CRM o un data warehouse con reintentos automáticos si la API de destino responde con error 500.
Errores comunes y buenas prácticas
El error más frecuente ya se mencionó: un visibility timeout menor al tiempo real de procesamiento, que genera duplicados constantes. Medí el p99 de tus jobs y fijá el timeout con margen, no el promedio.
El segundo error es no tener dead-letter queue configurada. Sin un maxReceiveCount ni una cola de fallidos, un mensaje envenenado consume workers en un bucle infinito y puede afectar el rendimiento de toda la cola para el resto de los jobs legítimos.
El tercero es guardar payloads grandes dentro del mensaje en vez de una referencia. Un job que carga un archivo de 50 MB directo en el mensaje satura el broker; lo correcto es subir el archivo a almacenamiento de objetos (S3, GCS) y encolar solo la URL o el ID.
El cuarto, menos obvio: tratar el orden de los mensajes como garantizado. Salvo que uses explícitamente una cola FIFO (SQS FIFO, o particiones ordenadas en Kafka), la mayoría de las colas estándar no garantizan que los mensajes se procesen en el orden en que se encolaron, sobre todo con varios workers en paralelo.
Por último: no monitorear la cola. La profundidad de la cola (cuántos mensajes esperan) y el tamaño de la DLQ son las dos métricas mínimas que necesitás alertar. Una DLQ que crece silenciosamente es trabajo que tu sistema dejó de hacer sin que nadie se entere.
Comparativa con alternativas
OpciónCuándo usarlaVentajaLimitación
Amazon SQSya estás en AWS y querés cero infraestructura propiaadministrado, escala sola, DLQ nativavendor lock-in, orden no garantizado en cola estándar
RabbitMQnecesitás enrutamiento complejo (exchanges, fanout, topics)protocolo AMQP maduro, muy flexibletenés que operar el clúster vos mismo
Redis + BullMQequipo Node.js, baja latencia, volumen moderadosimple de levantar, buenos dashboardsRedis no es un log durable como Kafka; la persistencia depende de tu configuración de AOF/RDB
Apache Kafkastreams de eventos de alto volumen, necesitás replayretención larga, múltiples consumidores leen el mismo streamcomplejo de operar; no tiene ack por mensaje individual como una cola clásica
Postgres (SKIP LOCKED)ya tenés Postgres y volumen bajo o moderadosin infraestructura nueva, transaccional junto a tus datosno escala igual que un broker dedicado a volúmenes muy altos
Profundizando: el patrón outbox y el exactly-once efectivo
Hay un problema previo a todo lo anterior: qué pasa si tu código escribe en la base de datos y después falla justo antes de encolar el job. El registro quedó guardado pero nadie lo va a procesar nunca. Al revés (encolar primero, escribir después) tiene el problema simétrico: el worker puede arrancar antes de que la transacción de la base de datos haya confirmado.
Esto se conoce como el problema de la doble escritura (dual write): dos sistemas distintos, la base de datos y la cola, que necesitan quedar consistentes pero no comparten una transacción. La solución estándar es el patrón outbox: en la misma transacción en la que escribís tu dato de negocio, escribís también una fila en una tabla outbox. Un proceso separado lee esa tabla y encola el mensaje real, marcando la fila como enviada. Como ambas escrituras (el dato y el evento) ocurren en la misma transacción de Postgres o MySQL, o las dos se guardan o ninguna, sin estado intermedio inconsistente.
Con outbox más idempotencia en el consumidor, se logra lo que en la práctica se llama exactly-once: no porque el mensaje se entregue literalmente una sola vez (eso sigue siendo at-least-once por debajo), sino porque el efecto neto en el sistema es el mismo que si se hubiera procesado una vez, sin importar cuántas veces se reintentó.
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: levantá el Redis con Docker de la sección "Cómo empezar", corré el worker con attempts: 3 y matalo a mitad de un job para ver el reintento y el visibility timeout en acción.
Preguntas frecuentes
¿Cuál es la diferencia entre una cola de trabajo y un stream de eventos como Kafka?
Una cola de trabajo clásica entrega cada mensaje a un worker y lo borra cuando se confirma (ack): el mensaje desaparece después de procesarse. Un stream como Kafka retiene los mensajes durante un período configurable y permite que múltiples consumidores lean el mismo stream de forma independiente, incluso volver a leer mensajes ya procesados (replay).
¿Es posible lograr exactly-once de verdad?
En el sentido estricto (el mensaje viaja por la red exactamente una vez), no, sin coordinación adicional costosa. Lo que se logra en la práctica es at-least-once entrega más procesamiento idempotente, que produce el mismo resultado neto.
¿Cómo elijo el valor del visibility timeout?
Medí el tiempo real que tardan tus jobs en el peor caso (p99), no el promedio, y fijá el timeout con margen sobre ese número. Si no tenés esa métrica todavía, empezá conservador (varios minutos) y ajustá con datos reales de producción.
¿Cuándo conviene usar una dead-letter queue?
Siempre que reintentes automáticamente. Sin un límite de intentos y una DLQ, un mensaje que falla siempre (payload corrupto, bug de validación) consume workers en un bucle infinito sin producir nada útil.
¿Cómo hago idempotente un job que ya escribí sin pensarlo?
Identificá el efecto secundario real (cobrar, enviar, escribir) y agregale una verificación previa con una clave de deduplicación única, construida a partir de datos del negocio (no de un timestamp), guardada en Redis con NX o en una columna con constraint único en tu base de datos.
¿Redis alcanza o necesito un broker dedicado como RabbitMQ o SQS?
Para volúmenes moderados y equipos que ya usan Redis, BullMQ es suficiente y simple de operar. Si necesitás enrutamiento complejo, entrega garantizada de nivel empresarial o streams de eventos de muy alto volumen, un broker dedicado como RabbitMQ, SQS o Kafka se vuelve la opción más sólida.
Referencias
- Amazon SQS: documentación oficial de visibility timeout, reintentos y dead-letter queues en un servicio administrado.
- RabbitMQ Documentation: guías sobre acknowledgments, colas duraderas y patrones de enrutamiento con AMQP.
- BullMQ Documentation: referencia completa de la librería de colas sobre Redis usada en los ejemplos de este artículo.
- Idempotence, Wikipedia: definición formal de idempotencia y su origen matemático.
- Sidekiq: implementación de referencia de un sistema de colas de trabajo para Ruby, con reintentos y colas de fallidos.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Top comments (0)