Background Jobs

Processamento em Lote

O processamento em lote (batch processing) move tarefas lentas e passíveis de reexecução para fora do caminho da requisição e as envia para workers. A fila intermediária é o que torna o sistema resiliente, observável e seguro para rodar em escala.

intermediate15 min readUpdated 16 de set. de 2026
worker.ts
ts
// worker.ts
import { Worker } from "bullmq";

const worker = new Worker(
  "emails",
  async (job) => {
    await sendEmail(job.data.to, job.data.template);
  },
  { connection, concurrency: 10 },
);

worker.on("failed", (job, err) => {
  console.error(`job ${job?.id} failed:`, err.message);
});
Entrega
At-least-once
Brokers comuns
Redis, SQS, RabbitMQ
Estratégia de retry
Exponential backoff com jitter
Destino de falhas
Dead-letter queue
Estados do job
queued, active, completed, failed
Ideal para
Trabalhos lentos, repetíveis e com picos de demanda

Por que importa

O que uma fila de jobs oferece

Trabalhos lentos saem da requisição

Um handler enfileira um job e retorna imediatamente. Redimensionamento de imagens, geração de PDF, e-mails e importações acontecem depois que a resposta já foi enviada.

Retentativas fazem parte do design

Falhas transitórias são esperadas, portanto, backoff, jitter e limites de tentativas são integrados à fila, em vez de serem improvisados no handler.

Workers escalam independentemente

Adicione processos de worker quando a fila crescer e remova-os quando ela esvaziar, sem tocar na API que produz o trabalho.

O panorama completo

As três partes de todo sistema de jobs

Um produtor enfileira o trabalho, uma fila o armazena de forma durável e um worker o processa em seu próprio cronograma.

Produtor

Enfileirar

Qualquer parte do sistema pode adicionar um job com um payload e um nome. O produtor não espera o trabalho terminar.

Fila

Buffer

Um armazenamento ordenado e durável mantém os jobs até que um worker esteja livre, absorvendo picos e sobrevivendo a reinicializações.

Worker

Processar

Um processo de longa duração extrai jobs, executa o handler sob um timeout e reporta sucesso ou falha de volta para a fila.

Fluxo

O ciclo de vida do job

Todo job segue o mesmo caminho, quer tenha sucesso na primeira tentativa ou termine em uma dead-letter queue após dez tentativas.

  1. 1

    Enfileirar um job

    O produtor escreve um job com um id único, um payload e um horário de execução na fila e retorna sem esperar.

  2. 2

    Um worker o coleta

    Um worker ocioso reivindica atomicamente o próximo job devido e o marca como ativo para que nenhum outro worker possa pegá-lo.

  3. 3

    Processar com timeout

    O handler roda com um prazo rígido. Um job que excede esse prazo é tratado como falha, em vez de ser permitido que ele fique travado para sempre.

  4. 4

    Sucesso ou falha

    Em caso de sucesso, o job é removido ou arquivado. Em caso de falha, a contagem de tentativas é incrementada e o erro é registrado.

  5. 5

    Retry com backoff

    O job é agendado novamente após um atraso crescente, acrescido de jitter, para que uma dependência instável não seja bombardeada por todos os workers ao mesmo tempo.

  6. 6

    Mover para a dead-letter queue

    Após o limite de tentativas, o job é colocado de lado com seu payload e o último erro para que um humano possa inspecioná-lo e reexecutá-lo.

O guia completo

Processamento em Lote: Tudo que voce precisa saber

O que é processamento em lote (batch processing)?

O processamento em lote é a prática de pegar tarefas que são lentas demais, frágeis demais ou que ocorrem em picos muito intensos para serem executadas enquanto um usuário espera, e delegá-las a um processo separado que as executa em seu próprio cronograma. A requisição do usuário faz apenas o mínimo necessário — validar a entrada, gravar uma linha, enfileirar um job — e retorna. Todo o restante acontece de forma assíncrona (out of band).

