2.2 库存扣减:先扣 Redis 还是先扣库

场景:两种扣法,一种超卖一种雪崩

库存扣减怎么做,团队里吵得最凶。一派主张先扣 MySQL,事务里 UPDATE stock SET num = num - 1,简单可靠;另一派主张先扣 Redis,理由是数据库扛不住洪峰。我们两种都试过:先扣 MySQL 的那场活动,一千个并发请求把行锁等超时,接口大面积 504;先扣 Redis 的那场活动,Redis 扣完了异步写库消息丢了 200 条,Redis 宕机重启后内存数据没持久化,库存对不上,夜里十一点还在手工对账。两条事故的共同根源是把一致性与性能当作单选题。Redis 与 MySQL 各自都有做不到的事:Redis 做不到不丢数据,MySQL 做不到扛住洪峰。承认这一点之后,方案就自然变成 Redis 管性能、MySQL 管真相、对账来弥合两者。两次事故的结论就是单纯先扣哪边都不行,必须组合起来用。我们的大部分基础设施决策都遵循这个思路:让每个组件只做自己擅长的事,剩下的交给补偿机制。

方案:两条路线的对比与最终选择

先把对比摆出来,为什么最后是 Redis 扣减加异步落库加对账兜底:

方案 吞吐能力 一致性风险 复杂度 适用场景
只扣 MySQL 行锁 低,约数百 QPS 强一致 低 深库存普通商品
只扣 Redis 极高 宕机即丢数据 低 能容忍丢失
先 MySQL 后 Redis 中 双写不一致 中 并发不极端
Redis 原子扣减加异步落库加对账 高 最终一致 高 秒杀首选

最终方案的要点是:扣减逻辑整体用 Lua 脚本在 Redis 服务端执行,判断库存、扣减、记录已购用户一步到位;扣减成功立即返回用户抢购成功;落库动作异步进入消息队列,消费者慢慢写 MySQL;最后用定时对账做兜底。用户侧的感受是毫秒级响应,数据库侧的感受是平稳的小流量。

这套方案里每个角色都有清晰边界,新人接手时我们在白板上画一遍就能看懂整个数据流向,这也是它优于其他方案的地方:

角色 职责 数据流向
Lua 脚本 原子判断与扣减 只在 Redis 内
异步队列 削峰并串行化写 Redis 到 MySQL
对账任务 独立核对 订单表到 Redis
补偿机制 失败自愈 异常修复回 Redis

异步落库必然有一致性窗口,所以我们设计了三道阀门兜底:第一是 Redis 的 AOF 持久化开启,断电最多丢一秒;第二是消费者批量落库失败时库存自动回补到 Redis;第三是每分钟一次的对账任务,把 MySQL 实际库存与 Redis 余额比对,发现偏差立即告警并自动修复。

关于扣减顺序还有一个反直觉的结论值得强调:先扣 Redis 再扣 MySQL 看起来像是把一致性置于危险中,但配合对账之后,它比先扣 MySQL 再写 Redis 安全得多。原因是先扣 MySQL 时,第二步写 Redis 失败会让 MySQL 有库存而 Redis 没扣,后续请求继续在旧余额上下单,超卖就在这种场景发生。反过来先扣 Redis,即使落库慢或者失败,Redis 余额已经忠实下降,超卖这个底线先被守住了,落库滞后只是账面延迟,对账能修。优先保住不超卖这个底线,账面可以慢慢追,这个原则贯穿整个库存设计。

配置:Lua 脚本实现原子扣减

Lua 脚本是这一节的核心。脚本里 KEYS 是库存键和已购集合,ARGV 是活动场次、用户编号、扣减数量:

private const LUA_DEDUCT = <<<'LUA'
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then return -1 end                    -- 活动未预热
if stock < tonumber(ARGV[3]) then return 0 end        -- 库存不足
if redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 1 then return -2 end -- 重复购买
redis.call('DECRBY', KEYS[1], ARGV[3])
redis.call('SADD', KEYS[2], ARGV[2])
return 1
LUA;

PHP 侧调用就一行,返回值映射成业务语义,注意任何返回非 1 的情况都不要把用户放进排队队列:

public function tryDeduct(int $skuId, int $userId): int
{
    $key = "seckill:stock:{$skuId}";
    $bought = "seckill:bought:{$skuId}";
    return (int) Redis::eval(self::LUA_DEDUCT, 2, $key, $bought, $skuId, $userId, 1);
}

扣减成功之后,订单创建走异步队列,库存落库消息跟着一起发出去,消费者拿到消息后更新 MySQL,整个过程对用户完全透明,前端只需要轮询订单状态:

// 扣减成功后发送异步消息
DeductStockJob::dispatch($skuId, $userId, $orderId)->onQueue('stock');

整条扣减链路的耗时拆解我们也做过量化,贴出来供参考。压测数据表明 Redis Lua 扣减本身只占整个下单链路的一小部分,大头在序列化与网络往返上,所以优化方向从来不是把扣减脚本压到极致,而是减少链路跳数:

环节 平均耗时 优化空间
Nginx 转发 2 毫秒 已经很快
Laravel 启动与中间件 15 毫秒 OPcache 与精简中间件
Lua 扣减(含网络) 3 毫秒 已接近极限
队列投递 5 毫秒 批量投递可再降
用户侧总响应 25~40 毫秒 满足秒杀要求

优化要有数据支撑,这张表让我们的精力集中在 Laravel 启动那一环,而不是反复折腾 Redis,这也是 1.2 节所说先有数据再优化原则的一次实践。

踩坑:关于扣减的四个细节坑

坑一:Lua 脚本里没做重复购买判断。 第一版脚本只有扣库存逻辑,同一用户洪峰期间点了两次按钮,第二下在第一下的异步订单还没落库前又扣了一次,一个人抢两台。加上 SISMEMBER 判重后才堵住,但要注意集合判重必须在同一个脚本里,放在脚本外判就又有缝隙。另外前端按钮也要做防重处理,点击后立即置灰几秒,前后端两层一起挡才算稳。

坑二:扣减成功但异步消息发送失败。 Redis 里库存扣了,落库消息没发出去,两边数据开始漂移。解决办法是扣减脚本成功后,消息发送与 Redis 操作放在同一个 try 里,消息发送失败时用补偿任务回补 Redis 库存,而不是任由漂移扩大。

坑三:已购集合无限膨胀。 一场活动十万人参与,集合里存几万个用户编号占内存不小。我们的做法是活动结束后把集合导出到 MySQL 持久化,然后删除 Redis 键,为下一场腾出空间。

坑四:Redis 集群模式下的脚本兼容。 库存键和已购集合必须落在同一个哈希槽,否则 eval 直接报错。用 {sku_id} 作为哈希标签统一前缀就能解决,这个小细节在迁移到云数据库 Redis 集群版时才暴露出来。

坑五:Lua 脚本里读了过多的键。 有一次性能优化在脚本里追加了风控黑名单的判断,脚本访问的键超过十个,Redis 单线程执行脚本时间从 0.1 毫秒拉长到几毫秒,洪峰期直接拖垮整个实例。经验值是单个 Lua 脚本访问的键控制在五个以内,超过就把逻辑拆分或者挪到业务层。秒杀链路的每一个操作都要以毫秒为单位精打细算,这与 5.2 节监控大盘的耗时指标直接挂钩。

小结

库存扣减的答案是 Redis 原子扣减做主角、异步落库做配角、定时对账做保险。Lua 脚本保证判断与扣减没有缝隙,补偿任务保证消息发送失败能自愈。下一节我们看兜底层怎么用 MySQL 乐观锁把最后的一致性缺口焊死。顺带提一个数字供参考:切换到 Lua 原子扣减后,库存接口的 P99 延迟从四十毫秒降到十二毫秒,超卖工单在半年里只出现过一次,还是因为运营手工改库忘了对账,属于人祸而非系统问题。那次人祸之后我们也立了规矩:任何手工改库存的操作必须走审批流,并且改完立即触发一次全量对账,让人的动作同样被系统校验。技术可以把并发处理得天衣无缝,但兜不住绕开流程的手——把人也纳入系统校验,一致性才算闭环,这也是整套库存方案最后的拼图,也是面试新人时必问的一道开放题,答得上来的人说明真的理解了一致性。

延伸阅读