分布式事务的边界该怎么划:一个 2400 倍吞吐差距的教训
同一个 SKU、同样的 100 个并发请求、同一套 Seata 配置,只因为全局事务的边界多划了一段,单 SKU 成交量从 20 单/秒掉到 0.0083 单/秒。这篇用实测数据说明全局锁的持有时间意味着什么,以及事务边界该怎么划。
一、两组数据
同一套代码,同一个爆款 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_* 四个变量后自动开启。