Destino Dev

Blog

Outbox transacional com Spring Boot, JPA e Kafka — implementação prática

Por John Pires · 14 de março de 2026

Outbox transacional com Spring Boot, JPA e Kafka — implementação prática

Motivacao e trade-offs

Por que o padrão Outbox existe

Em arquiteturas distribuídas, garantir que uma atualização de banco de dados local e a publicação de um evento para um broker (Kafka) aconteçam atomicamente é um problema clássico. Sistemas que tentam publicar diretamente no Kafka dentro da mesma transação do banco enfrentam falhas parciais: commit do DB ok, falha ao publicar; ou publicação ok, falha no commit. O padrão outbox resolve isso gravando o evento na mesma transação do banco e deixado para um componente externo publicar de forma confiável para o broker.

Trade-offs práticos

O uso do outbox evita a necessidade de transações distribuídas (XA), reduz complexidade operacional e melhora a confiabilidade. Em troca, introduz latência (o publicador lê a tabela e publica depois), necessidade de idempotência no consumidor e trabalho operacional para manter o poller/performer. Decidir o intervalo do poller e o tamanho do batch envolve trade-off entre latência e carga no DB.

Alternativas ao outbox

Existem alternativas: usar transações XA (complexo e pesado), usar Kafka Connect com Debezium (captura de CDC e publica no Kafka sem poller custom), ou enviar eventos via filas locais. Cada alternativa tem implicações em latência, consistência e custo operacional.

Quando escolher outbox

Escolha outbox quando quiser garantir atomicidade entre mudanças do banco e eventos sem XA e sem depender de infra adicional como Debezium. É recomendável para domínios em que eventual consistência é aceitável e quando você pode lidar com duplicatas no consumidor.

Fundamentos técnicos

Arquitetura básica

A arquitetura típica com outbox tem três peças: (1) a aplicação grava mudanças de negócio e uma linha na tabela outbox dentro da mesma transação; (2) um publisher (poller ou log-compactor) busca linhas não publicadas e envia ao Kafka; (3) após confirmação, o publisher marca as linhas como publicadas (ou as remove). O ponto central é que o DB é a fonte da verdade para eventos pendentes.

Modelos de implantação do publisher

O publisher pode ser: (a) um componente no mesmo serviço (thread local, scheduler), (b) um microserviço separado que executa polling, ou (c) Debezium/Log-based CDC que evita polling. Cada modelo tem impacto em disponibilidade, escalabilidade e disponibilidade de leitura do outbox.

Garantias e idempotência

Outbox entrega pelo menos uma vez (at-least-once) por natureza do poller: se o publish falhar no momento de marcar como enviado, o evento será reenviado. Isso exige idempotência do consumidor ou uso de chaves/ids de mensagem para deduplicação. No Kafka, habilitar producer.idempotence reduz duplicatas causadas pelo producer, mas não elimina duplicidade por reenvio do poller.

Configurações Kafka relevantes

Do lado do producer, habilite enable.idempotence=true, ajuste acks=all e defina retries adequados. Para throughput alto, use batches e compressão. Se quiser transações Kafka, o producer pode usar transactions, mas combinar com DB ACID exige soluções adicionais e não é coberto por outbox puro.

Exemplo 1: caso simples

Contexto e objetivo

Exemplo mínimo: gravar um pedido (Order) e emitir um evento OrderCreated no Kafka usando outbox. Objetivo: mostrar a gravação atômica do pedido + evento na tabela outbox e um poller simples que publica para o Kafka e marca como enviado.

Entidade OutboxMessage

O código abaixo define a entidade JPA para a tabela outbox. Ela contém payload JSON, tipo do evento, chave para Kafka, status e timestamps.

package com.example.outbox;

import javax.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "outbox_message")
public class OutboxMessage {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String topic;
    private String key;

    @Lob
    private String payload;

    @Enumerated(EnumType.STRING)
    private Status status = Status.PENDING;

    private Instant createdAt = Instant.now();

    public enum Status { PENDING, SENT, FAILED }

    // getters e setters omitidos para brevidade
}

