Pular para conteúdo

Trabalho Assíncrono

Quando o pagamento de um pedido é aprovado, a BoxOffice tem muito a fazer: marcar o pedido como confirmado, transformar o hold em assentos vendidos, gerar o PDF do ingresso com um QR code (cerca de 800 ms de CPU), enviá-lo por e-mail pelo provedor (300 ms, às vezes 5 s), mandar uma notificação push, atualizar o painel de vendas e reindexar a disponibilidade para a busca. Se a requisição de checkout fizer tudo isso antes de responder, o fã espera segundos, cada provedor lento deixa o checkout lento, e uma queda do e-mail derruba a venda de ingressos.

Nada desse trabalho muda a resposta que o fã está esperando — "seu pedido está confirmado". Ele pode acontecer um instante depois, em outro lugar. Esta página trata desse "outro lugar": workers que processam jobs de uma fila e schedulers que iniciam trabalho num horário definido — e de como deixar ambos corretos quando jobs são reexecutados e réplicas rodam em paralelo.


O que pertence ao caminho da requisição?

Faça uma pergunta sobre cada etapa: a resposta ao usuário depende dela?

Etapa depois de "pagar" A resposta depende dela? Onde roda
Verificar que o pagamento foi aprovado, confirmar o hold → pedido CONFIRMED sim — ela é a resposta na requisição (ou no handler do webhook de pagamento)
Gerar o PDF do ingresso não worker
Enviar o e-mail com o ingresso não worker (notification)
Notificação push não worker (notification)
Atualizar o painel de vendas não consumidor de analytics
Atualizar a disponibilidade na busca não consumidor de search

Tudo abaixo da primeira linha vira um job (uma unidade de trabalho descrita por uma mensagem pequena) colocado numa fila e executado por workers (processos que puxam jobs e os executam).

sequenceDiagram
    participant F as Fã
    participant O as order
    participant Q as Fila / Kafka
    participant W as worker de ingressos
    participant N as worker de notificação
    participant E as Provedor de e-mail
    F->>O: POST /orders/ord_9/confirm
    O->>O: pedido CONFIRMED (uma transação no BD)
    O->>Q: OrderConfirmed {orderId: ord_9}
    O-->>F: 200 Seu pedido está confirmado
    Q->>W: gerar ingressos de ord_9
    W->>W: PDF + QR → object store
    W->>Q: TicketsIssued {orderId: ord_9}
    Q->>N: TicketsIssued
    N->>E: enviar e-mail com o link
    E-->>N: 202 na fila

O que você ganha:

  • Latência: o checkout responde em dezenas de milissegundos em vez de segundos.
  • Disponibilidade: o provedor de e-mail pode ficar fora do ar por 20 minutos; os jobs esperam na fila e o checkout continua vendendo.
  • Nivelamento de carga: a fila absorve o pico da abertura de vendas; os workers a esvaziam no próprio ritmo, constante.
  • Escala independente: gerar ingressos consome CPU, enviar e-mail é limitado por I/O — cada pool de workers escala por conta própria.

O que você paga: o resultado chega eventualmente, não imediatamente; é preciso mostrar o status ("seus ingressos estão sendo emitidos"), tratar falhas sem um usuário a quem mostrar um erro e tornar cada job seguro para rodar mais de uma vez.

Publicando com confiabilidade

"Fazer commit do pedido e depois publicar o evento" tem uma brecha: se o processo morrer entre os dois, o pedido existe e nenhum ingresso é emitido. O transactional outbox fecha essa brecha — grave o evento numa tabela outbox na mesma transação do pedido e deixe um relay publicá-lo. A aula 8 (mensageria) trata disso; o hands-on 7 o implementa.

No código do BoxOffice: o e-mail sai da requisição (lab 7)

