分布式事务与消息队列:Seata / RocketMQ
一句话速览:Seata AT 用 undo_log 快照让分布式事务”自动可回滚”,RocketMQ 用异步消息让服务解耦——两者共同的教训是:它们的失效方式都是沉默的(事务没回滚不报错、订阅被覆盖不报错),可靠性必须靠机制和验证来保证,不能靠”没异常”来推断。
涉及原始记录:
07-面试问题/开发问题/02-框架与中间件集成问题.mdP0-01、P0-05;07-面试问题/验证问题/01-业务逻辑问题.mdP0-01最近修订:2026-08-07
目录
技术点 1:Seata AT 模式 —— 分布式事务
难度:⭐⭐⭐ | 掌握要求:能画出两阶段提交流程,说清 undo_log、全局锁、写隔离
核心概念
Seata AT 模式的本质是:业务代码不用手写补偿逻辑,Seata 自动记录 SQL 执行前后的数据快照(存在 undo_log 表),一旦全局事务需要回滚,就用快照生成反向 SQL 自动执行。
原理深挖:AT 模式的两阶段提交到底做了什么
Seata 有三个角色:TC(Transaction Coordinator,Seata Server,全局事务的大脑)、TM(Transaction Manager,标注 @GlobalTransactional 的发起方)、RM(Resource Manager,每个参与事务的服务,管理本地分支事务)。
sequenceDiagram
participant TM as lease服务(TM)
participant TC as Seata Server(TC)
participant RM1 as lease库(RM)
participant RM2 as house库(RM)
TM->>TC: 1. 开启全局事务,获得 xid
Note over TM,RM1: 一阶段:本地提交
TM->>RM1: 2. 执行本地SQL(携带xid)
RM1->>RM1: 记录 before/after 快照到 undo_log
向TC注册分支事务、获取全局锁
RM1->>RM1: 本地事务直接提交(释放本地锁)
TM->>RM2: 3. Feign调用house(xid随Header透传)
RM2->>RM2: 同样记录undo_log、注册分支、本地提交
alt 全部成功
TM->>TC: 4a. 全局提交
TC->>RM1: 二阶段:异步删除undo_log即可
TC->>RM2: 二阶段:异步删除undo_log即可
else 任一失败
TM->>TC: 4b. 全局回滚
TC->>RM1: 二阶段:用undo_log生成反向SQL补偿
TC->>RM2: 二阶段:用undo_log生成反向SQL补偿
end
AT 模式区别于 XA 等传统两阶段协议的关键设计,也是面试必考点:
- 一阶段本地直接提交:RM 在一阶段就把本地事务提交了(不是 hold 住锁等二阶段),本地锁占用时间极短,这是 AT 性能远好于 XA 的原因。代价是中间态对其他事务可见——所以隔离性要靠全局锁补。
- 全局锁(Global Lock):RM 在一阶段提交前会向 TC 申请被修改行的全局锁。另一个全局事务想改同一行,也必须先申请全局锁——拿不到就等待/失败。这样保证”两个全局事务不会同时改同一行”,即 AT 默认提供读未提交之上的写隔离。
- 回滚前先校验:二阶段回滚时,RM 会用 undo_log 的 after 镜像和当前数据比对(脏写校验),如果不一致(说明全局事务提交后数据又被别人改了),自动回滚会失败,需要人工介入——这是兜底中的兜底。
- undo_log 表是每个参与库的”本地资源”:这就是为什么必须在每个服务的数据库各建一份,而不是全局共享一张。
要让这套机制跑起来,四个条件必须同时满足:
- 引入
seata-spring-boot-starter - 每个参与分布式事务的服务的数据库都要有
undo_log表(这张表是每个库自己的,不是全局共享一张) - 全局事务上下文(
xid)要能跨服务传递——这是通过 Feign 调用时自动在请求头里带上xid实现的,需要注册 Seata 的SeataFeignClientInterceptor @GlobalTransactional必须标注在事务的发起方法(比如”创建租约”这个入口方法),不是标注在被调用的下游方法上
我们踩过的坑
P0-01:跨服务事务未生效,残留脏数据
现象:livin-lease 创建租约时用 Feign 调 livin-house 冻结房源,标了 @GlobalTransactional,house 侧异常后 lease 侧数据却没回滚。
根因是条件 2 和条件 3 都没满足:house 服务的库里没建 undo_log 表,Feign 也没注册 Seata 拦截器。这两个问题的报错都不明显——不满足条件不会让事务”报错”,只会让事务”不生效”(回滚请求发出去了,但因为没有 undo_log 记录,没有东西可以回滚)。
必须记住的结论
undo_log表要在每一个参与全局事务的服务库里都建一份,不是建一次就够。新增一个会参与分布式事务的服务时,第一件事是检查这张表在不在。@GlobalTransactional要标在事务发起的入口方法上(谁开启全局事务,谁负责收尾),不要标在被 Feign 调用的下游方法上。- Seata 相关的”没回滚成功”类问题,优先检查
undo_log表是否存在、Feign 拦截器是否注册,这两处不满足时不会有明显的异常堆栈,只会看到”回滚了但数据还在”这种沉默失败。 - AT 模式一阶段本地直接提交,性能好但中间态可见,隔离性靠全局锁保证;二阶段提交是异步删 undo_log(很快),回滚才用快照补偿(较慢)——“正常路径快、异常路径慢”是 AT 的设计取向。
关联坑:验证阶段发现的 tenant_id 为空问题(业务逻辑问题 P0-01)
这个问题看起来是 Seata 的锅,实际是验证方式错了:直接绕过 Gateway 调 lease 服务内部接口,导致 UserContextHolder.getUserId() 拿不到用户 ID(这个值本来是 Gateway 解析 JWT 后通过 Header 透传的),tenant_id 字段插入了 NULL,进而影响 Seata 回滚 SQL 的执行。
这条记录放在这里是想强调一个更通用的教训:看到”分布式事务没回滚干净”,不要第一反应就是排查 Seata 配置,先确认测试数据本身是不是正常业务流程产生的。绕过网关直连业务端口是本项目里反复出现的一种”伪造出来的假故障”来源,详见 09-认证鉴权与网关架构.md。
知识扩展:分布式事务方案全景对比
AT 不是唯一选项,面试常考横向对比:
| 方案 | 一致性 | 性能 | 业务侵入 | 适用场景 |
|---|---|---|---|---|
| Seata AT | 强一致(全局锁) | 较好(一阶段直接提交) | 低(注解即可) | 关系型库、短事务,本项目选择 |
| TCC(Try-Confirm-Cancel) | 强一致 | 好(资源预留粒度细) | 高(每个操作写三个方法) | 资金类核心链路,需精细控制资源 |
| Saga | 最终一致 | 好 | 中(每个操作写正向+补偿) | 长流程、跨系统(如订单→物流→通知) |
| 本地消息表 / MQ 事务消息 | 最终一致 | 最好 | 中 | 允许短暂不一致的异步场景(最常用) |
记住判断思路:要实时强一致选 AT/TCC,允许秒级不一致优先选 MQ 最终一致方案(实现简单、性能最好)。实际大厂系统里最终一致方案的使用频率远高于强一致方案。
技术点 2:RocketMQ —— 消息队列
难度:⭐⭐⭐ | 掌握要求:能说清存储结构、刷盘策略、消息不丢的三个环节、重试与死信、幂等消费
核心概念
rocketmq-spring-boot-starter 的消费者模型有一个容易被忽视的底层约束:每一个 consumerGroup 在客户端侧对应一个独立的 DefaultMQPushConsumer 实例,一个实例只能维护一份订阅关系(订阅一个 topic)。这个约束在写代码时完全体会不到——@RocketMQMessageListener(topic=..., consumerGroup=...) 这个注解看起来只是声明式配置,不会有任何编译期或启动期的提示告诉你”这个 group 已经被占用了”。
原理深挖:Broker 架构与消息存储
flowchart LR
P[Producer] -->|同步/异步/单向发送| B[Broker]
NS[NameServer
轻量级路由注册中心] <-.->|Broker注册/客户端查路由| B
NS <-.-> P
NS <-.-> C
B --> CL[CommitLog
所有topic消息顺序追加写]
CL --> CQ[ConsumeQueue
每个topic每个queue的索引
offset+size+tag]
CL --> IF[IndexFile
按key/messageId查询]
CQ --> C[Consumer
消费进度记录在Broker]
理解存储结构是理解 RocketMQ 一切行为的基础:
- CommitLog:所有 topic 的消息都顺序追加到同一个物理文件族——顺序写磁盘性能接近内存,这是 RocketMQ 高吞吐的根本原因。
- ConsumeQueue:逻辑消费队列,每个 topic 的每个 queue 一份,只存”消息在 CommitLog 的偏移量 + 长度 + tag 哈希”,消费者实际读的是它。写消息时先落 CommitLog,再异步分发构建 ConsumeQueue/IndexFile。
- 刷盘策略:
SYNC_FLUSH(同步刷盘,消息落盘才返回成功,最可靠最慢)vsASYNC_FLUSH(异步刷盘,先写 PageCache 就返回,性能高,机器断电可能丢最后几百毫秒数据)。金融级用同步,一般业务用异步。 - 主从复制:
SYNC_MASTER(同步双写,Master 和 Slave 都写完才返回)+ASYNC_FLUSH是常见的”可靠性/性能”折中组合。Broker 配置里brokerRole和flushDiskType两个参数决定可靠性水位。
原理深挖:消息”不丢”要守住哪几个环节
消息从生产者到消费者,丢失风险分布在三个环节,每个环节有对应的手段——这是”RocketMQ 如何保证消息不丢”这道面试题的标准拆解:
- 发送环节:用同步发送 + 检查
SendResult.SEND_OK;重要消息开启失败重试。 - 存储环节:
SYNC_FLUSH或至少SYNC_MASTER同步复制到 Slave,防单机断电/磁盘损坏。 - 消费环节:消费成功后再提交 offset。RocketMQ 消费者默认是”业务处理返回成功才提交消费进度”,如果处理中宕机,Broker 会把这条消息重新投递(重试)——所以”至少一次”投递语义下,消费者幂等是义务,不是可选项。
关于重试,还要记住这条链:消费失败 → 进入重试队列(%RETRY% + consumerGroup 命名)→ 按延迟等级退避重试(默认 16 次,从 10s 逐级到 2h)→ 仍失败进入死信队列(%DLQ% + consumerGroup 命名),死信消息不再自动投递,需要人工或专门的任务兜底处理。
我们踩过的坑
P0-05:consumerGroup 复用导致订阅被覆盖,消息静默丢失
新写 RefundResultConsumer 时图省事,直接复制了已有的 PaymentResultConsumer,只改了 topic,consumerGroup 没改:
1 | // 错误:两个不同 topic 的监听器用了同一个 consumerGroup |
Spring 容器按 Bean 加载顺序依次向这个 group 注册订阅,后注册的会覆盖先注册的。最终这个 consumerGroup 实际只订阅了其中一个 topic。表现是:MQ 生产者这边显示消息发送成功(SendResult.SEND_OK),但消费者那边完全没有任何反应——没有异常、没有报错日志、启动也完全正常,是这次踩坑记录里隐蔽性最强的问题之一。
排查方法很关键,值得记住:普通的应用日志排查不出这个问题,必须用 RocketMQ 自带的运维工具确认某个 group 实际订阅了哪些 topic:
1 | mqadmin consumerConnection -n 127.0.0.1:9876 -g livin-lease-consumer |
修复方式很简单:一个 consumerGroup 只订阅一个 topic,新增消费者时必须用独立的 group 名:
1 |
|
必须记住的结论
- 一个
consumerGroup只能订阅一个 topic,新建消费者时永远给一个新的、专属的 consumerGroup 名字,不要复制粘贴已有消费者时漏改这一项。 - 这类”注册被覆盖”的问题,应用层日志看不出任何异常,唯一可靠的排查手段是用
mqadmin consumerConnection -g <group>直接问 MQ 服务端”这个 group 现在到底订阅了什么”。 - 设计消息 Topic 时,同一个业务模块如果有多种消息语义(比如”收款结果”和”退款结果”),优先拆成独立的 Topic + 独立的 consumerGroup,而不是共用一个 Topic 靠消息体里的字段做分支判断——这样从命名上就不容易发生 group 复用的失误,下游消费者的订阅关系也更清晰可查。
- 消息不丢要守住三个环节:发送确认、刷盘/主从复制、消费成功后再提交 offset;消费失败的处理链是 重试队列(16 次退避)→ 死信队列(人工兜底)。
知识扩展:没踩过但必须知道的
- 消费幂等:RocketMQ 是”至少一次”投递,网络抖动、消费超时、Rebalance 都可能导致同一条消息被投递多次。标准做法是消费前查”这条消息是否已处理过”(业务唯一键 + DB 唯一索引 / Redis
setnx去重表),幂等详细设计见10-JVM与并发编程.md。 - 顺序消息:全局顺序只有一个 queue(吞吐量剧降),局部顺序(同一订单ID的消息进同一 queue)靠发送方用 MessageQueueSelector 按 key 哈希选队列 + 消费方单线程消费该 queue 实现。”支付成功先于退款成功被消费”这类诉求用局部顺序即可。
- 事务消息:解决”本地事务和发消息的原子性”(先扣款成功才能发出”支付成功”消息)。机制是半消息 + 回查:先发一条对消费者不可见的半消息 → 执行本地事务 → 根据事务结果 commit/rollback 该消息;如果结果不明,Broker 会回查生产者的本地事务状态。它是实现最终一致性的标准武器,也是 Saga/本地消息表之外的第三条路。
- 延迟消息:RocketMQ 内置 18 个延迟等级(1s~2h),常用于”30 分钟未支付自动关单”类场景。注意延迟等级是固定的,不能任意指定时间。
- 广播 vs 集群消费:集群消费(默认)下一条消息只被 group 内一个实例消费;广播消费下每个实例都收到全量——本项目的”缓存清理广播”如果用 MQ 实现就该用广播模式,这是
03-缓存与数据一致性.md里状态广播问题的另一种解法。
术语表
| 术语 | 含义 |
|---|---|
| TC / TM / RM | Seata 三角色:事务协调器(Server)、事务管理器(发起方)、资源管理器(参与方) |
| undo_log | AT 模式记录数据前后镜像的表,二阶段回滚的依据,每个参与库各一份 |
| 全局锁(Global Lock) | Seata AT 对被修改行的跨全局事务互斥锁,保证写隔离 |
| 两阶段提交 | 一阶段本地事务直接提交并注册分支;二阶段全局提交(删 undo_log)或回滚(快照补偿) |
| TCC | Try-Confirm-Cancel,把每个业务操作拆成预留/确认/取消三个方法的分布式事务模式 |
| Saga | 长事务模式,每个正向操作配一个补偿操作,任一失败按逆序补偿 |
| NameServer | RocketMQ 的轻量级路由注册中心,Broker 注册、客户端查路由 |
| CommitLog / ConsumeQueue | RocketMQ 存储核心:全量消息顺序写文件 / 按 topic+queue 组织的消费索引 |
| 同步/异步刷盘 | 消息写入磁盘后才返回 / 写入 PageCache 即返回,可靠性与性能的取舍 |
| 重试队列 / 死信队列 | %RETRY% + group 命名(最多重试16次)/ %DLQ% + group 命名(重试耗尽后的兜底队列) |
| 事务消息 | 半消息+本地事务+状态回查机制,保证”本地事务成功才发出消息” |
| Rebalance | 消费者组内实例数量变化时,queue 消费权的重新分配(期间可能重复消费) |