O nome vem dos jobs da era dos mainframes, que processavam uma pilha de registros em uma única execução, e a ideia não mudou. Em vez de uma fita, você tem uma fila. Em vez de uma janela noturna, você tem workers consumindo tarefas continuamente. O que permanece constante é a separação: a entidade que aceita o trabalho e a entidade que o executa são diferentes, e existe um buffer entre elas.

Essa separação é o ponto principal. Ela permite que o caminho da requisição permaneça rápido e previsível, enquanto o caminho lento leva o tempo que for necessário, tenta novamente em caso de falha e escala em uma curva diferente.

Por que tarefas pesadas não devem ficar em uma requisição

Uma requisição HTTP síncrona é um lugar ruim para tarefas lentas, por três razões que se acumulam.

Primeiro, os timeouts. Proxies, load balancers e clientes impõem prazos. Uma requisição que gera um PDF de 200 páginas, chama uma API de terceiros instável e envia um e-mail pode facilmente excedê-los. Quando isso acontece, o cliente vê um erro, embora a tarefa possa ter sido parcialmente concluída no servidor.

Segundo, o bloqueio do event loop. O Node.js executa JavaScript em uma única thread. Uma tarefa síncrona pesada de CPU — processamento de imagem, um parse de JSON volumoso, um loop criptográfico — impede que qualquer outra requisição seja atendida enquanto estiver em execução. Mesmo tarefas assíncronas retêm recursos: uma conexão de banco de dados aberta, um handle de arquivo, memória para a resposta.

Terceiro, retentativas são impossíveis. Se o provedor de e-mail retornar um 503, um handler inline não tem boas opções. Ele pode falhar a requisição inteira e forçar o usuário a tentar novamente, ou ignorar o erro e perder o e-mail. Uma fila oferece uma terceira resposta para a mesma falha: tentar novamente mais tarde, automaticamente, sem que o usuário saiba.

app.post("/reports", async (req, res) => {
  const report = await db.report.create({ data: { userId: req.user.id } });

  await reportsQueue.add("generate", { reportId: report.id });

  res.status(202).json({ id: report.id, status: "queued" });
});

O status 202 Accepted é a resposta honesta aqui: o servidor aceitou a requisição, mas não terminou o trabalho. Este é o padrão de todo endpoint de processamento em lote (batch) bem implementado.

A anatomia de um job

Um job é um registro pequeno e autoexplicativo. A maioria das filas armazena algo como isto:

{
  "id": "welcome:user_42",
  "name": "welcome",
  "data": { "userId": "user_42", "to": "[email protected]", "template": "welcome" },
  "status": "queued",
  "attemptsMade": 0,
  "maxAttempts": 5,
  "runAt": 1760000000000,
  "createdAt": 1759999100000
}

Cada campo tem a sua razão de existir. O id torna o job endereçável e, quando é derivado do trabalho em vez de ser aleatório, oferece deduplicação gratuitamente. O name roteia o job para um handler. O payload carrega tudo o que o worker precisa — e nada mais, pois um payload que aponta para uma linha do banco de dados é menor e mais atual do que um que a copia. O status rastreia o job ao longo do seu ciclo de vida. attemptsMade e maxAttempts controlam as tentativas de reexecução (retries). runAt agenda trabalhos atrasados ou recorrentes.

O payload deve ser um snapshot da intenção, não um objeto vivo. Se um usuário atualizar o e-mail entre o enfileiramento e a execução, o job ainda deve enviar para o endereço com o qual foi criado. No entanto, armazenar todo o registro do usuário é um erro: isso infla a fila e torna os dados obsoletos. Armazene ids e os poucos valores que definem o trabalho.

Payloads de jobs e versionamento

Uma fila é uma interface persistente entre dois deploys. Um produtor executando a versão 1 do código pode escrever um job que um worker executando a versão 2 deve ler. Este é o mesmo problema de compatibilidade de uma API, e é fácil ignorá-lo até que um deploy quebre o backlog.

