kafka实践教程
第一章 Kafka 核心概念
写业务代码前,先理清 Kafka 的通信模型与基础组件。不理解这些设计动机,使用 KafkaTemplate、@KafkaListener 与 DefaultErrorHandler 时就容易只会照搬配置。
1.1 同步调用的瓶颈
假设一个电商系统在处理创建订单请求 POST /orders 时,采用传统的单体同步调用:
1 2 3 4 5 6 7
| public void createOrder() { orderService.create(); stockService.decrease(); smsService.send(); pointService.add(); logService.record(); }
|
整个请求的链路耗时为各项之和(约 650ms)。但在多数业务场景下,调用方只需确认“订单是否创建成功”,而短信通知、积分发放、行为日志与推荐更新等操作,延迟数百毫秒甚至数秒并不会破坏业务一致性。
引入事件驱动后,订单服务仅需在本地完成核心入库逻辑,随后对外发布一条“订单已创建”事件即可返回响应,耗时直接压缩到 100ms 左右:
1 2 3 4 5 6 7
| ┌── 短信服务 │ OrderCreated ────┼── 积分服务 │ ├── 推荐服务 │ └── 日志服务
|
后续旁路系统各自监听事件、异步消费,彼此独立执行。
1.2 引入消息队列的收益
消息队列充当了调用方与处理方之间的缓冲媒介:
- 异步加速:将非强一致性的从属逻辑剥离出主响应链路,直接降低接口延迟。
- 系统解耦:上游只负责投递结构化事件,下游增减消费者(如新增风控系统)无需改动订单核心服务的代码。
- 流量削峰:当下游数据库最大承载能力为 1000 QPS,而瞬时请求激增到 10000 QPS 时,消息先在队列中持久化排队,消费者按自身处理能力匀速拉取,避免底层存储被瞬间压垮。
1.3 Kafka 消息模型与关键拓扑
Apache Kafka 是一套高吞吐、持久化、支持水平扩展的分布式事件流平台。在本项目中,各组件承载如下分工:
1
| OrderController -> OrderProducer -> Kafka -> OrderConsumer -> OrderProcessService
|
一条写入 Kafka 的物理记录通常包含以下属性:
1 2 3 4 5 6 7
| topic: 所属逻辑主题 partition: 所在物理分区编号 offset: 分区内的位点 key: 路由键,决定分区归属 value: 实际业务载荷(如序列化后的 JSON 字符串) timestamp: 时间戳 headers: 元数据键值对
|
项目消费者在参数中拿到的 ConsumerRecord<String, String>,就是该记录在客户端中的映射对象。
Broker 与集群
Broker 是 Kafka 的服务节点。生产集群通常由多个 Broker 协同工作以实现高可用;本 Demo 为简化部署,配置单节点服务监听于 localhost:9092。
Topic 与 Partition
Topic 是划分消息类型的逻辑主题,例如 order-events(正常事件)与 order-events-dlt(死信事件)。每个 Topic 可进一步切分为多个 Partition。
Partition 是 Kafka 保证并发能力与横向扩展的物理单元。单 Partition 同一时刻只能由同一 Consumer Group 内的一个消费者实例独占拉取。增加 Partition 数量,能够提升该消费组的消费并发上限。
单分区有序性
Partition 是仅支持顺序追加写(Append-only)的提交日志。消息按入队次序依次分配单调递增的偏移量:
1
| Partition 0: [offset 0: A] -> [offset 1: B] -> [offset 2: C] -> [offset 3: D]
|
Kafka 仅保证单个 Partition 内部的消息有序,不同 Partition 之间不存在全局顺序保证。
Offset
Offset 是消息在其所属 Partition 内的序列编号。它是相对概念,并非全局唯一 ID。定位一条物理记录的完整坐标为:Topic + Partition + Offset。消费者通过提交已消费的 Offset,向集群记录当前组在该分区的读取进度。
Consumer Group
同属一个 Consumer Group 的消费者协同消费一个 Topic。若 Topic 包含 3 个分区:
- 组内有 1 个 Consumer 时,该实例同时处理 3 个分区;
- 组内有 3 个 Consumer 时,每个实例分别独占消费 1 个分区;
- 组内若启动 4 个以上的 Consumer,多出的实例将处于空闲待命状态。
若有两个不同的 Consumer Group(例如订单处理服务与数据分析服务)订阅同一 Topic,两组会完全独立地推进各自的 Offset,互不影响。
消息路由键:Key 的选择
Kafka 默认使用 Murmur2 算法对 Key 计算哈希,并对 Partition 总数取模,以此决定消息路由的目标分区。只要 Topic 的分区数量不发生变更,相同 Key 的消息将始终落入同一个 Partition。
如果使用每个事件唯一的 UUID 作为 Key,同一订单的“创建”、“支付”、“发货”可能会散落到不同分区,被不同线程并发处理,从而导致“支付先于创建被消费”的乱序问题。因此本项目强制将业务主键 orderId 设为 Kafka Key,利用单分区有序性保证同一订单的生命周期顺序;每次操作唯一的 eventId 则放在消息体中,用于消费端的幂等去重。
ACK 与重复消费成因
ACK 是消费者向框架确认消息已处理完成的信号。在手动 ACK 模式下,代码必须在本地事务成功提交之后调用 acknowledge()。如果过早确认,后续业务执行抛出异常时,该消息在 Kafka 看来已被消费,将直接引发漏处理风险。
相反,如果业务处理已在 MySQL 中成功入库,但在执行 ACK 或框架提交 Offset 的瞬间进程宕机,Kafka 由于未收到确认,在消费者重启后仍会自上次已提交的 Offset 处重新投递该消息。因为存在这种“处理成功但确认丢失”的情况,消费端必须实现业务幂等。
临时重试与死信队列(DLT)
网络抖动或短时死锁属于瞬时异常,通常在重试一到两次后即可自愈;而 JSON 结构损坏属于永久性异常,原地无限重试只会白白占用线程与数据库连接,甚至造成单分区阻塞(即毒消息,Poison Message)。因此系统需要实施有限退避重试,耗尽重试次数后将数据投递至死信队列(order-events-dlt),隔离异常并交由告警系统介入。
第二章 项目架构总览
2.1 技术栈
| 组件 |
版本 / 来源 |
用途 |
| Spring Boot |
4.1.1 |
基础设施框架 |
| Spring Kafka |
与 Boot 版本配套 |
Kafka 客户端封装 |
| Jackson |
与 Boot 版本配套 |
JSON 序列化工具 |
| MySQL |
8.0+ |
业务存储与防重记录 |
| Java |
17 |
运行环境 |
2.2 目录组织
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| com.zzh.kafkademo ├── config │ ├── JacksonConfig.java # 序列化配置 │ ├── KafkaErrorConfig.java # 重试与死信队列拓扑配置 │ └── KafkaTopicConfig.java # 主题声明与分区参数 ├── consumer │ ├── OrderConsumer.java # 订单业务监听器 │ └── OrderDltConsumer.java # 死信消息监听器 ├── controller │ └── OrderController.java # 发送与重放接口 ├── exception │ └── BadMessageException.java # 不可重试异常标识 ├── model │ ├── CreateOrderRequest.java # HTTP 请求体 │ ├── OrderEvent.java # 核心领域事件 │ └── ProcessResult.java # 处理状态枚举(SUCCESS/DUPLICATE) ├── producer │ └── OrderProducer.java # 消息发送与回调组件 └── service ├── FailureSimulator.java # 故障注入工具 └── OrderProcessService.java # 事务落库与去重核心逻辑
|
2.3 核心数据流
整个流程按“先提交业务事务、再确认位点”的顺序执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| HTTP POST /orders │ ▼ OrderController (生成事件 UUID,设定 orderId 为路由键) │ ▼ OrderProducer (通过 Jackson 序列化为 JSON 文本) │ ▼ Kafka Broker (落入 order-events 的特定 Partition) │ ▼ OrderConsumer (拉取记录,解析 JSON 为 OrderEvent) │ ▼ OrderProcessService.process() [@Transactional 事务保护] ├── 1. INSERT IGNORE INTO processed_event (占位排重) │ 若主键冲突直接返回 DUPLICATE ├── 2. FailureSimulator.maybeFail() (故障模拟拦截) └── 3. INSERT INTO orders (业务落库) │ ├─ 正常提交 ──> 执行 acknowledgment.acknowledge() │ └─ 抛出异常 ──> 触发数据库 ROLLBACK (去重占位同步撤销) │ ▼ 异常传递给 DefaultErrorHandler ├── 可重试异常:按 FixedBackOff 重新拉取 └── 达到上限或不可重试:转入 order-events-dlt
|
第三章 环境搭建
3.1 容器化运行 Kafka(KRaft 模式)
Kafka 现代版本原生支持 KRaft 协议管理集群元数据,去除了对 ZooKeeper 的外部依赖。通过 Docker Compose 启动单节点服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| services: kafka: image: confluentinc/cp-kafka:7.6.0 container_name: kafka ports: - "9092:9092" environment: KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: broker,controller KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER: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
|
执行命令在后台拉起实例:
3.2 数据库 Schema 设计
在 MySQL 中创建去重表与订单业务表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| CREATE DATABASE IF NOT EXISTS kafka_demo DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;
USE kafka_demo;
CREATE TABLE IF NOT EXISTS processed_event ( event_id VARCHAR(36) PRIMARY KEY COMMENT '事件唯一标识', order_id BIGINT NOT NULL COMMENT '订单ID', processed_at DATETIME NOT NULL COMMENT '处理时间' ) ENGINE = InnoDB;
CREATE TABLE IF NOT EXISTS orders ( order_id BIGINT PRIMARY KEY COMMENT '订单ID', user_id BIGINT NOT NULL COMMENT '用户ID', product_id BIGINT NOT NULL COMMENT '商品ID', amount DECIMAL(10,2) NOT NULL COMMENT '订单金额', status VARCHAR(20) NOT NULL COMMENT '订单状态', created_at DATETIME NOT NULL COMMENT '创建时间' ) ENGINE = InnoDB;
|
去重依靠 event_id 主键约束:并发或重复投递的同一事件再次写入时,会被 InnoDB 的主键唯一性约束拦下。
3.3 验证环境可用性
1 2 3 4 5
| docker exec kafka kafka-broker-api-versions --bootstrap-server localhost:9092
mysql -u root -p -e "SHOW DATABASES LIKE 'kafka_demo'"
|
第四章 Kafka 配置详解
4.1 配置文件一览 (application.yml)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| spring: kafka: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer acks: all properties: enable.idempotence: true consumer: group-id: order-service-v2 key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer enable-auto-commit: false auto-offset-reset: earliest listener: ack-mode: manual_immediate
|
4.2 生产端核心参数
该参数指示 Leader 副本在收到消息后,必须等待同步副本集合(In-Sync Replicas, ISR)全量完成同步确认,才向生产者返回成功响应。从单节点扩展到集群后,它用来防止 Leader 掉线时丢失消息。
1
| enable.idempotence = true
|
开启生产端原生幂等后,Kafka 客户端会为每个 Producer 实例分配唯一的 PID,并为发送到每个分区的消息维护单调自增的 Sequence Number。一旦因网络丢包导致客户端未收到 Broker 确认并自动重试,Broker 端可根据 PID 与序列号过滤掉重复追加的记录。需要注意,这只防范了 Producer 到 Broker 链路的写入重复,不能解决消费端的业务重复问题。
为什么选择手写 JSON 序列化?
Spring Kafka 提供了开箱即用的 JsonSerializer,但其默认会在 Record Header 中塞入全限定 Java 类名,导致不同微服务间存在强类路径耦合。这里先用 Jackson 把领域对象序列化为普通 JSON 字符串,再交给 StringSerializer 转成字节流,其他系统解析报文时不依赖 Java 类路径。
4.3 消费端与进度控制
1 2
| enable-auto-commit = false ack-mode = manual_immediate
|
开启自动提交后,消费者会在后续的 poll() 调用中按 auto.commit.interval.ms(默认 5 秒)提交已拉取到的 Offset。如果消息在 poll 循环中同步处理,宕机时还没提交的进度会让消息被重新消费,更常见的后果是重复;如果处理被交给异步线程,Offset 可能在处理完成前就已提交,此时宕机,这部分消息就会丢失。关闭自动提交并设置为 manual_immediate 后,Offset 由业务代码显式提交,只有消息处理成功,位点才会提交到 Broker。
1
| auto-offset-reset = earliest
|
当一个全新的 Consumer Group 首次接入,或者其原有的位点已在集群中过期淘汰时,该参数决定读取策略。earliest 表示从该分区当前尚存的最早位点开始回放;latest 则只接收启动之后新追加的消息。若该消费组在集群中已存在有效的提交记录,Kafka 会沿用现有进度,不会从头读取。
4.4 Topic 编排
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Configuration public class KafkaTopicConfig {
public static final String ORDER_TOPIC = "order-events"; public static final String ORDER_DLT = "order-events-dlt";
@Bean public NewTopic orderTopic() { return new NewTopic(ORDER_TOPIC, 3, (short) 1); }
@Bean public NewTopic orderDltTopic() { return new NewTopic(ORDER_DLT, 3, (short) 1); } }
|
通过 Spring 容器管理 NewTopic,应用在启动时会自动补全缺失的主题定义。分区数设为 3,当前消费组最多可由 3 个消费者并发消费。副本因子受限于单节点 Broker 环境,在此设置为 1。
第五章 消息生产者
5.1 生产端逻辑实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| @Service public class OrderProducer {
private final KafkaTemplate<String, String> kafkaTemplate; private final ObjectMapper objectMapper;
public OrderProducer(KafkaTemplate<String, String> kafkaTemplate, ObjectMapper objectMapper) { this.kafkaTemplate = kafkaTemplate; this.objectMapper = objectMapper; }
public void send(OrderEvent event) { try { String json = objectMapper.writeValueAsString(event); String key = String.valueOf(event.orderId());
kafkaTemplate.send(KafkaTopicConfig.ORDER_TOPIC, key, json) .whenComplete((result, ex) -> { if (ex != null) { System.err.println("Kafka发送失败: " + ex.getMessage()); return; } var metadata = result.getRecordMetadata(); System.out.printf("Kafka发送成功 topic=%s partition=%d offset=%d%n", metadata.topic(), metadata.partition(), metadata.offset()); }); } catch (JsonProcessingException e) { throw new RuntimeException("订单事件序列化失败", e); } } }
|
5.2 发送行为与模式对比
发送消息通常有三种交互方式:
- 发后即忘(Fire-and-forget):调用
send() 后不处理返回值。响应快,但网络故障时无法感知投递丢失,可靠性最弱。
- 同步阻塞(Synchronous):调用
send().get() 显式等待服务端应答。线程阻塞在网络 I/O 上,吞吐量低,也违背了事件驱动的初衷。
- 异步回调(Asynchronous Callback):使用
whenComplete() 注册监听逻辑。业务主线程向底层发送缓冲区追加消息后即可继续向下执行,底层的 I/O 线程会在收到 ACK 时异步触发相应回调,兼顾吞吐与可观测性。
5.3 控制器入口与测试设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| @RestController @RequestMapping("/orders") public class OrderController {
private final OrderProducer orderProducer;
public OrderController(OrderProducer orderProducer) { this.orderProducer = orderProducer; }
@PostMapping public OrderEvent createOrder(@RequestBody CreateOrderRequest request) { OrderEvent event = new OrderEvent( UUID.randomUUID().toString(), request.orderId(), request.userId(), request.productId(), request.amount(), LocalDateTime.now() ); orderProducer.send(event); return event; }
@PostMapping("/replay") public OrderEvent replay(@RequestBody OrderEvent event) { orderProducer.send(event); return event; } }
|
第六章 消息消费者
6.1 消费端监听逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| @Component public class OrderConsumer {
private final ObjectMapper objectMapper; private final OrderProcessService orderProcessService;
public OrderConsumer(ObjectMapper objectMapper, OrderProcessService orderProcessService) { this.objectMapper = objectMapper; this.orderProcessService = orderProcessService; }
@KafkaListener(topics = KafkaTopicConfig.ORDER_TOPIC, groupId = "order-service-v2") public void consume(ConsumerRecord<String, String> record, Acknowledgment acknowledgment) { System.out.printf("收到消息 topic=%s partition=%d offset=%d key=%s%n", record.topic(), record.partition(), record.offset(), record.key());
OrderEvent event; try { event = objectMapper.readValue(record.value(), OrderEvent.class); } catch (JsonProcessingException e) { throw new BadMessageException("订单消息 JSON 无法解析", e); }
ProcessResult result = orderProcessService.process(event); System.out.println("ProcessResult: " + result);
acknowledgment.acknowledge(); } }
|
6.2 异常传递与位点提交控制
编写 Consumer 时最常见的误区是随意包裹大粒度的 try-catch (Exception e) 并在 catch 块中仅打印一条日志后继续向下执行。这会导致异常被内部消化,Spring Kafka 容器认为本轮消费正常结束,进而触发 ACK 并推进 Offset,失败的消息就这样被悄悄跳过。
正确的做法是让业务异常向外抛出至容器层。Spring Kafka 拦截到未处理异常后,会中止本次位点确认,并将其转交给配置好的 DefaultErrorHandler 执行后续重试或降级分流。
第七章 重试与死信队列
7.1 异常的区分与分流策略
生产环境中的消费异常大致可归为两类:
- 瞬态故障(Transient Failure):如数据库连接池打满、慢查超时、微服务网络毛刺。这些错误通常在数秒内具备自愈可能,应当使用退避策略原地重试。
- 确定性故障(Deterministic Failure):如业务载荷字段缺失、JSON 字符串破损、数字反序列化溢出。这类输入无论尝试多少次结果均不会发生改变,盲目重试只会消耗系统资源,必须直接拦截并路由至死信队列。
7.2 错误处理器配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| @Configuration public class KafkaErrorConfig {
@Bean public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate<String, String> kafkaTemplate) { DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer( kafkaTemplate, (record, exception) -> new TopicPartition(record.topic() + "-dlt", record.partition()) );
FixedBackOff fixedBackOff = new FixedBackOff(2000L, 2L);
DefaultErrorHandler errorHandler = new DefaultErrorHandler(recoverer, fixedBackOff);
errorHandler.addNotRetryableExceptions(BadMessageException.class);
errorHandler.setCommitRecovered(true);
errorHandler.setRetryListeners((record, exception, deliveryAttempt) -> { Throwable rootCause = exception; while (rootCause.getCause() != null) { rootCause = rootCause.getCause(); } System.err.printf("消费失败 [Topic: %s, Partition: %d, Offset: %d, 尝试序号: %d] 根因: %s: %s%n", record.topic(), record.partition(), record.offset(), deliveryAttempt, rootCause.getClass().getSimpleName(), rootCause.getMessage()); });
return errorHandler; } }
|
setCommitRecovered 的必要性
当一条消息耗尽所有重试机会并成功转储至 DLT 后,setCommitRecovered(true) 会通知容器自动提交该原消息在业务 Partition 的 Offset。若未开启此项,消费者实例一旦在后续某个时刻发生重平衡或重启,仍会从这条未提交的“毒消息”处重新拉取,再次陷入重试循环。
7.3 死信队列监听器
1 2 3 4 5 6 7 8 9 10
| @Component public class OrderDltConsumer {
@KafkaListener(topics = KafkaTopicConfig.ORDER_DLT, groupId = "order-dlt-inspector") public void consume(ConsumerRecord<String, String> record, Acknowledgment acknowledgment) { System.err.printf("[死信告警] 捕获不可恢复事件 Key: %s, 内容: %s%n", record.key(), record.value()); acknowledgment.acknowledge(); } }
|
第八章 幂等消费与事件去重
8.1 为什么必须在业务层做幂等
在分布式环境下,Kafka 遵循 At-Least-Once(至少投递一次)语义。只要存在网络抖动,就无法完全避免重复消费。
配置了 enable.idempotence = true 的 Producer 只能确保同一个消息批次写入 Kafka 日志时不重复;一旦 Consumer 处理完毕,但位点因断网没能提交到 Kafka,这条记录在重新平衡或重启后必会被二次投递。
8.2 基于本地事务的去重实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| @Service public class OrderProcessService {
private final JdbcTemplate jdbcTemplate; private final FailureSimulator failureSimulator;
public OrderProcessService(JdbcTemplate jdbcTemplate, FailureSimulator failureSimulator) { this.jdbcTemplate = jdbcTemplate; this.failureSimulator = failureSimulator; }
@Transactional public ProcessResult process(OrderEvent event) { int inserted = jdbcTemplate.update( "INSERT IGNORE INTO processed_event (event_id, order_id, processed_at) VALUES (?, ?, ?)", event.eventId(), event.orderId(), Timestamp.valueOf(LocalDateTime.now()) );
if (inserted == 0) { return ProcessResult.DUPLICATE; }
failureSimulator.maybeFail(event);
jdbcTemplate.update( "INSERT INTO orders (order_id, user_id, product_id, amount, status, created_at) VALUES (?, ?, ?, ?, ?, ?)", event.orderId(), event.userId(), event.productId(), event.amount(), "CREATED", Timestamp.valueOf(event.createdAt()) );
return ProcessResult.SUCCESS; } }
|
为什么去重记录与业务操作必须绑定在同一个 @Transactional 中?
假设将去重标志写入内存 Set,或在非事务环境下提前独立写入去重表:
- 消费端拉取消息,在去重表中插入了记录;
- 随后在执行业务落库或外部逻辑时抛出异常崩溃;
- 本次消费流程失败中断,Kafka 触发稍后重试;
- 重试拉取同一条消息再次执行,此时由于去重表中已存在记录,系统直接返回
DUPLICATE 并确认位点。
结果订单根本没有创建,这条业务数据就丢了。把 processed_event 和 orders 放进同一个本地事务后,业务异常回滚时去重占位记录会一起撤销;只有某次重试完整执行成功,两条记录才会一起提交。
第九章 故障模拟与实战验证
9.1 模拟器实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Component public class FailureSimulator {
private final ConcurrentHashMap<String, AtomicInteger> attempts = new ConcurrentHashMap<>();
public void maybeFail(OrderEvent event) { int attempt = attempts.computeIfAbsent(event.eventId(), key -> new AtomicInteger(0)) .incrementAndGet();
if (event.orderId().equals(8888L) && attempt <= 2) { throw new RuntimeException("模拟临时故障,第 " + attempt + " 次失败"); }
if (event.orderId().equals(9999L)) { throw new RuntimeException("模拟永久业务异常"); } } }
|
9.2 场景验证
场景一:正常流程写入
1 2 3
| curl -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"orderId":1001,"userId":1,"productId":100,"amount":99.9}'
|
控制台正常打印写入日志,MySQL 中 orders 与 processed_event 均生成相应记录,位点正常提交。
场景二:瞬时异常与退避自愈
1 2 3
| curl -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"orderId":8888,"userId":1,"productId":100,"amount":50.0}'
|
- T = 0s:首次执行,触发异常,本地事务回滚,打印第 1 次失败;
- T = 2s:等待 2 秒退避后自动重试,再次触发异常回滚,打印第 2 次失败;
- T = 4s:等待 2 秒后发起第 2 次重试(第 3 次执行),模拟器放行,事务提交,向 Kafka 发起 ACK。数据最终一致。
场景三:永久异常耗尽重试入死信
1 2 3
| curl -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"orderId":9999,"userId":1,"productId":100,"amount":50.0}'
|
经历初次执行与 2 次退避重试后,系统确认重试次数耗尽,DeadLetterPublishingRecoverer 捕获该事件并转发至 order-events-dlt。死信监听器输出告警,原队列位点向前推进。
场景四:重复投递与幂等拦截
先发送一笔正常订单并记录其返回报文中的 eventId:
1 2 3
| curl -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"orderId":2001,"userId":2,"productId":200,"amount":199.9}'
|
将刚才的完整报文原样通过重放接口二次投递:
1 2 3
| curl -X POST http://localhost:8080/orders/replay \ -H "Content-Type: application/json" \ -d '{"eventId":"<替换为上述实际ID>","orderId":2001,"userId":2,"productId":200,"amount":199.9,"createdAt":"2026-09-19T10:00:00"}'
|
控制台输出 ProcessResult: DUPLICATE,业务跳过实际 SQL 插入直接确认位点,MySQL 中并未产生重复订单行。
场景五:格式损坏直接分流
用 Kafka 自带的命令行生产者向 order-events 写入一条非 JSON 文本:
1 2 3 4 5
| docker exec -it kafka kafka-console-producer \ --bootstrap-server localhost:9092 \ --topic order-events \ --property "parse.key=true" \ --property "key.separator=:"
|
输入非法载荷:
Consumer 捕获到 JsonProcessingException 并重新包裹为 BadMessageException 向上抛出。由于该异常被显式标记为不可重试,系统跳过退避等待,直接将其投递进 order-events-dlt。
第十章 生产环境最佳实践
10.1 轮询生命周期参数管理
max.poll.records
定义了消费者底层单次调用 KafkaConsumer.poll() 允许返回的最大数据条数(默认 500)。如果单次拉取数据过多,而每条业务处理耗时较长,整批任务的总耗时就可能超过 max.poll.interval.ms。因此耗时较长的任务应当调低该值。
两次 poll 之间允许的最大时间跨度(默认 5 分钟)。如果某批记录处理过慢导致超过该阈值,Kafka 集群会判定该消费者工作线程已挂死,直接将其踢出 Consumer Group 并触发 Rebalance,导致该分区的任务被频繁重新指派并二次执行。
10.2 重平衡(Rebalance)与规避
Rebalance 是 Consumer Group 重新分配分区的过程。触发条件包括:
- 组内实例扩容、缩容或滚动发布重启;
- 某个实例因 Full GC 暂停或业务长时阻塞,导致心跳超时(
session.timeout.ms)或轮询超时(max.poll.interval.ms);
- 订阅的主题发生了分区扩容。
发生频繁重平衡期间,消费者需要挂起当前工作重新认领分区,易造成消费抖动与 Lag 攀升。规避手段包括设置合理的 GC 策略避免长暂停、调大心跳超时窗口、并将耗时过长的非核心业务(如第三方支付验证)异步化。
10.3 消费积压(Consumer Lag)的排查路径
当监控平台发出 Lag 持续增长告警时,排查次序通常如下:
- 检查消费状态:确认工作节点是否存在大量错误重试日志,或者发生频繁 Rebalance;
- 分析耗时瓶颈:排查数据库是否有慢查引发了线程池等待,或者外部 RPC 调用出现高延迟;
- 核算并行度上限:如果所有消费者节点 CPU 与 I/O 正常,仅为纯粹的业务流量突增,应评估当前的分区数。若 Topic 仅有 3 个 Partition,把消费者实例加到 10 个也没有效果,需要先扩充 Partition 数量,再水平扩容消费者。
10.4 消息契约演进原则
生产系统的报文格式迟早要变更,演进时遵循以下约定:
- 只增不减:只允许向报文中新增可为空的字段,严禁删除已有字段或变更现有字段的物理类型;
- 宽容解析:消费端 ObjectMapper 应当启用反序列化忽略未知属性配置,避免生产端发出新属性时下游全线崩溃;
- 复杂体系契约治理:对于跨部门的大规模微服务架构,可逐步引入 Apache Avro 或 Protobuf,依托 Schema Registry 服务在集群服务端进行 Schema 兼容性强校验。
附录 A 常见问题排查
A.1 为什么消费端始终无法拉取到消息?
依次排查:
- 执行
kafka-topics --list,确认投递的主题名称拼写是否一致;
- 执行
GetOffsetShell 检查该主题分区的 LOG-END-OFFSET 是否在持续递增,确认消息是否真正写进了集群;
- 执行
kafka-consumer-groups --describe --group <group-id>,核对当前消费组的分配拓扑,排查是否由于组内节点数量多于分区数导致当前节点处于空闲状态。
A.2 为什么修改了 group-id 后旧消息全部重新被消费了一遍?
当更换了全新的 group-id 时,Kafka 会认为这是一个完全未曾见过的消费群体。由于该群体在服务端不存在任何已提交的 Offset 记录,客户端将退回并应用 auto-offset-reset: earliest 的策略,从当前依然保存在日志中的最老位点从头回放全量数据。
A.3 为什么增加消费者节点后消费速率毫无变化?
Kafka 的分配约束是:同一个 Consumer Group 内,单个 Partition 在同一时刻只能指派给一个 Consumer。如果主题只有 3 个分区,那么组内最多只有 3 个消费者在并行工作。继续追加节点只会使其陷入无分区可分配的闲置状态,必须对 Topic 执行分区扩容才能解锁更高的并行度。
附录 B 关键配置速查表
B.1 核心概念边界判定
| 概念 |
定位 |
作用 |
| orderId |
业务主键 / Kafka Key |
决定消息散列的分区,守住单订单维度的有序处理 |
| eventId |
操作唯一标识 (UUID) |
存入去重表 processed_event,防止重复消费产生业务副作用 |
| Partition |
物理并发与存储单元 |
决定 Consumer Group 消费吞吐量的并发上限 |
| Offset |
单分区日志递增位点 |
供消费者标记当前分区的进度,作为故障恢复的基点 |
B.2 三种幂等与事务的边界区分
- Producer 幂等(
enable.idempotence = true):利用内部 PID 与 Sequence Number,消除生产客户端向 Broker 投递时因网络重试引发的日志重复写入。
- Consumer 幂等(
processed_event 约束):解决消费端在接收到多次重复投递时,防止业务执行(如扣款、插表)产生重复副作用。
- 本地事务(
@Transactional):确保“去重标记插入”与“订单业务插入”在本地数据库中一起提交或一起回滚。业务异常回滚时,去重记录同步消失,从而保证后续的重试得以正常重入。