3. Consumidor Idempotente e DLT
O Kafka e o outbox entregam pelo menos uma vez: um relay que cai depois do ack republica um lote; um consumidor que cai depois de enviar o e-mail, mas antes de confirmar o offset, recebe a mensagem de novo; um operador que volta um grupo de consumidores reprocessa horas de eventos. O serviço notification precisa, portanto, tornar uma duplicata inofensiva — e não pode deixar uma mensagem ruim parar a partição inteira.
| Problema | Técnica | Onde no código |
|---|---|---|
| o mesmo evento chega duas vezes | consumidor idempotente: lembrar os eventId processados, na mesma transação do efeito | NotificationService |
| uma falha transitória (soluço do SMTP) | retentativa com backoff exponencial, no lugar | KafkaConfig |
| uma mensagem que nunca vai dar certo (malformada, endereço inválido) | tópico de mensagens mortas (DLT): estacioná-la e manter a partição andando | KafkaConfig, DeadLetterListener |
O serviço notification
Um serviço Spring Boot em services/notification/, porta 8086. Ele também mantém o endpoint legado POST /notifications que order chama em NOTIFY_MODE=sync, para que as duas arquiteturas possam ser medidas com o mesmo código.
services/notification/
├── .dockerignore
├── Dockerfile
├── pom.xml
└── src/main/
├── java/boxoffice/notification/
│ ├── NotificationApplication.java
│ ├── NotificationProperties.java
│ ├── OrderEvent.java
│ ├── EmailSender.java
│ ├── NotificationService.java
│ ├── NotificationController.java
│ ├── OrderEventListener.java
│ ├── DeadLetterListener.java
│ └── KafkaConfig.java
└── resources/
├── application.yaml
└── db/migration/V1__create_notification.sql
Arquivos de build e esquema
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>boxoffice</groupId>
<artifactId>notification</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
# syntax=docker/dockerfile:1
# same Dockerfile as catalog (lab 1); only the port changes
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8086
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
server:
port: 8086
spring:
application:
name: notification
mvc:
problemdetails:
enabled: true
datasource:
url: jdbc:postgresql://${DB_HOST:db}:${DB_PORT:5432}/${DB_NAME:boxoffice}?currentSchema=notification
username: ${DB_USER:boxoffice}
password: ${DB_PASSWORD:boxoffice}
flyway:
schemas: notification
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:kafka:9092}
consumer:
group-id: notification
auto-offset-reset: earliest
listener:
ack-mode: record
concurrency: ${NOTIFY_CONCURRENCY:3}
mail:
host: ${MAIL_HOST:mailpit}
port: 1025
boxoffice:
notification:
email-latency: ${EMAIL_LATENCY:50ms}
idempotent: ${NOTIFY_IDEMPOTENT:true}
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
probes:
enabled: true # /actuator/health/liveness and /readiness
-- one row per event already handled: the idempotency memory of this consumer
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- one row per e-mail actually sent (no unique constraint: we want to SEE duplicates)
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL,
order_id UUID NOT NULL,
email VARCHAR(320) NOT NULL,
kind VARCHAR(50) NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- poison messages, kept for inspection and replay
CREATE TABLE dead_letters (
id BIGSERIAL PRIMARY KEY,
topic TEXT NOT NULL,
partition INT NOT NULL,
record_offset BIGINT NOT NULL,
record_key TEXT,
payload TEXT,
reason TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
sent_at usa clock_timestamp() (a hora real do INSERT), não now() (a hora em que a transação começou): com o e-mail enviado dentro da transação, now() esconderia a latência dele.
O evento, o e-mail e as propriedades
package boxoffice.notification;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
/** The consumer's own view of the event: unknown fields are ignored (tolerant reader). */
public record OrderEvent(
UUID eventId,
String eventType,
UUID orderId,
String showId,
String holdId,
String customerEmail,
BigDecimal amount,
Instant occurredAt) {
}
package boxoffice.notification;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;
@Component
public class EmailSender {
private final JavaMailSender mail;
private final NotificationProperties props;
public EmailSender(JavaMailSender mail, NotificationProperties props) {
this.mail = mail;
this.props = props;
}
public void orderReceived(OrderEvent e) {
if (e.customerEmail().startsWith("poison")) {
// a permanent failure: retrying will never help
throw new IllegalArgumentException("mailbox rejected: " + e.customerEmail());
}
simulateProviderLatency();
SimpleMailMessage m = new SimpleMailMessage();
m.setFrom("tickets@boxoffice.local");
m.setTo(e.customerEmail());
m.setSubject("BoxOffice: we received your order " + e.orderId());
m.setText("Order " + e.orderId() + " for show " + e.showId() + " (R$ " + e.amount()
+ ") is waiting for payment.");
mail.send(m);
}
/** A real e-mail API takes tens to hundreds of ms per call; Mailpit takes ~1 ms. */
private void simulateProviderLatency() {
try {
Thread.sleep(props.emailLatency());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException(ex);
}
}
}
package boxoffice.notification;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "boxoffice.notification")
public record NotificationProperties(
@DefaultValue("50ms") Duration emailLatency,
@DefaultValue("true") boolean idempotent) {
}
package boxoffice.notification;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class NotificationApplication {
public static void main(String[] args) {
SpringApplication.run(NotificationApplication.class, args);
}
}
Idempotência: deduplicar na mesma transação do efeito
package boxoffice.notification;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class NotificationService {
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
private final JdbcClient jdbc;
private final EmailSender email;
private final NotificationProperties props;
public NotificationService(JdbcClient jdbc, EmailSender email, NotificationProperties props) {
this.jdbc = jdbc;
this.email = email;
this.props = props;
}
/** @return true if an e-mail was sent, false if the event was a duplicate */
@Transactional
public boolean handle(OrderEvent e) {
if (props.idempotent()) {
int fresh = jdbc.sql("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING")
.param(e.eventId())
.update();
if (fresh == 0) {
log.info("duplicate event {} for order {} ignored", e.eventId(), e.orderId());
return false;
}
}
email.orderReceived(e); // throws -> the whole transaction rolls back, the dedupe row too
jdbc.sql("""
INSERT INTO notifications (event_id, order_id, email, kind, occurred_at)
VALUES (?, ?, ?, ?, ?)
""")
.params(e.eventId(), e.orderId(), e.customerEmail(), e.eventType(),
OffsetDateTime.ofInstant(e.occurredAt(), ZoneOffset.UTC))
.update();
return true;
}
}
A ordem das operações é o ponto central:
- Reivindicar o evento:
INSERT ... ON CONFLICT DO NOTHINGna chave primáriaevent_id. Zero linhas significa que outra entrega (ou outra instância do consumidor, ao mesmo tempo) já o reivindicou → pular. - Fazer o efeito — enviar o e-mail.
- Commit. Se o passo 2 lançar exceção, a reivindicação é desfeita e a retentativa pode tentar de novo.
Sobra uma janela: o e-mail é enviado e então o commit falha (o banco caiu exatamente naquele instante). A reentrega enviará um segundo e-mail. Nenhum consumidor consegue, sozinho, tornar um efeito colateral externo exactly-once; a correção completa é uma chave de idempotência aceita pelo provedor (a maioria das APIs de e-mail e de pagamento aceita uma — passe o eventId). O processamento exactly-once dentro do Kafka (transações, read_committed) cobre pipelines de Kafka para Kafka, não e-mails.
Na entrevista
"O Kafka dá at-least-once; eu torno os consumidores idempotentes com uma tabela de deduplicação indexada pelo id do evento, gravada na mesma transação do efeito, e passo o id do evento como chave de idempotência para os provedores externos." Essa frase responde à maioria das perguntas de acompanhamento do tipo "e as duplicatas?".
Listeners, retentativas e o tópico de mensagens mortas
package boxoffice.notification;
import tools.jackson.databind.ObjectMapper;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class OrderEventListener {
private final ObjectMapper mapper;
private final NotificationService notifications;
public OrderEventListener(ObjectMapper mapper, NotificationService notifications) {
this.mapper = mapper;
this.notifications = notifications;
}
@KafkaListener(topics = "order-events", groupId = "notification")
public void onOrderEvent(ConsumerRecord<String, String> record) {
OrderEvent event = mapper.readValue(record.value(), OrderEvent.class);
if (event.eventId() == null || event.eventType() == null) {
throw new IllegalArgumentException("not a BoxOffice event: " + record.value());
}
if (!"OrderPlaced".equals(event.eventType())) {
return; // other event types are not for this consumer (yet)
}
notifications.handle(event);
}
}
package boxoffice.notification;
import tools.jackson.core.JacksonException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;
@Configuration
public class KafkaConfig {
/** Picked up by Spring Boot's listener container factory (one CommonErrorHandler bean). */
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) {
// after the retries, publish to <topic>-dlt, same partition, with the exception in headers
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template);
ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
backOff.setInitialInterval(500L); // 0.5 s, 1 s, 2 s
backOff.setMultiplier(2.0);
backOff.setMaxInterval(5_000L);
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
// permanent failures: retrying is pointless, go straight to the DLT
handler.addNotRetryableExceptions(JacksonException.class, IllegalArgumentException.class);
return handler;
}
}
package boxoffice.notification;
import java.nio.charset.StandardCharsets;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.common.header.Header;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.stereotype.Component;
@Component
public class DeadLetterListener {
private static final Logger log = LoggerFactory.getLogger(DeadLetterListener.class);
private final JdbcClient jdbc;
public DeadLetterListener(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@KafkaListener(topics = "order-events-dlt", groupId = "notification-dlt")
public void onDeadLetter(ConsumerRecord<String, String> record) {
// the listener's exception is wrapped: the CAUSE class says what really failed
Header cause = record.headers().lastHeader(KafkaHeaders.DLT_EXCEPTION_CAUSE_FQCN);
String why = cause == null ? "?" : new String(cause.value(), StandardCharsets.UTF_8);
log.warn("dead letter at {}-{}@{}: {}", record.topic(), record.partition(), record.offset(), why);
jdbc.sql("""
INSERT INTO dead_letters (topic, partition, record_offset, record_key, payload, reason)
VALUES (?, ?, ?, ?, ?, ?)
""")
.params(record.topic(), record.partition(), record.offset(), record.key(), record.value(), why)
.update();
}
}
package boxoffice.notification;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/** The synchronous "before" path, used by order in NOTIFY_MODE=sync. */
@RestController
public class NotificationController {
private final NotificationService notifications;
public NotificationController(NotificationService notifications) {
this.notifications = notifications;
}
@PostMapping("/notifications")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void send(@RequestBody OrderEvent event) {
notifications.handle(event);
}
}
Como flui um registro que falha:
sequenceDiagram
participant K as order-events (partição 3)
participant L as listener
participant H as DefaultErrorHandler
participant D as order-events-dlt (partição 3)
K->>L: offset 41
L--xH: MailSendException (transitória)
H->>L: tenta de novo após 0,5 s
L--xH: falha de novo
H->>L: tenta após 1 s, depois 2 s
L--xH: continua falhando
H->>D: publica o registro + headers da exceção
H->>K: confirma o offset 42, segue em frente
K->>L: offset 42 (a partição volta a andar) - As retentativas acontecem no lugar, bloqueando só aquela partição por no máximo 0,5 + 1 + 2 = 3,5 s; as outras partições continuam andando nas outras threads consumidoras.
- Exceções não retentáveis pulam o backoff: um JSON malformado ou um endereço recusado vai para a DLT imediatamente.
- O Spring Boot liga o único bean
CommonErrorHandlerà sua fábrica de contêineres de listener; não é preciso nenhuma configuração extra.
Retentativas bloqueantes vs não bloqueantes
O Spring Kafka também oferece @RetryableTopic: registros que falham vão para os tópicos order-events-retry-0, -retry-1… com atrasos, então a partição principal nunca é bloqueada. O preço é ter mais tópicos e nenhuma ordem entre um evento retentado e eventos posteriores da mesma chave. Para e-mails, bloquear alguns segundos é aceitável; para fluxos de alto volume, retentativas não bloqueantes são comuns.
Adicione notification ao Compose e teste
notification:
build: ./services/notification
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
MAIL_HOST: mailpit
NOTIFY_CONCURRENCY: ${NOTIFY_CONCURRENCY:-3} # consumer threads (max useful: 6 partitions)
EMAIL_LATENCY: ${EMAIL_LATENCY:-50ms} # simulated e-mail provider latency
NOTIFY_IDEMPOTENT: ${NOTIFY_IDEMPOTENT:-true} # false = no dedupe (to see duplicates)
ports:
- "8086:8086"
depends_on:
db:
condition: service_healthy
mailpit:
condition: service_started
kafka-init:
condition: service_completed_successfully
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8086/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
notification-1 | ... b.notification.DeadLetterListener : dead letter at order-events-dlt-3@0: java.lang.IllegalArgumentException
notification-1 | ... b.notification.DeadLetterListener : dead letter at order-events-dlt-4@1: java.lang.IllegalArgumentExceptioncurl -s localhost:8025/api/v1/messages | jq '.total'2
O que aconteceu, em ordem:
- O grupo
notificationé novo, então começa no offset mais antigo e encontra primeiro as três mensagens do passeio do passo 1: elas não são eventos do BoxOffice →IllegalArgumentException→ direto para a DLT, sem bloquear os eventos reais atrás delas. - Os dois eventos
OrderPlacedreais (reservas0001e0002) produzem dois e-mails no Mailpit (http://localhost:8025).
Experimento: reprocessar sem duplicatas
Volte o grupo inteiro e deixe-o consumir tudo de novo:
docker compose stop notification
docker compose exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server kafka:9092 \
--group notification --topic order-events --reset-offsets --to-earliest --execute
docker compose start --wait notification
docker compose logs notification | grep duplicate
curl -s localhost:8025/api/v1/messages | jq '.total'
Os logs mostram duplicate event ... ignored para cada pedido e o Mailpit continua com 2 e-mails. Rode NOTIFY_IDEMPOTENT=false docker compose up -d --wait notification, repita os três primeiros comandos e conte de novo: cada reprocessamento envia todos os e-mails outra vez. Religue a deduplicação com docker compose up -d --wait notification antes de seguir.
Experimento: um pedido envenenado
curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \
-d '{"showId":"show-42","holdId":"hold-0003","customerEmail":"poison@example.com","amount":350.00}'
docker compose exec db psql -U boxoffice -d boxoffice \
-c "SELECT topic, partition, record_offset, reason FROM notification.dead_letters;"
O pedido dá certo (o fã não é afetado), o e-mail falha de forma permanente e o registro cai em order-events-dlt: depois de um ou dois segundos, a última linha da consulta é o pedido envenenado (as linhas antes dela são as mensagens do passeio, uma vez por passagem do grupo pelo tópico); abra-o no Kafka UI e veja os headers kafka_dlt-* (tópico, partição e offset originais, exceção).
Uma DLT que ninguém lê é perda de dados silenciosa
Cada mensagem morta é um cliente que não recebeu e-mail. Em produção: alerte quando a DLT crescer (lab 8), corrija a causa e então reprocesse os registros de volta para order-events (uma pequena ferramenta ou kafka-console-consumer | kafka-console-producer com as chaves originais). Reprocessar é seguro justamente porque o consumidor é idempotente.