Dois hábitos mantêm os payloads compatíveis. Primeiro, adicione campos em vez de renomeá-los ou removê-los, e atribua um valor padrão sensato aos novos campos no handler. Um worker que tolera a ausência de locale consegue processar jobs enfileirados antes de o campo existir. Segundo, inclua uma versão no payload quando a estrutura puder mudar significativamente:

await queue.add("import", { version: 2, importId, mapping });

O handler então faz uma ramificação baseada em version e sabe exatamente como interpretar o restante. Isso custa quase nada e transforma uma classe de incidentes pós-deploy em uma simples tabela de consulta.

Mantenha os payloads pequenos. Uma fila armazena cada job em espera, portanto, um payload que incorpora um objeto grande se multiplica por milhares de linhas e torna cada scan mais lento. Referencie os dados por id e deixe que o worker os busque. A única exceção é um valor que deve ser congelado no momento do enqueue — o destinatário de um e-mail, o preço cotado para um cliente — que pertence ao payload precisamente porque não deve mudar.

Filas, cron e gatilhos orientados a eventos

Nem toda tarefa de background precisa de uma fila, e escolher o gatilho errado torna um problema simples em algo complicado.

O Cron executa um handler com base em um horário fixo: toda noite às 02:00, toda segunda-feira às 09:00. É a ferramenta certa para conciliações periódicas, geração de relatórios e limpeza de dados. Sua fraqueza é que ele não possui o conceito de unidades de trabalho. Um cron job que demore mais do que o seu intervalo irá se sobrepor a si mesmo, e uma execução perdida é simplesmente ignorada.

Gatilhos orientados a eventos reagem a algo que acabou de acontecer: um webhook chegou, uma linha foi inserida no banco, um arquivo foi enviado para o storage. Eles são imediatos e naturais, mas não oferecem buffering, retry ou backpressure. Um handler de webhook que realiza um trabalho pesado é apenas uma requisição inline disfarçada.

As Filas ficam entre os dois. Um job é uma unidade de trabalho durável e com suporte a retry que pode ser agendada para agora ou para depois. A maioria dos sistemas em produção utiliza os três: o cron coloca um job de fan-out na fila, eventos enfileiram jobs em resposta a ações do usuário, e workers processam a fila. A regra geral é que o cron e os eventos decidem quando trabalhar, e a fila decide como trabalhar.

Producers, workers e a fila

Três papéis compõem o sistema, e mantê-los distintos garante que ele continue sustentável.

O producer é qualquer código que adiciona um job. Ele conhece o nome do job e o formato do payload, e nada mais. Ele deve ser rápido e seguro para ser chamado duas vezes — se o próprio producer tentar novamente após um timeout, você não vai querer dois jobs.

A fila é um armazenamento durável e ordenado. Redis com BullMQ, Amazon SQS, RabbitMQ e Google Cloud Tasks desempenham esse papel. A fila persiste os jobs entre reinicializações, os distribui de forma atômica, rastreia as tentativas e move os jobs esgotados para o lado. Você também pode construir uma usando uma tabela de banco de dados, o que é um começo razoável quando o volume é baixo, mas torna-se um problema quando deixa de ser.

O worker é um processo de longa execução que consome os jobs e executa os handlers. Ele é separado da API por bons motivos: pode ser implantado em hardware otimizado para CPU, escalado de acordo com a profundidade da fila e reiniciado sem derrubar requisições. No BullMQ, essa separação é explícita:

import { Worker } from "bullmq";
import { connection } from "./queue.js";

const worker = new Worker(
  "emails",
  async (job) => {
    await sendEmail(job.data);
  },
  { connection, concurrency: 10 },
);

Um único processo pode hospedar vários workers para filas diferentes, e uma única fila pode ser atendida por muitos processos worker. A fila é o único estado compartilhado, e é exatamente por isso que ela escala horizontalmente de forma tão limpa.

Escolhendo um broker

Os três brokers que você encontrará com mais frequência situam-se em pontos diferentes de um espectro entre funcionalidades e peso operacional.