O Hands-on 7 — Outbox mantém as duas versões do POST /orders atrás de uma chave, NOTIFY_MODE, para que você meça a diferença. Em sync o fã espera enquanto o order chama o notification; em outbox o pedido e seu evento OrderPlaced são dois inserts numa mesma transação, e o e-mail acontece depois. Numa execução de teste num notebook ocupado, a 50 pedidos/s, o p50 foi de 4,12 s (sync) para 47 ms (outbox); com o notification parado por 20 s, cerca de um terço dos pedidos sync falhou e nenhum dos outbox (medições). Note também o ON CONFLICT (hold_id) DO NOTHING: um pedido repetido para o mesmo hold não cria nada novo nem emite um segundo evento.

services/order/src/main/java/boxoffice/order/OrderService.java (place an order)
@Transactional
public PlaceResult place(OrderRequest req) {
    int inserted = jdbc.sql("""
            INSERT INTO orders (id, show_id, hold_id, customer_email, amount, status)
            VALUES (?, ?, ?, ?, ?, 'PENDING_PAYMENT')
            ON CONFLICT (hold_id) DO NOTHING
            """)
            .params(UUID.randomUUID(), req.showId(), req.holdId(), req.customerEmail(), req.amount())
            .update();

    OrderView order = jdbc.sql(SELECT + " WHERE hold_id = ?")
            .param(req.holdId())
            .query(OrderView.class)
            .single();

    if (inserted == 0) {
        // same hold already ordered: idempotent replay, no second event
        return new PlaceResult(order, false);
    }

    OrderEvent event = OrderEvent.placed(order);
    if (props.notifyMode() == NotifyMode.SYNC) {
        notifications.send(event); // BEFORE: the fan waits for the e-mail
    } else {
        outbox.append(event.eventId(), "order", order.id(), event.eventType(), event);
    }
    return new PlaceResult(order, true);
}

Quantos workers?

Workers são dimensionados como qualquer servidor, com a Lei de Little: o número de jobs em andamento é igual à taxa de chegada vezes o tempo que cada job leva.

\[ \text{workers needed} = \text{job arrival rate} \times \text{time per job} \]

Quando as chegadas superam a capacidade, o excesso se acumula como backlog, e o backlog só é drenado à diferença entre capacidade e chegadas:

\[ \text{backlog growth} = \lambda_{\text{in}} - \mu_{\text{workers}}, \qquad \text{drain time} = \frac{\text{backlog}}{\mu_{\text{workers}} - \lambda_{\text{in}}} \]

Exemplo resolvido — geração de ingressos na abertura de vendas

Suponha (valores ilustrativos) que 60.000 assentos sejam vendidos em cerca de 10 minutos → 100 pedidos/s; fora do pico, 10 pedidos/s. Gerar os ingressos de um pedido ocupa um worker por 0,8 s.

  • Para acompanhar o pico: 100 × 0,8 = 80 workers ocupados o tempo todo.
  • Com apenas 40 workers (capacidade de 50/s), o backlog cresce 50 jobs/s: depois de 10 minutos, há 30.000 jobs esperando. O último fã espera cerca de 30.000 / 50 = 600 s = 10 minutos pelos ingressos e, depois do pico, o backlog é drenado a 50 − 10 = 40 jobs/s, levando mais 12,5 minutos.
  • Isso é aceitável? Só o SLO pode dizer. Se a promessa é "ingressos na sua caixa de entrada em até 5 minutos", 40 workers não bastam.
2026-11-01T21:54:58.416155 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/

O gráfico traz duas lições. Primeiro, uma fila converte um problema de capacidade em atraso: nada falhou com 40 workers, os fãs só esperaram. Segundo, quem está a jusante define o limite real: se o provedor de e-mail aceita no máximo, digamos, 100 mensagens por segundo no seu plano, 500 workers de notificação não enviam mais rápido — só colecionam 429 Too Many Requests. Dimensione o pool de workers pela dependência mais lenta e deixe a fila guardar o resto.

Escale workers pela profundidade da fila ou pelo consumer lag, não pela CPU: um worker esperando um provedor lento não usa CPU, mas também não está livre. Serverless e autoscaling mostra como (KEDA).

Experimente no playground

