Blog

  • Decisão de arquitetura — Outbox com polling ou CDC com Debezium: como publicar eventos sem perder consistência

    O pedido é gravado no Postgres e, na linha seguinte, o serviço publica PedidoCriado no broker. Funciona até o dia em que o commit passa e a publicação falha — ou o contrário: o evento sai, a transação sofre rollback e o serviço de faturamento cobra um pedido que não existe. Não há ordem de chamadas que resolva isso sem uma transação distribuída, e ninguém quer 2PC entre banco e broker em produção.

    O padrão outbox resolve a primeira metade: o evento é gravado numa tabela do próprio banco, na mesma transação que a mudança de negócio. Ou os dois existem, ou nenhum existe. A segunda metade — tirar o evento da tabela e entregá-lo ao broker — é onde está a decisão. Há dois caminhos maduros: um processo que faz polling na tabela outbox, ou captura de mudanças (CDC) lendo o log de replicação do banco, tipicamente com Debezium.

    O ponto de partida comum: a tabela outbox

    Qualquer que seja o relay escolhido, a escrita é igual. Uma tabela com identificador, tipo de agregado, id do agregado, tipo do evento, payload e data de criação. A aplicação insere nela dentro da transação de negócio e não fala com o broker.

    CREATE TABLE outbox (
      id            uuid PRIMARY KEY,
      aggregate_type text        NOT NULL,
      aggregate_id   text        NOT NULL,
      event_type     text        NOT NULL,
      payload        jsonb       NOT NULL,
      created_at     timestamptz NOT NULL DEFAULT now(),
      published_at   timestamptz
    );
    
    BEGIN;
      INSERT INTO pedidos (...) VALUES (...);
      INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
      VALUES (gen_random_uuid(), 'pedido', '8812', 'PedidoCriado', '{...}');
    COMMIT;

    Duas propriedades valem para os dois caminhos e convém aceitá-las antes de comparar. A entrega é pelo menos uma vez: em algum cenário de falha o mesmo evento sai duas vezes, então o consumidor precisa ser idempotente, usando o id do evento como chave de deduplicação. E a ordem só é garantida por agregado, desde que a chave de partição no broker seja o aggregate_id. Ordem global entre agregados diferentes nenhum dos dois entrega de graça.

    Caminho 1: relay por polling

    Um worker consulta a tabela em intervalo fixo, pega um lote de eventos não publicados, envia ao broker e marca como publicados. No Postgres, o FOR UPDATE SKIP LOCKED permite rodar mais de uma instância do worker sem que duas peguem a mesma linha.

    WITH lote AS (
      SELECT id FROM outbox
      WHERE published_at IS NULL
      ORDER BY created_at
      LIMIT 200
      FOR UPDATE SKIP LOCKED
    )
    UPDATE outbox o SET published_at = now()
    FROM lote WHERE o.id = lote.id
    RETURNING o.*;

    O envio ao broker acontece entre a leitura e o commit dessa transação. Se o envio falhar, rollback e o lote volta a ficar disponível. Se o envio passar e o commit falhar, o lote sai de novo na próxima rodada — daí a duplicata que o consumidor idempotente absorve.

    O que pesa a favor: é código da aplicação, roda no mesmo deploy, usa o mesmo driver e a mesma observabilidade. Não exige permissão de replicação no banco, funciona igual em Postgres, MySQL ou SQL Server, e qualquer pessoa do time entende o que ele faz lendo trinta linhas.

    O que pesa contra aparece com volume. A latência mínima é o intervalo de polling: com consulta a cada 500 ms, um evento espera em média algo perto de 250 ms antes de sair, mais o tempo do lote. Cada rodada é uma consulta ao primário, mesmo quando não há nada para publicar. E o UPDATE de published_at gera tuplas mortas numa tabela de escrita intensa, o que cobra autovacuum bem configurado e uma rotina de limpeza das linhas antigas. Um índice parcial em created_at WHERE published_at IS NULL mantém a consulta barata enquanto a fila pendente for pequena.

    Há ainda uma armadilha de ordenação: duas transações podem gravar na outbox em ordem de created_at diferente da ordem de commit. Com SKIP LOCKED e várias instâncias, eventos do mesmo agregado podem sair fora de ordem. Quem precisa de ordem estrita por agregado costuma rodar uma única instância ativa ou particionar o relay por hash do aggregate_id.

    Caminho 2: CDC com Debezium

    O Debezium lê o log de escrita do banco — no Postgres, via replicação lógica com o plugin pgoutput — e transforma cada INSERT na tabela outbox num evento no Kafka. O transformador de outbox do próprio Debezium (o Outbox Event Router) usa as colunas da tabela para decidir tópico, chave e payload. A aplicação só grava; nada consulta a tabela.

    Como o log reflete a ordem de commit, a ordem entre transações é a real, o que elimina a armadilha do created_at. A latência cai para a casa das dezenas a poucas centenas de milissegundos em condição normal, sem nenhuma consulta extra ao primário. E como a publicação não depende de marcar linhas, a tabela outbox pode ser limpa agressivamente — há quem apague a linha na mesma transação em que a insere, já que o log captura o INSERT de qualquer forma.

    O custo está no que passa a existir fora da aplicação. O caminho padrão roda sobre Kafka Connect, ou seja, mais um cluster para operar, versionar e monitorar; o Debezium Server é uma alternativa para publicar em outros brokers sem Connect, mas continua sendo um processo a mais. O banco precisa de wal_level = logical, o que em serviço gerenciado costuma exigir mudança de parâmetro e reinício. E há o risco operacional que mais derruba times que adotam CDC sem preparo: o slot de replicação.

    Enquanto o conector está parado, o Postgres retém todo o WAL a partir da última posição confirmada pelo slot. Um conector que falha numa sexta à noite e só é notado na segunda pode encher o disco do primário. Isso é mitigável — alerta sobre o lag do slot, max_slot_wal_keep_size nas versões que o suportam — mas precisa ser feito no dia um, não depois do primeiro incidente.

    Consulta para monitorar o WAL retido por cada slot no Postgres. Alerta quando passar de alguns GB ou quando active ficar falso por mais de poucos minutos:
    SELECT slot_name, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS retido FROM pg_replication_slots;

    Failover é o outro ponto. Em versões mais antigas do Postgres, o slot lógico não sobrevive à promoção de uma réplica, e o conector precisa ser recriado com snapshot ou reposicionado manualmente. Versões recentes trouxeram sincronização de slots para standby, mas vale conferir o que o seu provedor gerenciado suporta de fato antes de contar com isso.

    Lado a lado

    Critério Polling CDC (Debezium)
    Latência típica Intervalo de polling + lote (centenas de ms a segundos) Dezenas a poucas centenas de ms
    Carga no primário Consulta periódica + UPDATE por evento Leitura do WAL, sem consulta à tabela
    Ordem entre transações Por created_at, sujeita a inversão Ordem de commit
    Infraestrutura nova Nenhuma Kafka Connect ou Debezium Server
    Permissões no banco Leitura e escrita na tabela Replicação lógica, wal_level = logical
    Falha mais cara Fila pendente crescendo sem alarme Slot retendo WAL até encher o disco
    Quem precisa entender Time da aplicação Time da aplicação + quem opera o Connect

    O critério de escolha

    A pergunta que decide não é “qual é mais moderno”, e sim “já existe Kafka Connect operado por alguém nesta empresa?”. Quase todo o custo do CDC é fixo: montar e sustentar o conector, os alertas de slot, o procedimento de failover. Se essa estrutura já existe para outros fluxos — replicação para data warehouse, por exemplo —, o custo marginal de mais um conector é baixo e o CDC ganha em latência, ordem e carga no banco.

    Se não existe, o polling atende à maioria das cargas de SaaS de porte médio sem esforço. Algumas centenas de eventos por segundo, com latência aceitável de até um segundo, cabem num relay por polling com índice parcial e limpeza periódica. O limite prático aparece quando o volume faz a própria tabela outbox virar gargalo de vacuum, ou quando um requisito de negócio exige latência abaixo de 100 ms.

    A decisão, cravada:

    • Polling para quem tem um ou poucos serviços publicando eventos, não opera Kafka Connect e tolera latência de centenas de milissegundos. É a escolha padrão para começar.
    • CDC com Debezium quando já há Connect em produção, quando vários serviços precisam publicar eventos com o mesmo mecanismo ou quando a ordem de commit é requisito e não detalhe.

    Nos dois casos, a tabela outbox tem o mesmo formato. Começar com polling e migrar para CDC depois é trocar o relay, não reescrever os produtores — e esse é o argumento mais forte para não adotar CDC antes de haver motivo concreto. O próximo passo prático é medir o volume atual de eventos por segundo e o p99 de latência que os consumidores toleram; com esses dois números, a decisão deixa de ser opinião.

  • 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.

  • Post-mortem: o retry que transformou um timeout de 3 segundos em duas horas de banco esgotado

    Um endpoint de terceiro passou a responder em 3,2 segundos onde antes respondia em 180 ms. O timeout do cliente HTTP estava configurado em 3 segundos. Quarenta minutos depois, o pool de conexões do banco primário estava saturado e a aplicação devolvia 503 em rotas que nem tocavam naquele integrador. O terceiro nunca saiu do ar — apenas degradou. Quem caiu foi o sistema que o consumia.

    O cenário abaixo é reconstruído como exercício, não como incidente real, mas o encadeamento é o mais frequente em aplicações com fila de jobs e retry automático: uma dependência lenta vira indisponibilidade total porque a política de repetição amplifica carga em vez de absorvê-la. Vale destrinchar cada elo, porque o elo que a maioria das equipes corrige primeiro é o que menos importa.

    Linha do tempo do incidente

    T+0 — a latência p99 do endpoint de validação de documentos sobe de 180 ms para 3,2 s. Nenhum alerta dispara: o monitor de disponibilidade do integrador continua verde, porque ele responde 200.

    T+2min — os workers que processam a fila de validação começam a estourar o timeout de 3 s. A política de retry configurada no framework é a padrão: três tentativas, intervalo fixo de 5 segundos. Cada job que antes ocupava um worker por 200 ms agora ocupa por aproximadamente 24 segundos — três tentativas de 3 s mais duas esperas de 5 s, somando o overhead de serialização.

    T+6min — o throughput efetivo da fila cai para cerca de um centésimo do normal, mas a taxa de entrada não muda. A fila cresce. O autoscaler, configurado para reagir a profundidade de fila, sobe de 8 para 40 workers.

    T+9min — aqui o incidente deixa de ser sobre o integrador. Cada worker abre uma conexão com o banco no início do job, consulta o registro, chama o terceiro, e só devolve a conexão depois de gravar o resultado. A conexão fica retida durante toda a chamada HTTP. Com 40 workers segurando conexão por ~24 s cada, o pool de 50 conexões do primário fica permanentemente ocupado.

    T+11min — requisições web, que compartilham o mesmo pool, passam a esperar na fila de aquisição de conexão. O timeout de aquisição é de 30 s. As respostas HTTP da aplicação começam a passar de 30 s e o balanceador marca as instâncias como não saudáveis.

    T+14min — 503 generalizado. A partir daqui, o tráfego retomado por clientes que também fazem retry acrescenta uma segunda onda de amplificação.

    Por que o retry de intervalo fixo piora o quadro

    Retry com intervalo fixo tem duas propriedades ruins sob degradação. A primeira é multiplicativa: com três tentativas, a carga enviada ao serviço lento é o triplo da carga original, exatamente no momento em que ele tem menos capacidade. Se a causa da lentidão for saturação do lado do terceiro — e em geral é —, a política de retry impede a recuperação.

    A segunda é a sincronização. Jobs que falharam na mesma janela voltam a tentar na mesma janela, porque todos esperam os mesmos 5 segundos. O padrão de chegada, que era razoavelmente uniforme, vira uma sequência de rajadas. Esse é o efeito conhecido como thundering herd: a carga não só aumenta, ela se concentra em picos que o dimensionamento do destino não previu.

    A correção é exponential backoff com jitter — a espera cresce a cada tentativa e recebe um componente aleatório que desfaz a sincronização. A variante full jitter costuma ser a melhor escolha para chamadas idempotentes, porque distribui as tentativas por todo o intervalo em vez de agrupá-las perto do teto:

    import random, time
    
    BASE = 0.2      # 200 ms
    CAP  = 20.0     # teto de espera
    MAX_ATTEMPTS = 4
    
    def call_with_backoff(fn):
        for attempt in range(MAX_ATTEMPTS):
            try:
                return fn()
            except TransientError:
                if attempt == MAX_ATTEMPTS - 1:
                    raise
                ceiling = min(CAP, BASE * (2 ** attempt))
                time.sleep(random.uniform(0, ceiling))
    

    Uma observação que costuma passar batida: backoff resolve a concentração, não o volume total nem o tempo de ocupação do worker. Um job que tenta quatro vezes com teto de 20 s pode ficar retido bem mais que os 24 s do incidente. Backoff sem limite de orçamento de tempo troca um problema por outro.

    Defina um orçamento de tempo por job, não só um número de tentativas. Em Python: deadline = time.monotonic() + 8 no início e, antes de cada nova tentativa, if time.monotonic() + espera > deadline: raise. O job falha rápido e volta para a fila em vez de segurar recurso indefinidamente.

    O erro de arquitetura que causou o dano real

    Nada do que foi descrito até aqui explica por que a página de login caiu. O retry ruim degradou a fila de validação; isso seria um incidente contido. O que espalhou o problema foi a conexão de banco retida durante a chamada de rede externa.

    É um padrão sutil porque o código parece correto. Uma transação aberta no início do handler, uma chamada externa no meio, um commit no fim — a leitura linear sugere consistência. Na prática, o tempo de vida da conexão passa a ser governado pela latência de um sistema sobre o qual a equipe não tem controle algum.

    A regra prática é direta: nenhuma conexão de banco deve estar aberta enquanto uma chamada de rede externa está em voo. Isso significa três blocos separados — ler e fechar, chamar o terceiro, reabrir e gravar. Quando a operação precisa ser atômica em relação ao efeito externo, o caminho é outbox ou máquina de estados persistida, não transação longa.

    O segundo agravante é o pool compartilhado. Tráfego web e processamento assíncrono têm perfis de risco diferentes: o job pode esperar, a requisição do usuário não. Compartilhar o mesmo pool significa que qualquer patologia no assíncrono se propaga para o síncrono. Separar em pools distintos com limites próprios é o bulkhead clássico, e é barato de implementar — costuma ser uma string de conexão adicional e um limite menor para os workers.

    As correções em ordem de retorno

    Nem todas valem o mesmo. Em ordem de impacto por esforço:

    Correção Esforço O que resolve O que não resolve
    Separar pool de workers e pool web Baixo Impede que a fila derrube o tráfego de usuário A fila continua parando quando o terceiro degrada
    Fechar conexão antes da chamada externa Médio Corta a raiz da retenção; reduz uso de pool em ordens de grandeza Exige revisar atomicidade e tratar gravação parcial
    Backoff exponencial com jitter e orçamento de tempo Baixo Elimina a rajada sincronizada e a amplificação 3x Continua tentando contra um destino já saturado
    Circuit breaker por destino Médio Para de chamar depois de N falhas e falha rápido Precisa de meia-abertura bem calibrada para não oscilar
    Reduzir timeout do cliente para p99 + margem Baixo Devolve o worker mais cedo Gera falso negativo se a margem for apertada demais

    Se houver espaço para apenas duas neste trimestre, as duas são separar os pools e fechar a conexão antes da chamada externa. Backoff e circuit breaker melhoram o comportamento sob falha; essas duas impedem que a falha atravesse a fronteira entre subsistemas. A diferença entre um incidente de fila e um incidente de plataforma está aí.

    O que instrumentar para ver antes

    O incidente ficou invisível por nove minutos porque a única métrica observada do integrador era disponibilidade. Quatro sinais teriam antecipado o diagnóstico:

    • Latência p99 por dependência externa, não média — a média de 180 ms mascara uma cauda de 3 s por muito tempo.
    • Conexões em uso sobre o tamanho do pool, com alerta na faixa de 70% a 80%, não em 100%.
    • Tempo de espera na aquisição de conexão. É o indicador que sobe primeiro e o que menos aparece em dashboard.
    • Taxa de retry por destino. Uma razão de tentativas por job que sai de ~1,0 e vai para 2,5 descreve o incidente inteiro em um gráfico.

    Por onde começar: abrir o código dos jobs que chamam serviços externos e localizar cada trecho em que uma conexão de banco está viva durante a chamada. Essa auditoria costuma render uma lista curta, entre três e dez pontos, e cada ponto corrigido reduz a superfície de propagação de qualquer degradação futura — inclusive das que ainda não têm nome.

  • Decisão de arquitetura — Sessão no servidor ou JWT: qual modelo de autenticação para qual aplicação

    A decisão aparece cedo em todo projeto novo e quase nunca é revisitada: o servidor guarda o estado da sessão e devolve um identificador opaco, ou emite um token assinado que carrega as claims e não precisa de consulta posterior. As duas abordagens funcionam. O que muda é o custo de revogar acesso, o comportamento sob múltiplos serviços e a superfície de ataque quando o token vaza.

    O erro comum não é escolher errado — é escolher JWT por reflexo, tratar como stateless de verdade e descobrir na primeira demissão de funcionário que não existe botão para invalidar o token dele.

    O que cada modelo realmente faz

    Na sessão em servidor, o backend cria um registro (em Redis, Postgres ou memória), gera um identificador aleatório sem significado e devolve num cookie. Toda requisição autenticada faz uma leitura desse registro. O estado da verdade fica no servidor; o cliente carrega só a chave.

    No modelo JWT, o servidor assina um payload com sub, exp, papéis e o que mais for necessário. A validação é uma verificação de assinatura local — nenhum acesso a banco. O estado da verdade fica no token, congelado no instante da emissão.

    Essa última frase é a origem de praticamente todos os problemas de JWT em produção. Um token com validade de 24 horas afirma, por 24 horas, algo que era verdade quando foi emitido. Se o usuário perdeu o papel de admin há dez minutos, o token não sabe.

    // sessão: uma leitura por requisição, verdade sempre atual
    const session = await store.get(cookie.sid);
    if (!session) return unauthorized();
    
    // JWT: verificação local, verdade congelada na emissão
    const claims = jwt.verify(bearer, publicKey);
    // claims.role pode estar desatualizado desde o login
    

    Revogação é o critério que separa os dois

    Com sessão em servidor, revogar é apagar uma linha. Encerrar todas as sessões de um usuário é apagar as linhas com aquele user_id. Deslogar de todos os dispositivos, forçar re-login após troca de senha, cortar acesso de alguém desligado da empresa — tudo é a mesma operação trivial, com efeito imediato.

    Com JWT, revogar exige uma denylist consultada a cada requisição. E uma denylist consultada a cada requisição é uma sessão em servidor com passos extras: voltou o acesso ao banco, voltou o ponto único de estado, e ainda sobrou a complexidade de gerenciar chaves de assinatura e rotação.

    A saída praticada é o par access token curto mais refresh token. O access vive de 5 a 15 minutos e é validado localmente; o refresh vive dias, fica no servidor e é revogável. Funciona — mas note o que aconteceu: voltou o estado no servidor, só que agora com dois tipos de credencial, duas validades e um fluxo de renovação para depurar. O ganho é que a janela de exposição de um token comprometido cai para minutos.

    Se o refresh token está guardado em banco e é consultado a cada renovação, o sistema é stateful. Chamar a arquitetura de stateless nesse ponto só atrapalha quem for debugar depois.

    Onde o token fica é mais importante que o formato

    Boa parte das discussões entre sessão e JWT confunde formato da credencial com meio de transporte. São eixos independentes: dá para colocar JWT em cookie e dá para mandar identificador de sessão em header.

    O que decide a superfície de ataque é o armazenamento no cliente. Token em localStorage é legível por qualquer script que rode na página — uma dependência npm comprometida basta para exfiltrar. Cookie com HttpOnly, Secure e SameSite=Lax não é acessível por JavaScript, ao custo de exigir proteção contra CSRF nas requisições que alteram estado.

    Set-Cookie: sid=<opaco>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600
    

    Para aplicação web servida no mesmo domínio da API, cookie HttpOnly é a opção mais segura e a mais simples de acertar. Para app mobile nativo, cookies não ajudam: o token vai no Keychain ou no Keystore e viaja em header Authorization. Esse é um caso em que JWT resolve melhor, não por ser stateless, mas porque o cliente não tem gerenciamento de cookie nem origem para amarrar.

    Quando o JWT compensa de verdade

    O argumento sólido a favor do token assinado não é performance — é atravessar fronteira de confiança sem chamada de volta.

    Em arquitetura com vários serviços, um serviço que recebe uma requisição precisa saber quem é o chamador. Com sessão centralizada, cada serviço consulta o serviço de identidade, ou consulta o Redis compartilhado. Com token assinado, cada serviço valida a assinatura com a chave pública e segue. Some a isso um gateway que já validou na borda e propaga as claims internamente, e o desenho fica limpo.

    A economia de latência isolada raramente justifica a escolha: uma leitura em Redis na mesma rede custa algo na casa de um milissegundo, enquanto verificar assinatura RS256 custa dezenas de microssegundos. A diferença só aparece em volume alto ou quando a alternativa é uma chamada HTTP entre serviços, não uma leitura em cache.

    O outro caso legítimo é federação: OIDC, login social, SSO corporativo. Aí o formato do token não é escolha do projeto — é o que o provedor emite.

    Critério Sessão no servidor JWT
    Revogação imediata Nativa, apagar registro Exige denylist ou access curto
    Mudança de papel reflete Na requisição seguinte Só na renovação
    Custo por requisição Leitura em cache (~1 ms) Verificação local (~dezenas de µs)
    Múltiplos serviços Estado compartilhado Validação independente
    Ponto único de falha Store de sessão Chave e rotação
    Complexidade operacional Baixa Média a alta
    Cliente mobile nativo Desconfortável Natural

    Os erros que aparecem em revisão de código

    Alguns são recorrentes o bastante para virar item de checklist.

    Aceitar o algoritmo declarado no header do token. Bibliotecas antigas honravam alg vindo do próprio JWT, o que permite trocar por none ou rebaixar RS256 para HS256 usando a chave pública como segredo HMAC. A validação deve fixar o algoritmo esperado, nunca lê-lo do token.

    Guardar dado mutável nas claims. Plano contratado, limite de crédito e permissões finas envelhecem dentro do token. Vale carregar apenas identificador e papéis grosseiros, buscando o resto no serviço dono do dado.

    Validade longa por conveniência de UX. Token de 30 dias para não incomodar o usuário transforma qualquer vazamento em acesso de 30 dias. O caminho é access curto com renovação silenciosa, não expiração distante.

    Payload inchado. Um JWT com lista completa de permissões vira um header de vários kilobytes repetido em toda requisição, inclusive nas de asset.

    Não planejar rotação de chave. Sem kid no header e sem endpoint JWKS, trocar a chave assinadora significa derrubar todo mundo. Isso precisa existir antes do incidente que obriga a rotação.

    A recomendação

    Para aplicação web monolítica ou com poucos serviços, servida no mesmo domínio da API: sessão no servidor, identificador opaco em cookie HttpOnly, store em Redis com fallback no banco. É menos código, revogação é grátis, e a superfície de ataque é menor. A escala em que isso vira gargalo está muito acima do ponto em que a maioria dos sistemas chega.

    Para app mobile, API pública com clientes de terceiros, ou malha de serviços que precisam validar identidade sem consultar um ponto central: JWT com access token de 10 a 15 minutos, refresh token opaco e revogável guardado no servidor, rotação de refresh a cada uso e detecção de reuso para invalidar a família inteira de tokens.

    Modelo híbrido — sessão para o painel web, JWT para o app e para integrações — é decisão defensável e comum, desde que a revogação seja centralizada num único lugar. Duas fontes de verdade sobre quem está logado é o começo do próximo incidente.

    Se o sistema já está em produção com JWT de validade longa e sem revogação, o próximo passo é encurtar o access token e introduzir o refresh revogável antes de qualquer refatoração maior. É a mudança que reduz mais risco por linha de código alterada.

  • Connection pooling no Postgres: por que o pool dentro da aplicação para de funcionar quando o deploy vira serverless

    O sintoma aparece sempre do mesmo jeito: a aplicação sobe em containers com autoscaling ou em funções serverless, o tráfego cresce um pouco acima do normal e o Postgres começa a devolver FATAL: remaining connection slots are reserved for non-replication superuser connections. O pool está configurado. O max do pool está baixo. E mesmo assim o banco recusa conexão.

    O problema não é o tamanho do pool. É o fato de que existe um pool por processo, e a quantidade de processos deixou de ser uma constante.

    O pool da aplicação assume um mundo que não existe mais

    HikariCP, node-postgres, SQLAlchemy, database/sql: todos implementam o mesmo modelo. O processo abre N conexões TCP com o banco, mantém essas conexões vivas e distribui entre as threads ou entre as tasks do event loop. O ganho é evitar o handshake — em Postgres, cada conexão nova custa a criação de um processo backend no servidor, e isso não é barato.

    Esse modelo funciona quando o número de processos da aplicação é conhecido e estável. Três instâncias com pool de 20 são 60 conexões, e a conta fecha. Quando o orquestrador escala de 3 para 30 réplicas em resposta a um pico, viram 600 conexões contra um banco cujo max_connections padrão é 100. O pool cumpriu exatamente o que foi configurado para fazer — o problema é que ninguém configurou o agregado.

    Em serverless o quadro é pior, porque a unidade de execução não é a réplica: é a invocação. Cada ambiente de execução frio abre a própria conexão, e o pool interno perde a função — não há concorrência dentro de uma invocação para amortizar o custo. O pool vira um wrapper caro em volta de uma conexão só, e a soma de ambientes quentes vira um número que ninguém controla diretamente.

    A conta que define o teto real

    Aumentar max_connections é a primeira reação e quase sempre a errada. Cada conexão no Postgres é um processo do sistema operacional, com stack, catálogo em cache e área de trabalho própria. O custo por conexão ociosa é da ordem de alguns megabytes e varia bastante com o schema e com o padrão de consultas — mas o número que costuma estourar não é esse.

    O que estoura é work_mem. Ele é alocado por operação de ordenação ou hash dentro de cada query, não por conexão. Uma consulta com três joins em hash e um sort pode consumir várias vezes o valor configurado. Multiplicar isso por 600 conexões ativas é a diferença entre um banco lento e um banco com OOM kill.

    Há ainda o custo de escalonamento: com centenas de backends ativos, o Postgres passa a gastar tempo relevante em contenção de lightweight locks e em troca de contexto. O throughput cai antes de o banco ficar sem memória. É comum um banco entregar mais transações por segundo com 40 conexões ativas do que com 400.

    Antes de mexer em qualquer parâmetro, meça de onde vêm as conexões:
    SELECT state, application_name, count(*) FROM pg_stat_activity GROUP BY 1,2 ORDER BY 3 DESC;
    Se a maior fatia estiver em idle — e não em idle in transaction ou active — o gargalo é de pooling, não de carga.

    Pooler externo: onde ele resolve e onde ele quebra o código

    A correção estrutural é mover o pool para fora do processo. Um pooler externo — PgBouncer, pgcat, ou o pooler gerenciado do provedor — aceita centenas de conexões dos clientes e multiplexa sobre uma dezena de conexões reais com o banco. O ponto de decisão é o modo de pooling, e ele tem consequência direta no código da aplicação.

    Modo Conexão real é devolvida Multiplexação O que deixa de funcionar
    session ao cliente desconectar praticamente nenhuma nada — mas também não resolve o problema
    transaction ao fim de cada transação alta, é o modo útil SET de sessão, LISTEN/NOTIFY, advisory locks de sessão, tabelas temporárias, cursores fora de transação
    statement ao fim de cada comando máxima transações com múltiplos comandos — inviável para a maioria das aplicações

    O modo transaction é o único que entrega o ganho procurado, e é onde a maior parte das migrações trava. Código que executa SET search_path logo após conectar e assume que aquilo vale para as queries seguintes vai falhar de forma intermitente: a próxima query pode cair em outra conexão real. O mesmo vale para isolamento multi-tenant baseado em SET app.tenant_id combinado com row level security — o padrão precisa migrar para SET LOCAL dentro da transação, ou o vazamento entre tenants é questão de tempo.

    Advisory locks são o caso mais silencioso. pg_advisory_lock mantém o lock atrelado à sessão; em transaction pooling a sessão pertence ao pooler, não à aplicação. A variante pg_advisory_xact_lock resolve, porque o lock morre com a transação — mas isso muda a semântica de qualquer lock que precisava sobreviver a mais de uma transação.

    Prepared statements e o atrito com o driver

    O segundo bloqueio típico são prepared statements no protocolo estendido. O driver prepara uma vez, recebe um nome de statement e reutiliza. Em transaction pooling, a próxima execução pode cair numa conexão real que nunca viu aquele statement, e o resultado é o erro prepared statement "S_1" does not exist — intermitente, proporcional à taxa de reuso de conexão, e por isso quase impossível de reproduzir em staging com pouca carga.

    Há dois caminhos. O primeiro é desligar o cache de prepared statements no driver — em JDBC, zerando prepareThreshold; em drivers Python e Node, desabilitando o modo estendido ou o cache nomeado. Custa CPU no banco, porque cada execução volta a passar por parse e plan, e o impacto real depende de quanto do workload são queries curtas e repetidas.

    O segundo é usar uma versão de pooler que rastreia prepared statements por conexão de cliente e os replica na conexão real quando necessário. O PgBouncer suporta isso no modo transaction a partir da série 1.21, através do parâmetro max_prepared_statements — que vem desligado por padrão e precisa ser definido explicitamente. Poolers mais novos já nascem com o comportamento. Essa é a opção a escolher quando ela estiver disponível na versão em uso: preserva o ganho de plano em cache sem obrigar a aplicação a abrir mão do protocolo estendido.

    Serverless muda a resposta, não o problema

    Em ambiente serverless o pooler externo continua sendo a resposta certa, com uma ressalva: ele precisa estar do lado do banco, não do lado da função. Um pooler embutido no runtime da função não multiplexa nada — cada ambiente de execução tem o seu.

    Quando a latência de rede entre a função e o pooler é alta, ou quando o modelo de execução não sustenta conexões TCP persistentes, a alternativa é um proxy de dados sobre HTTP: a função faz uma requisição, o proxy mantém as conexões reais com o Postgres. Perde-se transação multi-comando com estado do lado do cliente e ganha-se independência do ciclo de vida da conexão. Vale para workloads de leitura curta e escrita simples; não vale quando existem transações longas com lógica no meio.

    Uma decisão que aparece pouco e economiza muito: separar o pool de leitura do de escrita. Réplicas de leitura com um pooler próprio absorvem a maior parte da concorrência e mantêm o primário com um número pequeno de conexões — que é exatamente onde a contenção dói.

    Como dimensionar o pool real do pooler

    Definido o modo, resta o número que importa: quantas conexões o pooler mantém abertas contra o banco. É o parâmetro default_pool_size no PgBouncer, e ele costuma ser configurado alto demais por reflexo — o que devolve o problema original com uma camada a mais no caminho.

    A heurística clássica do ecossistema Postgres é dimensionar pelo paralelismo real do servidor, algo como o dobro do número de núcleos somado ao paralelismo do armazenamento, e não pelo número de clientes. Para workload OLTP em servidor de 8 núcleos, isso costuma cair entre 20 e 30 conexões ativas — número que soa baixo até a primeira medição mostrar que a latência média cai quando o valor diminui.

    Duas armadilhas de configuração aparecem depois: o teto agregado precisa considerar todas as instâncias do pooler, porque dois poolers com pool de 25 são 50 conexões no banco; e a soma de default_pool_size por par usuário/base pode ultrapassar max_client_conn sem que nada avise até o pico. O parâmetro reserve_pool_size existe justamente para absorver rajadas curtas sem inflar o pool base.

    Uma exceção precisa ficar documentada: aplicações que dependem de LISTEN/NOTIFY exigem uma conexão dedicada fora do pooler. É uma linha de configuração — e o custo de não fazê-la é descobrir em produção que as notificações passaram a chegar de forma intermitente, sem erro em log nenhum.

  • Cobrança por uso em SaaS: o checklist de medição que precisa existir antes do preço

    A decisão de cobrar por consumo costuma nascer numa reunião comercial e chegar ao time técnico como um requisito de faturamento. É a ordem errada. Sem instrumentação confiável antes da mudança de preço, o que se entrega é uma fatura que o cliente contesta, um suporte que não consegue explicar o número e uma receita que o financeiro se recusa a reconhecer.

    O trabalho técnico de cobrança por uso não é integrar um gateway. É construir uma cadeia de medição auditável entre o evento que acontece no produto e a linha que aparece no boleto. Cada elo dessa cadeia tem um modo de falha conhecido, e todos eles se manifestam como divergência de valor no fim do mês.

    Instrumentar antes de precificar, sempre nessa ordem

    Antes de escolher o valor da unidade, é preciso ter pelo menos um ciclo completo de faturamento medindo em modo sombra: a métrica é coletada, agregada e exibida internamente, mas ninguém é cobrado por ela. Esse período existe para responder três perguntas que nenhuma planilha responde.

    A primeira é qual a distribuição real de consumo entre os tenants. Em quase toda base madura, a curva é fortemente assimétrica: uma minoria de contas responde pela maior parte do volume, e a mediana fica muito abaixo da média. Precificar pela média produz um plano que é caro demais para o corpo da base e barato demais para quem realmente consome.

    A segunda é qual a variação intra-mês. Se um tenant oscila entre 200 e 2.000 chamadas por dia, o modelo precisa de teto, franquia ou média móvel — cobrar o pico gera contestação garantida.

    A terceira é qual a taxa de perda de eventos do pipeline atual. Se a coleta perde 2% dos registros em picos de carga, esse número precisa ser conhecido e reduzido antes de virar dinheiro. Divergência de medição vira crédito em conta, e crédito em conta vira desconfiança permanente no número.

    O modo sombra só é útil se o número medido ficar visível para o time de suporte durante o ciclo. É o suporte que descobre, olhando caso a caso, que o contador está somando as requisições de health check do próprio monitoramento do cliente.

    A unidade de medida é uma decisão de arquitetura, não de marketing

    Existe uma tentação de cobrar pela unidade que é fácil de explicar comercialmente — usuário ativo, documento processado, requisição. O critério correto é diferente: a unidade deve ser aquela que se correlaciona com o custo marginal de servir o cliente e que o cliente consegue controlar.

    Quando a unidade não se correlaciona com custo, a margem some no cliente que consome muito por unidade cobrada. Quando o cliente não consegue controlá-la, ele sente a cobrança como arbitrária e passa a contestar toda variação.

    Unidade Correlação com custo Controle pelo cliente Risco principal
    Usuário ativo no mês Fraca em produtos de processamento Alto Compartilhamento de login para reduzir a conta
    Requisição de API Boa em produtos de leitura Médio Retry automático inflar a contagem
    Registro armazenado Boa quando o custo é de storage Baixo — dado antigo não se apaga sozinho Cliente descobre a conta só quando ela já cresceu
    Job concluído Forte em processamento assíncrono Alto Definir o que conta como concluído em caso de falha parcial

    Entre as quatro, a mais defensável em produto de processamento é o job concluído, porque alinha o que o cliente pagou ao que ele recebeu. O preço a pagar por essa escolha é ter de definir por contrato o comportamento em falha: job que estourou timeout no meio, job que processou 80% dos itens e abortou, job reprocessado por erro da plataforma. Cada um desses casos precisa de uma regra escrita antes do lançamento, não depois da primeira contestação.

    A cadeia de medição e onde ela quebra

    O caminho do evento até a fatura tem quatro estágios, e cada estágio precisa de uma garantia explícita.

    Emissão. O evento é gerado no ponto do código onde o trabalho de fato aconteceu, não onde a requisição chegou. Emitir na borda conta tentativa; emitir na conclusão conta entrega. A emissão deve carregar identificador do tenant, tipo de unidade, quantidade, timestamp do evento e um identificador único de deduplicação.

    Transporte. Filas com garantia de entrega ao menos uma vez são a escolha padrão, o que transfere para o consumidor a responsabilidade de deduplicar. Descartar evento em caso de fila cheia é uma decisão que precisa ser consciente e monitorada — perder medição significa faturar a menos, e faturar a menos silenciosamente é pior do que uma falha ruidosa.

    Agregação. Os eventos brutos são somados por tenant, por unidade e por janela. O erro comum aqui é agregar destrutivamente: somar e descartar o detalhe. Sem o evento bruto retido por um período que cubra pelo menos dois ciclos de faturamento mais o prazo de contestação contratual, não existe forma de auditar uma conta questionada.

    Fechamento. A janela do ciclo precisa de um período de carência antes do fechamento definitivo, para absorver eventos atrasados por reprocessamento de fila. Um evento que chega depois do fechamento cai no ciclo seguinte com o timestamp original preservado — nunca é descartado nem recontado no ciclo já emitido.

    Idempotência: o único requisito não negociável

    Em pipeline com entrega ao menos uma vez, o mesmo evento chega mais de uma vez com frequência previsível. Se a agregação for um incremento cego, a conta do cliente cresce sozinha depois de qualquer redeploy do consumidor.

    A defesa é uma chave de idempotência gerada na emissão e verificada na ingestão, com a inserção do registro e a atualização do agregado dentro da mesma transação:

    -- ingestao de evento de uso, idempotente por event_id
    BEGIN;
    
    INSERT INTO uso_evento (event_id, tenant_id, unidade, quantidade, ocorrido_em)
    VALUES ($1, $2, $3, $4, $5)
    ON CONFLICT (event_id) DO NOTHING;
    
    -- so agrega se a linha acima foi de fato inserida
    UPDATE uso_agregado a
       SET quantidade = a.quantidade + $4
      FROM (SELECT 1 FROM uso_evento WHERE event_id = $1 AND inserido_em > now() - interval '1 second') novo
     WHERE a.tenant_id = $2 AND a.unidade = $3 AND a.ciclo = $6;
    
    COMMIT;

    A variação mais robusta em volume alto é abrir mão do agregado incremental e recalcular a soma sob demanda a partir da tabela de eventos, com índice composto por tenant, unidade e janela. Custa mais leitura, mas elimina a classe inteira de bugs em que o agregado e o detalhe divergem e ninguém sabe qual dos dois está certo.

    A chave de idempotência deve ser derivada do trabalho executado — identificador do job, por exemplo — e não gerada aleatoriamente no momento da emissão. Chave aleatória não protege contra reemissão pelo produtor, apenas contra reentrega pelo transporte.

    O que precisa estar de pé antes de virar a chave

    Cobrança por uso muda a relação do cliente com o produto: ele passa a olhar o consumo. Se o produto não oferece essa visibilidade, o suporte vira o painel de consumo, atendendo uma por uma.

    Três coisas precisam existir no dia do lançamento. Uma tela de consumo no próprio produto, atualizada com defasagem de no máximo algumas horas e com detalhamento suficiente para o cliente identificar qual integração ou usuário puxou o volume. Um alerta configurável quando o consumo do ciclo atinge um percentual definido pelo cliente. E um endpoint de exportação do detalhe do período, porque cliente corporativo vai querer bater o número contra o próprio log antes de aprovar a fatura.

    Do lado interno, o requisito equivalente é reconciliação automática entre a soma dos eventos brutos, o agregado e o valor emitido na fatura, rodando todo dia com alerta em qualquer divergência acima de uma tolerância mínima. Divergência descoberta pelo cliente é incidente comercial; descoberta pela reconciliação é bug.

    O passo seguinte, com a medição estável e um ciclo sombra completo em mãos, é modelar o preço sobre a distribuição observada — definir a franquia no percentil que cobre o corpo da base e o preço marginal sobre o excedente, e simular a fatura de cada tenant existente antes de comunicar qualquer mudança.

  • Post-mortem hipotético: a chave de API que vazou no log e ficou 40 dias válida

    Uma chave de API de um gateway de pagamento aparece em texto puro numa linha de log de erro. O log vai para o agregador, o agregador retém 90 dias, e três times têm acesso de leitura ao índice. A chave só é revogada quarenta dias depois, quando alguém percebe o padrão numa busca por outra coisa. Não houve uso indevido detectado, o que é diferente de não ter havido uso indevido.

    Este post-mortem é hipotético, mas a sequência de falhas é banal o bastante para servir de checklist. O ponto não é a chave: é que quatro controles independentes deveriam ter interrompido a cadeia e nenhum interrompeu.

    A cadeia de falhas, na ordem em que aconteceu

    O serviço fazia uma chamada HTTP ao gateway. Diante de um erro 4xx, o cliente HTTP logava o objeto de request inteiro para facilitar depuração — incluindo o dicionário de headers. O Authorization estava ali dentro. Em operação normal isso nunca aparecia, porque erros 4xx do gateway eram raros. Uma mudança de contrato do lado do gateway passou a rejeitar um campo, os 4xx dispararam, e o header foi parar em milhares de linhas.

    A primeira falha é de design do cliente HTTP: logar o request completo é conveniente até o dia em que não é. A segunda é que o pipeline de ingestão de logs não tinha nenhuma regra de redação — nada procurava por padrões de secret na entrada. A terceira é que a credencial era estática, criada manualmente no painel do gateway dois anos antes, sem data de expiração e sem dono registrado. A quarta é que ninguém monitorava uso da chave por origem: se ela tivesse sido usada de outro IP, o alerta não existia para disparar.

    Repare que as quatro falhas são de camadas diferentes — aplicação, plataforma, gestão de identidade e detecção. É por isso que corrigir só a primeira não resolve. Um logger.debug mal colocado em outro serviço reabre o mesmo buraco na semana seguinte.

    Redação no ponto de saída, não no ponto de entrada

    A correção intuitiva é ir ao código que logou e remover o header. Funciona uma vez. A correção que escala é filtrar no ponto onde o log é serializado, de forma que nenhum caminho de código consiga emitir um campo sensível — mesmo um caminho escrito por alguém que não leu a convenção.

    Em Python, isso é um filtro no logger raiz; em Node, um serializer no pino ou equivalente. A lista de chaves proibidas é curta e deve ser tratada como configuração de plataforma, não de serviço.

    Filtro aplicado no logger raiz, não em cada chamada. Chaves comparadas em minúsculo para pegar Authorization, authorization e variações:

    REDACT = {"authorization", "cookie", "set-cookie", "x-api-key",
              "proxy-authorization", "password", "secret", "token"}
    
    class RedactFilter(logging.Filter):
        def filter(self, record):
            if isinstance(record.args, dict):
                record.args = {
                    k: ("[REDACTED]" if k.lower() in REDACT else v)
                    for k, v in record.args.items()
                }
            return True
    
    logging.getLogger().addFilter(RedactFilter())

    Duas ressalvas. A primeira: filtro por nome de chave não pega secret embutido no corpo de uma string já formatada — f"falhou: {request}" escapa de qualquer filtro estruturado. A regra que fecha esse caso é proibir interpolação de objetos de request em mensagem de log e passar contexto sempre como campo estruturado. A segunda: redação no cliente protege o que sai da aplicação, não o que já está armazenado. O expurgo do histórico no agregador é um trabalho separado, e a maioria dos agregadores não apaga por linha — apaga por índice inteiro.

    A camada que realmente resolve: credencial sem valor duradouro

    Redação reduz a chance de vazar. Credencial de vida curta reduz o valor do que vaza. As duas resolvem problemas diferentes e a segunda tem impacto maior, porque transforma um incidente de quarenta dias num incidente de quinze minutos.

    Modelo Janela de exposição Esforço de adoção Rastreabilidade
    Chave estática em variável de ambiente Até alguém revogar manualmente Nenhum Nenhuma — a chave não sabe quem a usou
    Secret manager com rotação agendada Até a próxima rotação (dias) Médio: exige recarregar credencial em runtime Parcial — versão da credencial identifica a janela
    Credencial efêmera via OIDC / STS Minutos Alto na primeira vez, baixo depois Alta — cada token carrega identidade da carga de trabalho

    A recomendação, quando o provedor externo suporta federação de identidade: credencial efêmera. Quando não suporta — e muitos gateways de pagamento e ERPs brasileiros ainda não suportam —, secret manager com rotação e um cliente que releia a credencial sem redeploy. O anti-padrão é adotar secret manager e continuar carregando o valor uma única vez no boot: a rotação existe no papel e a aplicação segue usando a versão antiga até reiniciar, o que na prática significa que a revogação derruba produção.

    Detecção: o alerta que faltava não era sobre o log

    Varredura de secret em código e em log é útil e barata — uma etapa de gitleaks ou equivalente no pipeline, mais uma regra de padrão no agregador que dispara quando uma linha casa com o formato de chave de um provedor conhecido. Vale ter. Mas é detecção de sintoma.

    O sinal com menor taxa de falso negativo é o uso da credencial fora do perfil esperado. Isso exige duas coisas que raramente estão prontas: saber de quais IPs ou identidades a chave deveria ser usada, e ter acesso ao log de uso do lado do provedor. Quando o provedor expõe esse log, uma regra simples — chamada originada de faixa não conhecida, ou volume fora do horário de operação — cobre o caso em que a chave vaza por um caminho que ninguém previu, incluindo o notebook de um contratado.

    Sem esse acesso, a alternativa honesta é encurtar a janela em vez de tentar detectar. Rotacionar a cada sete dias não impede o vazamento, mas garante que a resposta a “há quanto tempo isso está exposto?” tenha um teto conhecido. Um teto conhecido é o que permite dimensionar a resposta ao incidente sem adivinhar.

    Três correções tentadoras que não valem a prioridade

    Reescrever o histórico do repositório. Se o secret nunca esteve versionado — e neste caso não esteve, ele veio de variável de ambiente —, limpar histórico é trabalho sem retorno. Mesmo quando o secret está no Git, a reescrita só faz sentido depois da revogação, e ela não desfaz os forks e clones existentes. Revogar é o que resolve; limpar histórico é higiene posterior.

    Trocar o agregador de logs por um que ofereça mascaramento nativo. Migração de observabilidade consome trimestres e o mascaramento do lado do agregador roda depois da transmissão — o secret já saiu da rede da aplicação e já passou pelo agente de coleta. A redação precisa acontecer antes, no processo que gera a linha.

    Bloquear logger.debug em produção via política. A tentação é tratar o incidente como indisciplina e responder com regra. O header vazou num log de erro, não de debug, e teria vazado igual com nível INFO. Controle que depende de alguém lembrar da regra falha na primeira semana em que o time está apertado — o filtro estrutural não depende.

    O padrão comum às três: são respostas ao evento específico, não à classe de falha. A pergunta que separa uma da outra é se a correção impediria um vazamento de um secret diferente, num serviço diferente, escrito por alguém que não participou do post-mortem.

    Ordem de execução e como medir

    As quatro correções não têm o mesmo custo nem o mesmo retorno. A ordem que faz sentido para a maioria dos times:

    1. Filtro de redação no logger de plataforma — horas de trabalho, cobre todos os serviços que usam a biblioteca comum.
    2. Inventário de credenciais externas com dono e data de criação — desconfortável, porque quase sempre revela chaves que ninguém sabe de onde vieram e cujo desligamento ninguém quer testar.
    3. Rotação automatizada com releitura em runtime — a parte cara é o releitura, não a rotação.
    4. Federação de identidade onde o provedor suportar — migração por serviço, não big bang.

    Três métricas dizem se o trabalho pegou, e nenhuma delas é “número de vazamentos”, que é raro demais para servir de sinal. A idade mediana das credenciais externas ativas — se não cai mês a mês, a rotação não está rodando de verdade. O percentual de credenciais com dono identificado, que deveria caminhar para 100% antes de qualquer automação. E o tempo entre a decisão de revogar e a revogação efetiva em produção, medido num exercício deliberado: escolher uma credencial, revogar, e cronometrar. Times que nunca fizeram esse exercício costumam descobrir que o número está em horas, não em minutos, e quase sempre por causa de um serviço que só relê o secret no boot.

    O próximo passo concreto, se nada disso existe hoje: rodar o inventário. Sem saber quantas credenciais externas estão ativas e quem responde por cada uma, as outras três correções são exercício de arquitetura sobre um sistema imaginário.

  • Sessão no servidor ou JWT: onde o token stateless deixa de compensar

    Um usuário clica em “encerrar sessão em todos os dispositivos” e continua autenticado por mais quarenta minutos. O suporte abre chamado, alguém vai no código e descobre que sessão nenhuma existe do lado do servidor: o que existe é um JWT assinado com expiração de uma hora e nenhum lugar onde marcá-lo como inválido. A escolha entre sessão no servidor e token stateless raramente é sobre desempenho. É sobre quem detém a autoridade para encerrar um acesso antes da hora.

    O que cada modelo guarda, e onde

    Na sessão no servidor, o cliente recebe um identificador opaco — uma string aleatória sem significado próprio — dentro de um cookie HttpOnly. Todo o estado relevante (id do usuário, tenant, papéis, timestamp de emissão, fingerprint do dispositivo) fica num Redis, num Postgres ou onde o time preferir. A cada requisição o servidor faz uma leitura por chave para descobrir quem está do outro lado.

    No JWT, a ordem se inverte. O token carrega as próprias claims em Base64URL e uma assinatura no fim: header.payload.signature. O servidor não consulta nada — verifica a assinatura com uma chave que já tem em memória e confia no conteúdo. O estado mora no cliente.

    Duas consequências dessa inversão são frequentemente ignoradas em revisão de código. A primeira: o payload não é criptografado, só assinado. Base64URL é codificação, não proteção. E-mail, plano contratado, id interno de tenant e papel do usuário ficam legíveis para qualquer pessoa que copie o token do DevTools. A segunda: como não há consulta, também não há ponto onde interceptar. Um token emitido às 9h com exp às 10h vale até as 10h, tenha o usuário sido demitido, tido o plano cancelado ou perdido o notebook às 9h05.

    Revogação é o eixo real da decisão

    Com sessão no servidor, revogar é apagar um registro. DEL session:9f3a... e a próxima requisição daquele cookie falha. Encerrar todas as sessões de um usuário é apagar um conjunto. O custo é uma leitura por requisição, e essa leitura é exatamente o que dá ao servidor o poder de dizer não.

    Com JWT, as saídas conhecidas são três, e nenhuma sai de graça.

    Expiração curta. Reduzir exp para 5 ou 10 minutos encolhe a janela de exposição, mas transfere o problema para o refresh: alguma coisa precisa emitir o próximo token, e essa coisa consulta um estado persistido. O modelo stateless volta a ter estado, só que num ponto menos óbvio.

    Denylist de tokens revogados. Funciona, e é a opção mais escolhida. Também é a que merece mais atenção: se cada requisição precisa checar se o jti está numa lista negra, o sistema faz uma leitura por requisição para autenticar — que é precisamente o custo da sessão no servidor, agora com assinatura criptográfica e complexidade extra por cima. Vale a pena quando a denylist é pequena e rara. Não vale quando revogação é evento cotidiano.

    Rotação da chave de assinatura. Invalida tudo de uma vez. Serve para incidente de segurança, não para um usuário trocando de senha.

    Uma medida útil antes de escolher: registre o tempo entre o pedido de revogação e a primeira requisição negada. SELECT max(revoked_at - requested_at) FROM audit_revocations WHERE requested_at > now() - interval '30 days'. Se o número passa de alguns segundos e o produto lida com dado sensível, a discussão já tem resposta.

    O que cada modelo custa em infraestrutura

    A conta de latência costuma ser apresentada como se fosse decisiva, e quase nunca é. Uma leitura em Redis na mesma rede fica na casa de dezenas a poucas centenas de microssegundos. A verificação de assinatura HS256 fica abaixo disso; RS256 e EdDSA custam mais que HMAC, mas ainda na mesma ordem de grandeza. Numa requisição que termina consultando o banco de dados da aplicação, essa diferença desaparece no ruído.

    Dois custos menos citados importam mais.

    O primeiro é tamanho na rede. Um JWT com claims de tenant, papéis e permissões chega facilmente a algumas centenas de bytes, às vezes ultrapassa o quilobyte quando alguém decide embutir a lista de permissões inteira. Esse peso viaja em toda requisição, inclusive nas de asset e polling. Proxies reversos têm limite de tamanho de header — o padrão do Nginx fica em poucos kilobytes por header — e o sintoma clássico é um 431 que só aparece para o usuário com mais permissões da base.

    O segundo é operação. Sessão no servidor adiciona um componente com estado ao caminho crítico de autenticação: se o Redis cai, ninguém entra. Isso exige replicação, política de persistência decidida de forma consciente e um plano para o que acontece quando o cache é limpo (todos deslogam ao mesmo tempo, o que gera um pico de login que precisa caber na capacidade). JWT dispensa esse componente, e é aí que ele realmente ganha.

    Há ainda um custo que aparece no front-end e é debitado da conta de segurança. Sessão em cookie HttpOnly é inacessível a JavaScript por construção: um XSS na aplicação continua sendo grave, mas não entrega o identificador de sessão de bandeja. JWT guardado em localStorage — prática comum em SPA por ser o caminho de menor atrito — é legível por qualquer script que execute na página, incluindo uma dependência transitiva comprometida. Guardar o JWT em cookie HttpOnly resolve esse ponto, mas devolve o problema de CSRF que o header Authorization evitava, e obriga a decidir entre SameSite=Lax e token anti-CSRF explícito. Vale registrar: quem escolhe JWT por segurança e o coloca em localStorage costuma terminar com uma superfície maior do que a sessão que substituiu.

    Onde cada modelo ganha

    Critério Sessão no servidor JWT stateless
    Revogação imediata Nativa, uma exclusão Só com denylist ou janela de expiração
    Dependência de armazenamento Obrigatória no caminho crítico Nenhuma para validar
    Tamanho por requisição Dezenas de bytes Centenas de bytes a alguns KB
    Validação fora da aplicação (edge, gateway) Exige consulta ao backend Local, só com a chave pública
    Exposição de dados no cliente Nada legível Payload legível por quem tiver o token
    Sessão simultânea e limite de dispositivos Consulta direta ao conjunto Precisa de registro paralelo
    Auditoria de acesso ativo Lista disponível a qualquer momento Só por inferência de logs

    O padrão que se repete: JWT ganha onde a validação precisa acontecer longe de quem emitiu. Chamada entre serviços internos, autorização num API gateway ou numa função de edge, token de curta duração emitido por um provedor de identidade externo, API pública com escopos por integração. Nesses cenários, exigir round-trip ao backend de autenticação é justamente o que se quer evitar.

    Sessão no servidor ganha em aplicação web com cookie, painel administrativo, produto B2B com controle de acesso por administrador de conta e qualquer domínio em que “tirar alguém de dentro agora” seja requisito e não desejo — financeiro, saúde, RH, prontuário, folha.

    A recomendação, e o que fazer com o que já existe

    Para um SaaS web multi-tenant começando hoje: refresh token opaco em cookie HttpOnly, Secure e SameSite=Lax, com registro persistido por dispositivo, mais access token JWT de vida curta — 5 a 15 minutos — usado para autorizar as chamadas. Revogar significa apagar o registro do refresh; a janela residual é o tempo que sobra do access token, e essa janela é o preço explícito da escolha, não uma surpresa descoberta em incidente.

    Quinze minutos de janela é aceitável para a maioria dos produtos. Não é aceitável quando a ação revogável tem efeito financeiro irreversível. Nesse caso a saída não é encurtar mais o token: é validar a autorização crítica contra o estado atual no momento da operação, independentemente do que o token afirma. Token diz quem é; a operação sensível confere se ainda pode.

    Para quem já tem JWT puro em produção e sente a dor da revogação, a migração não precisa ser um big bang. Introduzir o endpoint de refresh com registro persistido, encurtar o exp do access token em etapas e só depois ligar a revogação real permite reverter em qualquer ponto. O indicador que fecha a migração é o mesmo que a justificou: a janela máxima entre pedido de revogação e negação efetiva, medida em produção, não estimada em reunião.

  • Post-mortem: o dashboard de BI que virou a maior linha da fatura em três dias

    Cenário: um data warehouse na nuvem rodando em uma região, a ferramenta de BI hospedada em outra, e um dashboard comercial com atualização automática a cada cinco minutos para cerca de quarenta pessoas. Numa sexta à tarde alguém trocou o filtro padrão do dashboard de “últimos 30 dias” para “todo o histórico”. Na segunda de manhã, a fatura parcial da conta acusava um valor de transferência de dados de saída maior que o custo mensal inteiro da infraestrutura — algo na casa das dezenas de milhares de reais acumuladas em pouco mais de setenta horas.

    O relato abaixo é reconstruído para ilustrar o modo de falha, não descreve a operação de nenhuma empresa específica e os valores servem apenas para dar ordem de grandeza. O padrão, porém, aparece em qualquer arranjo onde o motor de consulta e o consumidor dos dados vivem em zonas, regiões ou provedores diferentes — e ele não dispara nenhum alerta técnico enquanto está acontecendo.

    A conta chega três dias depois do erro

    O que torna esse tipo de incidente caro não é a magnitude do erro, é a defasagem entre causa e sinal. Uma queda de serviço se manifesta em segundos: o health check falha, o alerta dispara, alguém acorda. Um vazamento de custo se manifesta no fechamento da fatura, e a maioria dos provedores consolida o uso de rede com atraso de horas — em alguns casos, mais de um dia.

    Durante todo o fim de semana, cada métrica que o time observava estava verde. CPU do warehouse dentro da média, porque o motor era serverless e escalava sozinho. Latência do dashboard estável, porque a consulta continuava respondendo em poucos segundos. Taxa de erro em zero. Não houve degradação: o sistema fez exatamente o que foi pedido, quarenta vezes por hora, durante três dias, movendo um resultado de consulta inteiro pela internet pública a cada execução.

    A descoberta veio pelo financeiro, não pela engenharia. Esse detalhe é o diagnóstico real do incidente. Se o primeiro a perceber um problema de infraestrutura é quem paga a fatura, o problema não foi o filtro do dashboard — foi a ausência de qualquer sinal de custo dentro do ciclo de observabilidade do time.

    Há um agravante estrutural nesse tipo de defasagem: o console de faturamento da nuvem não é uma ferramenta de engenharia. Ele agrega por serviço e por conta, raramente por consulta, quase nunca por usuário. Quando o time finalmente abre o relatório de custo, encontra uma linha chamada “transferência de dados” com um valor grande e nenhuma pista de qual carga a produziu. Reconstruir a origem exige cruzar log de auditoria do warehouse com log de acesso do BI, o que costuma levar mais tempo que a correção em si.

    Egress é métrica de consulta, não de infraestrutura

    Times de plataforma instrumentam recursos: contêiner, nó, fila, banco. Transferência de saída não pertence a nenhum recurso. Ela é o produto de quatro variáveis que ninguém observa junto: tamanho do resultado da consulta, número de consumidores, frequência de atualização e distância de rede entre origem e destino.

    Nesse caso, as quatro pioraram ao mesmo tempo por causa de uma única mudança. Trocar a janela de tempo do filtro multiplicou o tamanho do resultado por duas ordens de grandeza. O cache da ferramenta de BI estava configurado por usuário e por combinação de filtros, então quarenta pessoas com o dashboard aberto significavam quarenta execuções independentes, não uma. O refresh automático de cinco minutos manteve o ciclo rodando à noite e no fim de semana, com as abas abertas em máquinas que ninguém desligou. E o cruzamento de região transformou o que seria tráfego interno gratuito em tráfego cobrado por gigabyte.

    Nenhuma dessas quatro decisões é errada isoladamente. Cache por usuário existe porque filtros diferentes produzem resultados diferentes. Refresh curto existe porque o time comercial queria ver pedido entrando. Separar BI e warehouse em regiões distintas costuma ser herança de migração, não escolha deliberada. O custo aparece na multiplicação, e a multiplicação não tem dono.

    Onde o custo se acumula sem aparecer

    Vale mapear os padrões recorrentes antes de discutir correção, porque o mesmo mecanismo se repete em contextos que nada têm a ver com BI.

    Padrão de acesso Por que vaza O que costuma resolver
    Dashboard com auto-refresh Resultado completo trafega por usuário, por ciclo, inclusive fora do horário comercial Cache compartilhado por consulta e refresh sob demanda em vez de temporizado
    Exportação agendada de CSV Job diário puxa a tabela inteira quando só o delta mudou Extração incremental por marca d’água de atualização
    Réplica entre regiões Replicação contínua de tabelas que ninguém lê na região secundária Replicar apenas o subconjunto efetivamente consultado
    Ambiente de staging apontando para produção Consultas de teste percorrem o mesmo caminho de rede cobrado Amostra local do dataset, não espelho remoto
    Serviço em nuvem A lendo storage em nuvem B Toda leitura é saída cobrada, mesmo interna à aplicação Colocar processamento junto do dado ou cachear na borda

    O denominador comum é o mesmo: alguém desenhou o fluxo assumindo que ler dado é barato. Dentro da mesma zona, quase é. Atravessando fronteira de rede, o preço por gigabyte multiplica por um volume que cresce sozinho conforme o negócio cresce.

    Vale notar o que não resolve: comprimir o resultado. Compressão reduz o volume trafegado por execução, mas não toca no número de execuções, que é o termo que cresce sozinho. Um resultado três vezes menor rodando quarenta vezes por hora continua sendo um problema de multiplicação. A ordem correta de ataque é sempre frequência primeiro, número de consumidores depois, tamanho por último — o inverso da intuição de quem chega ao problema pelo lado da rede.

    O que teria cortado o prejuízo no primeiro dia

    Três controles independentes, e qualquer um deles sozinho teria limitado o dano a algumas centenas de reais em vez de dezenas de milhares.

    O primeiro é orçamento com janela diária. A maioria dos times configura alerta de orçamento mensal com limiar percentual, o que significa descobrir o estouro quando já se gastou boa parte do mês. Um alerta sobre gasto acumulado nas últimas 24 horas comparado à média das duas semanas anteriores teria disparado no sábado de manhã.

    O segundo é teto de bytes por consulta no próprio motor. Vários warehouses permitem rejeitar a execução de qualquer consulta que ultrapasse um volume declarado de leitura, e isso é preferível a descobrir depois: o dashboard teria quebrado na sexta às 16h, com uma mensagem de erro clara e um responsável óbvio.

    Em BigQuery, o teto é uma propriedade do job e vale ser aplicado por padrão na conexão de BI, não caso a caso:

    bq query --maximum_bytes_billed=10000000000 \
      --use_legacy_sql=false \
      'SELECT ... FROM vendas WHERE data >= @inicio'

    Motores diferentes expõem o mesmo controle com outro nome — em Snowflake, o caminho é limitar o tamanho do warehouse virtual somado a STATEMENT_TIMEOUT_IN_SECONDS. O princípio não muda: falhar rápido custa menos que responder devagar.

    O terceiro é cache compartilhado no BI. Trocar o cache por usuário por cache por assinatura de consulta faz quarenta execuções virarem uma, e o ganho não é só de rede: reduz também a concorrência no warehouse no horário de pico. A objeção usual é controle de acesso por linha, que de fato impede compartilhar resultado entre perfis diferentes. Nesse caso, a solução é segmentar o cache por papel, não por pessoa.

    Próximo passo: os três guardrails que cabem numa sprint

    Se a operação tem qualquer fluxo de dados atravessando região ou provedor, três itens valem entrar no backlog agora, nessa ordem.

    • Inventário de travessias. Liste todo par origem-destino em que o dado sai de uma região e entra em outra. Bastam meia dúzia de perguntas ao time: onde está o warehouse, onde está o BI, onde estão os buckets, onde roda o ETL, onde ficam os backups.
    • Alerta de derivada, não de teto. Configure notificação sobre variação do gasto diário em relação à linha de base, e não apenas sobre o valor absoluto do mês. Vazamento de custo é sempre uma inflexão de curva antes de ser um número grande.
    • Limite duro no motor de consulta. Defina teto de bytes lidos ou timeout na conexão usada por ferramentas de terceiros. Ferramenta de BI é a superfície onde alguém sem contexto de infraestrutura consegue disparar uma consulta arbitrária.

    O item que costuma gerar mais resistência é o terceiro, porque quebra dashboard de gente que reclama alto. Vale quebrar mesmo assim. Um dashboard que só funciona lendo o histórico inteiro a cada cinco minutos não é um dashboard — é um job de ETL disfarçado, e deveria ser materializado como tabela agregada em vez de recalculado a cada abertura de aba.