
开场秒杀系统到底考的是什么各位做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个请求超出直接返回“操作频繁”。这一层把脚本攻击和抓包重放挡在了系统外部。第三层是应用层预扣减。请求进入业务系统后先不直接操作数据库而是通过RedisLua脚本对库存做预扣减。库存扣完的请求在这里就结束了根本不会触达更深的服务。第四层是异步削峰。预扣减成功的请求把“秒杀资格”封装成消息丢进消息队列后端订单服务按自己的节奏去消费。这就是所谓的削峰填谷——把瞬时的下单洪峰变成一条平稳的工作流。第五层是数据库最终一致性扣减。订单真正落库时商品服务用乐观锁或行锁再次校验库存确认无误后才生成订单。这里是最底线的兜底不允许超卖发生。这个模型的本质是把“写”操作的成本分摊到不同的时间与计算层级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中。这一步千万不能等用户点击时才去读数据库初始化否则第一个请求就把数据库打爆了。我通常用定时任务或者后台管理触发预热流程是查询数据库中商品的可售库存写入Rediskey为seckill:stock:{skuId}value为库存数量同时写入商品秒杀信息如开始时间、结束时间、每个用户限购数量预热完成后将商品状态标记为“可秒杀”。这里有个细节预热时的Redis库存值需要和数据库库存对应好。如果数据库库存是100Redis就写100后续扣减时按1:1同步回写数据库。不要在预热环节做任何加价或缩放操作不然后面对账会非常麻烦。3.2 原子扣减用Lua脚本解决超卖Redis能源源不断扣减库存是因为我们用了Lua脚本而不是“读出来—减1—写回去”这种充满竞态条件的写法。下面这段脚本是秒杀扣减库存的核心-- KEYS[1] 是库存keyKEYS[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的DefaultRedisScriptString 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; DefaultRedisScriptLong 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}为keysetnx只有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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependencyRedis连接建议配置连接池参数用Lettuce或Jedis都行线上最好配maxTotal和maxIdle否则高并发下连接数会成为瓶颈。消息队列别忘了开启确认回调保证消息不丢失。4.2 秒杀接口完整链路一个对外暴露的秒杀接口至少是下面这个样子的。注意它做的事情很多但每一步都比较轻量PostMapping(/seckill) public ResultSeckillResponse 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。压测的核心观察指标是接口平均响应时间与TP9999%请求的响应时间Redis的QPS与连接数RabbitMQ的积压消息数量MySQL的慢查询与行锁等待时间。实测下来单纯用RedisLua预扣库存单机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做弹性伸缩。每一步都是有价值的深水区。如果你已经把这个基础版本跑通了欢迎沿着这些方向继续深挖届时你对“万人抢购”这个场景的理解会从“能设计”变成“能驾驭”。