废话不多说,直接进入正题。先说下背景。我接手这套秒杀系统时,运营给的需求一句话就讲完了:“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. 令牌桶限流:单机不可靠,要用 Redis+Lua 做全局令牌桶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 DefaultRedisScript<Long> tokenBucketScript() { DefaultRedisScript<Long> 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 的时候,我才真正理解了“削峰”这两个字的分量。本文的代码和参数来自我实际项目中的沉淀,具体数字换到你的场景里可能要重新压测,但这套思路是通用的:越靠近用户的层越要果断拦截,越靠近数据库的层越要让它只见真订单。
废话不多说,直接进入正题。先说下背景。我接手这套秒杀系统时,运营给的需求一句话就讲完了:“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. 令牌桶限流:单机不可靠,要用 Redis+Lua 做全局令牌桶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 DefaultRedisScript<Long> tokenBucketScript() { DefaultRedisScript<Long> 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 的时候,我才真正理解了“削峰”这两个字的分量。本文的代码和参数来自我实际项目中的沉淀,具体数字换到你的场景里可能要重新压测,但这套思路是通用的:越靠近用户的层越要果断拦截,越靠近数据库的层越要让它只见真订单。