前几天晚上收到一条消息:某促销活动上线十分钟,后台库存显示 -3,运营催着赶紧关接口。做电商或者任何涉及库存业务的同学,应该都对超卖这个词不陌生。高并发的库存抢购超卖问题,本质上是大量请求同时进入下单链路时,扣减动作没有做到原子性,同一份库存被多个订单同时占用。很多人第一反应是加服务器、加缓存,实际上绝大多数超卖事故都出在代码链路里少了关键防护。这篇文章我会把数据库扣减、Redis Lua 原子扣减、订单超时回滚、异步持久化这几条线的实战经验完整拆一遍,适合正在做秒杀和限量购的同学参考,文末也会把我实际踩过的坑一起列出来。
1. 超卖问题的根源:先还原一次典型事故
1.1 最典型的错误写法是先查再扣
很多团队的第一版下单接口通常长这样:先查数据库中剩余库存,然后在业务代码里判断库存大于零,最后再执行一条扣减 SQL。单独看这段逻辑没什么问题,问题是并发场景下,200 个线程可能都在第一步读到了剩余 1,判断和 update 之间没有加锁,所有请求都不会排队,最后库存自然被减成负数。超卖发生的那一刻,往往不是哪台机器变慢了,而是“读库存”和“扣库存”被拆成了两个步骤,两个步骤之间产生了竞争窗口。这个窗口在低流量时几乎不存在,流量一冲起来就会被无限放大。
1.2 扣减动作的原子性才是关键
可以拿超市排队结账做个类比:只有一个收银台,大家依次结账,就不会出现两个人买到同一件商品的问题。如果通道不排队,几个窗口同时声称自己拿到了最后一件商品,库存就超卖。数据库本质上也是这样,单条 update 往往是原子的,但“先查再改”的组合操作如果没有锁机制保护,等于把收银台拆成了两个独立柜台,看起来都有服务能力,实际上谁都没有资格给库存做最终决策。所以超卖的根因是扣减动作缺少原子性,而不是机器的并发能力不够。
注意:不是加了事务注解就能解决。默认的查询语句在多数隔离级别下并不会锁住刚读到的记录,更不能阻止其他请求同时读到同一行数据。事务只能保证自身操作的原子性,无法替代锁机制来保护并发写入。
1.3 限流并不能替代库存防护
有同学觉得超卖无非是流量太大,直接在入口限制并发,比如只允许 100 个请求进入下单接口,库存 50 是不是就安全了?限流只能控制请求总量,无法控制真正通过的用户里有几个会产生订单。即使只有 60 个请求进来,50 个库存也可能被这 60 个请求同时读到,照样超卖。限流保护后端不被打垮没问题,但资源分配的一致性必须靠底层的扣减方案来保证。换句话说,库存防护和流量控制是两个维度,各自都要做,不能互相替代。
2. 数据库扣减方案:从条件更新到行锁
2.1 条件更新 SQL 是最基础的防超卖手段
先把最简单的方案说清楚:不要先查库存,直接把扣减条件放进 update。写出来的效果是这样:
update sku_stock set stock = stock - 1 where sku_id = 1001 and stock > 0;如果返回受影响行数为 1,说明扣减成功,库存一定没有变成负数;如果返回 0,说明剩余库存不足,可以给用户返回已售罄。MySQL 在执行 update 时本身会加行锁,这一条语句从读到改到写是原子完成的,其他事务想同时改同一行库存就必须排队。这个方案实现成本最低,很多业务量不算特别夸张的电商系统其实完全够用。
有个容易忽略的细节:stock = stock - 1这种写法不能改成先把 stock 查出来,再set stock = oldValue - 1回写,否则又回到 select/update 分离的老路。正确写法必须让库存条件直接落在 where 里,让数据库自己判断剩余量。我见过有人把条件写在 ORM 实体属性判断里,写出来跟下面这种差不多:
Stock stock = stockMapper.selectById(skuId); if (stock.getStock() > 0) { stock.setStock(stock.getStock() - 1); stockMapper.updateById(stock); }这种做法无论外面包没包事务,并发一上来必然出问题,因为两个请求可能同时读到 stock = 1。
2.2 乐观锁版本号的实际用法
有些团队希望更新时能明确知道数据是否被别人改过,这时候可以用版本号方案。给库存表加一个 version 字段,每次扣减把当前版本号带进 where 条件:
update sku_stock set stock = stock - 1, version = version + 1 where sku_id = 1001 and stock > 0 and version = 1;执行成功说明版本号没有被其他请求改过;执行失败就重新查一次最新版本再尝试。这个方案的好处是业务层能看到冲突,可以根据冲突次数决定重试还是放弃;坏处是并发高的时候大量请求在重复执行“查询-更新-再查询”,数据库压力并不小。乐观锁适合读多写少、冲突概率低的场景,真正的秒杀流量通常需要更重的防护手段,不会把整个链路压在版本号重试上。
2.3 悲观锁 FOR UPDATE 的适用边界
悲观锁的思路在事务里先把行锁住,其他事务必须等当前事务提交后才能继续执行。代码如下:
select stock from sku_stock where sku_id = 1001 for update;由于 for update 已经锁定了这行记录,其他请求的相同查询会阻塞在当前事务提交之前,此时再去判断 stock 并执行 update 就不会出现并发冲突。这个方案的缺点是吞吐量被锁串行化,能下多少单完全取决于数据库能处理多少行锁排队。如果热点商品就是一行库存,周围流量全部堵在锁上,TPS 会很难看。实际项目中,悲观锁更适合后台运营工具使用,因为后台操作频率低、一致性要求高,而秒杀入口一般不做这种笨重方案。
2.4 数据库方案怎么选
我把这几种数据库方案放在一起对比,方便根据业务场景判断:
| 扣减方案 | 并发支撑 | 防超卖效果 | 主要代价 | 常用场景 |
|---|---|---|---|---|
| 条件更新 | 中等 | 强 | 接口被数据库瓶颈限制 | 小型活动、日常库存 |
| 乐观锁版本号 | 中等 | 强 | 高并发下重试多 | 冲突率低的场景 |
| 悲观锁 FOR UPDATE | 较低 | 很强 | 行锁排队,吞吐受限 | 后台运营、管理端 |
| 不做防护 | 低 | 无 | 必然超卖 | 只适合纯测试 |
3. Redis 层扣减方案:把库存前置到内存
3.1 为什么必须引入 Redis 库存
数据库方案虽然能防超卖,但抢购场景的峰值请求太高,比如一件爆款开售,10 万请求同时到达,如果每次都穿透到 MySQL,数据库连接数会瞬间被打满,CPU 持续飙高甚至把其他业务拖垮。把库存前置到 Redis 的思路是:热点库存放在内存里,利用 Redis 单线程模型保证原子执行,内存操作比数据库快几个数量级,能扛住高并发。但 Redis 只是挡在前面的缓存,最终记录还是要落回数据库,所以链路设计上必须考虑最终一致性,不能把 Redis 当成唯一的账本。
3.2 单独用 DECR 不够安全
不少人听说 Redis 有 DECR 命令,就直接在接口里写:先执行DECR sku:stock:1001,返回负数就说明超卖,再执行INCR补回去。这个思路单独看还算通顺,但补回操作发生在业务代码里,假设请求 A 扣成 0,请求 B 又扣成 -1,此时 A 才发起 INCR 回补,库存又变成 0,可 A 并没有真正扣到库存,总量就不对了。更麻烦的是,如果服务在 DECR 之后、INCR 之前宕机,Redis 里就会留下一个负数,相当于真实库存被多扣了。所以稳妥做法是让判断和扣减在 Redis 端一步完成,也就是用 Lua 脚本。
3.3 Lua 脚本保证判断和扣减的原子性
看一个最常用的扣减脚本:
local key = KEYS[1] local stock = tonumber(redis.call("get", key)) if stock == nil then return -1 end if stock > 0 then redis.call("decr", key) return 1 else return 0 end调用时把sku:stock:1001作为 KEYS 传进去,脚本返回 1 表示扣减成功,返回 0 表示库存不足,返回 -1 表示 Redis 中还没有对应 key,需要先初始化。因为 Redis 会把整个 Lua 脚本作为原子操作执行,其他命令在脚本运行期间不会插入,所以判断和扣减合在一起,就不会出现并发超卖。实际项目中建议把商品购买数量作为 ARGV 传进去,做成一次可以扣减多件的通用脚本,而不是只能扣 1 件。
如果需要更复杂的占用库存模型,脚本里可以维护两个 key:一个记录可用库存,一个记录已占用库存。下单时扣减可用库存并增加占用数,支付成功后再把占用数转成已售数量,这种设计在预约购买和预售场景里很有用。
3.4 热点 key 和库存分桶的取舍
设计 Redis key 时要考虑热点 key 问题。Redis 虽然快,但如果某个 sku 的请求量特别巨大,实例的单线程 CPU 会集中在同一个 key 上,同样可能成为瓶颈。有些团队会把库存切分成多个桶,比如把 1000 件库存拆成 10 个 key,每 key 100 件,请求根据用户标识路由到不同桶去扣减,进一步分散热点。分桶之后要处理单桶扣完的情况,比如从其他桶转移库存,复杂度会明显上升。我建议业务量还没有大到 Redis 单 key 成瓶颈时,不要急着做分桶,先把整个链路跑稳更重要。
4. 订单、库存与回滚全链路:防超卖只是第一步
4.1 下单流程的整体状态设计
解决超卖不能只看扣减那一刻,还要看订单创建后没支付、坏单、取消等情况怎么处理。通常的完整流程是:
- 用户提交下单请求,先过限流、防重、风控。
- 请求 Redis Lua 脚本扣减库存。
- 扣减成功后创建订单,订单状态为待支付或者已占用库存。
- 用户支付成功后,把订单状态改为已支付,同时异步把扣减结果落库到数据库库存表。
- 订单超时未支付,关闭订单并回补 Redis 库存。
这里有个关键点:Redis 扣减成功之后,要立刻落库订单,而不是先异步落订单。订单是库存占用的凭证,负责记录一个可审计的数据,如果订单缺失,后面对账根本无从查起。为了应对 Redis 扣减成功但下单接口异常的情况,还需要在异常分支做 Redis 库存回补,并在订单表加一个唯一约束或业务幂等 ID,避免用户重复点击产生多条有效订单。
4.2 同步扣减和异步扣减怎么选
扣减时机大致分两种。同步扣减是用户提交请求的瞬间,把 Redis 和数据库库存一起扣掉,链路简单、数据一致性好,但数据库压力会跟随峰值波动,在高并发场景下容易出现数据库打满的风险。异步扣减是 Redis 先扣,订单创建成功后投放一条消息,由消费端把 Redis 扣减结果同步到 MySQL。异步的好处是主下单链路不碰数据库,能扛更高的并发;坏处是 MySQL 库存存在短暂延迟,对账时得接受“最终一致”的状态。我个人比较推荐线上秒杀优先考虑异步,因为前端看到的抢购结果由 Redis 决定,MySQL 更多是后台统计和发货依据,允许几秒内的同步延迟。
4.3 超时回补必须幂等
超时回补是库存事故的高发区域。很多人只记得订单超时关闭时执行 INCR,却忘了数据库订单取消和手动退款也可能触发回补,同一个订单被补偿两次,Redis 库存越补越多;也可能回补任务并发产生重复消息,最终库存比初始值还高,用户反而抢不到。实际设计必须保证回补动作幂等:在订单状态流转表里记录回补事件,或者给订单增加“库存是否归还”的标志位,回补前先检查这个标志。归还库存时建议先更新订单状态,再 INCR 库存,通过本地事务或者消息任务组件保证事件不丢,同时用消费端的幂等逻辑去除重复消息。
4.4 数据库与 Redis 库存对账别省略
有些人上线 Redis 扣减后,发现 MySQL 库存和 Redis 库存对不上,开始怀疑方案有问题。其实 Redis 库存管“可抢数量”,MySQL 库存管“账实库存”,两者本来就不是同一时刻的镜像。关键是要定好同步口径:Redis 扣减成功且订单创建成功时,发消息去扣 MySQL;订单取消时,发消息去回补 MySQL。为了保证数据不越偏越远,每天还要跑定时对账任务,把一段时间内的订单扣减明细和库存流水做比对,把差异捞出来告警。对账是整个库存方案的兜底,前面链路如果出现细微纰漏,最后全靠这一步发现问题。
5. 我实际踩过的坑和排查经验
5.1 数据库表结构和索引带来的隐患
库存表设计上,stock 字段建议用 int 或 bigint,不要用短整型,否则高并发长期扣减回补后可能出现溢出。条件更新 SQL 的 where 条件最好能精确定位到单行,比如给 sku_id 建索引,避免全表扫描。热点秒杀场景下,如果每次扣减都全表扫,行锁排队会拖着整个库一起变慢。这里不是要求索引设计得多花哨,而是要注意扣减语句本身能不能快速定位到目标行,这是很多人忽略的性能细节。
5.2 Lua 脚本加载和发布的问题
通过 Redis 客户端调用 Lua 时,如果每个请求都发送一串完整脚本字符串,Redis 的网络带宽和 CPU 会白白浪费。比较好的方式是把脚本注册成 SHA,线上用 EVALSHA 调用,只传哈希值,压测的时候这两者差距非常明显。脚本上线也有坑:如果做了版本管理,某天改动了扣减逻辑,但 Redis 里的老 SHA 还被旧代码引用,就会出现新旧两套逻辑并存的情况。建议发布时先注册新脚本,等旧代码全部下线后再清理旧 SHA,避免线上闪断。
5.3 Redis 连接池大小要压测调优
库存扣减链路如果没有连接池,直接新建连接打 Redis,压测时基本都会报连接数异常。连接池太小,请求都排在等待队列里拿不到连接,接口超时;连接池太大,Redis 单实例的 CPU 又被不必要的空闲连接占用。建议压测前先做基准测试,分别测 10 并发、50 并发、100 并发,找到满足业务响应时间的连接池大小,不要一上来就最大连接数跑。线上 Redis 出现毛刺时,先看连接池的等待时间,这个指标往往最能说明问题。
5.4 超卖问题排查速查表
| 现象 | 重点排查方向 | 处理建议 |
|---|---|---|
| 订单数大于库存 | 是否先查再扣 | 改成条件更新或 Lua 扣减 |
| 库存出现负数 | SELECT 未加锁或回补有误 | 在扣减点加原子防护 |
| 订单取消后库存越补越多 | 回补重复触发 | 加幂等状态和唯一事件 |
| Redis 有库存但下单售罄 | 初始化失败或 key 过期 | 检查 Lua 返回值和初始化任务 |
| MySQL 有库存但 Redis 无库存 | 初始化时未同步 | 增加定时对账任务 |
5.5 上线前一定要做回归实验
我个人的习惯是每次改动库存链路上线前,先起一个模拟抢购脚本,用 200 个并发请求打 100 个库存,断言最后订单数不超过 100。这个实验能快速验证防超卖能力。如果改造用了 Redis Lua,还要在脚本里模拟下单失败回退,确保 Redis 库存只扣不回的问题能被及时暴露。做完这些,心里才有底,不然活动一冲量,后台立刻变成负数的事故现场就又得重演一遍。
最后再多说一句,库存方案并不是越复杂越好。我见过不少团队把 Redis Lua 当成银弹,结果忽略了订单回补和对账,上线照样出事故。正确的顺序应该是先把数据库条件更新跑通,命中了真实瓶颈再加 Redis,然后把回滚幂等链路做完整。不少时候,最基础的方案反而最不容易出错。如果你正准备做下一场抢购活动,可以把我前面列的这套检查思路当清单过一遍,至少能让超卖这个老问题在你这儿少踩几个坑。