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

发布时间:2026/10/21 4:56:19

高并发下单防超卖:从数据库原子扣减到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 这类关系型数据库里单商品扣库存最稳的写法不是先查后改而是一条带条件的UPDATEUPDATE 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 10value 必须是本次请求的唯一标识。锁的超时时间要留足业务执行余量如果业务可能超过锁时长还要考虑续期机制。线上出问题十次有八次是释放锁时没校验 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 管削峰和异步对账管意外偏差故障演练管手忙脚乱。如果让我重做一遍我会先把第一阶段做扎实再往上走——高并发方案从来不是选最贵的组件而是让每个组件出现在它该出现的位置。
延伸阅读

更多相关文章

2026/10/21 4:56:19

Linux下用ffmpeg和tree批量提取视频时长的实战方案

1. 项目概述:为什么需要“遍历子目录批量获取视频时长”这个动作在日常运维、媒体资产整理、课程资源归档、短视频素材库管理等实际场景中,我经常遇到一个看似简单却极其耗时的问题:一个根目录下嵌套着十几层子目录,每层里散落着几…

2026/10/21 4:56:19

Java视频大文件切片上传全指南:跨平台兼容性与服务端设计

刚接手某个视频内容管理项目的上传模块优化时,我被吐槽最多的一句话是:Java后端怎么连个视频都收不全?那时候我们在做一个面向Windows、macOS、Linux多端用户的视频大文件上传功能,视频文件动辄1GB起步,测试同学在Wind…

2026/10/21 8:22:08

医学大模型入门:从通用LLM到懂医学的模型

“AI医疗”这个词,前两年还停留在新闻稿和融资PPT里,今年已经开始变成实实在在的招聘JD和项目立项书了。医学大模型也不再是少数前沿团队的研究课题,而是一个小白程序员也有机会参与的方向:智能导诊、病历结构化、医学问答、科普内…

2026/10/21 8:22:08

450元云服务器赠金实战:注册流程、计费策略与避坑指南

1. 这 450 元赠金到底能干嘛,先讲清楚刚听说"白山智算免费 450 赠金"的时候,我第一反应是:又一个注册送钱的活动?但实际摸了一圈下来发现,这个赠金跟部分平台那种"送你一堆用不上的代金券"完全不是…

2026/10/21 8:22:08

Fluent与Simpack联合仿真:多编组列车横风气动-动力学耦合分析

干列车仿真的朋友应该都遇到过这个尴尬:CFD算出来的压力云图五彩斑斓很漂亮,Simpack算出来的动力学响应曲线也挺顺滑,但你把两者的结果放在一起对账,就是怎么都对不上。问题出在哪?出在它们是两个孤岛——流场不知道车…

2026/10/21 8:22:08

深入解析 Jessibuca 播放器内核:FLV 拉流与解封装的全链路实践

1. 从“播放器内核”视角看 Jessibuca 的模块划分先交代一下背景。我接触 Jessibuca 这个项目比较早,当时团队需要做一套纯前端的实时流播放方案,不允许上插件、不允许装播放器客户端,还要能应付老旧的摄像头 RTSP 转发流。我们最初试过用 We…

2026/10/21 8:22:08

Vue面试核心梳理:响应式、虚拟DOM与组件通信实战解析

马上又要到跳槽季了,后台私信里问Vue面试题的人明显多起来。这篇东西本来是我给团队内部同学做面试辅导用的讲义,整理之后拿出来分享。我自己做前端这些年,既面过别人,也被别人面过,对于Vue面试题的套路、误区、以及面…

2026/10/21 8:17:06

气象数据分析全流程:数据清洗、可视化与机器学习预测实践

简介:气象分析(数据分析)压缩包是一份面向数据分析课程实验与初学者的完整素材,以中国天气网某城市天气数据为对象,通过爬虫代码抓取数据,生成雷达图、条形图并进行说明,覆盖数据采集、清洗、可…

2026/10/21 4:53:08

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/21 0:00:35

交通流仿真实战:IDM跟驰与MOBIL换道模型参数调优与避坑指南

简介:这份资源是面向交通工程学习者与Python开发者的微观交通流仿真项目,核心实现智能驾驶模型(IDM)下的车辆跟驰与换道行为,可用于理解速度、密度与流量之间的动态关系,适合具备Python基础、希望深入交通模…

2026/10/21 0:00:35

揭穿智能体表面顺从:用追踪代码重建任务执行确定性

1. 表面顺从不是智能体的“礼貌”,而是任务执行链路上的隐性断点最近在调试一个跨平台长周期任务调度系统时,我反复遇到一种令人困惑的现象:智能体在用户指令发出后,几乎秒级响应“已收到”“正在处理”“请稍候”,UI上…

2026/10/21 0:00:35

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事:AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里,按宣传口径,本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”,而是这话背后的含金量——它把过去只能…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