资讯详情 百万QPS秒杀系统架构实践:流量分层削峰与库存设计
📅 2026/10/20 12:04:19
做电商的同学应该都躲不过这个话题秒杀。标题里这套方案——“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的。前端拿到剩余库存后本地缓存下来下次点击直接看本地标记不满足就直接拒绝这一步能挡住大量连点器导致的重复请求。具体实现上前端会在内存里维护一个OrderStateconst 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 ListJedisPool 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对比如下维度ApolloNacos配置管理能力更成熟支持灰度发布、配置回滚、权限管理支持基本配置管理功能更轻注册中心能力无自带注册中心可统一服务发现热更新机制推送更及时客户端长连接也支持长连接推送适合场景纯配置管理、团队配置规范要求严格微服务体系内顺带解决配置问题选型上如果团队里已经用了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 ListString 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。缓存穿透更隐蔽。比如攻击者不断请求一个不存在的商品IDRedis里查不到数据库里也没有每次请求都打到数据库。对大促场景恶意刷接口是常事。对策是接口层加布隆过滤器不存在商品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分片解决不了所有的热点分布式锁也不是银弹。关键是要想清楚每一层到底在解决什么问题流量在这个链条上是怎么被一步步消耗掉的。架构设计不是为了堆组件而是让流量有序地流向下游直到真正需要写入数据库的那一刻。这个方案还有很多可以扩展的地方比如结合用户行为分析做动态限流、把库存分片数量做成自适应调整、引入商家维度独立限流等。但不管怎么扩展核心思路不变越早削峰系统的压力越小稳定性越高。这套模型经历过多次真实大促的验证初学者拿去直接用应该能少走不少弯路。