Redis com BullMQ é o padrão para times de Node.js. É provável que o Redis já esteja na sua stack, o cliente é maduro e o BullMQ adiciona jobs atrasados, jobs repetíveis, prioridades, rate limiting, retentativas e uma UI. O ponto negativo é que o Redis é primariamente um armazenamento em memória, portanto, a durabilidade depende de como você configura a persistência. Um job confirmado, mas ainda não gravado no disco, pode ser perdido se a instância cair.

Amazon SQS é totalmente gerenciado e possui throughput efetivamente ilimitado. Você obtém durabilidade e disponibilidade sem precisar operar nada, pagando por requisição. Em troca, você abre mão de certa ergonomia: a entrega atrasada é limitada, não há um agendador integrado para jobs estilo cron e a API é de nível mais baixo do que um framework de jobs.

RabbitMQ é o mais flexível. Exchanges e routing keys permitem que uma mensagem seja distribuída para vários consumidores, e confirmações por mensagem, prioridades e dead-letter exchanges são recursos nativos. Ele é mais pesado de operar do que o Redis e possui uma curva de aprendizado mais acentuada, mas é ideal para roteamentos complexos.

A orientação sincera é começar com o que você já utiliza. Uma fila no Redis é muito melhor do que nenhuma fila por você estar esperando para avaliar brokers. Mude quando uma limitação específica — durabilidade, throughput ou roteamento — realmente se tornar um problema.

Idempotência e entrega at-least-once

O fato mais importante sobre filas de jobs é que a entrega é at-least-once (pelo menos uma vez), e não exatamente uma vez. Um worker pode travar após realizar o trabalho, mas antes de confirmá-lo, e a fila entregará o job novamente. Uma instabilidade na rede pode fazer com que a confirmação desapareça. O trabalho será executado novamente.

Isso não é um bug para ser contornado; é o contrato. A entrega exatamente-uma-vez através de uma rede é efetivamente impossível, então as filas optam por at-least-once e transferem a responsabilidade para você. Seu handler deve ser idempotente: executá-lo duas vezes deve produzir o mesmo estado final que executá-lo uma única vez.

Existem três técnicas práticas.

Idempotência natural. Algumas operações já são seguras para repetir. Definir o status de um usuário para active duas vezes é o mesmo que definir uma vez. Deletar uma linha por id na segunda vez é uma operação nula (no-op). Prefira estas abordagens sempre que possível.

Uma chave de deduplicação (dedupe key). Grave um marcador indexado pelo trabalho antes de realizá-lo e pule a execução se o marcador já existir. Uma constraint de unicidade ou um SET NX no Redis torna a verificação atômica, impedindo que dois workers concorrentes vençam simultaneamente.

export async function handleCharge(job) {
  const key = `charged:${job.data.orderId}`;
  const inserted = await connection.set(key, "1", "NX", "EX", 86_400);
  if (inserted === null) return { skipped: true };

  await stripe.charges.create(
    { amount: job.data.amount, source: job.data.token },
    { idempotencyKey: job.data.orderId },
  );
}

Chaves de idempotência do provedor. Gateways de pagamento e muitas outras APIs aceitam uma chave de idempotência. Passe o id estável do job e o provedor retornará o resultado original em vez de cobrar duas vezes. Sempre combine isso com sua própria deduplicação, pois a chave protege apenas a chamada, não a lógica ao redor.

Observe que a chave de deduplicação é derivada do trabalho — o id do pedido — e não do id do job. Isso é deliberado: torna o handler seguro mesmo que o produtor enfileire o mesmo trabalho lógico duas vezes.

Retentativas com exponential backoff e jitter

Falhas transitórias são normais. Um banco de dados sofre failover, uma API aplica rate-limit, um container é reagendado. Tentar novamente é a resposta correta, mas tentar imediatamente não é.

O exponential backoff aumenta o atraso a cada tentativa: aproximadamente 2s, 4s, 8s, 16s, 32s. Isso dá tempo para que uma dependência instável se recupere, em vez de ser bombardeada por requisições. O jitter adiciona um valor aleatório a cada atraso para que muitos jobs que falharam juntos não tentem novamente ao mesmo tempo, o que recriaria o pico que causou a falha original.

