Design de Fila de Jobs em Segundo Plano
Um sistema de jobs em segundo plano tem três modos de falha que você precisa projetar desde o primeiro dia: jobs perdidos, execução duplicada e retentativas descontroladas. Escolha a primitiva errada e você passará o próximo ano corrigindo todos os três. O consenso recorrente da indústria, entre os documentos do BullMQ, Sidekiq, SQS e Temporal, é 'pelo menos uma vez' é a única semântica de entrega honesta — projete cada handler para ser idempotente e pare de perseguir 'exatamente uma vez'. (Caduh — Filas 101)
Pule para o seu problema:
- Jobs executados duas vezes na retentativa → Idempotência
- Worker travou no meio do job, job desapareceu → Fetch confiável / timeout de visibilidade
- DLQ enchendo, alertas disparando → Classificação de erros + DLQ
- Redis OOM durante um backlog → Higiene de memória
- "Isso deveria ser Temporal?" → Fila vs workflow
- Tempestades de retentativa atingindo downstream → Backoff + jitter
- Concorrência excede um limite de taxa → Concorrência + limitador
Quando usar
- Projetando um novo sistema de jobs em segundo plano em Node, Ruby, Python ou Go.
- Migrando de
setTimeout/cron/ filas caseiras para um broker real. - Quedas de worker estão perdendo jobs.
- Retentativas em produção estão causando efeitos colaterais duplicados.
- Escolhendo entre BullMQ (Node + Redis), Sidekiq (Ruby + Redis), RQ / Celery / Dramatiq (Python), Asynq (Go + Redis), SQS (gerenciado) ou Temporal (execução durável).
Capacidades principais
Escolha a primitiva que corresponde ao trabalho
| Você tem | Alcance para | Motivo |
|---|---|---|
| Jobs curtos com efeitos colaterais (enviar e-mail, redimensionar imagem) em Node | BullMQ | Nativo Redis, DLQ maduro, padrões de fluxo, ~~5k jobs/s/nó típico |
| O mesmo em Ruby | Sidekiq (Pro para confiabilidade) | Mesma forma; o Pro super_fetch adiciona fetch confiável via LMOVE (Sidekiq wiki) |
| Workflows de várias etapas com fan-out de API externa, durações de horas a dias | Temporal | Execução durável; reproduz a partir do histórico de eventos (Temporal docs) |
| Nativo AWS, fanout simples, não quer rodar Redis | SQS + Lambda | Gerenciado 'pelo menos uma vez'; timeout de visibilidade + DLQ de primeira classe (AWS SQS DLQ) |
| Isolamento multi-região, multi-inquilino | Temporal ou SQS | Um único Redis não sobreviverá bem a multi-região |
| Precisa de 'exatamente uma vez' entre DB + e-mail + pagamentos | Nenhum — projete para 'pelo menos uma vez' + handlers idempotentes | "Exatamente uma vez" dentro do broker não se estende entre sistemas (Caduh) |
O critério oculto: quão complexa é a história de recuperação? Uma fila BullMQ simples com retentativas é suficiente para "enviar um recibo." Um workflow de compra de 12 etapas que pausa por 24 horas esperando um webhook é um workflow do Temporal, não 12 jobs de fila encadeados.
Idempotência
A propriedade obrigatória. Os documentos de todas as filas populares começam com isso. Da página de jobs idempotentes BullMQ: "não deve fazer diferença para o estado final do sistema se um job for concluído com sucesso em sua primeira tentativa, ou se falhar inicialmente e for bem-sucedido quando repetido." (BullMQ idempotent-jobs)
Padrões práticos:
// 1. Chave de idempotência derivada do evento de negócio, não da fila.
// Use o event_id upstream se tiver um (Stripe event.id, Shopify hook id).
const job = await queue.add(
'send-receipt',
{ orderId, amount },
{
jobId: `send-receipt:${orderId}`, // BullMQ deduplica por jobId na fila.
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
}
);
// 2. Dentro do handler, deduplique também no local do efeito colateral.
async function processSendReceipt(job) {
const { orderId, amount } = job.data;
// Se já enviamos, não faça 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) });
}
O jobId deduplica adds enquanto um job duplicado ainda está na fila. A restrição única do DB deduplica entre quedas de worker, retentativas e reproduções. Ambos são necessários; apenas o jobId é insuficiente porque o BullMQ remove jobs concluídos e a janela de deduplicação desaparece.
Fetch confiável e timeout de visibilidade
Quando um worker puxa um job e trava, o que acontece?
| Sistema | Comportamento padrão | Recuperação |
|---|---|---|
| BullMQ | O job tem um lockDuration (padrão 30s). O verificador de jobs parados (stalled-job checker) re-enfileira após o vencimento (BullMQ production) | Os workers DEVEM estender o lock ou terminar dentro de lockDuration; caso contrário, executa duas vezes |
| Sidekiq OSS | Pop não é atômico; se o worker morrer entre pop e process, o job é perdido | Atualize para Sidekiq Pro super_fetch (Redis 6.2+ LMOVE para uma fila de trabalho privada) |
| Sidekiq Pro super_fetch | LMOVE para lista de trabalho por processo. Heartbeat expira em 60s; verificação órfã varre e re-enfileira (Sidekiq wiki) | Integrado |
| SQS | Timeout de visibilidade (padrão 30s, máximo 12h). A mensagem reaparece após o timeout se não for excluída | Defina o timeout > p99 de latência do handler; estenda com ChangeMessageVisibility para jobs longos |
| Temporal | Atividades têm heartbeat + políticas de retentativa. O histórico do workflow sobrevive à morte do worker | Reproduzir a partir do histórico; você não pensa nisso |
A armadilha: lockDuration / timeout de visibilidade menor que o p99 do handler → job executa duas vezes. Maior que o tempo de recuperação aceitável → o trabalho travado espera demais. Meça seu p99 primeiro, defina o timeout para ~3x isso e faça o handler fazer heartbeat / estender o lock para trabalhos realmente longos.
Classificação de erros e DLQ
Nem todo erro deve tentar novamente para sempre. Dos documentos do SQS: "Defina o máximo de recebimentos... e a política de redrive para enviar mensagens para a DLQ assim que o limite for excedido." (AWS SQS DLQ)
// Classifique antes de lançar. Erros permanentes NÃO devem tentar novamente.
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 na primeira tentativa
if (user.suspended) throw new PermanentError('user-suspended');
await externalApi.call(user);
} catch (e) {
if (e instanceof PermanentError) {
// Mover para DLQ imediatamente — tentar novamente não vai ajudar.
job.discard(); // BullMQ: impede retentativa
throw e;
}
throw e; // Erro de rede/timeout → tentar novamente de acordo com a política de tentativas
}
}
A DLQ então precisa:
- Um alerta para contagem crescente (não apenas diferente de zero — "DLQ tem 3 coisas para sempre" é ok; "DLQ cresceu 100/min" é um incêndio).
- Uma ferramenta de reprodução (CLI ou UI administrativa) para que um humano possa corrigir o bug upstream, reproduzir os jobs na DLQ e limpá-la.
- Dashboards (veja
grafana-dashboard-builder).
Backoff e jitter
Backoff exponencial sozinho não é suficiente. Se 1000 jobs falharem todos às 12:00:00 porque um serviço downstream piscou, o backoff exponencial simples fará com que todos os 1000 tentem novamente exatamente às 12:00:01, 12:00:03, 12:00:07 — uma manada estrondosa (thundering herd) que pode manter o serviço downstream inativo. Adicione jitter total:
// "Full jitter" estilo AWS: delay = random(0, exp_backoff)
const baseDelay = Math.min(2 ** attempt * 1000, 30_000);
const delay = Math.random() * baseDelay;
Para a linha de base recomendada pelo BullMQ, o documento going-to-production sugere intervalos de retentativa entre 1s e 20s com retryStrategy. (BullMQ production)
Concorrência e limitador
A concorrência por worker executa N jobs em paralelo de um processo. Vários workers multiplicam isso. Sem um limitador no nível da fila, você não pode limitar chamadas agregadas para um downstream:
// BullMQ — limite de taxa global em todos os workers nesta fila.
const queue = new Queue('outbound-emails', {
connection,
limiter: { max: 100, duration: 1000 }, // 100 emails/s em toda a frota
});
A forma correta: muitos workers concorrentes + um limitador de fila para limites de contrato downstream. Sidekiq tem limitadores de taxa equivalentes; SQS usa concorrência Lambda ou taxa por mensagem.
Higiene de memória
A armadilha de produção mais enfatizada do BullMQ: maxmemory-policy noeviction na instância Redis. Qualquer outra coisa (allkeys-lru, volatile-lru) despejará silenciosamente jobs enfileirados sob pressão. (BullMQ production)
Também:
removeOnCompleteagressivo (ex.:{ age: 3600, count: 1000 }).removeOnFailainda mais agressivo depois de mover para um padrão DLQ.- Habilite persistência AOF do Redis —
appendfsync everysecé o ponto ideal documentado para o BullMQ. (BullMQ production) - Worker
maxRetriesPerRequest: nullpara evitar que o ioredis lance erros durante desconexões transitórias. (BullMQ production) - Lado da fila:
enableOfflineQueue: falsepara que o produtor falhe rapidamente em vez de armazenar em buffer.
Desligamento gracioso
process.on('SIGTERM', async () => {
await worker.close(); // parar de aceitar; finalizar jobs em andamento.
await connection.quit();
process.exit(0);
});
process.on('SIGINT', async () => { /* same */ });
O guia de produção BullMQ observa explicitamente: feche os workers antes de parar o processo; o timeout de stalling padrão é ~30s. (BullMQ production)
Decisão fila vs workflow
Estrutura útil dos documentos do Temporal: "Ao contrário das filas de mensagens que movem dados entre serviços, o Temporal orquestra processos inteiros... sabe onde você está em um workflow, o que foi concluído, o que está pendente e o que precisa repetir." (Temporal blog)
flowchart TD
A[Trabalho em segundo plano] --> B{Efeito colateral único ou pipeline?}
B -->|Único| C[BullMQ / Sidekiq / SQS]
B -->|Multi-etapas| D{Etapas duram segundos ou horas+?}
D -->|Segundos| E[BullMQ flows / Sidekiq batches]
D -->|Horas+ ou humano no loop| F[Temporal / DBOS / Restate]
C --> G{Precisa de isolamento multi-região ou multi-inquilino?}
G -->|Sim| H[SQS ou Temporal — Redis-cluster-único não vai funcionar]
G -->|Não| I[Rode com cluster Redis + AOF + noeviction]
Anti-padrões
Idempotência via Redis SET-NX apenas
Sintoma: Durante uma instabilidade ou reinicialização do Redis, o mesmo job é executado duas vezes e cria efeitos colaterais duplicados (cobrança, e-mail, ticket).
Diagnóstico: Redis é um cache; pode ser despejado, reiniciado ou particionado. A deduplicação SET-NX que vive apenas no Redis é 'melhor esforço'.
Correção: Chave de idempotência com uma restrição única no DB no registro de efeito colateral (ex.: email_sends.idempotency_key). A transação do DB que cria o efeito colateral É a primitiva de deduplicação.
lockDuration menor que p99 do handler
Sintoma: Entradas de log de stalled job; mesmo job processado por dois workers. Clientes reclamam.
Diagnóstico: O lockDuration padrão é 30s; o handler às vezes leva 45s; o segundo worker o pega.
Correção: Meça o p99, defina lockDuration para ~3x p99 e chame job.extendLock() dentro de handlers longos. Ou divida o trabalho.
Backoff exponencial simples (sem jitter)
Sintoma: Uma intermitência downstream se torna uma paralisação downstream; 10k jobs martelam o serviço em recuperação em sincronia. Diagnóstico: Sem jitter, as retentativas são correlacionadas. Correção: Backoff com jitter total. Limite o atraso máximo para limitar o tempo até a falha na DLQ.
maxmemory-policy allkeys-lru no Redis
Sintoma: A fila perde jobs misteriosamente sob carga. Sem erros. (BullMQ production)
Diagnóstico: O Redis despejou chaves de job para liberar espaço.
Correção: CONFIG SET maxmemory-policy noeviction E aplique na inicialização. Os workers devem se recusar a iniciar caso contrário.
Fingir 'exatamente uma vez' entre sistemas
Sintoma: Cliente cobrado duas vezes; dois e-mails iguais enviados. Engenheiro tem certeza de que "o broker é exatamente uma vez." Diagnóstico: Mesmo um broker FIFO/exatamente uma vez não estende sua garantia ao seu DB + provedor de e-mail + Stripe + ledger. Correção: Trate a fila como 'pelo menos uma vez'. Idempotência em cada fronteira de efeito colateral.
DLQ sem ferramenta de reprodução
Sintoma: "Temos 3.200 jobs na DLQ. Não sabemos o que está lá." A DLQ se torna um cemitério não monitorado. Diagnóstico: Construiu a DLQ, não construiu a ferramenta do operador. Correção: Reprodução por ID, reprodução por intervalo de tempo, drenagem com confirmação. CLI ou UI administrativa. Testada.
Trabalho de bloqueio longo no event loop
Sintoma: Processo do worker BullMQ trava; heartbeats param; lock expira; job é executado novamente.
Diagnóstico: Trabalho intensivo de CPU no mesmo processo que o heartbeat da fila; event loop bloqueia por mais de lockDuration.
Correção: Gere um processo filho ou thread de worker para trabalho intensivo de CPU. Ou execute um processador em sandbox (BullMQ suporta sandboxing de processo por job).
Portões de qualidade
- Teste: teste de caos — mate um worker no meio de um job; afirme que o job é executado novamente e o efeito colateral não é duplicado.
- Teste: teste de tempestade de retentativas — faça o downstream falhar por 60s; afirme que backoff + jitter espalha as retentativas (sem thundering herd nas métricas).
- Todo handler é idempotente e usa uma chave de idempotência estável derivada do evento de negócio.
- Uma restrição única no nível do DB faz backup de qualquer deduplicação no Redis (jobId, SET-NX).
-
lockDuration/ timeout de visibilidade ≥ 3× p99 medido de latência do handler, E handlers longos estendem o lock. - Erros permanentes (classe
PermanentErrorou equivalente) ignoram retentativa e vão direto para a DLQ. - Alertas da DLQ: dispare quando a taxa de crescimento > N por minuto, não apenas contagem > 0.
- Ferramenta de reprodução da DLQ existe e é testada (única + intervalo).
- Redis:
maxmemory-policy=noeviction, AOF habilitado (appendfsync everysec), aplicado na inicialização do worker. (BullMQ production) -
removeOnCompleteeremoveOnFailconfigurados. A métrica de contagem de jobs é monitorada.
Referências
- Caduh — Filas 101
- AWS SQS DLQ
- Sidekiq wiki: confiabilidade
- BullMQ: indo para produção
- BullMQ: jobs idempotentes
- Temporal: visão geral
- Temporal: fila vs workflow
Esta skill foi traduzida para o português.