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 的时候,别光看它挡住了多少故障,还要看它让团队少熬了多少夜——后者往往才是运营最看重的收益。对账体系上线后,我们的值班夜从「不确定明天会不会爆雷」变成了「看一眼对账报告就踏实」,这种心态变化是系统稳定带给人最好的礼物。新同学入职看到的第一份文档就是对账设计,看懂它的人自然就懂了这个系统把一致性放在多高的位置,这一点比任何规范文档都更能塑造团队文化,也是最让我们自豪的技术遗产之一,新系统立项时它直接作为基础设施被继承了下来,这也成了我们系统设计的一块基石。