A maioria das filas suporta isso de forma declarativa. No BullMQ, isso é uma propriedade do job:

await queue.add(
  "sync",
  { accountId },
  {
    attempts: 5,
    backoff: { type: "exponential", delay: 2_000 },
  },
);

Isso produz atrasos de cerca de 2s, 4s, 8s, 16s e 32s, com o jitter da própria fila aplicado. Quando você implementar o backoff manualmente, adicione o jitter por conta própria e limite o atraso máximo para que um job não fique dormindo por um dia inteiro:

function nextDelay(attempt: number, base = 1_000, cap = 60_000) {
  const exponential = Math.min(cap, base * 2 ** attempt);
  const jitter = Math.random() * exponential * 0.5;
  return Math.round(exponential + jitter);
}

Defina um limite de tentativas e decida deliberadamente o que acontece quando esse limite é atingido. Alguns jobs devem tentar indefinidamente em uma frequência baixa — como uma tarefa de reconciliação, por exemplo — mas a maioria deve parar e solicitar intervenção.

Dead-letter queues e poison messages

Uma poison message é um job que falha toda vez que é executado: dados malformados, um bug no handler ou uma linha de referência ausente. Como ele sempre falha, consome um slot de worker a cada tentativa e pode impedir a execução de jobs saudáveis. Tentá-lo indefinidamente é pior do que não tentar de forma alguma.

A solução é uma dead-letter queue (DLQ). Após o limite de tentativas ser atingido, a fila move o job — payload, tentativas e o último erro — para uma área de retenção separada. Os workers nunca a acessam, portanto ela não consegue travar nada, e um operador pode inspecioná-la, corrigir a causa e reprocessá-la.

const worker = new Worker("emails", handler, { connection });

worker.on("failed", async (job, err) => {
  if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
    await deadLetter.add("failed-email", {
      payload: job.data,
      error: err.message,
      failedAt: new Date().toISOString(),
    });
    await alerting.notify(`job ${job.id} exhausted retries`);
  }
});

Trate a DLQ como uma superfície operacional, não como um cemitério. Configure alertas para quando o volume de mensagens crescer, crie um dashboard e implemente um caminho de replay. Uma DLQ que ninguém lê é onde os bugs vão para se esconder.

Concorrência, rate limiting e backpressure

A concorrência de um worker é a quantidade de jobs que ele processa simultaneamente. Aumentá-la eleva o throughput até que o worker esgote a CPU, as conexões de banco de dados ou a memória, ponto em que a situação piora. O valor ideal é o maior número que mantenha cada dependência confortavelmente abaixo de seu limite.

A concorrência também é a primeira linha de defesa contra o efeito thundering herd. Se uma API downstream permite 50 requisições por segundo, um worker com concorrência 200 irá sobrecarregá-la. Muitas filas oferecem um rate limiter exatamente para isso:

const worker = new Worker("sync", handler, {
  connection,
  concurrency: 10,
  limiter: { max: 50, duration: 1_000 },
});

Backpressure é o que impede que a própria fila cresça indefinidamente. Se os produtores adicionam jobs mais rápido do que os workers conseguem processá-los, a fila torna-se um backlog crescente e sua latência passa a ser de horas. As opções incluem pausar os produtores quando a profundidade da fila ultrapassa um limite, rejeitar jobs de baixa prioridade e escalar os workers automaticamente. Uma fila que apenas cresce é uma queda de sistema que ainda não foi notada.

Mantenha as filas separadas por tipo de carga de trabalho. Uma importação noturna lenta e um reset de senha sensível ao tempo não devem compartilhar a mesma fila.

Agrupando gravações no banco de dados

O banco de dados geralmente é o gargalo em um job de processamento em lote, e a causa mais comum é a comunicação com ele linha por linha. Cada ida e volta (round trip) possui um overhead fixo — rede, parsing, planejamento — que torna o custo da linha em si insignificante. Um loop de inserts individuais gasta a maior parte do tempo esperando.

Um multi-row insert move os mesmos dados em um único statement:

