🏆 本文收录于 《滚雪球学 Spring Boot 3.x》 专栏,专注 Spring Boot 3.x 系统学习与实战进阶,内容持续更新中。
本专栏致力于打造高质量、体系化的 Spring Boot 3.x 学习路线,从零基础入门到进阶实战,帮助你快速掌握核心知识与企业级开发经验。 📌 想快速定位学习内容,可查看:【Spring Boot 3 教程导航帖】
🎉 当前专栏限时优惠中:一次订阅,终身阅读,后续更新章节全部免费解锁 👉 立即订阅 👈️ 🎁 想系统打通 Spring Boot 各主流版本?还可以查看: 👉 《Spring Boot 2.x 实战》 👉 《Spring Boot 3.x 实战》 👉 《Spring Boot 4.x 实战》
🚀 推荐合集:《Spring Boot 全栈实战合集》,一站式覆盖 Spring Boot 2.x、3.x、4.x 核心特性、项目实战与架构进阶内容。
演示环境说明:
- 开发工具:IDEA 2021.3
- JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
- Spring Boot版本:3.5.4(于25年7月24日发布)
- Maven版本:3.8.2 (或更高)
- Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
- 操作系统:Windows 11
全文目录:
-
- 一、写在前面:为什么这个主题值得单独写一篇?
- 二、先把地基打稳:为什么是 Spring Boot 3.x 语境下讨论这个问题
-
- 2.1 Spring Boot 3.x 的几个关键基础
-
- (1)Java 17 成为最低基线
- (2)全面转向 Jakarta 生态
- (3)更现代的可观测性体系
- (4)原生镜像/AOT 支持更成熟
- 2.2 为什么这会影响 Spring Kafka 的使用方式
- 三、批量监听到底是什么:先把概念讲透
-
- 3.1 单条监听与批量监听的本质差异
- 3.2 批量监听提升吞吐的根本原因
-
- (1)降低监听回调次数
- (2)更适合批量业务操作
- (3)更容易控制消费者吞吐模型
- 3.3 批量监听最容易踩的坑
-
- 误区一:把一次 poll 的结果当成一个天然业务事务
- 误区二:只看到吞吐,不看失败恢复成本
- 误区三:误以为开启事务就自动安全
- 四、事务边界到底是什么:很多人会用,但很少人真正画清楚
-
- 4.1 什么叫事务边界
- 4.2 消费端最常见的四种边界画法
-
- 方案 A:整批作为一个事务
- 方案 B:批量拉取,但逐条独立事务
- 方案 C:批量拉取 + `BatchToRecordAdapter`
- 方案 D:数据库事务 + Outbox
- 4.3 什么时候“整批一个事务”是错误设计
- 五、Spring Kafka 里的事务,究竟帮你做了什么
- 六、本文实战项目结构说明
-
- 6.1 项目目录
- 6.2 Maven 依赖
- 6.3 Docker Compose:启动 Kafka
- 6.4 应用配置 `application.yml`
- 6.5 代码解析
-
- (1)`enable-auto-commit: false`
- (2)`isolation.level: read_committed`
- (3)`max.poll.records`
- (4)`transaction-id-prefix`
- 七、公共模型与基础配置代码
-
- 7.1 启动类
- 7.2 Topic 配置属性类
- 7.3 Topic 自动创建配置
- 7.4 事件模型
- 7.5 幂等日志表实体
- 7.6 Outbox 表实体
- 7.7 Repository
- 八、监听容器配置:一篇文章的核心代码从这里开始
-
- 8.1 代码解析
-
- (1)为什么不用一个工厂打天下
- (2)为什么事务型工厂使用容器事务
- (3)为什么 `partialBatchFactory` 要用 `BatchToRecordAdapter`
- 九、业务服务:把幂等、落库、发事件封装起来
-
- 9.1 这段服务代码的设计重点
-
- (1)每种场景都做幂等检查
- (2)三种失败标记模拟了三类真实问题
- (3)Outbox 示例故意单独成方法
- 十、案例一:手动确认 + 批量监听 + 幂等消费
-
- 10.1 监听器代码
- 10.2 行为分析
- 10.3 适用场景
- 10.4 为什么它依然值得在生产中使用
- 十一、案例二:容器事务 + 数据库事务 + 下游事件发送
-
- 11.1 监听器代码
- 11.2 这段代码里实际上发生了什么
- 11.3 为什么这种设计依然很常用
- 11.4 这类方案的关键前提:幂等必须做好
- 11.5 这个方案适合什么场景
- 11.6 一张图看懂提交顺序
- 11.7 代码层面的扩展建议
- 十二、案例三:BatchToRecordAdapter + 坏消息隔离 + DLT
-
- 12.1 监听器代码
- 12.2 这个方案的关键价值
- 12.3 这个方案适合什么场景
- 12.4 什么时候不建议这样做
- 12.5 关于 DLT 的设计建议
- 十三、补充案例:Outbox 思路为什么经常是最后答案
-
- 13.1 Outbox 的核心思想
- 13.2 Outbox 保存代码
- 13.3 这个简化版 Outbox 要诚实看待
- 13.4 为什么我仍然建议你认真学 Outbox
- 十四、如何为不同业务选择边界:别再问“哪个最好”,要问“哪个最适合”
-
- 14.1 事务边界设计决策表
- 14.2 一个非常重要的判断标准
- 十五、批量监听与异常处理:真正难的不是成功路径,而是失败路径
-
- 15.1 为什么失败路径设计比成功路径更重要
- 15.2 推荐的异常分类思路
-
- 第一类:可重试技术异常
- 第二类:不可重试业务异常
- 第三类:幂等冲突/重复消费异常
- 15.3 为什么批量监听不适合简单粗暴地“一出错就整批重试”
-
- (1)已成功消息的重复执行成本
- (2)坏消息的拖垮效应
- 十六、性能调优:吞吐、时延、稳定性之间的三角关系
-
- 16.1 `max.poll.records` 不是越大越好
- 16.2 `max.poll.interval.ms` 要和业务耗时联动看
- 16.3 并发数不是越高越好
- 16.4 批量落库不等于大事务落库
- 16.5 关于 `read_committed` 的性能取舍
- 十七、监控与可观测性:Spring Boot 3.x 下你应该观察什么
-
- 17.1 消费端核心指标
-
- (1)批次大小分布
- (2)单批处理耗时
- (3)失败率与重试率
- (4)消费者 lag
- 17.2 事务型消费端的额外观测点
-
- (1)事务提交耗时
- (2)回滚次数
- (3)幂等命中率
- 17.3 建议打印的日志字段
- 17.4 为什么这和 Spring Boot 3.x 有关系
- 十八、常见踩坑清单:这些问题线上极其高频
-
- 18.1 坑一:误把技术批当业务批
- 18.2 坑二:开了事务就不做幂等
- 18.3 坑三:批次过大,单批处理过慢,导致消费者被踢出组
- 18.4 坑四:坏消息没有隔离机制,整条链路被拖死
- 18.5 坑五:在事务里做重型远程调用
- 18.6 坑六:DLT 只有入口,没有出口
- 18.7 坑七:只看平均吞吐,不看失败放大效应
- 十九、从零基础到进阶:你应该形成的正确思维方式
-
- 19.1 第一层:先分清目标是什么
- 19.2 第二层:再判断“批”是什么含义
- 19.3 第三层:明确失败后你能接受什么
- 19.4 第四层:最后才决定技术方案
- 二十、给初学者的一套实战建议
-
- 第一步:先做“手动确认 + 幂等”
- 第二步:再上“批量监听”
- 第三步:需要 read-process-write 时,再引入事务
- 第四步:一旦坏消息增多,优先考虑失败隔离
- 第五步:当系统越来越复杂时,再考虑 Outbox/CDC
- 二十一、如何验证本文示例
-
- 21.1 发送测试消息的接口
- 21.2 生产者服务
- 21.3 验证步骤建议
-
- 验证案例一
- 验证案例二
- 验证案例三
- 验证 Outbox
- 二十二、为什么说“所有案例代码可运行”并不等于“所有生产问题都被解决”
- 二十三、全文总结:把这篇文章压缩成十条结论
-
- 结论 1
- 结论 2
- 结论 3
- 结论 4
- 结论 5
- 结论 6
- 结论 7
- 结论 8
- 结论 9
- 结论 10
- 二十四、专栏结语
- 二十五、附:一份给读者的实战检查清单
-
- 设计层
- 消费层
- 一致性层
- 失败恢复层
- 运维层
- 二十六、补充进阶:从 Spring Boot 2.x 迁移到 3.x 时,这个主题会遇到什么变化
-
- 26.1 Java 基线升级带来的直接影响
- 26.2 `javax.*` 到 `jakarta.*` 的迁移
- 26.3 事务相关代码要重点回归验证
- 26.4 Boot 3.x 下更推荐的工程写法
- 二十七、批量监听下的顺序性问题:很多人只谈吞吐,不谈顺序
-
- 27.1 为什么批量监听不会天然破坏顺序
- 27.2 如何在批量监听中尽量保住业务顺序
-
- (1)生产端固定业务键
- (2)消费端避免对同一批次无脑并行
- (3)失败恢复时谨慎处理
- 27.3 哪些场景不应该过度追求顺序
- 二十八、再补一块硬知识:事务、重试、死信之间到底是什么关系
-
- 28.1 事务解决不了“坏消息”
- 28.2 重试不应该替代幂等
- 28.3 死信不是异常的终点,而是治理流程的起点
- 二十九、加入一组可运行测试:让这篇文章不只停留在“代码示例”
-
- 29.1 Embedded Kafka 测试示例
- 29.2 测试代码解析
-
- (1)用 `@EmbeddedKafka` 提升本地可测试性
- (2)断言不要只看发送成功
- (3)用等待机制处理异步链路
- 29.3 你还应该继续补什么测试
- 三十、给架构评审时可直接复用的一段结论
- 三十一、FAQ:把读者最常问的 12 个问题一次讲清楚
-
- Q1:批量监听一定比单条监听快吗?
- Q2:开了 Kafka 事务,是不是就等于数据库和 Kafka 完全原子?
- Q3:为什么明明用了事务,还要做幂等?
- Q4:批量监听下最怕什么?
- Q5:什么时候适合整批一个事务?
- Q6:什么时候适合批量拉取、逐条处理?
- Q7:`BatchToRecordAdapter` 的意义是什么?
- Q8:DLT 进了以后是不是就不用管了?
- Q9:事务型监听是不是一定更高级?
- Q10:Outbox 会不会太复杂?
- Q11:Boot 3.x 对这个主题最大的价值是什么?
- Q12:如果我是新手,第一步应该学什么?
- 三十二、结尾再强调一次:你真正要学会的不是“配法”,而是“边界感”
- 🧧 学习福利 · 限时开放 🧧
- 🫵 Who am I?
一、写在前面:为什么这个主题值得单独写一篇?
很多同学第一次接触 Kafka 消费端时,关注点通常只有两个:
当系统流量不大时,用最基础的单条监听、默认提交方式,往往也能跑起来。于是很多人会形成一种错觉:Kafka 消费没有那么复杂,能消费就算成功。
但真正到了线上,你会很快碰到下面这些问题:
- 消费吞吐突然不够,单条监听一秒只能处理几百条,积压越来越高;
- 业务要求“数据库更新 + 发出下游事件 + 提交消费位点”尽量保持一致;
- 一批消息中只有一条失败,结果整个批次都回滚重放;
- 消费者明明还活着,却频繁触发 rebalance;
- 开启事务以后,性能下降明显;
- 批量监听下,异常处理、重试、死信、幂等、顺序性全部搅在一起;
- 你以为自己做到了 exactly-once,最后却只是“看起来像 exactly-once”。
这时你就会发现,Spring Kafka 真正难的不是 API 怎么写,而是边界怎么画。
批量监听本质上是在提升吞吐;事务本质上是在约束一致性;二者一旦结合,设计重点就不再是“会不会用 @KafkaListener”,而是:
- 一次 poll() 拉到的一批消息,到底应该作为一个业务单元处理,还是只作为一个网络拉取单元?
- 是把整批消息包进一个事务,还是一条消息一个事务?
- Kafka 事务和数据库事务谁先提交?
- 失败后要整批重放,还是局部隔离坏消息?
- 你到底需要的是吞吐优先、一致性优先,还是故障隔离优先?
本篇文章就是围绕这些问题展开。你不但会知道“怎么配”,更会知道“为什么这样配”。
二、先把地基打稳:为什么是 Spring Boot 3.x 语境下讨论这个问题
Spring Boot 3.x 不是一次简单的“小版本升级”,而是一次面向现代 Java 技术栈的整体跃迁。对 Kafka 这类消息中间件的集成来说,Spring Boot 3.x 的价值并不仅仅是“依赖版本更新”,更重要的是它提供了更统一、更现代的工程基线。
2.1 Spring Boot 3.x 的几个关键基础
在实际工程里,Spring Boot 3.x 带来的影响主要体现在下面这些方面:
(1)Java 17 成为最低基线
这意味着我们的 Kafka 消费服务从语言层面就可以更自然地使用:
- record 作为事件对象;
- 更现代的 switch、文本块等语法;
- 更稳定的 JDK 新特性组合。
对消息模型来说,record 非常适合表达“天然不可变”的事件载荷,这比传统 JavaBean 更贴近事件驱动的思路。
(2)全面转向 Jakarta 生态
如果你从 Spring Boot 2.x 升级到 3.x,最明显的变化就是 javax.* 到 jakarta.*。这对 Kafka 监听器本身影响不大,但对整个服务的 Web、Validation、Persistence 层都会有影响。也就是说,Kafka 代码不可能孤立存在,它最终还是要落在一个完整的 Spring Boot 3.x 应用之中。
(3)更现代的可观测性体系
在 Kafka 场景里,排查问题常常比写代码更难。Spring Boot 3.x 在可观测性方面的提升,意味着我们可以更好地追踪:
- 某个批次消费耗时多少;
- 哪类消息失败率最高;
- Kafka 发送与数据库写入分别消耗了多少时间;
- 重试链路、死信链路是否异常增多。
这对后文讲的“批量监听 + 事务边界”非常关键,因为这类问题很多时候不是功能错,而是性能和边界错。
(4)原生镜像/AOT 支持更成熟
这一点和 Kafka 事务没有直接关系,但对微服务启动速度、资源占用有帮助。特别是批处理型、弹性伸缩型的 Kafka 消费服务,在容器环境中启动速度快,意味着扩缩容更敏捷。
2.2 为什么这会影响 Spring Kafka 的使用方式
很多文章在讲 Spring Kafka 时,完全脱离 Spring Boot 版本背景,结果写出来的代码虽然“能跑”,但不够现代,也不够贴近当前主流工程实践。
在 Spring Boot 3.x 环境里写 Kafka 消费代码,我们通常会遵循这些原则:
- 使用 Spring Boot 的自动配置管理 Kafka 基础设施;
- 尽量让 Spring Boot 统一管理依赖版本,而不是手工硬控一堆中间件版本;
- 使用 Java 17 的写法表达事件模型与配置模型;
- 利用 Boot 3.x 的可观测能力,为消息处理建立指标与追踪;
- 在事务设计上,优先采用 Spring 官方推荐的组合,而不是自己“拼”一个看起来很聪明的方案。
也就是说,Spring Boot 3.x 不只是运行环境,它决定了你写这类代码时的工程风格。
三、批量监听到底是什么:先把概念讲透
在 Spring Kafka 中,所谓批量监听,并不是 Kafka Broker 端额外提供了一种“批量消息类型”,而是指:
消费者一次 poll() 拉取到多条记录后,Spring Kafka 把这批记录整体交给监听方法处理。
换句话说,批量监听的“批”是消费端视角的批,它的边界来源于一次 poll() 的结果,而不是你的业务对象天然就是一批。
3.1 单条监听与批量监听的本质差异
我们先看一张图。
示意图绘制如下,仅供参考:
单条监听的特点是:
- 回调粒度细;
- 某条消息失败时,定位清晰;
- 每条消息都有一套方法调用、反序列化、拦截器、事务边界、异常处理链路;
- 在高吞吐场景下,单位消息的框架开销更高。
批量监听的特点是:
- 一次回调处理一批数据;
- 有利于提高吞吐;
- 更适合做批量入库、批量聚合、批量转发;
- 一旦失败,默认恢复粒度会更粗,往往需要重放整个批次;
- 更考验事务边界与幂等设计。
所以,批量监听不是“高级版单条监听”,而是一种权衡。
3.2 批量监听提升吞吐的根本原因
很多人会说,批量监听就是“for 循环处理 List”,这当然没错,但不够本质。
它提升吞吐的原因主要有三点:
(1)降低监听回调次数
单条监听是 N 条消息触发 N 次方法调用;批量监听是 N 条消息可能只触发 1 次方法调用。框架层、代理层、拦截器层、AOP 层的固定开销被摊薄了。
(2)更适合批量业务操作
例如:
- 批量插入数据库;
- 批量校验;
- 批量聚合后一次性发送到下游;
- 批量写缓存。
如果你的业务天然支持批处理,批量监听会比单条监听更契合。
(3)更容易控制消费者吞吐模型
通过 max.poll.records、并发数、批量处理逻辑,你可以较好地平衡:
- 每次拉取多少条;
- 每次处理多长时间;
- 每个实例的吞吐能力;
- 整个消费组的再均衡风险。
但要注意:批量监听提升吞吐,不代表总吞吐一定线性提升。 如果你的瓶颈在数据库、下游服务、磁盘 IO,那么把监听改成批量,也只是把压力更快地推给后端而已。
3.3 批量监听最容易踩的坑
批量监听最常见的误区有三个:
误区一:把一次 poll 的结果当成一个天然业务事务
一次 poll() 拉到 200 条消息,并不意味着这 200 条就应该被你包进一个数据库事务。它只是网络拉取与消费框架上的一批,不一定是业务上的一批。
误区二:只看到吞吐,不看失败恢复成本
吞吐上去了很好,但如果其中一条脏数据导致整批失败、整批重放、整批回滚,系统的真实成本可能更高。
误区三:误以为开启事务就自动安全
事务能解决部分一致性问题,但无法替你自动解决:
- 幂等;
- 坏消息隔离;
- 外部系统调用一致性;
- 部分成功如何补偿;
- 大批量回滚带来的性能问题。
所以后面的重点是:事务不是目的,边界设计才是目的。
四、事务边界到底是什么:很多人会用,但很少人真正画清楚
4.1 什么叫事务边界
所谓事务边界,你可以把它理解为:
哪些操作要么一起成功,要么一起失败。
在 Spring Kafka 消费场景中,通常涉及以下几类动作:
当我们讨论事务边界时,本质上是在问:
- 是不是要把 2、3、4、5 放进同一个一致性单元?
- 如果失败,应该回滚哪些动作?
- 回滚之后,哪些数据会重放?
- 失败对吞吐、时延、幂等要求有什么影响?
4.2 消费端最常见的四种边界画法
示意图绘制如下,仅供参考:
这四种方案没有绝对好坏,关键看你的业务要求。
方案 A:整批作为一个事务
特点:
- 最简单直接;
- 一批消息一次监听、一次业务事务、一次 Kafka 事务;
- 吞吐好;
- 但一条失败,默认可能导致整批重放。
适合:
- 同构消息、质量较高;
- 失败率很低;
- 更看重整体吞吐;
- 能接受整批重试。
方案 B:批量拉取,但逐条独立事务
特点:
- 消费端仍用批量拉取;
- 但业务上按单条消息独立处理;
- 失败更容易隔离;
- 每条消息一个事务,吞吐优势会被削弱。
适合:
- 业务逻辑重;
- 数据质量参差不齐;
- 更看重故障隔离而不是极致吞吐。
方案 C:批量拉取 + BatchToRecordAdapter
特点:
- Spring Kafka 批量容器仍然存在;
- 但监听器以单条记录方式被回调;
- 可以在批量框架下做逐条错误恢复与 DLT;
- 是“吞吐”和“可恢复性”之间比较平衡的一种方案。
适合:
- 想利用批量消费带来的框架吞吐优势;
- 同时又不想因为一条坏消息把整批拖下水。
方案 D:数据库事务 + Outbox
特点:
- 不强求数据库和 Kafka 发送在一个同步分布式事务里原子完成;
- 先把业务数据和 outbox 事件写进同一个数据库事务;
- 再由独立发布器异步投递到 Kafka;
- 最终一致性更强,可维护性通常也更高。
适合:
- 核心业务一致性非常敏感;
- 不愿意在消费链路里直接耦合太多跨资源同步事务;
- 可以接受最终一致性模型。
4.3 什么时候“整批一个事务”是错误设计
下面这些场景,通常不建议整批一个事务:
- 批次内消息来自不同业务实体,且失败概率较高;
- 批次内存在大量外部 RPC 调用;
- 单批处理耗时长,容易逼近 max.poll.interval.ms;
- 单条脏数据概率不低;
- 业务要求尽量减少无效重放;
- 数据库写入本身比较慢,整批回滚成本很大。
一句话总结:
如果你无法接受“批次中一条失败,整批回滚重放”的代价,就不要轻易把整批包成一个事务。
五、Spring Kafka 里的事务,究竟帮你做了什么
很多同学第一次听说 Kafka 事务,会本能地理解成“和数据库事务一样”。这其实不完全准确。
Kafka 事务的核心价值是:
如果你做的是典型的 read → process → write 流程,那么 Kafka 事务可以帮助你把:
- 读取到的偏移量提交;
- 发往下游主题的消息发送;
放在同一个 Kafka 事务里完成。
但请注意,Kafka 事务只天然覆盖 Kafka 资源本身,不会自动把你的数据库也变成 Kafka 事务的一部分。
所以一旦你的监听器里同时涉及数据库更新,就进入了“跨资源一致性”的话题。此时你不能只问“要不要开事务”,而要问:
- Kafka 事务和 DB 事务如何组合?
- 谁先提交?
- 提交失败后如何补偿?
- 幂等是不是足够?
这就是本文的核心。
六、本文实战项目结构说明
为了让文章不悬空,我们准备一个可运行的 Spring Boot 3.x 示例项目。这个项目包含三组可运行案例:
另外我还会在后面补充一个可运行的 Outbox 扩展案例,用于帮助你理解为什么很多生产系统最后会走向 Outbox/CDC。
6.1 项目目录
boot3-kafka-batch-tx-demo
├── pom.xml
├── docker-compose.yml
├── src/main/java/com/example/demo
│ ├── DemoApplication.java
│ ├── config
│ │ ├── DemoTopicProperties.java
│ │ ├── KafkaTopicConfiguration.java
│ │ └── KafkaListenerConfiguration.java
│ ├── controller
│ │ └── DemoOrderController.java
│ ├── domain
│ │ ├── OrderCreatedEvent.java
│ │ ├── OrderProcessedEvent.java
│ │ ├── OrderProcessLog.java
│ │ └── OutboxEvent.java
│ ├── repository
│ │ ├── OrderProcessLogRepository.java
│ │ └── OutboxEventRepository.java
│ └── service
│ ├── DemoProducerService.java
│ ├── OrderProcessingService.java
│ ├── ManualBatchOrderListener.java
│ ├── TransactionalBatchOrderListener.java
│ ├── PartialBatchOrderListener.java
│ └── OutboxPublisherService.java
└── src/main/resources
└── application.yml
6.2 Maven 依赖
下面的 pom.xml 采用 Spring Boot 3.x 风格。注意:不手写 Spring Kafka 的版本号,交给 Spring Boot 统一管理。
<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>3.4.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>boot3-kafka-batch-tx-demo</artifactId>
<version>1.0.0</version>
<name>boot3-kafka-batch-tx-demo</name>
<description>Spring Boot 3.x + Spring Kafka 批量监听与事务边界示例</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!– Web 依赖:用于提供测试接口 –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!– Kafka 依赖:Spring Boot 会管理与当前 Boot 版本兼容的 Spring Kafka 版本 –>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!– JPA 依赖:用于演示数据库事务、幂等表、Outbox 表 –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!– 参数校验 –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!– Actuator:用于观察运行状态与指标 –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!– H2:为了让示例开箱即跑,这里使用内存数据库 –>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</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>
<build>
<plugins>
<!– Spring Boot Maven 插件 –>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
6.3 Docker Compose:启动 Kafka
为了避免引入额外复杂度,这里用单节点 Kafka 演示。示例采用 KRaft 模式。
services:
kafka:
image: apache/kafka:3.8.0
container_name: demo–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@localhost: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
KAFKA_NUM_PARTITIONS: 3
运行方式:
docker compose up -d
6.4 应用配置 application.yml
这个配置文件很重要,它基本决定了后文所有案例的运行基础。
spring:
application:
name: boot3–kafka–batch–tx–demo
datasource:
# 使用 H2 内存数据库,便于示例直接运行
url: jdbc:h2:mem:demo;MODE=PostgreSQL;DB_CLOSE_DELAY=–1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2–console
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
kafka:
bootstrap-servers: localhost:9092
consumer:
# 示例默认消费组,具体监听器还会显式指定自己的 groupId
group-id: boot3–default–group
auto-offset-reset: earliest
enable-auto-commit: false
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
# 信任当前项目的包,便于反序列化事件对象
spring.json.trusted.packages: com.example.demo.domain
isolation.level: read_committed
# 控制单次 poll 返回记录数,便于演示批量监听
max.poll.records: 20
# 根据业务耗时调整,防止处理过慢触发消费者被踢出组
max.poll.interval.ms: 300000
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
# Spring Boot 会据此自动配置 KafkaTransactionManager
# 注意:生产环境必须保证每个实例前缀唯一
transaction-id-prefix: order–service–${HOSTNAME:local}–tx–
properties:
enable.idempotence: true
acks: all
listener:
# 这里只是全局默认值,具体工厂会再覆盖
ack-mode: manual
missing-topics-fatal: false
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
logging:
level:
org.springframework.kafka: info
org.springframework.transaction: info
com.example.demo: info
demo:
topics:
manual-input: demo.order.manual.input
tx-input: demo.order.tx.input
tx-output: demo.order.tx.output
partial-input: demo.order.partial.input
partial-dlt: demo.order.partial.input.dlt
outbox-output: demo.order.outbox.output
6.5 代码解析
这份配置里有几个点需要特别强调:
(1)enable-auto-commit: false
这是做严肃消费端设计的基本前提之一。因为一旦开启自动提交,你就很难精确控制:
- 什么时候才算真正处理成功;
- 失败后应该不应该提交;
- 提交位点与业务成功之间是否脱节。
(2)isolation.level: read_committed
当上游使用事务生产者时,如果你希望消费者只读到已提交事务中的消息,就应该使用这个级别。
(3)max.poll.records
这不是“越大越好”。批量监听的批次大小,最终要结合:
- 单条业务耗时;
- 整批业务耗时;
- 数据库吞吐;
- max.poll.interval.ms;
- 失败重放成本。
(4)transaction-id-prefix
只要你设置了这个前缀,Spring Boot 就会自动帮你配置 Kafka 事务管理器。这也是 Spring Boot 3.x 下最推荐的用法之一:用 Boot 的自动配置完成事务基础设施接线。
七、公共模型与基础配置代码
7.1 启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class DemoApplication {
public static void main(String[] args) {
// 启动 Spring Boot 应用
SpringApplication.run(DemoApplication.class, args);
}
}
7.2 Topic 配置属性类
package com.example.demo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "demo.topics")
public record DemoTopicProperties(
String manualInput,
String txInput,
String txOutput,
String partialInput,
String partialDlt,
String outboxOutput
) {
}
7.3 Topic 自动创建配置
package com.example.demo.config;
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;
@Configuration
public class KafkaTopicConfiguration {
@Bean
public NewTopic manualInputTopic(DemoTopicProperties properties) {
// 手动确认批量消费主题
return TopicBuilder.name(properties.manualInput())
.partitions(3)
.replicas(1)
.build();
}
@Bean
public NewTopic txInputTopic(DemoTopicProperties properties) {
// 事务型批量消费输入主题
return TopicBuilder.name(properties.txInput())
.partitions(3)
.replicas(1)
.build();
}
@Bean
public NewTopic txOutputTopic(DemoTopicProperties properties) {
// 事务型批量消费输出主题
return TopicBuilder.name(properties.txOutput())
.partitions(3)
.replicas(1)
.build();
}
@Bean
public NewTopic partialInputTopic(DemoTopicProperties properties) {
// 局部失败隔离示例输入主题
return TopicBuilder.name(properties.partialInput())
.partitions(3)
.replicas(1)
.build();
}
@Bean
public NewTopic partialDltTopic(DemoTopicProperties properties) {
// 死信主题
return TopicBuilder.name(properties.partialDlt())
.partitions(3)
.replicas(1)
.build();
}
@Bean
public NewTopic outboxOutputTopic(DemoTopicProperties properties) {
// Outbox 发布目标主题
return TopicBuilder.name(properties.outboxOutput())
.partitions(3)
.replicas(1)
.build();
}
}
7.4 事件模型
利用 Java 17 的 record,事件定义会更紧凑,也更贴近事件不可变的语义。
package com.example.demo.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record OrderCreatedEvent(
String eventId,
Long orderId,
BigDecimal amount,
String customerId,
String remark,
LocalDateTime createdAt
) {
}
package com.example.demo.domain;
import java.time.LocalDateTime;
public record OrderProcessedEvent(
String eventId,
Long orderId,
String status,
LocalDateTime processedAt
) {
}
7.5 幂等日志表实体
这个实体非常重要。它用来演示:即便消息重放,我们也可以通过业务幂等保证“重复消费不重复落库”。
package com.example.demo.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_order_process_log")
public class OrderProcessLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 64)
private String eventId;
@Column(nullable = false)
private Long orderId;
@Column(nullable = false, length = 32)
private String scene;
@Column(nullable = false, length = 32)
private String status;
@Column(nullable = false)
private LocalDateTime processedAt;
public OrderProcessLog() {
}
public OrderProcessLog(String eventId, Long orderId, String scene, String status, LocalDateTime processedAt) {
this.eventId = eventId;
this.orderId = orderId;
this.scene = scene;
this.status = status;
this.processedAt = processedAt;
}
public Long getId() {
return id;
}
public String getEventId() {
return eventId;
}
public Long getOrderId() {
return orderId;
}
public String getScene() {
return scene;
}
public String getStatus() {
return status;
}
public LocalDateTime getProcessedAt() {
return processedAt;
}
public void setStatus(String status) {
this.status = status;
}
}
7.6 Outbox 表实体
package com.example.demo.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_outbox_event")
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 64, unique = true)
private String eventId;
@Column(nullable = false, length = 128)
private String topic;
@Column(nullable = false, length = 64)
private String eventKey;
@Lob
@Column(nullable = false)
private String payload;
@Column(nullable = false, length = 16)
private String status;
@Column(nullable = false)
private LocalDateTime createdAt;
public OutboxEvent() {
}
public OutboxEvent(String eventId, String topic, String eventKey, String payload, String status, LocalDateTime createdAt) {
this.eventId = eventId;
this.topic = topic;
this.eventKey = eventKey;
this.payload = payload;
this.status = status;
this.createdAt = createdAt;
}
public Long getId() {
return id;
}
public String getEventId() {
return eventId;
}
public String getTopic() {
return topic;
}
public String getEventKey() {
return eventKey;
}
public String getPayload() {
return payload;
}
public String getStatus() {
return status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setStatus(String status) {
this.status = status;
}
}
7.7 Repository
package com.example.demo.repository;
import com.example.demo.domain.OrderProcessLog;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderProcessLogRepository extends JpaRepository<OrderProcessLog, Long> {
// 根据事件 ID 查询,用于幂等判断
Optional<OrderProcessLog> findByEventId(String eventId);
// 判断事件是否已经处理过
boolean existsByEventId(String eventId);
}
package com.example.demo.repository;
import com.example.demo.domain.OutboxEvent;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OutboxEventRepository extends JpaRepository<OutboxEvent, Long> {
// 查询待发布的 outbox 记录,按主键升序取前 20 条
List<OutboxEvent> findTop20ByStatusOrderByIdAsc(String status);
}
八、监听容器配置:一篇文章的核心代码从这里开始
Spring Kafka 真正体现设计能力的地方,不在 @KafkaListener 注解本身,而在容器工厂如何配置。
我们这里准备三个容器工厂,分别对应三种处理模式:
package com.example.demo.config;
import com.example.demo.domain.OrderCreatedEvent;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.transaction.KafkaTransactionManager;
import org.springframework.kafka.listener.ContainerProperties;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.kafka.listener.adapter.DefaultBatchToRecordAdapter;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.util.backoff.FixedBackOff;
@Configuration
public class KafkaListenerConfiguration {
@Bean
public ConcurrentKafkaListenerContainerFactory<String, OrderCreatedEvent> manualBatchFactory(
ConsumerFactory<String, OrderCreatedEvent> consumerFactory) {
ConcurrentKafkaListenerContainerFactory<String, OrderCreatedEvent> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
factory.setBatchListener(true);
factory.setConcurrency(2);
// 手动确认:只有业务处理完成后才显式提交位点
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
// 设置 poll 超时,便于更容易聚合成批
factory.getContainerProperties().setPollTimeout(3000L);
return factory;
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, OrderCreatedEvent> txBatchFactory(
ConsumerFactory<String, OrderCreatedEvent> consumerFactory,
KafkaTransactionManager<String, Object> kafkaTransactionManager) {
ConcurrentKafkaListenerContainerFactory<String, OrderCreatedEvent> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
factory.setBatchListener(true);
factory.setConcurrency(2);
// 使用容器事务:消费位点提交与发送下游消息可纳入同一个 Kafka 事务
factory.getContainerProperties().setTransactionManager(kafkaTransactionManager);
factory.getContainerProperties().setPollTimeout(3000L);
return factory;
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, OrderCreatedEvent> partialBatchFactory(
ConsumerFactory<String, OrderCreatedEvent> consumerFactory,
KafkaTemplate<String, Object> kafkaTemplate,
DemoTopicProperties topicProperties) {
ConcurrentKafkaListenerContainerFactory<String, OrderCreatedEvent> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
factory.setBatchListener(true);
factory.setConcurrency(2);
// DLT 恢复器:发生不可恢复异常时把坏消息写入死信主题
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(
kafkaTemplate,
(record, ex) -> new org.apache.kafka.common.TopicPartition(topicProperties.partialDlt(), record.partition())
);
// 批量容器下,把一批消息转换为逐条回调,便于做局部错误隔离
factory.setBatchToRecordAdapter(new DefaultBatchToRecordAdapter<>(recoverer));
// 失败后重试 2 次,每次间隔 1 秒,仍失败则进入 DLT
DefaultErrorHandler errorHandler = new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 2L));
factory.setCommonErrorHandler(errorHandler);
factory.getContainerProperties().setPollTimeout(3000L);
return factory;
}
}
8.1 代码解析
(1)为什么不用一个工厂打天下
可以,但不推荐。因为三种场景的处理目标不同:
- manualBatchFactory 重视显式确认与可控提交;
- txBatchFactory 重视事务一致性;
- partialBatchFactory 重视局部失败隔离。
把所有配置强塞进一个工厂,只会让语义越来越混乱。
(2)为什么事务型工厂使用容器事务
因为我们要演示典型的“消费 → 处理 → 发送下游事件 → 提交 offset”链路。在这种模式下,容器事务能把消费位点与 Kafka 发送绑定起来。
(3)为什么 partialBatchFactory 要用 BatchToRecordAdapter
因为原生批量监听在失败时,默认不知道哪一条出问题,于是整批重放。BatchToRecordAdapter 的价值就在于:
- 消费端仍以批量方式拉取,提高框架层吞吐;
- 监听器却可以按单条记录处理,细化失败隔离粒度。
这个特性对生产系统非常实用。
九、业务服务:把幂等、落库、发事件封装起来
下面的服务类贯穿三个案例。
package com.example.demo.service;
import com.example.demo.config.DemoTopicProperties;
import com.example.demo.domain.OrderCreatedEvent;
import com.example.demo.domain.OrderProcessLog;
import com.example.demo.domain.OrderProcessedEvent;
import com.example.demo.domain.OutboxEvent;
import com.example.demo.repository.OrderProcessLogRepository;
import com.example.demo.repository.OutboxEventRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderProcessingService {
private static final Logger log = LoggerFactory.getLogger(OrderProcessingService.class);
private final OrderProcessLogRepository orderProcessLogRepository;
private final OutboxEventRepository outboxEventRepository;
private final KafkaTemplate<String, Object> kafkaTemplate;
private final DemoTopicProperties topicProperties;
private final ObjectMapper objectMapper;
public OrderProcessingService(OrderProcessLogRepository orderProcessLogRepository,
OutboxEventRepository outboxEventRepository,
KafkaTemplate<String, Object> kafkaTemplate,
DemoTopicProperties topicProperties,
ObjectMapper objectMapper) {
this.orderProcessLogRepository = orderProcessLogRepository;
this.outboxEventRepository = outboxEventRepository;
this.kafkaTemplate = kafkaTemplate;
this.topicProperties = topicProperties;
this.objectMapper = objectMapper;
}
@Transactional
public void processManual(OrderCreatedEvent event) {
// 幂等判断:如果事件已经处理过,直接返回,避免重复消费导致重复落库
if (orderProcessLogRepository.existsByEventId(event.eventId())) {
log.info("[manual] 事件已处理,跳过。eventId={}", event.eventId());
return;
}
// 模拟业务校验失败:remark=FAIL_BATCH 时抛出异常,让外层监听器决定是否 ack
if ("FAIL_BATCH".equalsIgnoreCase(event.remark())) {
throw new IllegalStateException("手动确认批量消费示例:整批失败,触发重放");
}
OrderProcessLog logEntity = new OrderProcessLog(
event.eventId(),
event.orderId(),
"MANUAL_BATCH",
"DONE",
LocalDateTime.now()
);
orderProcessLogRepository.save(logEntity);
log.info("[manual] 处理成功,eventId={}, orderId={}", event.eventId(), event.orderId());
}
@Transactional
public void processTransactionalAndSend(OrderCreatedEvent event) {
// 幂等判断:事务型监听中同样必须有幂等保护
if (orderProcessLogRepository.existsByEventId(event.eventId())) {
log.info("[tx] 事件已处理,跳过。eventId={}", event.eventId());
return;
}
// 模拟业务失败:remark=FAIL_TX 时抛异常,回滚数据库事务与 Kafka 事务
if ("FAIL_TX".equalsIgnoreCase(event.remark())) {
throw new IllegalStateException("事务型批量消费示例:触发事务回滚");
}
orderProcessLogRepository.save(new OrderProcessLog(
event.eventId(),
event.orderId(),
"TX_BATCH",
"DONE",
LocalDateTime.now()
));
OrderProcessedEvent processedEvent = new OrderProcessedEvent(
event.eventId(),
event.orderId(),
"PROCESSED",
LocalDateTime.now()
);
// 在事务型场景中发送下游消息
kafkaTemplate.send(topicProperties.txOutput(), String.valueOf(event.orderId()), processedEvent);
log.info("[tx] 处理成功并已发送下游事件,eventId={}, orderId={}", event.eventId(), event.orderId());
}
@Transactional
public void processPartialRecord(OrderCreatedEvent event) {
// 幂等判断:防止死信重放、重试重放带来重复写入
if (orderProcessLogRepository.existsByEventId(event.eventId())) {
log.info("[partial] 事件已处理,跳过。eventId={}", event.eventId());
return;
}
// remark=FAIL_PARTIAL 时只让当前记录失败,由错误处理器决定重试与 DLT
if ("FAIL_PARTIAL".equalsIgnoreCase(event.remark())) {
throw new IllegalArgumentException("局部失败隔离示例:当前记录失败,应进入重试/死信流程");
}
orderProcessLogRepository.save(new OrderProcessLog(
event.eventId(),
event.orderId(),
"PARTIAL_BATCH",
"DONE",
LocalDateTime.now()
));
log.info("[partial] 单条处理成功,eventId={}, orderId={}", event.eventId(), event.orderId());
}
@Transactional
public void saveBusinessAndOutbox(OrderCreatedEvent event) {
// 幂等处理:如果已经处理过,则不重复保存业务日志与 outbox 事件
if (orderProcessLogRepository.existsByEventId(event.eventId())) {
log.info("[outbox] 事件已处理,跳过。eventId={}", event.eventId());
return;
}
orderProcessLogRepository.save(new OrderProcessLog(
event.eventId(),
event.orderId(),
"OUTBOX",
"DONE",
LocalDateTime.now()
));
try {
String payload = objectMapper.writeValueAsString(new OrderProcessedEvent(
event.eventId(),
event.orderId(),
"OUTBOX_PROCESSED",
LocalDateTime.now()
));
outboxEventRepository.save(new OutboxEvent(
UUID.randomUUID().toString(),
topicProperties.outboxOutput(),
String.valueOf(event.orderId()),
payload,
"NEW",
LocalDateTime.now()
));
} catch (JsonProcessingException e) {
throw new IllegalStateException("序列化 outbox payload 失败", e);
}
}
}
9.1 这段服务代码的设计重点
(1)每种场景都做幂等检查
很多新手会把幂等理解成“有事务就不需要幂等”。这是错误的。
真实世界里会导致重复消费的原因很多,包括但不限于:
- 消费者重启;
- rebalance;
- Kafka 事务提交失败后的重投;
- 手动 ack 前发生异常;
- 死信回放;
- 补偿任务重复执行。
所以,幂等是消息消费端的兜底保障,不是事务的替代品。
(2)三种失败标记模拟了三类真实问题
- FAIL_BATCH:代表整批失败更合适的场景;
- FAIL_TX:代表需要回滚事务的一致性场景;
- FAIL_PARTIAL:代表更适合局部隔离的坏消息场景。
这样设计的目的,是让你在同一个项目里直观看见不同边界的行为差异。
(3)Outbox 示例故意单独成方法
因为 Outbox 的核心思想就是:
- 业务数据与 Outbox 记录放在同一个数据库事务中;
- Kafka 发布由独立流程异步完成。
它与“监听器里直接发 Kafka”是两条不同思路。
十、案例一:手动确认 + 批量监听 + 幂等消费
这个案例适合作为初学者理解批量监听的第一步。
它的目标不是“最强一致性”,而是:
- 先把批量监听机制吃透;
- 明白手动确认的控制点在哪;
- 明白为什么幂等几乎是标配。
10.1 监听器代码
package com.example.demo.service;
import com.example.demo.domain.OrderCreatedEvent;
import java.util.List;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
@Component
public class ManualBatchOrderListener {
private static final Logger log = LoggerFactory.getLogger(ManualBatchOrderListener.class);
private final OrderProcessingService orderProcessingService;
public ManualBatchOrderListener(OrderProcessingService orderProcessingService) {
this.orderProcessingService = orderProcessingService;
}
@KafkaListener(
id = "manual-batch-order-listener",
topics = "${demo.topics.manual-input}",
groupId = "manual-batch-order-group",
containerFactory = "manualBatchFactory"
)
public void onMessage(List<ConsumerRecord<String, OrderCreatedEvent>> records, Acknowledgment acknowledgment) {
// 批量回调:一次 poll 拿到的记录会整体传入
log.info("[manual-listener] 本次收到 {} 条消息", records.size());
try {
for (ConsumerRecord<String, OrderCreatedEvent> record : records) {
// 逐条处理批次中的记录
orderProcessingService.processManual(record.value());
}
// 只有整批都成功时,才显式提交位点
acknowledgment.acknowledge();
log.info("[manual-listener] 本批处理完成,已确认 offset");
} catch (Exception e) {
// 不做 ack:让这批消息后续重放
log.error("[manual-listener] 本批处理失败,不提交位点,将等待重放", e);
throw e;
}
}
}
10.2 行为分析
这个方案的事务边界非常容易理解:
- Spring Kafka 帮你一次拉一批;
- 你在应用层按顺序逐条处理;
- 全部成功,显式 ack;
- 任何一条失败,不 ack,等待批次重放。
它的优点是:
- 实现简单;
- 处理逻辑透明;
- 提交时机可控;
- 对初学者来说容易排查问题。
它的缺点也很明显:
- 一条失败,整批不确认;
- 批次中前面已经处理成功的消息,可能再次被重放;
- 所以必须做幂等。
10.3 适用场景
这种方案适合:
- 业务处理主要是本地落库;
- 不涉及严格的下游事件一致性;
- 失败率较低;
- 更想先把吞吐和显式确认控制住。
10.4 为什么它依然值得在生产中使用
有些文章喜欢把“手动确认 + 幂等”说成一个过时方案,仿佛不上事务就是落后。其实并不是。
在很多系统里,如果你的核心要求是:
- 保证消息至少处理一次;
- 不怕重复,只怕丢失;
- 可以通过业务幂等消化重放;
- 希望实现成本低、行为可解释;
那么这个方案其实很稳。
请记住:
可解释、可维护、可回放的 at-least-once + 幂等,在很多场景下比“复杂但边界模糊的伪 exactly-once”更可靠。
十一、案例二:容器事务 + 数据库事务 + 下游事件发送
这是本文最核心的案例,也是最容易被误解的案例。
我们希望做到这样一件事:
11.1 监听器代码
package com.example.demo.service;
import com.example.demo.domain.OrderCreatedEvent;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class TransactionalBatchOrderListener {
private static final Logger log = LoggerFactory.getLogger(TransactionalBatchOrderListener.class);
private final OrderProcessingService orderProcessingService;
public TransactionalBatchOrderListener(OrderProcessingService orderProcessingService) {
this.orderProcessingService = orderProcessingService;
}
@Transactional
@KafkaListener(
id = "tx-batch-order-listener",
topics = "${demo.topics.tx-input}",
groupId = "tx-batch-order-group",
containerFactory = "txBatchFactory"
)
public void onMessage(List<OrderCreatedEvent> events) {
// 这里的 @Transactional 负责数据库事务
// 容器工厂中的 transactionManager 负责 Kafka 事务
log.info("[tx-listener] 本次收到 {} 条消息", events.size());
for (OrderCreatedEvent event : events) {
orderProcessingService.processTransactionalAndSend(event);
}
log.info("[tx-listener] 本批处理成功,等待事务提交");
}
}
11.2 这段代码里实际上发生了什么
很多同学看到这段代码会以为:
“太好了,一个 @Transactional 就把数据库和 Kafka 都包进同一个原子事务了。”
这其实是不准确的。
真实发生的是:
- 容器启动 Kafka 事务;
- 监听方法进入后,@Transactional 启动数据库事务;
- 方法内执行数据库写入与 KafkaTemplate 发送;
- 方法退出时,数据库事务先提交;
- 随后容器层再完成 Kafka 事务提交(包括 offset 与发送记录);
- 如果 Kafka 事务提交失败,消息可能被重放,所以数据库更新必须幂等。
也就是说,这不是传统意义上的 XA 两阶段提交,而是组合事务 + 幂等兜底的思路。
11.3 为什么这种设计依然很常用
因为它非常贴近很多事件驱动服务的真实需求:
- 既要消费消息;
- 又要改数据库;
- 还要给下游发事件;
- 希望 offset 提交不要和下游发送脱节。
如果你完全不用 Kafka 事务,那么常见问题是:
- 数据库已经更新了;
- 下游事件没发出去;
- offset 却可能已经提交;
- 最后系统状态分裂。
而容器事务 + 数据库事务的组合,虽然仍然不是“完美分布式原子”,但已经能解决很大一部分链路一致性问题。
11.4 这类方案的关键前提:幂等必须做好
请反复记住这一点:
DB 先提交,Kafka 后提交,一旦 Kafka 提交失败并触发消息重放,数据库侧必须能识别重复处理。
所以你在示例里看到的 existsByEventId(event.eventId()) 不是装饰,而是关键设计。
如果没有幂等保护,最典型的事故就是:
- 第一次消费:数据库已写成功;
- Kafka 事务提交失败;
- 消息重放;
- 第二次消费:数据库又写一遍;
- 结果业务数据重复。
11.5 这个方案适合什么场景
适合:
- read → process → write 比较明确;
- 需要发送下游 Kafka 事件;
- 能接受“通过幂等弥合最后一步提交失败”的模型;
- 比起绝对原子,更注重工程上的平衡。
不太适合:
- 数据库修改极其昂贵,不能接受重放校验成本;
- 外部系统不止 Kafka 一个,例如还要调第三方支付、远程库存等;
- 需要严格跨资源原子,而系统又无法容忍补偿与幂等。
11.6 一张图看懂提交顺序
示意图绘制如下,仅供参考:
11.7 代码层面的扩展建议
在生产里,你还应该额外考虑:
- 发送下游事件时是否需要携带 traceId;
- 是否需要记录重试次数;
- 对于关键事件,是否需要建立对账机制;
- 如果 Kafka 事务提交失败,是否要触发告警与人工补偿。
也就是说,这个方案不是“一配就完事”,而是一个良好的基础骨架。
十二、案例三:BatchToRecordAdapter + 坏消息隔离 + DLT
如果说案例二重视的是一致性,那么案例三重视的就是失败粒度。
典型痛点是这样的:
- 一次 poll 拉了 20 条;
- 前 19 条都很好;
- 第 20 条是脏数据;
- 如果你直接用原生批量监听,整批都可能回滚重放;
- 系统就在第 20 条上反复空转。
这时就要考虑局部失败隔离。
12.1 监听器代码
package com.example.demo.service;
import com.example.demo.domain.OrderCreatedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class PartialBatchOrderListener {
private static final Logger log = LoggerFactory.getLogger(PartialBatchOrderListener.class);
private final OrderProcessingService orderProcessingService;
public PartialBatchOrderListener(OrderProcessingService orderProcessingService) {
this.orderProcessingService = orderProcessingService;
}
@KafkaListener(
id = "partial-batch-order-listener",
topics = "${demo.topics.partial-input}",
groupId = "partial-batch-order-group",
containerFactory = "partialBatchFactory"
)
public void onMessage(OrderCreatedEvent event) {
// 虽然底层容器是批量拉取,但在 BatchToRecordAdapter 作用下
// 监听方法这里会被逐条调用
log.info("[partial-listener] 开始处理单条记录,eventId={}", event.eventId());
orderProcessingService.processPartialRecord(event);
log.info("[partial-listener] 单条记录处理完成,eventId={}", event.eventId());
}
}
12.2 这个方案的关键价值
你可能会问:
既然监听方法又变成单条了,那为什么不一开始就用单条监听?
这是个好问题。
答案是:批量容器和单条回调不是对立关系。
BatchToRecordAdapter 的价值在于:
- 消费端可以继续以批量方式拉取记录,减少 poll 与框架开销;
- 监听器又能以单条粒度执行业务与错误恢复;
- 当坏消息出现时,更容易把它隔离到 DLT,而不是拖垮整个批次。
换句话说,它帮你拿到了一种很实用的折中:
批量获取,逐条决策。
12.3 这个方案适合什么场景
非常适合:
- 数据质量不稳定;
- 业务上允许坏消息进入 DLT;
- 重点是“主流程不要被少数坏消息拖死”;
- 仍希望保留批量拉取带来的吞吐优势。
12.4 什么时候不建议这样做
如果你的业务要求非常严格:
- 一批数据必须整体成功整体失败;
- 不能容忍局部消息提前推进;
- 批次本身就是强业务单元;
那就不适合使用这种“局部隔离”策略。
12.5 关于 DLT 的设计建议
很多文章写到 DLT 就停了,好像“发进 DLT 一切就结束了”。其实 DLT 只是把问题从主链路移走,不代表问题消失。
你还应该考虑:
- DLT 消息有没有消费人;
- DLT 是人工处理还是自动回放;
- 回放前是否要修复数据;
- DLT 是否需要分级(技术异常 / 业务异常);
- DLT 积压是否要监控告警。
一个没有治理闭环的 DLT,最终很容易演化成“消息垃圾桶”。
十三、补充案例:Outbox 思路为什么经常是最后答案
很多团队一开始会尝试在监听器里同时做:
- 数据库更新;
- Kafka 事务发送;
- offset 提交;
- 各种下游联动。
随着系统复杂度变高,大家会发现:
- 同步事务链越来越长;
- 回滚成本越来越高;
- 调试与补偿越来越麻烦;
- 一致性问题看似减少,实际上只是被“延后暴露”。
这时很多系统会逐步演进到 Outbox 模式。
13.1 Outbox 的核心思想
业务数据和待发送事件一起落在数据库里,保证业务事实先被可靠保存;然后由独立发布器把事件异步发往 Kafka。
用图理解一下:
示意图绘制如下,仅供参考:
13.2 Outbox 保存代码
前面 saveBusinessAndOutbox 方法已经展示了核心思路:
- 在一个数据库事务里同时写业务日志与 outbox 记录;
- 不在当前事务里强求 Kafka 发送完成。
下面看一个可运行的发布器实现。
package com.example.demo.service;
import com.example.demo.domain.OutboxEvent;
import com.example.demo.repository.OutboxEventRepository;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OutboxPublisherService {
private static final Logger log = LoggerFactory.getLogger(OutboxPublisherService.class);
private final OutboxEventRepository outboxEventRepository;
private final KafkaTemplate<String, Object> kafkaTemplate;
public OutboxPublisherService(OutboxEventRepository outboxEventRepository,
KafkaTemplate<String, Object> kafkaTemplate) {
this.outboxEventRepository = outboxEventRepository;
this.kafkaTemplate = kafkaTemplate;
}
@Scheduled(fixedDelay = 5000L)
public void publishNewOutboxEvents() {
// 定时扫描待发布事件
List<OutboxEvent> events = outboxEventRepository.findTop20ByStatusOrderByIdAsc("NEW");
if (events.isEmpty()) {
return;
}
log.info("[outbox-publisher] 本次准备发布 {} 条 outbox 事件", events.size());
// 使用 Kafka 本地事务批量发送,提高发送一致性与吞吐
kafkaTemplate.executeInTransaction(operations -> {
for (OutboxEvent event : events) {
operations.send(event.getTopic(), event.getEventKey(), event.getPayload());
}
return true;
});
// Kafka 发送成功后,再更新本地状态
markPublished(events);
}
@Transactional
public void markPublished(List<OutboxEvent> events) {
for (OutboxEvent event : events) {
event.setStatus("PUBLISHED");
}
outboxEventRepository.saveAll(events);
log.info("[outbox-publisher] 已将 {} 条 outbox 事件标记为 PUBLISHED", events.size());
}
}
13.3 这个简化版 Outbox 要诚实看待
上面这个实现是教学版、可运行版。它能帮助你理解 Outbox,但不代表它已经是生产级的最终答案。
它的价值在于:
- 业务数据先可靠落库;
- 发送失败不会丢失事件;
- 发布过程可以独立重试;
- 可以减少监听器主链路里的同步耦合。
它的局限也必须说明:
- Kafka 成功发送后、数据库状态还没来得及更新时,如果进程崩溃,可能导致再次发布;
- 因此,发布端和消费端仍然需要幂等;
- 真正追求更强一致性的生产系统,往往会进一步用 CDC(如 Debezium) 来发布 Outbox 事件。
13.4 为什么我仍然建议你认真学 Outbox
因为当你的业务从“一个服务自己消费自己玩”升级为:
- 多个下游订阅;
- 跨团队协作;
- 跨库写入;
- 对账要求高;
- 容灾与补偿复杂;
你会越来越体会到:
在业务事实层面先可靠落库,再异步传播事件,常常比在消费链路里拼一个巨大的同步事务更稳。
十四、如何为不同业务选择边界:别再问“哪个最好”,要问“哪个最适合”
很多文章最大的问题就是喜欢给结论:
- “必须开事务”;
- “必须用批量”;
- “必须上 Outbox”;
- “必须上 DLT”。
这类结论往往不够负责任。
正确的方式应该是根据业务约束来选型。下面给你一个实战决策表。
14.1 事务边界设计决策表
| 处理逻辑简单、主要是本地落库、能接受重复消费 | 手动确认 + 幂等 | 简单、稳定、容易解释 |
| 需要 read-process-write,并向下游发 Kafka 事件 | 容器事务 + DB 事务 + 幂等 | 兼顾一致性与工程复杂度 |
| 数据质量不稳定、坏消息概率高 | BatchToRecordAdapter + DLT | 提高故障隔离能力 |
| 跨资源一致性要求高、系统演进复杂 | Outbox / CDC | 长期维护成本更低 |
| 批次是天然业务单元,必须整体成功整体失败 | 整批事务 | 与业务边界一致 |
| 批次只是拉取单元,不是业务单元 | 批量拉取 + 逐条处理 | 降低失败放大效应 |
14.2 一个非常重要的判断标准
请先问自己一句话:
我这里的“批”,到底是业务批,还是技术批?
如果是业务批,例如:
- 一份结算单包含 50 条明细,必须整单一致;
- 一次导入任务天然就是一批;
- 一组分片数据必须一起提交;
那整批事务是合理的。
如果只是技术批,例如:
- Kafka 一次刚好 poll 到 20 条;
- 20 条其实属于不同用户、不同订单、不同请求;
- 它们只是碰巧在同一次拉取里被拿到;
那么把它们强行绑成一个业务事务,通常不是好设计。
这句话很重要,请你一定记住。
十五、批量监听与异常处理:真正难的不是成功路径,而是失败路径
15.1 为什么失败路径设计比成功路径更重要
成功路径每个团队都会写,失败路径才会真正暴露架构水平。
在批量监听里,失败处理至少要回答下面这些问题:
- 是整批重试,还是单条重试?
- 重试几次?
- 重试之间是否退避?
- 哪些异常应该立即入 DLT?
- 哪些异常可以继续重试?
- 哪些异常其实是幂等冲突,可以直接忽略?
如果这些问题没有提前设计,你的系统很容易出现两种极端:
- 过度重试:坏消息把系统拖死;
- 过早放弃:一堆暂时性故障被直接打进 DLT。
15.2 推荐的异常分类思路
我建议你把异常分成三类:
第一类:可重试技术异常
例如:
- 短暂网络抖动;
- 数据库连接池瞬时获取失败;
- Kafka Broker 短暂不可用;
- 下游依赖偶发超时。
这类异常适合有限次重试。
第二类:不可重试业务异常
例如:
- 必填字段缺失;
- 金额非法;
- 状态机不允许当前转换;
- 数据版本不兼容。
这类异常通常不应无限重试,应尽快隔离到 DLT 或人工修复。
第三类:幂等冲突/重复消费异常
例如:
- eventId 已存在;
- 幂等表已有记录;
- 乐观锁显示该消息已经处理过。
这类异常一般不应当做真正失败,更适合作为“已处理”场景直接吞掉或转为成功路径。
15.3 为什么批量监听不适合简单粗暴地“一出错就整批重试”
因为在批量场景下,整批重试会放大两个成本:
(1)已成功消息的重复执行成本
哪怕你有幂等检查,重复进一次数据库、查一次日志表、走一次反序列化和校验,都是成本。
(2)坏消息的拖垮效应
如果批次中存在稳定失败的坏消息,而你没有局部隔离机制,那这条坏消息就会像钉子一样,把整个消费链钉死在某个批次上。
所以,批量监听不怕失败,怕的是“失败被放大”。
十六、性能调优:吞吐、时延、稳定性之间的三角关系
只要你一谈批量监听,最终都会谈到性能调优。但很多人把调优理解成“把参数调大”。这是不对的。
在 Kafka 消费端,调优的本质是三角平衡:
- 吞吐量;
- 单条时延;
- 系统稳定性。
16.1 max.poll.records 不是越大越好
它控制的是单次 poll 最多返回多少条记录。
调大它的好处:
- 更容易形成批量;
- 监听回调次数下降;
- 吞吐可能提升。
调大它的代价:
- 单批处理时间可能增长;
- 失败重放成本升高;
- 更容易逼近 max.poll.interval.ms。
经验建议:
- 先从较小值开始,例如 50、100;
- 结合单批平均耗时观察;
- 逐步扩大,而不是一上来就 1000。
16.2 max.poll.interval.ms 要和业务耗时联动看
这个参数控制:
两次 poll() 之间允许的最大空档时间。
如果你的监听器处理太慢,超过这个时间,消费者就可能被认为“失联”,从而被踢出组并触发 rebalance。
这也是为什么批量监听不能只看吞吐,不看单批耗时。
一个典型误区是:
- 把 max.poll.records 调得很大;
- 每条消息还要调多个外部接口;
- 结果一批处理十几分钟;
- 然后消费者疯狂 rebalance。
所以正确做法是:
- 估算单条平均处理时间;
- 估算单批最大处理时间;
- 给 max.poll.interval.ms 留足裕量;
- 但也不要无限调大,否则故障检测会变慢。
16.3 并发数不是越高越好
concurrency 控制的是容器并发消费者数量。它要和分区数一起看。
- 如果分区数是 3,并发开到 10,后面 7 个线程其实拿不到分区;
- 如果数据库只能支撑每秒 500 次写入,把并发拉高只会更快把 DB 打满;
- 如果每条消息都要发下游事件,生产端也会形成压力。
一般建议:
- 并发数不超过分区数;
- 从“数据库和下游能承受的吞吐”反推消费者并发;
- 对热点分区特别关注。
16.4 批量落库不等于大事务落库
一个很隐蔽的性能问题是:
- 你用了批量监听;
- 又把整批消息放进一个数据库事务;
- 再做批量 insert;
- 看起来非常“高性能”;
- 但一旦失败,回滚也变得异常昂贵。
所以,数据库批量写入优化和事务边界设计必须同时考虑,不能只顾前者。
16.5 关于 read_committed 的性能取舍
如果上游使用事务生产者,消费者启用 read_committed 可以避免读到已中止事务的消息。但它也意味着:
- 消费者只能读到已稳定提交的偏移;
- 某些情况下会略微影响可见性与吞吐;
- 但对一致性敏感场景,这个代价通常值得。
如果你的链路里压根没有事务消息,那么也没必要为了“看起来高级”而强行启用所有事务配置。
十七、监控与可观测性:Spring Boot 3.x 下你应该观察什么
消息系统不是写完就结束,真正的工作通常从上线后才开始。
在 Spring Boot 3.x 语境下,我建议你至少关注下面这些指标与日志维度。
17.1 消费端核心指标
(1)批次大小分布
你需要知道:
- 平均每批多少条;
- 最大批次多大;
- 批次大小是否长期偏小。
如果批次长期很小,说明你虽然配了批量监听,但实际上并没有形成批量价值。
(2)单批处理耗时
这是最重要的指标之一。
因为它直接影响:
- 吞吐;
- max.poll.interval.ms 风险;
- 事务持有时间;
- rebalance 频率。
(3)失败率与重试率
要分场景看:
- 技术异常;
- 业务异常;
- 幂等冲突;
- DLT 投递数。
(4)消费者 lag
lag 大并不一定表示系统坏了,但如果 lag 持续增长且单批耗时变长,通常说明处理能力已经跟不上生产速度。
17.2 事务型消费端的额外观测点
(1)事务提交耗时
Kafka 事务、数据库事务分别多长时间完成,值得单独观察。
(2)回滚次数
回滚次数上升往往意味着:
- 数据质量问题变多;
- 下游依赖不稳定;
- 批次过大导致失败放大。
(3)幂等命中率
如果幂等命中率突然上升,可能意味着:
- 消息大量重放;
- 事务提交异常;
- 监听器频繁中断重启。
17.3 建议打印的日志字段
至少建议统一输出:
- eventId
- orderId
- topic
- partition
- offset
- groupId
- listenerId
- batchSize
- elapsedMs
- retryCount
- scene
这样排查问题时,才能把“某条消息在某个批次、某个分区、某次重试中发生了什么”串起来。
17.4 为什么这和 Spring Boot 3.x 有关系
因为 Spring Boot 3.x 提供了更现代的可观测性接入方式,配合 Micrometer/Actuator,你更容易把批量监听与事务链路做成可度量对象,而不是靠日志盲猜。
一句话总结:
批量监听与事务边界不是只靠代码正确就行,它们必须可观测,否则出了问题你很难知道边界到底是怎么坏的。
十八、常见踩坑清单:这些问题线上极其高频
下面我把生产里最容易踩的坑集中列一下。
18.1 坑一:误把技术批当业务批
这几乎是最常见、也最根本的设计错误。
一次 poll() 得到 100 条,不代表这 100 条应该放进同一个数据库事务。
18.2 坑二:开了事务就不做幂等
这是事故高发点。
只要存在重试、重放、rebalance、补偿,幂等就几乎不可省略。
18.3 坑三:批次过大,单批处理过慢,导致消费者被踢出组
症状通常是:
- 频繁 rebalance;
- offset 提交失败;
- 处理日志看似没异常,但消费组状态很抖。
18.4 坑四:坏消息没有隔离机制,整条链路被拖死
只要数据质量不稳定,就必须思考:
- 是否需要 DLT;
- 是否需要 BatchToRecordAdapter;
- 是否需要异常分类。
18.5 坑五:在事务里做重型远程调用
如果你把:
- 数据库事务;
- Kafka 事务;
- 多次远程 HTTP/RPC 调用;
一起塞进同一个监听方法里,那么事务时间会被拉长,锁持有时间会拉长,失败成本也会被拉长。
18.6 坑六:DLT 只有入口,没有出口
消息进 DLT 不是终点。没有治理流程的 DLT,本质上只是把问题堆到了另一个 Topic。
18.7 坑七:只看平均吞吐,不看失败放大效应
很多方案在平均情况下跑得很好,但一旦失败,会把成本放大十倍、百倍。架构设计一定要看最坏情况,而不是只看平均情况。
十九、从零基础到进阶:你应该形成的正确思维方式
如果你是零基础读者,读完这篇文章后,不要急着背配置项,而是先建立下面这套思维。
19.1 第一层:先分清目标是什么
- 你到底是要提升吞吐?
- 还是要增强一致性?
- 还是要更细的失败隔离?
不同目标,对应不同方案。
19.2 第二层:再判断“批”是什么含义
- 业务批?
- 技术批?
- 两者兼而有之?
19.3 第三层:明确失败后你能接受什么
- 能否接受整批重试?
- 能否接受重复消费?
- 能否接受最终一致性?
- 能否接受部分消息进 DLT?
19.4 第四层:最后才决定技术方案
- 手动确认
- Kafka 事务
- DB 事务
- 批量监听
- BatchToRecordAdapter
- Outbox
- DLT
这些都只是工具,不是目的。
如果你按照这四层思路去设计,方案会清晰很多。
二十、给初学者的一套实战建议
如果你现在刚开始做 Spring Boot 3.x + Spring Kafka 项目,我建议按下面顺序学习与落地:
第一步:先做“手动确认 + 幂等”
先把最基础、最关键的控制点掌握住:
- 什么时候提交位点;
- 为什么重复消费不可避免;
- 如何用业务键做幂等。
第二步:再上“批量监听”
观察批量监听带来的收益和问题:
- 吞吐有没有提升;
- 批次大小是否合理;
- 失败是否被放大。
第三步:需要 read-process-write 时,再引入事务
不要一开始就把事务、重试、死信、Outbox 全堆上去。先明确链路里是否真的需要“消费后再发下游事件”的一致性保证。
第四步:一旦坏消息增多,优先考虑失败隔离
这时 BatchToRecordAdapter、异常分类、DLT 就会派上用场。
第五步:当系统越来越复杂时,再考虑 Outbox/CDC
Outbox 不是初学阶段的起点,但很可能是复杂系统演进后的终点之一。
二十一、如何验证本文示例
为了让这篇文章真正可用,这里给出一组建议的验证步骤。
21.1 发送测试消息的接口
下面这个控制器可以帮助你快速向不同主题发送测试数据。
package com.example.demo.controller;
import com.example.demo.config.DemoTopicProperties;
import com.example.demo.domain.OrderCreatedEvent;
import com.example.demo.service.DemoProducerService;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Validated
public class DemoOrderController {
private final DemoProducerService demoProducerService;
private final DemoTopicProperties topicProperties;
public DemoOrderController(DemoProducerService demoProducerService, DemoTopicProperties topicProperties) {
this.demoProducerService = demoProducerService;
this.topicProperties = topicProperties;
}
@PostMapping("/demo/manual/send")
public String sendManual(@RequestParam(defaultValue = "5") @Min(1) int count,
@RequestParam(defaultValue = "") String failRemark) {
demoProducerService.sendBatch(topicProperties.manualInput(), count, failRemark);
return "已发送到 manual-input,count=" + count + ", failRemark=" + failRemark;
}
@PostMapping("/demo/tx/send")
public String sendTx(@RequestParam(defaultValue = "5") @Min(1) int count,
@RequestParam(defaultValue = "") String failRemark) {
demoProducerService.sendBatch(topicProperties.txInput(), count, failRemark);
return "已发送到 tx-input,count=" + count + ", failRemark=" + failRemark;
}
@PostMapping("/demo/partial/send")
public String sendPartial(@RequestParam(defaultValue = "5") @Min(1) int count,
@RequestParam(defaultValue = "") String failRemark) {
demoProducerService.sendBatch(topicProperties.partialInput(), count, failRemark);
return "已发送到 partial-input,count=" + count + ", failRemark=" + failRemark;
}
@PostMapping("/demo/outbox/send")
public String sendOutbox(@RequestParam(defaultValue = "5") @Min(1) int count) {
demoProducerService.sendToOutboxDemo(count);
return "已触发 outbox 演示,count=" + count;
}
}
21.2 生产者服务
package com.example.demo.service;
import com.example.demo.domain.OrderCreatedEvent;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
@Service
public class DemoProducerService {
private final KafkaTemplate<String, Object> kafkaTemplate;
private final OrderProcessingService orderProcessingService;
public DemoProducerService(KafkaTemplate<String, Object> kafkaTemplate,
OrderProcessingService orderProcessingService) {
this.kafkaTemplate = kafkaTemplate;
this.orderProcessingService = orderProcessingService;
}
public void sendBatch(String topic, int count, String failRemark) {
for (int i = 1; i <= count; i++) {
String remark = (failRemark != null && !failRemark.isBlank() && i == count) ? failRemark : "";
OrderCreatedEvent event = new OrderCreatedEvent(
UUID.randomUUID().toString(),
System.currentTimeMillis() + i,
BigDecimal.valueOf(99.90),
"CUST-" + i,
remark,
LocalDateTime.now()
);
// 按订单号作为 key,便于同一订单天然路由到固定分区
kafkaTemplate.send(topic, String.valueOf(event.orderId()), event);
}
}
public void sendToOutboxDemo(int count) {
for (int i = 1; i <= count; i++) {
OrderCreatedEvent event = new OrderCreatedEvent(
UUID.randomUUID().toString(),
System.currentTimeMillis() + i,
BigDecimal.valueOf(199.90),
"OUTBOX-CUST-" + i,
"",
LocalDateTime.now()
);
// 直接调用业务方法,模拟在某个监听器或业务入口中写业务表和 outbox 表
orderProcessingService.saveBusinessAndOutbox(event);
}
}
}
21.3 验证步骤建议
验证案例一
验证案例二
验证案例三
验证 Outbox
二十二、为什么说“所有案例代码可运行”并不等于“所有生产问题都被解决”
你在学习技术文章时,一定要分清楚“示例可运行”和“架构完备”之间的区别。
本文给出的代码,目标是:
- 让你在本地能真正跑起来;
- 看到三种边界设计的行为差异;
- 理解 Spring Boot 3.x + Spring Kafka 的主流用法;
- 建立正确的设计思维。
但真正的生产系统还会额外关心:
- 配置中心与多环境隔离;
- 统一日志规范;
- 灰度与回滚;
- 熔断与限流;
- DLT 治理平台;
- 告警平台对接;
- 统一幂等组件抽象;
- Kafka ACL 与安全认证;
- 容器弹性伸缩;
- 跨机房容灾。
这也是为什么真正的技术成长,不能只停留在“会写示例代码”,而要进一步理解架构背后的取舍。
二十三、全文总结:把这篇文章压缩成十条结论
最后,我把整篇文章压缩成十条最值得带走的结论。
结论 1
批量监听的“批”首先是消费技术边界,不一定是业务边界。
结论 2
事务不是越大越好,边界画错了,事务只会放大失败成本。
结论 3
手动确认 + 幂等,是非常重要、非常实用的基础方案。
结论 4
容器事务 + 数据库事务 + 幂等,是 Spring Kafka 中 read-process-write 的高频组合。
结论 5
即便用了事务,幂等依然不能省。
结论 6
批量监听下,一条坏消息可能拖死整批,失败隔离能力必须提前设计。
结论 7
BatchToRecordAdapter 是批量拉取与逐条恢复之间非常实用的折中方案。
结论 8
Outbox 不是“更高级的花活”,而是复杂系统演进后的自然结果之一。
结论 9
吞吐、时延、稳定性三者必须一起看,参数不是调大就完事。
结论 10
Spring Boot 3.x 给你的不只是版本升级,而是一套更现代的 Java、自动配置、可观测与工程实践基础。
二十四、专栏结语
《零基础学 Spring Boot 3.x》这个专栏,不应该只教你“会写代码”,更应该帮你建立一套现代 Spring 应用的工程思维。
对于 Spring Kafka 中的批量监听与事务边界设计,你现在至少应该已经掌握:
- 批量监听是什么;
- 为什么批量监听会提升吞吐;
- 为什么失败粒度会变粗;
- 为什么事务边界才是核心;
- 如何在 Spring Boot 3.x 中正确配置 Spring Kafka;
- 为什么幂等几乎是必选项;
- 什么时候该用整批事务,什么时候该做局部失败隔离;
- 为什么复杂系统最终可能演进到 Outbox。
如果你把这些真正吃透了,那么你就不再只是“会配 Spring Kafka”,而是已经开始具备设计消息消费边界的能力了。
而这,恰恰才是从“能用框架”到“理解架构”的分水岭。
二十五、附:一份给读者的实战检查清单
你可以把下面这份清单当作自己在项目里落地时的核对表。
设计层
- 我有没有先区分业务批与技术批?
- 我的事务边界是不是和业务边界一致?
- 我能不能接受整批失败重试?
- 我的链路更关注吞吐、一致性还是失败隔离?
消费层
- 是否关闭了自动提交?
- 是否明确了 ack 时机?
- 是否合理配置了 max.poll.records?
- 是否考虑了 max.poll.interval.ms 与单批耗时?
一致性层
- 是否实现了幂等?
- 幂等键是什么?
- Kafka 事务与数据库事务的提交顺序是否清楚?
- 如果最后一步提交失败,是否有补偿思路?
失败恢复层
- 是否区分可重试异常与不可重试异常?
- 是否需要 DLT?
- DLT 后续由谁处理?
- 坏消息是否会拖垮主链路?
运维层
- 是否监控批次大小、批次耗时、失败率、重试率、DLT 数量?
- 是否观察消费者 lag 和 rebalance 频率?
- 是否统一打印 eventId / partition / offset 等关键日志字段?
如果这一整套清单你都能答清楚,那么你对这个主题的掌握就已经比较扎实了。
二十六、补充进阶:从 Spring Boot 2.x 迁移到 3.x 时,这个主题会遇到什么变化
虽然本文主要面向 Spring Boot 3.x,但真实工作里,很多团队并不是从零开始,而是从 2.x 迁移过来的。迁移时,批量监听与事务边界相关代码通常会遇到下面这些变化。
26.1 Java 基线升级带来的直接影响
从 2.x 迁移到 3.x,最先感受到的通常不是 Kafka,而是 Java 版本要求变高。你会发现:
- 构建环境要升级到 Java 17;
- CI/CD 镜像需要同步升级;
- 一些旧版依赖在 Java 17 下可能不兼容;
- 以前靠反射、字节码增强做的一些老写法,可能需要重新验证。
对 Kafka 消费端来说,这会影响:
- JSON 序列化反序列化依赖版本;
- 测试环境中 EmbeddedKafka 的依赖兼容性;
- Docker 基础镜像与 JVM 参数选择;
- 一些历史遗留工具包是否还能工作。
26.2 javax.* 到 jakarta.* 的迁移
消息监听器本身并不大量依赖 Servlet API,但只要你的监听逻辑会调用:
- Validation;
- JPA;
- Web 层 DTO 校验;
- 统一异常处理;
那 javax.* 到 jakarta.* 的迁移就不可避免。
最常见的地方包括:
- javax.validation.* → jakarta.validation.*
- javax.persistence.* → jakarta.persistence.*
- javax.servlet.* → jakarta.servlet.*
如果你把 Kafka 监听逻辑和业务逻辑耦合得很深,这种升级会牵一发动全身。也正因为如此,我一直建议把监听器写薄,把业务逻辑下沉到 service,让消息入口层更容易演进。
26.3 事务相关代码要重点回归验证
从 2.x 升级到 3.x 时,所有事务相关代码都值得做专项回归,尤其是:
- 监听器是否仍按预期参与容器事务;
- @Transactional 是否还绑定到正确的事务管理器;
- 批量监听异常时,恢复路径是否符合预期;
- DLT 行为、重试行为是否有变化;
- 序列化器、反序列化器升级后,消息头是否有兼容性问题。
不要以为代码能启动就等于迁移成功。消息系统里最可怕的问题,往往都是“平时不报错,线上一失败就暴露”。
26.4 Boot 3.x 下更推荐的工程写法
迁移到 Spring Boot 3.x 后,建议你顺手把这些工程实践也一起升级:
- DTO 尽量使用 record;
- 配置类尽量使用 @ConfigurationProperties;
- 监听器和业务逻辑职责清晰分层;
- 重要链路接入 Actuator/Micrometer 指标;
- 不再手写大量老式 XML/手工 Bean 装配代码。
换句话说,升级到 3.x 不是“只把包名改掉”,而是借机把你的消息工程风格也现代化。
二十七、批量监听下的顺序性问题:很多人只谈吞吐,不谈顺序
只要涉及 Kafka,就迟早会谈到“顺序性”。但顺序性不是一个绝对概念,它至少有三层含义:
Kafka 原生能较好保证的是单分区内顺序。所以在做批量监听设计时,你要特别关注这一点。
27.1 为什么批量监听不会天然破坏顺序
如果同一个分区中的记录被同一个消费者实例按 poll() 拉取出来,并在监听器中顺序处理,那么分区内顺序通常仍然可以保持。
问题往往出在下面这些地方:
- 你在监听器里把每条消息异步丢进线程池;
- 你把单批消息再次并行拆开处理;
- 你对同一个业务键没有做固定 key 路由;
- 你在失败重试时改变了处理路径。
所以,批量监听本身不是顺序杀手,随意并行化才是。
27.2 如何在批量监听中尽量保住业务顺序
建议从三个层面入手:
(1)生产端固定业务键
例如订单系统里,尽量使用 orderId 作为消息 key。这样同一订单的事件更容易落在同一分区。
(2)消费端避免对同一批次无脑并行
如果你的业务要求同键顺序,就不要把批次中的每条消息都丢进公共线程池乱跑。哪怕要并行,也应该按业务键做分组。
(3)失败恢复时谨慎处理
如果前一条消息失败,后一条消息即使能处理,也未必应该先推进。否则很容易打乱业务状态机。
27.3 哪些场景不应该过度追求顺序
顺序当然重要,但也不要神化顺序。很多系统里,真正需要的是:
- 单业务键顺序;
- 而不是全局顺序;
- 能接受最终按版本号收敛;
- 能通过幂等和版本控制化解乱序。
如果你一上来就追求所有消息严格顺序,很容易把吞吐和可扩展性都牺牲掉。
二十八、再补一块硬知识:事务、重试、死信之间到底是什么关系
这一块经常有人混淆。我们可以把它们看成三个不同维度的问题:
- 事务:定义成功/失败的一致性边界;
- 重试:定义失败后要不要再来一次;
- 死信:定义失败到什么程度不再阻塞主链路。
这三个维度经常一起出现,但它们不是一回事。
28.1 事务解决不了“坏消息”
事务可以保证失败时回滚,但如果一条消息本身就是脏数据,事务只会让它一次次失败、一次次回滚。
所以:
- 事务适合处理“暂时失败”;
- 死信更适合处理“稳定失败”。
28.2 重试不应该替代幂等
重试的本质是“再做一次”,幂等的本质是“重复做也不出错”。
一个成熟的消息消费系统,通常既需要:
- 有限次重试;
- 又需要幂等保障。
没有幂等的重试,很容易制造重复数据; 没有重试的幂等,又会让很多临时故障白白失败。
28.3 死信不是异常的终点,而是治理流程的起点
正确姿势应该是:
- 先给主链路减压;
- 再在 DLT 侧做分类、修复、回放;
- 最后形成闭环治理。
所以你在设计消息系统时,应该从一开始就想好:
- DLT 谁来消费;
- 消费频率如何;
- 回放机制如何;
- 是否支持人工干预;
- 是否能追踪原始异常原因。
二十九、加入一组可运行测试:让这篇文章不只停留在“代码示例”
真正高质量的技术文章,除了给出运行代码,更应该给出验证思路。下面补一组基础测试代码,帮助你理解如何在 Spring Boot 3.x 环境里验证 Kafka 监听行为。
说明:由于完整事务链路测试通常涉及数据库与 Broker 的联动,这里先给出一组偏教学型的集成测试骨架,帮助你建立测试思路。你可以在此基础上继续扩展。
29.1 Embedded Kafka 测试示例
package com.example.demo;
import static org.assertj.core.api.Assertions.assertThat;
import com.example.demo.domain.OrderCreatedEvent;
import com.example.demo.repository.OrderProcessLogRepository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.test.context.EmbeddedKafka;
import org.springframework.test.context.TestPropertySource;
@SpringBootTest
@EmbeddedKafka(partitions = 3, topics = {
"demo.order.manual.input",
"demo.order.tx.input",
"demo.order.tx.output",
"demo.order.partial.input",
"demo.order.partial.input.dlt",
"demo.order.outbox.output"
})
@TestPropertySource(properties = {
"spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}",
"spring.kafka.consumer.auto-offset-reset=earliest",
"spring.kafka.consumer.properties.isolation.level=read_committed"
})
class ManualBatchListenerTest {
@Autowired
private KafkaTemplate<String, Object> kafkaTemplate;
@Autowired
private OrderProcessLogRepository orderProcessLogRepository;
@Test
void shouldConsumeManualBatchSuccessfully() {
String eventId = UUID.randomUUID().toString();
OrderCreatedEvent event = new OrderCreatedEvent(
eventId,
1001L,
BigDecimal.valueOf(88.80),
"TEST-CUST",
"",
LocalDateTime.now()
);
// 发送测试消息到手动确认批量消费主题
kafkaTemplate.send("demo.order.manual.input", "1001", event);
// 等待消费完成,并验证幂等日志已经写入
Awaitility.await().untilAsserted(() ->
assertThat(orderProcessLogRepository.existsByEventId(eventId)).isTrue()
);
}
}
29.2 测试代码解析
这段测试代码虽然不长,但里面有几个非常重要的思想:
(1)用 @EmbeddedKafka 提升本地可测试性
这能让你在不依赖外部真实 Broker 的情况下,快速做集成测试。
(2)断言不要只看发送成功
很多人测试 Kafka 只断言“消息发出去了”,这没有意义。真正应该断言的是:
- 消费是否发生;
- 数据库是否落地;
- 幂等记录是否写入;
- 失败时是否进入预期路径。
(3)用等待机制处理异步链路
消息消费是异步过程,不要用 Thread.sleep() 粗暴等待。Awaitility 这种基于条件的等待方式,通常更清晰。
29.3 你还应该继续补什么测试
在真实项目里,我建议至少补齐这些测试:
- 正常批量消费测试;
- 批量中最后一条失败时的整批重放测试;
- 幂等命中测试;
- DLT 路由测试;
- 事务链路失败重放测试;
- Outbox 发布器重复执行测试。
会写测试的人,往往更能理解边界;因为测试本质上就是把边界显式化。
三十、给架构评审时可直接复用的一段结论
很多读者会把专栏文章用在团队分享、方案评审、面试准备里,所以这里我专门给你整理一段“可以直接拿去讲”的总结稿。
在 Spring Boot 3.x 中使用 Spring Kafka 做批量监听时,最关键的问题不是批量监听本身,而是事务边界如何设计。批量监听的优势在于降低回调频次、提升吞吐、支持批量业务操作;但它的代价在于失败粒度变粗,一条坏消息可能放大为整批回滚。对于只要求 at-least-once 的场景,推荐手动确认配合业务幂等;对于典型的 read-process-write 场景,可以采用容器事务加数据库事务的组合,并通过幂等抵御重放;对于坏消息概率较高的场景,则应考虑 BatchToRecordAdapter、有限次重试和 DLT;当系统演进到更复杂的跨资源一致性需求时,Outbox/CDC 往往是更稳妥的方向。最终选型的关键,不是盲目追求某一种“高级方案”,而是先判断这次拉取形成的“批”究竟是业务批还是技术批,再决定边界应该落在整批、单条还是异步发布阶段。
这段话你可以直接用于:
- 技术方案评审;
- 团队内部知识分享;
- 面试中的系统设计题;
- 项目复盘时的总结陈述。
三十一、FAQ:把读者最常问的 12 个问题一次讲清楚
Q1:批量监听一定比单条监听快吗?
不一定。它通常能降低框架层开销,但如果瓶颈在数据库、远程调用、磁盘 IO,那么批量监听不一定能显著提升总体吞吐。
Q2:开了 Kafka 事务,是不是就等于数据库和 Kafka 完全原子?
不是。Kafka 事务天然覆盖 Kafka 资源,本地数据库事务是另一套边界。二者组合后仍然需要幂等与补偿思路。
Q3:为什么明明用了事务,还要做幂等?
因为仍可能发生重试、重放、提交失败后的再次消费。事务不是幂等的替代品。
Q4:批量监听下最怕什么?
最怕失败被放大。尤其是一条坏消息把整批拖住,导致主链路反复重放。
Q5:什么时候适合整批一个事务?
当批次本身就是天然业务单元,且必须整体成功整体失败时。
Q6:什么时候适合批量拉取、逐条处理?
当“批”只是技术拉取单元,而不是业务单元时;尤其是在数据质量不稳定、坏消息概率高的场景。
Q7:BatchToRecordAdapter 的意义是什么?
它让你在保留批量拉取优势的同时,获得逐条处理和逐条失败隔离的能力。
Q8:DLT 进了以后是不是就不用管了?
不是。DLT 只是主链路的隔离区,后续必须有治理闭环。
Q9:事务型监听是不是一定更高级?
不是。是否使用事务,取决于一致性要求,而不是技术炫技。
Q10:Outbox 会不会太复杂?
对简单系统来说可能偏重,但对复杂一致性场景来说,它往往反而让系统更容易维护。
Q11:Boot 3.x 对这个主题最大的价值是什么?
更现代的 Java 基线、统一依赖管理、更成熟的自动配置以及更好的可观测性支撑。
Q12:如果我是新手,第一步应该学什么?
先学会:关闭自动提交、手动确认、幂等、批量监听的行为差异。把基础边界搞清楚后,再引入事务与 DLT。
三十二、结尾再强调一次:你真正要学会的不是“配法”,而是“边界感”
学消息中间件,初期很容易沉迷于配置项和注解:
- @KafkaListener 怎么写;
- AckMode 有几种;
- 事务怎么开;
- DLT 怎么配;
- max.poll.records 调多少。
这些当然重要,但它们都只是表层。
真正决定系统好坏的,是你有没有边界感:
- 你知不知道这次 poll 的“批”意味着什么;
- 你知不知道哪些动作应该绑定成功失败;
- 你知不知道失败后系统要以什么粒度恢复;
- 你知不知道哪些一致性靠事务,哪些一致性靠幂等,哪些一致性靠最终补偿。
当你具备这种边界感时,你写 Spring Kafka 就不再是“抄配置”,而是在做真正的架构设计。
而这,正是《零基础学 Spring Boot 3》专栏最希望带给读者的成长。
…
ok,同学们,本节课就上到这儿,下课~
🧧 学习福利 · 限时开放 🧧
当然,无论你是计算机专业在读学生,还是对编程充满兴趣的入门者,都强烈建议系统学习SpringBoot全体系专栏:👉 「滚雪球学 Spring Boot」;涵盖SpringBoot所有教学内容。
该专栏以“循序渐进 + 实战驱动”为核心理念,从基础到进阶到就业到架构师逐层展开,帮助你快速建立完整的 Spring Boot 技术体系,带你玩转SpringBoot框架。
📌 学习承诺: 通过该专栏,你将能够:
- 快速掌握 Spring Boot 核心开发能力
- 构建完整的后端项目认知体系
- 实现从“入门”到“独立开发”的跃迁
就像“滚雪球”一样,知识不断积累、能力持续放大,实现指数级成长 🚀
最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。
同时欢迎大家关注技术号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。
ps:本文涉及所有源代码,均已上传至Gitee开源,供同学们直接对照学习 Gitee传送门,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗
🫵 Who am I?
我是 bug菌,一名深耕 Java 后端领域数十年的一线研发老兵,曾担任独角兽企业后端技术经理、研发架构师等职位,长期专注于 Java 后端、分布式架构、微服务治理、高并发系统、工程效能与研发管理等方向。
目前活跃于多个主流技术社区,包括:
CSDN|稀土掘金|InfoQ|51CTO|华为云开发者社区|阿里云开发者社区|腾讯云开发者社区|开源中国|博客园|墨天轮 等平台。
曾获得:
- CSDN 博客之星 Top30
- 华为云多年度十佳博主 & 卓越贡献奖
- 掘金多年度人气作者 Top40
- CSDN、掘金、InfoQ、51CTO 等平台签约作者 / 优质作者
截至目前,全网技术内容累计影响读者众多,全网粉丝已超过 30w+。
如果你也关注 Java 后端、架构设计、技术成长、职场进阶与研发管理,欢迎关注我的技术内容合集入口:👉 点击查看 👈️
硬核技术号 「猿圈奇妙屋」 期待你的加入。
这里不仅分享技术干货,也记录一线研发人的成长、踩坑、思考与进阶路径。
愿我们一起打怪升级,在技术路上持续进阶。
– End –



