☰
秒杀系统优化方案:Redis预减库存与分层过滤设计
2026/10/15 13:28:03 网站建设 项目流程

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) { DefaultRedisScript<Long> 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好一些。配置大概是这样:

Cache<String, SeckillGoods> localCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(5, TimeUnit.SECONDS) .build();

注意这里的过期时间设得很短,只有5秒。为什么?因为秒杀场景下数据变化很快,库存可能在一秒内就从100变成0,本地缓存如果过期时间太长,用户看到的就是过期的库存信息。5秒是一个折中,既能挡住大部分重复读取,又不至于数据太旧。

本地缓存和Redis的协同逻辑是这样的:请求进来先查本地缓存,如果命中且数据有效(比如库存大于0),直接返回;如果本地缓存未命中或者数据已失效,再查Redis;Redis也没有才查数据库。这个顺序不能乱,因为本地缓存最快,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来实现分布式限流。Redis+Lua可以实现一个简单的令牌桶:

-- 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 Result<SeckillResult> 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/s
500120ms0.2%3800/s
1000350ms1.5%4200/s
2000800ms5.8%4500/s

从数据可以看出,并发到1000以上时错误率开始明显上升,说明系统瓶颈在1000并发左右。这时候就需要根据瓶颈点做针对性优化,比如增加应用服务器、优化Redis连接池、调整MQ的批量发送参数等。

注意:压测环境的数据不能直接等同于生产环境。生产环境的网络延迟、硬件配置、数据量都不一样。压测的目的是找到系统的瓶颈点和优化方向,而不是追求一个绝对的数字。

4. 常见问题与排查技巧实录

做秒杀系统的过程中,我踩过的坑比顺利走过的路还多。这一章我把常见问题和排查技巧整理出来,希望能帮你少走一些弯路。

4.1 库存超卖的排查与解决

库存超卖是秒杀系统最严重的问题之一。表现是:活动结束后,订单数量大于库存数量。排查思路如下:

第一步,检查Redis扣减逻辑是否原子。如果用的是GET+SET而不是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分钟。

这些细节单独看可能不起眼,但在秒杀这种极端场景下,任何一个细节出问题都可能导致整个系统崩溃。我个人的体会是,秒杀系统的优化是一个不断迭代的过程,没有一劳永逸的方案。每次大促之后都要复盘,看看哪里还可以改进,哪里出现了新的瓶颈。只有持续优化,才能让系统在越来越大的流量面前保持稳定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询