资讯动态

秒杀系统设计从入门到实战:高并发削峰、Redis防超卖与MQ异步落库

发布时间:2026/10/5 13:28:13 来源:尧图企业网站定制
做秒杀系统这些年我最大的感受是这个系统最难的从来不是代码而是所有流量挤在同一秒打进系统时的那种窒息感。秒杀系统的核心关键词拆开无非是三个并发、库存、一致性。第一次接手秒杀项目时我以为设计一个复杂的微服务网格就能体现水平结果上线第一天就被现实教育了——真正的秒杀系统不是越复杂越好而是越简单越有效。这篇文章写给正在做秒杀、电商大促、抢单业务的后端开发和技术架构同学。我会从秒杀的业务特征讲起把常见的设计思路、防超卖方案、异步削峰、容量评估和线上踩坑经验一层层拆开尽量还原一个真实秒杀系统的设计过程。看完之后你会发现秒杀系统其实是一套“做减法”的系统减得越彻底活下来的概率越大。1. 秒杀系统到底在解决什么问题1.1 秒杀不只是“抢得快”而是瞬间流量冲击很多人觉得秒杀就是“用户点按钮系统扣库存再回订单”听起来很简单但真实场景里问题完全不是这样。比如一个商品只放1万件开卖前挂出去页面访问量可能积累到几百万真正开售那一瞬间几十万的点击会在1到2秒内同时打到服务器上。这就像一家餐馆只有50个座位门口突然来了1万个人同时往里面冲。餐馆要做的事不是把所有人都请进去而是用最快的速度确认前50个人有资格进店然后把剩下的人安排在门外排队或直接告知“售罄”。秒杀系统的本质就是让系统以最小的代价快速筛选出“有资格成交”的那批请求而不是让所有请求都走到数据库里撞个你死我活。这里要特别说明一个概念平时大家听到的“高并发”比如高并发IM即时通讯消息的写入、AI Agent调用时的并发控制本质上是“大量请求持续不断、稳态地处理”考验的是吞吐和延迟。但秒杀的高并发是“尖峰脉冲式流量”瞬间流量可能是平时的几百上千倍然后几秒钟内又降下来。这两种场景的应对策略完全不同IM可以靠水平扩容慢慢消化秒杀必须在流量到达前就把大头挡在外面。1.2 躲不开的三大难题高并发、超卖、羊毛党秒杀系统绕不开三个核心问题网上相关热搜词里“并发数”“数据库并发锁”“nginx最大并发连接数老是用超”这些词频繁出现就是因为它们都在围绕这三件事打转。第一个是高并发。几万、几十万甚至上百万的请求在几秒内到达服务端要保证不被打垮。常见的瓶颈是nginx最大连接数、Java多线程和高并发下的线程池耗尽、微服务间调用超时还有数据库连接池被占满。这些问题不是调大一个参数就能解决的需要整套架构一层层做削峰。第二个是超卖。库存只有100件结果卖出去了120件这在业务上是重大事故。超卖的本质是“多个并发请求同时读到了库存为1然后都认为自己能扣减”。解决超卖必须保证库存扣减是原子操作同时对数据库并发锁的问题有清晰认识。我记得早年有个团队直接用MySQL的update stock set stockstock-1 where id? and stock0并发一上来数据库锁竞争和死锁直接拖垮整个库这就是典型的反面教材。第三个是羊毛党和恶意刷单。秒杀商品的差价太大机器脚本、代理IP、批量注册账号等手段层出不穷。如果不做风控就算系统扛住了流量真正买到商品的也全是专业“羊毛党”普通用户永远只能看到“已售罄”。所以秒杀系统设计时必须把风控作为一个独立的环节而不是最后补丁。1.3 从业务角度想清楚秒杀的目标是什么设计秒杀系统前产品经理往往希望在“开卖前聚人气、开卖时拉销量、开卖后做转化”但技术侧必须清醒一次秒杀的成败指标不是“用户量”而是“库存能否准确售罄”和“系统能否平稳可用”。这意味着我们并不追求“所有请求都能拿到结果”而是让系统在极高并发下选择有限个请求成功剩下的请求快速失败。因此秒杀系统的设计原则可以概括成四个词削峰、限流、异步、防超卖。削峰是把瞬时流量打散限流是主动拒绝超量请求异步是让下单核心链路不阻塞防超卖是保证库存与订单的一致性。这四个词会贯穿后面的所有设计决策。2. 设计秒杀系统前先想清楚这几件事2.1 先切一刀读写分离把所有“非必须”挡在前面我有一次和同事讨论秒杀架构他说“所有请求最终都要查询库存和生成订单那直接让请求查数据库不就行了”这句话听起来对但其实是坑。数据库能支持的并发读写是有上限的就算你用再好的分库分表两台MySQL能扛住的QPS也就在一两万左右离秒杀需要的几十万甚至百万QPS差得很远。秒杀系统里真正必须做的事其实只有两件判断库存够不够和记录谁抢到了。至于商品详情、用户画像、优惠计算、风控校验这些都是“可以在前面拦截掉”的逻辑。设计时一定要把流量区分开查询商品详情的流量走CDN和页面静态化不经过后端校验用户资格和风控放在网关或接入层真正能到达核心服务的只有“可能成交”的那一小撮请求。这个“切一刀”的思维非常关键。实际项目里我们经常在应用层加一段“本地缓存”来判断商品是否还在秒杀期不在直接返回“未开始/已结束”连Redis都不用打到。这就是最简单也最见效的削峰手段。2.2 分层架构与请求路径从流量入口到数据落库秒杀系统的标准分层我习惯分成五层客户端层H5、App、小程序。在这里做按钮置灰、倒计时、轮询结果。接入层Nginx、OpenResty、SLB。负责IP限流、UA校验、请求频率控制。应用层秒杀业务微服务。只暴露两个核心接口——checkAndDeduct预扣库存和createOrder异步建单。缓存层Redis。保存秒杀商品的库存、用户限购标记、幂等Token。数据层MySQL MQ。MQ先承接异步消息再把最终数据写入MySQL。一次完整的用户秒杀请求正常路径是这样的用户点击按钮 - 客户端先本地限频并生成请求签名 - 请求到Nginx网关层做流量染色和限流 - 应用层校验用户是否已购买、商品是否在秒杀窗口 - 应用层调用Redis Lua脚本原子扣减库存 - 扣减成功后生成一个消息投递到MQ - 消费者从MQ拉取消息在MySQL中创建订单并落库 - 用户通过轮询或消息推送获知结果。这个流程里请求到Redis为止返回“成功”的路径最慢也就几十毫秒而MySQL的落库是在后台异步进行的。用户不会感受到数据库的压力数据库也不会被瞬时流量打爆。这里要提醒一句同步链路里绝对不要等订单落库成功后再返回给用户否则数据库连接池大概率瞬间被占满整个系统雪崩。2.3 技术选型Redis、MQ、MySQL为什么是标配秒杀系统的技术栈看起来很简单但每一项选型背后都有原因的。Redis负责扛峰值。Redis单实例的QPS可以到10万以上而且支持Lua脚本原子操作适合做库存扣减这种“必须原子化”的动作。真实秒杀项目里Redis集群一般承担“瞬时判断”的职责所有的库存预扣都发生在Redis中这样可以把流量压力集中在内存层而不是磁盘数据库。MQ负责削峰填谷。Redis扣减成功后直接把建单消息发给MQ由消费者以稳定的速率去落库不管前端流量多猛MQ消费端都能根据自己的处理能力慢慢消化。这样即使MySQL只能支持每秒5000次写入消息队列也能把瞬间10万的量拉平成每秒几百上千系统才稳得住。Kafka、RocketMQ都行小项目用RabbitMQ也够关键是选一个支持持久化和消费幂等的。MySQL负责最终一致。Redis里的库存扣减是“预占”最终还是要以MySQL的订单和扣库为准。MySQL里需要创建订单表并且给“用户ID 商品ID 秒杀活动ID”建唯一索引防止同一用户重复下单给订单号建唯一索引防止MQ重复消费导致幂等失败。这三个组件缺一不可。有人问我“能不能不引入MQ直接异步线程池写库”如果你单机QPS只有几百线程池可行但如果秒杀峰值QPS过万线程池的队列一旦堆积内存占用会失控而且没有消息重试和持久化能力最终一致性根本得不到保障。所以在正规秒杀系统里MQ不是可选而是标配。2.4 并发控制数据库锁 vs Redis原子操作秒杀系统的核心动作是“扣库存”扣库存本质上是一个并发控制问题。数据库并发锁是最容易想到的方案比如悲观锁select for update或乐观锁update ... where stock 0但这两个方案在秒杀场景下都有明显缺陷。悲观锁的问题是锁等待。假设10000个并发请求同时来数据库会把它们排成一串每个事务都持有行锁其他事务等待响应时间被拉得很长还容易死锁。乐观锁虽然不用锁但是它是“先更新再判断影响行数”如果影响行数为0需要业务侧重试。在高并发下大量请求会发现扣减失败不断重试反而加剧数据库压力。我见过一个项目用乐观锁扛秒杀MySQL服务器CPU直接打满因为他们把重试逻辑写在了同步链路里。相比之下Redis Lua脚本是目前公认最优雅的方案。Lua脚本在Redis服务端执行整个过程是原子的多条命令不会被其他客户端插入。再加上Redis本身是单线程模型天然串行化不需要担心多线程并发写导致的超卖。所以现在几乎所有的秒杀系统库存扣减都放在Redis里。下面的章节我会给出一个可以直接用的脚本。3. 秒杀系统核心链路的设计与实现细节3.1 第一层削峰页面静态化、CDN、客户端限流秒杀开始前商品详情页的流量往往是核心服务压力的主要来源。用户不断刷新页面等待开始每次刷新都会打一个请求这个量比秒杀瞬间的点击量还要大。所以页面必须做静态化商品名称、价格、图片这些不经常变化的信息直接生成静态HTML放到CDN上。用户刷新时流量全部被CDN吸收根本进不了后端。到了客户端还需要做“前端限流”。App或H5在秒杀倒计时结束前按钮是置灰的倒计时结束后用户点击一次后续连续点击要在几秒内禁用或者只让第一次点击生效。这里有个小细节把“开始秒杀”的准确时间下发给客户端客户端提前500ms开放请求避免所有人在同一毫秒集中点击。经验值一般是300到800毫秒太早刷会撞上未开始太晚又会落后。网关层也要做一层简单的限流。Nginx可以用limit_req_zone按IP限制每秒请求数比如每个IP每秒最多2个秒杀请求。OpenResty可以写Lua脚本在网关层做更复杂的校验比如根据用户ID、设备指纹判断是否是机器。这些都可以在流量进入后端之前先砍掉一大半。3.2 库存扣减防超卖Redis Lua脚本实战这一步是整个秒杀系统的灵魂。我在生产环境常用的Lua脚本如下基于Redis的eval命令调用-- KEYS[1] 是商品库存的key比如 seckill:stock:1001 -- ARGV[1] 是扣减数量秒杀一般固定为1 local stock tonumber(redis.call(get, KEYS[1]) or -1) if stock 0 then return -1 -- 商品不存在或未预热 end if stock 0 then redis.call(decrby, KEYS[1], 1) return 1 -- 扣减成功 end return 0 -- 库存不足这个脚本的逻辑很简单核心优势是原子性。Redis保证这个脚本从头到尾执行期间不会有其他命令执行所以不会出现两个请求同时读到stock1的情况。调用的Java伪代码大概是这样String script 上面那段Lua脚本; ListString keys Collections.singletonList(seckill:stock: productId); ListString argv Collections.singletonList(1); Long result (Long) redisTemplate.execute( new DefaultRedisScript(script, Long.class), keys, argv); if (result 1) { // 扣减成功异步发送MQ消息 } else if (result 0) { // 库存不足直接返回“已售罄” } else { // 商品不存在一般说明库存key没有提前预热 }使用这个方案有几条实践经验要记住第一秒杀开始前必须把库存量提前写入Redis并且设置为活动结束时间一并过期。如果忘了预热所有请求都会返回-1效果等同“还没开始就售罄”这是很常见的上线事故。第二库存key不要用简单商品ID最好加上“活动ID”维度比如seckill:stock:{activityId}:{productId}这样可以支持一个商品参与多个不同活动。第三对于超热点商品单个Redis key的访问量会非常大可能触达单节点瓶颈后面我会单独讲热key保护。3.3 订单异步化与最终一致性Lua扣减成功不等于订单一定产生。我们需要把“扣库存成功”这个状态先返回给用户“抢到了”然后后台异步完成订单。这里有一个非常容易踩坑的点前面返回成功后面异步建单如果失败用户会投诉“明明抢到了却查不到订单”。通常的做法是这样扣减Redis库存成功后先给用户生成一个“秒杀成交令牌”或预占单号再把这个令牌和用户ID、商品ID、活动ID组成一条消息投递到MQ。MQ消费者收到消息后在MySQL中执行订单插入和库存扣减的最终校验事务提交后用户的订单状态变为“已支付/待支付”。为了保证最终一致性要做到两件事第一消息不能丢。Kafka或RocketMQ都开启持久化生产者发送消息时使用同步发送并等待确认消费者处理成功后手动确认offset或commit。一旦发现消息积压过多优先扩容消费者实例而不是丢弃旧消息。第二消费必须幂等。同一个用户抢购一次的MQ消息有可能因为网络抖动被重复投递。消费者在创建订单时利用“用户ID活动ID商品ID”的唯一索引挡住重复插入如果发现重复直接记录日志并跳过不再重复扣减数据库库存。这里还要设计一个对账补偿任务。比如每隔几分钟跑一次定时任务扫描Redis中已扣减但MySQL中还没创建订单的记录重新投递MQ或直接补单。如果数据库库存扣减失败比如商品被下架需要把Redis中的库存加回去并通过日志告警。这个补偿逻辑虽然不复杂但没做过对账的秒杀系统早晚会出财报对不上的事故。3.4 热key保护与系统防雪崩秒杀商品天然是“热key”因为大家都在抢同一个ID。如果Redis集群采用分片这个key只会落在某一个分片节点上所有请求都会压到一个节点的单线程里极易导致那个节点CPU飙高、访问延迟陡增甚至触发集群局部故障。热key问题在秒杀场景里非常常见尤其当秒杀商品数量很大时单个key的热度可能达到每秒几十万次访问单个Redis实例根本撑不住。应对热key的办法有几个。最简单的做法是本地缓存应用层先存一份商品库存的近似值比如剩多少件定期从Redis同步真正扣减库存时再走Redis但要容忍小幅度的“多卖”。更稳妥的做法是给key设计多副本比如把库存拆成16个分片keyseckill:stock:{productId}:{0~15}扣减时随机选择一个分片执行Lua脚本这样热点压力就分散到了多个Redis节点上。但注意拆分会带来“某个分片库存提前耗尽而其他分片还有库存”的问题需要把所有分片汇总或做加权随机选择复杂度略高。除了热key还要防“缓存击穿”。如果秒杀商品的库存key在活动期间突然过期大量请求会同时打到MySQL这就是缓存击穿。解决方法是给库存key设置合理的过期时间并在业务逻辑里判断“key不存在”时直接返回售罄而不是回源数据库。另外Redis可用性也必须保证秒杀时段建议开启Redis集群的高可用模式防止单节点宕机导致全盘不可用。我见过一次线上事故Redis主从切换期间秒杀接口报错率100%就是因为没有处理好故障期间的降级策略。4. 容量评估与压测方法4.1 怎么算并发量、QPS和机器数秒杀系统上线前最怕的不是没做秒杀功能而是“拍了脑袋”说我们系统能扛100万。容量评估必须算出来还要留出缓冲。我常用的估算方式是这样的假设某个秒杀活动预计参与用户数100万秒杀窗口是30秒商品库存1万件。用户不是均匀分布的通常前5秒是峰值假设前5秒内到达60%的流量也就是60万请求那么平均每秒到达的请求约12万峰值可能到15万到20万QPS。这里要记住秒杀的QPS估算不能拿总请求数除以总时长要用“峰值时间段”的请求数除以峰值时长否则会严重低估。有了预估QPS再估算每个环节的容量Nginx接入层单台Nginx的worker连接数大概能支撑几万并发连接但业务QPS受worker进程数和CPU影响。假如单机极限5万QPS每秒15万的请求至少需要3到4台接入层节点还要考虑单节点故障冗余所以备到5台。Redis集群单分片Lua脚本每秒大概能承载2到3万次事务取决于脚本复杂度和网络耗时如果用单key单个分片就会成为瓶颈所以要么分片要么引入多副本。如果峰值15万QPS的请求全都要扣库存Redis至少要准备5到8个分片。应用层服务假设单实例能扛2万QPS那么至少需要8个实例但实际秒杀应用还要做很多业务校验单实例QPS可能只有5000到8000所以建议先按1万预估然后通过压测实测。MQ和数据库数据库写入瓶颈往往在1万TPS以内如果每2秒只有1万个订单MySQL每秒只需要处理几百个插入基本没压力前提是有MQ削峰。这里给个小公式应用实例数 预估峰值QPS × (每个请求消耗的CPU时间 / 单个实例CPU可用时间) × 冗余系数。当然最终还是靠压测确定。4.2 全链路压测怎么做估算只是理论真正能不能扛住要用压测验证。常用的压测工具各有适用场景ab适合单接口快速压测wrk适合HTTP接口的高性能压测JMeter适合复杂场景和分布式压测Locust适合模拟真实用户行为。压测秒杀系统最推荐的是用wrk或JMeter做网关层压力用Locust模拟用户点击行为几种工具可以组合用。压测时一定不要只在单一服务上压要全链路压测。意思是请求从Nginx进经过应用层、Redis、MQ最终打到MySQL的完整链路都要压。很多项目只压应用层结果上线后才发现数据库连接池被占满就是因为数据库链路没有纳入压测范围。压测的关键指标看这几个接口成功率、P99响应时间、Redis CPU、MySQL连接数、MQ积压量。如果发现Redis CPU持续超过70%说明线性扩展不够如果MQ消息积压量持续增长说明消费能力不足如果应用层报连接池超时大概率是下游慢调用拖垮了。一个很实用的压测技巧压测时要从小并发开始逐步加大比如从100并发慢慢叠加到1万并发同时观察系统瓶颈曲线。我发现很多团队一上来就直接10万并发压测结果系统立刻雪崩根本分不清是哪一个环节先扛不住。正确做法是每加一档并发记录各层的指标变化找到第一个开始变慢的组件然后针对性地优化或扩容。5. 线上常见问题与排查手段秒杀系统上线后问题往往是五花八门的但大致可以归为下面几类。我把常见现象、原因和解决方案整理成一张速查表后面再挑两个典型案例详细复盘。常见现象可能原因排查思路与解决方案库存明明扣减成功订单却没生成MQ消息丢失或消费失败检查MQ积压和消费日志开启手动ack重试增加对账补偿任务商品显示售罄但后台库存还有剩余Redis库存key被提前误删或过期检查key的TTL和删除记录临时手动重置库存修改代码避免活动期间key过期同一用户生成多个订单消费端未做幂等增加唯一索引消费前判断订单是否已存在接口报连接池超时应用层线程池/数据库连接池耗尽查看监控确认是下游慢SQL还是流量超过上限增加核心线程数或削峰限流秒杀瞬间Redis CPU打满热key集中访问热key拆分、本地缓存、多副本读大量请求返回“系统错误”网关限流或熔断被触发查看限流日志调整阈值确保返回“已售罄”而不是“系统错误”数据库出现大量死锁悲观锁/更新库存的并发事务冲突改用Redis Lua扣库存MySQL只做账务记录5.1 一个典型的超卖事故复盘有次同事负责的秒杀上线后运营反馈“卖了1万件实际支付订单有1.03万”。我立刻查了Redis脚本发现脚本本身没有超卖问题问题出在MySQL的落库环节。原来他为了性能在MQ消费者里没有加“用户活动商品”的唯一索引只靠程序判断“查一下订单表再决定是否插入”。在高并发消费场景里两个消费者线程同时查询都发现没有订单然后同时插入于是超卖。后来加了唯一索引同时把插入操作改成insert ... on duplicate key update问题就解决了。这个案例告诉我们防超卖不是一个环节的事而是Redis和MySQL要双重保证。Redis保证“预占”不超卖MySQL用唯一索引保证“建单”不重复任何一层失守都可能出事故。5.2 消息堆积导致的咨询量暴增另一个常见事故是MQ积压。某个秒杀活动结束后业务方发现订单状态还停留在“待处理”用户大量投诉。我查看监控发现Kafka积压了几十万条消息原因是消费者服务的一个SQL查询走了慢查询单条消息处理时间从一个50毫秒拖到了3秒处理能力骤降。这里的关键是不要盲目重启消费者因为重启后积压消息还是会继续压过来。我们的处理办法是先把消费逻辑中的慢SQL切到只读从库减少锁竞争再把消费者临时扩容到原来的3倍同时调整消费组的分区分配让积压消息尽快消化。等积压清完之后再恢复原来的消费者数量。另外为了以后能更快发现积压要在监控上给“消费滞后量”设置告警超过阈值就短信通知。便宜的处理是一次性扩容更好的处理是给消息消费链路加一个“最长等待时间”的兜底超时后自动进入重试队列。5.3 恶意刷接口和批量薅羊毛的对抗秒杀系统上线后最大的敌人往往不是技术而是羊毛党。有些团队用IP限流结果羊毛党用代理池换IP有些团队用账号限购结果对方批量注册账号。我的经验是“多维度交叉验证”设备ID、手机号、收货地址、支付账户四个维度里要有至少一个能对上才算“可信用户”。网关层记录每个用户ID的请求频率超过每秒1次直接返回验证码应用层加入滑块验证或答题验证在秒杀高峰时自动启用。另外下单接口必须要求先通过秒杀接口拿到Token没有Token直接返回非法请求。Token用Redis存储有效期只有几秒同一个Token只能使用一次这样能挡住很大一部分直接调接口的机器脚本。要注意的是风控逻辑会增加接口延迟所以一定放在“扣库存”之前而且风控本身要高可用。我记得有一次风控服务挂了结果所有秒杀请求都被拒绝后来我们给风控加了弱依赖降级风控超时或不可用时先放行到限流队列由异步任务再做二次核验这样既不影响正常秒杀又能把风险降到可接受范围。最后说几句在秒杀系统上摸爬滚打这么久我个人最大的体会是架构设计不是把系统做得越复杂越好而是要想办法在“必须做的事”和“可以不做的事”之间划清界限。秒杀场景下绝大多数的流量都应该被提前过滤掉系统真正处理的只有极少数的核心成交请求。我见过很多团队把秒杀做成了一堆微服务互相调用反而因为分布式事务、网络延迟、服务雪崩这些问题把自己拖垮。如果你正在设计新的秒杀系统我建议先把“最简单可用的闭环”跑通Redis提前预热库存网关和服务端做限流Lua脚本原子扣库存MQ异步落库唯一索引防幂等。这套链路虽然朴素但经过了无数次大促验证是行业里公认的可靠方案。等这套闭环稳定了再逐步加风控、热key分片、对账补偿这些增强能力。最后再分享一个小技巧给秒杀接口的所有返回结果都设计成“可重试”的语义一个用户一次秒杀最多让他重试三次三次拿到相同结果就不再重试这样既能提升用户体验又能显著降低系统压力。希望这些经验能让你少走一些弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