Postgres WAL para Event Sourcing: Quando Menos Infraestrutura é Suficiente
A adoção de arquiteturas event-driven frequentemente começa com uma decisão: colocar Kafka na stack ou buscar alternativas mais simples. Essa escolha define a complexidade operacional, latência, custos e acoplamento da sua aplicação pelos próximos anos.
PostgreSQL oferece uma abordagem menos óbvia mas tecnicamente sólida: capturar mudanças diretamente do Write-Ahead Log através de logical replication. Não é solução universal. Para cenários específicos — sistemas que já dependem fortemente de Postgres, volumes moderados, equipes pequenas — pode eliminar camadas inteiras de infraestrutura. A questão não é “qual é melhor”, mas “quando os trade-offs compensam”.
Este artigo explora como logical decoding funciona internamente, os trade-offs práticos versus message brokers, e especialmente os operational concerns que raramente aparecem em tutoriais mas causam problemas reais em produção.
Como Logical Replication Captura Mudanças no WAL
Desde o PostgreSQL 10 (2017), o banco oferece logical replication como feature nativa. O funcionamento se baseia em três componentes: o Write-Ahead Log configurado para modo lógico, replication slots que garantem persistência das mudanças, e output plugins que convertem o formato binário do WAL em algo consumível.
O WAL sempre existiu no Postgres para garantir durabilidade — toda mudança é escrita no log antes de modificar as páginas de dados. O que mudou? A capacidade de decodificar essas mudanças em nível lógico (linha por linha) em vez de físico (blocos de disco). Com wal_level=logical, o banco inclui informações suficientes no WAL para reconstruir as operações DML: qual tabela mudou, quais colunas, valores antigos e novos.
Replication slots funcionam como cursores persistentes sobre o WAL. Quando você cria um slot, o Postgres garante que o WAL não será deletado até esse slot consumir as mudanças. O LSN (Log Sequence Number) funciona como offset — após processar mudanças até LSN 0/16B37F8, você pode reconectar posteriormente e continuar de onde parou. Essa garantia é crucial, mas tem um custo: slots inativos podem consumir disk space indefinidamente se não forem monitorados.
Output plugins transformam o WAL em formatos estruturados. O pgoutput é nativo e usado como default pelo Debezium desde a versão 1.0. O wal2json converte para JSON com suporte a filtros por schema e tabelas. O test_decoding existe para desenvolvimento e testes. Cada plugin tem trade-offs de performance — desserialização JSON no consumer é tipicamente o bottleneck maior que a captura em si.
-- Configuração básica no postgresql.conf
wal_level = logical
max_replication_slots = 20
max_wal_senders = 20
-- Criar slot e começar a consumir
SELECT pg_create_logical_replication_slot('my_slot', 'wal2json');
-- Consumir mudanças (pode ser feito via pg_recvlogical ou Debezium)
SELECT * FROM pg_logical_slot_get_changes('my_slot', NULL, NULL);
A latência típica entre uma transação commit e a captura da mudança fica entre 1-10ms em condições normais, segundo a documentação do Debezium e case studies da Zalando. Isso é mais rápido que uma roundtrip Kafka típica (5-50ms incluindo producer e consumer), mas existem nuances importantes nessa comparação.
Trade-offs versus Message Brokers: Latência vs Flexibilidade
A principal vantagem do WAL-based CDC é eliminar um salto na arquitetura. Aplicação escreve no Postgres, consumers leem diretamente do WAL — sem serializar, enviar para Kafka, persistir e deserializar. Shopify reportou redução de latência de minutos para segundos ao migrar de polling para logical replication. Para use cases onde baixa latência importa mais que durabilidade longa, isso pode ser decisivo.
Debezium processa tipicamente 10k-50k eventos por segundo por conector, dependendo da configuração. O overhead no banco fica abaixo de 5% de CPU adicional em cargas normais. O tamanho do WAL aumenta 20-30% comparado a wal_level=replica, mas isso raramente é limitante em infraestrutura moderna. Instagram e Zalando rodam isso em produção processando milhões de eventos por dia.
As limitações surgem quando você precisa de características específicas de message brokers. PostgreSQL permite um slot por consumer — não existe o conceito de consumer groups do Kafka onde múltiplos consumidores dividem o trabalho automaticamente. Se você precisa de 10 consumers independentes, precisa de 10 slots. O max_replication_slots default é 10 e requer ajuste manual.
Retention é outro ponto crítico. Slots do Postgres retêm WAL enquanto houver disk space. Kafka oferece retention configurável por dias ou tamanho, permitindo time-travel e reprocessamento histórico. Descobriu um bug e precisa processar eventos de 30 dias atrás? Kafka torna isso trivial. Com WAL slots, o histórico já foi deletado.
Schema evolution é mais complexa. Kafka com Schema Registry oferece versionamento e compatibilidade automática. Logical decoding captura apenas DML — DDL changes não aparecem no stream. Se você adiciona uma coluna, precisa coordenar manualmente entre consumers. Não existe um mecanismo nativo para sinalizar “schema mudou, se adapte”.
A escala horizontal também diverge fundamentalmente. Kafka particiona topics e distribui load entre brokers. Logical replication está limitada a um único Postgres. Você pode ter múltiplos databases e agregar streams no application layer, mas isso requer estratégia customizada. Para volumes que excedem o que uma instância Postgres consegue, message brokers separados fazem mais sentido.
O acoplamento é filosófico mas importante. WAL-based CDC aumenta a dependência do banco de dados como sistema central. Se Postgres cai, seu event streaming também cai. Message brokers independentes oferecem uma camada de isolamento — o banco pode ter problemas enquanto consumers continuam processando eventos do buffer. Para sistemas críticos, essa separação pode justificar a complexidade adicional.
Operational Concerns: O que Quebra em Produção
A maioria dos tutoriais termina após configurar o slot e consumir alguns eventos. Produção começa quando você enfrenta os problemas que a documentação menciona brevemente.
Slots inativos são o problema mais comum. Se um consumer morre e não remove o slot, o Postgres continua retendo WAL indefinidamente. Isso consome disk até causar outage. A view pg_replication_slots mostra todos os slots e seus LSNs. A coluna confirmed_flush_lsn indica até onde o consumer processou. Se a diferença entre pg_current_wal_lsn() e confirmed_flush_lsn cresce constantemente, você tem um problema.
-- Monitorar lag de slots
SELECT slot_name,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) as lag
FROM pg_replication_slots
WHERE active = false;
-- Dropar slot morto (cuidado: perde dados não processados)
SELECT pg_drop_replication_slot('dead_slot');
Snapshot inicial é outra fonte de problemas. Quando você cria um slot para uma tabela grande existente, o Debezium precisa ler todos os dados antes de começar a processar mudanças incrementais. Em tabelas com centenas de milhões de linhas, isso pode levar dias e causar CPU spikes. A documentação do Debezium menciona isso claramente, mas muitas equipes só descobrem ao tentar em produção.
WAL disk I/O pode saturar quando replication lag aumenta. Se consumers ficam lentos e acumulam backlog, o banco continua gerando WAL. Em sistemas com write-heavy workload, isso pode exaurir IOPS e afetar performance geral. Network I/O no consumer side também importa — desserializar JSON é CPU-intensive. Múltiplos case studies identificam essa desserialização como bottleneck principal.
Amazon RDS suporta logical replication mas não permite custom output plugins. Você fica limitado ao pgoutput. Isso não é necessariamente problemático — Debezium funciona bem com ele — mas elimina opções como wal2json com filtros customizados. Se sua arquitetura depende de features específicas de um plugin, RDS não será viável.
Failover e high availability requerem planejamento. Slots são locais à instância. Se você faz failover para uma réplica, os slots não existem lá. Você precisa recriar slots e potencialmente perder eventos não processados, ou implementar mecanismos de sincronização de slots entre instâncias. Nenhum desses cenários é simples.
A realidade é que WAL-based CDC funciona extremamente bem em 80% dos casos. Mas os 20% restantes — volumes muito altos, múltiplos consumers independentes, necessidade de long retention, ambientes multi-datacenter — são onde message brokers especializados justificam sua complexidade. A escolha correta depende de onde seus requisitos caem nesse espectro.
Logical replication do PostgreSQL não é uma alternativa universal ao Kafka — é uma ferramenta diferente para problemas diferentes. Quando você já tem Postgres como dependência central, volumes moderados (dezenas de milhares de eventos por segundo), e precisa de latência mínima sem adicionar infraestrutura, o WAL oferece uma solução tecnicamente sólida. O overhead é pequeno, a integração é direta, e elimina camadas de serialização.
Os trade-offs críticos são flexibilidade e escala. Você ganha simplicidade e latência, mas perde retention longa, consumer groups nativos, schema evolution robusta e scaling horizontal automático. Operacionalmente, requer monitoramento ativo de slots, planejamento de disk space para snapshots iniciais, e estratégias claras para failover.
Se você está começando um sistema event-driven e já tem Postgres, comece com logical replication. É mais simples de operar e provavelmente suficiente. Quando os limites aparecerem — e aparecem para sistemas que crescem — você terá contexto real para justificar a complexidade de message brokers dedicados. Arquitetura não é sobre escolher a ferramenta “melhor”, mas sobre entender quando os trade-offs de cada uma compensam.