Blog

  • Decisão de arquitetura — Busca no Postgres ou Elasticsearch: até onde o full-text nativo aguenta

    Um SaaS com alguns milhões de registros ligou a busca textual em cima do Postgres e ela funcionou por dois anos. Agora a consulta que respondia em 40 ms leva 900 ms quando o filtro por tenant entra junto, o time de produto pediu filtros facetados na lateral e o suporte reclama que buscar “nota fical” não encontra “nota fiscal”. A pergunta que chega até a arquitetura é sempre a mesma: sobe um Elasticsearch ou dá para espremer mais o banco?

    A resposta não é a mesma para os dois casos que costumam ser confundidos. Buscar texto e construir um motor de relevância são problemas diferentes, e o Postgres resolve o primeiro muito melhor do que a maioria dos times imagina.

    O que o Postgres entrega de fábrica

    O full-text search nativo do Postgres é maduro e cobre bem mais do que LIKE '%termo%'. Ele converte texto em tsvector — a lista de lexemas normalizados com posição — e compara contra um tsquery. Com stemming em português, remoção de stopwords e um índice GIN por cima, uma busca em uma tabela de poucos milhões de linhas responde em dezenas de milissegundos.

    A forma correta de montar isso em um schema novo é com coluna gerada, não com trigger:

    ALTER TABLE documentos
      ADD COLUMN busca tsvector
      GENERATED ALWAYS AS (
        setweight(to_tsvector('portuguese', coalesce(titulo, '')), 'A') ||
        setweight(to_tsvector('portuguese', coalesce(corpo,  '')), 'B')
      ) STORED;
    
    CREATE INDEX documentos_busca_idx ON documentos USING GIN (busca);

    O setweight é o detalhe que separa uma busca aceitável de uma ruim: ele permite que uma ocorrência no título pese mais do que no corpo quando o ts_rank ordena. Do lado da consulta, websearch_to_tsquery aceita a sintaxe que o usuário já conhece de buscador — aspas para expressão exata, - para exclusão, or — sem que a aplicação precise fazer parsing próprio.

    Para tolerância a erro de digitação, a extensão pg_trgm resolve o caso de “nota fical” com similaridade por trigramas, e unaccent resolve o de acentuação inconsistente. As duas cobrem a maior parte das reclamações reais de usuário sem nenhuma infraestrutura nova.

    Antes de decidir qualquer coisa, rode EXPLAIN (ANALYZE, BUFFERS) na consulta lenta com o filtro de tenant incluído. Na maioria dos casos o gargalo não é o GIN — é o planner escolhendo varrer a tabela porque a seletividade do filtro combinado está mal estimada, o que se corrige com índice composto ou CREATE STATISTICS.

    Onde o full-text nativo começa a doer

    Os limites aparecem em quatro frentes, e vale identificar qual delas está travando antes de trocar de ferramenta.

    Relevância. O ts_rank pontua por frequência e posição do termo, sem normalizar por tamanho de documento da forma que o BM25 faz. Em corpus com documentos de tamanhos muito diferentes, textos longos sobem indevidamente. Dá para compensar com ts_rank usando os parâmetros de normalização, mas é ajuste manual e frágil.

    Escrita. Índice GIN tem custo de atualização alto. Em tabela com escrita intensa, a fila de pendências do GIN cresce e as consultas passam a pagar por ela. O parâmetro fastupdate e o dimensionamento de gin_pending_list_limit ajudam, mas em cargas de escrita constante o índice vira o ponto quente da tabela.

    Facetas. Contar quantos resultados existem por categoria, por autor e por período, sobre o mesmo conjunto filtrado, é agregação sobre o resultado inteiro — não sobre a página. No Postgres isso significa varrer o conjunto completo a cada consulta, e o custo cresce linearmente com o número de acertos. É aqui que a maioria dos times bate no muro.

    Highlight. O ts_headline não usa o índice: ele reprocessa o texto original de cada linha retornada. Em página de 20 resultados com documentos grandes, ele sozinho pode dominar o tempo da consulta.

    Há um quinto ponto que não é limitação do full-text, mas é onde o problema aparece primeiro em SaaS: a combinação de índice GIN com filtro por tenant. São dois índices independentes, e o planner precisa decidir se parte da busca textual e filtra depois, ou se parte do tenant e varre. Quando o tenant é grande, a primeira estratégia ganha; quando é pequeno, a segunda. Estimativa errada nesse ponto explica a maior parte dos casos de “a busca ficou lenta de repente para um cliente só”.

    O que o Elasticsearch resolve e o que ele cobra

    O Elasticsearch — e o OpenSearch, que para efeito desta decisão se comporta igual — entrega BM25 por padrão, agregações que foram desenhadas justamente para facetas, highlight que usa a estrutura invertida em vez de reprocessar o documento, e uma cadeia de analisadores em que sinônimos, tolerância a erro e normalização são configuração, não gambiarra de query.

    A conta que raramente entra na proposta é a do outro lado. Um cluster separado significa JVM para dimensionar, shards para planejar, snapshot para operar e uma segunda fonte de verdade para manter sincronizada. Essa sincronização é o custo real: dual-write na aplicação cria janela de inconsistência a cada falha parcial, e captura de mudanças via replicação lógica exige um consumidor a mais em produção. Além disso, mudança de mapping em campo já indexado costuma exigir reindexação completa com alias — procedimento que precisa estar ensaiado antes de ser necessário.

    Há ainda a consistência. O índice fica visível para busca depois do refresh, que por padrão é próximo de um segundo. Fluxo em que o usuário salva um registro e imediatamente busca por ele volta vazio, e a correção — forçar refresh na escrita — anula boa parte do ganho de throughput.

    O critério de escolha

    Fator Postgres full-text Elasticsearch / OpenSearch
    Relevância ts_rank com pesos manuais BM25 por padrão, ajustável por campo
    Facetas sobre resultado grande Agregação linear no conjunto Agregações nativas, custo previsível
    Sinônimos e erro de digitação pg_trgm e unaccent, combinam mal com ranking Cadeia de analisadores, configuração declarativa
    Consistência com o dado transacional Imediata, mesma transação Eventual, janela de refresh
    Custo operacional Zero adicional Cluster, sincronização e plano de reindexação
    Isolamento por tenant Filtro na cláusula WHERE Filtro, roteamento ou índice por tenant

    O critério que funciona na prática é de contagem, não de tamanho de corpus: o Elasticsearch se paga quando pelo menos dois dos três requisitos abaixo são de produto, e não de preferência técnica — facetas contadas sobre o resultado inteiro, relevância como diferencial competitivo com ajuste contínuo, e busca sobre volume que já não cabe confortavelmente em cache no nó do banco.

    Com um só desses requisitos, o Postgres ainda ganha. Um SaaS B2B típico, com milhões de registros mas centenas de resultados por tenant depois do filtro, quase nunca chega aos dois. Nesse caso o esforço rende muito mais aplicado a índice composto, particionamento por tenant e limite de resultado do que a um cluster novo.

    A exceção clara é busca como produto — catálogo público, marketplace, base de conhecimento com tráfego de busca orgânica. Aí a relevância é o produto, o ajuste é semanal e o Postgres vira gargalo de roadmap antes de virar gargalo de performance.

    Se a migração for o caminho, o que fazer primeiro

    O erro comum é começar pela infraestrutura. A ordem que reduz risco é outra: primeiro instrumentar a busca atual — distribuição de latência por percentil, taxa de consulta sem resultado, taxa de clique no primeiro resultado. Sem esses três números não existe forma de saber se o cluster novo melhorou alguma coisa.

    Depois, indexar em paralelo e servir do Postgres. A aplicação escreve nos dois, consulta apenas o banco, e uma tarefa de comparação registra divergência de resultado entre as duas fontes. Semanas nesse modo revelam os problemas de mapping e de sincronização enquanto ninguém depende do índice novo.

    Só então trocar a leitura, e de forma progressiva por tenant, mantendo o caminho antigo alcançável por configuração. Rollback de busca precisa ser mudança de flag, não deploy.

    Duas decisões de modelagem valem ser tomadas antes da primeira indexação, porque mudá-las depois custa reindexação: o que vai no índice e o que fica só no banco, e como o tenant é isolado. Indexar o documento inteiro parece conveniente, mas infla o cluster com campo que ninguém consulta — o índice deve carregar o que participa da busca, do filtro ou da exibição do resultado, e o identificador para buscar o resto no banco. Quanto ao tenant, índice por cliente só se sustenta enquanto o número de clientes for baixo; acima disso o custo de shard por índice inviabiliza, e o caminho é índice compartilhado com roteamento pelo identificador do tenant.

  • Post-mortem: o cache que expirou junto e derrubou o banco por 12 minutos

    Cenário: catálogo de produtos servido por uma API em Node, cache em Redis com TTL fixo de 300 segundos, algo em torno de 4 mil requisições por segundo no pico da tarde. Um deploy às 14h02 subiu pods novos e, junto, um script de invalidação limpou o namespace inteiro do cache. Doze minutos depois o banco primário estava com o pool de conexões saturado, o p99 do checkout passava de 20 segundos e o balanceador devolvia 502.

    O relato abaixo é reconstruído para ilustrar o modo de falha — não descreve o incidente de nenhuma empresa específica. O padrão, porém, é dos mais frequentes em qualquer stack que coloque cache com TTL uniforme na frente de um banco relacional: o problema não foi o cache ter esvaziado, foi todas as chaves terem voltado a valer no mesmo instante.

    A curva diz mais que a linha do tempo

    Na leitura do gráfico, o que chama atenção não é o pico de latência — é o formato dele. Requisições ao banco saltam de uma base estável para dez, quinze vezes o volume normal em menos de um segundo, permanecem no teto por alguns minutos e caem de novo de forma abrupta. Não é a curva de um crescimento de tráfego: tráfego de usuário não dobra em 200 milissegundos. É a assinatura de um evento sincronizado do lado da aplicação.

    Três indícios separam esse cenário de uma sobrecarga genuína de tráfego. Primeiro, o volume de requisições HTTP na borda continua estável enquanto as queries ao banco explodem — a razão query por request muda, e é isso que importa. Segundo, as queries que dominam o topo são idênticas entre si, variando só no parâmetro de ID. Terceiro, o pico se repete em intervalos que correspondem ao TTL configurado, mais amortecido a cada ciclo, até sumir. Esse terceiro sinal é o mais conclusivo: o sistema está oscilando na frequência do próprio cache.

    Expiração sincronizada e o rebanho que atropela

    O mecanismo é simples. Quando um lote grande de chaves é gravado no mesmo instante — depois de um flush, de um deploy que muda o prefixo das chaves, ou de um cold start de um cluster novo — todas recebem o mesmo TTL. Trezentos segundos depois, todas expiram juntas. Cada requisição que chega nessa janela encontra um miss, e cada uma dispara sua própria recomputação contra o banco.

    O agravante é que a recomputação não é instantânea. Se a query que popula a chave leva 180 milissegundos, e chegam 4 mil requisições por segundo, aproximadamente 720 requisições entram na janela em que a primeira ainda não terminou de gravar o resultado. Nenhuma delas encontra o cache preenchido. Todas vão ao banco. O banco fica mais lento sob essa carga, a query passa a levar 900 milissegundos, e agora são 3.600 requisições concorrentes fazendo o mesmo trabalho. A degradação se realimenta — é por isso que o gráfico sobe em parede, e não em rampa.

    Há dois nomes circulando para variações do mesmo problema, e vale separá-los porque a correção é diferente. Cache stampede descreve muitas chaves distintas expirando ao mesmo tempo. Thundering herd em chave quente descreve uma única chave muito requisitada expirando e sendo recomputada por N clientes simultâneos. O primeiro é um problema de distribuição de TTL; o segundo, de exclusão mútua na recomputação.

    Jitter no TTL resolve a maioria dos casos

    A correção para o primeiro caso cabe em uma linha: em vez de gravar todas as chaves com o mesmo TTL, sorteie um desvio dentro de uma faixa. Um TTL base de 300 segundos com jitter de 20% distribui as expirações ao longo de uma janela de 120 segundos em vez de concentrá-las num instante.

    const TTL_BASE = 300;
    const JITTER = 0.2;
    
    function ttlComJitter() {
      const desvio = TTL_BASE * JITTER;
      return Math.round(TTL_BASE - desvio + Math.random() * desvio * 2);
    }
    
    await redis.set(chave, JSON.stringify(valor), 'EX', ttlComJitter());
    

    A escolha da faixa é um trade-off entre suavidade e frescor. Jitter de 10% mal resolve quando o volume é alto — a janela ainda é estreita demais. Jitter de 50% distribui bem, mas significa que parte dos dados pode viver metade do tempo pretendido, o que encarece o custo de banco no regime normal. Para cache de leitura de catálogo, 20% a 30% costuma ser o ponto razoável. Para dados em que a idade máxima é um requisito de negócio — saldo, estoque reservado, limite de crédito —, jitter só para baixo: sorteie entre 70% e 100% do TTL nominal, nunca acima.

    Se o pipeline de deploy invalida cache por prefixo, o jitter não ajuda no primeiro repovoamento — todas as chaves nascem juntas de qualquer forma. Nesse caso o ganho vem de aquecer o cache antes de trocar o tráfego, ou simplesmente de parar de invalidar o namespace inteiro a cada deploy.

    A chave quente exige lock, não jitter

    Jitter distribui expirações entre chaves diferentes. Não faz nada por uma chave só, requisitada mil vezes por segundo, cujo recálculo custa caro. Aqui a ferramenta é exclusão mútua: o primeiro cliente que encontra o miss adquire um lock curto e recomputa; os demais aguardam ou servem o valor anterior.

    async function buscarComLock(chave, recomputar) {
      const cache = await redis.get(chave);
      if (cache) return JSON.parse(cache);
    
      const lock = await redis.set(`lock:${chave}`, '1', 'NX', 'PX', 5000);
      if (!lock) {
        await sleep(50);
        return buscarComLock(chave, recomputar);
      }
    
      try {
        const valor = await recomputar();
        await redis.set(chave, JSON.stringify(valor), 'EX', ttlComJitter());
        return valor;
      } finally {
        await redis.del(`lock:${chave}`);
      }
    }

    Três detalhes decidem se essa implementação ajuda ou piora. O lock precisa de TTL próprio — sem o PX, um processo que morre no meio da recomputação trava a chave para sempre. O retry precisa de teto: espera indefinida transforma contenção de cache em esgotamento do pool de conexões HTTP. E o finally só libera o lock com segurança se o valor gravado for verificado antes da remoção; em cluster com failover, a versão correta usa um token aleatório e um script Lua para o delete condicional.

    Servir dado velho de propósito

    A alternativa que evita o problema em vez de administrá-lo é nunca deixar a chave ficar vazia. Grava-se o valor com dois prazos: um lógico, curto, que marca quando o dado passa a ser considerado velho; e o TTL físico do Redis, bem mais longo. Quando uma leitura encontra o dado velho mas ainda presente, devolve ele imediatamente e dispara a recomputação em background.

    Estratégia Resolve Custo Quando escolher
    Jitter no TTL Expiração em lote de muitas chaves Nenhum, uma linha Sempre. É o piso, não a solução completa
    Lock de recomputação Chave quente com recálculo caro Latência extra para quem espera; complexidade de lock distribuído Poucas chaves muito requisitadas, recálculo acima de 200 ms
    Stale-while-revalidate Ambos, sem espera para o usuário Serve dado desatualizado por alguns segundos Leitura tolerante a atraso: catálogo, listagem, agregados
    Refresh proativo por job Ambos, com frescor previsível Recomputa mesmo o que ninguém pediu Conjunto pequeno e conhecido de chaves críticas

    Entre as quatro, a escolha padrão para leitura de catálogo é stale-while-revalidate somado a jitter. O argumento é direto: o usuário nunca paga a latência da recomputação, o banco recebe uma requisição por chave por ciclo em vez de centenas, e o preço — alguns segundos de dado desatualizado numa listagem de produtos — é irrelevante para o negócio. Lock só entra em cima disso, e apenas nas chaves em que a recomputação é pesada o suficiente para que várias em paralelo doam. Refresh proativo por job é o mais previsível, mas só se paga quando o conjunto de chaves críticas é pequeno e estável; em catálogo com cauda longa, recomputar o que ninguém pede é desperdício puro.

    O sinal que aparece antes da queda

    Nenhuma das correções acima adianta se o time só descobre o problema pelo alerta de 502. A métrica que antecipa o evento é a razão entre queries ao banco e requisições HTTP recebidas, medida em janela curta. No regime saudável ela é baixa e estável. Quando um lote de chaves expira junto, ela salta antes de a latência subir o suficiente para disparar qualquer alerta de disponibilidade — há uma janela de dezenas de segundos ali.

    Duas outras séries valem o painel. A taxa de miss do cache segmentada por prefixo de chave mostra qual conjunto está expirando em bloco, o que aponta direto para o trecho de código que grava com TTL fixo. E o tempo de espera no pool de conexões do banco, que é o recurso que efetivamente esgota — a saturação aparece nele bem antes de aparecer na CPU do banco, e times que monitoram só CPU descobrem tarde.

    Próximo passo concreto: procure no código toda chamada de escrita no cache que use uma constante de TTL e verifique quantas delas gravam em lote. Se houver mais de uma dezena de chaves nascendo no mesmo laço com o mesmo prazo, o jitter compensa antes de qualquer discussão sobre lock ou revalidação em background.

  • Checklist de implementação: SSO via SAML num SaaS que já tem login próprio

    O contrato enterprise trava na cláusula de autenticação: o cliente quer que os funcionários entrem pelo provedor de identidade corporativo e o produto só sabe validar e-mail e senha na própria tabela de usuários. A tentação é resolver isso como um recurso de tela de login. Não é. SSO via SAML mexe no modelo de dados, no ciclo de vida da conta, na política de sessão e no offboarding — e a maior parte dos incidentes pós-lançamento vem de decisões tomadas antes de qualquer XML ser assinado.

    O roteiro abaixo cobre a implementação em um SaaS multi-tenant que já roda em produção com autenticação própria. A ordem importa: cada bloco assume o anterior resolvido.

    O modelo de dados precisa mudar antes do primeiro XML

    Autenticação local costuma guardar identidade e credencial na mesma linha. O e-mail identifica o usuário, o hash da senha o autentica, e o sistema trata os dois como a mesma coisa. SAML quebra essa fusão: quem autentica é o provedor de identidade, quem identifica é um atributo que o provedor envia — e esse atributo pode não ser o e-mail.

    Três estruturas precisam existir antes de qualquer coisa:

    • Conexão de identidade por organização, não por usuário. Cada tenant tem no máximo uma conexão ativa, contendo entity ID do provedor, URL de SSO, certificado público de assinatura e o mapa de atributos.
    • Identificador externo estável no usuário, separado do e-mail. O NameID persistente ou o objectGUID do diretório sobrevive a troca de sobrenome, mudança de domínio e reorganização interna. O e-mail não sobrevive a nenhuma das três.
    • Método de autenticação por usuário, com valores como senha, saml ou ambos. Sem esse campo não há como fazer a migração gradual descrita adiante.

    Guardar o certificado do provedor com data de expiração explícita, e não só o PEM cru, poupa o incidente mais previsível dessa integração: o certificado do cliente vence, todo mundo da conta perde acesso ao mesmo tempo e o suporte descobre pelo chamado. Um job diário que compara a data de expiração com a data atual e dispara alerta com trinta e sete dias de antecedência custa uma tarde e evita a ligação de sábado.

    Descoberta de provedor: o passo que degrada a experiência quando é feito errado

    Com login local, o usuário digita e-mail e senha na mesma tela. Com SSO, o sistema precisa descobrir para qual provedor redirecionar antes de pedir credencial nenhuma — e essa descoberta acontece com a única informação disponível: o e-mail digitado.

    O padrão que funciona é o formulário em dois passos. A primeira tela pede apenas o e-mail. O backend extrai o domínio, consulta a tabela de domínios verificados por organização e decide: se há conexão SAML ativa, redireciona para o provedor; se não há, mostra o campo de senha. O usuário de conta pessoal continua com a experiência de sempre; o usuário corporativo nunca vê um campo de senha que não usa mais.

    Verificar o domínio é obrigatório e não é burocracia. Sem prova de posse — registro TXT no DNS ou arquivo servido em caminho conhecido — qualquer conta pode reivindicar um domínio alheio e sequestrar o fluxo de login de outra empresa. A verificação precisa ser revalidada periodicamente, porque domínio expira e muda de dono.

    Sobre o fluxo iniciado pelo provedor, aquele em que o usuário clica no ícone do aplicativo dentro do portal corporativo: ele é o que os clientes pedem e o que a equipe de segurança deveria resistir a entregar primeiro. Sem RelayState validado e sem correlação com uma requisição de origem, a aplicação recebe uma asserção não solicitada e precisa confiar nela sem contexto. Se for entregue, que seja depois do fluxo iniciado pelo provedor de serviço estar estável, com lista estrita de destinos permitidos no RelayState.

    Validar a asserção não é só conferir a assinatura. Antes de criar sessão, checar em sequência: assinatura contra o certificado daquela organização específica; Destination igual à URL de callback real; Audience igual ao entity ID do produto; NotBefore e NotOnOrAfter dentro da janela com tolerância de relógio de poucos minutos; InResponseTo correspondendo a uma requisição registrada; e o ID da asserção ainda não consumido, guardado em cache de uso único até o fim da validade. Pular a última checagem transforma uma asserção capturada em replay válido.

    Provisionamento: JIT resolve a entrada, SCIM resolve a saída

    Provisionamento just-in-time cria ou atualiza o usuário no momento em que a asserção chega. É simples de implementar, não exige que o cliente configure nada além da conexão e cobre o caso comum de funcionário novo que precisa acessar hoje.

    O problema do JIT é o inverso: ele não sabe nada sobre quem saiu. Um funcionário desligado no diretório corporativo continua com usuário ativo no produto até alguém lembrar de removê-lo. Para produtos que guardam dado sensível, isso é achado de auditoria garantido.

    Critério JIT via SAML SCIM
    Esforço de implementação Baixo — aproveita o fluxo de login Alto — API REST completa de usuários e grupos
    Criação de conta No primeiro acesso Antes do primeiro acesso
    Desligamento Não detecta Desativa em minutos
    Sincronia de grupos e papéis Só o que vem na asserção, a cada login Contínua, incluindo remoção de grupo
    Exige configuração do cliente Mínima Token, endpoint e mapeamento no diretório

    A escolha prática: entregar JIT primeiro e tratar SCIM como item de roadmap com gatilho comercial claro — o primeiro cliente que colocar desprovisionamento automático em requisito de contrato. Enquanto SCIM não existe, o mitigante honesto é sessão curta para contas SAML, com revalidação contra o provedor a cada poucas horas em vez de token de trinta dias. Não é equivalente a desprovisionamento, mas reduz a janela de exposição de semanas para horas.

    Sobre mapear papéis a partir de grupos do diretório: funciona, e é o que o cliente vai pedir. O cuidado é definir precedência explícita. Se o papel vem do grupo, um administrador que perde o grupo deve perder o papel no login seguinte — inclusive o último administrador da conta. Deixar uma conta sem nenhum administrador por causa de mudança de grupo é o tipo de bug que só aparece em produção. Reservar ao menos um papel de administrador imune ao mapeamento resolve.

    A senha que já existe é o maior risco do projeto

    Ligar SAML é fácil. Desligar o que existia é onde o projeto trava. Uma organização com centenas de usuários ativos tem sessões abertas, tokens de API emitidos, convites pendentes, integrações que autenticam com credencial de serviço e pelo menos uma pessoa que entra por link salvo direto no formulário de senha.

    O caminho que funciona tem três estágios controlados por configuração da organização, não por deploy:

    • SSO opcional. Os dois métodos convivem. Serve para a organização validar o mapeamento de atributos com usuários reais antes de comprometer o acesso de todo mundo.
    • SSO obrigatório com exceções. O login por senha é bloqueado para os usuários do domínio verificado, com lista curta de exceções nominais — tipicamente contas de serviço e o administrador que configurou a conexão.
    • SSO exclusivo. Nenhuma exceção. Só entra aqui a organização que já validou o estágio anterior por algumas semanas.

    Duas armadilhas específicas. A primeira: ao tornar o SSO obrigatório, as sessões já ativas continuam válidas — o usuário só descobre o problema quando a sessão expira, possivelmente semanas depois, e o suporte recebe o chamado descolado da mudança. Invalidar as sessões existentes no momento da troca de estágio é mais gentil do que parece. A segunda: tokens de API pessoais emitidos antes da migração sobrevivem ao bloqueio de senha e são, na prática, uma porta paralela. Ou eles são revogados junto, ou o cliente que pediu SSO obrigatório recebeu menos do que acha que comprou.

    Convites pendentes precisam de tratamento próprio. Um convite emitido antes da conexão existir leva a um formulário de definição de senha que não faz mais sentido. Reescrever o destino do convite no momento em que a conexão é ativada evita o suporte previsível de segunda-feira.

    Checklist de corte

    O que precisa estar verificado antes de liberar a conexão para a primeira organização real, em ordem de execução:

    • Certificado do provedor armazenado com data de expiração e alerta agendado.
    • Suporte a dois certificados simultâneos na mesma conexão, para o cliente rotacionar sem janela de indisponibilidade.
    • Domínio verificado por DNS, com revalidação periódica agendada.
    • Validação completa da asserção, incluindo cache de uso único do ID.
    • Tolerância de relógio configurada e monitorada — divergência de horário é a causa mais comum de falha intermitente que não reproduz em teste.
    • Tela de erro que diz qual validação falhou, em linguagem que o administrador do cliente entende, sem expor conteúdo da asserção.
    • Log de auditoria com organização, identificador externo, provedor, resultado e motivo da falha — retido pelo prazo que o contrato exige.
    • Caminho de recuperação documentado para quando o provedor do cliente cai: quem pode reativar login por senha, com qual aprovação e em quanto tempo.
    • Teste de carga no endpoint de callback. Ele recebe rajada no início do expediente, quando a empresa inteira chega ao mesmo tempo.

    O último item da lista é o que costuma faltar. Uma conexão SAML transfere a disponibilidade do login para um sistema que não é o seu, operado por um time que você não conhece, com janela de manutenção que ninguém avisa. O plano de contingência não é opcional — e precisa estar escrito antes do primeiro cliente depender dele, porque escrever procedimento de emergência durante a emergência é como esse projeto vira post-mortem.

  • Runner de CI hospedado ou self-hosted: onde o custo deixa de ser o critério

    A fila de build passou de dez minutos de espera antes do primeiro job começar a rodar, a fatura de minutos de CI dobrou em dois trimestres e alguém na retrospectiva sugeriu subir runners próprios. A decisão parece financeira, mas o custo por minuto é o critério menos confiável dos três que realmente pesam: tempo de manutenção, superfície de ataque e previsibilidade de performance.

    Este comparativo trata dos dois modelos de execução de pipeline — runner gerenciado pelo provedor de CI e runner self-hosted em infraestrutura própria — assumindo GitHub Actions, GitLab CI ou equivalente. A mecânica é a mesma nos três.

    O que muda tecnicamente entre os dois modelos

    Runner hospedado entrega uma máquina virtual efêmera, criada do zero a cada job e destruída no fim. Imagem padronizada, cache de dependências gerenciado pelo provedor, isolamento garantido entre execuções. O time não administra nada e não controla nada: nem versão de kernel, nem tipo de disco, nem quanto tempo a VM leva para subir.

    Runner self-hosted é um processo agente rodando em máquina do time — bare metal, VM em nuvem, pod em Kubernetes — que faz long-polling na API do provedor esperando job. Quem define CPU, memória, disco, imagem base e política de limpeza é o time. O agente pode ser efêmero (sobe um pod por job e morre) ou persistente (a mesma máquina executa milhares de jobs em sequência). Essa escolha entre efêmero e persistente pesa mais do que a escolha entre hospedado e self-hosted, e é onde a maioria dos times erra.

    Runner persistente é rápido porque o cache de camadas Docker, os módulos de dependência e os artefatos de build sobrevivem entre execuções. Também é o modelo que acumula lixo de disco, mantém estado entre jobs de branches diferentes e transforma qualquer job comprometido em acesso persistente à máquina.

    A conta real, item por item

    Comparar preço de minuto contra preço de instância dá um resultado enganoso, porque as duas colunas medem coisas diferentes. A tabela abaixo separa o que cada modelo cobra e o que cada modelo consome de gente.

    Dimensão Runner hospedado Runner self-hosted
    Modelo de cobrança Por minuto de execução, com faixa gratuita mensal Custo da instância enquanto ligada, independente de haver job
    Ociosidade Custo zero Custo integral, salvo autoescala agressiva
    Tempo até o primeiro comando Dezenas de segundos, sujeito a fila em horário de pico Praticamente imediato em runner persistente
    Cache entre execuções Depende de upload/download para o storage do provedor Local, sem transferência de rede
    Máquinas grandes ou GPU Disponível em tiers caros Livre, limitado só pelo orçamento de infra
    Manutenção recorrente Nenhuma Patch, atualização do agente, limpeza de disco, monitoramento
    Isolamento entre jobs Total, por construção Só com runner efêmero, e por decisão explícita

    A linha que quase sempre decide é a segunda. Pipeline de time pequeno concentra execução em janelas curtas — meio da manhã, fim da tarde — e fica parado o resto do dia. Instância ligada 24 horas para atender três horas de carga é desperdício que o preço por minuto do provedor dificilmente supera. O ponto de virada aparece quando a utilização passa de algo em torno de um terço do dia, ou quando o job precisa de máquina que o tier hospedado só oferece em faixa cara.

    Vale medir antes de estimar. O tempo agregado de execução por mês está exposto na API do provedor e a soma real costuma surpreender: builds paralelos de matriz e jobs que rodam a cada push em branch de feature representam boa parte da fatura, e frequentemente podem ser cortados sem migrar nada.

    Antes de decidir, extraia o tempo real de execução por workflow dos últimos 90 dias e ordene por consumo. Em GitHub Actions: gh api /repos/ORG/REPO/actions/runs --paginate -q '.workflow_runs[] | [.name, .run_started_at, .updated_at] | @tsv' — a diferença entre os dois timestamps, somada por workflow, mostra onde os minutos estão indo. Costuma ser um único workflow respondendo pela maior parte da conta.

    O custo que não aparece na planilha

    Self-hosted transfere para o time responsabilidades que o provedor absorvia em silêncio. Nenhuma delas é difícil isoladamente; juntas consomem uma fração recorrente do tempo de alguém, e essa fração raramente entra na comparação.

    • Disco. Runner persistente acumula imagens Docker, node_modules, artefatos e diretórios de workspace. Sem docker system prune agendado e política de retenção, o disco enche e o sintoma é build falhando de madrugada por espaço, não por código.
    • Versão do agente. O provedor deprecia versões antigas e o runner simplesmente para de receber jobs. Atualização precisa ser automatizada ou vira incidente.
    • Deriva de imagem. A máquina de produção do CI acumula pacotes instalados manualmente para resolver um build específico. Seis meses depois ninguém sabe reconstruir o ambiente, e o pipeline passou a depender de estado não versionado.
    • Autoescala. Manter custo baixo exige escalar de zero — via Actions Runner Controller no Kubernetes, grupo de autoescala com o agente no user data ou equivalente. É infraestrutura própria, com seus próprios modos de falha.

    Regra prática: se ninguém no time tem tempo alocado para tratar o CI como serviço de produção, o self-hosted vai degradar. Não de imediato — em três ou quatro meses, quando a primeira falha de disco acontecer num sábado.

    Segurança: o ponto em que o comparativo deixa de ser sobre custo

    Runner self-hosted persistente executando workflow disparado por pull request de fork é a configuração mais perigosa dessa lista. Qualquer pessoa abre um PR alterando o arquivo de workflow, o job roda com o código dela na máquina do time e o processo tem acesso à rede interna, ao cache local e a tudo que jobs anteriores deixaram no disco. A documentação dos provedores desaconselha explicitamente essa combinação, e ela continua comum em repositório que virou público sem revisão do pipeline.

    Três mitigações, em ordem de eficácia:

    • Runner efêmero, um por job, sem reuso de máquina — resolve o problema estrutural em vez de contorná-lo.
    • Exigir aprovação manual para workflows disparados por colaboradores externos, configuração disponível nas próprias settings do repositório.
    • Isolar o runner em sub-rede sem rota para ambientes internos, com credenciais de curta duração via OIDC em vez de secrets estáticos.

    Runner hospedado elimina essa classe inteira de risco porque a VM nasce e morre com o job. Em compensação, cria dependência da postura de segurança do provedor e obriga a expor endpoints internos caso o pipeline precise alcançar rede privada — o que normalmente leva a um túnel ou a um runner self-hosted só para os jobs de deploy.

    O arranjo que costuma vencer

    Na maioria dos times que mantêm sistema em produção, o desenho híbrido resolve melhor que qualquer extremo: runner hospedado para tudo que é disparado por push e por PR — lint, teste unitário, build de imagem — e um pool self-hosted pequeno, efêmero, com acesso à rede privada, exclusivo para jobs de deploy e de teste de integração que precisam de banco interno.

    Um exemplo de segmentação por label, direto no workflow:

    jobs:
      test:
        runs-on: ubuntu-latest        # hospedado, efemero por construcao
      deploy:
        runs-on: [self-hosted, linux, deploy, ephemeral]
        environment: production        # exige aprovacao antes de executar

    Essa separação mantém a superfície de ataque restrita a jobs que só rodam a partir da branch principal, deixa o volume de execução no modelo que cobra por uso e evita administrar frota de máquinas para rodar teste unitário.

    Migrar tudo para self-hosted se justifica em dois cenários específicos: build que depende de hardware indisponível no tier hospedado — compilação nativa pesada, GPU, macOS em volume — ou utilização sustentada alta o suficiente para que a instância reservada saia mais barata que o minuto avulso com folga confortável. Fora desses casos, o ganho financeiro é pequeno e o passivo operacional é permanente.

    Próximo passo concreto: extraia o consumo por workflow dos últimos 90 dias, identifique o job que responde pela maior fatia e verifique se ele precisa mesmo rodar a cada push. Reduzir gatilho e paralelismo desnecessário costuma cortar mais da conta do que trocar de modelo de execução — e não adiciona nenhuma máquina para manter.

  • Idempotência em APIs de pagamento: o que quebra quando o cliente reenvia a mesma requisição

    Um POST /charges estoura o timeout do cliente em 30 segundos. O gateway, do outro lado, processou a cobrança normalmente aos 34 segundos. O cliente não recebeu resposta, aplica a política de retry e envia a mesma requisição. Resultado: duas cobranças no cartão do usuário final, um chamado de estorno que custa mais caro que o ticket e uma reconciliação manual no fim do mês.

    Idempotência é a resposta padrão, e quase toda equipe implementa alguma versão dela. O problema é que a implementação intuitiva — consultar se o registro já existe antes de inserir — falha exatamente na situação que motivou a solução: duas requisições concorrentes, ainda em voo, disputando o mesmo recurso. O que segue destrincha cada decisão de projeto e o critério de escolha.

    O que a retentativa quebra, em ordem de gravidade

    Nem toda repetição tem o mesmo custo. Vale separar três níveis antes de decidir quanta complexidade o sistema comporta.

    O primeiro nível é o efeito colateral duplicado e irreversível: cobrar duas vezes, emitir dois boletos, disparar duas transferências. Aqui o dano é financeiro e visível para o usuário final. É o caso que justifica praticamente qualquer investimento de engenharia.

    O segundo é o efeito duplicado reversível: dois e-mails de confirmação, dois webhooks para o parceiro. O dano é absorvível, mas corrói integrações — parceiros que recebem eventos duplicados passam a construir deduplicação do lado deles, o que empurra o problema para fora.

    O terceiro é o estado inconsistente parcial, o mais insidioso. A primeira requisição criou a cobrança, chamou o adquirente, gravou a transação e morreu antes de atualizar o saldo. A segunda entra, encontra a cobrança criada, assume que está tudo feito e retorna sucesso. O saldo nunca é atualizado e ninguém percebe até o fechamento. Idempotência mal implementada produz esse cenário com mais frequência do que a ausência total de tratamento, porque cria a ilusão de que o caso já foi coberto.

    A conclusão prática: idempotência não é uma verificação de duplicidade, é uma garantia de que o resultado observável de N execuções da mesma requisição é igual ao de uma execução. Isso inclui devolver a mesma resposta, com o mesmo corpo e o mesmo status, não apenas evitar o segundo insert.

    A chave de idempotência: quem gera, o que ela cobre, quanto tempo dura

    A chave é um identificador que o cliente gera e envia em um cabeçalho, normalmente Idempotency-Key, e que o servidor usa para reconhecer que duas requisições são a mesma tentativa lógica.

    Gerar a chave no servidor não funciona: se o servidor gerasse, a segunda requisição receberia uma chave diferente e seria tratada como nova. A chave precisa nascer no ponto em que a intenção do usuário se forma — a tela de checkout, o job que dispara a cobrança — e permanecer estável por todas as retentativas daquela intenção. Um UUID v4 gerado no início do fluxo resolve. Derivar a chave de um hash do corpo da requisição é tentador e problemático: dois pagamentos legítimos e idênticos do mesmo valor, no mesmo minuto, colidiriam.

    Sobre o escopo, a chave deve ser única por cliente da API e por endpoint, não globalmente. Escopo global cria acoplamento desnecessário entre integradores e transforma colisão acidental em incidente de segurança — um cliente conseguiria ler a resposta de outro se adivinhasse a chave. A tabela de idempotência deve ter chave composta por identificador do consumidor, rota e chave enviada.

    O tempo de retenção é onde a maioria dos projetos erra por excesso ou por falta. Reter por 24 horas cobre a janela realista de retry de qualquer cliente bem-comportado e a maior parte dos incidentes de rede. Reter para sempre transforma a tabela em um passivo que cresce indefinidamente e passa a dominar o custo de escrita. Reter por 5 minutos deixa de fora o cliente que ficou offline e reprocessou a fila meia hora depois. A faixa de 24 a 72 horas é o consenso na maior parte dos gateways de pagamento; abaixo disso é preciso ter certeza sobre o comportamento de retry de todos os consumidores.

    Uma regra que economiza incidente: a chave precisa cobrir também o corpo da requisição. Se a mesma chave chegar com um payload diferente — valor alterado, cartão diferente —, o correto é responder 422 em vez de devolver a resposta antiga. Isso captura bug de cliente que reutiliza chave por engano, que é uma classe de erro bem mais comum que a retentativa legítima.

    Guarde o hash do corpo junto com a chave e compare na entrada: sha256(body) != registro.body_hash retorna 422 com mensagem explícita. Sem essa comparação, um cliente com bug consegue receber a resposta de uma cobrança de R$ 10 ao tentar cobrar R$ 1.000, e o erro só aparece na conciliação.

    Onde guardar o registro: banco relacional, cache distribuído ou o próprio recurso

    Existem três lugares plausíveis para materializar a chave, com trade-offs bem diferentes.

    Estratégia Garantia Custo Quando usar
    Tabela dedicada no mesmo banco transacional Forte: a chave e o efeito colateral entram na mesma transação Uma escrita a mais por requisição; tabela precisa de expurgo Efeito colateral irreversível e banco relacional já no caminho crítico
    Redis com SET NX e TTL Fraca sob falha: a chave pode gravar e o efeito não, ou vice-versa Baixo, latência de milissegundos Deduplicação de efeitos reversíveis — e-mail, webhook, notificação
    Constraint única no próprio recurso Forte, sem tabela extra Zero de infraestrutura adicional Quando existe um identificador natural de negócio já enviado pelo cliente

    Para dinheiro, a escolha é a primeira. O ponto decisivo é a atomicidade: gravar a chave em um sistema (Redis) e o efeito em outro (Postgres) reintroduz o problema de dois commits sem transação distribuída. Se o processo cair entre os dois, o sistema fica em um estado em que a chave existe mas a cobrança não — e a retentativa passa a receber uma resposta de sucesso para algo que nunca aconteceu. É o terceiro nível de gravidade descrito acima, agora causado pela própria proteção.

    A terceira opção é subestimada. Quando o domínio já tem um identificador que o cliente controla — número de pedido, referência externa do parceiro, código de remessa —, uma unique constraint nesse campo entrega idempotência sem tabela auxiliar, sem TTL e sem código de limpeza. A limitação é que ela não guarda a resposta original: na segunda tentativa o servidor descobre a violação de constraint e precisa buscar o recurso existente para responder o mesmo corpo. Para APIs cujo retorno é o próprio recurso, isso é suficiente e vale a simplicidade.

    O caso difícil: a segunda requisição chega com a primeira ainda em voo

    Esse é o cenário que separa implementação real de implementação de exemplo. O cliente deu timeout aos 30 segundos e reenviou; a primeira requisição continua rodando no servidor, chamando o adquirente, e vai levar mais 10 segundos.

    A verificação ingênua — select, e se não achar, processa — coloca as duas requisições no mesmo caminho, porque no instante do select a primeira ainda não escreveu nada. O resultado é a cobrança dupla que a idempotência deveria impedir.

    A correção é inverter a ordem: escrever a chave primeiro, em estado em_andamento, e deixar o banco decidir quem ganhou a corrida. Quem consegue inserir processa; quem recebe violação de unicidade sabe que perdeu e responde de acordo.

    -- 1. tentativa de reserva; quem perder recebe violação de unicidade
    INSERT INTO idempotencia (consumidor_id, rota, chave, body_hash, estado)
    VALUES ($1, $2, $3, $4, 'em_andamento');
    
    -- 2. processa o efeito e finaliza na MESMA transação
    UPDATE idempotencia
       SET estado = 'concluido',
           status_http = $5,
           resposta = $6,
           concluido_em = now()
     WHERE consumidor_id = $1 AND rota = $2 AND chave = $3;

    Resta definir o que responder ao perdedor da corrida. Há duas escolhas defensáveis e uma ruim.

    A ruim é bloquear e esperar a primeira terminar para devolver a mesma resposta. Parece elegante e ocupa um worker por toda a duração da requisição original — sob incidente, quando todo mundo está reprocessando, isso esgota o pool de conexões e derruba o serviço inteiro. É o mecanismo clássico de amplificação de falha.

    A primeira escolha defensável é responder 409 Conflict imediatamente, sinalizando que aquela chave está em processamento e que o cliente deve tentar de novo em alguns segundos. É simples, libera o worker na hora e transfere a espera para o cliente, que é quem tem o contexto para decidir.

    A segunda é responder 202 Accepted com um identificador de acompanhamento, tornando o endpoint assíncrono nesse caso. Mais trabalho dos dois lados, melhor para operações longas.

    Entre as duas: 409 para APIs síncronas cujo processamento fica na casa de segundos, que é a maioria; 202 quando a operação envolve fila, terceiro lento ou processamento que pode passar de dez segundos. O critério não é a elegância, é quanto tempo um worker fica preso durante um pico de retentativas.

    Há ainda o registro órfão: o processo morreu depois do insert em em_andamento e nunca concluiu. Sem tratamento, aquela chave fica travada em 409 para sempre. A saída é considerar expirado todo registro em_andamento mais velho que o timeout máximo da operação — algo entre 60 e 120 segundos para APIs síncronas — e permitir que uma nova tentativa assuma o lugar.

    O que verificar antes de considerar pronto

    Testes de idempotência que passam sempre costumam ser testes sequenciais, e sequência é justamente o caso fácil. A bateria mínima tem cinco cenários, e três deles exigem concorrência real.

    • Mesma chave, mesmo corpo, sequencial: a segunda chamada devolve status e corpo idênticos aos da primeira, sem novo efeito colateral.
    • Mesma chave, mesmo corpo, concorrente: N chamadas disparadas em paralelo produzem exatamente um efeito; as demais recebem 409 ou a resposta gravada.
    • Mesma chave, corpo diferente: retorna 422 e não executa nada.
    • Falha no meio: matar o processo entre o insert da chave e a conclusão deve deixar o sistema em estado recuperável — a retentativa após a janela de expiração processa normalmente, sem duplicar.
    • Chave expirada: passada a janela de retenção, a mesma chave é tratada como requisição nova. Confirme que isso é aceitável para o domínio antes de encurtar o TTL.

    O quarto cenário é o que mais revela defeito e o menos testado, porque exige injetar falha no meio da transação. Vale o esforço de escrever um teste que aborte a conexão logo após o primeiro insert: é ele que diferencia um sistema que sobrevive a deploy no meio do pico de um que gera pendência de conciliação.

    O próximo passo natural, com a idempotência de escrita estabilizada, é aplicar a mesma disciplina ao consumo de eventos — webhooks recebidos de terceiros carregam garantia at-least-once e chegam repetidos por definição, com o agravante de que a chave, ali, é escolhida por quem envia.

  • Post-mortem: a migração de coluna que derrubou o checkout por 38 minutos

    Um ALTER TABLE para adicionar uma coluna com NOT NULL DEFAULT em uma tabela de pedidos com dezenas de milhões de linhas, executado às 14h de uma terça, com o checkout recebendo tráfego. O resultado: 38 minutos de erro 500 em finalização de compra. O caso abaixo é uma reconstrução hipotética, montada a partir de padrões de falha que se repetem em times que rodam migração junto com o deploy da aplicação. Serve para separar o que é limitação do banco do que é falha de processo.

    A linha do tempo do incidente

    O deploy subiu às 14h02. A migração era considerada trivial: uma coluna nova em pedidos, com valor padrão, para marcar o estado do pagamento. Em ambiente de homologação, com uma base de poucos milhares de registros, a migração rodou em menos de um segundo.

    Em produção, a sequência foi outra:

    • 14h02 — o runner de migração abre transação e emite o ALTER TABLE.
    • 14h02 — o comando adquire um lock exclusivo (ACCESS EXCLUSIVE, no vocabulário do PostgreSQL) sobre a tabela.
    • 14h03 — toda consulta nova em pedidos, inclusive SELECT, entra na fila atrás do lock.
    • 14h05 — o pool de conexões da aplicação satura. Requisições que nem tocam pedidos começam a falhar por indisponibilidade de conexão.
    • 14h11 — o alerta de taxa de erro dispara. O time ainda procura o problema no serviço de pagamento.
    • 14h33 — alguém consulta pg_stat_activity e encontra a migração ainda em execução, com uma fila de centenas de sessões em lock wait.
    • 14h40 — a migração é cancelada e sofre rollback. O sistema normaliza em dois minutos.

    Duas coisas importam nessa linha do tempo. A primeira é que o incidente durou 38 minutos, mas o diagnóstico consumiu 30 deles. A segunda é que o efeito extrapolou a tabela afetada: a saturação do pool transformou um problema localizado em queda geral.

    Por que o ALTER TABLE trava a tabela inteira

    A causa raiz não é o ALTER TABLE em si. Versões recentes de PostgreSQL adicionam coluna com valor padrão sem reescrever a tabela — o default fica registrado no catálogo e é materializado só na próxima escrita da linha. A operação é rápida.

    O problema é o que acompanha o comando. Três armadilhas explicam a maioria dos casos:

    O lock é exclusivo mesmo quando a operação é rápida. Para pegar o lock, o comando precisa esperar que todas as transações abertas sobre a tabela terminem. Basta uma consulta analítica de dois minutos, ou uma transação esquecida em estado idle in transaction, para o ALTER ficar bloqueado. E, uma vez na fila, ele bloqueia todo mundo que chega depois — inclusive leituras. Uma operação de 50 milissegundos vira uma parada de meia hora por causa de uma transação que ninguém fechou.

    Nem toda variação evita a reescrita. Adicionar coluna com default constante é barato. Adicionar com default calculado por função volátil, mudar tipo de coluna, ou adicionar uma constraint CHECK sem a cláusula NOT VALID força varredura ou reescrita completa. A diferença entre o caso barato e o caro está em detalhes de sintaxe que passam despercebidos em revisão de código.

    Migração e deploy compartilham o mesmo timeout. Se o runner de migração roda sem lock_timeout, ele espera indefinidamente. O comportamento desejado é o oposto: falhar rápido e tentar de novo.

    Rodar toda migração com limites explícitos muda o modo de falha. Em PostgreSQL: SET lock_timeout = '3s'; SET statement_timeout = '30s'; antes do DDL. A migração passa a falhar em três segundos em vez de bloquear a tabela por meia hora — e a falha some do caminho crítico assim que o runner tenta de novo.

    Expand e contract: a migração em quatro deploys

    A correção estrutural é parar de tratar mudança de schema como evento único. O padrão expand–contract divide a alteração em etapas nas quais schema antigo e novo coexistem, de modo que nenhum deploy exija que banco e aplicação mudem no mesmo instante.

    Para a coluna do incidente, a sequência fica assim:

    Deploy 1 — expandir. Adicionar a coluna como nullable, sem default e sem constraint. É a operação mais barata que existe: só catálogo, sem reescrita. A aplicação ainda não conhece a coluna.

    ALTER TABLE pedidos ADD COLUMN status_pagamento varchar(32);

    Deploy 2 — escrever nos dois lugares. A aplicação passa a preencher a coluna nova em toda escrita, mas continua lendo do campo antigo. Nenhuma leitura depende ainda do dado novo, então um bug aqui não quebra o fluxo do usuário.

    Deploy 3 — backfill e virada da leitura. Preencher as linhas antigas em lotes (detalhado na seção seguinte) e, só depois de o backfill terminar e ser verificado, mudar a leitura para a coluna nova.

    Deploy 4 — contrair. Remover a escrita dupla, aplicar a constraint NOT NULL e derrubar o campo antigo. A constraint entra em duas etapas para não varrer a tabela sob lock exclusivo:

    ALTER TABLE pedidos
      ADD CONSTRAINT pedidos_status_pagamento_nn
      CHECK (status_pagamento IS NOT NULL) NOT VALID;
    
    ALTER TABLE pedidos
      VALIDATE CONSTRAINT pedidos_status_pagamento_nn;

    A primeira instrução registra a constraint sem checar as linhas existentes — rápida, lock curto. A segunda varre a tabela, mas com um lock que não bloqueia leitura nem escrita. O custo é ter uma constraint CHECK onde se queria um NOT NULL de coluna; em compensação, o processo nunca para o sistema.

    O custo real do expand–contract é organizacional: quatro deploys em vez de um, e uma janela de dias em que o código carrega escrita dupla. Vale a pena para qualquer tabela grande com tráfego. Não vale para tabela de configuração com centenas de linhas — nesse caso o ALTER direto, com lock_timeout curto, resolve.

    Backfill: o detalhe que quase repetiu o incidente

    Depois do incidente, o time escreveu o backfill como um único UPDATE. Isso reproduz o problema em outra forma: um UPDATE que toca dezenas de milhões de linhas em uma transação gera uma transação longuíssima, infla o WAL, atrasa a replicação e impede o autovacuum de limpar versões mortas de linha. O sistema não trava, mas degrada por horas.

    Backfill correto é feito em lotes, com pausa entre eles e commit por lote:

    UPDATE pedidos
    SET status_pagamento = CASE
          WHEN pago_em IS NOT NULL THEN 'pago'
          ELSE 'pendente'
        END
    WHERE id IN (
      SELECT id FROM pedidos
      WHERE status_pagamento IS NULL
      ORDER BY id
      LIMIT 5000
    );

    O laço roda até nenhuma linha ser afetada. Três parâmetros governam o comportamento: tamanho do lote, pausa entre lotes e critério de parada. Lotes na casa de alguns milhares de linhas costumam ser o ponto de equilíbrio — grandes o bastante para terminar em tempo razoável, pequenos o bastante para que cada transação dure milissegundos. A pausa entre lotes existe para dar espaço ao autovacuum e à replicação; começar com algo em torno de cem milissegundos e ajustar observando o atraso de réplica é mais confiável do que fixar um número no código.

    O critério de parada merece atenção especial. Um backfill que roda por horas precisa poder ser interrompido e retomado sem perder progresso — daí filtrar por status_pagamento IS NULL em vez de paginar por offset. Se o processo morrer no meio, basta rodar de novo.

    Comparativo: quando usar cada estratégia

    Estratégia Cabe quando Custo Risco de indisponibilidade
    ALTER direto no deploy Tabela pequena, sem tráfego contínuo Um deploy Alto se a tabela crescer sem ninguém revisar
    ALTER com lock_timeout e retry Tabela média, operação que só mexe no catálogo Um deploy mais lógica de retry Baixo — falha rápido em vez de bloquear
    Expand–contract com backfill em lotes Tabela grande com tráfego, mudança que altera dados Três a quatro deploys ao longo de dias Muito baixo
    Ferramenta de migração online MySQL, ou mudança de tipo em tabela muito grande Ferramenta externa no pipeline, espaço em disco extra Baixo, com complexidade operacional maior

    Entre as quatro, a que mais se ganha adotando por padrão é a segunda. Colocar lock_timeout em toda migração é uma linha de configuração no runner e elimina a classe inteira de incidentes em que uma operação barata fica presa atrás de uma transação esquecida. O expand–contract é mais trabalhoso e deve ser reservado a mudanças que realmente mexem em dados de tabela grande.

    O que entra no runbook

    Três mudanças de processo evitam a repetição, e nenhuma delas depende de o desenvolvedor lembrar da regra na hora certa:

    • Migração fora do deploy da aplicação. Enquanto os dois compartilham o mesmo passo do pipeline, uma migração lenta segura o rollback do código. Separar os dois permite reverter a aplicação sem tocar no banco.
    • Limite de tamanho na revisão. Um teste no CI que consulta o número estimado de linhas da tabela alvo e exige aprovação explícita acima de um limiar — algo na casa do milhão de linhas — transforma a regra em barreira automática.
    • Monitor de transação longa. Um alerta para sessões em idle in transaction acima de alguns minutos. Foi uma dessas que segurou o lock no incidente, e ela já estava aberta antes do deploy começar.

    O próximo passo prático é levantar as três maiores tabelas do sistema e verificar quais migrações do último trimestre teriam sido bloqueantes nelas. O padrão costuma aparecer rápido: quase sempre são duas ou três tabelas que concentram o risco, e é nelas que o expand–contract precisa ser obrigatório.

  • Checklist de Segurança para APIs REST Antes de Ir para Produção

    Uma API que passou nos testes funcionais ainda pode estar aberta o suficiente para ser explorada no primeiro dia em produção. A verificação de segurança é uma etapa própria, com critérios que não se confundem com “funciona”: autenticação correta, autorização por recurso, limites de uso e controle do que a resposta devolve. Este texto lista o que precisa estar fechado antes de expor uma API REST na internet e onde as decisões costumam ser tomadas errado.

    Autenticação e autorização são duas verificações separadas

    Confirmar quem faz a chamada e confirmar que esse alguém pode acessar aquele recurso são checagens independentes. A maior parte das falhas graves de API não está na autenticação — o token é válido — e sim na autorização: o endpoint devolve o pedido de número 4821 sem verificar se ele pertence ao usuário autenticado. Isso é o clássico Broken Object Level Authorization, o item que lidera as listas de vulnerabilidades de API há anos.

    A regra prática: toda rota que recebe um identificador de recurso precisa validar propriedade no servidor, nunca confiar que o cliente só vai pedir o que é dele. Não adianta esconder o botão no front-end. Se o recurso é acessível por GET /pedidos/4821, alguém vai trocar o número na mão.

    @app.get("/pedidos/{pedido_id}")
    def obter_pedido(pedido_id: int, usuario = Depends(auth)):
        pedido = repo.buscar(pedido_id)
        if pedido is None or pedido.dono_id != usuario.id:
            raise HTTPException(status_code=404)  # 404, nao 403
        return pedido

    Devolver 404 em vez de 403 quando o recurso existe mas não pertence ao usuário evita confirmar a existência do objeto para quem não deveria enxergá-lo. É uma escolha deliberada, não descuido. A autorização por atributo de nível de campo é o passo seguinte que muita API pula: o mesmo endpoint pode ser legítimo para dois papéis, mas um administrador enxerga o campo de custo interno e o cliente final não. Filtrar isso na saída, por papel, evita que a permissão de rota vire permissão de dado.

    Escolha do mecanismo de token e sua revogação

    A decisão entre JWT autocontido e token opaco com consulta no servidor tem um trade-off que decide o resto da arquitetura de sessão. O JWT dispensa consulta a cada requisição, o que escala bem, mas não há como invalidá-lo antes de expirar sem reintroduzir estado — uma blocklist ou uma verificação de versão. O token opaco custa uma consulta por chamada, geralmente a um cache em memória, e em troca a revogação é imediata.

    Critério JWT autocontido Token opaco + store
    Custo por requisição Verificação de assinatura Consulta ao store
    Revogação imediata Exige estado extra Nativa
    Vazamento de payload Dados legíveis no token Nada exposto
    Melhor cenário Muitos serviços, expiração curta Sessão que precisa de logout real

    Para a maioria dos sistemas com login de usuário e necessidade de “encerrar sessão agora”, o token opaco com store de baixa latência é a escolha mais segura. JWT com expiração de 15 minutos e refresh rotativo resolve o caso de arquitetura distribuída onde a consulta central seria gargalo. O erro comum é adotar JWT longo por conveniência e descobrir tarde que não há como derrubar um token comprometido.

    Comunicação entre serviços internos é um caso à parte: token de usuário não deve trafegar de serviço para serviço como se fosse credencial de máquina. Aqui entram mTLS ou credenciais de cliente de curta duração, com escopo restrito ao que aquele serviço precisa chamar. Reaproveitar o token do usuário em toda a cadeia de chamadas amplia o estrago de um único vazamento para o sistema inteiro.

    Rate limiting e proteção contra abuso

    Sem limite de taxa, um endpoint de autenticação vira alvo de força bruta e qualquer rota cara vira vetor de negação de serviço. O limite precisa existir em duas camadas: uma global por IP na borda e uma por identidade autenticada na aplicação, com janelas diferentes para rotas sensíveis. Login e recuperação de senha pedem limites bem mais rígidos que uma leitura de catálogo.

    Um limitador por janela deslizante com Redis: INCR chave na primeira chamada, EXPIRE chave 60 junto, e recusa quando o contador passa do teto. Chave no formato rl:{rota}:{user_id} para separar o orçamento de cada rota por usuário, em vez de um balde único que a leitura esvazia antes de o login precisar.

    Retorne 429 com o cabeçalho Retry-After para que clientes legítimos saibam recuar. E registre o estouro: uma sequência de 429 em recuperação de senha vindo de um mesmo intervalo de IPs é sinal de ataque em andamento, não de usuário distraído. Em ambiente com várias instâncias da API, o contador precisa ser compartilhado — um limitador em memória local vaza porque cada réplica conta o seu próprio balde e o teto efetivo vira o teto multiplicado pelo número de instâncias.

    Validação de entrada e a superfície de injeção

    Toda entrada é hostil até prova em contrário. Isso vale para o corpo da requisição, parâmetros de query, cabeçalhos e o próprio caminho. A validação por esquema — tamanho máximo, tipo, formato, faixa de valores — precisa acontecer antes de a lógica de negócio tocar o dado. Bibliotecas de validação declarativa resolvem o grosso, desde que o esquema recuse campos desconhecidos em vez de ignorá-los.

    Consultas ao banco sempre parametrizadas, sem concatenar string de query. Isso elimina a injeção de SQL clássica, mas a mesma disciplina vale para qualquer interpretador que a entrada alcance: comandos de shell, montagem de filtros NoSQL, expressões passadas a um motor de template. Um teto explícito no tamanho do corpo aceito — configurado no servidor de aplicação, não só no proxy — corta o envio de payloads gigantes projetados para estourar memória.

    Um vetor que passa despercebido é o SSRF: qualquer campo em que o usuário fornece uma URL que o servidor vai buscar — webhook, importação por link, geração de miniatura — pode ser apontado para endereços internos da rede, incluindo metadados de nuvem. A defesa é uma lista de permissão de destinos e o bloqueio explícito de faixas privadas e do endereço de metadados, resolvido depois da resolução de DNS para impedir rebind.

    O que a resposta devolve sem querer

    Um vazamento comum não está no que a API protege, e sim no que ela responde. Serializar o objeto inteiro do banco expõe campos que ninguém pediu: hash de senha, flags internas, dados de outro contexto. A resposta precisa ser modelada por um esquema de saída explícito, com os campos permitidos listados um a um, em vez de despejar o modelo de persistência direto no JSON.

    Mensagens de erro merecem o mesmo cuidado. Um stack trace devolvido ao cliente entrega versão de framework, estrutura de diretório e às vezes trecho de query. Em produção, o erro que chega ao cliente é genérico e correlacionado a um identificador; o detalhe fica no log interno. Cabeçalhos que anunciam a stack — X-Powered-By, Server com versão — saem por padrão.

    Segredos, dependências e o checklist de corte

    Chave de API, credencial de banco e segredo de assinatura não moram no código nem em variável commitada. Ficam em um cofre de segredos ou em variável de ambiente injetada no deploy, com rotação prevista. E o log, que é onde muita coisa escapa: senha, token e número de cartão não podem ser gravados, o que exige uma camada de redação antes da escrita, não confiança em quem chama o logger.

    A cadeia de dependências é superfície de ataque tão real quanto o código próprio. Uma verificação automatizada de vulnerabilidades conhecidas nos pacotes, rodando no pipeline e falhando o build quando aparece falha crítica, evita subir para produção com uma biblioteca já sabidamente explorável. Fixar versões e revisar o que entra por transitividade fecha a porta do comprometimento por dependência substituída.

    Antes do deploy, a lista mínima que precisa estar toda marcada: HTTPS obrigatório com redirecionamento de HTTP; CORS restrito a origens conhecidas, sem * em rota autenticada; autorização por recurso testada com um usuário tentando acessar dado de outro; rate limit ativo em login e recuperação; esquema de saída que não vaza campo interno; erros genéricos ao cliente; segredos fora do repositório; dependências sem vulnerabilidade conhecida na verificação automatizada. Nenhum desses itens é opcional para uma API pública, e o barato de fechá-los antes é sempre menor que o custo de responder a um incidente depois que o tráfego real chegou.

  • Decisão de arquitetura — RabbitMQ ou Kafka: qual broker para qual carga

    A escolha aparece quando um endpoint síncrono começa a segurar a requisição por tempo demais: envio de e-mail, geração de relatório, chamada a um serviço externo instável. A resposta é tirar esse trabalho do caminho da requisição e jogá-lo numa fila. A pergunta imediata é qual broker colocar ali. RabbitMQ e Kafka resolvem problemas que se sobrepõem em alguns pontos e divergem em quase todo o resto. Escolher pelo mais popular do momento é como escolher banco de dados pelo logo: funciona até a primeira carga que o modelo não comporta.

    Este texto coloca os dois lado a lado pelo critério que importa na hora de decidir — o modelo de consumo — e termina cravando qual usar em cada cenário.

    O ponto onde os dois divergem: fila versus log

    RabbitMQ é um message broker no sentido clássico. A mensagem entra numa fila, um consumidor a retira, processa e confirma (ack). Confirmada, a mensagem desaparece da fila. O broker é responsável por rotear, entregar e esquecer. O roteamento é rico: exchanges do tipo direct, topic e fanout permitem que a mesma mensagem vá para filas diferentes conforme uma routing key, sem que o produtor saiba quem consome.

    Kafka não é uma fila. É um log distribuído e particionado. A mensagem é anexada ao fim de uma partition e permanece lá pelo tempo de retenção configurado — horas, dias ou indefinidamente — independentemente de ter sido lida. O consumidor não remove nada; ele mantém um offset, a posição de leitura dentro da partition. Ler significa avançar o offset. Isso muda tudo: vários consumidores independentes leem o mesmo dado em ritmos diferentes, e reprocessar o histórico é só mover o offset para trás.

    Dessa diferença — apagar após consumir contra reter e reler — derivam quase todas as outras. Vale fixar isso antes de olhar throughput ou lat; é o eixo em que a decisão realmente se define.

    Ordenação, throughput e entrega

    No RabbitMQ, a ordem se mantém dentro de uma fila com um único consumidor. No momento em que você escala para vários consumidores concorrentes na mesma fila para ganhar vazão, a ordem de processamento deixa de ser garantida — duas mensagens saem quase juntas e terminam fora de sequência. Se a ordem importa, você fica preso a um consumidor por fila, o que limita o paralelismo.

    Kafka resolve isso com particionamento por chave. Todas as mensagens com a mesma chave (por exemplo, o mesmo user_id) caem na mesma partition e são lidas em ordem, enquanto partitions diferentes são consumidas em paralelo por membros distintos de um consumer group. Você ganha ordem e paralelismo, ao custo de dimensionar o número de partitions com antecedência — aumentar depois é possível, mas quebra a garantia de ordenação para chaves já existentes.

    Em vazão bruta, Kafka opera em outra faixa. Escrita sequencial em disco e leitura em lote fazem centenas de milhares a milhões de mensagens por segundo serem viáveis num cluster modesto. RabbitMQ entrega dezenas de milhares por segundo com folga na maioria dos cenários; passar disso exige cuidado com filas longas, que degradam a latência porque o broker mantém estado por mensagem. A ordem de grandeza varia com hardware e tamanho de payload, mas a assimetria é real: Kafka foi desenhado para o regime de alto volume contínuo.

    Consumo com confirmação manual no RabbitMQ, o padrão que evita perder mensagem quando o worker morre no meio do processamento:

    channel.basic_qos(prefetch_count=10)
    for method, props, body in channel.consume('emails'):
        try:
            enviar(body)
            channel.basic_ack(method.delivery_tag)
        except Exception:
            channel.basic_nack(method.delivery_tag, requeue=True)

    Com prefetch_count baixo, o broker não despeja mil mensagens num worker lento; distribui conforme cada um confirma.

    Entrega: o que cada um garante de verdade

    Os dois oferecem at-least-once como padrão realista — a mensagem chega uma ou mais vezes, e o consumidor precisa ser idempotente. RabbitMQ confirma o consumo com ack; se o worker cai antes de confirmar, a mensagem volta à fila e pode ser entregue de novo. Kafka reprocessa a partir do último offset commitado; se o commit não aconteceu, a mesma faixa é relida.

    Kafka oferece ainda um modo exactly-once dentro do seu ecossistema, com produtores transacionais e commit de offset atrelado à escrita de saída. Funciona, mas custa complexidade e só vale a pena quando o processamento não pode ser idempotente por outros meios. Na prática, tornar o consumidor idempotente — uma chave de deduplicação, um upsert em vez de insert — resolve a maioria dos casos nos dois brokers e é mais barato que perseguir entrega exata na camada de transporte.

    Operação e custo de manter

    RabbitMQ é mais simples de subir e operar em escala pequena e média. Um nó único atende bem muitos projetos; clustering e filas espelhadas (ou quorum queues, hoje o caminho recomendado para alta disponibilidade) existem quando a durabilidade exige. A interface de administração é boa e o modelo mental — produtor, exchange, fila, consumidor — é direto.

    Kafka carrega mais peso operacional. Versões recentes eliminaram a dependência do ZooKeeper com o modo KRaft, o que simplificou o cluster, mas ainda há partitions para dimensionar, retenção para calibrar, fatores de replicação para definir e consumer groups para monitorar por lag. Nada disso é proibitivo, mas é trabalho contínuo que um broker de fila clássico não cobra. Rodar Kafka gerenciado por um provedor transfere boa parte desse custo, ao preço da conta mensal.

    Esse peso operacional muda de sinal conforme a maturidade do time. Uma equipe pequena que ainda não tem prática com sistemas distribuídos paga caro por cada partition mal dimensionada e por cada consumer group sem monitoramento de lag — problemas que aparecem em produção, sob carga, no pior momento. Já um time que roda observabilidade decente e trata o broker como infraestrutura de primeira classe absorve o custo do Kafka sem sobressalto e colhe o throughput. O ponto é honesto: a complexidade do Kafka não é um defeito, é o preço de recursos que o RabbitMQ não oferece. A pergunta certa não é qual é mais simples, e sim se o seu caso precisa daquilo que a complexidade compra. Escolher a ferramenta mais poderosa para um problema que não a exige é herdar o custo sem o benefício.

    Comparativo direto

    Critério RabbitMQ Kafka
    Modelo Fila: consome e apaga Log: retém e relê por offset
    Reprocessar histórico Não nativo Mover offset para trás
    Roteamento Rico (topic, fanout, direct) Simples (tópico + partition por chave)
    Ordem + paralelismo Excludentes na mesma fila Ambos, via partition por chave
    Vazão típica Dezenas de milhares/s Centenas de milhares/s ou mais
    Múltiplos leitores do mesmo dado Exige fanout e cópias Nativo (vários consumer groups)
    Custo operacional Baixo a médio Médio a alto

    Qual usar em qual cenário

    Use RabbitMQ quando o problema é distribuir tarefas para workers processarem e esquecerem: envio de notificações, geração de PDF, chamadas assíncronas a APIs externas, orquestração de jobs com roteamento por tipo. O modelo de fila com ack casa exatamente com esse trabalho, e o roteamento por exchange evita código de dispatch na aplicação. É também a escolha default quando o volume está na casa dos milhares por segundo e a simplicidade operacional pesa mais que o teto de throughput.

    Use Kafka quando o dado é um fluxo que vários sistemas precisam consumir de forma independente, quando o volume é alto e contínuo, ou quando reprocessar o histórico faz parte do desenho: pipelines de eventos, ingestão de métricas e logs, alimentação de um data lake, event sourcing, sincronização entre serviços por change data capture. A capacidade de reler e de ter múltiplos consumer groups sobre o mesmo tópico é o que justifica o custo operacional adicional.

    O erro comum é usar Kafka como fila de tarefas simples — herdando toda a complexidade sem usar o log — ou espremer RabbitMQ num pipeline de streaming de alto volume com múltiplos consumidores, recriando à mão o que o log entrega de graça. Se a decisão ainda estiver empatada, comece por RabbitMQ: o modelo mental é mais barato e migrar um caso de tarefa assíncrona para Kafka depois é viável; o contrário, desmontar um pipeline de streaming acoplado ao log, quase nunca é.

  • Arquitetura Multi-Tenant em SaaS: Como Isolar Dados e Escalar com Segurança

    Todo negócio de software que cresce chega ao mesmo dilema: como atender centenas ou milhares de clientes na mesma aplicação sem que um cliente enxergue — ou comprometa — os dados de outro. A resposta passa por uma decisão de arquitetura que define custo, segurança e capacidade de escala do produto: o modelo multi-tenant. Neste guia, explicamos de forma prática como isolar dados, escolher a estratégia certa de isolamento e evitar os erros que travam o crescimento de plataformas SaaS.

    O que é arquitetura multi-tenant e por que ela importa

    Multi-tenancy é o modelo em que uma única instância da aplicação atende múltiplos clientes, chamados de tenants (inquilinos). Cada tenant enxerga apenas seus próprios dados e configurações, mas todos compartilham a mesma base de código e, dependendo da estratégia, a mesma infraestrutura.

    Para uma empresa de SaaS, ERP ou CRM, esse modelo é o que torna o negócio economicamente viável. Manter uma instalação separada para cada cliente — o chamado single-tenant — multiplica custos de servidor, deploy e manutenção. Com multi-tenancy, uma correção de bug ou uma nova funcionalidade é entregue a todos de uma só vez. O desafio é que essa eficiência só compensa se o isolamento entre tenants for sólido. Uma falha que exponha dados de um cliente a outro pode encerrar a confiança no produto de forma irreversível.

    Vale distinguir multi-tenancy de simples “multiusuário”. Um sistema multiusuário permite que várias pessoas acessem a mesma conta ou organização. Multi-tenancy vai além: separa organizações inteiras, cada uma com seus usuários, permissões, dados e, muitas vezes, personalizações visuais. É essa separação por organização que exige um cuidado arquitetural especial.

    As três estratégias de isolamento de dados

    Não existe um único jeito de separar os dados dos tenants. Há três abordagens principais, e a escolha impacta diretamente custo, segurança e complexidade operacional.

    Banco de dados separado por tenant

    Cada cliente tem seu próprio banco de dados. É o maior nível de isolamento: um problema de consulta ou de segurança fica contido em um único cliente, e restaurar o backup de um tenant não afeta os demais. A desvantagem é o custo e a complexidade — centenas de bancos exigem automação robusta de provisionamento, migração e monitoramento. É a escolha típica para clientes enterprise com exigências regulatórias fortes.

    Schema separado no mesmo banco

    Todos os tenants ficam no mesmo banco, mas cada um em seu próprio schema (conjunto de tabelas). O isolamento é bom e o custo é intermediário. Migrações precisam ser aplicadas em cada schema, o que exige orquestração cuidadosa quando a base de clientes cresce. É um meio-termo popular para plataformas que atendem centenas de contas de porte médio.

    Tabela compartilhada com coluna de tenant

    Todos os tenants dividem as mesmas tabelas, e cada linha carrega um identificador de tenant (por exemplo, tenant_id). É o modelo mais barato e simples de escalar, mas o mais delicado em segurança: basta esquecer o filtro por tenant em uma única consulta para vazar dados. Exige disciplina de engenharia e camadas automáticas que garantam o filtro em toda operação.

    Na prática, muitas plataformas combinam abordagens: tabela compartilhada para a maioria dos clientes e banco dedicado para os contratos que exigem isolamento reforçado. A arquitetura ideal é a que permite conviver com mais de um modelo ao mesmo tempo.

    Como garantir o isolamento na prática

    Escolher a estratégia é só o começo. O isolamento real vem das camadas de proteção que você constrói ao redor dos dados:

    • Filtro automático por tenant: nunca dependa de o desenvolvedor lembrar de adicionar WHERE tenant_id = ?. Use middlewares, escopos globais ou row-level security do banco para aplicar o filtro de forma transparente.
    • Contexto de tenant na requisição: identifique o tenant no início de cada requisição (via subdomínio, token ou cabeçalho) e propague esse contexto por toda a aplicação.
    • Testes de vazamento: crie testes automatizados que simulam um tenant tentando acessar dados de outro e verificam que a resposta é sempre negada.
    • Chaves de criptografia por tenant: em cenários sensíveis, criptografe dados com chaves distintas por cliente, de modo que um acesso indevido ao banco não revele conteúdo legível.
    • Auditoria e logs segmentados: registre quem acessou o quê, sempre com o tenant identificado, para investigar incidentes com precisão.

    Um princípio útil é o de “isolamento por padrão”: o sistema deve negar acesso a menos que o contexto de tenant esteja presente e válido. Assim, um esquecimento não resulta em vazamento, e sim em erro visível durante o desenvolvimento, quando ainda é barato corrigir.

    Escalando sem perder performance

    À medida que a base cresce, alguns tenants ficam muito maiores que outros — o chamado problema do “vizinho barulhento”, em que um cliente pesado degrada a experiência dos demais. Estratégias que ajudam a manter a performance incluem o sharding, que distribui tenants entre diferentes bancos ou servidores conforme o volume; a separação de planos, movendo clientes enterprise para infraestrutura dedicada; e o uso de índices que sempre começam pela coluna de tenant, acelerando as consultas mais comuns. Cache por tenant e limites de uso (rate limiting) por cliente também evitam que um pico isolado afete todo o sistema.

    Um ponto frequentemente esquecido é o provisionamento automatizado. Quando um novo cliente assina, criar o schema, aplicar migrações e configurar dados iniciais deve ser um processo automático e idempotente. Fazer isso manualmente não escala e abre espaço para erros que quebram o isolamento. O mesmo vale para o processo inverso: quando um cliente cancela, é preciso ter um fluxo claro de exportação e exclusão de dados que respeite prazos legais e não deixe resíduos em tabelas compartilhadas.

    Erros comuns que travam o crescimento

    Muitas plataformas descobrem tarde demais que sua arquitetura não acompanha o crescimento. Entre os deslizes mais frequentes estão começar com tabela compartilhada sem uma camada sólida de filtro automático, deixar migrações manuais que se tornam impraticáveis com centenas de tenants, e não planejar como migrar um cliente grande para infraestrutura isolada quando ele exigir. Outro erro é tratar todos os clientes igualmente: clientes com necessidades de compliance específicas frequentemente pedem isolamento mais forte, e a arquitetura precisa permitir conviver com diferentes níveis ao mesmo tempo.

    Há ainda o risco de subestimar a observabilidade. Sem métricas por tenant, fica impossível saber qual cliente consome mais recursos, qual está próximo de um limite ou qual seria beneficiado por um plano superior. Monitorar por tenant não é só uma questão técnica: é também uma fonte de inteligência comercial que orienta preços e upgrades.

    Perguntas Frequentes

    Qual estratégia de isolamento é a mais segura?

    O banco separado por tenant oferece o maior isolamento, pois uma falha fica contida em um único cliente. Porém, “mais seguro” também depende das camadas de proteção construídas ao redor. Uma tabela compartilhada bem protegida com row-level security pode ser mais segura que um banco separado mal monitorado.

    É possível começar simples e migrar depois?

    Sim, e é o caminho de muitos SaaS. O importante é já nascer com o conceito de tenant embutido no modelo de dados e com filtro automático desde o primeiro dia. Assim, migrar de tabela compartilhada para schema ou banco separado torna-se uma evolução, não uma reescrita.

    Como lidar com o problema do “vizinho barulhento”?

    Combine limites de uso por tenant, monitoramento por cliente e a possibilidade de mover tenants pesados para infraestrutura dedicada. Sharding também ajuda a distribuir a carga antes que um único cliente comprometa a experiência geral.

    Multi-tenancy serve para clientes que exigem dados isolados por lei?

    Serve, desde que a arquitetura suporte diferentes níveis de isolamento. Clientes com exigências regulatórias costumam receber banco ou até infraestrutura dedicada, enquanto os demais permanecem em modelos compartilhados mais econômicos.

    Quanto o modelo multi-tenant reduz custos?

    A economia vem de compartilhar infraestrutura, deploy e manutenção entre muitos clientes. O ganho é maior quanto mais tenants você atende por instância, mas parte dessa economia deve ser reinvestida em automação e segurança para que o isolamento continue confiável conforme a base cresce.

    Preciso de personalização visual por tenant?

    Depende do público. Plataformas B2B costumam oferecer logotipo, cores e domínio próprio por tenant como diferencial. Isso pode ser resolvido com configurações por cliente, sem duplicar código, desde que a arquitetura separe claramente dados de configuração de dados de negócio.

  • Arquitetura Multi-Tenant: Como Escalar seu SaaS sem Perder Segurança e Desempenho

    Todo SaaS que cresce chega, mais cedo ou mais tarde, à mesma pergunta: como atender centenas ou milhares de clientes na mesma plataforma sem que um afete o outro? A resposta está na arquitetura multi-tenant — o modelo em que uma única instância da aplicação serve vários clientes (os tenants) de forma isolada. Escolher bem esse desenho no início evita retrabalho caro e problemas de segurança quando a base de usuários explode.

    Este guia é para times de produto e desenvolvimento que estão construindo ou reestruturando plataformas SaaS, ERPs e CRMs e precisam decidir como organizar dados e recursos de múltiplos clientes de maneira sustentável.

    O que é multi-tenancy e por que ela define o futuro do seu SaaS

    Multi-tenancy é o princípio de compartilhar a mesma infraestrutura e o mesmo código entre diversos clientes, mantendo os dados de cada um logicamente separados. Em vez de subir um servidor e um banco para cada empresa que assina o produto, você opera uma plataforma central que reconhece a qual tenant cada requisição pertence e responde apenas com os dados daquele cliente.

    O ganho é direto: menos custo de infraestrutura por cliente, atualizações aplicadas uma única vez para todos e operação muito mais simples. O desafio também é claro: um erro de isolamento pode expor dados de um cliente para outro, o pior pesadelo de qualquer plataforma. Por isso, a decisão de arquitetura não é apenas técnica, é estratégica.

    Os três modelos de isolamento de dados

    Existem basicamente três abordagens para separar os dados dos tenants, cada uma com um equilíbrio diferente entre custo, isolamento e complexidade operacional.

    Banco de dados compartilhado, schema compartilhado

    Todos os tenants vivem nas mesmas tabelas, diferenciados por uma coluna tenant_id. É o modelo mais barato e simples de escalar, mas exige disciplina absoluta: toda consulta precisa filtrar pelo tenant, e um esquecimento vira vazamento. Ideal para produtos com muitos clientes pequenos e planos de baixo ticket.

    Banco compartilhado, schema por tenant

    Cada cliente tem seu próprio conjunto de tabelas dentro do mesmo banco. O isolamento é maior e as customizações ficam mais fáceis, mas a manutenção de migrações se multiplica pela quantidade de schemas. Funciona bem em bases de dezenas a poucas centenas de clientes.

    Banco de dados por tenant

    Cada cliente recebe um banco dedicado. É o modelo de maior isolamento, exigido em setores regulados e por clientes enterprise que precisam de garantias fortes de segregação. Em contrapartida, é o mais caro e o mais complexo de operar em escala. Muitos SaaS adotam um modelo híbrido: clientes menores em banco compartilhado e contas enterprise em banco dedicado.

    Modelo Isolamento Custo por tenant Melhor para
    Schema compartilhado Baixo Muito baixo Muitos clientes pequenos
    Schema por tenant Médio Médio Base média com customizações
    Banco por tenant Alto Alto Enterprise e setores regulados

    Segurança: o ponto onde não existe negociação

    Independentemente do modelo, o isolamento precisa ser garantido em camadas e não confiar apenas na aplicação. Algumas práticas que separam plataformas maduras das improvisadas:

    • Filtro obrigatório por tenant: centralize a lógica de escopo em uma camada única, para que nenhuma query saia sem o identificador do cliente.
    • Row-Level Security: use recursos do próprio banco para bloquear acesso a linhas de outros tenants, criando uma segunda linha de defesa.
    • Chaves e credenciais isoladas: dados sensíveis criptografados com chaves específicas por cliente reduzem o impacto de qualquer incidente.
    • Testes automatizados de vazamento: inclua casos que tentam acessar dados de outro tenant e falham por padrão.

    Auditoria e logs por tenant também são essenciais: quando algo dá errado, você precisa reconstruir exatamente o que aconteceu para cada cliente.

    Desempenho e o problema do vizinho barulhento

    Em ambientes compartilhados, um cliente que dispara relatórios pesados pode degradar a experiência dos demais — o clássico noisy neighbor. Para controlar isso, plataformas maduras aplicam limites de uso por tenant (rate limiting), filas isoladas para tarefas pesadas e monitoramento que mostra o consumo de cada cliente separadamente. Assim, é possível identificar quem está pressionando o sistema e agir antes que o problema se espalhe.

    Estratégias de cache com chave por tenant, índices bem planejados incluindo o tenant_id e a possibilidade de escalar horizontalmente distribuindo clientes entre diferentes servidores completam o desenho de uma operação que cresce sem sustos.

    Como decidir o modelo certo para o seu produto

    Não existe resposta única. A escolha depende do ticket médio, do perfil do cliente e das exigências de conformidade. Produtos self-service com milhares de contas tendem ao schema compartilhado pela eficiência; plataformas B2B enterprise caminham para bancos dedicados pela exigência de isolamento. O caminho mais seguro é começar simples, medir e evoluir para um modelo híbrido conforme surgem clientes com necessidades específicas — sem tentar resolver problemas de escala que você ainda não tem.

    Onboarding e provisionamento de novos clientes

    Em uma plataforma multi-tenant, a experiência de cadastro de um novo cliente precisa ser praticamente automática. Quando alguém assina o produto, o sistema deve provisionar o ambiente daquele tenant sozinho: criar os registros iniciais, aplicar as configurações padrão e liberar o acesso em segundos. Processos manuais nessa etapa não escalam e viram gargalo assim que o volume de novos clientes cresce.

    Um bom fluxo de onboarding também define desde o início a qual modelo de isolamento aquele cliente pertence, quais recursos ele pode usar conforme o plano contratado e como seus dados serão organizados. Automatizar o provisionamento reduz erros humanos e garante que cada novo tenant nasça dentro dos padrões de segurança da plataforma.

    Customização sem comprometer o núcleo do sistema

    Clientes diferentes pedem ajustes diferentes, e o desafio é atender a isso sem transformar o código em uma colcha de retalhos. A saída é oferecer customização por configuração, não por alteração do núcleo: campos personalizados, regras ajustáveis e preferências que ficam guardadas por tenant, sem exigir uma versão separada da aplicação para cada cliente. Assim, a plataforma continua única e fácil de manter, enquanto cada cliente sente que o produto se adapta à sua realidade. Esse equilíbrio entre padronização e flexibilidade é uma das marcas de um SaaS multi-tenant bem projetado.

    Perguntas Frequentes

    O que significa tenant em um SaaS?

    Tenant é cada cliente ou organização que usa a plataforma. Em uma arquitetura multi-tenant, todos compartilham a mesma aplicação, mas cada tenant enxerga apenas os seus próprios dados, de forma totalmente isolada dos demais.

    Multi-tenant é mais barato que single-tenant?

    Em geral, sim. Como a infraestrutura e o código são compartilhados, o custo por cliente cai bastante e as atualizações são aplicadas uma única vez. O single-tenant, com instância dedicada por cliente, oferece isolamento máximo, mas eleva custo e complexidade operacional.

    Como garantir que um cliente não veja os dados de outro?

    Combine camadas: uma lógica central que sempre filtra pelo identificador do tenant, recursos de segurança no nível do banco de dados, criptografia de dados sensíveis e testes automatizados que tentam acessar dados de outro cliente e devem falhar. Nunca confie em um único ponto de controle.

    Posso misturar modelos de isolamento na mesma plataforma?

    Sim, e é uma prática comum. Muitos SaaS mantêm clientes menores em banco compartilhado e oferecem banco dedicado para contas enterprise que exigem isolamento mais forte. Esse modelo híbrido equilibra custo e conformidade.

    Quando devo me preocupar com o problema do vizinho barulhento?

    Assim que múltiplos clientes ativos compartilharem os mesmos recursos. Aplicar limites de uso por tenant, isolar tarefas pesadas em filas e monitorar o consumo individual desde cedo evita que um cliente comprometa o desempenho dos demais.