Depois de salvo na mesma transação que a entidade de domínio, a linha estará disponível para o poller. O campo status controla o estado do envio.

Repository e serviço de escrita

Um repository JPA simples e um serviço que, dentro de @Transactional, salva a entidade de negócio e cria a outbox. Assim garantimos atomicidade local com JPA/Hibernate.

package com.example.outbox;

import org.springframework.data.jpa.repository.JpaRepository;

public interface OutboxRepository extends JpaRepository<OutboxMessage, Long> {
}

package com.example.order;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepo;
    private final OutboxRepository outboxRepo;

    public OrderService(OrderRepository orderRepo, OutboxRepository outboxRepo) {
        this.orderRepo = orderRepo;
        this.outboxRepo = outboxRepo;
    }

    @Transactional
    public void createOrder(Order order) {
        orderRepo.save(order);

        OutboxMessage msg = new OutboxMessage();
        msg.setTopic("orders");
        msg.setKey(order.getId().toString());
        msg.setPayload("{...json...}");

        outboxRepo.save(msg);
    }
}

Ao salvar o Order e a OutboxMessage dentro da mesma transação, garantimos que não haverá evento sem o estado do domínio correspondente.

Poller simples que publica para Kafka

Este componente roda em intervalos e publica mensagens pendentes. Ele processa em batch e usa transações DB para marcar como SENT depois do publish.

package com.example.outbox;

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Component
public class OutboxPoller {

    private final OutboxRepository outboxRepo;
    private final KafkaTemplate<String, String> kafkaTemplate;

    public OutboxPoller(OutboxRepository outboxRepo, KafkaTemplate<String, String> kafkaTemplate) {
        this.outboxRepo = outboxRepo;
        this.kafkaTemplate = kafkaTemplate;
    }

    @Scheduled(fixedDelayString = "${outbox.poller.delay-ms:5000}")
    public void poll() {
        List<OutboxMessage> batch = outboxRepo.findAll(); // filtre PENDING na prática
        for (OutboxMessage m : batch) {
            try {
                kafkaTemplate.send(m.getTopic(), m.getKey(), m.getPayload()).get();
                markSent(m.getId());
            } catch (Exception e) {
                // log e marcar como FAILED eventualmente
            }
        }
    }

    @Transactional
    public void markSent(Long id) {
        OutboxMessage m = outboxRepo.findById(id).orElseThrow();
        m.setStatus(OutboxMessage.Status.SENT);
        outboxRepo.save(m);
    }
}

O método poll() demonstra a lógica, mas em produção você filtraria por PENDING, usaria paginação, e lidaria com concorrência e backoff.

Exemplo 2: caso avancado do mundo real

Contexto realista e requisitos

Num sistema de pagamentos: precisamos garantir que eventos de pagamento sejam publicados com baixa latência, tolerância a falhas e sem perdas. Requisitos extras: alto throughput, processamento ordenado por conta e tolerância a reinícios do poller.

Design aplicado

Para atender requisitos, combinamos:

  • tabela outbox particionada por shard/partitionKey (accountId) para preservar ordenação por chave;
  • poller concorrente com locking por partitionKey (optimistic locking ou select ... for update skip locked) para evitar dupla publicação;
  • producer Kafka com idempotence e acks=all;
  • marcação por lote com timestamps e tentativas limitadas.

Exemplo de poller com select for update skip locked

Usar SELECT FOR UPDATE SKIP LOCKED (em bancos que suportam) permite que múltiplos workers concorram sem duplicar trabalho. O código abaixo exemplifica um método JPA nativo para capturar um lote seguro.

package com.example.outbox;

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface OutboxNativeRepository extends CrudRepository<OutboxMessage, Long> {

    @Query(value = "SELECT * FROM outbox_message WHERE status = 'PENDING' AND partition_key = :pk ORDER BY id LIMIT :limit FOR UPDATE SKIP LOCKED", nativeQuery = true)
    List<OutboxMessage> fetchAndLock(@Param("pk") String partitionKey, @Param("limit") int limit);
}

Esse padrão reduz colisões entre nodes e preserva ordenação por partitionKey, importante para pagamentos e balances.

