Pular para conteúdo

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

services/notification/pom.xml
<?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>
services/notification/Dockerfile
# 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"]
services/notification/.dockerignore
target/
.idea/
.vscode/
*.iml
services/notification/src/main/resources/application.yaml
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
services/notification/src/main/resources/db/migration/V1__create_notification.sql
-- 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

services/notification/src/main/java/boxoffice/notification/OrderEvent.java
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) {
}
services/notification/src/main/java/boxoffice/notification/EmailSender.java
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);
        }
    }
}
services/notification/src/main/java/boxoffice/notification/NotificationProperties.java
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) {
}
services/notification/src/main/java/boxoffice/notification/NotificationApplication.java
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

services/notification/src/main/java/boxoffice/notification/NotificationService.java
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:

  1. Reivindicar o evento: INSERT ... ON CONFLICT DO NOTHING na chave primária event_id. Zero linhas significa que outra entrega (ou outra instância do consumidor, ao mesmo tempo) já o reivindicou → pular.
  2. Fazer o efeito — enviar o e-mail.
  3. 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

services/notification/src/main/java/boxoffice/notification/OrderEventListener.java
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);
    }
}
services/notification/src/main/java/boxoffice/notification/KafkaConfig.java
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;
    }
}
services/notification/src/main/java/boxoffice/notification/DeadLetterListener.java
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();
    }
}
services/notification/src/main/java/boxoffice/notification/NotificationController.java
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

docker-compose.yaml (excerpt)
  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
docker compose up -d --build --wait notificationdocker compose logs notification | grep -i "dead letter"notification-1 | ... b.notification.DeadLetterListener : dead letter at order-events-dlt-4@0: java.lang.IllegalArgumentException
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.IllegalArgumentException
curl -s localhost:8025/api/v1/messages | jq '.total'2

O que aconteceu, em ordem:

  1. 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.
  2. Os dois eventos OrderPlaced reais (reservas 0001 e 0002) 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.

Próximo: Sala de espera