一句话速览:Seata AT 用 undo_log 快照让分布式事务”自动可回滚”,RocketMQ 用异步消息让服务解耦——两者共同的教训是:它们的失效方式都是沉默的(事务没回滚不报错、订阅被覆盖不报错),可靠性必须靠机制和验证来保证,不能靠”没异常”来推断。

涉及原始记录:07-面试问题/开发问题/02-框架与中间件集成问题.md P0-01、P0-05;
07-面试问题/验证问题/01-业务逻辑问题.md P0-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 表是每个参与库的”本地资源”:这就是为什么必须在每个服务的数据库各建一份,而不是全局共享一张。

要让这套机制跑起来,四个条件必须同时满足:

  1. 引入 seata-spring-boot-starter
  2. 每个参与分布式事务的服务的数据库都要有 undo_log(这张表是每个库自己的,不是全局共享一张)
  3. 全局事务上下文(xid)要能跨服务传递——这是通过 Feign 调用时自动在请求头里带上 xid 实现的,需要注册 Seata 的 SeataFeignClientInterceptor
  4. @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(同步刷盘,消息落盘才返回成功,最可靠最慢)vs ASYNC_FLUSH(异步刷盘,先写 PageCache 就返回,性能高,机器断电可能丢最后几百毫秒数据)。金融级用同步,一般业务用异步。
  • 主从复制SYNC_MASTER(同步双写,Master 和 Slave 都写完才返回)+ ASYNC_FLUSH 是常见的”可靠性/性能”折中组合。Broker 配置里 brokerRoleflushDiskType 两个参数决定可靠性水位。

原理深挖:消息”不丢”要守住哪几个环节

消息从生产者到消费者,丢失风险分布在三个环节,每个环节有对应的手段——这是”RocketMQ 如何保证消息不丢”这道面试题的标准拆解:

  1. 发送环节:用同步发送 + 检查 SendResult.SEND_OK;重要消息开启失败重试。
  2. 存储环节SYNC_FLUSH 或至少 SYNC_MASTER 同步复制到 Slave,防单机断电/磁盘损坏。
  3. 消费环节消费成功后再提交 offset。RocketMQ 消费者默认是”业务处理返回成功才提交消费进度”,如果处理中宕机,Broker 会把这条消息重新投递(重试)——所以”至少一次”投递语义下,消费者幂等是义务,不是可选项

关于重试,还要记住这条链:消费失败 → 进入重试队列(%RETRY% + consumerGroup 命名)→ 按延迟等级退避重试(默认 16 次,从 10s 逐级到 2h)→ 仍失败进入死信队列(%DLQ% + consumerGroup 命名),死信消息不再自动投递,需要人工或专门的任务兜底处理。

我们踩过的坑

P0-05:consumerGroup 复用导致订阅被覆盖,消息静默丢失

新写 RefundResultConsumer 时图省事,直接复制了已有的 PaymentResultConsumer,只改了 topicconsumerGroup 没改:

1
2
3
4
5
6
// 错误:两个不同 topic 的监听器用了同一个 consumerGroup
@RocketMQMessageListener(topic = "livin-payment-result", consumerGroup = "livin-lease-consumer")
public class PaymentResultConsumer implements RocketMQListener<PaymentResultMessage> { /* 省略实现 */ }

@RocketMQMessageListener(topic = "livin-refund-result", consumerGroup = "livin-lease-consumer")
public class RefundResultConsumer implements RocketMQListener<RefundResultMessage> { /* 省略实现 */ }

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
2
@RocketMQMessageListener(topic = "livin-refund-result", consumerGroup = "livin-lease-consumer-refund")
public class RefundResultConsumer implements RocketMQListener<RefundResultMessage> { /* 省略实现 */ }

必须记住的结论

  • 一个 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 消费权的重新分配(期间可能重复消费)