const values = chunk
  .map((_, n) => `($${n * 3 + 1}, $${n * 3 + 2}, $${n * 3 + 3})`)
  .join(",");

await pool.query(
  `INSERT INTO orders (user_id, status, total_cents)
   VALUES ${values}
   ON CONFLICT (external_id) DO UPDATE
     SET status = EXCLUDED.status`,
  chunk.flatMap((r) => [r.userId, r.status, r.totalCents]),
);

A cláusula ON CONFLICT ... DO UPDATE transforma o insert em um upsert, que é o que torna a gravação em massa idempotente. Executar o job novamente atualiza as linhas existentes em vez de criar duplicatas, tornando a tentativa de reexecução segura.

Dois alertas. Mantenha os chunks limitados — de algumas centenas a alguns milhares de linhas — porque um statement parametrizado possui um limite de parâmetros e um statement muito grande retém locks e memória por mais tempo. Além disso, envolva um chunk em uma transaction se as linhas precisarem ser gravadas juntas, mas mantenha a transaction curta para não bloquear outros escritores.

Para cargas muito grandes, um caminho de bulk dedicado, como o COPY do Postgres, é ainda mais rápido, conforme abordado no guia de PostgreSQL.

Fragmentando grandes conjuntos de dados

Um job em lote que processa “todos os usuários” não consegue carregar todos eles na memória. A solução é fragmentar (chunking) o trabalho: processe uma página limitada, faça o commit e, em seguida, busque a próxima. Isso mantém o uso de memória estável e permite que o job seja retomado de onde parou.

A paginação por keyset é a maneira robusta de fazer isso. Em vez de OFFSET, que fica mais lento à medida que o volume cresce e pode pular ou repetir linhas quando os dados mudam simultaneamente, você armazena a última chave visualizada:

let cursor: string | null = null;

while (true) {
  const batch = await pool.query(
    `SELECT id, email FROM users
     WHERE ($1::text IS NULL OR id > $1)
     ORDER BY id
     LIMIT 1000`,
    [cursor],
  );

  if (batch.rowCount === 0) break;

  await processBatch(batch.rows);
  cursor = batch.rows[batch.rows.length - 1].id;
}

Cada fragmento é independente, portanto, uma falha no meio da execução perde apenas o fragmento atual, e o job pode ser retomado passando o último cursor. Isso se integra naturalmente a uma fila: coloque um job na fila por fragmento, para que uma única falha não force o reinício de toda a execução.

Agendando jobs recorrentes

Trabalhos recorrentes — como resumos diários, limpeza de dados ou conciliação — são melhor expressos como um job repetível do que como uma entrada de cron que chama um endpoint HTTP. Dessa forma, a fila passa a ser a responsável pelo agendamento, pela proteção contra sobreposição e pela política de tentativas (retry).

await reportsQueue.add(
  "daily-digest",
  { region: "eu" },
  {
    repeat: { pattern: "0 7 * * *" },
    jobId: "daily-digest:eu",
    attempts: 3,
  },
);

O jobId estável é fundamental: ele evita que o agendador empilhe uma nova cópia caso uma ainda esteja em execução, e permite que cada instância do app registre o mesmo agendamento sem criar duplicatas. Ainda assim, é prudente utilizar um lock distribuído em torno da execução real para jobs que jamais podem rodar duas vezes simultaneamente.

Prefira UTC para os agendamentos e torne o job ciente dos fusos horários ao formatar a saída. Um resumo enviado às 07:00 UTC não é o mesmo que um às 07:00 local, e essa diferença resulta em um ticket de suporte a cada mudança de horário de verão.

Observabilidade

