做电商的同学应该都躲不过这个话题:秒杀。标题里这套方案——“20w商品、100W预约、百万QPS”——放在任何一次大促里都算得上硬仗。我前前后后参与过好几轮类似的活动,从第一年的手忙脚乱到后来的有条不紊,中间踩过的坑、推翻过的方案,都沉淀在这篇文章里了。
先给大家一个心理预期:所谓百万QPS,不是一个接口打满百万,而是整个系统入口层的总流量可能摸到百万,真正落到下单接口的可能只有几万。把流量分层看,很多问题就好解了。我会按照流量从用户侧到数据库的完整路径来讲,覆盖前端、网关、Redis、MQ、数据库几个层面,配了代码和参数推导,文字版可以直接照着改。
1. 秒杀方案的整体设计思路:先搞清楚流量是怎么没的
1.1 百万QPS到底意味着什么
先从数学上看。100万人预约,假设在开抢的瞬间有30%的人真正点击,也就是30万请求在极短时间内涌进来。如果这30万请求全部打到数据库,按单库3000 QPS的承受能力,需要100台数据库才扛得住,这显然不现实。所以秒杀方案的核心手段就一句话:把流量在到达数据库之前,一层一层地削掉。
层级削峰的逻辑是这样的:
- 前端层:按钮置灰、倒计时、答题、验证码,把点击频率人为降下来。
- 网关层:令牌桶限流,直接拒绝超出阈值的请求。
- 应用层:做本地标记,卖完了直接短路,不再查Redis。
- Redis层:预扣库存,用Lua脚本保证原子性。
- MQ层:削峰,把订单创建的请求排队,异步落库。
每一层干掉一部分流量,通常到数据库这一层,真正的写请求已经只有每秒几千了。所以整个架构的关键不是某一个组件多强,而是每一层都干好自己的活,互相配合着把流量消化掉。
1.2 为什么不能只靠一台Redis或者一把分布式锁
有同学可能问,那我把Redis换成更大的机器,或者用分布式锁锁住库存行不行?
先说单机Redis。即便Redis单实例可以扛10万级QPS,一旦到了“百万QPS”这个量级,单台节点的网络带宽和CPU都会先顶不住。更重要的是,库存Key如果集中在一个节点上,这个节点就会成为热点,其他节点再闲也帮不上忙。所以需要分片,让不同商品的库存分散到多个Redis节点。
再说分布式锁。如果对每个库存Key都加一把锁,那么同一时刻只有一个线程能扣库存,其他线程全在等待。等待的请求堆积起来,超时、重试、死锁这些连锁问题就全来了。而且秒杀场景下,真正的并发瓶颈往往不是“并发修改同一把锁”,而是“大量请求同时来”本身。锁只是解决正确性问题,解决不了性能问题。
所以我的结论是:分布式锁在秒杀里要少用、慎用,能用原子操作解决的绝不用锁。这一点后面会展开。
2. 前端与接入层的削峰处理:把第一波流量挡在前面
2.1 按钮置灰与本地标记
前端最基础的削峰手段,就是用户抢购成功后或者商品库存显示为0后,立即把按钮置灰,不再允许重复提交。这个看似简单,但有一个细节很多人会忽略:按钮置灰不能只靠商品详情页的状态判断,必须和后端交互后更新。
因为秒杀的本质是“先到先得”,用户点击按钮时商品还剩多少,是要实时去问后端或者Redis的。前端拿到剩余库存后本地缓存下来,下次点击直接看本地标记,不满足就直接拒绝,这一步能挡住大量连点器导致的重复请求。
具体实现上,前端会在内存里维护一个OrderState:
const OrderState = { NOT_STARTED: 'NOT_STARTED', // 未开始 IN_PROGRESS: 'IN_PROGRESS', // 抢购中 SUCCESS: 'SUCCESS', // 抢购成功 SOLD_OUT: 'SOLD_OUT' // 已售罄 }; // 请求后端获取当前状态 async function fetchSeckillState(productId) { const res = await axios.get(`/api/seckill/state/${productId}`); const state = res.data.state; if (state === 'SOLD_OUT') { setButtonDisabled(true); localStorage.setItem(`seckill_${productId}`, 'SOLD_OUT'); } }注意别把状态只存在前端内存里,刷新页面就丢了。用localStorage做一层持久化,虽然用户可以清掉,但至少挡住绝大多数普通用户。
2.2 倒计时与答题验证码:天然的人肉限流器
倒计时的作用不只是营造紧张感,更重要的是统一了用户的点击时机。如果没有倒计时,用户会随时乱点,流量反而分散;有了倒计时,所有人在同一秒发起请求,对后端来说流量是脉冲式的。处理手段就是靠限流。
答题和验证码的作用经常被低估。很多人觉得这只是防机器人,其实更大的价值在于拉长了单用户的操作时间。抢购时需要先看清题目、输入答案,再提交,假设一个用户从看到题目到提交需要3秒,那么每秒自然最多只有三分之一的人能点进来。这比任何限流组件都来得简单直接。
实操中的做法是,用户点击“立即抢购”后,后端先返回一道随机数学题或者滑块验证码,用户完成验证后才允许进入下单接口。验证码的校验结果可以用JWT或者短Token串起来,有效期2到3分钟,防止用户跑去别的渠道绕过验证。
2.3 CDN与静态化:秒杀页面的流量隔离
每次大促,秒杀详情页的访问量是全站最高的,但这个页面本身的内容几乎不变,动态数据很少。如果所有用户都打到应用服务器上拿页面,应用服务器会被GET请求打满,根本没空处理下单接口。
所以秒杀详情页要提前静态化到CDN上,把商品图片、介绍、库存展示这些按时不变的内容全扔到CDN。只有“是否还有库存”这个动态状态放到Redis里,前端通过异步接口去查询。更狠的做法是,把秒杀商品的信息在活动开始前就全部生成好静态JSON,推到CDN的指定目录,用户进来直接命中缓存。
3. 令牌桶限流:网关层的第一道阀门
3.1 为什么用令牌桶而不是计数器或者漏桶
限流的经典算法有三种:固定窗口计数器、漏桶、令牌桶。固定窗口计数器的缺陷是临界问题,比如规定每秒100个请求,那第0.9秒和第1.1秒各来100个,两个窗口合起来看,0.2秒内实际通过了200个请求,流量直接翻倍。漏桶则太均匀,不允许突发流量,秒杀这种“一瞬间爆发”的场景会被压得很惨。
令牌桶的特点刚好匹配秒杀场景:桶里攒了一部分令牌,允许突发流量过去一部分,但整体速率被限制住。比如桶容量是2000,每秒钟往桶里放500个令牌,那前4秒没有任何请求时,桶里最多攒2000个令牌;秒杀开始的那一瞬间,最多可以放行2000个请求,之后每秒只能再放行500个。
常规实现里可以用Google Guava的RateLimiter,但要注意它只能做单机限流,多机部署时需要把限流的维度放在网关层统一做。下面是一个简单的自定义令牌桶实现,结合了Redis来做分布式限流:
public class RedisRateLimiter { private final Jedis jedis; private final String key; private final int capacity; private final int refillRatePerSecond; public RedisRateLimiter(Jedis jedis, String key, int capacity, int refillRatePerSecond) { this.jedis = jedis; this.key = key; this.capacity = capacity; this.refillRatePerSecond = refillRatePerSecond; } public boolean tryAcquire() { String script = "local capacity = tonumber(ARGV[1]) " + "local refill = tonumber(ARGV[2]) " + "local now = redis.call('TIME')[1] " + "local lastRefill = redis.call('GET', KEYS[1] .. ':last') " + "if not lastRefill then lastRefill = now end " + "local tokens = tonumber(redis.call('GET', KEYS[1] .. ':tokens') or capacity) " + "local elapsed = now - tonumber(lastRefill) " + "tokens = math.min(capacity, tokens + elapsed * refill) " + "redis.call('SADD', KEYS[1] .. ':last', now) " + "if tokens >= 1 then " + " tokens = tokens - 1 " + " redis.call('SADD', KEYS[1] .. ':tokens', tokens) " + " return 1 " + "else " + " redis.call('SADD', KEYS[1] .. ':tokens', tokens) " + " return 0 " + "end"; Long result = (Long) jedis.eval(script, Collections.singletonList(key), Arrays.asList(String.valueOf(capacity), String.valueOf(refillRatePerSecond))); return result == 1L; } }上面这段代码的写法里SADD是刻意简化,真实生产环境建议用SET配合SETEX。核心思想是:令牌数不是每秒钟主动加,而是在请求进来时惰性计算——根据离上次请求的时间间隔算出应该补多少个令牌。这样不管请求密度多高,都不会有后台线程去刷令牌,性能上更干净。
3.2 限流参数的推算方法
限流参数不能拍脑袋定。根据预估流量来,100万人预约,假设30%的转化率,峰值请求30万。网关层到下单接口的限流阈值,可以参考库存和转化率,库存只有20万,即使30万请求全进来也不可能都买到。
更合理的做法是:下单接口的令牌桶容量设为可放行走读的10%到20%,比如3万到6万。这个数字不严谨,但大方向是对的——流量在被Redis和MQ消化后,真正的订单创建能力取决于数据库写入带宽,通常给到数据库能力的3倍就够了。
举个例子:数据库单库能稳定接收3000 QPS的写入(下单记录入库),那下单接口的流量最多放到9000 QPS。多了也不会变成更多成交,反而把数据库拖垮。限流不是限制成交,是保护后面的活命通道。
3.3 多级限流的配合
网关层的令牌桶只能挡住一部分流量,到了应用层还需要再做一层本地限流。因为网关层和后面的应用之间还有网络延迟、负载均衡等环节,应用层如果不保护自己,突发流量依然会把应用线程池打满。
应用层的限流可以简单得多,直接用一个本地令牌桶,比如AtomicInteger配合定时任务重新填充。更轻量的方案是在进入Controller之前加一个信号量,限制并发处理数,超过则直接返回“系统繁忙”。记住一个原则:越靠近用户侧,限流越粗;越靠近资源侧,限流越严。
4. 商品分片与库存预扣:把热点拆散
4.1 为什么要把库存拆分成多个分片
20万商品,如果每个商品都只在Redis里放一个库存Key,秒杀开始后,热点商品的Key会承受数十万级别的并发访问。Redis是单线程模型,一个Key的操作是串行的,即便加Lua脚本保证原子性,这个节点上的其他Key也会被拖慢。
更关键的是,一个商品只有一个库存Key,意味着所有买家的扣减操作都集中在一个Key上。即使你能容忍这个Redis节点CPU满载,扣减操作的耗时也会从微秒级涨到毫秒级,极端情况下直接超时。所以必须做商品分片,把一个商品的可售库存拆成多份,分别存放在不同的Redis Key上。
假设某商品总库存1万件,拆成16片,每片625件。请求按userId哈希到固定的一片,片内再去原子扣减。用户每次请求面对的是一个“分片库存”而不是全局库存,大大降低了单Key压力。
分片数量怎么定?我的经验是单片库存最小不低于500件,否则容易出现某个分片先扣完、其他分片还剩货的局面,用户体验不好。像上面1万库存拆16片,每片625件,已经偏小,通常我会把单片的库存调到800到1000件,拆成10到12片。
4.2 库存扣减的原子性:Lua脚本是标配
库存扣减必须原子,除非你想超卖。用Java代码先查后改,一定会出问题——两个线程同时读到库存还剩1件,各自扣减,最终库存变成-1。解决方式是用Redis的Lua脚本,把“查库存和减库存”作为一个原子操作执行。
下面是生产环境里常用的一段扣减脚本:
-- KEYS[1]: 库存分片Key -- ARGV[1]: 扣减数量,一般传1 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock then return -2 -- 库存不存在 end if stock < tonumber(ARGV[1]) then return -1 -- 库存不足 end redis.call('DECRBY', KEYS[1], ARGV[1]) return stock - tonumber(ARGV[1])Java侧调用:
public boolean deductStock(String key, int quantity) { String script = "local stock = tonumber(redis.call('GET', KEYS[1])) " + "if not stock then return -2 end " + "if stock < tonumber(ARGV[1]) then return -1 end " + "redis.call('DECRBY', KEYS[1], ARGV[1]) " + "return stock - tonumber(ARGV[1])"; Long result = jedis.eval(script, Collections.singletonList(key), Arrays.asList(String.valueOf(quantity))); return result >= 0; }还有一个细节:Redis的DECRBY本身就是原子的,为什么还要包一层Lua?因为你要先判断“库存是否足够”再扣减,这个判断和扣减必须是一体的。单纯DECRBY可能出现库存减到负数。当然,你也可以先用DECRBY再判断返回值,如果小于0就回滚加回去,但这样逻辑绕了一圈,不如Lua直接。
4.3 用户维度分片:保证不超卖也保证一人一单
库存分片解决的是单Key热点问题,但还有一层要防的是“一人多单”。如果同一个用户连续点击10次,每次请求被哈希到不同的分片,就可能创建10个订单。所以分片的哈希维度不能是随机数,必须是用户ID。
// 根据userId计算分片下标 int shardIndex = Math.abs(userId.hashCode()) % SHARD_COUNT; String stockKey = "seckill:stock:" + productId + ":shard:" + shardIndex;这样同一个用户的所有请求永远落在同一片,这一片内的库存扣减是原子的,只要这一片被扣过一次,后续重复点击时库存不足就直接拒绝。再配合一个用户维度的幂等标记,就能做到一人一单。
幂等标记的Key通常是seckill:order:userId:productId,首次请求时SETNX成功,后续请求看到Key存在就直接返回“您已参与过该活动”。给这个Key设置一个合适的过期时间,比如活动结束后24小时,防止内存被无意义地长期占据。
5. Redis分片与缓存架构:让每一笔操作都有归属
5.1 一致性哈希和预分片的取舍
Redis分片有两种思路。一种是动态分片,用一致性哈希环,节点增减时Key自动迁移;另一种是预分片,提前把Key空间划分成固定数量的槽位,比如1024个槽,每个Redis节点负责一部分槽。
秒杀场景下我强烈建议用预分片。原因有几个:
- 一致性哈希虽然迁移方便,但迁移过程中Key的访问会有短暂不可用,秒杀这种不能停的场景难以接受。
- 预分片的方式简单粗暴:把商品ID哈希后对分片总数取模,Key就固定落在某一片。运维时直接掌握每片上的数据量,不会出现哈希倾斜。
- 秒杀的活动时长通常只有几分钟到几小时,数据量是可控的,没有频繁扩缩容的需求,预分片完全够用。
预分片的实现思路:
public class RedisShardRouter { private static final int SHARD_COUNT = 32; private final List<JedisPool> shardPools; public JedisPool route(String key) { int shardIndex = Math.abs(key.hashCode()) % SHARD_COUNT; return shardPools.get(shardIndex); } }这里有个容易被忽略的点:String.hashCode()在某些JDK版本下可能出现负数,所以取模前一定要取绝对值。更严谨的做法是取key.hashCode() & 0x7fffffff % SHARD_COUNT,避免极端情况下Integer.MIN_VALUE取绝对值后溢出。
5.2 库存Key的过期时间设计
库存Key在秒杀结束后就没有意义了,应该设置过期时间,但有一定讲究。假设活动持续30分钟,把过期时间设为30分钟,看起来没问题。但实际运营中经常出现“活动延长15分钟”的情况,如果Key已经过期,库存数据就丢了。
我的处理方式是:不单独设置短过期时间,而是在活动结束后由定时任务统一清理。清理动作把分片库存Key和用户幂等标记Key一起删除,避免残留数据占用内存。同时给库存Key设置一个兜底过期时间,比如48小时,防止定时任务挂了造成内存泄漏。
5.3 热点Key的拆散:把读请求分摊到多个副本
分片解决了库存扣减写操作的单点问题,但还有一个读操作热点。秒杀开始前,所有人都在轮询“还有没有库存”,这个查询请求密密麻麻地打到Redis上。库存Key即使分片了,单个分片的读QPS依然可能非常高。
常规手段是给热点Key增加副本。比如把原来一个库存Key拆成N份副本Key,每次读取时随机读一个副本,写操作时同步写所有副本。读请求被分散到N个Key上,单个Key的访问量降到原来的1/N。
public String getStock(String productId) { // 使用随机后缀,从多个副本中读 int randomSuffix = ThreadLocalRandom.current().nextInt(COPY_COUNT); String copyKey = "seckill:stock:copy:" + productId + ":" + randomSuffix; return jedis.get(copyKey); }注意,这里副本Key的写操作要同步,一旦发生扣减,所有副本都要同步更新。如果扣减不同步,副本之间的库存数据就会不一致,可能超卖。所以这种副本方案只适合“读多写少”的场景,秒杀开始后的扣减操作最好还是走原始分片Key,副本Key只用于“还有没有库存”的查询。
6. 分布式锁在秒杀里的正确用法:该用的时候才用
6.1 什么时候才真正需要分布式锁
很多秒杀代码里动不动就RedissonClient.getLock("stock:1001").lock(),看起来天经地义,但仔细想想:库存扣减的原子性已经被Lua脚本保证了,还给谁加锁呢?
分布式锁真正发挥价值的地方有两个:
第一个是创建订单的幂等控制。用户提交订单时,需要先判断这个用户是否已经下过单。判断和写入如果分开做,可能出现两个请求同时判断“都没下单”,然后各自创建了订单。这时候用分布式锁把“判断+写入”这个过程保护起来,是对的。
第二个是防止库存回补与扣减并发。比如运营在活动结束后手动回补库存,或者系统自动释放超时未支付的订单,这些操作和用户扣减库存可能同时发生。如果不加锁,可能出现回补的库存被新请求立刻扣走的情况。
RLock lock = redisson.getLock("seckill:order:create:" + userId); boolean locked = false; try { locked = lock.tryLock(500, 10, TimeUnit.SECONDS); if (!locked) { return Result.fail("系统繁忙,请稍后再试"); } // 检查幂等标记 Boolean first = redisTemplate.setIfAbsent( "seckill:order:" + userId + ":" + productId, "1", 24, TimeUnit.HOURS); if (!first) { return Result.fail("您已参与过该活动"); } // 扣减库存(Lua脚本) boolean deduct = deductStock(stockKey, 1); if (!deduct) { redisTemplate.delete("seckill:order:" + userId + ":" + productId); return Result.fail("商品已售罄"); } // 发送MQ创建订单 mqTemplate.send("seckillOrderTopic", buildOrderMessage(userId, productId)); return Result.success("抢购成功"); } catch (Exception e) { log.error("seckill failed", e); return Result.fail("系统异常"); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } }注意上面这段里的失败回滚:幂等标记和库存扣减必须保证一致性。如果库存扣减失败,幂等标记要回滚,否则用户这次失败后就被永久记住了。这个很容易遗漏,我见过不少线上问题就出在这里。
6.2 锁粒度与锁超时:不要锁商品,要锁用户
加锁的粒度要尽可能小。锁库存Key粒度太粗,所有用户抢同一个商品时都在等同一把锁,并发直接退化成串行;锁用户ID的粒度更合适,一个用户同一时间只有一个请求能创建订单,不同用户之间互不干扰。
锁超时时间也要仔细算。tryLock(500, 10, TimeUnit.SECONDS)的意思是等待锁最多500毫秒,持锁时间最多10秒。为什么持锁时间设10秒而不是更长?因为秒杀场景下后端处理链路极短,正常情况几百毫秒就能完成。如果10秒还没完成,要么是Redis挂了,要么是业务逻辑卡住了,这时候让锁自动释放可能比“永远锁死”更好。但要注意,如果业务真的超过10秒,锁提前释放会导致幂等标记被并发穿过。所以持锁时间要根据实际业务耗时留足余量,建议设置成3倍于正常耗时。
6.3 Redisson的看门狗机制要不要依赖
Redisson默认的lock()会启动看门狗,默认30秒续期。看起来很美好,但秒杀场景下我不太建议依赖看门狗。原因有两个:
- 看门狗的续期逻辑依赖于锁持有线程还在执行,如果线程卡在MQ发送上,锁会一直续期,别人永远等不到。
- 秒杀场景要求的是“快速失败”,而不是“无限等待”。用带超时时间的
tryLock,超时后直接返回失败,比等锁更符合秒杀的特性。
另一个和Redisson相关的点是公平锁。公平锁可以保证请求按顺序获取锁,但在高并发下会让锁等待队列很长,后面的请求全部超时。秒杀场景本来就不是公平的,先到先得就是设计目标,所以默认的非公平锁就够了。
6.4 分布式锁之外的方案:乐观锁与原子操作对比
有时候根本不需要锁。比如回补库存的场景,可以先更新数据库里的库存字段,带上版本号:
UPDATE seckill_product SET stock = stock + #{supply} WHERE product_id = #{productId}数据库的行锁天然保证了这个更新的原子性,压根不需要Redis分布式锁。Redis锁只有在并发跨度超过单行SQL的能力时才必要,比如多个Redis Key上的数据需要同时修改,这时才用得上分布式事务锁。
从我踩过的坑来看,大多数“需要分布式锁”的判断,其实是无脑套用。动手之前先问自己:我这个操作能不能用一条原子命令完成?如果能,就别上锁。
7. 配置中心Apollo/Nacos:秒杀开关和阈值的动态调整
7.1 为什么秒杀必须依赖配置中心
秒杀活动的配置变更非常频繁:活动开始前要打开开关、压测时要调整限流阈值、活动中发现某个商品卖完了要紧急下架。如果这些变更都要发版上线,不光慢,而且风险大,一不小心就全量出问题。
配置中心的价值在于配置与代码分离,支持实时热更新。把秒杀开关、限流阈值、白名单、活动时长这些参数放到Apollo或者Nacos上,线上修改配置,应用无需重启即可感知。Apollo和Nacos对比如下:
| 维度 | Apollo | Nacos |
|---|---|---|
| 配置管理能力 | 更成熟,支持灰度发布、配置回滚、权限管理 | 支持基本配置管理,功能更轻 |
| 注册中心能力 | 无 | 自带注册中心,可统一服务发现 |
| 热更新机制 | 推送更及时,客户端长连接 | 也支持长连接推送 |
| 适合场景 | 纯配置管理、团队配置规范要求严格 | 微服务体系内顺带解决配置问题 |
选型上,如果团队里已经用了Nacos做注册中心,那配置顺手就放Nacos;如果配置管理要求高,比如需要配置审计、精确灰度,那Apollo更合适。我个人的做法是:秒杀这种短期高并发活动,倾向用Apollo,因为它灰度发布、一键回滚在应急时更快。
7.2 秒杀开关的实时生效
秒杀活动通常分三个阶段:未开始、进行中、已结束。用配置中心管理开关,可以在不重启服务的情况下切换状态。
@ConfigurationProperties(prefix = "seckill") @Component public class SeckillConfig { private volatile boolean enabled = false; private volatile int limitRate = 5000; private volatile int limitCapacity = 20000; private volatile int stockShardCount = 16; private volatile List<String> whiteList = new ArrayList<>(); // getter setter 省略 }配置中心的监听器会实时把最新的配置注入到这个类中,接口里直接判断:
@GetMapping("/api/seckill/{productId}") public Result seckill(@PathVariable Long productId, HttpServletRequest request) { if (!seckillConfig.isEnabled()) { return Result.fail("活动未开始"); } // 白名单验证,内部测试系统专用 String userId = request.getHeader("X-User-Id"); if (seckillConfig.getWhiteList().contains(userId)) { return startSeckill(productId, userId); } // 常规限流 if (!rateLimiter.tryAcquire()) { return Result.fail("请求过于频繁,请稍后再试"); } return startSeckill(productId, userId); }白名单有多重要?压测时经常需要一个专门的测试账号不走限流、不占库存,直接验证完整下单链路。没有白名单,压测账号每次都被限流挡住,根本测不到真实情况。
7.3 阈值热更新的风险控制
配置中心的“实时更新”是把双刃剑。改配置虽然方便,但如果改了之后有误操作,影响面是瞬间扩大的。比如限流阈值从5000改到50000,一旦写错,后面所有流量都能穿透到Redis和MQ,可能直接把活动冲垮。
所以我建议所有配置中心的修改走变更流程:先在测试环境验证,再推到预发,最后才上生产。Apollo支持命名空间和环境的隔离,实践上可以把秒杀配置单独放在一个命名空间里,只有特定权限的人能改。
7.4 本地缓存与配置中心的配合
配置中心的实时监听有一个性能问题:每次请求都去配置中心拿配置显然不现实。实际做法是启动时把配置拉取到本地,配置中心推送变更后再刷新本地缓存。
@Component public class ConfigRefresher { @Autowired private SeckillConfig seckillConfig; @ApolloConfigChangeListener("seckill") public void onChange(ConfigChangeEvent changeEvent) { for (String key : changeEvent.changedKeys()) { String newValue = changeEvent.getChange(key).getNewValue(); switch (key) { case "enabled": seckillConfig.setEnabled(Boolean.parseBoolean(newValue)); break; case "limitRate": seckillConfig.setLimitRate(Integer.parseInt(newValue)); break; case "whiteList": seckillConfig.setWhiteList(Arrays.asList(newValue.split(","))); break; default: break; } } } }配置变更后,本地的RateLimiter、库存分片数量这些参数如果已经初始化过,都需要做对应的重建。这里有一个坑:分片数量变更后,库存Key已经按旧分片写好了,新分片会拿不到库存。所以分片数量这类参数只能在活动开始前修改,活动进行中改了也没意义,甚至会把存量数据搞乱。我在设计时会把这类参数标记为“仅启动时生效”,在配置变更监听里过滤掉。
8. 异步化与MQ削峰:数据库最后一道防线
8.1 订单创建流程的异步改造
库存扣减已经完成,但订单不能立刻插入数据库。原因很简单:数据库的写入能力有限,如果所有抢购成功的人同时写订单表,数据库一样会崩。
处理方式是把订单创建的消息发送到MQ,由消费者异步创建订单。用户在接口层面看到的是“抢购成功”,但实际订单会稍后入库,状态为“待支付”。
链路如下:
- 用户请求下单接口。
- 接口层扣减Redis库存,发送
SeckillOrderMessage到MQ。 - 接口层立即返回“抢购成功,请在15分钟内完成支付”。
- 消费者从MQ拉取消息,处理订单创建。
- 用户前端轮询订单状态接口,查到订单号后跳转支付页。
MQ在这里的作用是削峰,把瞬时写入压力转成均匀的流式压力。消费者可以根据数据库处理能力动态调整消费速率,这是阻塞式同步调用做不到的。
8.2 消息可靠投递与消费者幂等
MQ引入后带来的新问题是消息可能重复消费。比如消费者处理完订单后还没来得及ACK,服务重启了,MQ重新投递消息,同一订单会被创建两次。
解决办法是消费者侧做幂等。看到一个通知消费前先判断订单是否已经存在,订单号可以用userId + productId的哈希生成,天然保证同一用户对同一商品只创建一条订单。
@KafkaListener(topics = "seckillOrderTopic", groupId = "seckill-consumer") public void onMessage(SeckillOrderMessage message) { String orderId = generateOrderId(message.getUserId(), message.getProductId()); // 先查订单表,已存在则跳过 Order order = orderMapper.selectByOrderId(orderId); if (order != null) { log.info("duplicated order, skip: {}", orderId); return; } // 创建订单,幂等插入 try { orderMapper.insert(Order.build(message)); } catch (DuplicateKeyException e) { log.warn("duplicated order id inserted: {}", orderId); } }消息积压也是秒杀的高频问题。如果消费者处理速度跟不上,MQ里的消息越积越多,用户端会看到“抢购成功但订单迟迟不出现”。压力测试时要重点观察消费者吞吐量和消息积压量,必要时提前扩容消费者实例。
8.3 支付超时释放库存的回补策略
用户抢购成功后如果15分钟内没有支付,这批库存要释放回池,否则会浪费大量可售库存。回补策略有几种,我一般用延时消息。
用户下单成功的同时,往MQ发送一条延时15分钟的消息,消息内容是订单ID。15分钟后消费者消费到这条消息,先检查订单状态,如果仍然未支付,就关闭订单并回补库存。
回补库存的流程要注意原子性:先更新订单状态为已取消,再回补Redis库存。如果先回补库存,再更新订单状态,可能用户正好在支付的瞬间被系统回补了库存,导致支付成功但订单被取消了。顺序不能反。
回补库存的操作依然用Lua脚本,保证不会超卖:
-- KEYS[1]: 库存分片Key -- ARGV[1]: 回补数量 local stock = tonumber(redis.call('GET', KEYS[1]) or 0) redis.call('SET', KEYS[1], stock + tonumber(ARGV[1])) return stock + tonumber(ARGV[1])9. 常见问题排查与避坑技巧实录
9.1 Redis热点Key导致节点CPU满载
症状是秒杀开始后,某个Redis节点CPU跑到100%,其他节点很闲,接口整体延迟变高。排查时发现该节点集中了最热门商品的库存Key。
解决办法是前面说的库存分片和副本读。但还有一个细节:Redis Cluster模式下,Key的分片是自动的,你不能指定某个Key落在哪里。这种情况下热点Key无法手动迁移,解决思路是把Key拆散:热商品库存拆成多个Key,数据相同,每次请求随机访问一个。这种副本方案只适合读多写少的查询场景。
9.2 超卖问题:库存减到负数
超卖多半不是Redis扣减出了问题,而是业务链路里某个环节绕过了Redis库存直接落库。最常见的是运营后台手动改库存,或者售后流程自动回补库存时没有走Lua脚本,而是用Java先查再改。
排查方法是在所有修改库存的地方打上统一日志,标注操作来源。一旦出现负库存,通过日志就能定位到是哪个环节绕过了原子操作。治本的方法是库存的读写必须全部收敛到一个服务,不允许其他服务直接操作库存Key。
9.3 缓存雪崩与穿透
秒杀活动开始前,大量商品缓存同时失效,所有请求同时打到数据库,这是缓存雪崩。常规规避方法:缓存过期时间加随机值,不同商品过期时间错开;或者提前预热缓存,活动开始前就把热点商品数据加载到Redis。
缓存穿透更隐蔽。比如攻击者不断请求一个不存在的商品ID,Redis里查不到,数据库里也没有,每次请求都打到数据库。对大促场景,恶意刷接口是常事。对策是接口层加布隆过滤器,不存在商品ID直接返回;或者在库存Key不存在时,设置一个空值缓存并给定短过期时间,防止请求反复穿透。
秒杀用的商品ID在活动前就固定了,完全可以用布隆过滤器把所有合法商品ID预先加载进去,接口层直接过滤非法请求。
9.4 分布式锁的坑:锁失效导致库存误扣
用Redisson时如果配置不当,锁会在Redis主从切换时丢失。比如锁在主节点上写入,还没来得及同步到从节点,主节点宕机,从节点升级为主节点后锁就没了。这时候另一个线程可以拿到同一个Key的锁,幂等校验被穿透。
这个问题的标准解法是红锁(RedLock),但红锁本身也有争议,它依赖多个独立Redis节点的协调,在极端情况下依然可能失效。秒杀场景的实效性要求很高,我更推荐从业务设计上绕开对锁的强依赖。比如幂等校验用SETNX配合过期时间,库存扣减用Lua,尽量做到无锁化。只有创建订单这个环节用了分布式锁,那已经是业务链路的最后一道防线了,就算锁短暂失效,最坏的结果也就是多创建几个重复订单,通过数据库的唯一索引兜底拦截。
9.5 100万预约的分流处理
预约用户不等于参与用户。100万人预约,真正在开抢瞬间点击的可能只有30万人甚至更少。但这个预约量本身就是信号:活动预热期可以按预约用户量做容量预估,提前扩容Redis分片数量、增加MQ消费者实例、评估数据库写入带宽。
预约用户在开抢前还可以做一次提前分流:通过客户端推送提醒用户设置“抢购闹钟”,把用户的注意力集中到固定时间点,这样反而让流量更集中。从系统角度看,这是好事——流量集中在可预期的窗口内,限流参数能设计得更准确。
9.6 压测时要模拟真实用户行为
压测不是简单地把QPS压上去就完了。真实用户的请求特征是有思考时间、有重试逻辑、有网络抖动,而不是恒定速率地灌流量。压测时要把用户的行为模型带进去:一部分用户最早几秒内进入,另一部分用户分批到达,还要模拟超时重试的情况。
压测之后要重点看几个指标:接口P99延迟、Redis节点的CPU和内存、MQ积压量、数据库连接数和慢查询。如果P99延迟超过1秒,就要考虑是不是有线程池排队、GC停顿或者Redis热点Key的问题。
10. 个人在多次秒杀活动里的几点体会
方案写得再好,落到细节还是会出幺蛾子。我自己经历过印象最深的一次事故:活动开始前半小时,运维误操作把一个配置中心的限流阈值从5000改成了500000,数字多打了一个0。秒杀开始后,流量直接冲垮了Redis分片的几个节点,导致部分用户抢购失败。复盘的时候发现配置中心的权限管理没做到位,限流阈值这种关键参数应该单独设置权限,且变更时要有二次确认。
还有一次是活动结束后发现库存数据对不上,排查了半天,最后定位到是回补库存和用户支付动作并发导致的。用户在下单后15分钟内支付,而库存回补的延时消息恰好在支付完成之前触发了取消逻辑,把已经支付的订单给取消了。后来加了状态机校验,订单状态为“已支付”时不允许取消,才算彻底解决。
秒杀方案的组件很多,但每个组件都不是万能的。令牌桶拦不住所有的请求,Redis分片解决不了所有的热点,分布式锁也不是银弹。关键是要想清楚:每一层到底在解决什么问题,流量在这个链条上是怎么被一步步消耗掉的。架构设计不是为了堆组件,而是让流量有序地流向下游,直到真正需要写入数据库的那一刻。
这个方案还有很多可以扩展的地方,比如结合用户行为分析做动态限流、把库存分片数量做成自适应调整、引入商家维度独立限流等。但不管怎么扩展,核心思路不变:越早削峰,系统的压力越小,稳定性越高。这套模型经历过多次真实大促的验证,初学者拿去直接用,应该能少走不少弯路。