资讯动态

秒杀系统优化方案:Redis预减库存与分层过滤设计

发布时间:2026/10/15 13:28:05 来源:尧图企业网站定制
1. 秒杀系统方案优化的整体设计思路拆解秒杀系统这个东西做过电商的人都知道它跟普通业务系统完全不是一个物种。普通业务系统追求的是功能完整、逻辑清晰而秒杀系统追求的是在极端流量下还能活下来。我见过太多团队在功能开发阶段一切顺利一到大促就崩得亲妈都不认识。所以这一篇学习手记我想把秒杀系统方案优化的整体设计思路彻底拆开讲清楚包括每一步为什么这么做、不这么做会怎样。1.1 秒杀系统的核心矛盾到底是什么很多人一上来就说“秒杀要高并发”这话没错但太笼统。秒杀系统的核心矛盾其实是三个字瞬时性。一场秒杀活动可能90%的流量集中在开抢后的前3到5秒内涌入。这意味着系统在绝大多数时间里是空闲的但在那几秒钟里要承受平时几十倍甚至上百倍的QPS。如果按照峰值流量去配置资源平时就是巨大的浪费如果按平时流量配置峰值一来直接雪崩。这个矛盾决定了秒杀系统的设计不能走常规的“加机器”路线而必须走“层层过滤、逐级削峰”的路线。所谓层层过滤就是在请求到达最终数据库之前尽可能在前面的环节把无效请求挡掉。所谓逐级削峰就是把瞬时的高并发分散到不同的时间窗口去处理。理解了这一点后面所有的优化手段你都能自己想明白为什么。我个人的经验是秒杀系统的优化本质上是一个漏斗模型。最上层是CDN和浏览器缓存往下是网关限流再往下是应用层缓存然后是Redis预减库存最后才是数据库。每一层都在做同一件事让尽可能少的请求穿透到下一层。漏斗越往下能通过的请求越少最终到达数据库的请求应该是可控的、有序的。1.2 为什么选择Redis作为核心缓冲层在秒杀系统的技术选型中Redis几乎是绕不开的。原因很简单内存操作比磁盘操作快几个数量级。MySQL的QPS在单机优化良好的情况下大概能到几千而Redis单机轻松跑到十万级别。秒杀场景下库存扣减这个动作如果直接打到MySQL数据库连接池瞬间被打满后续请求全部排队超时。但Redis也不是银弹。用Redis做库存预减核心思路是把库存数据提前加载到Redis中所有扣减操作在Redis层面完成只有扣减成功的请求才允许进入后续的订单创建流程。这样一来MySQL承受的写压力就从“所有请求”变成了“抢到库存的请求”数量级完全不一样。这里有个关键细节Redis的库存扣减必须保证原子性。你不能先GET再SET那样在并发下必然超卖。正确的做法是用DECR或者Lua脚本。DECR的好处是简单但缺点是如果后续订单创建失败需要回补库存逻辑会变得复杂。Lua脚本的好处是可以把“判断库存是否大于0”和“扣减库存”两个操作打包成一个原子操作逻辑更清晰。我实测下来用Lua脚本的方式在可维护性上更好虽然性能比纯DECR略低一点点但在秒杀场景下这点差异完全可以接受。1.3 分层过滤的具体设计分层过滤不是一句空话每一层都有具体的实现手段。我把常见的分层策略整理成下面这个表格方便你对照自己的系统看看哪一层还薄弱层级过滤手段过滤目标典型实现客户端层按钮置灰、验证码重复提交、机器人前端JS控制、图形验证码CDN层静态资源缓存静态请求页面静态化、CDN回源策略网关层限流、黑名单超量请求、恶意IP令牌桶、漏桶算法应用层本地缓存、队列重复请求、无效请求Guava Cache、内存队列缓存层Redis预减库存超卖、数据库压力Lua脚本、DECR数据库层唯一索引、乐观锁最终一致性唯一约束、版本号这个表格里的每一层都不是可有可无的。我见过有些团队只做了Redis预减库存觉得已经够了结果网关层没做限流恶意请求直接把应用服务器的线程池打满Redis还没来得及发挥作用系统就已经挂了。所以分层过滤的核心思想是不要指望任何单一手段解决所有问题每一层只解决一部分问题合起来才是完整的方案。1.4 方案选型中的取舍逻辑做秒杀系统优化你会面临很多取舍。比如限流算法选令牌桶还是漏桶令牌桶允许一定的突发流量漏桶则严格按固定速率处理。秒杀场景下我倾向于令牌桶因为秒杀本身就是突发流量漏桶的严格速率限制会导致大量请求被无谓地丢弃。再比如库存扣减是先扣Redis还是先扣数据库答案永远是先扣Redis因为Redis是缓冲层它的作用就是替数据库挡子弹。还有一个容易被忽视的取舍同步还是异步。秒杀请求进来之后是同步等待订单创建结果还是先返回“排队中”然后异步处理这两种方案各有优劣。同步方案用户体验好但服务器需要保持连接并发能力受限于线程数。异步方案并发能力强但用户需要轮询或者等待推送。我个人的建议是如果秒杀商品数量很少比如几百个同步方案就够了如果商品数量上万异步方案更稳妥。2. 核心细节解析与实操要点整体思路理清楚之后这一章我们深入到每个核心环节的细节里。秒杀系统的优化不是靠某一个“银弹”技术而是靠一堆细节的堆砌。每个细节单独看可能只提升10%的性能但十个细节叠加起来就是质的飞跃。2.1 Redis库存预减的Lua脚本实现细节先看最核心的库存预减。前面说了用Lua脚本保证原子性但脚本具体怎么写、参数怎么传、返回值怎么处理这里面有很多门道。下面是我在实际项目中反复打磨过的一个Lua脚本-- KEYS[1]: 库存key -- ARGV[1]: 本次扣减数量 -- 返回: 剩余库存-1表示库存不足 local stock redis.call(GET, KEYS[1]) if not stock then return -1 end stock tonumber(stock) local num tonumber(ARGV[1]) if stock num then return -1 end redis.call(DECRBY, KEYS[1], num) return stock - num这个脚本看起来简单但有几个细节值得展开说。第一为什么用GET加DECRBY而不是直接用DECR因为DECR在库存为0时会变成负数虽然可以通过判断返回值来补救但逻辑上不够干净。先用GET判断再DECRBY语义更清晰。第二返回值设计成“剩余库存”和“-1表示失败”调用方只需要判断是否大于等于0就能知道结果不需要额外的状态码。调用这个脚本的时候Java端的代码大概长这样public Long deductStock(String stockKey, int num) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(LUA_SCRIPT); script.setResultType(Long.class); return redisTemplate.execute(script, Collections.singletonList(stockKey), String.valueOf(num)); }注意Lua脚本中不要使用redis.call(GET)之后又用redis.call(SET)虽然Lua脚本本身是原子执行的但GET和SET之间的逻辑如果写错了依然会出现逻辑上的超卖。用DECRBY或者INCRBY这种原子命令更稳妥。还有一个坑我踩过库存key的过期时间。如果秒杀活动结束后库存key没有及时清理下一次活动如果复用了同一个key就会出现库存数据错乱。我的做法是活动结束后手动删除或者给key设置一个合理的过期时间比如活动结束后24小时。但过期时间不能设得太短否则活动还没结束key就没了所有请求都会穿透到数据库。2.2 本地缓存与Redis缓存的协同Redis虽然快但每次请求都走一次网络IO在极端并发下网络IO本身也会成为瓶颈。所以我在应用层又加了一层本地缓存用来缓存一些变化频率低但读取频率高的数据比如商品基本信息、活动状态等。本地缓存用Guava Cache或者Caffeine都行我选的是Caffeine因为它的淘汰算法W-TinyLFU在命中率上确实比LRU好一些。配置大概是这样CacheString, SeckillGoods localCache Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(5, TimeUnit.SECONDS) .build();注意这里的过期时间设得很短只有5秒。为什么因为秒杀场景下数据变化很快库存可能在一秒内就从100变成0本地缓存如果过期时间太长用户看到的就是过期的库存信息。5秒是一个折中既能挡住大部分重复读取又不至于数据太旧。本地缓存和Redis的协同逻辑是这样的请求进来先查本地缓存如果命中且数据有效比如库存大于0直接返回如果本地缓存未命中或者数据已失效再查RedisRedis也没有才查数据库。这个顺序不能乱因为本地缓存最快Redis次之数据库最慢。但这里有个问题本地缓存和Redis之间的数据一致性怎么保证我的做法是库存扣减只在Redis层面做本地缓存只缓存商品的基本信息名称、价格、图片等不缓存库存。这样就不存在一致性问题了。库存的读取直接走Redis虽然多一次网络IO但保证了准确性。2.3 消息队列削峰的具体配置消息队列在秒杀系统中的作用是削峰填谷。用户抢到库存之后订单创建这个动作不需要立即完成可以丢到消息队列里慢慢处理。这样应用服务器就能快速释放线程去处理更多的抢购请求。我选的是RocketMQ原因有三一是它支持事务消息可以保证“扣库存”和“发消息”两个操作的最终一致性二是它的单机吞吐量足够大十万级QPS没问题三是它的延迟消息功能可以用来处理订单超时未支付的回补库存。消息生产者的配置要点DefaultMQProducer producer new DefaultMQProducer(seckill_producer_group); producer.setNamesrvAddr(nameserver地址); producer.setRetryTimesWhenSendFailed(2); producer.setSendMsgTimeout(3000); producer.start();setRetryTimesWhenSendFailed(2)这个参数很关键。秒杀场景下网络抖动是常态如果发送失败不重试用户明明抢到了库存却创建不了订单体验极差。但重试次数也不能太多否则消息重复的概率会增加。2次是一个比较平衡的值。消费者端要注意幂等性。因为消息可能重复投递消费者必须保证同一条消息处理多次的结果和处理一次是一样的。我的做法是在订单表上建一个唯一索引索引字段是“用户ID商品ID活动ID”这样即使消息重复第二次插入也会因为唯一约束而失败不会产生重复订单。2.4 限流算法的选择与参数计算限流是保护系统的最后一道防线。我试过几种限流算法最终在秒杀场景下选择了令牌桶。原因前面说过令牌桶允许突发流量更符合秒杀的业务特点。令牌桶的核心参数有两个桶容量和令牌生成速率。这两个参数怎么定我的经验公式是这样的桶容量 系统能承受的最大瞬时并发数 × 1.5令牌生成速率 系统能承受的持续QPS举个例子假设你的应用服务器集群能承受的最大瞬时并发是5000持续QPS是2000那么桶容量设为7500令牌生成速率设为2000/秒。这样在秒杀开始的瞬间最多有7500个请求能拿到令牌超出的请求直接被限流拒绝。随着时间推移令牌以每秒2000个的速度生成系统持续处理请求。用Guava的RateLimiter实现大概是这样RateLimiter limiter RateLimiter.create(2000); if (limiter.tryAcquire()) { // 处理请求 } else { // 返回限流提示 }但Guava的RateLimiter是单机的分布式环境下需要用Redis来实现分布式限流。RedisLua可以实现一个简单的令牌桶-- KEYS[1]: 令牌桶key -- ARGV[1]: 桶容量 -- ARGV[2]: 令牌生成速率个/秒 -- ARGV[3]: 当前时间戳秒 -- ARGV[4]: 本次请求令牌数 local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) local bucket redis.call(HMGET, KEYS[1], tokens, last_time) local tokens tonumber(bucket[1]) or capacity local last_time tonumber(bucket[2]) or now local delta math.max(0, now - last_time) local filled math.min(capacity, tokens delta * rate) if filled requested then redis.call(HMSET, KEYS[1], tokens, filled - requested, last_time, now) return 1 else redis.call(HMSET, KEYS[1], tokens, filled, last_time, now) return 0 end这个脚本的逻辑是根据距离上次请求的时间差计算这段时间内应该生成多少令牌加上剩余令牌但不能超过桶容量。如果令牌足够就扣减并返回1否则返回0。提示分布式限流脚本中的时间戳建议用Redis服务器的时间而不是应用服务器的时间。因为多台应用服务器的时间可能有偏差用统一的时间源更准确。可以用redis.call(TIME)获取Redis时间。3. 实操过程与核心环节实现理论讲了一堆这一章我们来看具体的实操过程。我会按照一个请求从进入系统到最终完成的完整链路逐步拆解每个环节的实现细节。你可以把这部分当作一个“抄作业”的参考但要注意根据自己的业务场景做调整。3.1 秒杀接口的完整请求链路一个秒杀请求从用户点击按钮到最终看到结果中间会经过很多环节。我把这条链路完整画出来用文字描述因为不能用图表用户点击 - 前端拦截按钮置灰 - 网关限流 - 应用层本地缓存检查 - Redis库存预减 - 发送MQ消息 - 返回“排队中” - 消费者创建订单 - 用户轮询结果每一步都有它的作用。前端拦截挡住重复点击网关限流挡住超量请求本地缓存挡住无效查询Redis预减挡住超卖MQ削峰让订单创建异步化。整个链路的设计原则是越靠前的环节越轻量越靠后的环节越重。轻量环节快速失败重量环节异步处理。具体到代码层面Controller层的秒杀接口大概是这样PostMapping(/seckill/{goodsId}) public ResultSeckillResult seckill(PathVariable Long goodsId) { Long userId getCurrentUserId(); // 1. 本地缓存检查活动状态 SeckillActivity activity localCache.get(activity: goodsId); if (activity null || !activity.isActive()) { return Result.error(活动未开始或已结束); } // 2. Redis库存预减 Long remainStock stockService.deductStock(stock: goodsId, 1); if (remainStock null || remainStock 0) { return Result.error(库存不足); } // 3. 发送MQ消息 SeckillMessage message new SeckillMessage(userId, goodsId, activity.getId()); mqProducer.send(message); // 4. 返回排队中 return Result.success(SeckillResult.queuing()); }这段代码里deductStock是前面说的Lua脚本调用mqProducer.send是RocketMQ的消息发送。注意第3步和第4步之间没有等待订单创建完成直接返回“排队中”这就是异步化的核心。3.2 库存回补的时机与实现库存回补是秒杀系统中容易被忽视但非常重要的环节。什么情况下需要回补库存主要有两种一是用户抢到库存但超时未支付二是订单创建过程中出现异常需要回滚。超时未支付的库存回补我用的是RocketMQ的延迟消息。用户抢到库存后发送一条延迟消息比如延迟30分钟消费者收到延迟消息后检查订单状态如果还是“未支付”就取消订单并回补库存。Message message new Message(seckill_topic, timeout, seckillMessage.getBytes()); message.setDelayTimeLevel(16); // RocketMQ的延迟级别16对应30分钟 producer.send(message);RocketMQ的延迟级别是固定的几个档位不是任意时间。常用的级别和对应时间如下延迟级别延迟时间11秒51分钟106分钟1630分钟181小时如果你需要更灵活的延迟时间可以考虑用时间轮算法自己实现或者用RabbitMQ的延迟插件。但在秒杀场景下30分钟这个档位基本够用了。回补库存的时候要注意先更新数据库再更新Redis。因为数据库是最终数据源如果先更新Redis再更新数据库万一数据库更新失败Redis和数据库就不一致了。正确的顺序是数据库更新成功后再去更新Redis即使Redis更新失败也可以通过定时任务补偿。3.3 订单创建消费者的幂等处理消费者端处理订单创建时幂等性是必须保证的。我见过太多因为消息重复导致用户被扣两次款的案例。幂等的实现方式有很多种我选的是数据库唯一索引方案。订单表的设计CREATE TABLE seckill_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, activity_id BIGINT NOT NULL, status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_goods_activity (user_id, goods_id, activity_id) );uk_user_goods_activity这个唯一索引保证了同一个用户对同一个商品在同一个活动中只能有一条订单记录。消费者插入订单时如果捕获到DuplicateKeyException说明这条消息已经处理过了直接返回成功即可不需要做任何额外操作。但这里有个细节捕获异常后要不要回滚Redis库存答案是不要。因为重复消息对应的库存已经在第一次处理时扣减过了如果第二次再回补就会导致库存多出来。所以捕获到重复键异常时直接确认消息消费成功不做任何库存操作。3.4 压测方案与性能数据记录优化做完了怎么知道有没有效果必须压测。我用的压测工具是JMeter因为它支持分布式压测可以模拟更高的并发。压测方案的设计要点线程数从100开始逐步增加到500、1000、2000观察系统在不同并发下的表现Ramp-up时间设置为1秒模拟秒杀开始的瞬时流量循环次数每个线程只循环1次因为秒杀就是一次性抢购断言检查响应时间、错误率、吞吐量我记录的一组实测数据模拟环境非生产数据并发数平均响应时间错误率吞吐量10045ms0%2100/s500120ms0.2%3800/s1000350ms1.5%4200/s2000800ms5.8%4500/s从数据可以看出并发到1000以上时错误率开始明显上升说明系统瓶颈在1000并发左右。这时候就需要根据瓶颈点做针对性优化比如增加应用服务器、优化Redis连接池、调整MQ的批量发送参数等。注意压测环境的数据不能直接等同于生产环境。生产环境的网络延迟、硬件配置、数据量都不一样。压测的目的是找到系统的瓶颈点和优化方向而不是追求一个绝对的数字。4. 常见问题与排查技巧实录做秒杀系统的过程中我踩过的坑比顺利走过的路还多。这一章我把常见问题和排查技巧整理出来希望能帮你少走一些弯路。4.1 库存超卖的排查与解决库存超卖是秒杀系统最严重的问题之一。表现是活动结束后订单数量大于库存数量。排查思路如下第一步检查Redis扣减逻辑是否原子。如果用的是GETSET而不是DECR或Lua脚本那超卖几乎是必然的。解决办法就是换成Lua脚本。第二步检查是否有多个Redis实例。如果库存key被分片到不同的Redis节点而扣减逻辑没有做全局协调也会超卖。解决办法是确保同一个商品的库存key始终在同一个Redis节点上可以用hash tag强制路由。第三步检查数据库层面是否有兜底。即使Redis层面没问题如果数据库没有唯一约束或乐观锁极端情况下依然可能超卖。解决办法是在数据库层面加一个stock 0的检查UPDATE seckill_goods SET stock stock - 1 WHERE id ? AND stock 1;这条SQL的stock 1条件保证了即使并发请求同时到达也只有库存足够时才会扣减成功。4.2 Redis连接池打满的处理秒杀高峰期Redis连接池打满是很常见的问题。表现是大量请求报“Could not get a resource from the pool”错误。排查和解决步骤如下先看连接池配置。默认的Jedis连接池最大连接数只有8这在秒杀场景下远远不够。我一般会调到200到500具体数值根据应用服务器的数量和Redis的承载能力来定。JedisPoolConfig config new JedisPoolConfig(); config.setMaxTotal(500); config.setMaxIdle(100); config.setMinIdle(20); config.setMaxWaitMillis(1000);setMaxWaitMillis(1000)这个参数很关键。它表示从连接池获取连接的最大等待时间超过1秒就抛异常。为什么要设这个因为秒杀场景下等待超过1秒的请求基本已经没有意义了与其让它继续等不如快速失败释放线程去处理其他请求。如果调大连接池后依然打满那说明Redis本身的处理能力到了瓶颈。这时候可以考虑读写分离把读请求分流到从节点或者对库存key做分片把压力分散到多个Redis实例。4.3 消息积压的应急处理消息积压的表现是MQ中的消息数量持续增长消费者处理速度跟不上生产速度。原因可能是消费者处理逻辑太慢或者消费者数量不够。应急处理方案临时增加消费者实例这是最快的办法多启动几个消费者实例并行消费。优化消费者逻辑检查消费者中是否有耗时的操作比如远程调用、复杂计算等能异步的异步能缓存的缓存。批量消费如果MQ支持批量消费开启批量消费可以显著提升吞吐量。但这些都是治标不治本。根本的解决办法是评估消费者的处理能力确保它至少能跟上生产者的速度。我一般会留出30%的余量比如生产者峰值是5000条/秒消费者的处理能力至少要达到6500条/秒。4.4 常见问题速查表为了方便你快速定位问题我把常见的异常现象、可能原因和解决方案整理成下面这个表格异常现象可能原因解决方案库存超卖Redis扣减非原子改用Lua脚本或DECR连接池打满最大连接数太小调大maxTotal设置合理maxWait消息积压消费者处理慢增加消费者、优化逻辑、批量消费重复订单消息重复投递数据库唯一索引幂等处理响应超时线程池满调大线程池、异步化、限流缓存穿透查询不存在的key布隆过滤器、空值缓存缓存雪崩大量key同时过期过期时间加随机值热点key单个key访问量过大本地缓存、key分片这个表格里的每一条都是我实际遇到过并解决过的。你可以把它打印出来贴在工位上出问题的时候对照着排查能省不少时间。4.5 几个容易被忽视的细节最后分享几个我在实操中总结的、常规文档里不会写的细节。第一个是日志打印。秒杀高峰期日志量巨大如果每个请求都打详细日志磁盘IO会成为瓶颈。我的做法是正常请求只打DEBUG级别出错时才打ERROR级别并且ERROR日志要包含足够的上下文信息用户ID、商品ID、请求ID方便排查。第二个是JVM调优。秒杀场景下对象创建和销毁非常频繁年轻代如果太小会导致频繁Young GC。我一般会把年轻代调大一些比如-Xmn2g并且用G1收集器替代CMS因为G1在大堆内存下的表现更稳定。第三个是数据库连接池。秒杀场景下数据库连接池的配置和Redis连接池类似最大连接数要调大但也不能太大否则数据库本身承受不了。我一般设置为数据库最大连接数的80%左右留一些余量给其他业务。第四个是时间同步。分布式系统中各台服务器的时间必须同步否则限流、延迟消息等功能都会出问题。我一般用NTP服务做时间同步同步间隔设为1分钟。这些细节单独看可能不起眼但在秒杀这种极端场景下任何一个细节出问题都可能导致整个系统崩溃。我个人的体会是秒杀系统的优化是一个不断迭代的过程没有一劳永逸的方案。每次大促之后都要复盘看看哪里还可以改进哪里出现了新的瓶颈。只有持续优化才能让系统在越来越大的流量面前保持稳定。

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

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

免费获取报价 →
↑