Abra o cenário async-queue: produtores escrevem numa fila esvaziada por um pool de workers.

  1. Rode o perfil spike e observe o backlog e o consumer lag subirem durante a rajada e depois caírem.
  2. Dobre as réplicas de workers: o pico do backlog cai e é drenado mais cedo — confira com a fórmula acima.
  3. Reduza o rate limit do serviço a jusante: mais workers deixam de ajudar, e aparecem erros no lugar.

Projetando jobs que sobrevivem ao mundo real

Uma fila não é mágica: workers caem no meio de jobs, brokers reentregam, provedores estouram o timeout. A maioria dos brokers oferece entrega at-least-once (pelo menos uma vez): um job só é removido (confirmado, ack) depois que o worker avisa que terminou, então uma queda antes do ack significa que o job será entregue de novo. Todas as regras abaixo decorrem disso.

Regra Por quê Exemplo na BoxOffice
Torne os jobs idempotentes o mesmo job vai rodar duas vezes antes de gerar, verifique se ord_9 já tem ingressos; envie o e-mail com uma chave de deduplicação ord_9:tickets
Dê o ack depois do trabalho, não antes dar o ack primeiro transforma uma queda num job perdido fazer commit do offset do Kafka depois que o PDF foi armazenado
Coloque ids na mensagem, não dados as mensagens ficam pequenas; o worker lê o estado atual {"orderId": "ord_9"}, não o pedido inteiro
Faça retry com backoff e depois estacione erros transitórios se resolvem; os permanentes não podem travar a fila 4 tentativas com 1 s, 2 s, 4 s, depois a dead letter queue (DLQ)
Defina um timeout por job um job travado prende um worker para sempre a geração do PDF é abortada após 10 s
Separe filas por prioridade um job em massa não pode atrasar os urgentes tickets (urgente) e marketing-emails (em massa) em filas e pools diferentes
Registre o status do job usuários e suporte precisam ver o progresso ticket_status = PENDING / ISSUED / FAILED no pedido
Respeite os limites a jusante mais paralelismo do que o provedor permite = erros um rate limiter no worker de notificação

Uma mensagem envenenada (poison message) é aquela que falha sempre (um payload malformado, um bug). Sem limite de retries, ela é reprocessada para sempre e, em partições ordenadas, bloqueia todas as mensagens atrás dela. A DLQ a estaciona para um humano (ou para uma versão corrigida do código) e deixa a fila seguir. Monitore o tamanho da DLQ: uma DLQ crescendo é um incidente.

Com o Spring for Apache Kafka, retries com backoff e um tópico de dead letter são poucas anotações3:

ticket-worker: TicketJobListener.java
@Component
class TicketJobListener {

    private final TicketService tickets;

    TicketJobListener(TicketService tickets) { this.tickets = tickets; }

    @RetryableTopic(
        attempts = "4",                                    // 1 try + 3 retries
        backOff = @BackOff(delay = 1000, multiplier = 2.0), // 1 s, 2 s, 4 s (Spring Kafka 4)
        dltTopicSuffix = ".dlt")
    @KafkaListener(topics = "order.confirmed.v1", groupId = "ticket-worker")
    void onOrderConfirmed(OrderConfirmed event) {
        if (tickets.alreadyIssued(event.orderId())) {
            return;                                        // idempotent: duplicate delivery
        }
        tickets.renderAndStore(event.orderId());           // PDF + QR → object store
    }

    @DltHandler
    void onDeadLetter(OrderConfirmed event) {
        tickets.markFailed(event.orderId());               // visible to support; alert on rate
    }
}

Onde os jobs vivem

Opção Durável? Bom para Cuidado com
No próprio processo (@Async, um pool de threads) não — perdido numa queda ou num deploy trabalho fire-and-forget que você pode se dar ao luxo de perder não é uma fila; sem retries, sem visibilidade
Filas baseadas em Redis (Sidekiq, Celery, BullMQ) em geral (depende da configuração de persistência) filas de jobs simples, com retries e agendamentos memória do Redis e configuração de persistência
Filas de broker (SQS, RabbitMQ, Cloud Tasks) sim um job → um worker, ack por mensagem, atrasos, DLQ ordenação fraca ou só por grupo
Log de eventos (Kafka, Pub/Sub, Kinesis) sim, com replay muitos consumidores dos mesmos fatos, alto throughput, ordenação por chave paralelismo limitado pelas partições; acks por offset
Motores de workflow (Temporal, AWS Step Functions) sim, com estado processos de várias etapas que duram de minutos a dias, com compensações uma nova plataforma para aprender e operar

