微服务

分布式事务的边界该怎么划:一个 2400 倍吞吐差距的教训

同一个 SKU、同样的 100 个并发请求、同一套 Seata 配置,只因为全局事务的边界多划了一段,单 SKU 成交量从 20 单/秒掉到 0.0083 单/秒。这篇用实测数据说明全局锁的持有时间意味着什么,以及事务边界该怎么划。

2026-03-22 约 15 分钟 6068 字 微服务 分布式事务 Seata 对账

一、两组数据

同一套代码,同一个爆款 SKU,同样 100 个并发下单请求,同一份 Seata 配置。唯一的差别是全局事务的边界划到了哪里。

指标 划法 A(只覆盖到库存锁定) 划法 B(一直等到支付后发积分)
全局锁持有时间 50 ms 120 s
单 SKU 每秒成交 20 单/秒 0.0083 单/秒
100 个并发请求成功数 100 / 100 1 / 100
大促 30 分钟成交量 受库存上限 15 单

吞吐差 2400 倍。

第二组数据里最刺眼的不是吞吐,是「100 个并发请求只成功了 1 个」。这个数字说明划法 B 遇到的不只是「慢」,而是新请求直接被拒绝——和「排队等一会儿」是完全不同性质的故障。

这篇要讲清楚的就是这件事:分布式事务的边界不是随手一框的,它直接决定这把全局锁要陪你走多久,而全局锁的持有时间就是系统的串行点长度。

二、机制:全局锁要陪你走完整个全局事务

Seata AT 模式的一阶段执行顺序:

① 执行业务 SQL,同时往 undo_log 写前后镜像
② 申请该记录的【全局锁】            ← 关键:拿不到就不允许提交本地事务
③ 提交本地事务(本地行锁立刻释放)
④ 全局事务提交 / 回滚时,才释放全局锁

前三步都很快,加起来通常在几十毫秒量级。第 ④ 步才是问题所在——全局锁的释放时机不是本地事务提交,而是整个全局事务结束。

这就产生了一个非常容易被忽略的错位:

本地锁很快就放了,但全局锁要陪你走完整个全局事务。所以事务边界划多大,这把锁就持有多久。

为什么必须有这把全局锁?因为它要防的是「脏写」——事务 A 已经改了这条记录并提交,之后事务 B 又改了同一条记录;如果这时 A 回滚,它的 undo_log 会把 B 的修改覆盖掉。全局锁的存在保证了「只要这条记录还被某个全局事务持有,别人就不能提交对它的修改」,从而避免这种覆盖。

所以全局锁不是设计缺陷,它是 AT 模式在放弃一阶段提交强一致之后,用来换回写隔离的必要手段。它只是有一个不容忽视的副作用:持有时间 = 全局事务持续时间。

三、划法 B 为什么会「基本不可用」

把「订单创建 + 库存锁定」和「支付成功后的积分发放」放进同一个全局事务,时间线是这样的:

事务1   0 ms 开始  ├████████ 订单 + 库存 ████████┤ ……… 等用户付款 ………
                                  全局锁一直被事务1占着,
                                  直到用户付款完成、积分发完才释放(约 120 秒)

事务2     300 ms 申请全局锁 → 重试 30 次 → 抢不到 → ✗ 失败
事务3     600 ms 申请全局锁 → 重试 30 次 → 抢不到 → ✗ 失败
事务4     900 ms 申请全局锁 → 重试 30 次 → 抢不到 → ✗ 失败
...(同样的失败在接下来的 120 秒里持续发生)

关键在于抢不到锁之后的行为。Seata 申请全局锁失败后会重试,默认策略是重试 30 次、每次间隔 10ms,合计约 300ms。超过这个范围就放弃申请、回滚本地事务。

也就是说:

新请求不是在排队等待,而是在 300ms 之后直接失败。

这是理解「100 个并发只成功 1 个」的关键。排队至少还有成功的可能,直接失败意味着这段时间窗口内几乎没有任何新订单能成交。一个爆款 SKU 在整个大促期间基本卖不动,不是性能下降,是功能不可用。

再算一遍数字会更直观:120 秒的锁持有时间意味着单 SKU 的理论上限是 1 / 120 ≈ 0.0083 单/秒。大促开 30 分钟,也就是 1800 × 0.0083 ≈ 15 单。而划法 A 的 50ms 对应 1 / 0.05 = 20 单/秒,同样 30 分钟理论上是 36000 单——最终受限于库存,而不是事务。

四、边界该怎么划:三条判据

从上面的机制能反推出划边界的原则。判断一个操作该不该进同一个全局事务,问三个问题。

判据一:它们发生在同一时刻吗

这是最硬的一条。看一次下单流程里各个操作的实际时机:

操作 发生时机 时间跨度 该进全局事务吗
订单创建 用户点「提交订单」 T+0 ✅
库存锁定 用户点「提交订单」 T+0 ✅
优惠券核销 用户点「提交订单」 T+0 ✅
积分发放 支付成功之后 T+几分钟甚至几十分钟 ❌
消息通知 支付成功之后 T+几分钟 ❌
更新搜索索引 无所谓 完全异步 ❌

前三个发生在同一瞬间,用户点一下按钮,它们必须一起成功或一起失败。这就是全局事务该覆盖的范围。

积分发放不一样。它和订单创建之间隔着一个「用户付款」的不可控等待——用户可能 30 秒后付款,也可能放到第二天购物车里再付,还可能永远不付。给这段等待加一把全局锁,等于让整条下单链路等一个不受系统控制的外部事件。

这里有一个概念必须区分清楚:「积分」这个词对应两种完全不同的操作。

  • 下单时用积分抵扣:发生在 T+0,和订单创建同一时刻,可以进同一个全局事务
  • 支付成功后发放积分:发生在 T+N,不能进同一个全局事务

名称一样,时机天差地别。划边界时如果只看操作的名字,很容易把这两个搞混。

判据二:用户愿意为它等多久

全局事务持续期间,被它锁住的记录对其他人是不可写的。所以第二个判据是:这个操作的最长耗时,用户能接受吗?

同步调用链上的操作,耗时必须在几十到几百毫秒量级。一旦某个操作需要等待外部事件(用户付款、第三方回调、人工审核),它就不能放在事务边界内——那不是优化问题,是架构问题。

判据三:失败之后能自动补偿吗

能自动重试、重试失败也不影响主流程的操作,不需要强一致。发积分失败了可以重发,消息通知失败了可以重投,搜索索引更新失败了可以重建。这些操作天生适合最终一致,没必要用全局事务绑住。

反过来,「库存扣了但订单没创建」这种状态是不可接受的——库存被白占,用户还没下单。这类必须原子。

五、拆出去的部分靠什么保证

把积分和通知拆出全局事务之后,它们靠 MQ 做最终一致。这里有一个很容易踩的坑:「发到 MQ 了」不等于「一定会被处理」。

常见做法是本地消息表:

CREATE TABLE `local_message` (
  `id`          bigint(20)   NOT NULL AUTO_INCREMENT,
  `biz_type`    varchar(32)  NOT NULL COMMENT '业务类型:POINTS/NOTIFY',
  `biz_id`      varchar(64)  NOT NULL COMMENT '业务唯一键,如订单号',
  `payload`     varchar(2048) NOT NULL,
  `status`      tinyint(4)   NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2已消费',
  `retry_count` int(11)      NOT NULL DEFAULT 0,
  `next_retry`  datetime     DEFAULT NULL,
  `created_at`  datetime     NOT NULL,
  `updated_at`  datetime     NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_biz` (`biz_type`, `biz_id`)   -- 幂等:同一订单只入表一次
) ENGINE=InnoDB;

流程是:业务事务里同时写业务表和消息表(本地事务保证原子),然后由定时任务扫描 status = 0 的记录投递到 MQ,投递成功后置为已发送。这样即使 MQ 短暂不可用,消息也不会丢。

消费端必须幂等。MQ 的投递语义通常是 at least once,重复消费是必然会发生的,不是异常:

// 消费端幂等:用业务唯一键 + 唯一索引兜底
@RabbitListener(queues = "points.queue")
public void onMessage(PointsMessage msg) {
    // 唯一索引冲突说明已处理过,直接 ack
    try {
        pointsService.grant(msg.getOrderNo(), msg.getUserId(), msg.getPoints());
        pointsLogMapper.insert(msg.getOrderNo(), msg.getUserId(), msg.getPoints());
    } catch (DuplicateKeyException e) {
        log.info("积分已发放过,跳过 orderNo={}", msg.getOrderNo());
    }
}

唯一索引是比「先查再写」更可靠的幂等实现。先查再写在并发下依然可能重复插入,唯一索引由数据库保证,不存在这个窗口。

六、失败怎么处理

事务边界划对了,还有一半问题没解决:失败了怎么办? 这部分内容往往比边界本身更容易被忽略。

全局事务超时必须设

@GlobalTransactional(
        rollbackFor = Exception.class,
        timeoutMills = 30000     // 全局事务 30 秒超时
)
public void createOrder(OrderDTO dto) {
    orderMapper.insert(buildOrder(dto));
    stockClient.deduct(dto.getSkuId(), 1);
}

timeoutMills 绝对不能省。没有超时的全局事务会在 TC 端无限期挂着,全局锁也一直不释放。一旦某个分支因为网络问题卡住,它会拖住整条业务线,而不是只影响自己这个请求。超时是全局锁持有时间的上界——有上界,故障影响范围才是可控的。

不同失败的分级处理

失败类型 处理方式 是否要人工
网络抖动导致的临时失败 自动重试 1~2 次,指数退避 否
业务校验失败(库存不足) 不重试,直接返回用户 否
分支提交失败、全局回滚成功 记录日志,数据已一致 否
全局事务超时、TC 不可达 记录待处理工单,定时任务重试 视情况
undo_log 后镜像比对不一致 告警 + 生成工单,不自动改数 是
对账发现差异 告警 + 生成工单,不自动改数 是

中间两行是重点。「全局事务超时、TC 不可达」这类失败会让数据停在一个不确定的状态——不知道到底是提交了还是回滚了。这时靠自动重试往往没用,需要定时任务去核对实际数据状态,再决定补哪一边。

最后两行遵循同一条原则:发现不一致的时候,不要自动改数。

这个原则值得展开说。自动改数的风险在于:对账逻辑本身可能有 bug,或者差异的成因比看上去复杂(比如是另一个补偿流程正在处理中)。一旦自动把数改了,原本只是「不一致」的状态会变成「不一致且无法追溯原因」——原始证据被抹掉了,后续排查无从下手。

正确的做法是:告警、生成工单、把差异明细和上下文都留下来,由人或一个独立的、经过充分验证的修复流程来处理。

七、对账:最后一道防线

Seata 也做不到 100%。undo_log 可能因为各种原因补偿失败,TC 可能长时间不可用,人工操作可能绕过事务。最终能发现这些问题的,只有对账。

一个基本的对账设计:

-- 每小时跑一次:找出「订单已创建但库存未扣」的差异
SELECT o.order_no, o.sku_id, o.status, s.available
FROM `order` o
LEFT JOIN stock s ON o.sku_id = s.sku_id
WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
  AND o.status = 'CREATED'
  AND NOT EXISTS (
      SELECT 1 FROM stock_log l
      WHERE l.order_no = o.order_no AND l.op_type = 'LOCK'
  );
-- 有结果 → 告警,不自动补

对账要覆盖的维度至少有三组:

对账项 比对内容 发现什么问题
订单 ↔ 库存流水 订单是否都有对应的锁定记录 事务半成功
库存流水 ↔ 实际库存 流水累加是否等于当前库存 补偿失败、绕过事务的修改
积分发放 ↔ 支付记录 已支付订单是否都发了积分 MQ 消息丢失或消费失败

对账的粒度要权衡。全量对账准确但慢,增量对账快但会漏。生产上通常是:核心链路每小时做一次增量对账,每天凌晨做一次全量对账兜住增量窗口的边界问题。

八、Seata 提供的,和你必须自己决定的

最后回到一个根本性的认知上。

Seata 本身不「保障」一致性。 它只提供两样能力:

Seata 提供的 说明
回滚能力 undo_log 记录前后镜像,失败时反向补偿
隔离能力 全局锁,防止「写了又被回滚掉」的脏写

真正的一致性来自你自己的三个设计决策:

你必须决定的 决定错的后果
1. 事务边界划在哪 划大了全局锁把系统锁死(2400 倍吞吐差距)
2. 失败了怎么办 重试策略、超时时间、什么时候人工介入
3. 对账兜底怎么做 前两条都失效时的最后一道网

框架给的是能力,不是结果。把一段业务逻辑框进 @GlobalTransactional 只是让它「有机会」保持一致;它到底能不能一致、代价有多大,取决于上面这三个决策。

架构评审里常听到「引入 Seata 就保障了一致性」这类说法,它之所以站不住脚,是因为把决策 1 和决策 2 都省略了——而这两条恰恰是决定方案成败的部分。

九、小结

  • 全局锁的持有时间是从申请成功到全局事务结束;本地行锁提交即释放,全局锁要陪你走完整个全局事务
  • 因此事务边界划多大,这个串行点就有多长;实测同一 SKU、同样配置下,边界多划一段导致吞吐差 2400 倍
  • 划法不当的后果不是「变慢」,是新请求在 300ms 重试窗口后直接失败,100 个并发只成功 1 个
  • 划边界的三条判据:是否同一时刻发生、用户愿意等多久、失败能否自动补偿
  • 「下单时积分抵扣」和「支付后发放积分」是两个不同时刻的操作,不能因为名字像就放进同一个事务
  • 拆出去的部分用本地消息表 + MQ 做最终一致,消费端必须幂等,唯一索引比先查再写可靠
  • 全局事务必须设 timeoutMills,超时是全局锁持有时间的上界,有上界故障影响范围才可控
  • 出错分级处理:临时故障重试,不确定状态核对后补偿,发现不一致告警而不自动改数
  • 对账是最后一道防线,增量对账保时效、全量对账兜边界
  • Seata 只提供回滚能力和隔离能力;一致性的边界、失败的处理、对账的兜底,都得自己定

把「全局锁的持有时间 = 全局事务的持续时间」这句话记住,事务边界的大部分判断都能自己推出来:凡是需要等待一个不受系统控制的外部事件,它就不属于这个事务。

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。