Um job em segundo plano é invisível, a menos que você o torne visível. Quatro sinais cobrem a maior parte do que você precisa.

  • Profundidade da fila (Queue depth) — quantos jobs estão aguardando. Uma profundidade crescente significa que os workers não estão conseguindo acompanhar a demanda, o que é o aviso mais precoce de uma queda no serviço.
  • Duração do job — um histograma por nome de job. Um p95 que sobe com o tempo sinaliza que alguma dependência está ficando lenta.
  • Taxa de falha — falhas por minuto, divididas por nome de job. Um pico após um deploy aponta diretamente para a alteração realizada.
  • Idade do job mais antigo aguardando — a profundidade diz quantos, a idade diz o quão grave é. Dez mil jobs que são processados em um segundo estão ok; dez que esperaram por uma hora não estão.

Adicione um correlation id ao payload de cada job e inclua-o nos logs, para que um job possa ser rastreado desde a requisição que o criou até cada tentativa de reprocessamento (retry). Sem isso, debugar a falha de um worker significa fazer grep em timestamps e tentar adivinhar o que aconteceu.

await queue.add("import", { importId, correlationId: req.id });

Exponha as métricas no mesmo dashboard da sua API e configure alertas para a profundidade da fila e a idade do job mais antigo, em vez de alertas para falhas individuais, que são esperadas.

Graceful shutdown

Um worker encerrado no meio de um job deixa esse job em um estado ambíguo. A fila acabará por entregá-lo novamente, o que é correto, porém ineficiente, e um encerramento abrupto pode interromper uma transação de banco de dados no pior momento possível.

Trate SIGTERM e encerre o worker deliberadamente:

process.on("SIGTERM", async () => {
  await worker.close();
  await connection.quit();
  process.exit(0);
});

worker.close() interrompe a aceitação de novos jobs e aguarda a conclusão dos que estão em execução. Combine isso com um período de carência (grace period) de deployment longo o suficiente para o job mais lento, e limite os timeouts dos jobs para que nenhum job individual ultrapasse esse tempo. Se um job for genuinamente demorado, implemente checkpoints de progresso para que ele possa ser retomado em vez de reiniciado.

A mesma disciplina se aplica à conexão: feche o pool do banco de dados e o cliente do broker para que o processo seja encerrado de forma limpa, em vez de ficar travado em sockets abertos.

Melhores práticas

  • Mantenha os request handlers limitados a uma escrita e um enqueue; retorne 202 quando o trabalho for adiado.
  • Torne cada handler idempotente, pois a entrega é do tipo at-least-once.
  • Derive as chaves de deduplicação do trabalho em si, e não de um job id aleatório.
  • Use exponential backoff com jitter e limite o tempo de atraso.
  • Defina um limite de tentativas e direcione jobs esgotados para uma dead-letter queue.
  • Separe as filas por carga de trabalho para que jobs lentos não prejudiquem os urgentes.
  • Limite a concorrência de acordo com a capacidade do seu banco de dados e APIs downstream.
  • Agrupe escritas no banco de dados com multi-row inserts ou upserts, em chunks limitados.
  • Pagine grandes conjuntos de dados com keyset pagination, não OFFSET.
  • Monitore a profundidade da fila, a duração do job, a taxa de falhas e a idade do job mais antigo.
  • Encerre os workers de forma graciosa (gracefully) e configure um período de carência correspondente nos deploys.

Erros comuns

  • Fazer chamadas lentas para terceiros durante a requisição e achar que está tudo bem porque funciona localmente.
  • Assumir que um job é executado exatamente uma vez e acabar cobrando o cliente duas vezes.
  • Tentar novamente de forma imediata, sem backoff, amplificando a falha original.
  • Tentar processar uma poison message infinitamente e causar o starvation da fila.
  • Executar um único job gigante que processa todas as linhas em uma única transação.
  • Inserir linhas um comando por vez e culpar o banco de dados.
  • Configurar a concorrência tão alta que o banco de dados atinge o limite de conexões.
  • Agendar jobs recorrentes com um id aleatório e acumular duplicatas.
  • Nunca olhar a dead-letter queue.
  • Matar workers com SIGKILL e perder o trabalho em andamento.
  • Deixar a profundidade da fila fora do dashboard até acontecer o primeiro incidente.

Próximos passos

