4. Orientado a Eventos
Um fã paga por dois lugares, o serviço order da BoxOffice grava o pedido… e cai antes de avisar alguém. Nada de e-mail com o ingresso. Ou o contrário: o evento sai, a transação no banco é desfeita e um fã recebe um ingresso de um pedido que não existe. Ou o broker entrega o mesmo evento duas vezes e o fã recebe dois e-mails — e depois, dois ingressos. Esse é o problema da escrita dupla (dual write), e todo sistema orientado a eventos precisa resolvê-lo. Neste exercício você o resolve com um outbox transacional, um consumidor idempotente e um tópico de mensagens mortas (DLT), e depois se esforça para quebrá-lo.
Objetivos de aprendizagem
Ao final deste exercício você será capaz de:
- Explicar por que "gravar no banco, depois publicar no Kafka" perde ou inventa eventos, e como o outbox resolve isso.
- Implementar um outbox transacional com um relay por polling e um consumidor idempotente com retentativas e DLQ.
- Raciocinar sobre semânticas de entrega: entrega at-least-once + processamento idempotente = efeitos colaterais effectively-once.
- Verificar a correção sob falhas com uma consulta de reconciliação, e medir a latência fim a fim e o atraso do consumidor.
Contexto
A Aula 8 tratou de filas, pub/sub e do log de eventos, das partições e grupos de consumidores do Kafka, das semânticas de entrega e dos padrões outbox/saga1. O Lab 7 montou o Kafka (KRaft) com os serviços order e notification da BoxOffice. Aqui você torna esse fluxo correto sob falhas e prova isso.
flowchart LR
client([k6]) -->|POST /orders| order[order]
order -->|"1 · uma transação: pedido + linha no outbox"| odb[("order_svc<br/>orders · outbox")]
relay[relay do outbox] -->|"2 · busca linhas não publicadas"| odb
relay -->|"3 · publica, chave = orderId"| kafka{{"Kafka · order-events"}}
kafka -->|"4 · consome"| notif[notification]
notif -->|"5 · uma transação: processed_events + ticket_emails"| ndb[("notification_svc<br/>processed_events · ticket_emails")]
notif -.->|"mensagem envenenada após retentativas"| dlt{{"order-events.DLT"}} Por que cada peça existe:
| Peça | Falha que ela trata |
|---|---|
| Linha no outbox na mesma transação do pedido | Queda entre "gravar" e "publicar": o evento está no banco e será publicado depois |
| O relay marca a linha como publicada depois do ack do broker | Broker fora ou queda do relay: a linha é reenviada (→ duplicatas são possíveis: at-least-once) |
Inserção em processed_events na mesma transação do efeito colateral | Entrega duplicada: a segunda inserção conflita e o efeito colateral é pulado |
| Retentativas com backoff, depois DLT | Erros transitórios são repetidos; uma mensagem envenenada não trava a partição para sempre |
Chave do Kafka = orderId | Todos os eventos de um pedido vão para a mesma partição, em ordem |
Tarefa
Implemente e verifique o fluxo pedido confirmado → e-mail com o ingresso da BoxOffice (ou o fluxo equivalente dos seus serviços):
ordergrava o pedido e uma linha no outbox (envelope do evento) em uma transação.- Um relay publica as linhas não publicadas do outbox no tópico
order-events(chave =orderId) e as marca depois que o broker confirma. notificationconsome os eventos de forma idempotente (deduplicação pelo id do evento na mesma transação do efeito colateral), repete erros transitórios com backoff e manda mensagens envenenadas para uma DLT.- Execute uma carga de 10 minutos injetando falhas (queda do relay, queda do broker, queda do consumidor, evento duplicado, mensagem envenenada) e prove com uma consulta de reconciliação que há zero e-mails perdidos e zero duplicados.
- Meça a latência fim a fim e o atraso do consumidor, com e sem falhas.
Requisitos
- (obrigatório) Linha do outbox gravada na mesma transação da mudança de negócio (mostre o código).
- (obrigatório) Consumidor idempotente: deduplicação pelo id do evento na mesma transação do efeito colateral.
- (obrigatório) Reconciliação depois da execução de 10 minutos com falhas: 0 perdidos e 0 duplicados (a consulta e sua saída no relatório).
- Envelope do evento com pelo menos
eventId,type,version,occurredAte a chave de negócio. - O relay publica com
acks=alle marca as linhas só depois do ack; o outbox volta a 0 linhas não publicadas depois que o broker volta (tempo medido). - Retentativas com backoff exponencial e uma DLT; a mensagem envenenada está na DLT e o consumidor continua processando as mensagens seguintes.
- Roteiro de falhas executado durante a carga: queda do relay/
order, broker parado por ≥ 60 s, consumidor morto (SIGKILL) no meio do processamento, um evento válido duplicado, uma mensagem envenenada. - Latência fim a fim (pedido confirmado → efeito colateral) p50/p95/p99, e atraso do consumidor ao longo do tempo.
- Uma discussão sobre ordem (o que a chave garante e o que não garante) e sobre o e-mail real (um efeito colateral externo, fora do seu banco).
- Seção Uso de IA (veja a política).
Entregáveis
No seu repositório (tag ex4): o código dos dois serviços, as migrações, o compose.yaml, o script k6, o script de falhas, as consultas de reconciliação e de latência e suas saídas; uma página event-driven no site da sua documentação seguindo o template.
Passo a passo
1. Kafka (KRaft) e os schemas
Reaproveite o Kafka do Lab 7. Como referência, um broker KRaft de nó único com a imagem oficial:
services:
kafka:
image: apache/kafka:3.8.0
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:29092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:29092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
ports:
- "29092:29092"
Cada serviço é dono dos seus dados. Para que a reconciliação seja uma única consulta, este exercício mantém os dois serviços em uma instância PostgreSQL com dois schemas (serviço postgres do Compose, banco boxoffice: order_svc e notification_svc), cada serviço usando apenas o seu schema. Em produção seriam bancos separados e a reconciliação, um pequeno job.
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ticket_emails (
id BIGSERIAL PRIMARY KEY,
order_id UUID NOT NULL,
event_id UUID NOT NULL,
recipient VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Sem restrição de unicidade em ticket_emails.order_id
É tentador acrescentar uma — e em produção talvez você acrescente. Aqui, deixe de fora: a ideia é mostrar que a lógica do consumidor evita duplicatas. Uma restrição de unicidade esconderia um consumidor com defeito.
2. Grave o pedido e o evento juntos
package boxoffice.order;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public OrderService(JdbcTemplate jdbc, ObjectMapper json) {
this.jdbc = jdbc;
this.json = json;
}
@Transactional
public UUID confirm(NewOrder cmd) throws Exception {
UUID orderId = UUID.randomUUID();
Instant now = Instant.now();
jdbc.update("""
INSERT INTO orders (id, event_id, seats, customer_email, status, confirmed_at)
VALUES (?, ?, ?, ?, 'CONFIRMED', ?)
""", orderId, cmd.eventId(), cmd.seats(), cmd.customerEmail(), java.sql.Timestamp.from(now));
UUID eventId = UUID.randomUUID();
Map<String, Object> envelope = Map.of(
"eventId", eventId.toString(),
"type", "OrderConfirmed",
"version", 1,
"occurredAt", now.toString(),
"orderId", orderId.toString(),
"customerEmail", cmd.customerEmail(),
"seats", cmd.seats());
jdbc.update("""
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
VALUES (?, 'order', ?, 'OrderConfirmed', ?::jsonb)
""", eventId, orderId.toString(), json.writeValueAsString(envelope));
return orderId; // both rows commit together, or neither does
}
}
Neste exercício um pedido é confirmado quando é criado (o pagamento está fora do escopo); se o seu serviço tem uma etapa de confirmação separada, grave a linha do outbox nela.
3. O relay
Um publicador por polling lê as linhas não publicadas, publica cada uma e espera a confirmação do broker antes de marcá-la. FOR UPDATE SKIP LOCKED permite rodar um segundo relay sem que os dois publiquem a mesma linha ao mesmo tempo.
package boxoffice.order;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class OutboxRelay {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 200) // requires @EnableScheduling on a configuration class
@Transactional
public void publishBatch() {
List<Map<String, Object>> rows = jdbc.queryForList("""
SELECT id, aggregate_id, payload::text AS payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
""");
for (Map<String, Object> row : rows) {
String key = (String) row.get("aggregate_id");
String payload = (String) row.get("payload");
kafka.send("order-events", key, payload).join(); // wait for the ack (acks=all)
jdbc.update("UPDATE outbox SET published_at = now() WHERE id = ?", row.get("id"));
}
}
}
spring:
kafka:
bootstrap-servers: kafka:9092
producer:
acks: all
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
properties:
enable.idempotence: true
delivery.timeout.ms: 30000
Se o relay cair depois do send e antes do commit da transação, a linha é publicada de novo no próximo polling. Tudo bem — o consumidor é idempotente. Change Data Capture com Debezium (lendo o outbox a partir do WAL do PostgreSQL) é um relay alternativo; é uma boa extensão nível A.
4. O consumidor idempotente, as retentativas e a DLT
package boxoffice.notification;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TicketEmailService {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public TicketEmailService(JdbcTemplate jdbc, ObjectMapper json) {
this.jdbc = jdbc;
this.json = json;
}
@Transactional
public void handle(String message) {
JsonNode event = parse(message);
UUID eventId = UUID.fromString(event.path("eventId").asText());
int inserted = jdbc.update(
"INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING", eventId);
if (inserted == 0) {
return; // duplicate delivery: already processed, skip the side effect
}
jdbc.update("INSERT INTO ticket_emails (order_id, event_id, recipient) VALUES (?, ?, ?)",
UUID.fromString(event.path("orderId").asText()), eventId, event.path("customerEmail").asText());
// The real e-mail is sent from ticket_emails by a separate sender,
// using event_id as the idempotency key with the e-mail provider.
}
private JsonNode parse(String message) {
try {
JsonNode event = json.readTree(message);
if (!"OrderConfirmed".equals(event.path("type").asText()) || event.path("eventId").isMissingNode()) {
throw new InvalidEventException("unexpected event: " + message);
}
return event;
} catch (InvalidEventException e) {
throw e;
} catch (Exception e) {
throw new InvalidEventException("malformed event: " + e.getMessage());
}
}
}
package boxoffice.notification;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.common.TopicPartition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.config.TopicBuilder;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;
import org.springframework.stereotype.Component;
@Configuration
public class KafkaConfig {
@Bean
NewTopic orderEvents() {
return TopicBuilder.name("order-events").partitions(3).replicas(1).build();
}
@Bean
NewTopic orderEventsDlt() {
return TopicBuilder.name("order-events.DLT").partitions(3).replicas(1).build();
}
// Picked up by Spring Boot's listener container factory.
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) {
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template,
(record, ex) -> new TopicPartition(record.topic() + ".DLT", record.partition()));
ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
backOff.setInitialInterval(500);
backOff.setMultiplier(2.0);
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
handler.addNotRetryableExceptions(InvalidEventException.class); // poison: straight to the DLT
return handler;
}
@Component
static class OrderEventsListener {
private final TicketEmailService service;
OrderEventsListener(TicketEmailService service) {
this.service = service;
}
@KafkaListener(topics = "order-events", groupId = "notification")
void onMessage(ConsumerRecord<String, String> record) {
service.handle(record.value());
}
}
}
InvalidEventException é uma RuntimeException simples. O consumidor só confirma o offset depois que handle retorna (os modos de ack padrão do Spring Kafka, BATCH/RECORD, confirmam depois do listener)2, então uma queda no meio significa reentrega — que a tabela processed_events absorve.
5. Carga e falhas
import http from 'k6/http';
import { check } from 'k6';
const BASE = __ENV.BASE_URL || 'http://localhost:8081';
export const options = {
scenarios: {
orders: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.RATE || 20), timeUnit: '1s',
duration: __ENV.DURATION || '10m',
preAllocatedVUs: 20, maxVUs: 200,
},
},
};
export default function () {
const body = JSON.stringify({
eventId: 1 + Math.floor(Math.random() * 100),
seats: 1 + Math.floor(Math.random() * 4),
customerEmail: `fan${__VU}-${__ITER}@example.com`,
});
const res = http.post(`${BASE}/orders`, body, { headers: { 'Content-Type': 'application/json' } });
check(res, { 'order accepted': (r) => r.status === 201 });
}
#!/usr/bin/env bash
# Failure schedule, started together with the 10-minute k6 run.
set -x
sleep 90; docker compose kill order; sleep 20; docker compose start order
sleep 90; docker compose stop kafka; sleep 60; docker compose start kafka
sleep 90; docker compose kill notification; sleep 20; docker compose start notification
sleep 30
# a duplicate: publish again an event that was already published
docker compose exec -T postgres psql -U boxoffice -d boxoffice -tAc \
"select aggregate_id || '|' || payload::text from order_svc.outbox where published_at is not null limit 1" \
| docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka:9092 --topic order-events \
--property parse.key=true --property key.separator='|'
# a poison message
echo 'poison|{"type":"OrderConfirmed","orderId":' \
| docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka:9092 --topic order-events \
--property parse.key=true --property key.separator='|'
As requisições para o order falham enquanto ele está fora — isso é esperado (os erros do k6 não são o foco aqui). O que precisa valer é: todo pedido aceito recebe exatamente um e-mail com o ingresso.
6. Verifique e meça
-- lost: confirmed orders without exactly one ticket e-mail
SELECT o.id, count(t.id) AS emails
FROM order_svc.orders o
LEFT JOIN notification_svc.ticket_emails t ON t.order_id = o.id
WHERE o.status = 'CONFIRMED'
GROUP BY o.id
HAVING count(t.id) <> 1;
-- duplicated side effects
SELECT order_id, count(*) FROM notification_svc.ticket_emails GROUP BY order_id HAVING count(*) > 1;
-- outbox backlog (must return to 0)
SELECT count(*) AS unpublished FROM order_svc.outbox WHERE published_at IS NULL;
-- end-to-end latency in seconds: order confirmed -> ticket e-mail recorded
SELECT percentile_cont(ARRAY[0.5, 0.95, 0.99]) WITHIN GROUP (
ORDER BY extract(epoch FROM t.created_at - o.confirmed_at)) AS p50_p95_p99
FROM notification_svc.ticket_emails t
JOIN order_svc.orders o ON o.id = t.order_id;
docker compose exec kafka /opt/kafka/bin/kafka-consumer-groups.sh \
--bootstrap-server kafka:9092 --describe --group notification
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server kafka:9092 --topic order-events.DLT --from-beginning --timeout-ms 5000
Amostre o atraso do consumidor a cada poucos segundos durante a execução (um loop em volta de kafka-consumer-groups.sh, ou as métricas do Kafka na stack de observabilidade do Lab 8) para plotá-lo ao longo do tempo: você deve vê-lo crescer enquanto o notification está fora e esvaziar depois.
Templates
# Event-driven — outbox and idempotent consumer
## 1. Flow and design
Diagram; event envelope (fields and an example); topic, partitions, key; consumer group.
## 2. Why it is correct
| Failure | What happens | Why nothing is lost or duplicated |
|-|-|-|
| order/relay crash after the commit | | |
| broker down | | |
| consumer crash mid-processing | | |
| duplicate event | | |
| poison message | | |
## 3. Run with failures
Rate ... orders/s, 10 min. Failure timeline: T+90 s order killed, ...
## 4. Verification
| Check | Result |
|-|-|
| Accepted orders | |
| Lost (query 1) | 0 |
| Duplicated (query 2) | 0 |
| Outbox back to 0 unpublished after broker restart | ... s |
| Poison message in the DLT | yes / no |
## 5. Latency and lag
| | p50 | p95 | p99 |
|-|-|-|-|
| Without failures | | | |
| With failures | | | |
Consumer lag over time (chart) and explanation.
## 6. Discussion
Ordering per key; exactly-once vs effectively-once; sending the real e-mail; outbox vs CDC.
## 7. AI usage
# Orientado a eventos — outbox e consumidor idempotente
## 1. Fluxo e design
Diagrama; envelope do evento (campos e um exemplo); tópico, partições, chave; grupo de consumidores.
## 2. Por que está correto
| Falha | O que acontece | Por que nada se perde nem se duplica |
|-|-|-|
| queda do order/relay depois do commit | | |
| broker fora do ar | | |
| queda do consumidor no meio do processamento | | |
| evento duplicado | | |
| mensagem envenenada | | |
## 3. Execução com falhas
Taxa ... pedidos/s, 10 min. Linha do tempo das falhas: T+90 s order derrubado, ...
## 4. Verificação
| Verificação | Resultado |
|-|-|
| Pedidos aceitos | |
| Perdidos (consulta 1) | 0 |
| Duplicados (consulta 2) | 0 |
| Outbox de volta a 0 não publicados depois do reinício do broker | ... s |
| Mensagem envenenada na DLT | sim / não |
## 5. Latência e atraso
| | p50 | p95 | p99 |
|-|-|-|-|
| Sem falhas | | | |
| Com falhas | | | |
Atraso do consumidor ao longo do tempo (gráfico) e explicação.
## 6. Discussão
Ordem por chave; exactly-once vs effectively-once; envio do e-mail real; outbox vs CDC.
## 7. Uso de IA
Rubrica
Níveis conforme definidos em Como os exercícios são avaliados.
| Critério | I | D | C | B | A |
|---|---|---|---|---|---|
| Outbox transacional (obrigatório) | Ausente | Publicação fora da transação (escrita dupla) | Outbox na mesma transação, relay marca depois do ack | Envelope versionado; relay seguro com duas instâncias | Relay por CDC (Debezium) comparado com o polling |
| Consumidor idempotente (obrigatório) | Ausente | Deduplicação fora da transação do efeito colateral | Deduplicação pelo id do evento na mesma transação | Retenção/limpeza de processed_events discutida | Envio real do e-mail idempotente com o provedor (chave = id do evento) |
| Retentativas e DLT | — | Sem DLT, ou a mensagem envenenada trava a partição | Backoff + DLT; o consumidor segue em frente | Erros repetíveis vs não repetíveis justificados | Procedimento de reprocessamento da DLT documentado e testado |
| Verificação sob falhas (obrigatório) | Sem prova | Algumas falhas não executadas | As cinco falhas; 0 perdidos, 0 duplicados | Linha do tempo da execução com backlog do outbox e atraso | Falha adicional (por exemplo, partição de rede) testada |
| Medições | — | Só médias | p50/p95/p99 fim a fim + atraso | Com vs sem falhas comparados e explicados | Atraso previsto a partir das taxas (backlog ÷ taxa de consumo) e conferido |
| Discussão | — | Ausente | Ordem e efeito colateral externo tratados | Trade-offs do exactly-once do Kafka vs idempotência | Enunciado claro das garantias do fluxo inteiro |
FAQ
O Kafka tem semântica exactly-once. Por que preciso de tudo isso?
As transações do Kafka dão exactly-once dentro do Kafka (consumir → produzir → confirmar offsets). Os seus efeitos colaterais — uma linha no PostgreSQL, um e-mail — vivem fora do Kafka. A receita robusta é entrega at-least-once mais processamento idempotente, o que faz o efeito acontecer uma vez.
Posso usar @TransactionalEventListener em vez de um outbox?
Ele publica depois do commit, mas se o processo morrer entre o commit e a publicação, o evento se perde — o problema da escrita dupla de novo, só que mais raro. O outbox sobrevive a quedas porque o evento está no banco.
O relay vai publicar eventos fora de ordem?
Com um relay e ORDER BY created_at, os eventos são publicados na ordem de commit (aproximadamente). O Kafka mantém a ordem por partição, e a chave orderId coloca todos os eventos de um pedido na mesma partição. Não há ordem global entre pedidos — e a BoxOffice não precisa de uma. Explique isso na sua discussão.
Por quanto tempo guardo processed_events?
Pelo menos enquanto uma duplicata puder chegar (retenção do tópico, retentativas do relay). Na prática, dias; depois apague as linhas antigas com um job agendado. Diga o que você escolheria e por quê.
O atraso do meu consumidor nunca diminui depois que o broker volta.
Confira se o consumidor reconecta (logs), se não está preso repetindo uma mensagem envenenada (a DLT está configurada?) e se o tópico tem o número de partições que você espera. kafka-consumer-groups.sh --describe mostra o atraso por partição.
A injeção da duplicata não criou e-mail duplicado. Funcionou?
Esse é o resultado esperado. Prove que a duplicata foi entregue: a linha de log do consumidor para uma duplicata pulada, ou a contagem de mensagens consumidas vs linhas em processed_events.
-
Chris Richardson, "Pattern: Transactional outbox", microservices.io. ↩
-
Referência do Spring for Apache Kafka, "Handling exceptions" e "Publishing dead-letter records", docs.spring.io/spring-kafka. ↩