Tratamento de falhas e retries

Se o publish falhar, aumente um contador de tentativas e exponha erros para alerting. Considere mover mensagens que excederem retry max para uma tabela dead_letter para análise humana. Evite reprocessar imediatamente para não sobrecarregar Kafka ou o DB.

Debugging e troubleshooting

Erros comuns e armadilhas

Listagem de problemas que aparecem na prática:

  1. Poller encontra muitas linhas e gera picoteamento no DB: use paginação e limites simples.
  2. Deadlocks com SELECT FOR UPDATE: ajuste tamanho do batch e ordem de acesso a chaves.
  3. Mensagens duplicadas no consumidor: não trate isso como bug do outbox — implemente idempotência no consumer.
  4. Esquecer de filtrar apenas PENDING: marcar mensagens indevidamente pode causar perda.
Dica: prefira marcar como SENT só depois do futuro.get() do kafkaTemplate; use producer idempotence para reduzir duplicatas.

Como depurar falhas de publicação

Passos práticos para investigação:

  • verifique o status da mensagem na tabela outbox e timestamps;
  • log do producer (enable DEBUG em org.apache.kafka.clients.producer) para ver retries e erros;
  • confirme configurações de broker (acks e quotas) e conectividade de rede;
  • se usar SELECT...FOR UPDATE SKIP LOCKED, confirme que o banco suporta e que isolation level é adequado.

Ferramentas úteis

Use estas ferramentas para acelerar identificação de problemas:

  • kafka-console-consumer para inspecionar tópicos reais;
  • JMX do Kafka para métricas de producer/consumer;
  • logs estruturados (traceId/correlationId) para correlação entre gravação do domínio e publicação;
  • painéis Grafana com métricas do poller e fila de outbox.

Erros comuns, armadilhas e dicas práticas

Principal armadilha: confiar em envio síncrono dentro da transação

Muitos tentam usar kafkaTemplate.send() dentro de um @Transactional e esperar que seja atômico. Isso falha porque o Kafka e o banco não compartilham a mesma transação. Se precisar esperar commit antes de enviar, use TransactionSynchronizationManager para enfileirar pós-commit, mas isso ainda não resolve crashes entre commit e envio.

Use TransactionSynchronizationManager com cautela

Exemplo de registro pós-commit: registrar um callback que publica após o commit. Bom para reduzir latência, ruim para recuperação em caso de crash entre commit e publicação. Prefira outbox se você precisa de recuperação garantida.

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
        kafkaTemplate.send("topic", key, payload);
    }
});

Esse trecho demonstra como executar lógica após o commit. Mas lembre-se: se a máquina morrer depois do commit e antes do envio, o evento será perdido.

Escalonamento e manutenção

Mantenha rotinas de limpeza para mensagens antigas (archiving) e monitore backlog de PENDING. Para grandes volumes, particione a tabela outbox por tempo ou chave para melhorar performance.

Conclusão prática

Outbox é um padrão sólido para conciliar consistência local e publicação de eventos em sistemas distribuídos sem recorrer a XA. Ele shift-a responsabilidade para um pipeline confiável de publicação e para o consumidor, que deve ser tolerante a duplicatas.

Resumo das recomendações:

  • salve domínio e outbox na mesma transação JPA;
  • use um poller resiliente (batch, skip-locked, backoff);
  • configure Kafka producer com idempotence e acks=all;
  • trate duplicatas no consumidor; implemente dead-letter para mensagens irrecuperáveis;
  • monitore métricas do outbox e do poller para detectar backlog.

Implementações mostradas aqui são pontos de partida. Em produção, ajuste locks, timeouts e padrões de retry conforme seu SLA. A combinação de outbox + poller oferece um balanço pragmático entre complexidade e garantia de entrega, e é uma das melhores práticas para integrar bancos relacionais com brokers como Kafka em microservices.

Leitura adicional: procure por artigos sobre Debezium CDC, kafka exactly-once semantics (EOS), e patterns de idempotência em consumidores para complementar esta abordagem.

Assuntos: Java, spring-boot, jpa, kafka, outbox, microservices

Leia também