Pular para o conteúdo

Message Outbox

Um serviço muda seu estado local e precisa notificar os demais publicando um Domain Event. A tentação é:

orderRepository.save(order); // 1. commit no banco
kafkaTemplate.send("orders", ev); // 2. publica no broker

Isso são duas operações em dois sistemas transacionais distintos sem coordenação atômica. Falhas possíveis:

  • Commit no banco OK, processo crasha antes do send → estado mudou, evento perdido. Consumidores nunca ficam sabendo.
  • send OK, mas a transação do banco faz rollback → evento publicado sobre um estado que não existe.

Não dá para colocar banco e Kafka na mesma transação ACID (seria 2PC, que não escala e nem sempre é suportado). A janela entre as duas escritas é a fonte da inconsistência.

  1. Escrita atômica: na mesma transação local que altera o agregado, insira uma linha na tabela outbox com o payload do evento. Como é o mesmo banco, ambas as escritas commitam ou fazem rollback juntas.
  2. Relay (message relay): um processo separado lê as linhas não publicadas da outbox e as envia ao broker, marcando-as como enviadas (ou removendo). Duas estratégias:
    • Polling publisher: uma tarefa agendada faz SELECT ... WHERE published = false periodicamente. Simples, mas gera latência e carga de polling.
    • CDC (Change Data Capture): uma ferramenta como Debezium lê o log de transações (WAL/binlog) e transforma inserts na outbox em mensagens. Baixa latência, sem polling, mas mais infraestrutura.

O relay garante at-least-once delivery: um evento pode ser publicado mais de uma vez (ex.: relay envia ao broker mas crasha antes de marcar como publicado). Por isso, idempotência no consumidor é obrigatória.

Como a entrega é at-least-once, o consumidor precisa deduplicar. Estratégias:

  • Tabela de mensagens processadas: guardar o messageId de cada evento consumido; ignorar se já visto (na mesma transação do efeito).
  • Operações naturalmente idempotentes: SET status = 'PAID' é idempotente; balance = balance + 10 não é.
  • Chave de idempotência derivada do evento (ex.: orderId + eventType).
  • Prós: elimina dual write, publicação confiável, desacopla o commit da publicação, funciona com qualquer broker.
  • Contras: latência extra (especialmente polling), tabela outbox a manter e limpar (purga de linhas antigas), ordenação de eventos exige cuidado (chave de partição no Kafka), e o consumidor precisa ser idempotente.
  • Quando o serviço não publica eventos — não há dual write a resolver.
  • Quando perder um evento eventual é aceitável e simplicidade importa mais (raro em fluxos de negócio).
  • Se o broker oferece transações que englobam a fonte de forma nativa e suficiente para o caso (situação incomum entre banco relacional e Kafka).

Escrita atômica do agregado + outbox na mesma transação, e um polling publisher com @Scheduled.

@Entity
@Table(name = "outbox")
public class OutboxMessage {
@Id
private UUID id;
private String aggregateType;
private String aggregateId;
private String type;
@Column(columnDefinition = "text")
private String payload;
private Instant createdAt;
private boolean published;
protected OutboxMessage() {}
public OutboxMessage(String aggregateType, String aggregateId, String type, String payload) {
this.id = UUID.randomUUID();
this.aggregateType = aggregateType;
this.aggregateId = aggregateId;
this.type = type;
this.payload = payload;
this.createdAt = Instant.now();
this.published = false;
}
public void markPublished() { this.published = true; }
public UUID id() { return id; }
public String topic() { return aggregateType.toLowerCase() + "-events"; }
public String key() { return aggregateId; }
public String payload() { return payload; }
}
@Service
@RequiredArgsConstructor
public class PlaceOrderService {
private final OrderRepository orders;
private final OutboxRepository outbox;
private final ObjectMapper mapper;
@Transactional
public void place(PlaceOrderCommand cmd) throws JsonProcessingException {
var order = Order.place(cmd.customerId(), cmd.items());
orders.save(order);
var event = new OrderPlaced(order.id(), order.customerId(), order.total());
var message = new OutboxMessage(
"Order",
order.id().toString(),
"OrderPlaced",
mapper.writeValueAsString(event));
outbox.save(message);
}
}
@Component
@RequiredArgsConstructor
public class OutboxRelay {
private final OutboxRepository outbox;
private final KafkaTemplate<String, String> kafka;
@Scheduled(fixedDelay = 500)
@Transactional
public void publishPending() {
var batch = outbox.findTop100ByPublishedFalseOrderByCreatedAtAsc();
for (var message : batch) {
kafka.send(message.topic(), message.key(), message.payload()).join();
message.markPublished();
}
}
}
sequenceDiagram
    participant App as PlaceOrderService
    participant DB as Banco (orders + outbox)
    participant Relay as OutboxRelay
    participant Broker as Kafka
    participant Cons as Consumidor
    App->>DB: BEGIN
    App->>DB: INSERT order
    App->>DB: INSERT outbox(OrderPlaced)
    App->>DB: COMMIT (atômico)
    loop polling / CDC
        Relay->>DB: SELECT outbox WHERE published=false
        Relay->>Broker: publish(OrderPlaced)
        Relay->>DB: UPDATE outbox SET published=true
    end
    Broker->>Cons: OrderPlaced (at-least-once)
    Note over Cons: dedup por messageId (idempotência)
  • Dual write “otimista” (save seguido de kafka.send sem outbox): a bomba-relógio que o padrão existe para desarmar.
  • Consumidor não idempotente: com at-least-once, processar duas vezes corrompe estado (ex.: crédito em dobro).
  • Publicar dentro do @Transactional da regra de negócio: prende recursos e não resolve atomicidade real; delegue ao relay.
  • Não purgar a outbox: a tabela cresce indefinidamente. Agende limpeza de linhas publicadas.
  • Ignorar ordenação: sem chave de partição consistente, eventos do mesmo agregado podem ser reordenados no broker.
  1. O que é o problema do dual write?
Resposta

É a impossibilidade de gravar no banco e publicar no broker de forma atômica: são dois sistemas transacionais distintos. Um crash entre as duas escritas deixa o sistema inconsistente — evento perdido (estado mudou, ninguém foi notificado) ou evento fantasma (publicado sobre um estado que sofreu rollback).

  1. Por que a escrita na tabela outbox é atômica com a mudança de estado?
Resposta

Porque ambas ocorrem no mesmo banco de dados, dentro da mesma transação local ACID. Ou as duas commitam, ou as duas fazem rollback. Não há segundo sistema envolvido no momento da gravação — a publicação no broker fica para o relay, depois.

  1. Qual a diferença entre relay por polling e por CDC?
Resposta

O polling publisher consulta periodicamente a tabela outbox por linhas não publicadas — simples, mas adiciona latência e carga de polling. O CDC (ex.: Debezium) lê o log de transações do banco (WAL/binlog) e transforma inserts na outbox em mensagens quase em tempo real, sem polling, ao custo de mais infraestrutura.

  1. Por que o consumidor precisa ser idempotente?
Resposta

Porque o relay garante entrega at-least-once: ele pode publicar um evento e crashar antes de marcá-lo como enviado, republicando-o depois. O consumidor deve deduplicar (tabela de mensagens processadas por messageId, ou operações naturalmente idempotentes) para não aplicar o mesmo efeito duas vezes.