news 2026/10/21 4:56:18

高并发下单防超卖:从数据库原子扣减到Redis预扣减的完整方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高并发下单防超卖:从数据库原子扣减到Redis预扣减的完整方案

后端同学基本都撞见过这么个场景:活动一开始,库存明明只有 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 管削峰和异步,对账管意外偏差,故障演练管手忙脚乱。如果让我重做一遍,我会先把第一阶段做扎实再往上走——高并发方案从来不是选最贵的组件,而是让每个组件出现在它该出现的位置。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 4:56:02

AI视频生成提速50%:采样步数压缩与注意力优化实战

真实的视频生成痛点&#xff0c;终于有人正面刚了AI视频生成这两年火得一塌糊涂&#xff0c;但用过的人都有一个共同感受&#xff1a;生成一条几秒钟的视频片段&#xff0c;动辄等上几分钟甚至更久&#xff0c;而且算力成本高得离谱。模型在云端跑一条1080P的视频&#xff0c;等…

作者头像 李华
网站建设 2026/10/21 4:53:49

Python模糊测试落地指南:突破隐性壁垒,从Hypothesis到Atheris

前两天在技术社区里看到一个帖子&#xff0c;问题特别直接&#xff1a;Python写了这么多年业务代码&#xff0c;为什么身边团队几乎没人正经跑模糊测试&#xff1f;回复里有一半说是性能问题&#xff0c;一半说生态里没有趁手的工具&#xff0c;还有人觉得Python有类型标注就不…

作者头像 李华
网站建设 2026/10/21 4:53:49

gem5 ISA DSL入门:指令定义与自动代码生成详解

1. 项目概述&#xff1a;这不是语法书&#xff0c;而是一把打开处理器模拟世界大门的钥匙如果你刚接触gem5&#xff0c;看到“ISA DSL”这四个字母&#xff0c;第一反应可能是皱眉——DSL&#xff08;Domain-Specific Language&#xff09;本就带着点学术距离感&#xff0c;再叠…

作者头像 李华
网站建设 2026/10/21 4:53:48

gem5 ISA DSL语法详解:声明式定义指令集的核心方法

1. 项目概述&#xff1a;这不是“写代码”&#xff0c;而是给CPU设计语言的语法手册你打开gem5源码&#xff0c;看到src/arch/arm/isa/decoder.isa里一堆类似inst("ADD", "00011000", ...)的调用&#xff1b;翻到src/arch/x86/isa/insts/arith.isa&#xf…

作者头像 李华
网站建设 2026/10/21 4:52:13

iPad协议859低版本兼容方案:静态库绕过iOS系统握手限制

简介&#xff1a;本资源是面向微信生态开发者与iOS协议调试工程师的iPad协议859低版本兼容性修复部署包&#xff0c;聚焦解决旧版iPad设备&#xff08;如iPad 8059&#xff09;在调用微信相关接口时因协议变更导致的验证码绕过失效、登录异常及心跳不稳定等核心问题。压缩包为2…

作者头像 李华
网站建设 2026/10/21 4:51:57

把零散技能变成个人竞争力:一套可落地的技能管理体系

个人技能库的搭建设想&#xff0c;最早是源于一次很普通的面试。对方问了一句“你擅长什么”&#xff0c;我脑子里瞬间闪过很多东西&#xff0c;但真要往纸上写的时候&#xff0c;却发现自己根本说不清楚。会的东西零零散散&#xff0c;学过的工具用不上&#xff0c;用过的技术…

作者头像 李华