Os motores de execução durável (durable execution) merecem menção: quando um processo tem muitas etapas e esperas — "evento cancelado → reembolsar 60.000 pedidos → enviar e-mail a cada fã → esperar 7 dias → fechar a contabilidade" — escrevê-lo como um workflow, cujo progresso o motor persiste após cada etapa, é mais simples e mais seguro do que encadear filas à mão4.


Schedulers

Parte do trabalho é disparada pelo tempo, não por uma requisição ou um evento:

  • a cada 30 s, liberar os holds expirados para que seus assentos possam ser vendidos de novo;
  • às 10:00:00, abrir as vendas de um evento;
  • 24 h antes de cada show, enviar um lembrete a cada portador de ingresso;
  • toda noite, conciliar os pagamentos com o relatório do provedor (integrações).

O problema do cron duplicado

A implementação óbvia é um método @Scheduled no serviço. Funciona com uma réplica. Com 10 réplicas, o job roda 10 vezes: 10 e-mails de lembrete por fã, 10 varreduras concorrentes brigando pelas mesmas linhas. O mesmo acontece com uma entrada de cron copiada em cada VM. Há quatro soluções padrão:

Rode o job como um pod próprio, de vida curta, criado pelo cluster segundo um agendamento, de modo que as réplicas do serviço nunca o executem.

release-holds-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: release-expired-holds
spec:
  schedule: "* * * * *"              # every minute (cron's finest granularity)
  concurrencyPolicy: Forbid          # never two runs at the same time
  startingDeadlineSeconds: 30        # skip a run that could not start in time
  successfulJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 50      # kill a run that hangs
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: sweeper
              image: ghcr.io/boxoffice/inventory:1.8.2
              args: ["--job=release-expired-holds"]

Simples e visível (kubectl get jobs). A granularidade é de um minuto e iniciar um pod custa segundos, então serve para jobs que rodam a cada minuto ou com menos frequência.

Toda réplica tenta rodar o job, mas só aquela que obtém um lock num armazenamento compartilhado (PostgreSQL, Redis) de fato o executa.

inventory: HoldSweeper.java
@Component
class HoldSweeper {

    private static final Logger log = LoggerFactory.getLogger(HoldSweeper.class);
    private final HoldRepository holds;

    HoldSweeper(HoldRepository holds) { this.holds = holds; }

    @Scheduled(fixedRate = 30_000)
    @SchedulerLock(name = "releaseExpiredHolds",
                   lockAtMostFor = "PT25S",   // lock expires even if this replica dies
                   lockAtLeastFor = "PT5S")   // absorb small clock differences
    void releaseExpiredHolds() {
        int released = holds.releaseExpired(Instant.now(), 5_000);   // batch of 5,000
        log.info("released {} expired holds", released);
    }
}

O ShedLock guarda o lock como uma linha (name, lock_until, locked_by) e o obtém com um UPDATE condicional, então não precisa de infraestrutura além do próprio banco de dados do serviço2.

As réplicas elegem um líder que executa todo o trabalho agendado; se ele morrer, outro assume depois que o lease expira. O Kubernetes oferece objetos Lease para isso (o mesmo mecanismo que seus próprios controllers usam), e Spring Integration, etcd e ZooKeeper fornecem receitas de eleição.

Serve para serviços com muitas tarefas periódicas, ou tarefas que precisam manter estado em execução (um líder que mantém uma conexão com o stream de um provedor).

A nuvem chama você segundo um agendamento: AWS EventBridge Scheduler, Google Cloud Scheduler, timers do Azure Logic Apps / Functions. Ele invoca um endpoint HTTP, uma fila ou uma função — no máximo uma vez por agendamento do ponto de vista do scheduler, mas ainda pelo menos uma vez do ponto de vista do seu endpoint, se ele fizer retries.

