Blog

  • Webhook que dispara duas vezes: o checklist de idempotência antes de ir para produção

    O provedor de pagamento reenviou o mesmo evento payment.succeeded três vezes em quatro minutos, e o handler creditou o saldo três vezes. Não houve bug no provedor: a entrega “pelo menos uma vez” é o contrato documentado de praticamente todo sistema de webhooks, e o consumidor é quem precisa tornar a repetição inofensiva. O checklist abaixo cobre o que deve estar pronto antes de um endpoint de webhook receber tráfego real.

    1. Defina a chave de idempotência antes de escrever o handler

    Idempotência começa pela escolha da chave, e a escolha errada é a causa mais comum de duplicidade que sobrevive a testes. Há três candidatos, nesta ordem de preferência: o identificador do evento entregue pelo provedor (o event.id), o identificador do recurso combinado com o tipo de transição, ou um hash do corpo. O terceiro é o pior, porque o provedor pode reenviar o mesmo evento com campos de metadados diferentes, como contagem de tentativas ou timestamp de entrega, e o hash deixa de coincidir.

    O identificador do recurso sozinho também não serve. Um pedido passa por created, paid e refunded, e a chave order_123 descartaria as transições legítimas depois da primeira. A chave precisa identificar o evento, não o objeto.

    • Use event.id quando o provedor garante unicidade por evento.
    • Sem esse campo, componha recurso_id + tipo_do_evento + versão.
    • Evite hash do payload inteiro e evite qualquer chave que inclua horário de entrega.

    2. Grave a chave e execute o efeito na mesma transação

    O erro clássico é consultar “já processei esse id?” e, se não, processar e depois gravar o id. Entre a consulta e a gravação existe uma janela: duas entregas simultâneas passam pela consulta, ambas veem “não processado” e ambas executam o efeito. Retry de provedor costuma chegar justamente em rajada, então a janela é exercida com frequência.

    A correção é inverter a ordem e deixar o banco arbitrar. Uma tabela de eventos processados com restrição de unicidade na chave, inserida dentro da mesma transação que aplica o efeito de negócio, resolve a corrida: a segunda transação falha na inserção, desfaz tudo e o handler responde com sucesso, porque a duplicata não é erro.

    BEGIN;
    INSERT INTO eventos_processados (chave, recebido_em)
    VALUES ($1, now())
    ON CONFLICT (chave) DO NOTHING;
    -- se 0 linhas afetadas: COMMIT e responda 200 sem aplicar o efeito
    UPDATE contas SET saldo = saldo + $2 WHERE id = $3;
    COMMIT;

    Dois detalhes importam aqui. Primeiro, a checagem de “0 linhas afetadas” precisa acontecer no código da aplicação antes do UPDATE; o SQL acima mostra a estrutura, não o fluxo de controle completo. Segundo, o efeito colateral que sai do banco, como e-mail, chamada a outra API ou publicação em fila, não participa da transação e exige tratamento próprio, tema da seção 4.

    3. Responda rápido e processe depois, mas só se a fila também for idempotente

    A maioria dos provedores considera a entrega falha se a resposta não chegar em poucos segundos, e o limite varia de provedor para provedor. Handler que faz trabalho pesado de forma síncrona produz timeout, o provedor reenvia, e o sistema cria o próprio problema de duplicidade. O padrão usual é validar a assinatura, gravar o evento bruto, responder 200 e processar de forma assíncrona.

    Esse padrão desloca o problema em vez de eliminá-lo. A fila interna também entrega pelo menos uma vez, então o worker precisa da mesma proteção de unicidade da seção 2. Quem ignora isso passa a ter idempotência na borda e duplicidade no worker, um defeito mais difícil de reproduzir porque só aparece quando a fila reentrega após falha do consumidor.

    Há também a decisão sobre o que gravar de imediato. Gravar o corpo bruto junto com os cabeçalhos de assinatura permite reprocessar depois de um deploy com bug, sem depender de o provedor reenviar. Vale a pena pelo custo de armazenamento, que costuma ser desprezível diante do custo de uma conciliação manual.

    4. Trate os efeitos que não cabem na transação

    Enviar e-mail de confirmação, emitir nota fiscal e chamar uma API de terceiros ficam fora do banco. Aqui existem duas estratégias, e a escolha depende de o destino aceitar ou não uma chave de idempotência própria.

    Situação do destino Estratégia Risco residual
    Aceita chave de idempotência (cabeçalho ou campo) Reenviar a mesma chave derivada do evento a cada tentativa Baixo; o destino descarta a repetição
    Não aceita chave Outbox: gravar a intenção na transação e um worker envia marcando o status Duplicata se o worker cair entre enviar e marcar
    Efeito irreversível e sem chave (e-mail, SMS) Outbox com status e política explícita: prefere-se perder um envio a duplicar? Decisão de negócio, não técnica

    A última linha da tabela costuma ser omitida nas revisões de arquitetura. Em e-mail transacional, o custo de um envio duplicado é pequeno e o de um envio perdido pode ser alto, então a política recomendada é aceitar a duplicata rara. Para cobrança, a política inverte: duplicar é o pior desfecho, e um envio perdido gera um alerta e uma ação manual.

    Valide a assinatura e a janela de tempo

    Idempotência não substitui autenticação. O endpoint precisa verificar a assinatura HMAC sobre o corpo bruto, antes de qualquer parse de JSON, porque reserializar o objeto muda a ordem das chaves e quebra a verificação. Frameworks que entregam o corpo já parseado são fonte frequente de falha aqui; configure a rota para preservar os bytes originais.

    Provedores sérios incluem um timestamp assinado. Rejeitar eventos com timestamp fora de uma janela curta limita o ataque de replay, e essa verificação convive bem com a tabela de eventos processados, que só precisa reter chaves dentro da janela de reentrega do provedor. Reter para sempre cresce sem benefício; um prazo de retenção de algumas semanas, alinhado à política de retry documentada do provedor, mantém a tabela pequena. Confirme o prazo exato na documentação do seu provedor em vez de assumir.

    5. Eventos fora de ordem: idempotência não resolve sozinha

    Entrega duplicada e entrega fora de ordem são problemas diferentes, e tratar um com a solução do outro deixa buracos. Se subscription.canceled chega antes de subscription.updated porque o segundo teve uma tentativa falha, aplicar os eventos na ordem de chegada deixa a assinatura ativa no seu sistema quando o estado real é cancelado. A tabela de eventos processados não detecta isso: ambos os ids são novos.

    Há duas saídas e a escolha depende do que o provedor expõe. A primeira é comparar um campo monotônico do evento, como um número de versão ou o timestamp de criação do evento (não o de entrega), e descartar qualquer evento mais antigo do que o último aplicado ao mesmo recurso. A segunda, mais robusta e mais cara, é tratar o webhook apenas como um aviso de que algo mudou e buscar o estado atual do recurso na API do provedor antes de aplicar. Essa abordagem de “notificação mais consulta” elimina a classe inteira de problemas de ordem ao custo de uma chamada extra por evento e de depender da disponibilidade da API.

    A recomendação aqui é usar a consulta para recursos cujo estado final importa mais do que o histórico, como assinaturas e status de pedido, e a comparação de versão para fluxos em que cada transição gera um efeito próprio, como lançamentos financeiros. Misturar as duas no mesmo handler é razoável, desde que a regra de cada tipo de evento esteja escrita num lugar só.

    6. Teste a duplicata de verdade antes de publicar

    Teste unitário com um handler chamado duas vezes em sequência não prova nada sobre concorrência. O teste que pega a corrida dispara a mesma requisição em paralelo contra o endpoint real, com banco real, e verifica que o efeito aconteceu uma vez só. Dez requisições simultâneas com o mesmo event.id bastam para expor a janela da seção 2.

    seq 1 10 | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
      -X POST https://staging.exemplo.com/webhooks/pagamento \
      -H "Content-Type: application/json" \
      -H "X-Assinatura: $ASSINATURA" \
      --data @evento-fixo.json | sort | uniq -c

    Depois do disparo, conte as linhas afetadas no banco. O resultado esperado é uma única alteração de saldo e dez respostas 200. Se aparecerem dez alterações, a transação não está protegendo o efeito; se aparecerem respostas 500, a violação de unicidade está escapando como erro em vez de ser tratada como duplicata.

    Por fim, monitore a taxa de duplicatas descartadas. Um pico indica que o provedor está reenviando por causa de lentidão na resposta, o que sinaliza um problema de latência no endpoint antes de qualquer cliente reclamar.

  • Fila de jobs em Postgres ou Redis com BullMQ: o critério que decide, não o benchmark

    O time precisa enviar e-mail de confirmação, gerar PDF e sincronizar com um ERP depois que o pedido é gravado. A decisão não é “qual fila é mais rápida”, e sim: o job pode nascer na mesma transação que o pedido, ou pode ser perdido e reenfileirado depois? Quase toda escolha entre uma fila em Postgres e o BullMQ sobre Redis se resolve respondendo isso primeiro.

    O que cada opção garante de verdade

    Uma fila em Postgres, seja uma tabela própria com SELECT ... FOR UPDATE SKIP LOCKED ou uma biblioteca como pg-boss ou Graphile Worker, grava o job no mesmo banco dos dados de negócio. Se a transação do pedido falha, o job some junto. Se ela confirma, o job existe. Não há janela em que o pedido foi salvo e o job não.

    O BullMQ guarda tudo no Redis. A garantia de entrega é boa e a ergonomia é superior (prioridades, delays, jobs repetíveis, rate limit por fila, painel pronto), mas a escrita no Redis acontece fora da transação do banco. Em algum momento o código vai gravar o pedido e, se o processo morrer antes do queue.add, o job se perde. Quem usa BullMQ com seriedade acaba implementando um outbox: grava o evento numa tabela na transação e um relay publica no Redis. Nesse ponto o Postgres já faz parte da solução.

    Na prática: se você já escreve um outbox para alimentar o Redis, avalie se o worker não pode simplesmente consumir a própria tabela com SKIP LOCKED. Você elimina uma peça móvel e o relay.

    Onde o Postgres começa a sofrer

    Fila em tabela gera muito churn: insere, atualiza estado, apaga ou arquiva. Isso produz tuplas mortas, e o autovacuum precisa acompanhar. Em volume de centenas de jobs por segundo sustentado, com workers fazendo polling agressivo, a tabela incha, os índices degradam e a fila passa a competir por I/O com as consultas do produto. O sinal clássico é a latência de consultas não relacionadas subir no horário de pico de processamento.

    Há mitigações reais: particionar a tabela por dia e descartar partições antigas em vez de dar DELETE, usar LISTEN/NOTIFY para acordar workers em vez de polling curto, manter o índice parcial só sobre jobs pendentes. Elas funcionam, mas exigem alguém que entenda de manutenção de Postgres. Se esse alguém não existe no time, o custo é maior do que parece.

    Como ordem de grandeza, a maioria dos sistemas de gestão e SaaS B2B de porte médio fica folgada com dezenas de jobs por segundo. Esse volume cabe em Postgres sem cerimônia. Quando o gargalo aparece, normalmente é em jobs de longa duração segurando conexão, e não na taxa de enfileiramento.

    Onde o Redis com BullMQ vence

    Três cenários justificam a peça extra. O primeiro é volume alto e jobs curtos, em que a vida útil de cada job é de milissegundos e o custo de gravar e apagar em disco transacional é desperdício. O segundo é a necessidade de recursos de agendamento sofisticados: limite de concorrência por grupo, prioridade dinâmica, jobs repetíveis com cron e deduplicação por ID. Dá para construir tudo isso em SQL, mas você estará reescrevendo o BullMQ pior. O terceiro é isolamento de carga: o processamento pesado não deve encostar no banco principal de forma alguma.

    O custo é operacional. Redis precisa de persistência configurada (AOF, de preferência com appendfsync everysec), política de memória em noeviction para que ele recuse escrita em vez de descartar jobs silenciosamente, e monitoramento de memória. Redis gerenciado resolve parte, e cobra por isso. Um erro comum é rodar BullMQ no mesmo Redis usado como cache com política allkeys-lru: sob pressão de memória, o Redis despeja chaves de jobs e a fila perde trabalho sem erro algum.

    Retry, idempotência e o que ninguém testa

    Em ambas as opções, a entrega é pelo menos uma vez. O job vai rodar duas vezes em algum momento: worker que morre depois de chamar a API do ERP e antes de confirmar, timeout que expira com o trabalho ainda em curso. A decisão de arquitetura que importa mais do que a fila é tornar o handler idempotente, com chave de idempotência enviada ao sistema externo ou checagem de estado antes do efeito colateral.

    Configure também o backoff exponencial com teto e um limite de tentativas. Sem isso, um endpoint externo fora do ar transforma a fila num gerador de carga contra o serviço que já está caído. E mantenha uma dead letter visível: um job que falhou cinco vezes precisa aparecer num painel ou alerta, não em log que ninguém lê.

    Observabilidade: o que medir desde o primeiro dia

    Uma fila sem métricas é uma caixa-preta que só se manifesta quando o cliente reclama. Quatro números bastam para começar: idade do job mais antigo pendente, tamanho da fila por tipo, taxa de falha por handler e duração p95 de execução. A idade do mais antigo vale mais que o tamanho: mil jobs pendentes com o mais velho de dois segundos é um sistema saudável, e dez jobs com o mais velho de uma hora é um incêndio.

    Em Postgres, essas consultas são um SELECT contra a própria tabela e entram em qualquer dashboard. Em BullMQ, o próprio ecossistema expõe contadores por estado, e vale exportá-los para o sistema de métricas que o time já usa em vez de depender só do painel visual. Em ambos os casos, o alerta que mais salva é o de “nenhum job concluído nos últimos N minutos com fila não vazia”, porque pega worker travado, deploy quebrado e credencial expirada de uma vez.

    Migrar de uma para outra sem parar o sistema

    A troca, nos dois sentidos, é menos dramática se o código de negócio nunca falar direto com a biblioteca da fila. Uma interface fina, algo como enqueue(tipo, payload, opcoes), isola a decisão. Com ela, a migração segue um roteiro previsível: passa a gravar nas duas filas atrás de uma flag, deixa os workers antigos drenarem o que já estava pendente, confirma que a fila antiga zerou e só então remove o caminho velho. Evite o corte seco, porque o job em voo no momento da troca é exatamente o que some.

    Um cuidado específico ao sair do Postgres para o Redis: jobs agendados para o futuro precisam ser reenfileirados com o atraso restante, calculado a partir do horário original, e não com o atraso inicial. Ao voltar do Redis para o Postgres, o risco é inverso, e o payload costuma carregar tipos que o BullMQ serializava de forma implícita, como datas. Teste a desserialização de jobs reais antes do corte.

    Tabela de decisão

    Critério Postgres (SKIP LOCKED) BullMQ + Redis
    Atomicidade com dado de negócio Nativa, mesma transação Exige outbox
    Volume sustentado alto Degrada, exige manutenção Confortável
    Recursos de agendamento Básicos ou via biblioteca Completos e maduros
    Peças operacionais Nenhuma além do banco Redis com persistência
    Risco principal Inchaço da tabela Perda por falha de configuração

    O que escolher

    Um último critério, frequentemente subestimado: a experiência de quem depura. Com a fila em Postgres, o suporte consulta o job com um SELECT e cruza com o pedido por JOIN, na mesma ferramenta de sempre. Com Redis, é preciso outro cliente, outro vocabulário e outro acesso em produção. Para times pequenos, esse atrito diário pesa mais que qualquer diferença de throughput.

    Para uma aplicação que ainda não tem Redis em produção e processa menos de algumas dezenas de jobs por segundo, Postgres com pg-boss ou Graphile Worker. Você ganha atomicidade, um componente a menos para monitorar e backup único. Migre para BullMQ quando houver evidência medida de que a fila prejudica o banco, ou quando o produto exigir agendamento que a biblioteca SQL não entrega. Se o Redis já existe e é bem operado, com persistência e memória dedicada à fila, BullMQ com outbox é uma escolha sólida. O que não vale é adotar Redis por reflexo e descobrir três meses depois que o pedido foi salvo e o e-mail nunca saiu.

  • Fila de jobs em Postgres ou broker dedicado: o critério que decide antes de subir mais um serviço

    O sistema já tem Postgres, já tem uma fila de e-mails e webhooks para disparar, e alguém sugere subir um RabbitMQ “para fazer direito”. Outra pessoa responde que uma tabela com SKIP LOCKED resolve. As duas estão certas em algum contexto, e a decisão depende de três coisas: o volume, a consistência com o resto do dado e quem vai operar a infraestrutura.

    O que a fila em Postgres entrega de graça

    A vantagem principal é transacional. Se o job nasce na mesma transação que altera o dado de negócio, ele existe se e somente se o commit acontecer. Com um broker externo, esse par vira o problema clássico da dupla escrita: o pedido é gravado, a publicação falha, e o job se perde. Ou o inverso: a mensagem é publicada, o commit falha, e o consumidor processa algo que nunca existiu.

    A segunda vantagem é operacional. Uma fila em tabela usa o backup, a replicação, o monitoramento e as permissões que você já tem. Inspecionar a fila é um SELECT; reprocessar um job é um UPDATE.

    O padrão de consumo mais usado é este:

    WITH proximo AS (
      SELECT id
      FROM jobs
      WHERE status = 'pendente'
        AND executar_em <= now()
      ORDER BY executar_em
      FOR UPDATE SKIP LOCKED
      LIMIT 10
    )
    UPDATE jobs
    SET status = 'em_execucao',
        tentativas = tentativas + 1,
        travado_ate = now() + interval '5 minutes'
    FROM proximo
    WHERE jobs.id = proximo.id
    RETURNING jobs.*;
    Na prática: crie um índice parcial em (executar_em) WHERE status = 'pendente'. Sem ele, a consulta de polling varre a tabela inteira conforme o histórico cresce, e o custo aparece primeiro em lock e CPU, não em erro.

    Onde a fila em Postgres começa a doer

    O primeiro limite é o polling. Sem LISTEN/NOTIFY, os workers perguntam ao banco a cada intervalo se há trabalho, o que adiciona latência ou carga. Com NOTIFY, a latência cai, mas a entrega não é garantida em caso de queda da conexão, então o polling de segurança continua necessário.

    O segundo é a tabela inchada. Jobs concluídos geram linhas mortas, o autovacuum precisa acompanhar o ritmo, e uma tabela de fila com alto churn compete com as tabelas de negócio por I/O. Particionar por data e apagar partições antigas é a solução limpa, mas dá trabalho a mais.

    O terceiro é o acoplamento de capacidade. Se os workers ocupam conexões demais, o banco principal sente. Em picos, quem paga é a aplicação inteira, e não só a fila.

    Há também o problema da observabilidade. Em uma tabela, é fácil medir a idade do job pendente mais antigo, que é o melhor indicador de saúde da fila: se ele passa de alguns minutos num sistema que deveria processar em segundos, algo travou. Quem adota um broker precisa montar esse painel por outro caminho, com métricas de profundidade de fila, taxa de consumo e mensagens não confirmadas. Nenhum dos dois é difícil, mas é comum que só o segundo apareça no planejamento, e a fila vá para produção sem alarme algum.

    Por fim, considere o custo de migração. Mover de uma fila em tabela para um broker é viável quando a interface está isolada. Mover no sentido contrário também, mas raramente vale o esforço. Isso sugere uma assimetria: errar para o lado simples custa pouco para corrigir, enquanto errar para o lado complexo cobra operação desde o primeiro dia.

    O que um broker dedicado resolve

    Um broker como RabbitMQ ou uma fila baseada em Redis resolve exatamente o que o Postgres faz mal: entrega por push, backpressure, roteamento, filas de retentativa e dead letter nativas. A latência do consumidor é baixa porque ele não faz polling, e o banco fica livre para o que importa.

    O ponto de atenção é a durabilidade. No RabbitMQ, ela depende de filas duráveis, mensagens persistentes e confirmação do consumidor (ack). No Redis, depende da configuração de persistência, e uma fila sem isso pode perder jobs numa reinicialização. Ambos exigem que você trate a idempotência no consumidor, porque entrega “pelo menos uma vez” é o padrão realista.

    A tabela abaixo resume as diferenças que costumam pesar na decisão:

    Critério Fila em Postgres Broker dedicado
    Consistência com o dado de negócio Transacional, na mesma transação Exige outbox ou aceitar dupla escrita
    Latência de entrega Depende de polling ou NOTIFY Push, tipicamente baixa
    Roteamento e fan-out Manual Nativo (exchanges, tópicos)
    Operação adicional Nenhuma além do banco Novo serviço, monitoramento, upgrade
    Teto de escala Limitado pelo banco principal Escala de forma independente
    Retentativa e dead letter Você implementa Recursos nativos

    O meio-termo: outbox com broker

    Quando a consistência importa e o fan-out também, o padrão outbox une os dois mundos. A aplicação grava o evento em uma tabela outbox na mesma transação do dado, e um processo separado lê essa tabela e publica no broker, marcando cada linha como enviada.

    BEGIN;
      UPDATE pedidos SET status = 'pago' WHERE id = 42;
      INSERT INTO outbox (agregado, evento, payload)
      VALUES ('pedido', 'pedido_pago', '{"id": 42}');
    COMMIT;

    Você troca um pouco de complexidade por uma garantia sólida: nenhum evento se perde, e o broker cuida da distribuição. O preço é que o consumidor receberá duplicatas em caso de falha entre a publicação e a marcação, o que exige idempotência. Uma chave de evento única, checada antes do efeito colateral, resolve a maior parte dos casos.

    Retentativa, jobs travados e idempotência

    Seja qual for a infraestrutura, três perguntas definem se a fila é confiável. O que acontece quando o worker morre no meio de um job? Quantas vezes o job será reexecutado? E o que impede que a reexecução repita o efeito colateral?

    Na fila em tabela, a resposta à primeira é o campo travado_ate do exemplo anterior: um job em execução cujo prazo expirou volta a ser elegível. Um processo de varredura periódico, ou a própria consulta de consumo, trata status = 'em_execucao' AND travado_ate < now() como pendente. O prazo precisa ser maior que o pior tempo de execução legítimo, senão dois workers processam o mesmo job ao mesmo tempo.

    A segunda pergunta pede um teto de tentativas e um destino para quem estoura: um status falhou com a última mensagem de erro, para alguém olhar. Backoff exponencial, aplicado ao atualizar executar_em, evita martelar um serviço externo que já está fora do ar.

    UPDATE jobs
    SET status = CASE WHEN tentativas >= 5 THEN 'falhou' ELSE 'pendente' END,
        executar_em = now() + (interval '30 seconds' * power(2, tentativas)),
        ultimo_erro = $2
    WHERE id = $1;

    A terceira pergunta é a mais negligenciada. Enviar e-mail, cobrar cartão e chamar webhook são operações que não se desfazem. A defesa é uma chave de idempotência por job, gravada antes do efeito e consultada na reexecução, ou a própria API externa aceitando um identificador de requisição. Sem isso, qualquer fila, a mais cara que seja, entrega duplicata em algum dia ruim.

    No broker dedicado, o mecanismo muda, mas o raciocínio é o mesmo: o ack só deve acontecer depois do efeito concluído, e a mensagem não confirmada será reentregue. Cabe ao consumidor tolerar a repetição.

    Critério de escolha

    Ficaria com a fila em Postgres quando: o volume é de dezenas a poucas centenas de jobs por segundo no pico, o time é pequeno, os jobs são disparados por mudanças de dado e a equipe não tem quem opere um broker com tranquilidade. Nesse cenário, o ganho de simplicidade supera o custo de polling, e a consistência transacional evita uma categoria inteira de bugs.

    Escolheria um broker dedicado quando: há vários consumidores independentes para o mesmo evento, a latência precisa ser baixa e previsível, o volume pode crescer muito além do que o banco principal tolera, ou a fila atende sistemas diferentes que não compartilham banco.

    E usaria outbox com broker quando o sistema já passou do primeiro cenário, mas ainda não pode abrir mão da garantia de que “se gravou, publica”. É a combinação que mais costuma sobreviver ao crescimento sem reescrita.

    Uma regra prática para não errar: comece pela fila em tabela, mas desenhe a interface do produtor e do consumidor como se houvesse um broker atrás. Se a aplicação enfileira por meio de uma função enfileirar(tipo, payload) e os handlers são idempotentes, trocar a implementação depois é uma mudança local, não uma reforma.

  • Webhook entregue em duplicidade: idempotência, ordem e retry sem perder evento

    O handler de webhook devolve 200, o provedor marca a entrega como concluída, e três segundos depois a mesma notificação chega de novo. O resultado costuma ser cobrança em dobro, e-mail de boas-vindas duplicado ou estoque baixado duas vezes. A causa quase nunca é bug do provedor: é o contrato de entrega, que na prática é at-least-once. Quem consome precisa tratar duplicidade como caso normal, não como exceção.

    Por que o duplicado acontece mesmo com 200

    Três mecanismos geram repetição, e cada um pede uma defesa diferente. O primeiro é o timeout: o provedor espera de poucos segundos até cerca de trinta pela resposta, e se o handler demora mais, considera falha e reenvia, mesmo que o processamento tenha terminado depois. O segundo é a perda da resposta: o handler processou, gravou no banco e respondeu 200, mas a conexão caiu antes de o provedor receber. Para ele, a entrega falhou. O terceiro é o replay manual, quando alguém clica em “reenviar” no painel do provedor para recuperar um período de indisponibilidade.

    Em todos os casos o payload chega idêntico, normalmente com o mesmo identificador de evento. É esse identificador, não o conteúdo, que deve ancorar a deduplicação. Comparar hash do corpo parece atraente, mas falha quando o provedor reenvia o mesmo evento com um campo de tentativa ou timestamp de entrega diferente no corpo.

    Dedupe por chave única no banco, não em memória

    A tentação inicial é guardar os IDs já vistos num cache em memória ou no Redis com TTL. Funciona em desenvolvimento e quebra em produção por dois motivos: com mais de uma réplica, a checagem “já vi?” e a gravação “agora vi” deixam de ser atômicas entre processos, e o TTL expira antes de um replay manual feito no dia seguinte.

    A solução com menos partes móveis é uma tabela de eventos processados com restrição de unicidade sobre o ID do evento, e a inserção acontece na mesma transação que o efeito colateral de negócio. Se a inserção falha por violação de unicidade, o evento já foi tratado e o handler responde 200 sem refazer nada.

    BEGIN;
    INSERT INTO eventos_processados (provedor, evento_id, recebido_em)
    VALUES ('gateway_x', 'evt_8841', now())
    ON CONFLICT (provedor, evento_id) DO NOTHING;
    -- se 0 linhas afetadas: duplicado, faça ROLLBACK e responda 200
    -- se 1 linha afetada: aplique o efeito de negócio e faça COMMIT
    

    A chave composta por provedor e ID evita colisão quando o mesmo sistema consome webhooks de mais de uma origem. O ponto que mais se erra aqui é separar a inserção da marcação do efeito: se o evento é gravado como processado e o efeito falha em seguida, o retry do provedor será descartado como duplicado e o dado se perde em silêncio. Por isso, uma única transação.

    Ordem não é garantida

    Deduplicar resolve repetição; não resolve ordem. Um evento de “pagamento aprovado” pode chegar depois do “pagamento estornado” se o primeiro caiu em retry e o segundo passou direto. Aplicar eventos na ordem de chegada produz um pedido marcado como pago quando o estado correto é estornado.

    Há duas estratégias defensáveis. A primeira é comparar versão: se o payload traz um número de sequência ou timestamp de criação do evento, o handler só aplica a mudança quando ele é mais novo que o último estado gravado. A segunda, mais robusta, é tratar o webhook apenas como aviso e buscar o estado atual do recurso na API do provedor antes de gravar. O custo é uma chamada extra por evento; o ganho é que ordem e repetição deixam de importar, porque o estado lido é sempre o corrente.

    Para fluxos financeiros, a segunda opção é a escolha mais segura. Para notificações de baixo risco, como “usuário abriu o e-mail”, comparar timestamp basta e evita a chamada.

    Responder rápido e processar depois

    O handler que valida a assinatura, grava o evento bruto e responde 200 em poucas dezenas de milissegundos elimina a maior fonte de timeout. O processamento real passa a uma fila interna, consumida por workers que aplicam a lógica de negócio e a deduplicação descritas acima.

    Abordagem Latência da resposta Risco principal Quando usar
    Processar dentro do handler Alta e variável Timeout gera reentrega durante o processamento Efeito trivial, sem I/O externo
    Gravar bruto e enfileirar Baixa e estável Fila vira ponto de falha adicional Padrão para qualquer fluxo com efeito de negócio
    Buscar estado na API ao processar Irrelevante, já assíncrono Limite de taxa da API do provedor Eventos financeiros e de estado crítico

    A validação de assinatura precisa vir antes de gravar qualquer coisa, e deve usar o corpo bruto da requisição. Frameworks que fazem parse do JSON e reserializam alteram espaços e ordem de chaves, e a verificação de HMAC passa a falhar de forma intermitente, o que costuma ser diagnosticado errado como “o provedor mudou algo”.

    Testar duplicidade antes que o provedor teste por você

    Poucas equipes têm teste automatizado para entrega repetida, e é justamente o caso que aparece em produção numa sexta à noite. O teste mínimo tem três cenários: o mesmo evento enviado duas vezes em sequência, o mesmo evento enviado duas vezes em paralelo e dois eventos do mesmo recurso chegando fora de ordem. O segundo é o que mais revela problema, porque duas requisições simultâneas passam juntas pela checagem “já processado?” quando ela é feita com um SELECT antes do INSERT. A restrição de unicidade resolve isso no banco, e o teste confirma que a segunda transação realmente falha em vez de gravar um segundo registro.

    Vale também simular o caso em que o efeito de negócio lança exceção depois da inserção do evento. Se a transação está correta, o evento não fica marcado como processado e o retry do provedor refaz o trabalho. Se ficar marcado, há um buraco: o dado do cliente se perde sem erro visível, e só aparece quando alguém concilia números no fim do mês.

    Um detalhe operacional costuma ser esquecido: o ambiente de homologação do provedor raramente reenvia com a mesma agressividade que o de produção. Muitos provedores deixam disparar o reenvio manualmente por evento, e usar esse recurso em homologação, com um handler propositalmente lento, reproduz o timeout real sem esperar um incidente.

    Retries do lado do consumidor e dead letter

    Quando o worker falha ao processar, o reprocessamento precisa de limite. Três a cinco tentativas com espera crescente cobrem falhas transitórias, como banco indisponível por alguns segundos. Passado o limite, o evento vai para uma fila de descarte com o payload bruto e o erro, e alguém é avisado. Reprocessar para sempre um evento que sempre falha, por exemplo com um campo obrigatório ausente, trava a fila inteira se o consumo for sequencial e esconde o problema se for paralelo.

    O erro a evitar é responder 500 ao provedor para “aproveitar” o retry dele como mecanismo de reprocessamento. Isso acopla a política de repetição ao comportamento de um terceiro, que varia entre provedores e pode desativar o endpoint depois de falhas seguidas.

    Retenção, replay e o que monitorar

    Guardar o payload bruto de cada evento por algumas semanas paga o custo de armazenamento na primeira investigação de divergência financeira. Com o bruto em mãos, reprocessar um período vira uma consulta, não um pedido de suporte ao provedor.

    Três métricas contam a saúde do fluxo sem ruído: taxa de eventos descartados como duplicados, tempo entre recebimento e conclusão do processamento, e quantidade de eventos na fila acima de um limiar de idade. Um aumento súbito de duplicados quase sempre indica que o handler ficou lento e o provedor está reenviando; vale tratar esse número como alerta de latência, não como sinal de que a deduplicação “está funcionando bem”.

    A posição aqui é clara: chave única transacional mais resposta imediata cobre a grande maioria dos casos, e a busca do estado na API entra apenas onde um erro de ordem custa dinheiro. Qualquer arquitetura mais elaborada que isso merece justificativa em incidente real, não em previsão.

  • Webhook duplicado: como deduplicar com chave de idempotência sem travar o endpoint

    O provedor de pagamentos reenviou o mesmo evento payment.succeeded três vezes em quatro minutos, e o sistema liberou três licenças. O endpoint respondeu 200 na primeira, mas demorou mais que o timeout do provedor, que tentou de novo. Esse é o comportamento documentado de praticamente todo emissor de webhook: entrega pelo menos uma vez, nunca exatamente uma. Quem recebe é quem precisa garantir que processar duas vezes dê o mesmo resultado que processar uma.

    Por que a entrega duplicada é a regra, não a exceção

    Emissores de webhook tratam qualquer resposta que não seja 2xx dentro do prazo como falha e reenviam com backoff. Timeout de rede, deploy no meio da requisição, pico de CPU e até um 200 que se perdeu no caminho de volta produzem o mesmo efeito: o evento chega de novo. Alguns provedores também reentregam em massa depois de um incidente próprio. Construir o receptor assumindo entrega única é construir um bug que só aparece sob carga ou em dia ruim de infraestrutura.

    O segundo ponto é a ordem. Eventos de um mesmo objeto podem chegar fora de sequência: um invoice.paid processado antes do invoice.created. Deduplicação resolve repetição, não ordenação, e as duas coisas costumam ser confundidas no mesmo ticket.

    Chave de idempotência: de onde ela vem

    A chave precisa identificar o evento, não o objeto de negócio. Usar o ID do pedido como chave bloqueia legitimamente eventos distintos do mesmo pedido (pago, estornado, reembolsado). A fonte correta, nesta ordem de preferência:

    • O ID do evento entregue pelo provedor, quase sempre presente no corpo ou em cabeçalho próprio.
    • Um hash determinístico do corpo bruto da requisição, quando o provedor não manda ID. Frágil se o emissor reserializar o JSON entre tentativas.
    • Uma combinação de tipo de evento, ID do objeto e timestamp do provedor, como último recurso.

    A verificação de assinatura HMAC vem antes de tudo e usa o corpo bruto, byte a byte. Parsear e reserializar o JSON antes de validar quebra a assinatura em silêncio, e a correção típica (desligar a validação “temporariamente”) vira dívida permanente.

    Opção A: tabela de eventos com restrição única

    A abordagem mais barata e mais robusta para a maioria dos times: uma tabela com a chave como UNIQUE e a inserção dentro da mesma transação do efeito de negócio.

    BEGIN;
    INSERT INTO eventos_processados (evento_id, recebido_em)
    VALUES ($1, now())
    ON CONFLICT (evento_id) DO NOTHING;
    -- se 0 linhas afetadas: duplicado, faça COMMIT e responda 200
    -- senão: aplique o efeito (liberar licença, creditar saldo)
    COMMIT;

    A garantia vem do banco, não do código da aplicação. Duas requisições simultâneas com o mesmo evento disputam a restrição única, e só uma passa. O erro clássico é fazer SELECT para ver se o evento existe e depois INSERT: entre os dois comandos há uma janela onde a segunda requisição também enxerga “não existe”. Em carga real, essa janela é atingida.

    O custo é crescimento da tabela. Uma rotina que apaga registros mais velhos que a janela de reentrega do provedor (em geral dias, não meses; confirme na documentação de cada um) resolve sem perder a proteção.

    Opção B: enfileirar primeiro, processar depois

    Nesta variante o endpoint só valida a assinatura, grava o payload bruto numa fila e responde 200 imediatamente. Workers consomem a fila e aplicam a deduplicação lá. O ganho é de latência e resiliência: o provedor nunca vê timeout, e um bug no processamento não perde o evento, que fica retido para nova tentativa.

    O custo é operacional. Surge uma fila para monitorar, uma política de mensagens mortas e a pergunta de quem avisa quando o worker parou. A fila também não elimina a necessidade da tabela da opção A: filas padrão entregam pelo menos uma vez, e o worker precisa ser idempotente do mesmo jeito. Quem adota fila esperando “exactly-once” de graça descobre o contrário em produção.

    Comparativo e critério de escolha

    Critério Tabela + UNIQUE na transação Fila + worker idempotente
    Latência da resposta ao provedor Depende do efeito de negócio Constante e baixa
    Componentes novos Uma tabela Fila, workers, política de mensagens mortas
    Reprocessamento após bug Exige reentrega pelo provedor Reprocessa da própria fila
    Proteção contra duplicata Restrição única Ainda precisa de restrição única
    Quando o efeito é lento (e-mail, PDF, integração externa) Risco de timeout Sem risco

    O critério é o tempo do efeito de negócio. Se aplicar o evento é uma escrita no próprio banco e termina em dezenas de milissegundos, a opção A basta e a fila é complexidade sem retorno. Se o processamento chama serviços externos, gera documentos ou pode passar de alguns segundos, a opção B deixa de ser luxo: o timeout do provedor passa a ditar a arquitetura. Em qualquer dos dois cenários, a tabela de eventos fica. A fila é camada adicional, não substituta.

    Fora de ordem: o problema que a deduplicação não resolve

    Deduplicar garante que um evento seja aplicado uma vez. Não garante que seja aplicado na hora certa. Considere uma assinatura que recebe updated (troca de plano) e, dois segundos depois, canceled. Se a rede entregar o segundo antes do primeiro, e o receptor simplesmente gravar o estado do payload, a assinatura termina como “plano novo, ativa”, exatamente o contrário do que aconteceu.

    Há três estratégias reais, com custos diferentes. A primeira é comparar versão: guardar, por objeto, o timestamp ou número de versão do último evento aplicado e descartar qualquer um mais antigo. Funciona quando o provedor expõe um campo confiável de ordenação. A segunda é buscar o estado atual na API do provedor ao receber o evento e usar o webhook apenas como gatilho. Custa uma chamada extra por evento e depende de limite de requisições do provedor, mas elimina a classe inteira de bugs de ordem. A terceira é serializar o processamento por objeto, particionando a fila pelo ID do objeto, o que preserva a ordem de chegada, não a ordem de emissão, e portanto só ajuda se o provedor entregar em sequência.

    A escolha prática: para pagamentos e assinaturas, onde um estado errado custa dinheiro, prefira buscar o estado atual. Para eventos informativos (log de atividade, notificação), comparar versão é suficiente e mais barato.

    O que monitorar depois que está no ar

    Três números dizem se a implementação está saudável. A taxa de duplicatas descartadas: um valor perto de zero por semanas indica que o provedor está estável ou, pior, que a deduplicação não está sendo exercitada e ninguém sabe se funciona. A idade do evento mais antigo não processado, se houver fila: é o único alerta que pega worker travado antes de o cliente reclamar. E a taxa de assinaturas inválidas: um salto aqui é rotação de segredo mal feita ou tentativa de forjar eventos, e os dois pedem ação no mesmo dia.

    Registre a chave de idempotência em todo log do fluxo. Quando alguém abrir o chamado “cobrado em duplicidade”, a pergunta “esse evento chegou quantas vezes e o que cada tentativa fez” precisa ter resposta em uma consulta, não em uma tarde.

    Armadilhas que sobrevivem à revisão de código

    Responder 200 em caso de duplicata, e não 409. Para o provedor, o evento foi entregue; devolver erro só alimenta mais reentregas. Registrar a chave antes de o efeito ser confirmado e sem transação: se o processo morrer no meio, o evento fica marcado como feito e nunca é aplicado. Tratar eventos fora de ordem aplicando o estado do payload sem comparar com o estado atual: um subscription.updated atrasado sobrescreve um cancelamento mais recente. Para isso, guarde o timestamp ou a versão do objeto no provedor e ignore eventos mais antigos que o último aplicado.

    Teste de aceitação mínimo: dispare o mesmo payload assinado 20 vezes em paralelo contra o endpoint e confirme que o efeito de negócio ocorreu uma única vez. Se o teste não existe na suíte, a idempotência é uma crença, não uma propriedade.
  • Multi-tenant em Postgres: schema por tenant ou coluna tenant_id

    O primeiro cliente enterprise pede isolamento de dados “de verdade” e a pergunta chega ao backlog: manter todos os tenants nas mesmas tabelas, com uma coluna tenant_id, ou dar a cada um o seu schema? A decisão parece reversível e não é. Migrar de um modelo para o outro com dados em produção costuma custar um trimestre de engenharia.

    Os dois modelos funcionam. O que muda é onde o custo aparece: no schema por tenant ele aparece em migrações e conexões; na coluna compartilhada, aparece em disciplina de acesso e vizinho barulhento.

    Na coluna compartilhada, todas as tabelas carregam tenant_id, e toda query filtra por ele. Existe um único conjunto de tabelas, um único conjunto de índices, uma única migração por deploy.

    No schema por tenant, cada cliente ganha um schema próprio (tenant_acme, tenant_beta) com cópia completa das tabelas. A aplicação define o search_path no início da requisição e o resto do código nem sabe que existe multi-tenancy.

    Existe um terceiro caminho, banco por tenant, que fica fora desta comparação: só se justifica quando o contrato exige isolamento físico, e o custo operacional sobe de patamar.

    Onde a coluna compartilhada quebra

    O risco central é o esquecimento. Uma query sem o filtro de tenant vaza dados entre clientes, e o erro não aparece em teste a menos que o teste use dois tenants. A mitigação séria é Row Level Security: a política no banco filtra por uma variável de sessão, e a aplicação passa a ter o filtro garantido mesmo quando alguém esquece.

    Na prática: ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY; seguido de CREATE POLICY isola_tenant ON pedidos USING (tenant_id = current_setting('app.tenant_id')::uuid);. A aplicação executa SET LOCAL app.tenant_id = '...' no início de cada transação. Com pool de conexões em modo transação, o SET LOCAL é obrigatório; um SET simples vaza para a próxima requisição que reaproveitar a conexão.

    O segundo problema é o vizinho barulhento. Um tenant com dez milhões de linhas dentro da mesma tabela degrada o plano de execução dos outros, porque as estatísticas do planner são globais para a tabela. Índices compostos começando por tenant_id resolvem a maior parte, mas relatórios pesados de um cliente ainda disputam cache e I/O com todo mundo.

    O terceiro é a exclusão. Remover um tenant (pedido de LGPD, fim de contrato) vira um DELETE em dezenas de tabelas, em lote, sem travar o resto. Funciona, só exige cuidado e um roteiro testado.

    Onde o schema por tenant quebra

    O custo aparece em escala de quantidade. Cada schema com 40 tabelas e seus índices adiciona milhares de objetos ao catálogo do Postgres. Com algumas centenas de tenants a coisa segue tranquila; na casa dos milhares, o catálogo incha, o pg_dump demora, e o autovacuum passa a trabalhar sobre um número enorme de relações pequenas. O ponto exato varia com hardware e versão, e vale medir antes de prometer.

    A migração é o segundo custo. Um ALTER TABLE precisa rodar em todos os schemas, em ordem, com tratamento para o caso em que 312 de 400 já foram e o 313º falha. Sem ferramenta própria de versionamento por schema, o time acaba mantendo um estado de migração paralelo, e tenants em versões diferentes viram fonte de bug difícil de reproduzir.

    O terceiro é a conexão. Pool por schema não existe; o que existe é search_path por requisição, e isso conflita com alguns modos de pooling de conexão. Quem usa PgBouncer em modo transação precisa definir o schema dentro de cada transação, com a mesma atenção do SET LOCAL acima.

    Comparativo direto

    Critério Coluna tenant_id Schema por tenant
    Isolamento lógico Depende de RLS ou de disciplina Forte por construção
    Migração de esquema Uma execução por deploy N execuções, com controle de falha parcial
    Escala em número de tenants Milhares sem problema estrutural Catálogo pesa a partir de centenas a milhares
    Vizinho barulhento Maior risco Menor, ainda compartilha instância
    Exportar ou apagar um cliente Queries por tabela Dump ou drop do schema
    Customização por cliente Difícil Possível, mas cara de manter

    Como provar o isolamento antes do primeiro incidente

    Qualquer que seja o modelo, o isolamento só existe quando é testado de forma adversarial. O teste mínimo cria dois tenants, popula ambos com dados parecidos e percorre cada endpoint autenticado como o tenant A tentando ler recursos do tenant B por identificador. A resposta esperada é 404, não 403: devolver 403 já confirma que o recurso existe.

    No modelo de coluna, vale um segundo teste no nível do banco: conectar com o papel que a aplicação usa, sem definir a variável de sessão, e executar SELECT count(*) em cada tabela com tenant_id. O resultado precisa ser zero. Se vier qualquer outro número, existe tabela sem política, ou a aplicação está conectando com um papel dono das tabelas, que ignora RLS por padrão. Esse segundo caso é o erro mais comum: o papel de migração e o papel da aplicação precisam ser distintos.

    No modelo de schema, o equivalente é verificar que o papel da aplicação não tem permissão em schemas de outros tenants, e que o search_path nunca inclui mais de um schema de cliente. Um search_path com dois schemas de tenant resolve nomes de forma silenciosa e errada, e o bug só aparece em produção.

    A experiência de times que operam os dois modelos aponta para um padrão: a coluna compartilhada gera menos chamados de infraestrutura e mais incidentes de segurança potenciais, enquanto o schema por tenant faz o inverso. Falha de migração parcial, backup lento e catálogo inchado são problemas de plantão. Vazamento entre tenants é problema de reputação, e a conta da reputação costuma ser maior. Por isso, no desempate, a coluna só deve ganhar se RLS estiver ligado e testado, com o teste rodando no pipeline a cada deploy e não apenas na semana da auditoria. Um isolamento que ninguém verifica automaticamente tende a se degradar com cada tabela nova criada às pressas.

    Os sinais que decidem

    Alguns fatos do produto resolvem a escolha antes da discussão técnica. Se a base de clientes é grande e pulverizada, com muitos tenants pequenos (típico de SaaS self-service), a coluna compartilhada é a resposta. O custo por tenant precisa tender a zero, e criar um schema com 40 tabelas para cada trial que talvez nunca ative é desperdício.

    Se a base é pequena e cada cliente é grande e exigente (dezenas de contas B2B com contrato anual), o schema por tenant ganha. O isolamento vira argumento comercial, a exportação de dados de um cliente é um comando só, e o número de schemas fica longe do limite incômodo.

    Um sinal pouco discutido: customização. Se vendas promete campos e tabelas próprias por cliente, o schema por tenant parece atraente, mas o preço é cada cliente virar uma versão do produto. Em geral, resolve-se melhor com colunas JSONB ou tabelas de metadados na estrutura compartilhada.

    O que escolher, por cenário

    Para SaaS que vende para muitas empresas pequenas e médias: coluna tenant_id, com RLS ligado desde o primeiro dia e um teste automatizado que cria dois tenants e tenta ler os dados do outro em cada endpoint. Esse teste vale mais do que qualquer revisão de código.

    Para produto com poucas dezenas de clientes grandes, regulados ou com exigência contratual de segregação: schema por tenant, com ferramenta de migração que registre a versão aplicada em cada schema e permita retomar do ponto de falha.

    Para a zona do meio, com centenas de clientes de porte variado, o híbrido costuma funcionar: coluna compartilhada para a massa e schema (ou banco) dedicado para os poucos que pagam por isso. Exige que a camada de acesso aceite os dois caminhos desde o início, e é bem mais barato desenhar assim do que retrofitar depois.

    Na dúvida sobre o futuro do produto, comece pela coluna compartilhada com RLS. Separar um tenant grande para um schema próprio depois é uma operação conhecida e pontual. Juntar quatrocentos schemas em tabelas únicas, não.

  • Monolito modular ou microsserviços para um sistema de gestão que ficou lento

    O cadastro de clientes cresceu, o relatório mensal já leva quarenta segundos para abrir e alguém sugeriu “migrar para microsserviços”. Antes de abrir esse debate, vale separar o que é problema de arquitetura do que é problema de consulta. A maioria dos sistemas de gestão que chega a esse ponto tem o segundo, e a solução custa uma semana, não um semestre.

    O que o monolito bem feito ainda resolve

    Um monolito modular com um único banco relacional entrega transação ACID entre pedido, estoque e financeiro sem nenhum esforço. Quando você parte isso em serviços, cada fronteira vira consistência eventual, filas, retentativas e idempotência. Para um ERP de médio porte, com dezenas de milhares de registros por dia e não bilhões, esse custo raramente se paga.

    O gargalo costuma estar em três lugares: consultas sem índice adequado, N+1 no ORM e relatórios pesados rodando no mesmo banco que atende a operação. Os três se resolvem sem mudar a topologia.

    Antes de dividir, meça

    Ative o log de consultas lentas e deixe rodar uma semana. Ordene por tempo total, não por tempo individual: uma query de 80 ms chamada 200 mil vezes pesa mais do que uma de 4 segundos chamada duas vezes. Em Postgres, o EXPLAIN (ANALYZE, BUFFERS) mostra se o plano faz varredura sequencial onde deveria usar índice.

    Na prática: CREATE INDEX CONCURRENTLY idx_pedidos_cliente_data ON pedidos (cliente_id, criado_em DESC); — o CONCURRENTLY evita travar escritas durante a criação, o que importa em produção.

    O N+1 que o ORM esconde

    É o defeito mais barato de corrigir e o que mais aparece em telas de listagem. A tela carrega 50 pedidos e, para cada um, o ORM dispara uma consulta extra para buscar o cliente, outra para os itens, outra para o status. Em desenvolvimento, com 20 registros, ninguém percebe. Em produção, a listagem faz 150 idas ao banco e a latência de rede entre aplicação e banco, mesmo de 1 ms, já soma 150 ms antes de qualquer processamento.

    O diagnóstico é contar consultas por requisição, não olhar tempo. Se uma listagem executa mais de 10 consultas, desconfie. A correção é carregar as relações junto (select_related ou prefetch_related no Django, includes no Rails, joinedload no SQLAlchemy) ou montar uma consulta única com JOIN e agregação.

    Na prática: Pedido.objects.select_related("cliente").prefetch_related("itens") reduz a listagem de 150 consultas para 3, sem tocar em banco, infraestrutura ou arquitetura.

    Relatório pesado não deve disputar o banco da operação

    Relatórios gerenciais fazem agregações sobre milhões de linhas e seguram memória e I/O. Rodando no mesmo primário que atende o caixa ou o pedido, eles derrubam a latência de todo mundo às nove da manhã de segunda, quando a diretoria abre o painel. Uma réplica de leitura resolve isso por uma fração do custo de qualquer redesenho. Duas ressalvas: a réplica tem atraso de replicação, normalmente de segundos, então relatório “em tempo real” de caixa precisa de decisão consciente; e consultas longas na réplica podem ser canceladas pelo mecanismo de conflito de replicação, o que exige ajustar max_standby_streaming_delay ou aceitar retentativas.

    Para relatórios que ninguém precisa ver ao segundo, uma tabela de resumo recalculada de hora em hora, ou uma view materializada atualizada por agendamento, entrega tela instantânea. É um pouco de código e muito alívio.

    Como extrair um módulo sem parar o resto

    Quando os sinais acima aparecerem, o caminho que menos dá problema segue uma sequência. Defina a fronteira dentro do próprio monolito primeiro: o módulo passa a ser acessado só por uma interface interna, sem consultar suas tabelas diretamente de outros lugares. Isso por si só costuma levar um ou dois sprints e já mostra quais acoplamentos escondidos existem.

    Depois, coloque uma fila entre o monolito e o módulo para o que for assíncrono (emissão, notificação, processamento). Rode os dois caminhos em paralelo por algumas semanas, comparando resultados. Só então mova o processamento para um serviço próprio, com banco próprio quando a fronteira de dados estiver limpa. Defina desde o início o contrato versionado: um campo novo opcional é compatível, remover ou renomear campo quebra consumidores.

    O erro clássico é extrair o módulo com mais dependências primeiro, por ser o “mais importante”. Comece pelo mais periférico. Você aprende o custo operacional de ter um serviço (deploy, logs, alertas, tracing) num lugar onde errar é barato.

    Cache só onde a leitura se repete

    Cache esconde problema quando entra antes do diagnóstico. Depois de corrigir índice e N+1, olhe o que ainda é lido repetidamente com o mesmo resultado: tabela de preços, configuração por tenant, permissões do usuário. Um cache em memória da aplicação com expiração curta, de 30 a 120 segundos, já corta boa parte da carga sem introduzir infraestrutura nova. Redis entra quando há mais de uma instância da aplicação e a invalidação precisa ser coordenada. A pergunta que decide é: o que acontece se o usuário ver um dado de dois minutos atrás? Se a resposta for “nada”, cacheie. Se for “emitimos nota com preço errado”, não.

    Comparativo: monolito modular x serviços separados

    Critério Monolito modular Serviços separados
    Transação entre domínios Nativa Saga ou consistência eventual
    Deploy Um artefato Por serviço, com versionamento de contrato
    Escala independente Só do conjunto Por serviço
    Depuração Stack trace único Tracing distribuído obrigatório
    Custo operacional Baixo Alto: observabilidade, filas, CI por serviço
    Times necessários Um ou dois Vários, com dono por serviço

    Quando a separação passa a fazer sentido

    Há três sinais que justificam extrair um módulo. Primeiro, o módulo tem perfil de carga completamente diferente: emissão de nota fiscal em lote, processamento de imagem, envio de e-mail em massa. Segundo, equipes diferentes precisam publicar em cadências diferentes e o deploy conjunto virou fila. Terceiro, uma exigência de isolamento, como dados de pagamento sob escopo próprio de conformidade.

    Nesses casos, extraia um só módulo, comece pela borda (o que menos depende do resto) e mantenha o banco compartilhado em modo somente leitura por um período, até confiar no contrato. Extrair tudo de uma vez é a forma mais confiável de ficar seis meses sem entregar funcionalidade.

    Decisão

    Um cenário hipotético para calibrar

    Imagine um ERP de distribuidora com 25 mil pedidos por mês e relatório de comissões que demora 40 segundos. O time propõe extrair o módulo financeiro. A investigação mostra que a consulta de comissões faz uma varredura sequencial em uma tabela de itens de pedido sem índice por vendedor e data, e que o relatório roda no primário. Um índice composto e a mudança para a réplica levam o relatório a poucos segundos. O módulo financeiro continua onde estava, e a equipe passou um mês entregando funcionalidade em vez de reescrever infraestrutura. O exemplo é inventado, mas o padrão é recorrente: a causa fica na camada de dados com muito mais frequência do que na topologia.

    Minha posição: trate a extração de serviço como a última opção da lista, não a primeira. A ordem que eu seguiria é medir, indexar, eliminar N+1, separar leitura de escrita, cachear o que for repetido e só então avaliar fronteiras de serviço. Cada etapa custa menos que a seguinte e, em geral, uma das primeiras já resolve. Registre o tempo das consultas críticas antes e depois de cada mudança, para que a próxima conversa sobre arquitetura use números e não impressão. Se mesmo assim a carga ou a organização do time pedirem separação, você chega à decisão com evidência e com um módulo candidato bem delimitado, o que reduz muito o risco da migração.

    Se o sintoma é lentidão e o time tem menos de dez pessoas, fique no monolito modular, corrija índices e N+1, mova relatórios para uma réplica de leitura e coloque um cache onde o perfil de consulta mostrar leitura repetida. Se o sintoma é bloqueio entre times ou carga assimétrica em um módulo específico, extraia esse módulo. Microsserviços como ponto de partida para um sistema de gestão sem esses sintomas é custo antecipado por um problema que talvez nunca apareça.

  • Fila de jobs em Postgres ou Redis: onde o SKIP LOCKED deixa de bastar

    O time precisa enviar e-mails de confirmação, gerar PDFs e chamar três APIs de terceiros depois que um pedido é gravado. O banco já é Postgres. A pergunta que aparece na revisão de arquitetura é se vale subir um Redis só para ter fila, ou se uma tabela jobs resolve. A resposta depende de duas coisas que raramente são medidas antes da decisão: quantos jobs por segundo no pico e se o job precisa nascer na mesma transação do dado de negócio.

    O que a fila em Postgres entrega de graça

    O ganho central é atomicidade. Quando o pedido e o job são inseridos na mesma transação, não existe a janela em que o pedido foi commitado e o enfileiramento falhou, nem o inverso. Com Redis como fila, essa garantia exige padrão outbox: gravar o evento numa tabela, ter um processo que o lê e publica, e tratar duplicidade no consumidor. É mais uma peça para operar, e é exatamente a peça que costuma ser esquecida até o primeiro incidente de pedido sem e-mail.

    O segundo ganho é visibilidade. Job é linha de tabela: dá para fazer SELECT com filtro por status, tempo de espera e tipo, juntar com a tabela de clientes e montar o painel de fila sem ferramenta adicional. Reprocessar um lote é um UPDATE. Backup, replicação e retenção já estão resolvidos porque são os do banco.

    O mecanismo que torna isso viável é o FOR UPDATE SKIP LOCKED: cada worker pega uma linha pendente sem esperar as que outro worker já travou.

    Na prática

    WITH proximo AS (
      SELECT id FROM jobs
      WHERE status = 'pendente' AND executar_em <= now()
      ORDER BY prioridade DESC, id
      FOR UPDATE SKIP LOCKED
      LIMIT 10
    )
    UPDATE jobs SET status = 'rodando', iniciado_em = now(), tentativas = tentativas + 1
    FROM proximo WHERE jobs.id = proximo.id
    RETURNING jobs.*;

    Índice parcial em (prioridade DESC, id) WHERE status = 'pendente' mantém a consulta barata mesmo com milhões de jobs concluídos na tabela.

    Onde Redis com BullMQ ganha

    Throughput e latência de entrega. Uma fila em memória entrega o job ao worker em milissegundos, sem polling e sem pressão sobre o banco principal. Em Postgres, a alternativa ao polling é LISTEN/NOTIFY, que funciona bem para acordar workers, mas não substitui a leitura transacional e tem limites quando há muitas conexões escutando. Para ordem de grandeza: uma tabela bem indexada sustenta milhares de jobs por segundo em hardware modesto, e o ponto de dor aparece quando os jobs são muitos, curtos e disputam as mesmas páginas e o mesmo autovacuum das tabelas de negócio. O número exato varia com o tamanho da linha, o hardware e a configuração, então meça com a carga real.

    O segundo ponto a favor do Redis é o recurso pronto: limitação de taxa por fila, jobs repetitivos, atraso, prioridade, dependência entre jobs e fluxos pai-filho. Dá para reproduzir tudo isso em SQL, mas cada item vira código seu para manter.

    O custo é a durabilidade. Redis persiste por snapshot ou por AOF, e com AOF em everysec uma queda pode perder cerca de um segundo de escritas. Para job de e-mail, aceitável. Para job que representa cobrança, não.

    O problema que ninguém mede: churn de linhas

    Fila em Postgres é a carga mais hostil para o MVCC. Cada job é inserido, atualizado uma ou duas vezes e removido ou arquivado. Isso gera tuplas mortas em volume, e o autovacuum precisa acompanhar. Três práticas evitam o pior cenário:

    • Separar a tabela de fila da tabela de histórico e mover os concluídos em lote, ou particionar por dia e descartar partições antigas.
    • Ajustar autovacuum_vacuum_scale_factor só nessa tabela, com valor bem menor que o padrão.
    • Evitar transações longas no mesmo banco, porque uma transação aberta há uma hora impede a limpeza das tuplas mortas da fila inteira.

    Quando o time ignora isso, o sintoma é uma consulta de dequeue que levava 2 ms e passa a levar centenas, sem nenhuma mudança de código.

    Retry, idempotência e job travado

    Independente da tecnologia, o worker vai morrer no meio de um job, e o sistema precisa decidir o que acontece depois. Em Postgres, a convenção é gravar iniciado_em e ter um processo que devolve para pendente os jobs rodando há mais que o timeout, com contador de tentativas e fila morta ao estourar o limite. Em BullMQ existe o conceito de lock renovável e de jobs “stalled”, com comportamento equivalente.

    O ponto que importa: em ambos os casos a entrega é pelo menos uma vez. O job pode rodar duas vezes. Todo handler precisa ser idempotente, seja com chave de idempotência enviada à API externa, seja com verificação de estado antes de agir. Quem escolhe Redis esperando “exatamente uma vez” está comprando uma garantia que nenhuma das duas oferece.

    Operação: o que cada opção cobra no plantão

    O custo operacional é onde a comparação costuma ficar desonesta, porque o Redis aparece como “simples” até alguém precisar mantê-lo. Um Redis de fila em produção exige decisão sobre persistência, política de memória (noeviction é a única segura para fila, senão chaves de jobs podem ser descartadas sob pressão), failover e monitoramento de memória. Em serviço gerenciado, boa parte disso vira cobrança mensal, e o volume de memória cresce com o backlog: uma fila que acumula por falha de um consumidor pode encher a instância.

    A fila em Postgres herda a operação que o time já tem, mas compartilha recursos com o tráfego principal. Um backlog que dispara numa madrugada de importação compete por I/O e conexões com as consultas dos usuários. A mitigação é limitar o número de workers e, se o volume crescer, apontar a fila para um banco separado, mantendo a mesma interface.

    Critério Postgres (SKIP LOCKED) Redis (BullMQ)
    Job na mesma transação do dado Nativo Exige outbox
    Latência de entrega Polling ou NOTIFY, dezenas a centenas de ms Milissegundos
    Durabilidade A do banco Depende de AOF e réplica
    Rate limit, fluxos, repetição Você implementa Pronto
    Consulta ad hoc da fila SQL comum Painel ou comandos Redis
    Risco principal Churn e autovacuum Memória e perda na queda

    Um cenário comum para calibrar: 40 jobs por segundo no pico, cada um com duração média de 300 ms, exige em torno de 12 workers concorrentes (40 × 0,3). Isso são 12 conexões de banco ocupadas só pela fila, o que num Postgres com pool de 50 já é um quarto da capacidade. Aqui a conta pesa a favor do Redis ou de um pool dedicado. Com 5 jobs por segundo, a mesma conta dá dois workers, e a fila em Postgres é irrelevante para o orçamento do banco.

    Critério de decisão

    Postgres como fila quando: o volume de pico fica abaixo de algumas centenas de jobs por segundo, o job precisa ser consistente com a transação que o originou, o time é pequeno e cada componente novo é um plantão a mais. É o caso da maioria dos sistemas de gestão, e-commerce de porte médio e SaaS B2B.

    Redis com BullMQ quando: o volume é alto e em rajadas, a latência entre o evento e a execução importa em tempo de segundos ou menos, há necessidade real de rate limit, fluxos de jobs dependentes ou agendamentos recorrentes complexos, e a perda de uma janela curta de escritas é tolerável ou mitigada com outbox.

    A escolha mais comum e mais defensável para um time de até dez pessoas é começar em Postgres, isolar o enfileiramento atrás de uma interface de duas funções (enfileirar e consumir) e migrar quando o autovacuum ou o volume provarem que é hora. A interface pequena é o que torna a migração um projeto de uma semana em vez de um semestre. Migrar por antecipação, sem métrica, costuma trocar um problema que o time sabe resolver por outro que ainda não conhece.

  • Fila de mensagens em produção: quando o Postgres basta, quando o RabbitMQ compensa e quando o Kafka se justifica

    A pergunta costuma chegar deformada: “qual broker usar?”. A decisão real é outra — o que precisa acontecer depois que a mensagem é consumida. Se a resposta for “nada, o trabalho acabou”, metade das opções do mercado está sobrando. Se a resposta for “outro time vai querer ler o mesmo evento daqui a seis meses”, uma fila tradicional já está errada desde o primeiro dia.

    O critério que separa os três modelos

    Postgres com SKIP LOCKED, RabbitMQ e Kafka não são o mesmo produto em escalas diferentes. São três semânticas distintas.

    Em uma fila de trabalho — Postgres ou RabbitMQ — a mensagem é um comando destinado a um consumidor. Consumida com sucesso, ela desaparece. O broker mantém estado por mensagem: pendente, em processamento, concluída, morta. É esse estado que permite retry individual, dead-letter queue e prioridade.

    No Kafka, a mensagem é um fato registrado em um log append-only particionado. Ela não desaparece quando alguém lê: fica até a retenção expirar. O que avança é o offset de cada grupo de consumidores. Isso muda o que é barato e o que é caro. Reprocessar três dias de eventos é trivial — reposiciona o offset. Já dar retry em uma única mensagem que falhou, sem travar as que vieram atrás dela na mesma partição, é um problema que exige tópico de retry, DLQ manual e disciplina.

    Quem escolhe Kafka para processar 200 jobs de geração de PDF por dia acaba reimplementando, em código de aplicação, o gerenciamento de estado por mensagem que o RabbitMQ entrega pronto. Quem escolhe RabbitMQ para distribuir eventos de domínio a cinco consumidores independentes acaba criando cinco filas espelhadas e descobrindo, no primeiro incidente, que não há como um consumidor novo ler o histórico.

    Postgres como fila: o limite não é o throughput

    Uma tabela de jobs com SELECT ... FOR UPDATE SKIP LOCKED sustenta com folga a faixa de centenas a poucos milhares de mensagens por segundo em hardware comum — muito acima do que a maioria dos sistemas de gestão, ERPs e plataformas B2B produz. O padrão é curto:

    UPDATE jobs SET status = 'running', locked_at = now()
    WHERE id IN (
      SELECT id FROM jobs
      WHERE status = 'pending' AND run_after <= now()
      ORDER BY priority DESC, id
      FOR UPDATE SKIP LOCKED
      LIMIT 20
    )
    RETURNING *;

    A vantagem decisiva não é performance: é a transação. O job entra na mesma transação que cria o pedido. Ou os dois existem, ou nenhum existe. Com broker externo, esse acoplamento exige outbox pattern — uma tabela de eventos gravada na transação e um processo separado publicando no broker — o que reintroduz o Postgres no caminho de qualquer forma.

    O limite real aparece em outro lugar. Filas geram alta rotatividade de linhas, e alta rotatividade gera bloat: a tabela de jobs vira a mais cara do autovacuum, e um pico de backlog não expurgado degrada o banco que atende as requisições dos usuários. Duas contramedidas resolvem a maior parte dos casos: mover jobs concluídos para uma tabela de histórico em vez de deixá-los acumulando, e ajustar autovacuum_vacuum_scale_factor especificamente nessa tabela, já que o padrão global é conservador demais para ela.

    Ajuste por tabela, não globalmente: ALTER TABLE jobs SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_delay = 0); — em uma tabela com muitos DELETE/UPDATE, o fator padrão de 0.2 deixa o vacuum passar tarde demais.

    O segundo limite é o fan-out. Distribuir o mesmo job para consumidores com ritmos diferentes na mesma tabela vira uma máquina de estados na aplicação. É aí que a fila em banco deixa de compensar — não quando o volume cresce.

    RabbitMQ: roteamento e retry prontos, operação por conta da casa

    O RabbitMQ entrega o que é trabalhoso de escrever à mão: ack e nack por mensagem, prefetch para controlar quanto cada worker puxa, dead-letter exchange, TTL, mensagens com prioridade e roteamento por topic exchange. Um worker que morre no meio do processamento devolve a mensagem para a fila sem nenhuma linha de código adicional — o canal caiu, o ack não veio, a mensagem volta.

    O preço vem em dois itens. O primeiro é operacional: quorum queues replicadas exigem entender comportamento de partição de rede, e a decisão de memória e disco do nó não é auto-ajustável — um nó que atinge o watermark de memória aplica backpressure em todos os publishers, inclusive nos que estão publicando em filas saudáveis. O segundo é que a fila do RabbitMQ não é um histórico. Mensagem consumida, mensagem perdida. Quando alguém pergunta “quais pedidos passaram por esse fluxo na semana passada?”, a resposta precisa vir do banco, nunca do broker.

    Há um detalhe que morde com frequência: prefetch alto em consumidores com tempo de processamento desigual produz filas invisíveis dentro do próprio worker. Trinta mensagens reservadas por um consumidor que travou ficam paradas até o timeout do canal, enquanto os outros workers estão ociosos. Para jobs longos e heterogêneos, prefetch 1 é o ponto de partida sensato, e valores maiores só se justificam quando o processamento é curto e uniforme.

    Kafka: o log compensa quando há mais de um leitor

    Kafka se justifica quando o mesmo evento interessa a consumidores independentes — o serviço de faturamento, o pipeline de analytics, o índice de busca — e quando reprocessar histórico é requisito, não exceção. Adicionar um quarto consumidor a um tópico existente não afeta os três que já estão lá, e ele pode começar a leitura do início da retenção.

    O ordenamento é por partição, não por tópico. Quem precisa que eventos do mesmo pedido sejam processados em ordem particiona pela chave do pedido. Isso tem consequência direta: o paralelismo máximo de um grupo de consumidores é o número de partições. Vinte e quatro partições significam, no máximo, vinte e quatro consumidores ativos naquele grupo — o vigésimo quinto fica ocioso. E aumentar partições depois redistribui as chaves, o que quebra a ordem para as mensagens em trânsito.

    O ponto mais subestimado é o retry. Uma mensagem envenenada trava a partição inteira enquanto o consumidor insiste nela. A saída conhecida é publicar a falha em um tópico de retry com atraso e seguir adiante, mas isso abre mão da ordem exatamente para os casos que falharam. Não existe configuração que resolva — é decisão de projeto.

    A tabela e o erro que se repete

    Critério Postgres (SKIP LOCKED) RabbitMQ Kafka
    Semântica Comando Comando Fato/evento
    Retry por mensagem Trivial Nativo Manual, com tópico extra
    Múltiplos leitores independentes Ruim Exige fila por consumidor Nativo
    Reprocessar histórico Só se você guardou Não Nativo
    Atomicidade com o dado de negócio Direta Exige outbox Exige outbox
    Custo operacional Baixo Médio Alto

    O erro mais frequente não é escolher a opção errada — é escolher uma e usá-la com a semântica da outra. Publicar eventos de domínio em fila de trabalho e depois criar uma fila por consumidor, ou tratar tópico Kafka como fila de jobs e reimplementar ack por mensagem em cima do offset. Nos dois casos o sistema funciona por alguns meses e só cobra a conta quando entra o terceiro consumidor ou o primeiro incidente de reprocessamento.

    O que fazer na segunda-feira

    Para um sistema que ainda não tem broker: fila no Postgres, com tabela de histórico separada e autovacuum ajustado. Ela cobre e-mail, geração de relatório, webhook de saída e importação de arquivo pelo tempo que a maioria dos produtos leva para chegar à escala seguinte — e elimina uma dependência de infraestrutura enquanto o time ainda é pequeno.

    Migre para RabbitMQ quando a máquina de estados dos jobs começar a acumular casos especiais — prioridade, atraso exponencial, roteamento por tipo. O sinal é o código da aplicação, não o gráfico de throughput.

    Adote Kafka quando existir um segundo consumidor genuinamente independente do primeiro, com ritmo próprio e necessidade de ler histórico. Antes disso, o custo operacional não encontra contrapartida. E se a resposta for “vamos precisar dos dois”, o desenho que se sustenta é Kafka para eventos de domínio e fila de trabalho — Postgres ou RabbitMQ — para os comandos que cada consumidor gera a partir deles. São camadas diferentes, não concorrentes.

  • Migração de schema sem downtime: o roteiro de sete etapas para alterar tabela grande em produção

    A tabela de pedidos tem 40 milhões de linhas e o time precisa trocar a coluna status de varchar para um tipo enumerado. Em staging, com 200 mil registros, o ALTER TABLE roda em dois segundos. Em produção, a mesma instrução mantém um lock exclusivo pelo tempo que o banco levar para reescrever o arquivo inteiro — e toda requisição que toca a tabela fica na fila atrás dele. A diferença entre uma janela de manutenção de madrugada e uma migração invisível não está na instrução SQL: está em quebrar a mudança em etapas que nunca exigem que schema antigo e código novo existam ao mesmo tempo.

    O problema não é o ALTER, é o lock que ele segura

    Alterações de schema se dividem em duas famílias. As que só mexem no catálogo — adicionar coluna anulável sem default, renomear constraint, criar índice de forma concorrente — terminam em milissegundos porque não tocam nos dados. As que reescrevem a tabela — mudar tipo, adicionar coluna NOT NULL com valor calculado, alterar collation — precisam percorrer cada linha. Nessa segunda família o custo cresce com o volume, e o lock que o banco adquire bloqueia leitura, escrita ou ambas, dependendo do motor.

    Há um agravante que costuma passar despercebido: o lock não começa quando o ALTER inicia, começa quando ele consegue entrar na fila. Se existe uma transação longa aberta sobre a tabela — um relatório que varre meses de histórico, um cursor esquecido por um job — o ALTER espera por ela segurando um pedido de lock exclusivo. Todas as consultas que chegam depois ficam atrás desse pedido. O resultado é uma fila que trava a aplicação antes mesmo de a migração ter começado a fazer qualquer trabalho.

    Por isso a primeira decisão não é como executar a alteração, e sim se ela pode ser decomposta em passos que jamais precisem de um lock demorado. O padrão que resolve isso se chama expand-contract: expandir o schema para suportar as duas formas, migrar os dados e o código, depois contrair removendo a forma antiga.

    As sete etapas do expand-contract

    O roteiro abaixo vale para qualquer mudança destrutiva — troca de tipo, divisão de coluna, normalização de campo, renomeação. Cada etapa é um deploy independente, e nenhuma delas pode assumir que a anterior já rodou em todas as instâncias da aplicação.

    1. Adicionar a nova estrutura vazia. Coluna nova, anulável, sem default calculado. Essa operação é de catálogo na maioria dos bancos modernos e termina instantaneamente. Nada no código a usa ainda.
    2. Escrever nos dois lugares. Deploy da aplicação gravando em coluna velha e nova simultaneamente, lendo ainda da velha. A partir daqui todo registro novo já nasce correto; só falta o histórico.
    3. Backfill em lotes. Preencher o histórico com um job que processa faixas pequenas de chave primária, com pausa entre elas. Detalhado na próxima seção.
    4. Validar a equivalência. Consulta que conta divergências entre as duas colunas. Só avança quando o resultado é zero em duas execuções separadas por algumas horas — a segunda cobre registros que entraram durante a primeira.
    5. Ler da nova, escrever nas duas. Deploy que inverte a fonte de leitura mantendo a escrita dupla. Este é o único passo com risco real, e é também o único que reverte instantaneamente: basta voltar a versão anterior da aplicação, porque a coluna antiga continua atualizada.
    6. Parar de escrever na antiga. Deploy que remove a escrita dupla. A coluna velha congela, mas permanece no banco.
    7. Remover a coluna antiga. Só depois de o sistema rodar alguns dias lendo exclusivamente da nova. Em Postgres, DROP COLUMN é operação de catálogo e não reescreve a tabela; o espaço volta no vacuum.

    São sete deploys onde havia um comando. O ganho não é elegância — é que qualquer etapa pode ser revertida sem perda de dado, e nenhuma delas segura lock por mais que alguns milissegundos.

    Backfill: o lote que não derruba a réplica

    O erro clássico do backfill é um único UPDATE sobre a tabela toda. Além de manter uma transação gigantesca, ele gera um volume de WAL que a réplica precisa aplicar sequencialmente — e o lag de replicação sobe até que leituras direcionadas à réplica passem a devolver dados defasados. O padrão correto é percorrer a chave primária em faixas fixas, confirmar cada lote e esperar.

    O loop de backfill em faixas de chave primária, com pausa proporcional ao lag observado:

    -- roda em loop na aplicação ou em um job dedicado
    UPDATE pedidos
    SET status_enum = status::pedido_status
    WHERE id >= :inicio AND id < :inicio + 5000
      AND status_enum IS NULL;

    Entre um lote e o seguinte, consultar o lag da réplica e dormir enquanto ele passar do limite aceitável. Se o lag não cair, o problema é o tamanho do lote — reduza pela metade em vez de aumentar a pausa.

    O tamanho do lote depende do custo por linha, não do total de linhas. Uma coluna de conversão barata aceita faixas maiores; uma que dispara trigger, atualiza índice composto ou recalcula valor derivado exige faixas bem menores. A calibragem prática é começar conservador — faixas na casa do milhar — medir o tempo de cada lote e subir até que ele se aproxime do limite que a operação tolera, tipicamente algo abaixo de um segundo por lote.

    Duas condições fazem o job ser seguro para reexecução: filtrar por IS NULL na coluna de destino, para que um lote repetido não faça trabalho duplicado, e persistir o último intervalo concluído fora da transação, para que uma queda no meio do processo retome de onde parou em vez de recomeçar.

    Onde cada banco muda o roteiro

    O padrão é o mesmo, mas o que cada motor considera operação barata varia — e é isso que determina quais etapas podem ser puladas.

    Operação PostgreSQL MySQL / InnoDB SQL Server
    Adicionar coluna anulável Catálogo, instantâneo Instantâneo a partir do 8.0 com ALGORITHM=INSTANT Catálogo, instantâneo
    Adicionar coluna NOT NULL com default Instantâneo desde a versão 11 Instantâneo em versões recentes do 8.0 Instantâneo na edição Enterprise
    Criar índice sem bloquear escrita CREATE INDEX CONCURRENTLY Online DDL por padrão WITH (ONLINE = ON), Enterprise
    Alterar tipo da coluna Reescreve a tabela Reescreve, mas permite escrita concorrente em parte dos casos Reescreve
    Remover coluna Catálogo; espaço volta no vacuum Reescreve a tabela Catálogo; espaço volta na reconstrução

    A linha que mais muda decisão é a última. Em Postgres, o passo 7 é gratuito e pode acontecer assim que a equipe se sentir confortável. Em MySQL, remover a coluna reescreve a tabela inteira — o que significa que o passo final merece a mesma cautela do backfill e costuma ser executado por ferramenta externa de migração online, não por DDL direto.

    As armadilhas que aparecem só em produção

    Três falhas concentram a maior parte dos incidentes de migração, e nenhuma delas aparece em ambiente de teste.

    A primeira é o timeout de lock ausente. Sem um limite explícito, o ALTER espera indefinidamente pela transação longa e leva a aplicação junto. Definir um timeout curto — poucos segundos — antes de qualquer DDL faz a migração falhar rápido em vez de travar o sistema. Falhar é o comportamento desejado: o job tenta de novo em um momento de menor concorrência.

    A segunda é a ordem entre deploy e migração. Em rollout gradual, instâncias antigas e novas da aplicação convivem por alguns minutos. Se a migração remove algo que a versão antiga ainda usa, essas instâncias quebram. A regra que resolve: migração que adiciona vai antes do deploy, migração que remove vai depois — e o passo de remoção só entra no ciclo seguinte, nunca no mesmo.

    A terceira é a constraint validada na criação. Adicionar chave estrangeira ou CHECK faz o banco varrer a tabela para conferir as linhas existentes, com lock durante a varredura. Postgres permite criar a constraint como NOT VALID e rodar VALIDATE CONSTRAINT depois, em lock mais fraco — dois comandos no lugar de um, pelo mesmo motivo de sempre.

    O próximo passo concreto para quem tem uma alteração dessas na fila: pegar a migração pendente, classificar cada instrução como catálogo ou reescrita consultando a tabela acima, e reescrever as reescritas no formato de sete etapas. O que sobrar de instrução barata pode ir direto — desde que com timeout de lock definido.