1. 面试复盘:一场没有侥幸的分布式事务拷问
之前有位读者反馈,说自己十年 Java 开发经验,出去面试广发银行的外包岗位,结果在分布式事务这个环节被面试官来回追问了四十分钟。这位读者本身基础不算差,SSM、Spring Boot、微服务都带过团队,但分布式事务一直是“用过 Seata,但没深入原理”的状态——结果一到面试现场,被问到“Seata AT 模式底层到底怎么实现的”“TCC 和 2PC 的本质区别是什么”“消息最终一致性在金融场景怎么保证不丢不重”的时候,一下子答不到点子上。
这种情况其实非常普遍。很多做业务开发的 Java 工程师,日常写的最多的是单库事务,@Transactional一把梭。真正到了分布式系统面试场景,需要说清楚分布式事务的来龙去脉,就很容易卡壳。
这篇文章就以这次面试复盘为线索,把分布式事务这套知识体系完整梳理一遍。内容覆盖:
- 分布式事务到底解决什么问题,和本地事务的区别;
- 2PC / XA / TCC / 可靠消息最终一致性 / 最大努力通知的核心原理;
- 每个方案的适用场景和硬伤;
- 金融业务(银行外包场景)更看重哪些能力,比如幂等、对冲、对账;
- 面试高频追问和回答思路;
- Seata 实战示例和工程落地建议。
不管你是准备面试,还是正在做微服务架构设计,这套内容都值得沉下心看一遍。
2. 分布式事务的核心概念:从本地事务说起
2.1 什么是分布式事务
先来说概念。单体应用时代,一个业务流程通常在一个数据库里完成,事务由数据库保证。经典 ACID 四要素——原子性、一致性、隔离性、持久性——由数据库引擎实现,开发只需要在方法上加一个事务注解,或者在代码里手动 commit / rollback。
但是在微服务架构下,一个用户下单流程可能要跨多个服务:
- 订单服务:写入订单表;
- 库存服务:扣减库存;
- 积分服务:增加用户积分;
- 支付服务:生成支付流水。
如果这些操作在同一个进程、同一个数据库里,一个@Transactional就能搞定。问题是微服务之后,每个服务往往独立部署、独立数据库,本地事务只能保证自己那个数据库里的一致性,跨库的多个操作就没有一个全局的“事务管理器”来统一协调了。
于是分布式事务要解决的核心问题就变成:如何让多个独立的数据源之间的操作,要么全部成功,要么全部失败(或最终一致),并且在失败时可以通过补偿让数据回到正确状态。
2.2 本地事务与分布式事务的本质区别
| 对比项 | 本地事务 | 分布式事务 |
|---|---|---|
| 作用范围 | 单个数据库连接 | 多个服务/多个数据库 |
| 事务管理器 | 数据库自身 | 全局协调者 + 各参与者 |
| 回滚方式 | 基于 undo 日志直接回滚 | 依赖回滚接口、反向 SQL、消息补偿 |
| 一致性强度 | 强一致,提交即可见 | 强一致或最终一致,取决于方案 |
| 常见实现 | @Transactional、JDBC 事务 | Seata、RocketMQ 事务消息、TCC 框架 |
| 故障影响 | 单库故障 | 任一参与者故障都可能影响全局 |
这里有个常见误区:使用 Seata 或消息队列,不代表事务就自动变简单了。恰恰相反,分布式事务方案都会引入额外的复杂度,比如全局锁、分支事务注册、幂等表、对账任务、补单机制。面试官深挖分布式事务,本质上是在考察你对“数据一致性在复杂环境下的工程处理能力”。
2.3 为什么强一致在分布式环境里这么难
分布式系统的经典困境是 CAP 理论:一致性、可用性、分区容错性三者不可兼得。分布式事务需要在网络分区可能发生的现实下做选择:
- 如果要强一致(比如所有节点同一时刻看到相同数据),通常需要引入协调者,并牺牲一定的可用性——2PC 就是一个典型;
- 如果优先保证可用性,那么系统只能在业务允许的时间窗口内达到最终一致——可靠消息方案就是这种思路。
在金融、支付、交易类系统中,资金数据不允许出现“跨库不一致”,但同时又要求系统保持高可用。所以实际落地方案不会只用一种,而是按业务场景混合使用,比如核心资金链路用 TCC,异步通知类用消息队列最终一致性。这个思路在面试中非常重要,能体现你对方案选型的理解。
3. 分布式事务主流方案深挖
3.1 2PC(两阶段提交):强一致的经典方案
2PC 是最经典的分布式事务模型,分为两个阶段:
第一阶段:准备阶段(Prepare)
协调者向所有参与者发送 prepare 请求,参与者接到请求后执行本地事务,但不提交,并把 undo / redo 信息写入本地日志,然后向协调者返回“可以提交”或“不能提交”的响应。
第二阶段:提交阶段(Commit / Rollback)
如果所有参与者都回复“可以提交”,协调者广播 commit 命令,各参与者正式提交本地事务。如果任何一个参与者回复“不能提交”或超时未响应,协调者广播 rollback,各参与者回滚本地事务。
用一个朴素的语言描述:先问所有人“准备好了没”,所有人都说没问题,才统一说“提交”;只要有一个人说不行,全部撤回。
2PC 的实现代表是 XA 协议。Java 中的 JTA(Java Transaction API)对 XA 提供了支持,主流关系型数据库(Oracle、MySQL 等)都实现了 XA 接口。Spring 中JtaTransactionManager就是用来管理这类事务的。
2PC 最大的问题有两个:
- 同步阻塞:准备阶段所有参与者都在等待协调者指令,资源被锁定,并发性能差;
- 协调者单点故障:如果协调者在第二阶段挂了,参与者无法收到 commit 或 rollback 指令,只能一直阻塞,造成数据悬挂。
所以在高并发互联网场景,2PC 用得越来越少,反而是在银行老核心系统、传统企业级应用中还能看到基于 XA 的分布式事务。面试时如果聊到 2PC,可以主动点出它“偏重强一致但牺牲可用性”的特性,并说明为什么现代高并发系统更倾向最终一致。
3.2 TCC(补偿事务):Try / Confirm / Cancel
TCC 是“补偿事务”的典型代表,全称是 Try-Confirm-Cancel。它的核心思路是:业务方自己实现三个操作,把自己业务逻辑拆成三个阶段。
- Try:完成资源预留或业务检查。比如下单时,不是直接扣库存,而是先把库存“冻结”起来;
- Confirm:如果所有参与者 Try 都成功,执行真正的业务提交。比如把冻结的库存真正扣掉;
- Cancel:如果某个参与者 Try 失败,或 Confirm 阶段出现异常,执行补偿回滚。比如释放冻结的库存。
下面用一个库存扣减的 TCC 示例来理解:
public interface StockService { /** * 第一阶段:冻结库存 */ boolean tryFreezeStock(Long orderId, Long skuId, int quantity); /** * 第二阶段:正式扣减库存 */ boolean confirmDeductStock(Long orderId, Long skuId, int quantity); /** * 第三阶段:释放冻结库存 */ boolean cancelFreezeStock(Long orderId, Long skuId, int quantity); }实现类可以这么写:
@Service public class StockServiceImpl implements StockService { @Resource private StockMapper stockMapper; @Override @Transactional public boolean tryFreezeStock(Long orderId, Long skuId, int quantity) { // 检查库存是否充足并冻结 int rows = stockMapper.freezeStock(skuId, quantity); if (rows == 0) { // 冻结失败说明库存不足,抛出异常触发 Cancel throw new BizException("库存不足"); } // 记录冻结流水,用于后续 Confirm / Cancel 判断 stockMapper.insertFreezeLog(orderId, skuId, quantity, "TRY"); return true; } @Override @Transactional public boolean confirmDeductStock(Long orderId, Long skuId, int quantity) { // 真正扣减库存,同时清理冻结记录 stockMapper.confirmDeduct(skuId, quantity); stockMapper.updateFreezeLogStatus(orderId, "CONFIRM"); return true; } @Override @Transactional public boolean cancelFreezeStock(Long orderId, Long skuId, int quantity) { // 释放冻结库存 stockMapper.releaseFrozenStock(skuId, quantity); stockMapper.updateFreezeLogStatus(orderId, "CANCEL"); return true; } }TCC 的优势是:没有全局锁,性能比 2PC 好,可以灵活控制业务粒度。但劣势同样明显:
- 业务侵入性强,每个操作都要拆 Try / Confirm / Cancel 三段;
- 很多场景是“有 Try 没有真正可冻结的资源”,比如短信发送、外部接口调用,很难做预占;
- Confirm 和 Cancel 必须实现幂等,因为网络重试可能让它们被调用多次。
面试中被问到 TCC,重点回答三件事:资源的预占与释放、 Confirm 和 Cancel 的幂等性、空回滚和悬挂问题的处理。能讲清楚这三点,已经能打败大部分只背概念的候选人。
3.3 可靠消息最终一致性:异步解耦的常态方案
可靠消息方案是目前互联网高并发场景下用得最多的一类思路。它的目标不是强一致,而是最终一致。
核心思想是:把本地业务操作和消息发送放在同一个本地事务中,然后通过消息队列的可靠性投递,让下游服务最终消费消息并完成自己的业务。
经典实现是“本地消息表”方案。比如订单服务创建订单时,同时往本地消息表插入一条待发送消息,然后本地事务一起提交。独立的发送线程扫描本地消息表,把状态为“待发送”的消息投递到 MQ,投递成功后才更新消息状态。下游服务消费消息后,通过业务幂等判断避免重复处理。
本地消息表的简化创建脚本如下:
CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', biz_id VARCHAR(64) NOT NULL COMMENT '业务主键', payload VARCHAR(2048) NOT NULL COMMENT '消息内容', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2已完成', retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_status_retry (status, next_retry_time), UNIQUE KEY uk_biz (biz_type, biz_id) ) COMMENT '本地消息表';发送逻辑可以抽象为一个独立服务方法:
/** * 创建订单同时写本地消息表,同一个本地事务 */ @Transactional public Long createOrderAndMessage(OrderRequest request) { // 1. 创建订单 OrderDO order = new OrderDO(); order.setUserId(request.getUserId()); order.setAmount(request.getAmount()); order.setStatus("CREATED"); orderMapper.insert(order); // 2. 写入本地消息表 LocalMessageDO message = new LocalMessageDO(); message.setBizType("ORDER_CREATED"); message.setBizId(String.valueOf(order.getId())); message.setPayload(JSON.toJSONString(order)); message.setStatus(0); localMessageMapper.insert(message); return order.getId(); }可靠消息方案最怕的是两个问题:
- 消息丢失:本地事务提交了但消息没发出去,或者消息发出去但消费者没处理成功;
- 消息重复:消费者收到同一条消息多次,导致业务重复执行。
解决消息丢失靠重试和定期扫描补偿;解决消息重复靠消费者幂等——比如根据biz_id的唯一索引防止重复插入。这部分是面试的高频追问点,下面第 4 节会专门展开。
3.4 最大努力通知:弱一致性场景的兜底方案
最大努力通知是另一种非常实用的思路,一般用在“不需要强一致、能接受延迟、允许一定失败率”的场景,比如支付结果通知、退款回调通知、商户清算通知。
它的流程是:
- 业务系统主动调用外部接口或服务;
- 如果失败,按一定次数和时间间隔重试(比如 1 分钟、5 分钟、30 分钟、2 小时、1 天);
- 重试达到上限后,停止通知,转入人工对账或查询补偿流程。
在银行外包场景里,最大努力通知最常见的使用,就是对账任务。比如支付渠道回调失败,核心系统不会一直无限重试,而是记录失败状态,由每日对账任务去拉取渠道对账单,逐笔核对,把漏掉的单子补齐。
面试官问到最大努力通知和可靠消息的区别,典型的回答是:可靠消息强调的是“消息必须被下游处理,且通过本地事务保障消息不丢”;最大努力通知强调的是“不保证一定成功,但会尽最大努力去通知,最终靠对账兜底”。两者的设计哲学和应用场景不一样,不能混为一谈。
4. 高并发下的一致性:隔离与幂等是面试深水区
4.1 为什么分布式事务必须聊幂等
分布式事务一个很隐蔽的坑,是网络重试。在分布式环境中,调用超时并不代表对方没有执行。比如订单服务调用库存服务扣减库存,网络超时了,订单服务可能重试;但库存服务其实已经把库存扣了,第二次重试就会重复扣减。
所以在分布式事务面试中,幂等是必考话题。所谓幂等,就是“同一个操作执行一次和执行多次,最终结果一致”。实现幂等常见手段有四种:
| 方案 | 思路 | 适用场景 |
|---|---|---|
| 唯一索引/唯一键 | 数据库表建立业务唯一键,重复插入自动失败 | 创建订单、生成流水 |
| 状态机 | 业务状态只能按既定方向流转,重复操作无效果 | 订单状态更新、支付回调 |
| 去重表 | 单独建一张已处理消息表,根据消息 ID 判断是否处理过 | MQ 消费者幂等 |
| token / 分布式锁 | 处理前先获取令牌或锁,防止并发重复执行 | 接口防重、定时任务防重 |
下面是一个基于去重表的消费者幂等示例:
@Transactional public void handleOrderCreated(OrderCreatedMessage message) { // 1. 先检查幂等记录是否存在 int exists = messageConsumeLogMapper.checkExists(message.getMessageId()); if (exists > 0) { log.info("消息重复消费,直接忽略,messageId={}", message.getMessageId()); return; } // 2. 执行真正的业务逻辑 orderService.updateOrderConfirm(message.getOrderId()); // 3. 插入幂等记录 MessageConsumeLogDO logRecord = new MessageConsumeLogDO(); logRecord.setMessageId(message.getMessageId()); logRecord.setBizId(message.getOrderId()); messageConsumeLogMapper.insert(logRecord); }唯一索引可以用下面这个 SQL 约束:
CREATE TABLE message_consume_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, message_id VARCHAR(64) NOT NULL, biz_id VARCHAR(64) NOT NULL, consume_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_message_id (message_id) ) COMMENT '消息消费幂等记录表';注意:幂等判断和业务操作必须在同一个本地事务里,否则会出现“业务做了但幂等记录没写”或者反过来,导致幂等机制失效。
4.2 隔离性:Saga 模式下的脏读问题
在分布式事务方案里,还有一个容易被深挖的点,那就是隔离性。TCC 和 2PC 多多少少能提供一定程度隔离,而 Saga 模式(一种长事务模式,把一个分布式事务拆成多个本地事务,通过反向补偿来回滚)几乎不提供隔离,它允许中间状态对外可见。
比如一个跨银行的转账 Saga:先扣款、再汇款。如果用户在扣款后、汇款前查询余额,看到的是“钱少了但对方没收到”的中间状态。这在金融场景里是不可接受的。所以 Saga 工程落地时常配合“状态字段不全量开放”“对账中间表”等手段缓解。
面试时遇到这类问题,不要只说“用 Seata 解决了”,要能说清楚方案本身有哪些能力边界。面试官真正想确认的是:你在做技术选型时,到底是理解了方案本质,还是只停留在拼凑配置的层面。
5. Seata 实战:AT 模式的落地示例
5.1 Seata 是什么
Seata 是阿里巴巴开源的一套分布式事务解决方案,目前常见的模式有 AT、TCC、Saga、XA 四种。其中 AT 模式是 Seata 的招牌模式,它利用数据库本地事务 + undo log 表模拟全局事务,对业务代码侵入较小。
AT 模式的核心思路是:
- 全局事务开始时,注册分支事务;
- 每个参与的服务在执行业务 SQL 前,先记录数据快照到 undo_log 表;
- 全局提交时,各分支正式提交并删除 undo_log;
- 全局回滚时,根据 undo_log 反向生成补偿 SQL,恢复原数据。
这里有一个版本提示:Seata 的版本迭代较快,不同版本配置文件差异比较大。本文示例以常见的 1.x / 2.x 版本配置思路为例,实际使用时需要根据你项目的 Seata 版本、注册中心类型、数据库类型做调整。
5.2 Seata 初始化环境
使用 AT 模式需要先创建 undo_log 表。以 MySQL 为例,建表脚本参考如下:
CREATE TABLE IF NOT EXISTS `undo_log` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `branch_id` BIGINT(20) NOT NULL COMMENT '分支事务ID', `xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID', `context` VARCHAR(128) NOT NULL COMMENT '上下文', `rollback_info` LONGBLOB NOT NULL COMMENT '回滚信息', `log_status` INT(11) NOT NULL COMMENT '状态:0正常 1全局回滚', `log_created` DATETIME(6) NOT NULL COMMENT '创建时间', `log_modified` DATETIME(6) NOT NULL COMMENT '修改时间', PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT事务模式undo日志表';每个需要参与全局事务的数据库都要建这张表。
5.3 服务接入 Seata
引入依赖(Maven):
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency>如果使用的不是 Spring Cloud Alibaba,而是 Spring Boot 直连 Seata,可以用:
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> </dependency>配置文件中加入 Seata 相关配置:
seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: DEFAULT_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: DEFAULT_GROUP service: vgroup-mapping: my_test_tx_group: default在入口方法上加全局事务注解:
@GlobalTransactional(name = "createOrderTx", rollbackFor = Exception.class) public void createOrder(OrderCreateDTO dto) { // 调用本地订单服务 orderService.createOrder(dto); // 远程调用库存服务 stockFeignClient.deductStock(dto.getSkuId(), dto.getQuantity()); // 远程调用积分服务 pointsFeignClient.addPoints(dto.getUserId(), dto.getAmount().intValue()); }@GlobalTransactional会开启一个全局事务,Seata 会把这个调用链上的所有参与者的本地事务纳入同一个全局事务中。只要其中一个参与者报错,全局事务就会回滚,Seata 会通过 undo_log 自动生成反向 SQL 恢复数据。
当然,AT 模式也有明显局限:它依赖数据库 undo_log 机制,对 SQL 有一定约束,高并发下全局锁冲突可能比较严重。这也是面试中能展示深度的点——Seata AT 模式并不适合所有场景,简单业务用 AT,复杂资源操作或跨系统调用优先考虑 TCC 或可靠消息最终一致。
6. 金融外包场景:分布式事务面试之银行核心链路
6.1 银行外包项目为什么特别爱问分布式事务
银行类外包项目有很强的业务特征:资金安全是底线,数据准确是合规要求,而且业务链路往往跨越多个系统。存钱、转账、开户、理财申购赎回,任何一笔操作都可能涉及核心系统、渠道系统、风控系统、会计系统等多个子系统。
所以银行外包面试官问分布式事务,表面是问技术,实际是在筛选候选人有没有金融业务意识。比如问到“转账”场景,候选人如果只能背出“2PC、TCC”几个名词,但不清楚资金对账、冻结解冻、会计分录这些金融概念,在会做业务的面试官面前容易减分。
6.2 典型资金链路推演
以“银行卡转账”为例,拆解一个分布式事务的完整链路:
- 用户在手机银行发起转账;
- 渠道系统校验账户状态、密码、风控;
- 核心账务系统扣减转出账户余额;
- 记账系统写入转出流水;
- 支付网关调用同行 / 跨行清算接口;
- 核心账务系统增加转入账户余额;
- 记账系统写入转入流水。
在这个链路中,“扣减转出账户”和“增加转入账户”必须原子化,否则会出现一边扣了一边没加的资金差错。银行传统核心系统通常用大机 / 单库事务解决;到了分布式架构下,核心扣款用 TCC 或 XA,渠道通知、记账、清算则用事务消息 + 对账补偿。
另一个非常像外包面试题的场景是“余额冻结”。比如用户买理财产品,先冻结余额,再申购,如果申购失败则解冻。这个场景本质上就是 TCC 的 Try 冻结 / Confirm 扣减 / Cancel 解冻。回答这类问题时,把资源预占、状态流转、幂等控制讲清楚,比背概念更有说服力。
6.3 对账与补账:分布式事务的最后一道防线
这里有一个容易被忽略但面试官很看重的点:不管用哪种分布式事务方案,真实金融系统最终都会依赖对账来兜底。
日终对账系统会拉取各系统的流水文件,逐笔核对业务状态。如果发现不一致,系统自动或人工触发补账任务。所以在工程实践中,分布式事务并不是“用了一个框架就高枕无忧”,而是方案 + 幂等 + 对账 + 补偿任务的组合拳。
你可以准备一个话术:如果面试官问“TCC 的 Cancel 也失败了怎么办”,就回答“引入重试机制,多次重试仍然失败就进入人工处理队列 / 对账任务,最终通过日终对账发现并修复”。
7. 面试回答模板与高频追问
7.1 用一段话介绍分布式事务
面试里经常会遇到“请介绍一下你理解的分布式事务”这类宽泛问题。建议先给定义,再给场景,再给方案分类,最后给选型思路。可以参考下面的骨架:
分布式事务解决的是跨服务、跨数据库的数据一致性问题。 单体应用可以用本地事务,但在微服务架构下, 多个服务的业务操作没有全局事务管理器,因此需要引入分布式事务方案。 目前主流方案有强一致的 2PC/XA、业务侵入性较强的 TCC、 异步解耦的可靠消息最终一致性,以及 Seata 这类集成了多种模式的框架。 选型的时候,核心资金链路追求强一致或准实时一致, 而对实时性要求不高的场景,优先考虑消息最终一致性。这段话不长,但把概念、场景、方案、选型串起来了,方便面试官进一步追问。
7.2 高频追问清单
| 追问方向 | 考察点 | 建议回答 |
|---|---|---|
| Seata AT 模式原理 | 是否真正用过 Seata | 讲清楚事务协调者、分支事务、undo_log 回滚机制 |
| AT 模式和 TCC 的区别 | 方案边界 | AT 侵入小但有全局锁;TCC 侵入大但并发性能更好 |
| 可靠消息如何防丢 | 消息可靠性 | 本地消息表 + 定期扫描重投 + 消费者幂等 |
| TCC 的 Cancel 和 Confirm 重复调用怎么办 | 幂等设计 | 通过业务状态机 + 唯一事务 ID 保证幂等 |
| 分布式事务和 MQ 消息事务的区别 | 概念辨析 | MQ 事务消息本质也是最终一致性,适合异步场景 |
| 能不能全用 2PC | 对性能的理解 | 2PC 阻塞、性能差、协调者单点,不适合高并发互联网场景 |
| 回滚失败怎么办 | 工程兜底意识 | 重试、告警、人工处理队列、对账补单 |
7.3 被追问“你项目里为什么没用 TCC”怎么答
很多候选人被问到“为什么不选 TCC”就慌了,以为面试官在质疑方案。其实这时候考官想听到的是选型思考。可以参考这几点:
- 业务场景是订单创建 + 库存扣减,库存是强资源,适合用 Seata AT 模式快速落地;
- 团队当时对 Seata 已有运维经验,TCC 需要手写大量 Confirm / Cancel 逻辑,成本更高;
- 非核心的积分赠送、短信通知用消息最终一致性,异步削峰;
- 核心资金类接口如果未来要接入,会单独评估 TCC 或基于业务冻结的定制方案。
这种回答展现了两个能力:知道多种方案、能结合成本和场景做取舍。这才是面试官最想看到的。
8. 常见问题与工程落地避坑
8.1 常见问题速查
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| Seata 全局事务回滚不生效 | undo_log 表缺失 / 分支事务未注册 | 确认每个库建了 undo_log,检查全局事务是否传递 |
| 本地消息表消息积压 | 发送线程被阻塞 / MQ 故障 | 增加扫描频率,重试间隔设置指数退避,接入告警 |
| 消费者重复执行 | 没有幂等设计或幂等判断与业务不在同一事务 | 建唯一键 / 去重表,并把判断和业务放同一个本地事务 |
| TCC Confirm 重复调用 | 框架重试机制 | 用事务状态字段 + 唯一流水号保证幂等 |
| 消息消费后业务回滚了,但消息还是显示成功 | 消费者没有捕获异常并重新投递 | 业务异常时抛出并触发重试,重试耗尽进入死信队列 |
| AT 模式高并发下性能下降 | 全局锁竞争激烈 | 改用 TCC 或最终一致方案 |
8.2 工程落地避坑清单
第一,不要在分布式事务分支里写耗时过长的外部调用。比如在@GlobalTransactional方法里调用一个第三方短信接口,如果第三方迟迟不返回,全局事务就会一直持有锁,拖垮整个链路。
第二,分支事务之间不要直接操作对方的数据库。微服务分库之后,服务间应该通过接口 / 消息协作,而不是互相开放数据库访问权限。否则事务边界会变得混乱,回滚也无法控制。
第三,重试一定要有上限和退避策略。不管本地消息表、TCC 还是最大努力通知,无限重试会造成资源浪费和故障放大。建议设置最大重试次数和递增间隔,超过上限降级到错误表 + 告警。
第四,对账必须进入设计,而不是等出了事故再补。日终对账、明细核对、差错处理,是金融场景的必备能力。
第五,配置隔离和灰度发布。分布式事务框架的配置属于核心配置,变更前要在测试环境验证。生产环境如果使用 Apollo 或 Nacos 管理 Seata 配置,发布变更时要走审批与灰度流程。
9. 面试之外的深度:分布式事务与数据架构设计
如果只把分布式事务当成一个面试话题,其实是浪费了这套知识的价值。在实际数据架构设计中,是否引入分布式事务、用哪种方案,本质上是在回答一个问题:业务能接受多少延迟,能接受多少不一致风险?
这里总结一个选型判断顺序:
- 业务是否必须强一致?如果是,优先考虑 2PC / XA,或在技术允许的范围内用 TCC 模拟强一致;
- 是否允许秒级或分钟级最终一致?如果是,可靠消息最终一致性性价比最高;
- 是否存在跨系统且无法预占资源的操作?如果有,用事务消息或 Saga 替代 TCC;
- 是否有对账条件?如果有,最大努力通知 + 对账补单是兜底的好选择;
- 团队是否有足够运维能力?Seata 这类中间件不是装上去就完事,全局事务数、分支事务成功率、回滚成功率都需要监控。
之前看到一个很有共鸣的说法:分布式事务问题,很多时候可以用数据架构设计来避免。比如业务上能接受的场景,尽量把跨库操作改造成同库操作;不能让所有业务都为了分布式事务付出额外的性能和复杂度代价。
这是很多资深工程师的共识,面试答完方案后补充一句这种设计哲学,通常能拿到不错的印象分。
10. 总结与后续学习方向
复盘这次面试,核心收获不是“多背了几个分布式事务概念”,而是建立了一套完整的技术认知:
- 分布式事务本质是解决跨服务跨库的数据一致性问题;
- 强一致方案(2PC / XA)和最终一致方案(可靠消息 / 最大努力通知)适用场景完全不同;
- TCC 是中间态最可控的强业务方案,但成本和侵入性也最高;
- Seata AT 模式上手简单,但要知道它的全局锁、undo_log 机制和性能瓶颈;
- 幂等设计、对账机制、重试退避这些工程能力,才是金融业务真正看重的东西。
如果接下来要系统补齐这块知识,可以按以下路线逐步深入:
- 先用 Seata 搭一个 Spring Boot + MySQL 的分布式事务 demo,跑通 AT 模式;
- 再用 RocketMQ 事务消息写一个下单 + 加积分的最终一致性案例;
- 自己尝试用一个转账场景,分别用 2PC、TCC、可靠消息实现一遍,对比优缺点;
- 阅读 Seata 源码中关于分支事务注册、undo_log 生成与回滚的部分;
- 结合项目实际,为现有系统设计一个幂等方案和对账任务。
面试中被深挖不可怕,反而是一次很好的查漏补缺。把这次复盘整理成文档,再对照文章里的方案和代码动手跑一遍,下次再遇到分布式事务,你可以很从容地接住面试官的任何追问。