开场:秒杀系统到底考的是什么
各位做Java开发的朋友,如果你刷过面试题,一定见过“怎么设计一个秒杀系统”这种问题。有人觉得这是“八股文”,背几个Redis、MQ的关键词就算完。但真正把系统推向“万人同时抢购”这个量级,很多细节只有动手做过才会懂——比如库存怎么扣才不超卖、接口怎么扛住瞬时洪峰、Redis挂了怎么办、消息队列积压了又如何兜底。
这篇文章我想聊的不是概念堆砌,而是一套可以落地的设计方案。围绕“万人同时抢购”这个核心场景,我会讲清楚每个环节的设计思路、技术选型理由、核心代码实现,以及我在实际项目中踩过的坑。适合正在准备Java面试、或者准备动手做一个秒杀项目积累经验的开发者。看完之后,你对“高并发”“数据一致性”“削峰限流”这些词的感受,会上一个台阶。
1. 先搞清楚秒杀系统到底难在哪
1.1 万人同时点按钮,流量远超你想象
很多人误解了“万人同时抢购”这个场景。它并不是1万个人每秒钟发送一个请求,而是1万个人同时盯着屏幕,到点后疯狂点击、刷新、重试。常规情况下,单用户前几秒可能产生5到10个请求,如果其中有人写了脚本自动点击,这个数字会放大到几十上百。所以真实冲击到系统的峰值QPS(每秒请求数)常常是人数的一个数量级以上,也就是说,万人抢购的瞬时QPS可能达到10万甚至更高。
这个流量特征和日常业务完全不同。平时系统是“平坦的”,比如一天几十万请求分摊到24小时,每秒平均不过几个。秒杀是“脉冲式的”,一瞬间把流量拉高几十倍,之后又快速跌回谷底。如果按峰值流量长期扩容,成本太高,而且大部分资源在潮水退去后就闲置了。设计秒杀系统的核心任务,是让有限的资源扛住这个脉冲,而不是简单堆机器。
1.2 秒杀的本质矛盾:商品有限,流量无限
抛开各种技术名词,秒杀系统要解决的无非是三件事。
一是防超卖。100件库存,只能让100个人付款成功,第101个必须拿到“已售罄”的反馈。这是数据一致性的底线。
二是防击穿。热门商品就像一个瘦弱的磁盘,所有人同时读同一个库存值,缓存、数据库、甚至网络带宽都会被打爆。不加以拦截,系统会像一根被同时踩住的吸管,瞬间失去响应。
三是防重复与恶意请求。有人用脚本刷新、有人提前预埋请求、有人一次点击触发多次下单,这些都要在入口处拦截。判断一个秒杀系统是否成熟,看的不是“功能对不对”,而是它在极端流量下的行为是否可预期——是否稳定返回“成功”或“失败”,而不是卡死、超时、报500。
收藏一个核心观念:秒杀系统设计的终极目标,不是让每一条请求都被“处理”,而是让每一条请求都被“恰当回应”。大部分请求在入口就被拦截和拒绝,这是正常的,也是高效的。
2. 整体架构:漏斗式过滤,逐层削峰
2.1 漏斗模型:把流量一层层筛掉
面对脉冲流量,最有效的策略是把它做成一个漏斗——每一层只放过一部分流量,最终到达数据库的请求数,被压缩到系统能够轻松处理的量级。我设计的秒杀系统,从用户点击到下单成功,一共经历五层筛选。
第一层是前端拦截。秒杀页面的静态资源全部放CDN,用户点击后先经过前端JS限频(比如同一按钮5秒内只能点击一次),再配合图形验证码。这一层能过滤掉一部分手动重复点击,也能给后端争取缓冲时间。
第二层是网关限流。Nginx或API网关层面做IP维度限流,比如单IP每秒最多5个请求,超出直接返回“操作频繁”。这一层把脚本攻击和抓包重放挡在了系统外部。
第三层是应用层预扣减。请求进入业务系统后,先不直接操作数据库,而是通过Redis+Lua脚本对库存做预扣减。库存扣完的请求在这里就结束了,根本不会触达更深的服务。
第四层是异步削峰。预扣减成功的请求,把“秒杀资格”封装成消息丢进消息队列,后端订单服务按自己的节奏去消费。这就是所谓的削峰填谷——把瞬时的下单洪峰变成一条平稳的工作流。
第五层是数据库最终一致性扣减。订单真正落库时,商品服务用乐观锁或行锁再次校验库存,确认无误后才生成订单。这里是最底线的兜底,不允许超卖发生。
这个模型的本质,是把“写”操作的成本分摊到不同的时间与计算层级:CDN拦截重复静态请求,Redis拦掉无意义的库存试探,MQ消化处理峰值,数据库只在最后处理少量的真实有效写入。
2.2 技术选型:为什么是这些组件
先说Redis。秒杀场景下,库存数据必须在内存中操作,原因很简单:MySQL单机支持的事务读写能力通常在每秒几千次这个量级,而Redis单实例的纯内存操作可以轻松跑到每秒十万次级别。更重要的是,Redis配合Lua脚本后能原生保证“检查库存—扣减库存—返回结果”这个复合操作的原子性,这正是防超卖的关键能力。
再说消息队列。我这里以RabbitMQ为例,因为它功能足够、文档丰富、中小企业用得最广。选它能解耦秒杀请求和订单处理:高并发时期,订单服务不需要具备和秒杀峰值同等量级的处理能力,稍后慢慢消费消息队列中的下单消息即可。如果你面对的是更大规模场景,用Kafka也未尝不可,吞吐量更高,但运维复杂度也更大。
数据库选择上,MySQL依然是多数秒杀项目的主库。它负责保存商品最终库存和用户订单,是数据可靠性的最终承载者。
很多新手容易陷入一种误区:把所有功能都堆在Redis和MQ上,以为组件越多越高级。实际上,秒杀系统的每一层组件都是为特定问题服务的——Redis解决原子扣减,MQ解决削峰解耦,MySQL解决最终落账。缺了任何一环,系统要么数据不一致,要么直接被流量打垮。明确这个分工,比单纯背组件名词有价值得多。
2.3 工程落地:模块如何划分
用Maven多模块来组织工程,至少分出这几个模块:
seckill-gateway:网关服务,负责IP限流、参数校验;seckill-goods:商品服务,管理商品信息、库存预热、库存扣减;seckill-order:订单服务,消费秒杀消息,生成订单,维护订单状态;seckill-user:用户服务,负责登录、鉴权、用户维度限流;seckill-common:公共模块,放统一返回结果、异常处理、Redis/MQ配置工具。
模块拆分的目的就是为了各司其职:商品服务不关心订单怎么生成,订单服务也不关心库存具体怎么扣,通过消息队列进行通信。团队协作时,不同人负责不同模块,不会相互干扰,也方便做独立的故障隔离。
3. 核心功能:从库存预热到订单落库
3.1 库存预热:把数据提前塞进Redis
秒杀正式开始前,系统需要把商品库存从数据库加载到Redis中。这一步千万不能等用户点击时才去读数据库初始化,否则第一个请求就把数据库打爆了。我通常用定时任务或者后台管理触发预热,流程是:
- 查询数据库中商品的可售库存;
- 写入Redis,key为
seckill:stock:{skuId},value为库存数量; - 同时写入商品秒杀信息,如开始时间、结束时间、每个用户限购数量;
- 预热完成后,将商品状态标记为“可秒杀”。
这里有个细节:预热时的Redis库存值,需要和数据库库存对应好。如果数据库库存是100,Redis就写100,后续扣减时按1:1同步回写数据库。不要在预热环节做任何加价或缩放操作,不然后面对账会非常麻烦。
3.2 原子扣减:用Lua脚本解决超卖
Redis能源源不断扣减库存,是因为我们用了Lua脚本,而不是“读出来—减1—写回去”这种充满竞态条件的写法。下面这段脚本是秒杀扣减库存的核心:
-- KEYS[1] 是库存key,KEYS[2] 是已售key if tonumber(redis.call('get', KEYS[1])) <= 0 then return -1 end redis.call('decr', KEYS[1]) local sold = redis.call('incr', KEYS[2]) -- 库存扣减成功,返回当前已售数量 return sold调用时用Java的DefaultRedisScript:
String luaScript = "if tonumber(redis.call('get', KEYS[1])) <= 0 then return -1 " + "else redis.call('decr', KEYS[1]) " + "return redis.call('incr', KEYS[2]) end"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class); Long sold = redisTemplate.execute( redisScript, Arrays.asList("seckill:stock:" + skuId, "seckill:sold:" + skuId) ); if (sold == null || sold < 0) { // 库存不足 throw new BizException(500, "已抢光"); }脚本本身保证:库存判断和扣减在一个原子操作内完成,并发情况下不会出现两个请求同时读到库存为1、又同时扣减成功——因为单线程执行Lua脚本时,后到的请求只能在前一个请求完整执行后再判断。这也是所有Redis扣库存的主流方案。
补充一点,注意Redis的持久化策略。如果Redis重启,库存数据可能丢失,所以预热脚本和数据库库存对账是必须的。我建议在秒杀结束或Redis重启后,用一个补偿任务把Redis中的最终销量同步回MySQL,保证两边一致。
3.3 限流:用户维度与全局维度双管齐下
限流大致分两层:全局限流,防止系统整体过载;用户限流,防止单个人刷单。
全局维度我推荐令牌桶算法。一个形象的比喻:令牌桶像一个在规定速率下不断装入令牌的桶,每个请求必须先拿到一个令牌才能继续往下走,桶满了令牌就丢弃,桶空了请求就只能排队或被拒绝。它允许一定程度的突发流量,适合秒杀这种场景。用Redis实现一个简易令牌桶并不复杂,也可以用现成库如Guava的RateLimiter(单机)或Sentinel(分布式)。
用户维度更直接:每个用户在Redis中有一个计数器,比如seckill:user:limit:{userId}:{skuId},从点击秒杀开始,5秒内最多允许3次请求。超出后返回“操作过于频繁”。这样一来,脚本批量点击会被挡住,正常用户点错一两次也不受影响。
给出一段简单的Redis计数器限流代码:
String key = "seckill:user:limit:" + userId + ":" + skuId; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1) { redisTemplate.expire(key, tryAcquireIntervalInSeconds, TimeUnit.SECONDS); } if (count != null && count > maxAttempts) { throw new BizException(429, "操作过于频繁"); }这段代码的逻辑是:第一次访问时创建计数器并设置有效期,有效期内的访问次数超过阈值就拒绝。因为Redis单命令自增是原子的,所以并发环境下计数是准确的。
3.4 防重复下单:接口幂等设计
限流可以拦住大多数恶意流量,但正常用户双击、重试也可能产生多个下单请求。所以要设计幂等机制——同一个用户对同一个商品,在秒杀期间只能成功下单一次。
实现上我惯用的方式是:用户点击秒杀时,后端生成一个全局唯一的uuid作为本次“秒杀令牌”,前端带着这个令牌发起请求。后端在Redis中以seckill:token:{userId}:{skuId}为key,setnx(只有key不存在时才能设置成功)的方式写入该令牌。如果setnx返回false,说明这个用户已经请求过了,直接拒绝。
Boolean firstAttempt = redisTemplate.opsForValue().setIfAbsent( "seckill:token:" + userId + ":" + skuId, uuid, Duration.ofMinutes(5) ); if (!Boolean.TRUE.equals(firstAttempt)) { throw new BizException(409, "您已参与过秒杀"); }这个操作的巧思在于setnx的原子性:即使同一时刻来了5个请求,Redis内部的原子性保证只有第一个能写入成功。用户后端的重试、多线程、甚至分布式环境下的多实例,都能被同一把锁拦住。
3.5 异步下单:消息队列怎么缓解数据库压力
预扣减成功后,请求会把用户的秒杀资格发送到MQ,而不是直接调用订单服务。这一步是整个系统抗住高并发的关键。
生产端处理逻辑大概是这样的:
// 如果预扣成功,发送消息 SendResult result = rabbitTemplate.convertSendAndReceive( "seckill.exchange", "seckill.order.routingkey", seckillMessage );消息体包含userId、skuId、token等必要信息。订单服务监听队列,消费消息并生成订单。
为什么这样做?因为数据库的写入能力是有限的,而MQ本质上是把“写请求”变成“写任务”,把瞬间压力分摊到更长的时间窗口。比如数据库每秒能承载1000次订单写入,而秒杀期间来了3万次请求,如果全部直写数据库,系统立刻过载。现在这些请求先把资格消息丢进MQ,订单服务以自己的速度消费,平均下来每秒写入只有几百次,数据库压力完全可控。
这里要特别提一下消费端的幂等性。MQ消息可能因为网络原因被重复投递,所以订单服务消费时必须判断这条消息是否已经处理过。我用消息中的token作为唯一键,在订单表中做唯一索引;重复消费时发现token已存在,直接丢弃或返回成功,绝不生成第二条订单。
3.6 数据库扣减:最后的兜底防线
订单服务拿到消息后,会在一个本地事务中执行两个操作:
- 插入订单记录;
- 以乐观锁方式更新数据库库存:
update seckill_goods set stock = stock - 1 where id = ? and stock > 0。
注意这里的where stock > 0条件,是数据库层面的最终防超卖保障。如果用行锁实现:select ... for update,同样可以,但并发度比乐观锁低。秒杀场景下,由于Redis已经拦掉了大量的无效请求,到数据库这一层的并发量非常有限,用乐观锁配合判断就够了。
一个完善的事务方法大概长这样:
@Transactional(rollbackFor = Exception.class) public boolean createOrder(SeckillMessage message) { // 1. 幂等判断:订单表unique key检查 int exist = orderMapper.countByToken(message.getToken()); if (exist > 0) { return true; } // 2. 乐观锁扣库存 int rows = goodsMapper.decreaseStock(message.getSkuId()); if (rows == 0) { // 库存在数据库侧仍然不足,补偿Redis redisTemplate.opsForValue().set("seckill:stock:" + message.getSkuId(), 0); throw new BizException(500, "库存不足"); } // 3. 插入订单 orderMapper.insert(buildOrder(message)); return true; }一旦数据库扣减失败,说明库存真的见底了,我还会主动把Redis里的库存置为0,让后续请求第一时间拿“已抢光”的结果,避免无谓的数据库试探。这是一个很实用的补偿逻辑。
4. 实操过程:手把手搭一个可用版本
4.1 环境准备与依赖
假设你已经装了JDK8+、Maven、MySQL、Redis以及RabbitMQ。这里只列出关键的依赖坐标,完整POM可以按需增补:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency>Redis连接建议配置连接池参数,用Lettuce或Jedis都行,线上最好配maxTotal和maxIdle,否则高并发下连接数会成为瓶颈。消息队列别忘了开启确认回调,保证消息不丢失。
4.2 秒杀接口完整链路
一个对外暴露的秒杀接口,至少是下面这个样子的。注意它做的事情很多,但每一步都比较轻量:
@PostMapping("/seckill") public Result<SeckillResponse> seckill(@RequestParam Long skuId, @RequestParam Long userId, @RequestParam String token) { // 1. 校验用户登录身份(省略具体实现) // 2. 用户维度限流 // 3. 校验秒杀令牌,防重复 // 4. 准备秒杀消息 SeckillMessage msg = new SeckillMessage(userId, skuId, token); // 5. Redis + Lua 预扣减库存 Long sold = stockService.preDeduct(skuId); if (sold < 0) { return Result.error("已抢光"); } // 6. 发送MQ消息 rabbitTemplate.convertAndSend("seckill.exchange", "seckill.order", msg); return Result.success(new SeckillResponse(sold)); }这段代码的妙处在于:接口同步返回时,用户下单还没完成,只拿到了“抢购成功”的资格。真正订单是否落库,由后台异步执行。对用户而言,体验上是“点击—等待几秒—看到订单”。对系统而言,数据库没有任何瞬时压力。
4.3 压测关注哪些参数
写完代码不能直接上线,必须先压测。我用JMeter模拟过50个线程并发循环,逐步增加到500、1000、5000。压测的核心观察指标是:
- 接口平均响应时间与TP99(99%请求的响应时间);
- Redis的QPS与连接数;
- RabbitMQ的积压消息数量;
- MySQL的慢查询与行锁等待时间。
实测下来,单纯用Redis+Lua预扣库存,单机Java服务在常规配置下能稳定扛住每秒2万左右的请求。如果加上网关限流、前端验证码、MQ异步下单,最终落到数据库的写请求每秒只有几百,数据库根本不会成为瓶颈。压测时如果发现接口报错率升高,先看Redis连接池和网络I/O,八成是这两个位置被打满了。
5. 常见问题排查与避坑经验
5.1 超卖了,怎么定位问题
超卖是所有秒杀系统的头号大bug。排查时按以下顺序来:
- 看Redis扣减脚本:是不是用了“先get再decr”的非原子操作;如果是,立刻改成Lua;
- 看MQ消费端事务:有没有在事务里先扣库存再插入订单,如果顺序反了,会留下库存扣了但订单没有的“幽灵扣减”;
- 看数据库更新SQL:有没有写
where stock > 0条件;没有的话,并发下必然超卖; - 看幂等:用户重复点击、消息重复投递是否会导致同一个用户生成多张订单。
5.2 缓存击穿:热点商品的Redis key失效了
如果Redis中秒杀商品的key被误删或过期,大量请求会同时回源到数据库,造成缓存击穿。解决方案有两类:一是对热点key设置永不失效(秒杀期间库存key本来就是动态扣减的,不需要设置过期);二是加互斥锁,当缓存未命中时,只允许一个线程去加载数据,其他线程等待或直接快速失败。
对于秒杀场景,我更推荐快速失败而不是等待:让请求返回“抢购尚未开始”或“系统繁忙”,负载高的时期维持用户体验的确定性和系统的稳定性,比“多一个用户成功抢到”重要得多。
5.3 消息队列积压:消费者追不上生产速度
秒杀流量太猛时,MQ积压是常态。处理思路是增加消费者实例数量,或者临时开启“批量消费模式”,一次拉取多条消息批量处理。如果像我们前面设计的那样把写库操作优化到很轻量,通常增加2~3个消费者实例就能追平。
如果积压实在太多,也可以考虑降级策略:先只把“秒杀成功”的结果更新到Redis,订单信息后续再异步补偿。这个方案能极大降低消费者压力,但要额外处理“用户查询订单为空”的体验问题。
5.4 数据一致性:Redis和MySQL库存对不上怎么办
最直接的方法是引入对账任务。秒杀结束后,比对Redis中的已售量和数据库中的订单量或库存扣减量,如果发现不一致,以数据库为准进行修正。
实际上,秒杀系统允许短暂的不一致(比如Redis扣了库存但MQ消息还在排队,数据库还没扣),但要保证最终一致。确保这个最终一致的关键是:任何情况下都要有补偿机制——消息消费失败要重试,重试失败要记录日志并由人工介入。
5.5 防刷:除了限流还能做什么
限流只能控制频率,不能完全防刷。更好的组合是验证码、设备指纹和用户行为分析:
- 图形验证码或滑块验证码:手动用户成本高,脚本难以自动通过;
- 同一IP、同一设备指纹的异常频率检测;
- 秒杀开始前不让前端拿到真正的接口地址,用动态Token方式在开始时刻下发。
这些手段可以按业务需要组合,但对一个从零开始的秒杀项目,先把限流、幂等、预扣这三板斧做好,已经能应对绝大多数场景。过度设计防御机制,反而会让系统复杂度和维护成本大幅上升。
6. 写在最后:一点经验分享
动手做一个秒杀系统,最大的收获不是学会了Redis和MQ的API,而是理解了“高并发问题的本质是资源有限,而设计就是决定把这有限的资源花在哪些请求上”。读多少篇高并发文章,不如亲手写一个压测下跑一跑。我第一次做秒杀项目时,也踩过超卖、缓存穿透、消息积压这些经典坑,修复每一个坑后对系统的理解都深了一层。
如果你是面试场景,聊秒杀系统时不要只背名词,试着从“流量怎么进来、怎么被拦截、怎么被削峰、怎么最终落库”这条链路去讲,面试官会更认可你的实际理解。如果你是想练手,建议从单体架构起步,一步步引入Redis、MQ,再逐步微服务化,不要一上来就拆十几个模块。
这个内容后续还可以怎么扩展?比如接入分布式事务保证最终一致性、加Sentinel实现更精细的流控、用ClickHouse做秒杀报表分析、把秒杀服务容器化部署到K8s做弹性伸缩。每一步都是有价值的深水区。如果你已经把这个基础版本跑通了,欢迎沿着这些方向继续深挖,届时你对“万人抢购”这个场景的理解,会从“能设计”变成“能驾驭”。