2.3 一致性兜底:MySQL 乐观锁与对账

场景:Redis 挂了的那五分钟

第二场秒杀进行到一半,云数据库 Redis 主节点故障,哨兵切换花了约 30 秒,切换完发现部分库存键的值比 MySQL 里少。原因是切换瞬间有几个异步落库消息消费成功但回写 Redis 失败,扣减只落到了 MySQL。那晚我们靠手工对账到凌晨两点,第二天就立了对账项目的军令状。教训很清楚:异步链路再优雅,也必须有独立的、不信任任何中间态的对账机制。

方案:三层兜底防住不一致

我们不信任任何单点,设计了对账体系的三层,从近到远分别是乐观锁、实时比对、定期全量对账。三层各自的成本和收益不同,组合起来才能既省机器又保住底线:

兜底层 触发时机 实现方式 成本
乐观锁 每次库存写 MySQL 版本号递增,失败重试 极低
实时比对 每分钟一次 定时任务比对 Redis 与 MySQL 低
全量对账 每天活动结束后 独立账本重放订单流水 中

乐观锁保护的是写环节。MySQL 的库存表带一个 version 字段,更新时带上条件,版本号对不上就重试。写法比悲观锁简单,而且没有行锁等待问题:

// 库存表迁移:增加版本号字段
Schema::table('sku_stocks', function (Blueprint $table) {
    $table->unsignedInteger('version')->default(0)->after('stock');
});
// 乐观锁更新:失败自动重试,最多 3 次
public function syncStock(int $skuId, int $delta): bool
{
    for ($i = 0; $i < 3; $i++) {
        $row = SkuStock::where('sku_id', $skuId)->first();
        $affected = SkuStock::where('sku_id', $skuId)
            ->where('version', $row->version)
            ->update([
                'stock' => $row->stock + $delta,
                'version' => $row->version + 1,
            ]);
        if ($affected) {
            return true;
        }
    }
    return false;
}

乐观锁冲突重试在高并发下概率不高,因为洪峰期写 MySQL 的并发量被异步队列削平了,队列消费者天然是串行的。这一点值得多说一句:异步队列在这里扮演了串行化器的角色,把洪峰期上万并发的库存写请求变成每秒几十次的串行写入,MySQL 的行锁与乐观锁几乎不会冲突。可以说削峰是库存一致性的隐藏前提,没有削峰就没有廉价的最终一致。两种锁的适用场景我们也对比过,贴出来供参考:

锁机制 适用场景 我们的用法
乐观锁 冲突少、读多写少 库存的最终落库
悲观行锁 冲突激烈 绝不用于秒杀链路
分布式锁 跨服务互斥 全局对账任务的防重复执行

我们刻意避免在秒杀链路上使用任何基于锁的等待机制。秒杀的性能要求是毫秒级返回,锁等待的存在会让耗时时不可控。用无锁的 Lua 原子扣减配合串行消费,是简单可靠的组合。

配置:每分钟跑一次的对账任务

对账任务的核心逻辑是独立计数:从 MySQL 订单表算出某 SKU 已售件数,与活动总库存、Redis 余额三方核对,任何一项对不上就走核对流程并告警:

class ReconcileStockTask
{
    public function __invoke(): void
    {
        $sessions = SeckillSession::where('status', 'live')->get();
        foreach ($sessions as $s) {
            $sold = Order::where('sku_id', $s->sku_id)
                ->whereIn('status', ['paid', 'shipping'])
                ->sum('quantity');
            $redisLeft = (int) Redis::get("seckill:stock:{$s->sku_id}");
            $expected = $s->total_stock - $sold;
            if ($redisLeft !== $expected) {
                $this->alert($s, $redisLeft, $expected);
                Redis::set("seckill:stock:{$s->sku_id}", $expected); // 以订单账本为准修复
            }
        }
    }
}