Zero infraestrutura; aceita agendamentos únicos ("em 2027-03-14T10:00:00-03:00"), que o cron não consegue expressar.

Locks expiram: fencing tokens

Um lock distribuído com expiração tem uma brecha sutil, descrita por Martin Kleppmann1: uma réplica obtém o lock e então pausa (uma coleta de lixo longa, uma migração de VM) além da expiração do lock. Outra réplica obtém o lock e inicia o job. A primeira acorda, ainda acreditando que detém o lock, e as duas escrevem.

sequenceDiagram
    participant A as Réplica A
    participant L as Armazenamento de locks
    participant B as Réplica B
    participant DB as Banco de dados
    A->>L: obtém (token 33), expira em 25 s
    Note over A: pausa de GC: 40 s
    B->>L: lock expirado → obtém (token 34)
    B->>DB: libera holds (token 34)
    A->>DB: libera holds (token 33) — ainda acha que tem o lock
    DB-->>A: rejeitado: token 33 < 34 já visto

A correção é um fencing token: o armazenamento de locks entrega um número crescente a cada aquisição, e o recurso protegido rejeita escritas com um token mais antigo. Quando o recurso não consegue verificar tokens, o próprio job precisa ser idempotente — o que você precisa de qualquer forma. Um lock é uma otimização que evita esforço duplicado; a correção tem de vir da idempotência ou do banco de dados (um UPDATE ... WHERE status = 'ACTIVE' AND expires_at < now() condicional libera cada hold no máximo uma vez, não importa quantas varreduras rodem).

Trabalho atrasado: três formas de expirar um hold

Liberar um hold 10 minutos depois de criado é um job atrasado (delayed job). Três designs, muitas vezes combinados:

Design Como Prós Contras
Varredura (sweeper) um job agendado libera WHERE expires_at < now() em lotes simples, pega tudo os assentos voltam com até um período de atraso; carga em lote no BD
Mensagem atrasada ao criar o hold, enfileira "liberar H-7" com atraso de 10 min (delay do SQS, sorted set do Redis por horário de vencimento, delayed exchange do RabbitMQ) horário preciso, sem varredura uma mensagem por hold; ainda precisa ser idempotente (o hold pode ter sido confirmado)
Expiração preguiçosa (lazy expiry) trata um hold como livre quando expires_at < now() no momento da leitura correta imediatamente, nenhum job necessário para a correção linhas expiradas permanecem até a limpeza

A regra da BoxOffice: a correção nunca espera pelo scheduler. Um hold cujo expires_at já passou não pode ser confirmado, tenha a varredura rodado ou não (expiração preguiçosa), e uma varredura devolve seus assentos à venda. Se o scheduler ficar fora do ar por 5 minutos, nenhum assento é vendido duas vezes; os assentos dos holds expirados só voltam mais tarde. (Um desenho totalmente preguiçoso vai além: novos holds podem pegar os assentos de um hold expirado na hora, sem esperar a varredura.)

No código do BoxOffice: verificação preguiçosa e varredura no inventory (lab 6)

No Hands-on 6 — Holds, o confirm é um UPDATE condicional que inclui expires_at > now(), então um hold expirado responde 409 mesmo que nenhum job o tenha tocado. O expireHolds é a varredura: a cada 5 s ele expira no máximo 500 holds e libera seus assentos num único comando. Ele roda em toda réplica do inventory, sem lock e sem líder: o FOR UPDATE SKIP LOCKED faz cada réplica pular as linhas que outra já está processando, o que resolve o problema do cron duplicado dentro do banco. O preço é o da tabela: um assento volta até 5 s depois de seu hold expirar.

