后端同学基本都撞见过这么个场景:活动一开始,库存明明只有 1000 件,后台订单数却蹭蹭涨到三千多。系统没崩,接口也都返回成功,仓库却压根拿不出那么多货。这个现象叫超卖,本质上是高并发场景下订单与库存两个链路没有协调好。这篇文章不堆概念,只讲一套我梳理过很多遍的处理方案:从数据库原子更新,到 Redis 预扣减,再到异步下单,以及围绕这些核心的对账、幂等、分布式锁和压测校准。每一步解决什么问题、引入什么新问题、怎么兜底,我会按方案演进顺序讲清楚,适合正在设计秒杀、抢购、限量发售这类系统的同学参考,也适合被超卖事故追着跑、想理清整套思路的团队。
1. 超卖到底是怎么发生的:解剖一个典型并发事故
1.1 先查后扣的经典写法为什么必然出问题
很多系统的库存代码一开始都长这样:
public boolean createOrder(ProductOrderDTO order) { Integer stock = inventoryMapper.selectStock(order.getProductId()); if (stock < order.getBuyCount()) { throw new StockNotEnoughException(); } inventoryMapper.decreaseStock(order.getProductId(), order.getBuyCount()); orderMapper.insert(order); return true; }这段代码的逻辑非常直白:先查库存,够就扣减,然后生成订单。问题是,查库存 -> 判断库存 -> 扣减库存这三个动作合在一起并不是一个原子操作。两个请求完全可能同时查到库存是 1,然后都认为自己有资格买,最后订单表里插进两条记录,库存却被扣成 -1。我把这个现象叫做“检查点和执行点之间的窗口期”,并发越大,这个窗口期被踩中的概率就越高。
很多人会问:数据库不是有事务吗?加了@Transactional难道还防不住?事务保证的是要么全部提交、要么全部回滚,但默认隔离级别下,普通的SELECT并不会把读到的行锁住。两个事务都能在对方提交前读到同一个库存值,然后再各自执行扣减和插入。事务管得住“一致性”,管不住“并发下的读取窗口”,这就是超卖最原始的成因。
1.2 单机加锁为什么拦不住多实例的并发
第一反应基本都是加锁,比如在createOrder方法上套一个synchronized或者 JVM 内部的Lock。单机部署的时候,锁确实能挡住进程内的并发请求,可现在的服务基本至少两三个实例起步,每台机器上的锁互相不通,请求被负载均衡分散到不同实例上,A 实例锁住了 B 实例的请求照样进来,库存该超卖还是超卖。
也有人说那就用分布式锁,让所有实例抢同一把 Redis 锁。这个思路方向是对的,但把它放到“每一次下单扣库存”这种高频路径上,并不是最优解。抢锁本身要消耗一次 Redis 请求,持有锁期间所有请求串行化,吞吐直接被打下来;更麻烦的是锁超时自动释放后,临界区里的业务还没执行完,后面的请求已经冲进来了。分布式锁有它的适用场景,后面我会专门讲,但先把话说在前面:解决超卖,第一步应该回到数据存储本身,让扣减操作在数据库这一层就具备原子性,而不是在应用代码里加各种锁。
2. 数据库原子扣减:先守住第一道防线
2.1 真正可靠的扣减 SQL 只有一条
先给出结论:在 MySQL 这类关系型数据库里,单商品扣库存最稳的写法不是先查后改,而是一条带条件的UPDATE:
UPDATE inventory SET stock = stock - #{buyCount} WHERE product_id = #{productId} AND stock >= #{buyCount};这条 SQL 执行的时候,InnoDB 会对命中的这一行加行锁。多个并发事务同时执行这条UPDATE时会排队,轮到第二个事务真正执行时,它拿到的已经是第一个事务提交后的最新库存。如果此时库存已经被扣到不足,WHERE条件里的stock >= #{buyCount}就不成立,更新影响行数为 0。应用层只需要判断影响行数:
int rows = inventoryMapper.deductStock(order.getProductId(), order.getBuyCount()); if (rows == 0) { throw new StockNotEnoughException(); }影响行数为 1,说明扣减成功,继续后面的订单创建;影响行数为 0,说明库存不足,直接返回“已抢光”。整个过程不需要先SELECT,更不需要SELECT ... FOR UPDATE。SELECT ... FOR UPDATE会把一行锁到事务结束,锁持有时间很长,并发吞吐最低;而带条件判断的UPDATE是加锁后立刻判断并更新,锁窗口小得多。这条 SQL 是整个库存方案的底座,后面的 Redis 预扣、对账回补,最后都要落回这个语义上。
2.2 事务边界、加锁顺序和连接池这三个坑
数据库原子扣减能解决超卖,但用不好会引出三个新问题。第一个是事务里的加锁顺序。订单创建通常会访问inventory和order两张表,所有请求都应该固定按照“先 inventory 后 order”的顺序访问。如果一些请求先扣库存再插订单,另一些请求先插订单再扣库存,两个事务就可能各自持有一把锁、又等着对方那把锁,形成死锁。多商品组合下单的时候,还要把所有商品的库存 key 按 ID 排序后再挨个加锁,同样是为了避免循环等待。
第二个坑是事务要尽量小。不要在数据库事务里调远程接口、不要sleep、更不要把 Redis 扣减包进数据库事务里。事务越大,行锁和数据库连接被持有的时间就越长,连接池很容易被拖垮。我见过一个项目,事务里调了个第三方风控接口,平时耗时 50 毫秒没什么感觉,大促时第三方一抖,数据库连接池直接被占满,整个下单接口全部超时。库存相关的事务里,只放必要的本地写操作。
第三个坑是连接池参数。即使事务很小,也要给数据库连接池留足余量,并且给连接设置合理的最大等待时间。活动峰值来临时,与其让请求无限排队等连接,不如快速失败返回“系统繁忙”,把压力挡在前面。
2.3 version 乐观锁为什么不是秒杀首选
除了条件更新,另一种常见方案是乐观锁,给inventory表加一个version字段:
UPDATE inventory SET stock = stock - #{buyCount}, version = version + 1 WHERE product_id = #{productId} AND version = #{oldVersion};影响行数为 0 就重试或直接失败。乐观锁在写冲突少的场景里非常优雅,比如后台人工调库存、低频的库存盘点。但秒杀这种场景,几乎所有请求都在抢同一行库存,乐观锁的第一次更新大概率失败,紧接着就是大量重试,重试的请求会再次打到数据库,形成重试风暴。相比之下,stock >= #{buyCount}这种条件扣减,失败就明确告诉用户“已抢光”,不用重试,数据库压力反而更可控。一句话:乐观锁适合冲突少的业务,秒杀正好相反。
3. Redis 前置扣减:真正扛住瞬时流量的那一步
3.1 数据库吞吐有上限,Redis 用来做名额预占
数据库原子更新解决了超卖问题,但解决不了吞吐问题。同一条库存记录不管来多少并发,最终都在行锁上排队,单行 UPDATE 的 TPS 大约在几千到一万这个量级,取决于机器配置和事务大小。而一场秒杀的瞬时流量经常是几十万 QPS,让数据库硬扛,等于让一个收银台去消化整条街的人流,结果必然是所有人都堵在门口。
Redis 的强项正好补在这里:单实例每秒能处理十万级以上的简单命令,操作纯内存,延迟稳定在亚毫秒级。所以常见的架构会把库存量提前预热到一个 Redis key 里,请求进系统后先在 Redis 扣减,扣成功的才算抢到名额,扣失败的当场返回“已售罄”。这一步只是“预占”,不是真正的账本修改,真正的库存台账和订单记录仍然走数据库。这样数据库承受的压力就从“几十万并发”变成了“Redis 扣减成功的那一小部分”,量级完全不同。
3.2 Lua 脚本:把“查+扣”做成一个原子动作
有人会想:既然 Redis 的DECR命令本身是原子的,那直接用DECR不行吗?DECR确实不会超卖,但它只能无脑减一,减成负数也不会拦住你。如果先用GET读库存再判断再DECR,中间又会插入其他请求,退回“检查点和执行点分离”的老问题。正确做法是把判断和扣减写进一个 Lua 脚本,让 Redis 把整段逻辑当成一个原子动作执行:
local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 end if stock < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1返回值语义:1 表示扣减成功,0 表示库存不足,-1 表示 key 不存在。因为 Lua 脚本在 Redis 内是串行执行、不会被打断的,所以“当前库存充足”和“库存被扣减”之间不可能插进别的请求,超卖从源头被堵死。
这里有两个容易踩的细节。第一,活动期间的库存 key 不要设太短的 TTL,最好设置成远大于活动时长的过期时间。否则 key 中途过期,所有请求都走到 -1 的返回值,用户会看到明明还有库存却下单失败。第二,如果脚本返回 -1,上层要能区分“活动没开始/缓存被清”和“真正没货”,必要时要触发库存缓存重建流程。对账和降级的逻辑就是为这些异常场景兜底的。
3.3 对账与降级:Redis 只是预占,不能当账本
Redis 是内存存储,AOF 没刷盘、主从切换、内存淘汰都可能导致数据丢失。所以 Redis 里的库存值只能作为“预占名额”,最终账本必须落在数据库上。为了兜住 Redis 和数据库之间可能出现的偏差,需要一个对账任务:定期按商品维度统计数据库里有效订单占用的库存总数,用“初始库存 - 已占用数”算出应剩余库存,再和 Redis 当前值对比,不一致就回写。
SELECT product_id, SUM(buy_count) FROM orders WHERE status IN ('WAIT_PAY', 'PAID') GROUP BY product_id;回写 Redis 的时候要小心并发。我习惯用一把粗粒度的分布式锁包住“读取数据库占用数 -> SET Redis 值”的整个过程,避免对账写入和用户扣减请求互相覆盖。对账不用跑太频繁,活动结束后的几分钟内能纠正过来就行,重点是把偏差捞出来,而不是实时纠正。
降级预案同样要提前写好。Redis 一旦抖动或者宕机,通过开关把流量直接切到数据库原子扣减。这个模式下 TPS 会明显下降,用户可能感觉到变慢,但至少不会超卖。数据正确性永远是第一位,体验问题可以事后补偿,超卖问题一出现就是事故。
4. 订单链路异步化:用队列削峰,用状态机兜底
4.1 同步下单链路为什么必然被峰值打穿
如果下单接口同步走完“Redis 扣减 -> 生成订单 -> 支付”整个链路,数据库的 TPS 上限就是整个系统的上限。活动开始那几秒,几十万请求同时打到订单表,数据库连接池瞬间被占满,接下去的请求开始排队,响应时间从几十毫秒涨到几秒,最后带着一堆超时错误一起雪崩。
异步化是解决这个问题的标准思路。下单接口只做三件事:校验参数、Redis 扣减库存、把一条订单消息投递到 MQ,然后立刻返回“下单成功,正在处理”。订单落库、库存台账、后续清结算全部由消费者异步完成。数据库负载从“瞬间并发几十万”变成“消费者可控的每秒几千笔”,这个落差就是队列削峰的意义。
这里面有一个必须处理的兜底:如果 Redis 扣减成功,但订单消息没投递成功,用户名额就等于被白白占了。解决方案是做本地消息表,在同一个数据库事务里写入订单消息记录,后台任务扫描消息表补投 MQ,投递成功后再标记状态。这样消息不丢,库存和订单最终一定能对上。
4.2 幂等消费:同样的消息不能生成两条订单
MQ 的消费语义通常是“至少一次”,消费者可能在处理过程中宕机、超时,然后重新收到同一条消息。如果没有幂等保护,一条消息被消费两次,订单表就多出一行,库存台账也被扣两次。最靠谱的防线是给订单表的业务订单号加唯一索引。流程是这样的:Redis 扣减成功后生成一个订单号,放进消息;消费者拿到消息直接执行INSERT;如果插入时撞了唯一索引,说明订单已经被创建过,直接确认消息,不再重复处理。先查再插的方式在极端并发下仍然有窗口期,唯一索引才是最终防线。
重复请求的问题也可以在这层解决。前端同一用户疯狂点击提交按钮,后端可以要求每次提交带一个幂等号,订单表把幂等号也纳入唯一约束;或者用用户维度锁把同一用户的并发下单串行化。做活动系统时这属于基本操作,否则一次大促下来,光重复订单就能让客服从早解释到晚。
4.3 超时关单与库存回补
用户下单后不支付,库存会一直被占用,必须要有超时关单机制。常见做法是 15 分钟或 30 分钟后自动关闭未支付订单并回补库存。最适合的实现是 MQ 的延迟消息:订单创建成功后投递一条延迟消息,延迟时间等于支付有效期;消费者收到消息后查订单状态,如果还是待支付,就把订单状态流转到已关闭并回补库存,如果已经支付就忽略。
回补库存同样要防重。订单状态只能用 CAS 方式从“待支付”改成“已关闭”,状态改成功的才执行UPDATE inventory SET stock = stock + 1,改不成功说明订单已经关过,直接跳过。没有这个保护,延迟消息重复投递或者重试机制重复触发,库存会被多回补一次。除了超时兜底,用户主动取消订单也要立刻走回补流程,不能等超时任务来收拾。
如果消息队列不支持延迟消息,也可以用 Redis 的 ZSET 做一个简易调度器,把订单号按支付截止时间戳写入 ZSET,定时任务每隔几秒取出到期成员逐个处理。这个方案能跑,但调度频率和数据库扫描压力要自己权衡,通常几秒一轮足够了。
5. 再往深走:分布式锁、限流和压测校准
5.1 分布式锁不该用在抢库存这种高频路径上
一聊到防超卖,很多人的第一反应是上分布式锁。我前面说过,抢库存这种高频路径并不适合分布式锁:每次下单都要先抢锁再释放,锁持有期间所有请求串行,吞吐反而被拉低;一旦锁超时自动释放,临界区里的业务还没做完,后面的请求照样冲进来,保护作用名存实亡。数据库行锁和 Redis Lua 脚本已经解决了扣减原子性问题,分布式锁不应该重复解决同一个问题。
分布式锁真正擅长的是低频、跨时长、需要互斥的关键操作,比如对账回写、热点缓存重建、定时任务防止多实例重复执行。实现上有一个特别容易写错的地方:释放锁之前必须校验持有者,否则可能把自己刚接管的锁误删掉。标准的释放逻辑是 Lua 脚本:
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end加锁用SET key value NX EX 10,value 必须是本次请求的唯一标识。锁的超时时间要留足业务执行余量,如果业务可能超过锁时长,还要考虑续期机制。线上出问题,十次有八次是释放锁时没校验 value,剩下的几次是锁时间设置得太短。
5.2 限流参数和压测数据的解读方式
库存前置只是保住了库存,整个下单接口还要限流保护。常规做法分两层:按用户维度限流,比如一个用户一个商品最多买 N 件,超了直接拒绝;按接口总 QPS 限流,防止瞬时流量把所有实例的线程池打满。限流参数不能拍脑袋,得靠压测校准。
我通常先在压测环境单独打数据库,测出单行库存更新的稳定 TPS,比如 5000。消费者线程数配置按数据库能力的 60%-80% 来,留出余量。网关入口限流可以放到消费者能力的 3-5 倍,多出来的流量进 MQ 排队,而不是直接打到下游。压测时重点看四个指标:超卖数量、下单接口成功率、数据库连接池等待时间、Redis 延迟曲线。最常见的四个问题和对策看下面这个表:
| 压测暴露的问题 | 可能原因 | 常见对策 |
|---|---|---|
| 超卖 | 扣减没走原子操作 | 用 UPDATE 条件扣减或 Redis Lua |
| 重复订单 | 消费重试没做幂等 | 订单号唯一索引 |
| 数据库连接池打满 | 事务太大或同步落库 | 异步化,缩小事务边界 |
| Redis 延迟抖动 | 热点 key 或大 key | 拆分 key,预热,熔断降级 |
这套压测数据本质上是在做容量规划。数据库吞吐、消费者速率、入口限流三者必须形成一个闭环:限流挡掉超出的流量,队列缓冲瞬时压力,消费者按数据库能承受的速率慢慢消费。
5.3 故障演练:把最坏的情况提前打一遍
方案写得再漂亮,没演练过等于没方案。我建议把三个故障场景做成常态化演练。第一个是 Redis 宕机:验证降级开关能不能在 30 秒内切到数据库扣减模式,切过去后接口是否仍然可用,用户是否会看到明显错误。第二个是 MQ 故障:验证消息积压后,服务恢复时消费者能否追平积压,订单状态是否靠补偿任务兜住,会不会出现用户下完单却一直看不到订单的情况。第三个是数据库慢查询:验证连接池等待时间、队列积压是否在可接受范围,限流能多大程度快速止血。
每个演练都要有明确指标:超卖数必须是 0,订单丢失数要在可接受范围,恢复时间要有一个公认的 SLO。没有量化指标的演练只是表演。演练完要出复盘记录,至少要写清楚触发条件、影响链条、恢复动作和后续改进项,否则下次故障还是会手忙脚乱。
6. 方案边界与演进顺序:别把锤子当万能钥匙
6.1 不是所有系统都需要 Redis 前置
整套方案做下来确实能扛住大流量,但成本也不少:Redis、MQ、对账任务、故障演练,每一项都需要有人维护。如果业务日订单量只有几万,峰值 QPS 几百,数据库原子更新加订单号唯一索引完全够用,强行上 Redis 和 MQ 反而多出两个不稳定因素。方案是为场景服务的,不是越复杂越好。
还有一个中间态:只做 Redis 预扣,不异步化。如果下单业务本身不重,订单表写入能力足够,Redis 扣完直接同步落库也能撑住中等量级的抢购。异步化的收益只有在下游数据库成为瓶颈时才明显,不要为了架构上好看而提前引入。分库分表就更往后了,那是订单量到亿级之后的话题。秒杀这种单商品热点,数据库分片解决不了单行热点,热点照样要靠 Redis 前置来吸收。
6.2 我建议的演进顺序
如果让我给一个团队设计路线,我会按四个阶段走。第一阶段先把数据库原子扣减、订单号唯一索引、订单状态机做好,这一阶段能解决 90% 的超卖和重复订单问题,工作量不大,收益非常直接。第二阶段加 Redis 预扣减和降级开关,解决瞬时峰值吞吐的问题。第三阶段把下单链路异步化,引入 MQ、延迟队列、本地消息表和对账任务,解决数据库写放大和超时关单的问题。第四阶段才是限流、容量规划、故障演练这些保障手段。
每走一步,先确认上一阶段的问题是不是已经被解决。我见过太多项目一上来就搭 Redis、MQ、分布式锁的豪华架子,最后反而死在一个最普通的消费重复上。架构演进永远跟着问题走,跟着流量走,而不是跟着概念走。
方案不在多,在每一层都有兜底。数据库管最终一致性,Redis 管峰值吞吐,MQ 管削峰和异步,对账管意外偏差,故障演练管手忙脚乱。如果让我重做一遍,我会先把第一阶段做扎实再往上走——高并发方案从来不是选最贵的组件,而是让每个组件出现在它该出现的位置。