设计原则是以订单账本为基准:订单是已经发生的事实,Redis 余额只是缓存态。修复方向只能是 Redis 向订单对齐,绝不能反过来,这条原则在洪峰期救过好几次场。对账的频率也要掌握好分寸:太频繁浪费从库资源,太稀疏修复不及时。我们实践下来的甜点是每分钟一次,扫描只限进行中的活动,消耗的连接数控制在个位数。

每天活动结束后还会跑一次全量对账:把当天订单流水逐条重放,重建每一步库存变化,与日志服务 CLS 里记录的扣减事件交叉验证。CLS 的检索能力让这场重放从天级压缩到小时级,日志接入在 5.3 节细讲。

对账体系跑顺之后还有一个收益是加强的运营信心。以前运营每次搞秒杀都提心吊胆,生怕卖超被财务追责;有了分钟级对账,活动页可以直接告诉客服当前余量,用户质询时从容应答。甚至有几次活动因为对账提前发现了异常并主动停场,主动停场的损失远小于超卖后赔付。技术基础设施做到位,业务上能放开手脚,这个回报比压测数字更直观。每天晚上的全量重放报告还会自动抄送一份给财务,财务对账不再需要找技术导数据,跨部门沟通成本几乎降为零。可以说对账体系不仅是技术兜底,也成了业务侧的信任基础。对账这件事做到极致就是财务系统的思路,我们团队有人在银行业做过,把记账的方法论带过来之后,整个库存体系的设计水准明显上了一个台阶,这也是我们鼓励团队跨行业交流的原因。

踩坑:对账自身的坑

坑一:对账任务执行时间太长。 一开始对账扫全表加上 CLI 单进程,跑完要两分钟,活动激烈时下一轮又开始了,两轮互相打架。优化办法是只查 live 状态的活动、走从库读、加索引,压到 10 秒以内,再错峰执行避免与异步消费者抢连接。

坑二:对账把未支付订单也算成已售。 早期逻辑把创建中的订单也算进 sold,结果每分钟对账都把 Redis 库存改大,超卖风险陡增。后来订单状态机收窄,只有已支付及之后的中间态才算占用库存,待支付订单由 4.4 节的超时回滚机制单独管理。

坑三:告警太多反而没人看。 一开始对账稍有偏差就发告警,运营群里消息刷屏,真告警反而被忽略。后来告警分级:偏差 1 件以内只记日志,超过阈值才电话告警,告警必须带上活动编号、偏差数量、建议动作三要素。这套分级机制跑了一周之后,告警的打开率和响应速度都肉眼可见地提升了。顺带一个实践心得:告警消息里附上对账任务最近一次执行成功的耗时与输出摘要,值班同学能凭这一条信息判断系统是否健康,翻日志的时间都省了。

小结

一致性不是设计出来的,是对账对出来的。乐观锁堵住并发的写,实时对账每分钟校准 Redis,全量重放兜住一整天的账。对账的基准永远是订单事实表,中间态向事实看齐。这一套组合拳下来,我们再没为库存数字睡不着觉过。最后给一个量化感受:库存类客诉从每周十几条降到接近于零,运营对秒杀活动的信心明显回升,这比任何压测数字都更有说服力。对账任务本身的资源消耗很小,每分钟跑一次只占用从库少量连接,是所有兜底手段里性价比最高的一种。给任何兜底机制定 KPI 的时候,别光看它挡住了多少故障,还要看它让团队少熬了多少夜——后者往往才是运营最看重的收益。对账体系上线后,我们的值班夜从「不确定明天会不会爆雷」变成了「看一眼对账报告就踏实」,这种心态变化是系统稳定带给人最好的礼物。新同学入职看到的第一份文档就是对账设计,看懂它的人自然就懂了这个系统把一致性放在多高的位置,这一点比任何规范文档都更能塑造团队文化,也是最让我们自豪的技术遗产之一,新系统立项时它直接作为基础设施被继承了下来,这也成了我们系统设计的一块基石。

延伸阅读