Kafka 消费者重试与死信:Spring for Apache Kafka 的边界设计
一、先把问题说清楚:一条消息处理失败之后发生了什么
假设你维护一个订单履约系统:订单服务把"订单已支付"事件写进 Kafka 主题 order-paid,履约服务订阅这个主题,收到消息后调用库存服务扣减库存、再写一条履约记录。某天库存服务因为一次发布短暂不可用,履约服务消费到的消息在扣库存时抛出异常。这时候你必须回答四个问题:
- 这条消息会不会被重试?重试几次?谁来决定?
- 重试的时候,消费线程是被占住等待,还是能腾出来处理别的消息?
- 重试全部失败后,这条消息会消失吗?会阻塞后面的消息吗?
- 如果重试成功了一次但结果已经写进数据库、第二次又执行一遍,业务会不会重复扣库存?
这四个问题分别对应 Spring for Apache Kafka(下称"Spring Kafka")里的四块机制:监听容器并发模型 、错误处理器链 、非阻塞重试与死信主题投递 、消费位移提交语义 。很多线上事故不是因为开发者不知道这些名词,而是因为默认配置下的行为与直觉不一致:以为重试是在"另一条线程里慢慢重试",实际是当前线程被 sleep 占住;以为失败消息会被丢进死信队列,实际只是被跳过并提交了位移;以为"至少一次"语义只影响极端情况,实际在重平衡时经常发生。
本文的目标不是把 Spring Kafka 的所有配置项罗列一遍,而是沿一条主线讲清楚:当一条消息处理失败,Spring Kafka 在什么条件下、由哪个组件、对线程和位移做了什么,最终产生什么可观察结果。为了让结论可验证,我们会在第四节搭一个最小可运行工程,故意先写成错误配置,观察它的失败现象,再改成正确配置,用日志、消费者组位移和死信主题内容来验证。
先约定本文的版本基线,后文凡是涉及版本敏感的行为都会标注适用范围:
| 项目 | 版本 | 说明 |
|---|---|---|
| JDK | 21 | 示例编译与运行基线 |
| Spring Boot | 4.1.x | 新项目示例 |
| Spring Framework | 7.0.x | 由 Boot BOM 管理 |
| Spring for Apache Kafka | 由 Boot 4.1 BOM 管理的 4.x 版本 | 具体补丁版本随 BOM 走,不在此写死 |
| Apache Kafka | 4.x 服务端(KRaft 模式) | 仅作为被连接的服务端 |
| 存量对照 | Spring Boot 3.5.x / Spring Kafka 3.x | 迁移时注意 API 差异 |
需要提醒的是:Spring Kafka 的版本号与 Spring Boot BOM 强绑定,官方建议通过 Boot 的依赖管理引入,不手动指定 Spring Kafka 版本,否则容易出现 kafka-clients 版本错配。本文涉及的类名和配置属性都基于上述版本;如果你在 Boot 3.5 上复现,个别类(例如错误处理器的一些构造方式)可能不同,文末会给出迁移注意点。下面所有"已验证""预期观察"的表述都会区分是官方文档保证、源码可见行为,还是需要你在自己环境确认的推断。
1.1 为什么"默认配置"是最危险的地方
很多团队在接入 Kafka 时的第一份代码往往只有一个 @KafkaListener,没有任何错误处理相关配置。这份代码在正常流量下工作得很好,于是被当作模板复制到别的消费者上。问题在于:错误的暴露前提是失败发生,而失败在开发期往往不具备发生的条件。库存服务在本地永远可用,所以本地阻塞重试的代价看不到;broker 只有一个分区、消息只有几条,所以顺序性被偶然满足;DLT 主题不存在,因为从未配置过死信恢复器。
等到生产环境出现第一次下游抖动,这些被隐藏的默认行为同时爆发:消费线程被退避时间占住,分区 LAG 迅速上升,重平衡触发,位移回退,消息被重复消费,同时业务侧又没有任何去重,最终表现为库存被扣了两次、履约记录写了两次,而日志里只留下几段相似的异常栈。事后复盘时,团队能定位到"某条消息处理失败",却很难说清"为什么失败一条消息会把整个分区拖住",因为默认行为的因果链没有在设计阶段被显式写出来。
所以本文把"默认配置下会发生什么"作为第一条主线,而不是把"如何配置重试"作为起点。理解默认行为,才知道自己在什么时候需要偏离它;不理解默认行为,任何自定义配置都只是迷信。
1.2 四个问题之间的关系不是并列的
上面四个问题看起来是并列的,实际有依赖关系:并发模型决定了重试的成本,因为重试要么占用并发单元,要么把工作移交给另一个并发单元;错误处理器链决定了失败消息的走向,它又直接决定了位移怎么提交;位移提交语义决定了重复消费的边界,而重复消费是否会造成业务损害,取决于业务是否幂等。
用一个更直白的顺序表达:先有并发模型,才有"重试占不占线程"这个问题;先有错误处理器的决策,才有"位移前进还是回退"这个问题;先有位移语义,才有"重复消费能不能避免"这个问题;而重复消费是否被接受,最终由业务幂等决定。这条链条上任何一环被误解,后面的结论都会错。
二、环境与兼容版本:先把地基铺平
2.1 依赖怎么引
最小工程只需要三样东西:Spring Boot Web(方便暴露一个查询接口观察位移)、Spring for Apache Kafka、以及测试用的 spring-kafka-test(它内置一个可编程的嵌入式 Kafka 与 @EmbeddedKafka 支持)。用 Maven 表达如下:
xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
这里没有写 kafka-clients 的版本,是刻意的:Spring Kafka 的 POM 会声明它测试过的 kafka-clients 版本,Boot BOM 会把两者对齐。手动覆盖 kafka-clients 到更新的小版本通常可行,但跨大版本(例如把 3.x 客户端塞进为 4.x 客户端设计的 Spring Kafka)属于未验证组合,出了序列化或协议错误很难排查。
2.2 需要理解的两个"版本层次"
Kafka 生态里有两个独立的版本概念,混淆它们是很多配置错误的根源:
- 客户端库版本 :你项目里
kafka-clients的版本,决定生产/消费 API 和协议能力。 - 服务端 broker 版本:决定主题、事务、日志格式等能力,也决定一些协议特性是否可用。
普通消费者用到的能力(拉取、提交位移、消费者组协调)在很老的 broker 上就有,所以"客户端新、broker 旧"通常能跑;但像增量式合作再均衡(cooperative rebalancing)、静态成员(static membership)这类特性和 broker 版本、客户端版本都有关。本文主线只用到基础能力,因此对 broker 版本要求不高,但如果你要在生产启用 CooperativeStickyAssignor,需要确认客户端和 broker 都支持。这一点属于"特性可用性随版本变化"的范畴,具体以所用版本的官方文档为准。
2.3 一个容易被忽略的前提:主题要预先存在
如果你连接的是真实 broker 并关闭了自动建主题(生产环境常见的安全设置),那么 order-paid、order-paid-retry-0、order-paid-dlt 这些主题都必须提前创建。Spring Kafka 提供了 KafkaAdmin 与 NewTopic Bean 来声明主题,示例工程里会这样写,好处是本地测试和集成测试可以自动建好,免得"重试主题不存在"被误诊为"重试没生效"。
这条前提在排障中格外重要,因为**"重试没生效"和"重试主题没建出来"在日志上看起来很像**:都表现为失败消息没有被再次消费。区分方法不是看应用日志里有没有重试记录,而是先看 broker 上主题列表里有没有对应的重试主题和 DLT。如果主题不存在,那么讨论重试次数、退避时间、异常分类都没有意义,因为消息从第一步就没有地方可去。
2.4 测试环境的取舍
第五节会给出两种验证路径:一种用 @EmbeddedKafka 在测试里跑,适合 CI;另一种用 Docker Compose 起一个单节点 Kafka,适合手工观察位移和死信内容。两条路径的结论应当一致,但 @EmbeddedKafka 的 broker 是内嵌的、单节点的,重平衡和分区分配行为比真实集群简化,涉及多消费者重平衡的场景建议用 Docker 路径验证,或至少在结论里注明"这是单节点观察"。
另外要区分"单节点观察"与"生产事实"的差别:单节点下消费者组协调器只有一个 broker,重平衡的触发条件与多节点集群不同;单节点下没有副本同步,所以与副本、ISR 相关的行为完全观察不到。本文主线只涉及单分区内的处理与位移,这些结论在单节点下和多节点下应当一致,但凡是涉及"多个消费者竞争分区""重平衡导致重复消费"的推演,都需要在更接近生产的环境里再确认一次。
三、核心机制:从拉取到提交的完整链路
3.1 监听容器到底是什么
"监听容器"这个词容易让人以为它是一个请求分发器。更准确的理解是:它是一个长期存活的循环,负责从 Kafka 拉一批消息,把它们交给你的监听方法,然后按规则提交位移 。Spring Kafka 把这个循环封装成 MessageListenerContainer,你写的 @KafkaListener 方法只是它回调的目标。
它的生命周期大致是:容器启动 → 消费者加入消费者组、被分配分区 → 循环"拉取一批 → 逐条(或批量)调用监听器 → 提交位移" → 容器停止时提交最终位移并退出组。这个循环里任何一个环节出问题,都会体现为"消息没被处理"或"消息被处理了两次"。
关键点是:位移提交发生在监听方法返回(或抛出被错误处理器吞掉的异常)之后。这就解释了为什么"处理失败但异常被吞掉"会导致消息丢失------容器认为这条消息处理完了,于是提交位移,Kafka 就不会再把它投给这个消费者组。
还有一个容易被忽略的细节:容器的"循环"和"拉取"不是一一对应的。一次 poll 可能带回多条消息,容器把这些消息逐条交给监听方法,然后按 AckMode 决定何时提交。所以"一条消息失败"在容器视角下不是一个孤立事件,而是"一批处理过程中的某一次回调失败",这个视角差异在讨论位移时会反复出现。
3.2 并发模型:并发度、分区与线程的三角关系
并发模型是理解重试成本的前提。Spring Kafka 的并发由 concurrency 控制,含义是启动多少个消费者实例(线程)。不是"多少线程处理一条消息",而是"多少个并行的消费流"。每个消费流内部是单线程顺序处理的。
这里有一个硬约束必须先讲清楚:Kafka 的消费并行度上限是分区数 。同一个消费者组里,一个分区在同一时刻只能被一个消费者实例消费。所以如果你把 concurrency 设成 8,而主题只有 3 个分区,那么最多 3 个线程有活干,另外 5 个线程处于空闲(它们仍然是组成员,但不持有分区)。这不是浪费,只是提醒你:并发度应该与分区数对齐,而不是越大越好。
主题 order-paid:分区 P0 P1 P2
消费者组 fulfillment,concurrency=3
线程 C0 -> P0 -> 顺序消费 P0 上的消息
线程 C1 -> P1 -> 顺序消费 P1 上的消息
线程 C2 -> P2 -> 顺序消费 P2 上的消息
若 concurrency=5:
线程 C0..C2 持有分区;C3、C4 空闲(仍是组成员)
这张图想说明的因果是:单个分区内的消息是严格顺序的。如果 P1 上有一条"毒消息"(怎么处理都失败),而重试又是阻塞式的,那么 C1 会被卡住,P1 上排在它后面的所有消息都处理不了。这就是"阻塞重试会拖住整个分区"的机制来源,也是为什么需要非阻塞重试方案。
这里再补一层:并发度调大不会让单个分区的处理变快,只会让更多分区被并行处理。所以"加大 concurrency 解决消费积压"这个经验只在分区数足够时成立。如果你的主题只有 3 个分区,把 concurrency 从 3 调到 30 只会增加空转的消费者实例和组协调负担,积压仍然受限于 3 个分区的处理速度。要提高单个分区的吞吐,只能优化单条消息的处理耗时,或者把业务按 key 拆到更多分区。
另一个常被低估的配置是 max.poll.records(一次拉取多少条)和 max.poll.interval.ms(两次拉取之间允许的最长间隔)。如果一批消息处理很慢(比如每条都要调远程服务),处理时间超过 max.poll.interval.ms,broker 会认为这个消费者已经死了,把它踢出组并触发重平衡,于是这批消息会被另一个消费者重新消费------这就是典型的"重复消费 + 处理更慢 + 更频繁重平衡"的恶性循环。Spring Kafka 对批处理场景提供了 BatchListenerFailedException 等机制来精细控制,但主线不展开。
3.3 错误处理器链:异常从监听方法到容器
监听方法抛出的异常不会直接"消失",它会被交给错误处理器。Spring Kafka 的错误处理有两层结构:
- 监听器级别 :
@KafkaListener(errorHandler = ...)或容器工厂上的默认错误处理器。 - 容器级别 :
CommonErrorHandler(这是 Spring Kafka 2.8 之后统一的概念,替代了早期的ErrorHandler+RetryingMessageListenerAdapter组合;3.x、4.x 沿用并扩展)。
这条链的核心行为可以概括成一句话:错误处理器决定"这条消息是重试、是跳过、还是拒绝";它做出决定后,容器据此决定要不要提交位移。
常见的 CommonErrorHandler 实现有:
DefaultErrorHandler:默认实现,支持有限次数的本地重试和"不可重试异常"判断。FixedBackOff/ExponentialBackOff:重试的退避策略对象。DeadLetterPublishingRecoverer:重试耗尽后把消息发到死信主题。
这里有个语义细节很重要:"重试"发生在同一个监听容器线程里 。DefaultErrorHandler 的本地重试默认是不阻塞的?其实反过来------它在当前线程里执行退避(BackOff),退避期间线程在等待。也就是说,FixedBackOff(1000L, 3) 表示"失败后等 1 秒再试,最多重试 3 次",这 1 秒里当前消费线程什么也干不了。对于 P1 这样的分区,意味着这 1 秒里 P1 后面的消息都被挡住了。这是"本地重试"与"非阻塞重试"最本质的差别,也是本文后面要专门解决的核心问题。
还有一类异常被视为"不可重试",典型是反序列化失败、消息格式错误。这类错误重试多少次都不会成功(因为输入本身就是坏的),DefaultErrorHandler 默认可通过 addNotRetryableExceptions 声明,或在 Spring Kafka 4.x 里借助 ExceptionClassifier 思路分类。对这类消息,合理的处理是直接进死信主题,而不是空转重试把分区堵死。
这里需要额外说明一个判断原则:"是否可重试"本质上是业务判断,不是技术判断。反序列化失败天然不可重试,因为它不依赖任何外部状态;而"库存不足"可能是可重试的(等补货),也可能是不可重试的(这个订单永远无法履约)。框架只能根据异常类型做默认分类,业务必须显式告诉框架哪些异常属于"再试也没用"。如果这项判断交给默认值,就会出现"明明库存永远不足,却被重试了若干次、拖住了分区"的情况。
3.4 位移提交语义:至少一次、至多一次与提交时机
消费位移提交语义是本文四个机制里最底层、也最容易被"默认值"掩盖的。Kafka 消费者有两种提交方式:
- 自动提交 :
enable.auto.commit=true,由客户端库按auto.commit.interval.ms周期性提交"已经拉取到的位置",而不是"已经处理完的位置"。 - 手动提交 :由应用在合适的时机调用提交;Spring Kafka 的容器在
AckMode控制下替你决定时机。
Spring Kafka 的默认 AckMode 是 BATCH:一批消息处理完之后提交这批里最大的位移。这意味着如果在"处理成功若干条、随后一条失败"的批里,提交位置会覆盖到哪一条,取决于错误处理器是否吞掉了异常。如果错误处理器把失败消息"跳过"(例如发到死信后返回),容器会认为它处理完了,位移继续前进,这通常是期望行为;如果错误处理器让异常继续抛出,容器会停止这批的处理并把位移退回到批的起点,于是整批被重新消费。
一个必须强调的语义边界:Kafka 的消费者只保证提交位移之前的消息不会因为"提交"而丢失,但提交之后崩溃仍可能导致重复 。因此"至少一次"是所有默认配置下的现实语义;"恰好一次"需要事务生产者 + 读-处理-写事务(KafkaTransactionManager)或幂等消费端设计,且 Kafka 事务不覆盖你写到外部数据库的副作用。本文主线不把事务当默认,而是把 幂等式处理 作为工程上更可靠的基础。
再补一个常被混淆的点:BATCH 模式下"提交这批里最大的位移"并不是"把每条消息的位移都提交一遍",而是提交一个位置。位置的含义是"这个位置之前的消息都已经处理过"。所以当一批 10 条消息里第 7 条失败且异常继续抛出时,容器把位移停在批的起点,第 1 到第 6 条会被再次投递,尽管它们上一次已经成功。这就把"批处理"与"局部失败"的张力暴露出来了:批越大,单条失败导致的重复工作量越大 。这是 max.poll.records 不能随便调大的另一个原因,前文从重平衡角度讲过,这里从位移语义角度再成立一次。
3.5 非阻塞重试:把等待从消费线程里挪出去
"非阻塞重试"解决的是上一节提到的核心矛盾:本地重试会占住分区。它的思路是:失败消息不留在原地重试,而是被转发到一个"重试主题",由另一个消费者按延迟计划再消费;原来的消费线程立刻返回,继续处理同一分区的后续消息。
Spring Kafka 为此提供了一套约定式的基础设施:RetryTopicConfiguration(或者 @RetryableTopic 注解)+ DefaultErrorHandler 配合的非阻塞重试链路,其结构大致是:
order-paid
│ 失败
▼
order-paid-retry-0 (第一次重试,延迟 t0)
│ 失败
▼
order-paid-retry-1 (第二次重试,延迟 t1)
│ 失败
▼
order-paid-dlt (死信主题,停止重试)
每一层重试主题的消费都遵循同样的"拉-处理-提交"循环。这样做的代价是:主题数量随重试次数线性增长,且延迟是通过"消息在重试主题里等待"或"定时器"来实现的,具体是哪种,取决于 Spring Kafka 版本与配置(较新的版本支持用 @RetryableTopic 的 delay 等属性表达延迟,并由后台调度器把消息重新发布回重试主题;早期实现更多依赖"在重试主题上等待"的想法,实际落地需要你确认所用版本的 Destinations 语义)。这里必须诚实说明:重试延迟的精确性不保证,它不是定时任务,具体精度以所用 Spring Kafka 版本的文档与源码为准,不要把它当成"延迟 5 秒一定在 5 秒后执行"。
非阻塞重试还引入了一个新问题:顺序性 。如果你把失败消息转发到重试主题、让主主题继续往下走,那么"消息 A 失败、消息 B 成功"的顺序就被打破了------B 可能先于 A 的重试完成。对于订单这类有严格顺序要求的场景,这是必须接受的取舍,或者改用分区级暂停、单线程串行等更保守的方案。非阻塞重试交换的是"分区不被阻塞",代价是"顺序不再有保证",这一点必须在设计时就写进文档。
还有一层常被忽略的边界:重试阶段的消费者组是另一套 。重试主题由框架生成的监听容器消费,它们有自己的并发配置与错误处理器。这意味着主主题的 concurrency 设置不会自动沿用到重试主题上,重试主题的并发同样受其分区数限制,而框架创建的重试主题分区数通常与原主题一致。如果原主题 3 个分区、重试阶段却配了很高的并发,同样会出现空闲线程。这一点在容量规划时容易漏掉,因为它是"隐式生成的容器",不在你的显式配置里。
3.6 死信主题:最后一站与它的语义边界
死信主题(Dead Letter Topic,DLT)是重试耗尽后的落点。它本身没有魔法:就是一个普通 Kafka 主题,里面存着处理失败的消息(通常带有原始消息和若干诊断头信息,例如异常类名、异常消息、原始主题、原始分区)。
需要清楚几个边界:
- 进 DLT 不等于问题解决。DLT 只是把"堵在分区里的坏消息"挪到"一个可以人工或补偿任务处理的主题"。如果没有监控和后续处理,它等价于消息黑洞。
- DLT 消费需要单独设计。谁消费 DLT?人工排查后重放回主主题,还是自动补偿?重放时如何避免再次触发同样的失败?这些问题不在框架保证范围内。
- DLT 的投递本身也可能失败 (broker 不可达、序列化失败)。
DeadLetterPublishingRecoverer在投递失败时会抛异常,此时容器的行为(是否重试、是否提交位移)取决于错误处理器链的配置,需要单独验证。
还有一个与顺序相关的边界值得单独写出来:DLT 里的消息顺序也不保证与原主题一致。如果原分区里 A、B 两条消息都失败并先后进入 DLT,DLT 的分区选择通常依据键值哈希;只有当 A、B 使用相同 key 且 DLT 分区数与原主题一致时才可能落在同一分区,从而保留相对顺序。跨 key 的消息在 DLT 中本来就没有顺序语义,重放时也不应该假设有。
3.7 一张图串起来
┌────────────────────────────────────────────┐
│ Kafka broker(order-paid 主题) │
└───────────────┬────────────────────────────┘
│ poll
▼
┌──────────────────────────────────────────────────────┐
│ 监听容器(concurrency=3,每个线程一个消费者实例) │
│ 线程 C0 -> P0 线程 C1 -> P1 线程 C2 -> P2 │
└───────────┬──────────────────────────────────────────┘
│ 调用监听方法
▼
┌──────────────────────────────────────────────────────┐
│ 业务监听方法(扣库存 + 写履约记录) │
└───────────┬───────────────────────────┬──────────────┘
│ 成功 │ 抛异常
▼ ▼
┌──────────────────────┐ ┌─────────────────────────────┐
│ 返回,容器提交位移 │ │ CommonErrorHandler 决策 │
└──────────────────────┘ └──────┬──────────────┬───────┘
│ 本地重试 │ 转发重试主题
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ 占住当前线程 │ │ order-paid-retry │
│ 退避后重试 │ │ -> dlt │
└────────────────┘ └──────────────────┘
这张图要表达的因果链是:异常不决定结果,错误处理器的决策才决定结果;决策又决定了位移与线程的命运。理解这一点,后面所有配置才有意义。
四、主线示例:一个订单履约消费者的"错误版 → 正确版"
4.1 业务场景与最小数据
我们做一个极简履约服务:
- 主题
order-paid,3 个分区,消息体是 JSON:{"orderId":"O-1001","sku":"SKU-1","qty":2}。 - 监听方法:先查本地库存表,扣减;再写一条履约记录。库存不足或下游异常时抛异常。
- 为了可控地制造失败,我们让库存服务在收到
sku=SKU-FAIL的消息时抛异常,其他消息成功。这样同一个分区里既有正常消息也有毒消息,方便观察"毒消息是否挡住后面的消息"。
最小数据(初始化 SQL):
sql
CREATE TABLE stock (
sku VARCHAR(32) PRIMARY KEY,
quantity INT NOT NULL
);
CREATE TABLE fulfillment_log (
order_id VARCHAR(32) PRIMARY KEY,
sku VARCHAR(32) NOT NULL,
qty INT NOT NULL,
created_at TIMESTAMP NOT NULL
);
INSERT INTO stock (sku, quantity) VALUES ('SKU-1', 100), ('SKU-FAIL', 100);
fulfillment_log 用 order_id 做主键,是后面讲幂等性的伏笔:如果同一条消息被消费两次,第二次插入会因主键冲突而失败,反而帮助我们发现重复。
为了让主线可以被完整复现,这里明确工程结构:一个 Spring Boot 应用,包含 KafkaConfig(主题声明与错误处理器)、OrderPaidListener(监听器)、OrderPaidProducer(测试消息发送)、StockService(业务逻辑,含扣减与写履约记录)。数据库用 H2 内存库即可,表由上面的 SQL 在启动时执行。这样整条链路的依赖只有一个 broker,其余都在进程内。
StockService 的主线实现要点如下(示意,未在本环境执行):
java
@Service
public class StockService {
private final JdbcTemplate jdbcTemplate;
public StockService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void deductAndRecord(OrderPaidEvent event) {
// 伪代码:先判断是否已处理,保证幂等
Integer handled = jdbcTemplate.queryForObject(
"select count(*) from fulfillment_log where order_id = ?",
Integer.class, event.orderId());
if (handled != null && handled > 0) {
return;
}
if ("SKU-FAIL".equals(event.sku())) {
throw new IllegalStateException("下游库存服务不可用(示意)");
}
jdbcTemplate.update(
"update stock set quantity = quantity - ? where sku = ?",
event.qty(), event.sku());
jdbcTemplate.update(
"insert into fulfillment_log (order_id, sku, qty, created_at) values (?, ?, ?, current_timestamp)",
event.orderId(), event.sku(), event.qty());
}
}
这段代码刻意把"查重 → 扣减 → 写记录"放在同一个方法里,但它不是事务边界:如果扣减成功、写记录失败,重试时查重查不到记录,会再扣一次。这正是后面 5.4 节要验证的问题。真实项目里,这两步要么放进同一个本地事务,要么用"先写记录、后扣减"的顺序配合幂等键,具体取舍取决于哪一步更难补偿。示例保留这个缺陷,是为了让第 6 节的反例有可观察的对象。
4.2 错误版本:默认配置下的"本地阻塞重试"
先写一个"看起来没问题"的版本:默认错误处理器 + 一个小的本地重试。
java
@Configuration
public class KafkaConfig {
@Bean
public NewTopic orderPaid() {
return TopicBuilder.name("order-paid").partitions(3).replicas(1).build();
}
@Bean
public DefaultErrorHandler defaultErrorHandler() {
// 重试 3 次,每次间隔 2 秒
return new DefaultErrorHandler(new FixedBackOff(2000L, 3L));
}
}
java
@Component
public class OrderPaidListener {
private static final Logger log = LoggerFactory.getLogger(OrderPaidListener.class);
private final StockService stockService;
public OrderPaidListener(StockService stockService) {
this.stockService = stockService;
}
@KafkaListener(topics = "order-paid", groupId = "fulfillment", concurrency = "3")
public void onOrderPaid(String payload) {
log.info("收到消息: {}", payload);
OrderPaidEvent event = Json.from(payload, OrderPaidEvent.class); // 伪代码:JSON 反序列化
stockService.deductAndRecord(event);
}
}
这段代码会产生什么? 注意这里的因果关系,而不是只记结论:
- 一条
SKU-FAIL的消息进来,stockService抛异常。 DefaultErrorHandler接住异常,判断是否可重试(默认大多数异常可重试)。- 它在当前消费线程里等待 2 秒,再重新调用监听方法。重试 3 次,共等待 6 秒。
- 如果 3 次都失败,
DefaultErrorHandler的默认恢复动作是记录日志并跳过 ------注意:默认情况下它并不自动把消息发到死信主题,除非你显式配置DeadLetterPublishingRecoverer。 - 跳过意味着异常被"吞掉",容器认为处理完成,位移继续前进。
于是你会看到两个后果:
- 该分区在这约 6 秒内被堵住。如果 P1 上排着 1000 条正常消息,它们要等这条毒消息的重试结束才能处理。
- 毒消息"消失"了:位移跳过它,没有 DLT,也没有补偿任务,你只能在日志里找到它。
这不是说这个配置一定错。如果业务上"失败就是失败,记日志即可",跳过是合理的;但如果你以为"重试几次后消息会自动进死信",那就错了。默认恢复动作是跳过,不是投递 DLT。
4.3 观察错误版本:用什么看
要验证上面的推演,可以观察三类证据:
- 应用日志:同一异常出现多次、间隔约 2 秒,最后一次出现后不再出现。
- 消费者组位移 :用
kafka-consumer-groups.sh --describe --group fulfillment看order-paid各分区的CURRENT-OFFSET是否越过了失败消息的偏移。如果越过了,说明被跳过。 - DLT 主题是否存在且为空 :如果没配置死信恢复器,
order-paid-dlt这个主题根本不会被创建,自然查不到。
命令示例(假设 broker 在 localhost:9092):
bash
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group fulfillment
预期输出里 LAG 列会在毒消息处理完后回到 0,CURRENT-OFFSET 指到毒消息之后。如果 LAG 一直是 0 但你知道应该有失败消息,就去日志里找,而不是在 DLT 里找------这是一个常见的误诊。
需要说明:以上是基于机制的预期结果,不是我在某台生产机器上的实测数据。你在自己环境复现时,日志时间戳与位移数字会不同,但"跳过 + 位移前进 + 无 DLT"这个组合应当稳定出现;如果没出现,优先检查错误处理器是否被其他 Bean 覆盖。
关于"错误处理器是否被覆盖",可以按这个顺序排查:先确认容器工厂上的 CommonErrorHandler 是不是你定义的那个(可以通过在 Bean 中打印或断点确认);再确认 @KafkaListener 上是否另设了 errorHandler 属性;最后确认是否有自动配置或其他 @Configuration 类也注册了同名或同类型的 Bean。Spring 容器在同类 Bean 冲突时会依赖名称与 @Primary 决定注入哪一个,而自动配置的默认值往往是"后注册者生效"或"用户定义优先",具体规则随版本与注册顺序变化------这也是为什么直接打印比记忆更可靠。
4.4 正确版本:非阻塞重试 + DLT
下面改成官方推荐思路:用 @RetryableTopic 让失败消息进入重试主题,耗尽后进 DLT;同时把不可重试异常直接送 DLT。
java
@Component
public class OrderPaidListener {
private static final Logger log = LoggerFactory.getLogger(OrderPaidListener.class);
private final StockService stockService;
public OrderPaidListener(StockService stockService) {
this.stockService = stockService;
}
@RetryableTopic(
attempts = "3",
backoff = @Backoff(delay = 1000, multiplier = 2.0),
dltTopicSuffix = "-dlt",
retryTopicSuffix = "-retry"
)
@KafkaListener(topics = "order-paid", groupId = "fulfillment", concurrency = "3")
public void onOrderPaid(String payload) {
log.info("收到消息: {}", payload);
OrderPaidEvent event = Json.from(payload, OrderPaidEvent.class);
stockService.deductAndRecord(event);
}
@DltHandler
public void onDlt(String payload) {
log.error("进入死信: {}", payload);
// 真实项目:写告警表、发通知、落地待人工处理表
}
}
这里有几个必须解释清楚的机制点,否则你会写出"看起来用了非阻塞重试、实际仍有问题"的代码:
第一,@RetryableTopic 是声明式的,它会自动创建重试主题和 DLT,并配置对应的监听器。 主题命名遵循"原主题名 + retryTopicSuffix + 序号"和"原主题名 + dltTopicSuffix"的约定。因为我们在配置里写了 -retry 和 -dlt,实际主题会是 order-paid-retry-0、order-paid-retry-1、order-paid-dlt(具体级数与 attempts 有关)。如果 broker 禁止自动建主题,需要你用 NewTopic 或运维脚本预先建好这些主题,否则重试链路会在"找不到主题"处失败------这是很常见的踩坑点。
第二,attempts 的语义是"总尝试次数",不是"重试次数"。 attempts=3 意味着首次消费 + 两次重试。如果你按"重试 3 次"去理解,就会多算一层,导致 DLT 提前到来。这个语义差异值得在团队里统一口径。
第三,非阻塞重试让原分区不再被堵住。 消息被转发到 order-paid-retry-0 后,原分区立刻可以继续处理后续消息。这正是我们要的效果。但如 3.5 节所说,顺序不再保证:后续消息可能先于重试消息完成。如果业务要求同一订单的事件严格有序,需要额外的按 key 分区 + 串行消费设计,或放弃非阻塞重试。
第四,@DltHandler 是你唯一能可靠接管"最终失败"的地方。 它没有魔法,就是把 DLT 主题的消息交给一个方法。你可以在这里写告警、落库、发人工处理工单。如果没有 @DltHandler,DLT 里的消息依然会被某个消费者(如果配置了 DLT 的监听容器)消费掉,但如果没人监听,它就静静躺在主题里。所以"配置了 DLT"和"处理了失败消息"是两件事。
第五,@RetryableTopic 生成的重试主题消费也遵循"至少一次"。 重试主题的监听器在处理失败并继续转发时,同样要提交位移。如果它在转发到下一层后、提交位移前崩溃,消息会再次被投递到当前重试主题,于是同一层可能出现两次转发。这不是框架缺陷,而是"至少一次"在多层主题链路里的自然延伸;它意味着重试层数越多,重复出现的窗口越多。
4.5 关于 DefaultErrorHandler 与 @RetryableTopic 共存
一个常见的困惑是:既然 @RetryableTopic 已经做了重试,那 DefaultErrorHandler 还有用吗?在 Spring Kafka 的实现里,非阻塞重试的链路本身也依赖错误处理器来"触发转发"(把失败消息发到下一个重试主题)。如果你同时在容器工厂上自定义了一个 DefaultErrorHandler(例如本文 4.2 节那个 FixedBackOff),两者可能叠加:一次失败先被本地重试 3 次、再被转发到重试主题。这就不是你要的行为了。实践建议是:用 @RetryableTopic 时,不要在同一个监听器上再套一个带重试的 DefaultErrorHandler;如果全局容器工厂上设了,需要确认 @RetryableTopic 生成的容器是否会覆盖它。 这个"是否会覆盖"属于版本相关细节,不同版本实现有别,建议在你的版本上用一个失败消息实测确认,而不是凭记忆判断。
这里可以给出一个不依赖版本细节的验证思路:把 FixedBackOff 的退避时间设成非常容易辨认的值(例如 2000 毫秒),然后观测失败消息从首次失败到出现在 order-paid-retry-0 的时间间隔。如果间隔约等于 2000 毫秒的倍数,说明本地重试先发生、之后才转发;如果几乎是立即转发,说明本地重试没有叠加。这个方法只依赖你自己的观测,不依赖对源码实现细节的记忆。
五、执行与观察:验证你理解的是不是真的
5.1 发送测试消息
用 KafkaTemplate 发几条消息,故意把毒消息和正常消息放在同一分区(用 orderId 做 key,让同一订单落同一分区;或直接指定分区)。
java
@Component
public class OrderPaidProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderPaidProducer(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void send(String orderId, String sku) {
String payload = "{\"orderId\":\"" + orderId + "\",\"sku\":\"" + sku + "\",\"qty\":1}";
// 显式指定分区 1,方便把毒消息和正常消息放在一起
kafkaTemplate.send("order-paid", 1, orderId, payload);
}
}
发送顺序很重要:先发 SKU-FAIL,再发一批 SKU-1。这样才能看到"毒消息是否挡住后面"。
完整的手工验证步骤可以固定为下面几步,便于复现:
- 启动 broker(单节点 KRaft 或 Docker Compose,均可),确认
localhost:9092可连。 - 启动错误版应用,等待容器启动完成、日志里出现分区分配信息。
- 通过一个临时端点或测试方法,先发 1 条
SKU-FAIL,再发 10 条SKU-1,全部指定分区 1。 - 观察应用日志:正常消息是否在毒消息重试结束前被处理;毒消息异常是否重复出现且间隔约 2 秒。
- 执行
kafka-consumer-groups.sh --describe,记录分区 1 的CURRENT-OFFSET与LAG随时间的变化。 - 换成正确版应用,重复 3 到 5 步,对比两次观察的差异。
第 4 步的关键观察点是"正常消息被处理的时间点"。如果 10 条正常消息在毒消息最后一条异常日志之后才集中出现,说明本地重试确实占住了分区;如果它们在毒消息重试期间就陆续出现,说明你观察的其实不是同一分区,或者错误处理器没有生效。
5.2 需要观察的三个证据
| 证据 | 怎么看 | 预期(正确版) | 预期(错误版) |
|---|---|---|---|
| 主主题处理进度 | kafka-consumer-groups.sh --describe |
order-paid 的 LAG 很快归零 |
LAG 在重试期间停滞约 6 秒 |
| 重试主题内容 | 消费 order-paid-retry-0 或用控制台工具 |
毒消息出现,带原始主题头信息 | 不存在该主题 |
| 死信主题内容 | 消费 order-paid-dlt 或看 @DltHandler 日志 |
最终出现一条毒消息 | 不存在该主题,日志只有重试记录 |
这里的"预期"是基于 3.x/4.x @RetryableTopic 的机制推演,不是一次真实运行的结果 ;你在自己环境执行时,主题名字会因配置不同而变(例如你改过 retryTopicSuffix),attempts 与重试主题数量的对应关系也以实际创建的主题为准。建议用 kafka-topics.sh --list 先确认主题结构,再去验证消息。
再补充一点关于"LAG 归零"的解读:LAG 归零只说明位移追到了日志末端,不说明消息被正确处理。错误版里毒消息被跳过后 LAG 同样归零,但那条消息从未成功履约。所以LAG 是进度指标,不是正确性指标;正确性要靠 DLT 内容、业务表数据或幂等记录来判断。把这两类指标混为一谈,是排障时很容易走偏的地方。
5.3 用 @EmbeddedKafka 做回归测试
在 CI 里更实际的做法是写一个集成测试,断言"毒消息最终进入 DLT"。测试要让 StockService 在收到 SKU-FAIL 时抛异常,然后用 KafkaTestUtils 或 Consumer 消费 DLT 主题,用 Awaitility 等待。
java
@SpringBootTest
@EmbeddedKafka(partitions = 3, topics = {"order-paid"})
class DlqIntegrationTest {
@Autowired KafkaTemplate<String, String> kafkaTemplate;
@Test
void failedMessageShouldEndUpInDlt() {
kafkaTemplate.send("order-paid", 1, "O-1001",
"{\"orderId\":\"O-1001\",\"sku\":\"SKU-FAIL\",\"qty\":1}");
// 伪代码:用 Consumer 订阅 order-paid-dlt,等待并断言收到 O-1001
// await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { ... });
}
}
这段测试的关键不是代码本身,而是它验证的性质 :失败消息在有限时间内出现在 DLT,且处理过程中主主题不被长时阻塞。注意:@EmbeddedKafka 默认不预建 order-paid-retry-0 等重试主题,如果测试里 broker 不允许自动建主题,测试会失败在"主题不存在"------这时要在 @EmbeddedKafka(topics = {...}) 里把重试主题和 DLT 都列上,或者在测试里用 KafkaAdmin 创建。这一步失败很容易被误诊为"重试没生效",实际是主题缺失。
写这类测试时还有两个容易踩的坑。第一,等待时间要给足:非阻塞重试的总耗时是各层延迟之和再加处理时间,测试里用 30 秒这类宽松上限比用 3 秒更稳。第二,断言要针对"消息内容"而不是"消息条数",因为重试链路上同一业务消息可能在多个主题里各出现一次;如果只数条数,很容易在重试层数变化时得到偶发失败。
5.4 验证"重复消费"而不只是"重试"
重试成功不代表业务正确。要验证幂等,可以这样做:
- 在
stockService.deductAndRecord里,先检查fulfillment_log是否已有该orderId;已有则直接返回(幂等)。 - 人为制造一次"处理后崩溃":在扣库存之后、写履约记录之前抛异常,模拟"库存已经扣了但记录没写"。
- 观察重试后是否重复扣库存。如果没有幂等判断,第二次重试会再扣一次。
这个实验的价值是:它把"至少一次"从抽象语义变成可观测现象。如果业务没有幂等设计,非阻塞重试和 DLT 只是让失败更可控,并不能阻止重复副作用。
更具体地说,重复消费有两种来源,验证时要分开看:第一种是"错误处理器主动重试",它是计划内的重复;第二种是"位移未提交导致的重平衡重投",它是计划外的重复。前一种可以通过日志里异常出现的次数观察,后一种只能通过消费者组状态与位移变化观察。两者叠加时,同一条消息可能出现远多于配置次数的处理,这正是第 6.3 节反例的来源。
六、失败与反例:那些"看起来对、实际错"的写法
6.1 反例一:用 AckMode.MANUAL 手动提交,却在异常时也提交
有人想"精确控制提交",于是用 AckMode.MANUAL,在监听方法里处理完就 ack.acknowledge()。但如果在 try/catch 里把异常吞掉后仍然 acknowledge(),效果等价于"跳过失败消息"。提交位移的时机决定了失败消息的命运 :你提交了,就等于告诉 Kafka"这条我已经处理完了"。正确做法是异常时不提交,让错误处理器接手;或者干脆不要手动提交,交给 DefaultErrorHandler 统一管理。
6.2 反例二:把非阻塞重试当"保证顺序的重试"
如 3.5 节所述,非阻塞重试的代价是顺序丢失。一个常见追问是:"那我把 concurrency 设成 1,是不是就保序了?"设成 1 只能保证同一时刻只有一个线程在跑,但重试消息是通过另一个主题、由另一个监听器消费的 ,它和主主题的处理天然不同步。所以即使 concurrency=1,主主题的后续消息仍可能在重试消息重投之前完成。要真正保序,需要更复杂的设计(例如按 key 串行化、或失败时暂停分区并原地重试)。没有"免费保序的非阻塞重试",这属于设计取舍,不是配置能解决的。
6.3 反例三:max.poll.interval.ms 与重试叠加导致重平衡
假设一次本地重试耗时较长(比如 5 次 × 10 秒 = 50 秒),而 max.poll.interval.ms 默认是 5 分钟,但你的批次里有 10 条都失败,总耗时超过 5 分钟,broker 就会把该消费者踢出组,触发重平衡,消息被别的消费者拉到,从头再来。这会表现为"同一条消息被反复处理、重试次数远超预期"。诊断方法是看日志里是否有 CommitFailedException 或成员被移除的记录,并对照 max.poll.interval.ms 与单批耗时。非阻塞重试能缓解这个问题,但根本上仍要控制单批处理时间。
6.4 反例四:把 DLT 当"问题解决"
DLT 只是"把没处理成功的消息存起来"。如果上线后没人看 DLT,它就退化成"消息丢失的另一种形式"。工程上至少需要:DLT 主题的监控、@DltHandler 的告警、以及一条明确的"重放入口/人工处理流程"。这些不是框架保证的,需要你自建。
6.5 反例五:忽略反序列化失败的不可重试性
如果消息体本身格式错误(JSON 不合法),无论重试多少次都会失败。这类异常应当被判定为不可重试并直接进 DLT,否则白等退避时间。DefaultErrorHandler 提供 addNotRetryableExceptions 等方法,Spring Kafka 4.x 也在异常分类上做了增强;具体 API 以所用版本为准。把它和 @RetryableTopic 结合时,要确认不可重试异常是否仍然会被转发到 DLT 而不是无限重试。
6.6 反例六:用容器级并发解决单分区毒消息
另一个常见的错误直觉是"分区被毒消息堵住了,那就把 concurrency 调大"。这在单分区上完全无效:同一个分区在同一时刻只能被一个消费者实例持有,调大并发只会增加空闲实例,被毒消息卡住的那个消费者仍然是那一个。如果主题有多个分区,调大并发只能让其他分区被更快处理,被堵住的分区本身没有变化。真正能缓解的办法是缩短单条消息的处理时间、把可重试的等待移出消费线程(非阻塞重试),或者人为把该分区的后续消息切到别的分区(这通常意味着改变分区键设计,代价很大)。
七、适用条件与结论
7.1 什么条件下选哪种方案
| 关注点 | 本地重试(DefaultErrorHandler + BackOff) |
非阻塞重试(@RetryableTopic) |
|---|---|---|
| 分区阻塞 | 会(退避期间占住线程) | 不会(失败消息转发到重试主题) |
| 顺序保证 | 保留(同分区顺序重试) | 不保证 |
| 主题数量 | 不增加 | 随重试层数增加 |
| 延迟精度 | 相对可控(本地计时) | 依赖调度实现,精度不保证 |
| 适合场景 | 瞬时抖动、要求保序、失败概率低 | 下游长时间不可用、要求分区继续前进 |
| 与 DLT 组合 | 需显式配置 DeadLetterPublishingRecoverer |
内置 DLT 约定 |
结论要落到"边界"上:
- 默认错误处理器的默认恢复动作是"跳过并记日志",不是"投递 DLT"。如果你需要 DLT,必须显式配置,不能靠默认。
- 非阻塞重试用顺序换吞吐。对严格保序的业务,要么接受本地重试的阻塞,要么引入按 key 的串行化设计。
- "至少一次"是默认语义。重试、重平衡、崩溃恢复都会导致重复消费;幂等(或去重表)是工程上必需的一层,不能依赖"不会重复"的直觉。
- DLT 不是终点,处理流程才是。没有监控和补偿的 DLT 等于消息黑洞。
- 版本差异要验证,不要靠记忆 。
@RetryableTopic与DefaultErrorHandler的交互、重试主题的延迟实现、不可重试异常的分类 API,在 Spring Kafka 3.x 与 4.x 之间可能有细节差异;涉及升级时,应在目标版本上用一条毒消息实测,而不是假设"行为和旧版一样"。
补充一条关于适用条件的判断方法:先回答"失败是短暂的还是持久的"。短暂失败(网络抖动、下游短暂过载)适合本地重试,因为等待时间短、顺序不破坏、实现简单;持久失败(下游下线、数据本身有问题)会让本地重试变成分区阻塞器,此时非阻塞重试或直接进 DLT 更合适。这个判断不需要知道任何框架细节,只需要对下游的可用性特征有基本认识。
7.2 需要你自行确认的问题
- 所用 Spring Kafka 补丁版本中,
@RetryableTopic生成的容器是否会覆盖全局DefaultErrorHandler? - 重试主题的延迟是"定时调度重发"还是"主题内等待"?这决定了对 broker 的负载特征。
- 你的 broker 是否允许自动建主题?如果不允许,重试主题与 DLT 必须预建。
- 消费者组在重平衡时,未提交位移的消息会被重复消费,你的下游是否幂等?
- 重试主题的分区数与并发度是否与原主题匹配?隐式生成的容器是否有独立的容量配置?
- DLT 是否有监控、告警和明确的重放流程?没有的话它是否只是把问题推迟了?
这些问题没有统一答案,但每一个都应当在你上线前被显式回答。本文给出的机制推演可以作为排查起点,但具体行为仍以你锁定版本的源码和实测为准。
八、参考资料(按需查阅)
以下链接为 Spring 官方与 Apache Kafka 官方文档入口,可用于核对你所用版本的细节;本文未逐条引用,而是作为验证方向的索引:
- Spring for Apache Kafka 参考文档(Retry/DLT 章节):https://docs.spring.io/spring-kafka/reference/
- Spring for Apache Kafka API 文档:https://docs.spring.io/spring-kafka/api/
- Spring Boot 依赖版本管理:https://docs.spring.io/spring-boot/
- Apache Kafka 官方文档(消费者配置与消费者组):https://kafka.apache.org/documentation/
- Spring for Apache Kafka 测试支持:https://docs.spring.io/spring-kafka/reference/testing.html
- Spring Boot 版本支持与升级说明:https://spring.io/projects/spring-boot#support
再次说明:本文示例代码与观察结果为基于公开机制的推演与示意,未在本次写作环境中实际运行;请在你的目标版本上用第五节的方法复现确认。