分布式(二):分布式事务、分布式 ID 与幂等
分布式(二):分布式事务、分布式 ID 与幂等
导语:分布式事务是架构面试的"必答题",也是最容易答得空泛的一章。本篇先把底层协议讲透(2PC / 3PC / XA 的流程与缺陷),再给出完整的方案谱系与选型决策,然后逐个拆开柔性事务的四种落地姿势——TCC、Saga、本地消息表、MQ 事务消息,补上很少有人提但生产必需的对账兜底,落到 Seata 的 AT 模式与 TC/TM/RM 执行流程;后半篇补齐与事务强绑定的两块内容——分布式 ID(唯一标识、雪花算法时钟回拨、号段模式)与幂等设计(唯一索引、Token、状态机,以及幂等/去重/防重放的层次分工),共 18 题。
一、分布式事务协议
1. 两阶段提交(2PC)的原理与缺陷?
答: 2PC 是强一致的分布式事务经典协议,引入一个协调者(Coordinator)统一指挥多个参与者(Participant):
阶段一:准备 / 投票(Prepare,也叫 CanCommit / Vote)
- 协调者向所有参与者发送
prepare请求; - 参与者执行事务但不提交:写 undo/redo 日志、锁定所需资源,返回
yes(能提交)或no(不能)。
阶段二:提交 / 回滚(Commit / Rollback)
- 全部 yes:协调者先写 commit 日志(关键!防止自身宕机后失忆),再向所有参与者发
commit;参与者提交、释放锁、应答ack。 - 任一 no 或超时:协调者发
rollback,参与者用 undo 日志回滚并释放资源。
四个核心缺陷(这也是 2PC 在大规模互联网系统少用的原因):
| 缺陷 | 说明 | 后果 |
|---|---|---|
| 同步阻塞 | 参与者在 prepare 后一直持有锁直到收到决断,且要经历两次网络往返 + 多次日志 fsync | 并发急剧下降,长事务下锁等待甚至死锁 |
| 协调者单点故障 | 协调者宕机,参与者无法自行决断,只能一直阻塞(甚至需要人工介入) | 资源长期被锁,系统大面积不可用 |
| 数据不一致 | 阶段二协调者只发了一部分 commit 就宕机,部分节点提交、部分未提交 | 出现"未决事务(in-doubt)",需要靠 xa_recover 或人工恢复 |
| 性能差 | 跨网络协调 + 每阶段都要落盘日志 | 吞吐远低于本地事务,只适合短事务、低并发 |
高频追问:"协调者宕机后参与者一直锁着怎么办?"——标准答案是:参与者不能自己决定(否则可能与协调者后续决策冲突),只能:① 由协调者的事务日志恢复后重新决断;② 依赖超时 + 恢复流程(如
xa_recover);③ 极端情况人工介入。这个"无法自治"正是 3PC 想解决的问题。
2. 三阶段提交(3PC)及与 2PC 的区别?
答: 3PC 在 2PC 前增加一次询问,把原来的"准备"拆成 CanCommit + PreCommit,形成三段:
| 阶段 | 协调者动作 | 参与者动作 |
|---|---|---|
| 1. CanCommit(询问) | 询问"能不能做" | 只做可行性检查(不执行、不加锁),返回 yes/no |
| 2. PreCommit(预提交) | 发 preCommit | 执行事务但不提交,写日志、加锁,返回 ack |
| 3. DoCommit(提交) | 发 doCommit / doRollback | 真正提交或回滚,释放资源 |
与 2PC 的核心差别:参与者引入超时后可以"自治决断"
- 进入 PreCommit 后超时(即阶段 3 没等到指令):参与者默认提交——因为能进到 PreCommit,说明阶段 1、2 大多数节点都同意了,协调者大概率只是网络问题;
- 阶段 1 后超时:参与者默认中止(回滚)——因为还没执行、没加锁,中止最安全。
3PC 的问题(所以实践中也很少用):
- 仍无法彻底避免不一致:超时"默认提交"的参与者,可能与协调者最后的"回滚"决策冲突(分区场景下无法根除);
- 多了一次网络往返:延迟更高,理论收益小于性能损失;
- 只降低阻塞概率,不解决根本问题。
答题定式:3PC = 2PC + CanCommit 预询问 + 参与者超时自治,它把"阻塞"风险降低,但代价是更多往返,且分区下仍可能不一致,所以工程上基本被 TCC / 消息最终一致替代,只作理论演进存在。
3. XA 协议是什么?与 2PC 的关系?有什么缺陷?
答: XA 是 X/Open 组织制定的分布式事务规范(标准接口),定义了 TM(事务管理器)与 RM(资源管理器,如数据库)之间的编程接口;而我们常说的 2PC 就是 XA 规范在数据库层的具体实现算法——XA 是"规范",2PC 是"算法/实现"。
X/Open DTP 模型的三个角色:
| 角色 | 职责 | 典型实现 |
|---|---|---|
| AP(Application) | 业务应用,定义事务边界 | 业务代码 |
| TM(Transaction Manager) | 协调者:分配事务 ID、驱动两阶段提交、负责恢复 | JTA/JTS 实现(Atomikos、Bitronix)、Seata XA 模式 |
| RM(Resource Manager) | 参与者:提供资源并实现 XA 接口 | MySQL、Oracle、MQ 等 |
XA 的关键接口(能报出名字就很加分):
xa_start 开启/加入一个全局事务分支
xa_end 结束分支(标记 SQL 执行完)
xa_prepare 阶段一:准备(投票)
xa_commit 阶段二:提交
xa_rollback阶段二:回滚
xa_recover 恢复:查询处于"未决"状态的事务,用于故障恢复工程实例:
- MySQL:
XA START 'xid'→ 业务 SQL →XA END 'xid'→XA PREPARE 'xid'→XA COMMIT 'xid'; - Java:JTA / JTS,由 Atomikos 等 TM 驱动多个数据源;
- Seata:提供
XA模式,把 TM 角色交给 Seata Server(TC)。
XA / 2PC 的缺陷(与 2PC 同源,注意别和上一题重复堆砌,面试时挑 3 点讲透即可):
- 同步阻塞、锁持有时间长:
XA PREPARE之后资源锁一直不释放直到阶段二,并发极低; - 单点与长事务风险:TM 宕机,参与者长期持锁不释放,需要靠
xa_recover+ 人工恢复; - 数据不一致风险:阶段二网络分区导致部分分支提交、部分未提交;
- 性能代价:两次网络交互、两轮日志落盘(
prepare与commit各一次 fsync),吞吐远低于本地事务。
结论:XA 适合短事务、低并发、强一致的内部系统(如银行内部账务);互联网高并发场景一律用柔性事务替代。
二、方案总览与选型
4. 分布式事务的成因是什么?有哪些主流解决方案?
答:
成因:业务操作跨多个数据库 / 服务 / 资源,而单机 ACID 事务的 undo/redo 只能回滚本库——一旦跨库,数据库的本地事务机制失效,无法保证"多个资源要么都成功、要么都回滚"。
主流方案总览(按"一致性强度 → 性能/侵入度"排列):
| 方案 | 一致性 | 业务侵入 | 性能 | 适用场景 | 代表 |
|---|---|---|---|---|---|
| 2PC / XA | 强一致(CP) | 低(框架托管) | 差(同步阻塞) | 短事务、低并发、内部系统 | MySQL XA、JTA、Seata XA |
| TCC | 强/最终一致 | 高(写 Try/Confirm/Cancel) | 好(不长期持锁) | 核心交易、资金、库存 | Seata TCC、Hmily |
| Saga | 最终一致 | 中(写补偿逻辑) | 好 | 长流程、跨多服务(履约、订票) | Seata Saga、Camunda |
| 本地消息表 | 最终一致 | 中 | 好 | 异步解耦、不依赖 MQ 高级特性 | 自研 |
| MQ 事务消息 | 最终一致 | 低 | 好 | 最常见(订单→积分/通知/搜索) | RocketMQ、Kafka 事务 |
| 最大努力通知 | 最终一致(弱) | 低 | 好 | 对一致性要求低(支付结果回调) | 自研 + 对账 |
| Seata AT | 最终一致 | 最低(无侵入) | 中(有全局锁) | 通用、快速接入存量系统 | Seata |
演进脉络(一句话串起来):
2PC/XA(强一致但同步阻塞)
→ TCC(业务层预留,换掉长锁)
→ Saga(长事务补偿)
→ 本地消息表 / MQ 事务消息(异步最终一致,最主流)
→ Seata AT(把补偿自动化,做到无侵入)回答这题的关键:不要只罗列,要给出"同步阻塞 → 异步补偿 → 无侵入自动化"的演进逻辑,并明确"互联网主流是消息最终一致 + TCC 兜核心链路"。
5. 分布式事务怎么选型?有没有"不用分布式事务"的办法?
答:
选型决策(按业务特征对号入座):
| 业务特征 | 推荐方案 | 理由 |
|---|---|---|
| 跨库短事务、并发不高、要求强一致 | XA / 2PC | 开发最简单,业务无侵入 |
| 资金、库存、券码等核心写链路 | TCC | 资源预留、可精确控制、无长锁 |
| 长流程、多步骤、可接受中间态 | Saga | 单步本地事务 + 补偿,吞吐高 |
| 跨服务异步(下单 → 积分/通知/索引) | MQ 事务消息 / 本地消息表 | 解耦、削峰、最常见 |
| 对接外部系统(支付、物流) | 最大努力通知 + 对账 | 对方不接受你的事务协议 |
| 存量系统希望"少改代码" | Seata AT | 通过数据源代理自动补偿 |
更重要的答案:能不用就不用。 分布式事务的第一优先级是设计上规避:
- 合并边界:把强一致要求的数据放同一个库(如订单与订单明细同库),从根上消掉分布式事务;
- 只对"必须一致"的部分用分布式事务:通知、统计、积分、搜索索引这些下游派生数据一律走异步最终一致;
- 拆分"强弱":核心链路用 TCC/强一致,非核心用消息 + 对账;
- 数据最终一致的兜底靠对账(见第 10 题),而不是指望流程 100% 不出错。
答题金句:"分布式事务的终极方案是尽量不产生分布式事务;不可避免时,用'同步 TCC 保核心 + 异步消息保外围 + 对账兜底'的三层结构。"
三、柔性事务的四种落地姿势
6. TCC(Try-Confirm-Cancel)模式是怎么实现的?三个工程难点是什么?
答: TCC 把一次分布式事务拆成业务层面的三个阶段,由协调者统一驱动各分支:
| 阶段 | 动作 | 要点 |
|---|---|---|
| Try | 预留资源(冻结库存、预扣可用余额、预占优惠券),做资源检查 + 占位 | 不真正提交,但资源已被"挂起" |
| Confirm | 所有分支 Try 成功 → 各自执行真正的提交(扣减冻结的库存/余额) | Try 已占位,Confirm 必须成功,失败要无限重试 |
| Cancel | 任一分支 Try 失败 → 逆向释放各分支预留的资源(解冻) | 必须幂等,且失败要重试 |
与 2PC 的本质区别:TCC 的"锁"是业务层的资源预留,而不是数据库的行锁——因此本地事务很快就提交,不长期持锁,并发性能远好于 XA。
三个工程难点(面试必考,逐个展开):
| 难点 | 场景 | 解决 |
|---|---|---|
| 幂等 | 网络重试让 Confirm / Cancel 被多次调用 | 用事务控制表(事务 ID + 分支 ID 唯一索引)记录已执行状态,重复请求直接返回成功 |
| 空回滚 | Try 根本没执行成功(如超时/未到达),却收到了 Cancel | Cancel 时查事务控制表:若不存在 Try 记录 → 说明是空回滚,记录一条 Cancel 状态并直接返回成功 |
| 防悬挂 | Cancel 比 Try 先到(网络乱序),之后 Try 才执行 → 资源被预留却无人释放 | Try 执行前先查事务控制表:若已有 Cancel 记录 → 拒绝执行 Try |
实现要点(答出这几点会显得很懂):
- 必须有独立的"事务控制表",而不是只靠业务表状态——因为空回滚与悬挂的判断依据是"该分支是否执行过",业务状态无法表达这个语义;
- Confirm / Cancel 不允许失败:必须重试到成功(同步重试 + 异步补偿任务);
- Try 要预留"足够但不过量"的资源,且要能应对并发预留(如 Redis/Lua 原子扣减冻结额度);
- 常见实现:Seata TCC 模式、Hmily、TCC-Transaction。
适用与代价:TCC 一致性好、性能可控,适合资金、库存、券等核心链路;但业务侵入极强(每个分支都要写三个接口 + 控制表 + 幂等 + 补偿重试),开发成本高。不能预留的资源(如第三方接口)无法用 TCC,此时用 Saga。
7. Saga 模式是什么?与 TCC 的区别?
答: Saga 把长事务拆成一串本地事务 T1…Tn,并为每个本地事务准备一个补偿操作 C1…Cn(语义上的"逆向操作"):
两种失败恢复策略:
- 向前恢复(Forward Recovery):认为失败是暂时的,重试直到成功(适合幂等且最终必然成功的步骤,如调第三方);
- 向后恢复(Backward Recovery,补偿):某步彻底失败,则按逆序调用已成功步骤的补偿:
Tm失败 → 执行C(m-1) … C1。
两种协调方式:
| 方式 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 编排(Choreography) | 各服务通过事件互相触发,无中央协调者 | 去中心、松耦合、无单点 | 流程散落各处、难追踪,循环依赖风险 |
| 编制(Orchestration) | 中央协调器(状态机) 按序调用各服务与补偿 | 流程集中、可视化、易管理 | 协调器是单点,需高可用 |
与 TCC 的对比(面试核心):
| 维度 | TCC | Saga |
|---|---|---|
| 执行时机 | Try 只预留不提交,Confirm 才真正落库 | 每一步立即真正提交 |
| 回滚方式 | 释放预留资源(Cancel) | 执行补偿操作(语义逆向) |
| 隔离性 | 较好(资源被"冻结",其他事务不可用) | 无隔离——中间态对外可见,可能出现脏读 |
| 是否需要"可预留" | 必须能预留资源 | 不要求(可调第三方、可发消息) |
| 侵入度/复杂度 | 高(三个接口 + 控制表) | 中(每步一个补偿接口) |
| 适用 | 核心交易短链路(资金/库存) | 长流程、跨多服务(旅游订票:机票+酒店+租车) |
Saga 的两个固有短板(必须承认):
- 没有隔离性:
T1已提交的结果可能被其他事务读到,之后才被补偿掉——所以补偿操作必须能"覆盖"这段中间态带来的影响(如"已发券"的补偿是"收回券"); - 补偿不一定成功:补偿本身可能失败(如第三方拒绝撤销),需要重试 + 人工兜底 + 对账。
一句话区分:TCC 是"先冻结、后确认",Saga 是"先干了、不行再补"。 前者一致性强但要求能预留,后者灵活但放弃了隔离性。
8. 本地消息表与最大努力通知方案是怎么做的?
答:
(1)本地消息表(Local Message Table)——"把消息和业务放进同一个本地事务"
流程:
- 业务操作与"待发送消息记录"在同一个本地事务里写入同一个库(业务表 + 消息表);
- 独立线程 / 定时任务扫描消息表(状态 = 待发送),投递到 MQ 或直接调用下游;
- 投递成功 → 更新消息状态为"已发送";失败 → 重试(带退避);
- 消费者幂等消费,处理成功后回执(回调接口或反向发消息)更新消息状态为"已完成";超时未回执则继续重试。
优点:不依赖 MQ 的事务特性,实现简单、可靠性高(与业务同库同事务,天然不会"业务成功消息没落库")。
缺点:消息表与业务库强耦合(占业务库资源、需定时任务)、有投递延迟、需要额外维护状态机与重试。
(2)最大努力通知(Best-Effort Notification)——"尽力而为 + 对账兜底"
流程:
- 发起方在业务完成后,主动通知接收方,失败则按阶梯退避重试(如 5s、30s、1min、5min……最多 N 次);
- 接收方提供查询接口(或对账文件),供发起方主动核对通知结果;
- 超过重试次数仍未成功 → 由接收方对账 / 人工补偿兜底。
特点:只追求"尽力通知",不保证一定送达,一致性最弱,但实现最简单。适用于对一致性要求低的场景,如支付结果通知、物流状态回调。
对比记忆:本地消息表是"生产者侧用同库事务保证消息一定发出";最大努力通知是"发起方尽力重试 + 接收方对账"。前者可靠性更高,后者更轻量。
9. 基于消息队列的最终一致性方案(事务消息)是怎么做的?
答: 这是互联网最主流的分布式事务落地方式。以 RocketMQ 事务消息为代表:
- 生产者向 Broker 发送半消息(Half Message)——消息已存储但对消费者不可见;
- 半消息发送成功后,生产者执行本地事务(如创建订单);
- 根据本地事务结果,向 Broker 发送
commit(消息对消费者可见)或rollback(消息被丢弃); - 回查机制兜底:若 Broker 长时间没收到 commit/rollback(生产者宕机、网络中断),会定时回查生产者(
checkLocalTransaction)——生产者查询本地事务状态(通常是查业务表)后告知结果; - 消费者消费消息并幂等处理,成功才 ACK;失败则重投。
Kafka 的事务方案(不同思路):enable.idempotence=true(幂等生产者,靠 PID + 序列号去重)+ transactional.id(跨分区原子写 + 事务标记),实现跨分区 Exactly-Once,但不解决"本地事务与发消息的原子性"——那一环仍需靠本地消息表 / Outbox 模式或 Kafka Connect 的 CDC 方案。
关键三要素(答题必须点出):
| 环节 | 要求 | 手段 |
|---|---|---|
| 生产者可靠发 | 本地事务与消息发送原子 | 事务消息(半消息 + 回查)/ 本地消息表 |
| Broker 可靠存 | 消息不丢 | 持久化 + 多副本(Kafka ISR、RocketMQ 主从)、acks=all |
| 消费者可靠收 | 不丢 + 不重复副作用 | 手动 ACK + 重试 + 消费幂等(唯一键 / 状态机) |
注意点:RocketMQ 回查有次数上限(默认 15 次),超过则默认回滚(消息被丢弃),所以业务侧必须能通过"查本地事务表"给出确定答案——这也是为什么事务消息的本地事务要可查询。
一句话:事务消息 = 本地消息表的"MQ 原生版"——把"消息表 + 定时扫描"换成 Broker 的"半消息 + 回查",代价是对 MQ 能力有依赖。
10. 最终一致性的"兜底"怎么做?为什么一定要对账?
答: 因为任何自动化流程都可能失败:补偿接口可能超时、消息可能丢、程序可能有 bug、人工可能误操作。所以"最终一致"的完整表达应该是:
最终一致 = 正向流程(正常执行)
+ 补偿流程(失败回滚 / 重试)
+ 对账校正(定时比对,修正漏网之鱼)对账体系的设计要点:
| 环节 | 做法 |
|---|---|
| 数据基础 | 双方都要有唯一业务流水号 + 状态 + 金额/数量 + 时间,能一一对应 |
| 对账频率 | 准实时(分钟级,扫"长时间未完成"的中间态单据)+ T+1 全量对账(或小时级)双保险 |
| 差异分类 | ① 单边账(一方有、一方无)② 金额/数量不一致 ③ 状态不一致(一方成功一方失败) |
| 处理策略 | 幂等的自动补偿(补发消息 / 主动查询上游后修正状态);无法自动处理的进人工工单 |
| 告警与度量 | 差异笔数、差异金额、补偿成功率必须监控告警,差异率异常要能快速定位 |
常见落地组合:
- 支付链路:与银行/渠道每日对账文件核对,长短款挂"差错处理";
- 订单 ↔ 库存:定时比对"已扣未支付""已支付未扣"的单据,做补偿或回滚;
- MQ 消费:死信队列(DLQ) + 人工/自动重投,避免消息永久丢失。
答题金句:"最终一致性不是'相信流程一定成功',而是'承认流程一定会有失败,然后用对账把它纠回来'。" 能主动提对账,是区分"背过八股"与"做过生产"的关键信号。
四、Seata 落地
11. Seata 支持哪些模式?AT 模式的原理是什么?
答: Seata 提供四种模式,覆盖从强一致到最终一致的全谱系:
| 模式 | 一致性 | 侵入 | 原理 | 适用 |
|---|---|---|---|---|
| AT(默认) | 最终一致 | 无侵入 | 自动生成 undo_log 做反向补偿 | 通用、存量系统快速接入 |
| TCC | 强/最终一致 | 高(手写三接口) | 资源预留 + 确认/取消 | 核心交易 |
| Saga | 最终一致 | 中 | 状态机 + 补偿 | 长流程 |
| XA | 强一致 | 低 | 数据库 XA 接口 | 强一致、低并发 |
AT 模式原理(重点,分两阶段说):
一阶段(业务 SQL 执行):
- Seata 通过
DataSourceProxy代理数据源,拦截并解析业务 SQL 语义; - 执行前查询原数据 → 生成前镜像(before image);
- 执行 SQL;
- 执行后查询新数据 → 生成后镜像(after image);
- 把 undo_log(前镜像 + 后镜像 + 业务 SQL 信息)与业务数据在同一个本地事务中一起提交;
- 同时向 TC 注册分支事务,并申请该行数据的全局锁。
二阶段(TC 决策):
- 全局提交:各 RM 只需异步删除 undo_log(数据已提交,无需再动)——所以 AT 的提交是"极快"的;
- 全局回滚:
- 校验(后镜像比对):用后镜像与当前数据库数据比对,若已被其他事务修改(脏写)则拒绝回滚并告警;
- 反向补偿:用前镜像生成反向 SQL(
INSERT→DELETE、UPDATE→还原旧值)执行; - 删除 undo_log。
隔离性说明(高频追问):
| 隔离维度 | AT 的默认行为 | 如何加强 |
|---|---|---|
| 写隔离 | 靠 TC 的全局锁:同一行数据在全局事务提交前,其他全局事务不能改 | 默认已保证 |
| 读隔离 | 默认是全局读未提交——因为全局锁在二阶段才释放,中间态对普通查询可见 | 对需要强读的场景用 @GlobalLock + SELECT ... FOR UPDATE(会加本地锁) |
AT 的优缺点:
- 优点:几乎零代码侵入(加个
@GlobalTransactional即可),改造成本最低; - 缺点:① 需要每个业务库建
undo_log表;② 有全局锁,热点行(如秒杀库存)竞争明显;③ 只支持关系型数据库的 DML,Redis、MQ、外部 HTTP 接口都不适用;④ 大事务/批量 SQL 会让 undo_log 膨胀、性能下降。
与 XA 的对比(常考):AT 的本地事务在阶段一就提交(只留 undo_log),不长期持数据库锁,因此性能优于 XA;代价是中间态对读可见(隔离性弱于 XA)。想强一致 + 不忍受中间态 → XA;想低侵入 + 高性能 → AT。
12. Seata 的 TC / TM / RM 三大角色与执行流程?
答: Seata 把分布式事务拆成三个角色(注意与 XA 的 TM/RM 概念同名但不同层):
| 角色 | 全称 | 部署位置 | 职责 |
|---|---|---|---|
| TC | Transaction Coordinator | 独立部署的服务端(seata-server) | 全局事务的大脑:生成 XID、开启/提交/回滚全局事务、汇总分支状态、维护全局锁、驱动失败重试;持久化 global_table / branch_table / lock_table |
| TM | Transaction Manager | 业务服务内(客户端) | 定义全局事务边界:@GlobalTransactional 注解所在的方法就是边界,负责向 TC 开启、提交、回滚全局事务 |
| RM | Resource Manager | 业务服务内(客户端) | 管理分支事务资源:向 TC 注册分支、上报状态、接收 TC 指令执行提交/回滚(AT 模式下自动写 undo_log,由 DataSourceProxy 完成) |
执行流程(AT 模式):
1. TM:@GlobalTransactional 拦截 → 向 TC 申请开启全局事务 → 拿到全局 XID
2. XID 透传:通过 RPC 附件 / 线程上下文(RootContext)传给所有下游服务
3. RM(每个分支):
- 执行本地业务 SQL(DataSourceProxy 生成 undo_log)
- 本地事务提交(业务数据 + undo_log 一起落库)
- 向 TC 注册分支,申请全局锁
- 上报分支状态(成功/失败)
4. TM:方法执行结束 → 通知 TC 全局"提交"或"回滚"
5. TC:汇总所有分支结果 → 决策 → 向各 RM 下发统一指令(失败自动重试)
- 提交:各 RM 异步删除 undo_log
- 回滚:各 RM 用前镜像反向补偿并删除 undo_log高可用与容灾(加分点):
- TC 集群:seata-server 可多实例部署,通过注册中心(Nacos/Eureka)注册,客户端按事务分组(tx-service-group)负载均衡;
- TC 宕机恢复:全局事务状态已持久化在
global_table/branch_table,TC 重启或切换到其他节点后可读取未完成事务并继续驱动(这也是为什么"提交/回滚必须可重试"); - 分支提交失败:TC 会持续异步重试,直到成功(因此分支接口必须幂等)。
一句话串联:TM 开事务、RM 干分支、TC 做决策;AT 模式下 RM 的"记账与补偿"(undo_log + 全局锁)由 Seata 自动完成,这也是 AT"无侵入"的代价——必须依赖 undo_log 表与 TC 的全局锁。
五、分布式 ID
分布式 ID 解决的是「唯一标识」问题:它既是幂等的幂等键来源,也是分库分表路由、链路追踪、订单流水的基础(幂等见本篇第六节)。
13. 分布式 ID 有哪些生成方案?各自优缺点?
答: 分布式 ID 的诉求通常是:全局唯一、趋势递增(对索引友好)、高性能、高可用、信息安全(不泄露业务量)。
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| UUID | 本地随机生成 128 bit | 简单、无网络依赖、性能极高 | 完全无序 → B+Tree 索引页分裂、写入性能差;占空间(36 字符);不可读;作主键不友好 |
| 数据库自增(多实例设步长) | 各实例 auto_increment_increment = 实例数、起始值错开 | 简单、绝对递增、ID 紧凑 | 依赖 DB(单点/瓶颈);扩容困难(改步长要重配);强依赖可用性 |
| 数据库号段(Leaf-segment) | 一次从 DB 取一批(如 1000 个)到内存分发 | DB 压力小(降 3~4 个数量级)、趋势递增、性能高 | ID 不连续(跳号);依赖 DB;需双 buffer 防取号阻塞 |
| Redis INCR | INCR / INCRBY 取号 | 性能高、递增、天然原子 | 依赖 Redis 持久化(AOF everysec 可能丢号)、多一套组件 |
| 雪花算法(Snowflake) | 时间戳 + 机器位 + 序列号,本地生成 | 趋势递增、性能极高(本地无网络)、可解析出时间 | 依赖时钟,有回拨问题;workerId 需唯一分配 |
| Leaf-snowflake | 雪花 + ZK 分配 workerId、处理回拨 | 解决 workerId 与回拨 | 依赖 ZK,部署更重 |
| UidGenerator(百度) | 雪花 + RingBuffer 预生成 | 吞吐更高、弱化时钟依赖 | 需 DB 分配 workerId,实现复杂 |
| 有序 UUID(ULID / UUIDv7) | 时间前缀 + 随机后缀 | 有序 + 本地生成 | 生态/存储支持度不如数字 ID,仍偏长 |
选型结论:
- 小规模 / 内部系统:数据库号段(ID 连续可控、实现简单);
- 大规模、高并发:雪花类(本地生成、无网络开销、趋势递增);
- 需要 ID 绝对连续(如对账流水号):只能回到数据库自增/号段,雪花做不到连续;
- 只要求唯一、不要求有序(如链路追踪 ID):UUID 足够,还更安全(不可被枚举)。
关键认知:趋势递增是为了对 B+Tree 索引友好(避免随机插入造成页分裂与大量随机 IO),这是互联网系统普遍选雪花类方案的根本原因。
14. 雪花算法(Snowflake)的结构是什么?时钟回拨怎么解决?
答: Twitter Snowflake 用 64 位 Long 拼出全局唯一的 ID:
| 1 bit 符号位(恒为 0) | 41 bit 时间戳(毫秒) | 10 bit 机器标识 | 12 bit 序列号 |
0 自增偏移量(相对起始纪元) 5位机房+5位机器 同毫秒内递增各段的容量(面试常追问):
| 位段 | 位数 | 容量 | 说明 |
|---|---|---|---|
| 符号位 | 1 | — | 固定 0,保证 ID 为正数 |
| 时间戳 | 41 | 约 69 年 | (2^41 - 1) / 1000 / 3600 / 24 / 365 ≈ 69.7,从自定义纪元(epoch)起算 |
| 机器标识 | 10 | 1024 个节点 | 通常拆为 5 位数据中心 + 5 位工作节点 |
| 序列号 | 12 | 每毫秒 4096 个 | 同一节点同一毫秒内的自增序号 |
最大吞吐:单节点 4096 × 1000 = 409.6 万 ID/秒。
特点:本地生成(零网络开销)、趋势递增、ID 中自带时间(可粗略推断生成时间);缺点:强依赖时钟。
时钟回拨问题及解决方案:
为什么回拨:NTP 校时(时钟走得快被"拨回")、运维手动改时间、虚拟机迁移、闰秒处理。
回拨会导致什么:时间戳变小 → 可能生成与之前重复的 ID(如果序列号也刚好重置),在订单号/主键场景下是严重事故。
| 方案 | 做法 | 适用 |
|---|---|---|
| 1. 短回拨自旋等待 | 记录 lastTimestamp,若回拨幅度小于阈值(如 5ms ~ 100ms),则自旋等待时钟追上上一毫秒后再生成 | 最常见、实现简单 |
| 2. 长回拨拒绝服务 + 告警 | 回拨超过阈值 → 抛异常 / 拒绝生成并立即告警,人工介入 | 保护数据正确性优先 |
| 3. 用扩展位抵消回拨 | 预留若干位(如用序列号高位 / 单独的扩展位)记录"回拨次数",回拨期间用新的子序号空间避免重复 | 不想中断服务时 |
| 4. 借用未来时间 / 备用 workerId | 回拨时临时切换到另一个 workerId(或直接消耗未来毫秒的序列号) | 需可用 workerId 池 |
| 5. Leaf-snowflake | 用 ZK 记录每台机器上报的最近时间戳,启动时若发现回拨则等待或拒绝;并靠 ZK 保证 workerId 唯一 | 美团 Leaf,生产验证充分 |
| 6. UidGenerator(RingBuffer) | 预生成一批 ID 放环形缓冲,业务从缓冲取,弱化对实时时间的依赖 | 高吞吐场景 |
部署注意事项(加分):
- workerId 必须全局唯一:可用 ZK 顺序节点 / DB 自增 / 机房位 + 机器位 + 配置中心 分配;不要在容器里直接用 IP 哈希(Pod IP 会变、复用);
- NTP 校时不要激进:建议用
chrony做渐进式校正(slew)而非跳变(step),从源头减少大回拨; - 监控:对"回拨次数、自旋耗时、异常率"打点告警。
一句话:雪花的正确性 = workerId 唯一 + 时钟不回拨。工程上用"短回拨自旋、长回拨报警、扩展位/备用 workerId 兜底"三件事把它做稳。
15. 数据库号段模式(Leaf-segment)是怎么实现的?为什么比数据库自增好?
答: 号段模式的核心思路:把"每次取 1 个 ID"改成"每次取一段 ID",用内存换 DB 压力。
表结构(典型):
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) PRIMARY KEY, -- 业务标识(如 order、coupon)
max_id BIGINT NOT NULL, -- 当前已分配到的最大 ID
step INT NOT NULL, -- 一次分配的号段长度
version BIGINT NOT NULL -- 乐观锁
);取号流程:
- 服务启动/号段用尽时,执行:
UPDATE leaf_alloc SET max_id = max_id + step, version = version + 1 WHERE biz_tag = ? AND version = ?; - 拿到
[max_id + 1, max_id + step]这段号,缓存在本地内存; - 业务发号直接从内存
AtomicLong自增,不再访问 DB——这就是 DB 压力下降几个数量级的来源。
相比"数据库自增"的改进:
| 对比项 | 数据库自增(含步长方案) | 号段模式 |
|---|---|---|
| DB 访问频率 | 每发一个 ID 访问一次 DB | 每 step 个 ID 才访问一次(DB QPS 降低 step 倍) |
| 扩容 | 改步长需重配所有实例,麻烦 | 改 step 即可,多实例天然支持 |
| 性能 | 受 DB 限制,QPS 上限低 | 内存发号,接近本地生成 |
| 单点风险 | 依赖 DB | 依赖 DB(但访问频率低,影响面小) |
三个注意事项:
- ID 不连续(跳号):服务重启会丢弃未用完的号段,所以 ID 只能保证趋势递增,不能保证连续——需要连续流水的场景(如对账)不能用它;
- 双 buffer 预取(Leaf-segment 的关键优化):当前号段消耗到 10% 时,异步去 DB 加载下一段放进备用 buffer,业务无感知切换——避免"号段用尽时才去 DB 取"造成的发号尖刺/阻塞;
- DB 高可用:号段服务与 DB 都需主备;
biz_tag维度隔离,不同业务互不影响(一个业务取号慢不拖累其他业务)。
对比雪花:号段模式依赖 DB 但 ID 趋势递增更平滑、可控性强、不依赖时钟;雪花本地生成性能更高但依赖时钟与 workerId 分配。美团 Leaf 同时提供两者(
Leaf-segment与Leaf-snowflake),按业务选。
六、幂等设计
幂等是分布式重试的兜底:只要有重试(网络超时、RPC 重试、MQ 至少一次投递),就必须有幂等。
16. 什么是幂等?为什么分布式系统特别需要它?
答: 幂等(Idempotence) 指同一请求执行一次与执行多次,对系统产生的副作用相同。
注意两个容易答错的点:
- 不是"返回结果相同",而是"副作用相同"——第一次返回"下单成功",第二次返回"订单已存在",返回值不同但状态没变,这依然是幂等的;
- 天然幂等的操作:查询(
SELECT)、删除(DELETE同一 ID)、置值写(SET status = 'X')、PUT语义;天然不幂等:INSERT(会重复)、累加/自增(balance = balance + 100)、PUT之上叠加的"增量"语义。
为什么分布式系统特别需要: 因为重试无处不在,而每一次重试都可能把同一个请求"再来一遍":
| 重试来源 | 说明 |
|---|---|
| 用户/客户端重试 | 超时后用户点两次按钮、App 自动重试 |
| RPC 框架重试 | Dubbo / Feign 的 retries 配置、超时重发 |
| 网关 / Nginx 重试 | 上游超时后转发到另一台实例 |
| MQ 至少一次投递 | 消费失败重投、ack 丢失重投 → 必然重复消费 |
| 定时任务补偿 | 补偿任务与正常流程并发执行同一业务 |
不做幂等的后果:重复扣款、重复下单、重复发券、重复扣库存——这些都是资损级事故。所以 "MQ 至少一次 + 消费幂等"、"RPC 重试 + 接口幂等"是分布式系统的默认标配。
一句话:分布式系统里"恰好一次"是幻觉,"至少一次 + 幂等"才是现实。
17. 幂等的实现方案有哪些?
答: 按"可靠性 + 成本"从高到低,常用的五种:
(1)唯一索引 / 去重表(最可靠、最常用)
-- 业务唯一键(订单号、请求号)建唯一索引
ALTER TABLE t_order ADD UNIQUE KEY uk_order_no (order_no);重复插入时 DB 直接抛 DuplicateKeyException(MySQL 错误码 1062),业务捕获后视为已处理返回成功。
- 优点:由数据库保证原子性,最可靠、不依赖额外组件;
- 变体:建独立的"幂等表/去重表"(
request_id唯一索引 + 处理状态),避免污染业务表;也可用INSERT ... ON DUPLICATE KEY UPDATE做"插入或忽略"。
(2)Token 机制(防重复提交,适合"用户操作")
- 进入提交页面前,客户端先向服务端申请一次性 token(服务端
SET token <uuid> NX EX 300); - 提交时携带 token,服务端用 Lua:比对成功则删除并放行(保证"校验 + 删除"原子);
- 重复提交时 token 已被删除 → 直接拒绝。
变体:直接把"请求号 + 结果"缓存下来,重复请求返回首次结果(幂等缓存)。
(3)状态机(幂等状态流转,最适合订单类业务)
UPDATE t_order SET status = 'PAID'
WHERE order_no = ? AND status = 'UNPAID';
-- 影响行数 = 1 → 首次生效;= 0 → 说明已处理(或状态不合法),直接返回成功用 WHERE 带上期望的前置状态做 CAS,让"状态只能前进一次",天然幂等且能防逆序状态流转(见《分布式(三)》顺序性一题)。
(4)乐观锁 / 版本号
UPDATE t_account SET balance = ?, version = version + 1
WHERE id = ? AND version = ?;影响行数为 0 → 说明已被其他请求改过 → 重试读取最新值再计算,或直接放弃。
(5)唯一请求号 + 去重记录(Redis)
上游下发全局唯一的 requestId,下游:
SET requestId 1 NX EX 86400 # 抢到则处理,抢不到说明重复- 注意:Redis 方案必须设置 TTL(否则 key 无限膨胀),并接受"Redis 丢数据 → 可能重复处理"的风险,所以关键业务仍应叠加唯一索引。
实现时的四个要点(区分"背过"与"做过"):
- "判断 + 执行"必须原子:靠唯一索引、状态机 CAS 或 Lua 保证,不能"先查有没有、再写"(并发下两者都会通过);
- 幂等记录要有粒度:幂等键用业务唯一键(订单号/流水号),不要用
userId这种粗粒度; - 失败也要能重试:要区分"已成功(直接返回)"与"处理中/已失败(允许重试)",否则会把失败请求永久卡死(状态机要设计中间态与超时);
- 幂等 ≠ 分布式锁:幂等是"重复执行也没事",锁是"防止同时执行"——锁会失效,幂等不会,所以幂等才是最后的防线。
口诀:查询天然幂等;写操作靠"唯一键(防重复)+ 状态机(防逆序)+ token(防重复提交)"三板斧。
18. 幂等、去重、防重放有什么区别?分别在哪一层做?
答: 三个词经常被混用,但它们解决的是不同维度的问题:
| 概念 | 定义 | 解决的问题 | 典型实现 | 所处层次 |
|---|---|---|---|---|
| 幂等 | 同一请求执行多次,副作用一致 | 业务正确性:重复执行不出错 | 唯一索引、状态机、版本号 | 业务/服务层(最终防线) |
| 去重 | 识别并丢弃重复的消息/请求 | 减少无效处理、提升性能 | 消息 ID 去重表、Redis SETNX、布隆过滤器 | 消息层 / 接入层 |
| 防重放(Anti-Replay) | 防止攻击者截获并重放历史请求 | 安全性:防止越权/盗刷 | timestamp + nonce + sign 验签、时间窗口校验 | 网关 / 安全层 |
三者的关系:
防重放(安全层):拦住"过期的、伪造的重放包"
↓
去重(消息/接入层):拦住"短时间内重复到达的同一请求",降低无效处理
↓
幂等(业务层):即使前面都漏了,也能保证"重复执行不出错"各层的落地要点:
- 网关层(防重放):要求请求带
timestamp(如 ±5 分钟窗口)、nonce(随机串,Redis 缓存校验)、sign(签名防篡改);仅对敏感接口开放,否则影响性能; - 接入/MQ 层(去重):用消息唯一 ID 做去重(Redis 短 TTL 或去重表),但去重窗口有限(超过窗口的重复仍会到达业务)——所以去重不能替代幂等;
- 业务层(幂等):唯一索引 + 状态机是不可省略的一层。
两个高频追问:
- "有了去重表还需要幂等吗?" → 需要。去重表的 TTL / 清理策略 / Redis 丢数据都会让重复请求穿透,幂等是兜底;
- "幂等键用什么?" → 用业务语义的唯一键(订单号、支付流水号、业务单号),由上游生成并透传;不要用"时间戳+随机数"这种无法跨重试复用的值,否则重试时会生成新键,幂等直接失效。
一句话:防重放管"不能重复进来",去重管"重复进来的不要处理",幂等管"就算处理了也不出错"——三层各司其职,业务层幂等是唯一不可省的。
