分布式事务 6 连问从 Seata AT 到本地消息表落地

面试官问分布式事务,不是考你知不知道 @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 集群高可用,注册中心探活,单节点挂掉能接管;
  • 每个分支事务本地落一条状态记录 ,超时后由定时任务主动反向查询全局事务状态,而不是被动等指令。

关键在"主动查"。等不来指令就自己去找答案。

追问三:脏回滚------超时判失败,把成功的扣款冲了

扣余额执行成功了,网络波动导致响应超时。协调器按超时判失败,发起回滚,一笔成功扣款被反向冲掉。

脏回滚靠单一超时判定避免不了。防法三层:

  1. 超时不等于失败,超时只能触发"查真实状态",不能触发回滚;
  2. 回滚前反向校验业务状态,余额服务查自己的账务流水,确认这笔成没成;
  3. 回滚操作幂等txId + branchId 唯一索引兜底,重复回滚不重复冲账。

协调器的判断是参考值,不是事实。

追问四:消息发了,余额服务就是消费失败

换成消息驱动的最终一致性:扣库存成功 → 发消息 → 余额服务消费扣款。消息发了,余额服务持续消费失败,库存扣了,用户的钱没扣。

这不是"怎么重试"的问题,是"重试到什么时候算完"的问题。

本地消息表 + 状态机是我认为最可靠的基线方案,因为它把消息的生命周期也纳入了事务管理。

流程长这样:

sequenceDiagram participant C as 订单服务 participant DB as 本地库 订单表+消息表 participant MQ as 消息队列 participant S as 余额服务 C->>DB: 本地事务:写订单 + 写消息表 同一次提交 DB-->>C: 提交成功 C->>MQ: 定时任务异步投递消息 MQ->>S: 投递扣款消息 S->>S: 幂等校验 + 扣余额 S-->>MQ: ACK MQ-->>C: 回调更新消息状态为 DONE

消息表结构:

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 分钟

状态机保证每一步流转都前置校验:

flowchart LR A[INIT 待投递] -->|投递成功| B[SENT 已投递] B -->|消费成功 ACK| C[DONE 已完成] B -->|消费失败| D[RETRYING 重试中] D -->|未达上限| B D -->|超过 16 次| E[DEAD 死信] E -->|人工对账修复| C

库存已扣、余额扣款最终失败的极端情况,靠对账任务兜底:每天跑一次差异比对,反向给用户退款并回补库存。这不是技术兜底,是业务兜底------但必须有。

追问五: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_messaget_tx_control
兜底 每日对账任务,扫描中间态超过阈值的记录

代价是实时性差(定时任务粒度决定延迟),换来的是零新增组件、业务入侵可控、出问题能人工介入

压缩成三层:边界、落地、兜底

六个追问压成三层:

第一层:先划场景边界。 强一致场景(资金扣减)接受两阶段提交的性能损耗;非核心链路优先高可用,走最终一致。别一上来选框架。

第二层:方案落地。 最可靠基线是「本地消息表 + 状态机 + 异步补偿」,TCC 用于需要资源预留的场景,必须做空回滚和悬挂防御。

第三层:异常兜底。 协调器多副本 + 分支本地状态 + 主动反查;消息指数退避 + 死信队列 + 人工对账;热点库存拆分或异步化。

新手只会加注解,出问题就说中间件不稳定。能扛事的后端,能从选型一路设计到兜底。

一句话收束

面试官考的不是你会不会用 Seata,是你知不知道 Seata 在什么情况下会失效,以及失效之后你怎么收场。

写在最后

那个候选人的问题不在技术深度,在于他把"框架能做什么"当成了"我的方案能扛什么"。协调器宕机、网络超时、消息丢失这三件事,任何一个发生都足够让一套只加了注解的架构崩掉。

你们团队现在的一致性方案是哪一种------本地消息表 + 定时任务,还是直接上了事务消息/Seata?有没有踩过空回滚或者脏回滚的坑?评论区聊聊。

有用的话点个赞。

相关推荐
Spider Cat 蜘蛛猫1 小时前
微服务- 自动审核商家入驻
微服务·云原生·架构
阿拉斯攀登2 小时前
内网渗透工具栈:Metasploit、Impacket、CrackMapExec、mimikatz实战
架构
风哥2号2 小时前
数据库教程FGMT20‑Oracle容灾体系架构与数据库升级迁移方案
数据库·oracle·架构
盛世宏博智慧档案2 小时前
档案环境治理一体机感知‑计算‑执行闭环架构与物联网协议适配方案
物联网·架构
真上帝的左手2 小时前
27. 数据产品- BI - AI 应用实战终篇-从 RAG 架构到企业级平台落地
大数据·人工智能·架构
东方佑2 小时前
当架构开始为存储让路:DeepSeek-V4.1-Flash 与一场静默的范式转移
架构
Sylvia33.2 小时前
板球实时数据接入实战:从Frames模型到多赛制适配
java·python·websocket·网络协议·架构
爱吃红星柚2 小时前
【学习】框架设计(DSL + 领域模型)(2)基于vue3完成动态组件库建设
架构
X54先生(人文科技)2 小时前
《元创力》纪实录 · 卷宗 3.5-C《协议的形状——ELR体系第一份商业合同的形成全记录》
人工智能·深度学习·架构·ai写作·开源协议