Uma fila é tão boa quanto o armazenamento por trás dela, então o guia de Redis é a leitura natural seguinte para o broker que a maioria dos times de Node.js utiliza. O guia de Caching mostra como evitar de realizar o trabalho completamente, e o de Connection Pooling explica como evitar que uma frota de workers esgote seu banco de dados. Quando o trabalho envolve escrita em massa, o guia de PostgreSQL cobre COPY, upserts e os formatos de transação que o tornam rápido.

Na pratica

Enfileirar, processar, deduplicar, escrita em lote

As quatro operações que compõem quase todo pipeline de background.

jobs/queue.ts
import { Queue } from "bullmq";
import IORedis from "ioredis";

export const connection = new IORedis({ maxRetriesPerRequest: null });
export const emails = new Queue("emails", { connection });

export async function enqueueWelcome(userId: string, to: string) {
  await emails.add(
    "welcome",
    { userId, to, template: "welcome" },
    {
      jobId: `welcome:${userId}`,
      attempts: 5,
      backoff: { type: "exponential", delay: 2_000 },
      removeOnComplete: 1_000,
      removeOnFail: false,
    },
  );
}

Background job vs trabalho inline

Executar trabalhos pesados dentro da requisição acopla a latência do usuário a terceiros. Enfileire-o e deixe o worker gerenciar as retentativas.

Preferir
app.post("/signup", async (req, res) => {
  const user = await db.user.create({ data: req.body });

  await emails.add("welcome", { userId: user.id, to: user.email });

  res.status(201).json({ id: user.id });
});
Evitar
app.post("/signup", async (req, res) => {
  const user = await db.user.create({ data: req.body });

  // A slow mail provider now owns your p99 latency,
  // and a timeout loses the email entirely.
  await sendWelcomeEmail(user.email);

  res.status(201).json({ id: user.id });
});

Bulk insert vs linha por linha

Uma ida e volta (round trip) por linha gasta a maior parte do tempo na rede. Uma instrução de múltiplas linhas move os mesmos dados em uma fração do tempo.

Preferir
const values = chunk
  .map((_, n) => `($${n * 2 + 1}, $${n * 2 + 2})`)
  .join(",");

await pool.query(
  `INSERT INTO events (user_id, name) VALUES ${values}`,
  chunk.flatMap((e) => [e.userId, e.name]),
);
Evitar
for (const event of chunk) {
  await pool.query(
    "INSERT INTO events (user_id, name) VALUES ($1, $2)",
    [event.userId, event.name],
  );
}

Trade-offs

Uma fila vale a complexidade da infraestrutura?

Uma fila de jobs adiciona infraestrutura e um novo modo de falha. Para trabalhos genuinamente lentos ou repetíveis, ela se paga imediatamente.

Strengths

  • Requisições permanecem rápidas

    O usuário espera por uma escrita no banco de dados e um enfileiramento, não por uma renderização de PDF ou uma API de terceiros. A latência torna-se previsível.

  • Falhas tornam-se recuperáveis

    Uma queda no meio de um job não faz o trabalho ser perdido. A fila o redeliver e a política de retry decide quanta paciência ter.

  • Carga é suavizada

    Um pico de cadastros preenche a fila em vez de sobrecarregar um serviço downstream, e os workers a esvaziam em um ritmo controlado.

Trade-offs

  • A entrega é at-least-once

    Um job pode rodar mais de uma vez após um timeout ou crash. Handlers que cobram cartões ou enviam mensagens devem ser idempotentes.

  • O debug abrange múltiplos processos

    Uma falha reside no log do worker, não no log da requisição. Sem correlation ids e métricas de fila, os jobs desaparecem em uma caixa preta.

  • Mais infraestrutura para manter

    Redis, SQS ou RabbitMQ precisam de monitoramento, planejamento de capacidade e um plano para quando o broker estiver indisponível.

Perguntas frequentes

Perguntas frequentes

Keep learning

Related topics from the roadmap.

$ comecar a aprender

Pronto para aprender Batch Processing?

Nosso tutorial interativo te guia por Batch Processing passo a passo — com quizzes e codigo real que voce pode executar no navegador.