面试官问分布式事务,不是考你知不知道 @GlobalTransactional。协调器宕机、热点锁竞争、消息消费失败、脏回滚------这四类异常才是分水岭。能扛住它们的设计,先划场景边界,再谈框架。
场景:扣库存成功,扣余额超时
三年经验的候选人,被扔了道看起来常规的题:
下单同时扣库存、扣余额,两个服务各一个库。库存扣成功,余额服务超时无响应。事务执行到一半,怎么兜?
这题不问"要不要用分布式事务",问的是网络分区、服务宕机、消息丢失、事务悬挂、空回滚全都可能发生时,怎么保证绝不出现资损。
候选人回答很典型:Seata AT,方法上加 @GlobalTransactional,框架自动管全局事务。
面试官没接,顺着往下问了六个问题。一个比一个刁。
追问一:热点商品下,AT 的全局行锁就是瓶颈
AT 模式一阶段持全局锁到二阶段结束。秒杀 SKU 所有请求排同一条库存行,锁竞争直接把吞吐打下去。
优化两条路。
拆分库存行。 一个 SKU 拆 N 行,按 hash 或轮询选行:
sql
CREATE TABLE sku_stock_shard (
sku_id BIGINT NOT NULL,
shard_no TINYINT NOT NULL, -- 0 ~ N-1
available INT NOT NULL,
PRIMARY KEY (sku_id, shard_no)
);
-- 扣减时:UPDATE ... SET available = available - 1
-- WHERE sku_id = ? AND shard_no = ? AND available >= 1
核心链路换方案。 库存预扣减放 Redis,异步落库对齐,同步锁竞争变异步补偿。代价是接受短暂不一致窗口。
面试官想听的是:热点场景下 AT 就不该出现在核心链路上。
追问二:协调器宕机,分支事务卡在中间态
二阶段 TC 没下发指令,分支事务停在中间态,数据长期不一致。
候选人说把超时时间调长。等于没答。
两条腿走路:
- TC 集群高可用,注册中心探活,单节点挂掉能接管;
- 每个分支事务本地落一条状态记录 ,超时后由定时任务主动反向查询全局事务状态,而不是被动等指令。
关键在"主动查"。等不来指令就自己去找答案。
追问三:脏回滚------超时判失败,把成功的扣款冲了
扣余额执行成功了,网络波动导致响应超时。协调器按超时判失败,发起回滚,一笔成功扣款被反向冲掉。
脏回滚靠单一超时判定避免不了。防法三层:
- 超时不等于失败,超时只能触发"查真实状态",不能触发回滚;
- 回滚前反向校验业务状态,余额服务查自己的账务流水,确认这笔成没成;
- 回滚操作幂等 ,
txId + branchId唯一索引兜底,重复回滚不重复冲账。
协调器的判断是参考值,不是事实。
追问四:消息发了,余额服务就是消费失败
换成消息驱动的最终一致性:扣库存成功 → 发消息 → 余额服务消费扣款。消息发了,余额服务持续消费失败,库存扣了,用户的钱没扣。
这不是"怎么重试"的问题,是"重试到什么时候算完"的问题。
本地消息表 + 状态机是我认为最可靠的基线方案,因为它把消息的生命周期也纳入了事务管理。
流程长这样:
消息表结构:
sql
CREATE TABLE t_local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_no VARCHAR(64) NOT NULL,
topic VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0待投递 1已投递 2已消费 3死信
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME NOT NULL,
UNIQUE KEY uk_biz_topic (biz_no, topic)
);
业务和写消息必须在一个本地事务里:
java
@Transactional
public void createOrder(OrderCmd cmd) {
orderMapper.insert(cmd.toOrder());
stockMapper.deduct(cmd.getSkuId(), cmd.getQty());
// 同库同事务,订单成功消息必然存在
localMessageMapper.insert(LocalMessage.of(cmd.getOrderNo(), "balance.deduct", cmd));
}
投递侧用指数退避,到顶进死信:
java
@Scheduled(fixedDelay = 1000)
public void dispatch() {
for (LocalMessage m : localMessageMapper.scanPending(100)) {
try {
mq.send(m.getTopic(), m.getPayload());
localMessageMapper.markSent(m.getId());
} catch (Exception e) {
int next = m.getRetryCount() + 1;
if (next > 16) {
localMessageMapper.markDead(m.getId()); // 进死信,触发人工对账
} else {
localMessageMapper.scheduleRetry(m.getId(), next, backoff(next));
}
}
}
}
// backoff: 2^n 秒,上限 30 分钟
状态机保证每一步流转都前置校验:
库存已扣、余额扣款最终失败的极端情况,靠对账任务兜底:每天跑一次差异比对,反向给用户退款并回补库存。这不是技术兜底,是业务兜底------但必须有。
追问五:TCC 的空回滚和悬挂,代码层必须堵死
这两个坑从代码层面就能堵死。
空回滚:Try 没执行(服务宕机),Cancel 先到。Cancel 去释放一笔不存在的资源。
悬挂:Cancel 先到,Try 后到。Try 真扣了资源,但全局事务已回滚,资源永远释放不掉。
核心是一张事务控制表,txId + branchId 唯一索引。
java
public void cancelFreeze(String txId, String userId, BigDecimal amount) {
// 空回滚:Try 从未执行过
if (!txControlMapper.existsTry(txId)) {
txControlMapper.insertRollback(txId); // 留痕,防止后续悬挂
log.warn("空回滚,txId={}", txId);
return; // 必须返回成功
}
accountMapper.unfreeze(userId, amount);
}
java
public void tryFreeze(String txId, String userId, BigDecimal amount) {
// 悬挂:Cancel 已经来过,Try 必须拒绝执行
if (txControlMapper.existsRollback(txId)) {
log.warn("悬挂请求,拒绝 Try,txId={}", txId);
return;
}
if (!txControlMapper.insertTry(txId)) { // 幂等,唯一索引兜底
return;
}
accountMapper.freeze(userId, amount);
}
要点:Cancel 遇空回滚必须返回成功并留痕,Try 执行前必须查回滚记录。
追问六:没有权限上新中间件,老项目怎么活
最现实的一问。生产环境里,你根本没权限上 Seata 集群。
答案就是本地消息表的简化版:
| 约束 | 方案 |
|---|---|
| 不引入 MQ | 消息表 + 定时任务轮询投递(HTTP 调用下游) |
| 不引入 Seata | 业务侧手写状态机,状态流转全落库 |
| 改造成本最低 | 只在原业务库加两张表:t_local_message、t_tx_control |
| 兜底 | 每日对账任务,扫描中间态超过阈值的记录 |
代价是实时性差(定时任务粒度决定延迟),换来的是零新增组件、业务入侵可控、出问题能人工介入。
压缩成三层:边界、落地、兜底
六个追问压成三层:
第一层:先划场景边界。 强一致场景(资金扣减)接受两阶段提交的性能损耗;非核心链路优先高可用,走最终一致。别一上来选框架。
第二层:方案落地。 最可靠基线是「本地消息表 + 状态机 + 异步补偿」,TCC 用于需要资源预留的场景,必须做空回滚和悬挂防御。
第三层:异常兜底。 协调器多副本 + 分支本地状态 + 主动反查;消息指数退避 + 死信队列 + 人工对账;热点库存拆分或异步化。
新手只会加注解,出问题就说中间件不稳定。能扛事的后端,能从选型一路设计到兜底。
一句话收束
面试官考的不是你会不会用 Seata,是你知不知道 Seata 在什么情况下会失效,以及失效之后你怎么收场。
写在最后
那个候选人的问题不在技术深度,在于他把"框架能做什么"当成了"我的方案能扛什么"。协调器宕机、网络超时、消息丢失这三件事,任何一个发生都足够让一套只加了注解的架构崩掉。
你们团队现在的一致性方案是哪一种------本地消息表 + 定时任务,还是直接上了事务消息/Seata?有没有踩过空回滚或者脏回滚的坑?评论区聊聊。
有用的话点个赞。