百万QPS秒杀系统设计:令牌桶、Redis分片、分布式锁与配置中心

发布时间:2026/10/20 10:49:59
百万QPS秒杀系统设计:令牌桶、Redis分片、分布式锁与配置中心
废话不多说直接进入正题。先说下背景。我接手这套秒杀系统时运营给的需求一句话就讲完了“20 万商品全部参加活动100 万人预约开抢那一下全给我接住。”但这句话背后是整个后端团队三个月的改造。标题里提到的令牌桶、Redis 分片、商品分片、分布式锁、Apollo/Nacos 配置中心每一个都在这个项目里真实落地过也真实踩过坑。这篇文章把完整方案从前往后捋一遍配上能直接抄的代码适合正在做电商秒杀、准备大促压测的 Java 后端同学也适合想搞明白“百万 QPS 到底是怎么扛下来”的架构师。1. 100万预约不等于百万QPS先把问题拆成四个瓶颈1.1 预约用户数和瞬时并发之间的换算关系先把一个关键认知掰清楚100 万预约用户开抢瞬间能打出来的峰值 QPS 不是 100 万。除非用压测工具把所有请求灌在同一毫秒否则用户的点击会被倒计时差异、网络抖动、不同设备的时间偏差自然扩散。我这边压测过100 万预约在开抢瞬间的真实峰值大概在 20 万到 40 万 QPS 之间如果提前做了全平台预热冲击到七八十万也有可能。所以标题里的“百万 QPS”实际含义是“按百万级压测目标去设计系统冗余”而不是说所有预约用户都会在同一毫秒发起请求。这个认知直接影响后面的流量分配策略想清楚之后很多方案说起来才有依据。1.2 秒杀真正要解决的四个瓶颈点第一个瓶颈是读请求。用户开抢前会疯狂刷新商品详情页、倒计时、活动状态这部分读流量通常会占到整体流量的 70% 以上远超过真正的下单流量。第二个瓶颈是库存扣减这是最硬的并发点——20 万库存的商品所有请求都集中在一个 Redis key 上单实例 Redis 就是这么被打满的。第三个瓶颈是下单写库MySQL 的并发写能力有限绝对不能让每个点击都直接落库。第四个瓶颈是重复请求人在焦急等待时会不自觉地反复点击前端如果没做拦截请求量可以轻松放大 5 到 10 倍。把这四个点看清楚后面每一层方案的设计目的就很明确了。整个解题思路就四个词削峰、限流、拆热点、异步化。2. 从点击到下单请求要跨过五道闸门2.1 链路全景每一层到底拦掉什么我在实际项目里设计的链路大概是这样浏览器 → CDN 静态页 → Nginx/API 网关验签、限流→ 秒杀应用服务令牌桶二次限流、风控校验→ Redis库存分片、Lua 原子扣减、排队状态→ MQ异步下单→ 订单服务 → MySQL。每一层都在做减法。CDN 挡住绝大部分静态流量到源站的请求已经少了一个数量级网关做验签和第一道全局限流把非法请求和明显超阈值的流量拒掉应用层按商品维度做细粒度限流和幂等校验Redis 用分片和 Lua 脚本扛住真正的并发扣减压力MQ 把下单动作从同步变成异步削掉订单创建的高峰。最终到达 MySQL 的写请求已经被压到和真实订单量同一个数量级。2.2 最容易忽略的分工原则越靠近用户的层拦截越多这个原则听起来像废话但真正落地时很多人会做反。有人一上来就在 Redis 后面加分布式锁当作瓶颈保护——结果锁本身成了新的单点。正确的思路是能在外层拒绝的请求绝不放进内层能异步做的操作绝不在同步链路里做。我给自己定了一条硬规矩数据库层只能看到真正秒杀成功的请求。理想情况下MySQL 的写流量和真实成交订单量一个数量级而不是和用户点击量一个数量级。这套链路能扛住百万 QPS 压测核心就是这句话。只要这条规矩立住了整套系统基本不会崩。3. 前端与接入层的拦截按钮、CDN、排队号3.1 页面静态化与服务端时间秒杀详情页里 90% 的内容在活动前后是不变的商品图、价格、卖点、规格参数这些全部静态化到 CDN用户点击时根本不回源站。只有倒计时、活动状态、剩余库存这些信息需要动态展示而且这部分会单独封装成一个小接口从 Redis 读不走后端渲染。这里有一个特别容易被忽略的细节倒计时的起始时间必须用服务端时间。前端在页面初始化时请求一次服务端时间和本地时间做差值缓存之后用这个差值校准本地倒计时。不然用户把系统时间一改倒计时提前结束就会产生一批提前请求。虽然后端还会再做一次时间校验但平白增加无意义流量完全没有必要。3.2 重复点击、幂等 token 与客户端随机错峰开抢那一刻前端立刻把按钮置灰同一用户短时间内的多次点击在前端直接丢弃。这只是基本操作真正有用的是随机错峰开抢后的 0 到 300 毫秒内给每个用户分配一个随机延迟让流量自然地扩散开。但前端的错峰只是软性的后端必须硬兜底。用户点击秒杀前先调一次预检接口拿一个幂等 token同一个用户同一场活动只能持有一次有效 token。后续的秒杀接口必须携带这个 token 才能进入业务逻辑。前端随机延迟加 token 校验实测能过滤掉至少 30% 的无效流量。3.3 用排队号轮询结果而不是刷新详情页用户提交秒杀请求后页面要立刻变成“排队中”而不是让他继续刷新。前端拿回一个排队号用 1 到 3 秒一次的低频轮询去查状态。这个查询接口只查 Redis 里的排队映射不查订单表更不查 MySQL。很多团队在这里犯了错误让用户不停调“订单查询”接口结果把数据库压垮了。排队号的设计也有讲究如果 Redis 里已经标记该商品售罄直接返回“已售罄”前端关掉轮询如果还在排队返回队列位置或者预计等待时间给用户一个确定性。一个低频的排队轮询接口代替了高频的订单详情刷新对整个系统的压力差别是巨大的。4. 令牌桶限流单机不可靠要用 RedisLua 做全局令牌桶4.1 为什么选令牌桶而不是固定窗口或漏桶固定窗口有一个经典缺陷窗口临界点附近的突发流量可能两倍于预期限流效果打折扣。漏桶的输出是匀速的确实很稳但秒杀场景里的“匀速”意味着本来可以立即进入的下单请求也要排队用户体验和库存消耗速度都会受影响。令牌桶允许一定程度的突发——桶里攒下的令牌可以在开抢的第一秒集中放行一批请求这正好匹配秒杀“让第一批手快的用户尽快完成下单”的目标。我见过有人坚持秒杀用漏桶更稳但我觉得漏桶更适合做数据库层的下游保护不能把保护数据库和入口限流两件事混在一起。入口层用令牌桶DB 前的队列用漏桶思路各司其职。4.2 分布式令牌桶的 Lua 实现与调用代码单机令牌桶很简单Guava 的 RateLimiter 就能用。但秒杀服务是集群部署的如果 20 个节点每个单独限 1 万 QPS整体就能放出 20 万 QPS完全不可控。所以必须有一个全局的令牌桶。我用 Redis 存储桶状态用 Lua 脚本保证“取令牌 更新状态”的原子性。下面是完整脚本local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) -- 每秒补充速率 local now tonumber(ARGV[3]) -- 当前时间戳毫秒 local requested tonumber(ARGV[4]) -- 本次请求需要的令牌数 local token_key key .. :tokens local ts_key key .. :ts local last_tokens tonumber(redis.call(get, token_key) or capacity) local last_refresh tonumber(redis.call(get, ts_key) or now) local delta math.max(0, now - last_refresh) local new_tokens math.min(capacity, last_tokens delta * rate / 1000) if new_tokens requested then redis.call(set, token_key, new_tokens - requested) redis.call(set, ts_key, now) return 1 else redis.call(set, token_key, new_tokens) redis.call(set, ts_key, now) return 0 endJava 侧通过 Spring Data Redis 的 DefaultRedisScript 调用Bean public DefaultRedisScriptLong tokenBucketScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(token_bucket.lua)); script.setResultType(Long.class); return script; } public boolean tryAcquire(String key, int capacity, double rate, int requested) { Long result redisTemplate.execute( tokenBucketScript, Arrays.asList(key), String.valueOf(capacity), String.valueOf(rate), String.valueOf(System.currentTimeMillis()), String.valueOf(requested) ); return result ! null result 1L; }这里有一个我踩过的坑第一次写脚本时用redis.call(get, token_key)直接拿值但 key 不存在时返回的是空串Lua 转成 number 会变成 0导致桶永远从空开始第一批请求全被拒。改成tonumber(redis.call(get, token_key) or capacity)之后桶初始是满的开抢瞬间的突发能力才恢复。这种细节单独很难发现压测时第一秒流量表现不对劲就要往这个方向查。4.3 限流阈值怎么定三层层级配置全局令牌桶不是只建一个。我通常拆成三层用户维度限制单个用户每秒最多 N 次请求用来防脚本刷单。商品维度限制单个商品每秒进入库存扣减流程的最大请求数。全局维度限制整个秒杀场景的总流量作为 System 级兜底。三个维度各自维护独立的 Redis key互不干扰。参数不靠拍脑袋靠压测。我当时的估算体系是这样的入口网关最多放 60 万 QPS 进来商品维度的令牌桶按热度分开配20 万库存的爆款商品放 8 万 QPS 进入扣减全局维度放 30 万 QPS 做兜底。真正走到 Redis 扣减的流量已经只剩几万到几千因为桶在最外层会拦截掉绝大多数并发。提示令牌桶的容量和速率必须拆成两个配置项。容量决定突发能力速率决定长期吞吐。秒杀开抢时突发流量很大容量可以设高一点比如等于商品库存的 1/10让桶在一开始拦住人但不至于全部拒绝。5. Redis 分片与商品分片把热点 Key 拆碎才能上百万5.1 单实例 Redis 的瓶颈在哪里单实例 Redis 的 QPS 大概在 8 万到 12 万之间单独看不算低。但秒杀场景的所有请求都在打同一个商品 key而这个 key 只存在于一台 Redis 实例上等于全部流量压一个点。更麻烦的是写操作在 Redis 里是串行的热点 key 一旦热起来整台实例的 CPU 都会被打满还会连累同一实例上的其他业务键。所以必须做两层拆分第一层让 Redis 本身可以水平扩展第二层让同一个商品的热点 key 被拆成多个分散到不同实例上。5.2 Redis 分片的选型Cluster 还是客户端分片Redis Cluster 是官方方案数据按 slot 自动分布客户端自动路由扩缩容时支持数据迁移。有运维精力的团队我建议直接用 Cluster省心。客户端分片例如基于一致性哈希把 key 分散到多个单实例 Redis做起来轻量但节点增减时需要自己处理数据迁移。缓存场景可能忍得住秒杀这种严格场景会增加不少额外复杂度。但不管用哪种分片都不能只靠 Redis 层解决热点问题。因为一个商品的 key 哈希到哪个 slot 是固定的可能两个爆款商品恰好落在同一台实例上热点依然集中。所以商品分片是必选项。5.3 商品库存分片与扣减策略商品分片就是把一个商品的库存 key 拆成多份。20 万库存拆成 20 个分片每个分片 1 万key 设计为seckill:stock:{goodsId}:{shardIndex}。一个请求进来根据goodsId userId联合哈希决定优先访问哪个分片也可以用随机数核心是让请求均匀落到不同 key 上。每个分片的扣减是独立的 Lua 脚本保证库存非负local key KEYS[1] local quantity tonumber(ARGV[2]) local stock tonumber(redis.call(get, key) or 0) if stock quantity then redis.call(decrby, key, quantity) return 1 else return 0 end主分片扣减失败时允许重试其他分片。这里要克制不能无限重试否则请求放大量级。我设的最大重试次数是 3 次3 次都失败就返回售罄。分片数也不是越多越好分片多单个 key 压力小但内存碎片、监控面、维护成本都会上升。20 万库存拆 10 到 20 片比较合适具体在压测里调。5.4 分片带来的库存碎片问题怎么收口分片后最怕出现库存碎片某个分片还有剩余库存其他分片却已经空了用户重试几次后依然失败页面显示“还有库存”但就是卖不出去。我在项目里的解法是用一个单独的 Redis 有序集合维护“当前还有库存的分片列表”。扣减前先从这个集合取一个分片扣减成功后如果该分片库存降到 0就把这个分片 id 移出集合。后续请求只会落到真正有库存的分片上碎片问题基本消除。这个集合本身也会成为热点所以访问它的逻辑也要轻尽量在应用内存里缓存一段时间防止它变成新的瓶颈。6. 扣库存到底要不要分布式锁Lua 与锁的分工6.1 Redis 扣减用 Lua 原子操作不在这里用锁很多人一听到秒杀就想到分布式锁但扣减库存这个动作我强烈建议不要用锁。Lua 脚本在 Redis 中是原子执行的天然避免并发超卖性能比分布式锁高一个数量级。分布式锁的获取、释放、续期都是额外开销在百万 QPS 的入口流量下锁机制本身就会变成新瓶颈。所以我的代码里真正扣减库存的路径上没有锁只有 Lua 脚本。6.2 分布式锁真正该用的场景幂等下单那分布式锁用在哪里答案是下游的下单流程。Redis 扣减成功后我会把订单创建任务丢进 MQ由订单服务消费。这个环节中同一个用户可能因为网络重试产生重复消息必须防止重复下单。我用分布式锁锁住userId goodsId维度锁内先查订单是否已存在存在直接返回不存在才创建订单。还有一种场景多个服务节点同时执行库存预热、活动开关翻转这类定时任务也需要分布式锁保证同一时刻只有一个节点在跑。6.3 Redisson 工程落地与锁过期的坑工程上我直接使用 Redisson代码非常简洁RLock lock redissonClient.getLock(seckill:order: userId : goodsId); try { boolean locked lock.tryLock(0, 5, TimeUnit.SECONDS); if (!locked) { return Result.fail(操作太频繁, null); } // 幂等判断 // 创建订单 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里面有一个高频坑如果自己用SET key value EX 5 NX实现锁任务执行超过 5 秒锁就自动过期另一个节点又拿到了锁同一个用户就可能被创建两个订单。Redisson 的看门狗会自动续期能缓解这个问题但它不是银弹。秒杀场景里锁内代码必须短不能在锁里调用远程接口或者做慢 SQL。我把锁内的写库操作控制在 50 毫秒以内5 秒超时完全够用。解锁前一定要判断isHeldByCurrentThread防止锁被主动过期后误删其他节点的锁。这个细节不处理会遇到莫名其妙的并发问题而且特别难排查。7. Apollo 与 Nacos 配置中心秒杀活动里的动态开关7.1 没有配置中心时改个阈值要等发版我第一次做秒杀时还没有接入配置中心。压测发现限流阈值设得太高Redis 已经打到 90%想改只能改代码、发版、重启——对于一个可能只持续几分钟的活动这一套流程走完活动早结束了。后来我把所有秒杀参数都收入配置中心活动总开关、商品限流阈值、令牌桶容量、令牌补充速率、分片数、灰度放量比例。这个动作看起来不起眼却是活动能平稳落地的最重要保障。7.2 Apollo 动态调整限流参数的做法用 Apollo 时配置项定义在命名空间seckill下程序启动时根据配置初始化限流器。关键是监听配置变更事件在运行时重建限流器ApolloConfigChangeListener(seckill) public void onChange(ConfigChangeEvent event) { if (event.isChanged(seckill.limit.rate)) { String newRate event.getChange(seckill.limit.rate).getNewValue(); limiterRef.set(new TokenBucketLimiter(Integer.parseInt(newRate), capacity)); } }限流器对象必须包装在AtomicReference里因为多个线程会同时读写。变更时先构建新对象再set请求线程完全无感知。7.3 Nacos 在 Spring Cloud 场景下的动态刷新Nacos 的做法更贴近 Spring Cloud 生态。用NacosValue就能拿到自动刷新的配置值NacosValue(value ${seckill.limit.rate:8000}, autoRefreshed true) private Integer limitRate;不过这里有个坑NacosValue标注的字段在配置变化时会更新但如果这个值在启动时已经被拷贝进限流器对象字段更新不等于限流器更新。所以还需要配合一个配置监听逻辑把新值真正应用到请求线程使用的限流器上。我踩过这个坑配置中心显示已经改了但限流器参数还是老值等于白改。7.4 Apollo 与 Nacos 的选型对比简单说下我的实际感受比较维度ApolloNacos功能定位独立的配置中心不依赖注册中心配置中心和注册中心一体化配置实时性长轮询秒级生效支持灰度发布长轮询动态刷新能力满足秒杀场景可视化与权限独立管理界面环境、权限管理成熟管理界面完整也支持版本管理部署复杂度需要部署 Config Service、Admin Service、数据库单进程即可部署更轻量适用范围老项目、异构系统、强管理后台需求新项目使用 Spring Cloud顺手集成团队里用一种就够了不要两套配置系统并行。我当时的项目已经在用 Nacos 做注册中心配置也顺势收进 Nacos省了一套系统要维护。Apollo 更适合那些配置管理要求细、项目不是纯 Spring Cloud 技术栈的场景。8. 压测与实战中常见的坑和排查记录8.1 分片哈希不均匀某一台 Redis 还是打满第一次压测整体 Redis 负载确实下降了但有一台节点 CPU 一直是 100%。排查发现是分片函数的问题用商品 ID 直接取模而爆款商品的 ID 在生成时是连续的哈希结果容易落在同一个分片热点全压在一台上。后来改成goodsId userId拼接后再做一致性哈希热点被用户维度摊开负载才真正均匀。教训是分片函数不能随手写必须拿真实的商品 ID 分布做模拟验证看落到每个分片的请求量是否均衡。8.2 限流太死导致误杀排队用户进不来第一次上线时令牌桶参数设得非常保守。活动开跑一分钟后台显示大量用户排队但真正扣库存的请求少得可怜库存消耗速度远低于预期。我们把商品维度的令牌桶速率上调一档库存消耗立刻恢复正常。后来我养成了习惯限流阈值预留一档“安全上调”空间以压测数据作为建议值活动实际运行时根据库存消耗速度微调而配置中心恰好就是干这个的。8.3 先扣 Redis 再异步落库失败后的回补幂等这是最终一致性问题。Redis 扣减成功后MQ 消息可能投递失败或者订单服务挂掉出现“Redis 库存扣了但数据库没有订单”的情况。必须做回补检测到投递失败把库存加回对应分片。回补逻辑要求绝对幂等不能因为同一批订单数据被处理两遍就加两次库存。我在消息里带一个全局消息 ID消费端记录已处理 ID重复投递直接忽略。另外库存回补只能用incrby加回不能直接set覆盖否则会把同一时段扣掉的其他单子一起覆盖掉。8.4 预警与降级配置中心是最后的保险压测中最先挂掉的不一定是核心链路而是监控链路。监控采集太频繁会把压测机器的 CPU 打满进而影响压测数据所以压测时要把非必要日志和采样全部关掉。正式活动运行时我只盯三个指标Redis 集群 CPU、MQ 堆积量、订单落库成功率。任何一个异常第一反应就是去配置中心关闭秒杀入口或者把限流降一档——宁可少卖不能打死。这个预案因为配置中心是动态的一分钟内就能完成降级这是它能救场的关键。我自己的体会是秒杀方案没有银弹。令牌桶、商品分片、分布式锁、配置中心所有组件都在做同一件事把流量从同步链路上挪走。第一次压测跑到 40 万入口 QPS而 MySQL 写入只有 2000 的时候我才真正理解了“削峰”这两个字的分量。本文的代码和参数来自我实际项目中的沉淀具体数字换到你的场景里可能要重新压测但这套思路是通用的越靠近用户的层越要果断拦截越靠近数据库的层越要让它只见真订单。