services/inventory/src/main/java/boxoffice/inventory/HoldService.java (confirm, sweeper)
/** Payment succeeded (lab 7 calls this): seats become SOLD. */
@Transactional
public HoldOut confirm(String userId, UUID holdId) {
    int changed = jdbc.sql("""
            UPDATE inventory.holds SET status = 'CONFIRMED'
            WHERE id = :id AND user_id = :userId AND status = 'ACTIVE' AND expires_at > now()""")
            .param("id", holdId).param("userId", userId)
            .update();
    if (changed == 0) {
        throw new ResponseStatusException(HttpStatus.CONFLICT, "hold is not active (expired, released or unknown)");
    }
    jdbc.sql("UPDATE inventory.seats SET status = 'SOLD', version = version + 1 WHERE hold_id = :id")
            .param("id", holdId).update();
    return find(userId, holdId);
}
@Scheduled(fixedDelay = 5000)
@Transactional
public void expireHolds() {
    int freed = jdbc.sql("""
            WITH expired AS (
                UPDATE inventory.holds SET status = 'EXPIRED'
                WHERE id IN (SELECT id FROM inventory.holds
                             WHERE status = 'ACTIVE' AND expires_at < now()
                             ORDER BY expires_at
                             LIMIT 500
                             FOR UPDATE SKIP LOCKED)
                RETURNING id)
            UPDATE inventory.seats
            SET status = 'AVAILABLE', hold_id = NULL, version = version + 1
            WHERE status = 'HELD' AND hold_id IN (SELECT id FROM expired)""")
            .update();
    if (freed > 0) {
        log.info("expired holds freed {} seats", freed);
    }
}

Abrindo as vendas às 10:00

Não faça "as vendas abrem" depender de um cron disparando exatamente às 10:00:00 — schedulers atrasam segundos, e uma execução perdida significaria nenhuma venda. Guarde onSaleAt = 10:00:00 junto com o evento; o inventory simplesmente rejeita holds antes desse instante (uma verificação contra o relógio, com relógios sincronizados por NTP — sistemas distribuídos). O scheduler faz apenas a preparação: às 09:45 ele escala a frota e aquece os caches (serverless e autoscaling).

O cenário notification-system combina um scheduler, uma fila e os provedores de e-mail/push. Experimente aumentar o número de jobs do scheduler para simular a rajada de lembretes de 24 horas e observe onde o backlog se acumula.

Abrir o simulador completo


Trade-offs em resumo

Fazer na requisição Fazer num worker
Latência para o usuário soma de todas as etapas só as etapas essenciais
Falha de uma etapa não essencial falha a requisição retry mais tarde; usuário não é afetado
Consistência imediata eventual — mostre o status
Picos de carga atingem todas as dependências ao mesmo tempo absorvidos pela fila
Depuração um stack trace seguir um job pelas filas (tracing)
Peças operacionais nenhuma broker, workers, DLQ, monitoramento do lag

Na entrevista

Sempre que um design tiver uma etapa lenta, sujeita a falhas ou em rajadas que não muda a resposta ao usuário, diga: "Eu tiraria isso do caminho da requisição: a API grava o pedido e um evento numa única transação (outbox), e um pool de workers o consome — idempotente, com retries, backoff exponencial e uma DLQ. Eu escalaria os workers pelo consumer lag." Para trabalho agendado, mencione que um cron em cada réplica roda N vezes e escolha CronJob, um lock ou eleição de líder — mais idempotência, porque locks expiram. Depois dê um número: "100 jobs/s × 0,8 s = 80 workers no pico".

Boas práticas

  • Alerte sobre a idade do job mais antigo, não só sobre o tamanho da fila — 10 jobs com 3 horas de idade são um incidente; 10.000 jobs que drenam em 30 s não são.
  • Derive o número de workers e escreva a conta ao lado da configuração — workers = jobs/s × tempo por job ÷ utilização-alvo: 100 e-mails/s × 50 ms = 5 threads ocupadas, então 6 ou 7. Na BoxOffice: NOTIFY_CONCURRENCY 1 vs 6 no lab 7.
  • Use o banco como uma fila de jobs pequena antes de acrescentar um broker — o FOR UPDATE SKIP LOCKED deixa vários workers dividirem uma tabela com segurança, dentro das transações que você já tem. Na BoxOffice: a varredura de reservas do lab 6 (quadro acima).
  • Registre cada execução agendada — início, fim, itens processados e resultado, como linha de log e métrica; uma varredura que liberou 0 assentos por uma hora durante uma venda é um sinal. Na BoxOffice: o expireHolds registra quantos assentos liberou.
  • Mantenha os agendamentos em UTC e guarde o próximo horário devido — fusos locais pulam ou repetem execuções na mudança do horário de verão, e um horário devido guardado permite detectar uma execução perdida.

