欢迎光临
我们一直在努力

【SpringBoot 3.x 第287节】夯爆了,Spring Kafka 中批量监听与事务边界设计,一文带你吃透(超详解)!

🏆 本文收录于 《滚雪球学 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 消费场景中,通常涉及以下几类动作:

  • 拉取消息;
  • 执行业务逻辑;
  • 写数据库;
  • 发送新的 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_committed 时,避免读到已中止事务中的消息。
  • 如果你做的是典型的 read → process → write 流程,那么 Kafka 事务可以帮助你把:

    • 读取到的偏移量提交;
    • 发往下游主题的消息发送;

    放在同一个 Kafka 事务里完成。

    但请注意,Kafka 事务只天然覆盖 Kafka 资源本身,不会自动把你的数据库也变成 Kafka 事务的一部分。

    所以一旦你的监听器里同时涉及数据库更新,就进入了“跨资源一致性”的话题。此时你不能只问“要不要开事务”,而要问:

    • Kafka 事务和 DB 事务如何组合?
    • 谁先提交?
    • 提交失败后如何补偿?
    • 幂等是不是足够?

    这就是本文的核心。

    六、本文实战项目结构说明

    为了让文章不悬空,我们准备一个可运行的 Spring Boot 3.x 示例项目。这个项目包含三组可运行案例:

  • 案例一:手动确认 + 批量监听 + 幂等消费
  • 案例二:容器事务 + 数据库事务 + 下游事件发送
  • 案例三:BatchToRecordAdapter + 坏消息隔离 + DLT
  • 另外我还会在后面补充一个可运行的 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 注解本身,而在容器工厂如何配置。

    我们这里准备三个容器工厂,分别对应三种处理模式:

  • 手动确认批量监听;
  • 事务型批量监听;
  • 批量拉取 + BatchToRecordAdapter。
  • 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 验证步骤建议

    验证案例一
  • 调用 /demo/manual/send?count=5;
  • 观察全部成功,并且位点正常确认;
  • 调用 /demo/manual/send?count=5&failRemark=FAIL_BATCH;
  • 观察最后一条失败,整批不 ack,后续会重放;
  • 再观察幂等日志,确认已成功消息不会重复落库。
  • 验证案例二
  • 调用 /demo/tx/send?count=5;
  • 观察数据库写入与下游事件发送;
  • 调用 /demo/tx/send?count=5&failRemark=FAIL_TX;
  • 观察事务回滚与消息重放;
  • 通过幂等表验证重复处理被拦截。
  • 验证案例三
  • 调用 /demo/partial/send?count=5&failRemark=FAIL_PARTIAL;
  • 观察正常消息继续推进;
  • 观察失败消息重试后进入 DLT;
  • 验证主流程没有被单条坏消息拖死。
  • 验证 Outbox
  • 调用 /demo/outbox/send?count=5;
  • 观察 t_outbox_event 表生成 NEW 记录;
  • 等待定时发布器执行;
  • 观察发布成功后状态变为 PUBLISHED。
  • 二十二、为什么说“所有案例代码可运行”并不等于“所有生产问题都被解决”

    你在学习技术文章时,一定要分清楚“示例可运行”和“架构完备”之间的区别。

    本文给出的代码,目标是:

    • 让你在本地能真正跑起来;
    • 看到三种边界设计的行为差异;
    • 理解 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,就迟早会谈到“顺序性”。但顺序性不是一个绝对概念,它至少有三层含义:

  • Topic 全局顺序;
  • 分区内顺序;
  • 业务键维度顺序。
  • 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 –

    赞(0)
    未经允许不得转载:171主机测评 » 【SpringBoot 3.x 第287节】夯爆了,Spring Kafka 中批量监听与事务边界设计,一文带你吃透(超详解)!
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址