4.2 分布式事务:库存与订单的一致性
场景:库存扣了订单没建成的尴尬
异步下单链路跑了一周后,对账任务报出 30 单库存扣减成功但订单创建失败。原因是订单插入时违反了唯一约束(同一用户同一场次重复订单被防重逻辑拦截),而扣减早已完成。这 30 单的库存悬空了:既没有对应订单,也没有自动回补,等于商品凭空少了 30 件。这就是分布式事务的典型困境——库存操作在 Redis 上,订单操作在 MySQL 上,两套系统没有共享事务。悬空的三十单最终只能人工逐条回补,花了两个通宵。我们把那天的复盘整理成四类不一致形态,后续所有的事务设计都奔着堵住这四类洞去:
| 不一致形态 | 表现 | 触发原因 |
|---|---|---|
| 悬空扣减 | 库存无订单 | 建单失败 |
| 重复扣减 | 一单扣两次 | 消费重复 |
| 漏扣减 | 订单无库存 | 投递丢失 |
| 回补缺失 | 取消未还库存 | 任务宕机 |
方案:四种分布式事务思想对比
库存属于秒杀的高频热点,强一致事务(比如两阶段提交)的开销根本无法承受。我们先看对比再选方案:
| 方案 | 一致性 | 性能 | 复杂度 | 秒杀适用 |
|---|---|---|---|---|
| 两阶段提交 | 强一致 | 极差 | 高 | 否 |
| TCC 补偿 | 最终一致 | 中 | 高 | 可行但重 |
| 本地消息表 | 最终一致 | 高 | 中 | 我们的选择 |
| 事务消息 | 最终一致 | 高 | 中 | 无成熟轮子 |
我们选择本地消息表模式的原因是它不需要引入新组件。核心思路:订单表与消息表在同一个 MySQL 事务里写入,消息表的内容是库存变更事件,一个后台任务不断扫描消息表并投递出去,投递成功后标记完成。由于订单与消息同库同事务,订单建不成消息也不会出现,从根上解决了库存悬空。它唯一的要求是业务能接受最终一致:秒杀场景下订单创建后几秒内完成库存对账,用户无感知,这个延迟完全可接受。反过来说,如果业务要求强一致(比如银行间转账),本地消息表就不是最佳选择,方案选型永远跟着业务诉求走,没有银弹。
| 事件类型 | 含义 | 后续动作 |
|---|---|---|
| deduct | 库存扣减 | 同步 Redis 并落库 |
| rollback | 库存回补 | 回补 Redis |
| confirm | 订单确认 | 更新订单状态 |
配置:本地消息表的实现
订单创建与消息写入放在同一个事务中,保证两者同生共死。消息表的字段设计也值得一提,每一个字段都对应一类故障的处理需求:
| 字段 | 作用 | 设计动机 |
|---|---|---|
| id | 事件编号 | 分布式锁键 |
| order_id | 关联订单 | 幂等防重 |
| type | 事件类型 | 区分扣减与回补 |
| state | 处理状态 | 轮询扫描条件 |
| retry_count | 重试计数 | 超阈告警 |
| done_at | 完成时间 | 积压监控 |
class CreateOrderJob
{
public function handle(): void
{
DB::transaction(function () {
$order = Order::create([
'no' => $this->orderNo, 'user_id' => $this->userId,
'sku_id' => $this->skuId, 'status' => 'pending',
]);
StockEvent::create([
'order_id' => $order->id,
'type' => 'deduct',
'sku_id' => $this->skuId,
'quantity' => 1,
'state' => 'pending',
]);
});
}
}
后台任务轮询消息表,处理完一个事件就把状态改成 done,订单与库存两端都留下可对账的轨迹:
class DispatchStockEventTask
{
public function __invoke(): void
{
$events = StockEvent::where('state', 'pending')->limit(500)->get();
foreach ($events as $event) {
try {
$this->apply($event); // Redis 扣减或回补
$event->update(['state' => 'done', 'done_at' => now()]);
} catch (Throwable $e) {
$event->increment('retry_count');
if ($event->retry_count >= 3) {
ClsLog::critical('stock_event_stuck', ['event' => $event->id]);
}
}
}
}
}
这套设计还有两个兜底细节。其一,消息表里每条事件都有唯一约束防重,即使任务重复执行也不会重复扣减。其二,对账任务(2.3 节)把订单、库存、流水三表与消息表联合比对,任何悬空事件最终都会在对账里暴露。
为什么不在扣减 Redis 时就开启 MySQL 事务?我们最初这样尝试过:事务开启、扣 Redis、写订单、提交。结果 Redis 扣减成功而事务提交失败时,库存照样悬空,而且事务持有期间 Redis 连接占用变长,并发一高连接池就告急。本地消息表把远程调用彻底移出事务,事务里只有同库写操作,时长稳定在五毫秒以内。
| 对比项 | 事务内调 Redis | 本地消息表 |
|---|---|---|
| 事务时长 | 不稳定 | 五毫秒内 |
| 悬空风险 | 高 | 低 |
| 一致性 | 强 | 最终 |
| 吞吐量 | 低 | 高 |
踩坑:消息表模式的三个坑
坑一:事件积压监控缺失。 刚上线时没看消息表的积压量,某次 Redis 抖动后积压了几千条事件没人发现,用户体验是订单创建延迟变高。补上积压告警后,值班同学在积压超过三百条时就会收到电话通知。
坑二:事务里塞了远程调用。 有次事务中除了写订单与消息表,还调用了同时更新 Redis 的逻辑,Redis 稍慢就拖长事务,MySQL 行锁等待上升。事务里只写同库数据,远程调用一律移出事务,这条规则我们写进了代码规范。
坑三:重复消费导致重复扣减。 分布式调度器偶尔会起两个任务实例,同一条事件被处理两次。给事件处理加 Redis 分布式锁(锁键为事件编号),第二个实例直接跳过,加上消息表的幂等字段双保险,参考 2.2 节的 Lua 扣减设计。
坑四:积压告警阈值定太低。 最初设了五十条就告警,结果大促高峰正常波动也频繁报警,值班同学逐渐麻木,真积压时反而没人看。阈值改为三百条并配合增速(每分钟增长超过一百条)才报警,误报率降下来后告警才重新变得可信。
小结
分布式事务的关键不是追求强一致,而是让每一步可回滚、可追踪、可对账。本地消息表用最小的复杂度换来最终一致,订单与库存的每一次变化都留在流水里,任何异常都能通过重放修复。选方案时要克制,别一上来就上 TCC,那种级别的复杂度小团队扛不住。
落地这套方案有一个前提容易被忽略:参与方都必须提供幂等接口。消息表重试是常态而非异常,如果下游接口不能重复调用,重试就会变成灾难。我们在推进时先改造了库存与订单两侧的接口,全部支持按事件编号幂等执行,消息表才敢放心重试。这个顺序不能颠倒。
轮询间隔也需要按业务节奏调。我们最初设成一秒一次,高峰期消息表不大时没什么问题;大促时事件量放大十倍,一秒一次的扫描本身成了负担。后来改成自适应:积压少时间隔拉长到五秒,积压超过一百条自动收缩到一秒,兼顾了延迟与数据库压力。这套机制的成效在第二次大促就体现出来了:Redis 主从切换造成两分钟的事件积压,系统在切换恢复后四分钟内消化完全部积压,全程没有一笔订单需要人工处理。比起第一次大促时两个通宵的人工回补,这是实打实的进步,也是我们坚持把每个异常形态都做成自动恢复的原因,最终一致的可观测性比强一致的幻象更让人安心。