Armadilhas comuns

  • @Async tratado como fila: o trabalho desaparece a cada deploy ou queda.
  • Jobs não idempotentes: a entrega at-least-once transforma um retry num segundo e-mail ou numa segunda cobrança.
  • Sem limite de retries e sem DLQ: uma mensagem envenenada bloqueia uma partição para sempre.
  • Escalar workers pela CPU: workers limitados por I/O parecem ociosos enquanto o backlog cresce.
  • Mais workers do que o provedor permite: só produz 429s.
  • @Scheduled em toda réplica: o job roda N vezes.
  • Confiar num lock para a correção: locks expiram; use fencing tokens ou escritas idempotentes e condicionais.
  • O scheduler no caminho crítico: se "as vendas abrem" ou "o hold expira" exige que um job dispare na hora, um job atrasado vira uma indisponibilidade.

Como se conecta

flowchart LR
    RP["caminho da requisição: só o que<br/>a resposta precisa"] --> Q["jobs numa fila<br/>→ Mensageria"]
    Q --> W["workers<br/>taxa × tempo por job"]
    W --> JOB["jobs idempotentes, ack após o trabalho,<br/>retries com backoff"]
    JOB --> DLQ["DLQ para mensagens venenosas"]
    W --> SCALE["escalar pelo atraso da fila<br/>→ Autoscaling"]
    CRON["trabalho agendado<br/>em N réplicas"] --> LOCK["CronJob, lock distribuído,<br/>eleição de líder"]
    LOCK --> FENCE["locks expiram: fencing tokens<br/>→ Consenso distribuído"]
    CRON --> LAZY["expiração preguiçosa + varredor<br/>reservas → Bancos de dados"]

Componentes da paleta: queue (jobs e mensagens atrasadas) · worker (consumidores dimensionados pela lei de Little) · scheduler (cron e trabalho atrasado, uma vez por cluster) · kafka (commit do offset após o trabalho) · sql (a linha do ShedLock, reservas que expiram preguiçosamente) · objstore (PDFs dos ingressos) · notifications · email (o e-mail do ingresso, fora do caminho da requisição). Cenários: async-queue, notification-system. Continua em: Serverless e autoscaling — escalar pela profundidade da fila, Mensageria — filas e pub/sub, Mensageria — semântica de entrega, Distribuídos — consenso (fencing), Hands-on 7.

Pontos-chave

  • Mantenha na requisição só aquilo de que a resposta ao usuário depende; mova o resto para jobs numa fila processados por workers.
  • Dimensione workers com a Lei de Little (taxa × tempo por job); uma fila transforma sobrecarga em atraso, e a dependência a jusante mais lenta define o limite real.
  • A entrega é at-least-once: jobs idempotentes, ack depois do trabalho, retries com backoff, uma DLQ para mensagens envenenadas, prioridades por fila.
  • Trabalho agendado em N réplicas roda N vezes: use um CronJob, um lock distribuído (ShedLock) ou eleição de líder — e mantenha a idempotência, porque locks expiram (fencing tokens).
  • Projete regras baseadas em tempo de modo que a correção não dependa de um job disparar na hora (expiração preguiçosa + varredura).


  1. KLEPPMANN, M. How to do distributed locking. 2016. Veja também KLEPPMANN, M. Designing Data-Intensive Applications, capítulo 8 (fencing tokens). ↩

  2. Kubernetes — CronJob; ShedLock. ↩

  3. Spring for Apache Kafka — Non-blocking retries. ↩

  4. Temporal documentation — Durable execution. ↩