SpringCloud微服务秒杀架构:高并发削峰与防超卖实战

发布时间:2026/10/14 21:58:53
SpringCloud微服务秒杀架构:高并发削峰与防超卖实战 1. 为什么秒杀这种“极端场景”必须认真设计架构聊到 SpringCloud 微服务秒杀架构很多人第一反应是“不就是做个下单接口嘛”真上手做一次就知道秒杀是电商技术栈里最苛刻的练兵场没有之一。它把所有常见的分布式问题——高并发、一致性、超卖、雪崩、热点——全部压缩到几秒钟内集中爆发。我参与过的某电商项目中第一次压测时服务直接被流量打挂复盘后才发现问题根本不在代码逻辑而在架构模型的选择。秒杀系统的核心需求其实只有三条不让系统崩、不能超卖、用户能快速拿到结果。这三条互相牵扯任何一个环节掉链子整个活动就塌了。比如为了防超卖在数据库里做行锁结果把下单链路拖成“龟速”用户疯狂刷新流量又压上来形成恶性循环。再比如为了追求性能全走缓存结果缓存穿透打穿数据库照样雪崩。SpringCloud 在这里出现的原因也很直接。秒杀场景天然适合做流量分层流量入口、业务校验、库存扣减、订单落库、结果通知每一步的负载特征截然不同必须拆成独立的服务来隔离故障和扩缩容。单体应用扛 1000 TPS 可能没问题但秒杀瞬间冲上来的是几十万 QPS你总不能让订单服务和支付服务共享一个进程一个接口拖垮全部业务。我见过把秒杀模块硬塞进商城 monolith 里的项目活动开始十分钟后整个商城都无法访问代价极其惨痛。这套架构适合谁来参考如果你是刚接触微服务的开发者或者团队准备做一次秒杀活动但拿不准技术选型这篇内容可以帮你把思路理清楚。我会把我在实际项目中踩过的坑、压测出来的数据、最终稳定运行的方案完整拆开讲包括为什么某些“看起来正确”的做法会翻车以及正确的兜底方案应该怎么设计。2. 整体架构拆解每一层到底负责挡住什么流量秒杀系统的本质是“有限资源、海量请求”所以架构设计的主线就是四个字层层削峰。客户端、网关、应用、数据库每一层都承担不同的拦截职责把真正的写压力压缩到数据库能承受的量级以内。2.1 从客户端到数据层的四层流量拦截策略先看我最终落地的那套分层结构每层都有明确的使命层级核心职责关键手段可承载量级客户端层降低无效请求按钮置灰、倒计时、轮询转长连接不产生额外压力网关层粗粒度限流令牌桶、IP维度黑名单、单机限流单机可达几万QPS应用层业务校验与削峰Redis缓存、消息队列、分布式锁异步化后可抗瞬时尖峰数据库层最终一致性兜底乐观锁更新、唯一索引、事务稳定在几百TPS客户端层的价值经常被忽略但它是最便宜的拦截方式。很多人觉得秒杀嘛反正客户都会疯狂点按钮干脆不管了。实际上如果不用 JS 把按钮置灰加倒计时任由用户多点几次每次点击都会给后端带来一次真实的接口请求。我们压测时发现一个不置灰的按钮最高能让单用户请求放大 20 倍这还是在没有恶意刷单的前提下。所以上线前一定要做好按钮防重复、倒计时同步、轮询转 WebSocket 推送结果这些基础工作。网关层做的是粗粒度限流只放行“看起来像正常用户”的请求。我们用 Sentienl 按接口维度配了令牌桶单用户维度做了滑窗限流同时把可疑的 IP 段拉黑。这一层能挡掉大约 40% 的无效流量但对秒杀来说还不够因为真正的洪峰是合法用户制造出来的需要应用层继续消化。2.2 业务服务的拆分边界是怎么定的微服务的拆分最关键的是找到“变化节奏不同”的边界。秒杀业务里商品查询是高频读库存预扣是高并发写订单创建是事务操作结果通知是异步推送这四者的资源消耗模型完全不同强行放在一个服务里注定互相干扰。我把项目拆成了五个独立模块秒杀商品服务负责缓存预热与商品详情、库存服务负责 Redis 预扣减与数据库兜底、订单服务负责创建订单与幂等校验、网关服务负责限流与路由、任务调度服务负责异步消费和状态补偿。每个服务独立部署在 3 个节点通过 Feign 做轻量通信注册中心用的 Nacos。这里有个很重要的经验服务和服务的边界要按“数据域”划分而不是按“功能操作”划分。库存扣减和订单创建看起来是同一个操作链路但库存关心的是商品维度订单关心的是用户维度两者数据的更新频率和一致性要求都不一样。硬把它们绑定在一个服务里后续你会为“一条慢事务拖垮库存扣减”这种问题心力交瘁。2.3 网关层选型与动态路由配置网关是整个流量的总入口我在项目中使用的方案是 SpringCloud Gateway 配合 Nacos 做服务发现和动态路由。Gateway 天然基于 WebFlux 反应式模型单机吞吐量测试下来比基于 Servlet 的 Zuul 高出一倍以上而且它的断言和过滤器机制非常适合做秒杀场景的定制化限流。路由配置上有一个细节值得注意秒杀接口要独立路由不要和普通商城商品接口混在一起。原因很简单秒杀接口的流量特征是瞬时洪峰普通接口是平缓爬坡混在一起会让网关的线程池和连接池被打满连带着普通用户也看不了商品。当时我特意把秒杀链路设计成独立的/seckill/**路径单独配置心跳超时和最大连接数。Gateway 的限流我用的是内置的 RequestRateLimiter 过滤器底层依赖 Redis 实现令牌桶算法。这个方案的好处是限流信息集中保存在 Redis 里多实例部署时也可以做到全局限流而不是每个实例各自为战。配置上要留意一点令牌桶的 replenishRate 参数单位是“每秒填充令牌数”burstCapacity 是“桶的容量”秒杀场景通常把 burstCapacity 设置成 replenishRate 的 3 到 5 倍给短瞬的流量突刺留一点弹性不然真实用户容易在高峰期被误伤。3. 削峰填谷消息队列如何把“洪峰”变“平流”限流只能挡掉趋势性的大流量真正苛刻的情况是10 万用户同时点按钮即使网关只放行 50%仍然有 5 万请求瞬间涌进应用层。这个量级没有任何数据库能扛得住。所以削峰必须靠异步化核心手段就是消息队列。3.1 为什么削峰对数据库如此重要数据库的瓶颈是“连接数 事务锁”。一台普通的 MySQL 实例并发稳定在几百个连接一旦连接数超过阈值新增请求只能排队等待等价于系统提前进入不可用状态。如果我们把下单请求同步打到数据库5 万个请求就意味着 5 万个事务数据库直接连不上完全在意料之中。生活里类比一下就很清楚自来水厂不可能直接对着一根家庭水管灌水中间必须有蓄水池来平缓流量。秒杀系统的消息队列就是那个蓄水池。用户点秒杀后应用层只做两件事——检查库存、投放消息然后立刻返回“排队中”。真正的订单创建和发短信动作全部由下游消费者慢慢完成。这个过程里我们用的是 RocketMQ没有选 Kafka。原因不复杂RocketMQ 的事务消息和定时消息机制对订单类场景更友好而 Kafka 的优势在日志类的顺序读写场景。电商秒杀里经常需要“库存已经扣了但订单还没有创建成功过几分钟再补偿”之类的反向操作RocketMQ 的事务消息可以直接把本地事务和消息发送绑在一起极大减少了手工补偿代码量。3.2 消息队列选型与 Topic 分配方案我建了三个 Topic秒杀订单 Topic、库存扣减补偿 Topic、结果通知 Topic。订单 Topic 的消息格式很简单里面只放 userId、skuId、quantity、请求 ID以及一个表示来源渠道的字段库存补偿 Topic 不是给正常链路用的是用来做“订单超时未支付”后的库存回补结果通知 Topic 则把秒杀成功、失败的结果发布出去WebSocket 服务订阅它来推送实时结果。Topic 的并发度设置直接决定了消费速度。RocketMQ 的一个 ConsumeQueue 同一时刻只能被一个消费者线程读取所以 Topic 的读队列数量需要和消费者实例数做匹配。我们在秒杀订单 Topic 上配置了 16 个读队列订单服务部署了 8 个实例每个实例消费 2 个分片单消费者线程拉取的并发度严格控制避免出现消费速度跟不上生产速度的问题。这里我踩过一个坑把消费者线程池配得过大导致单机同时消费几十个消息每个消息都去数据库做一次事务操作结果数据库连接被抢空消费反而更慢后来用 Semaphore 做并发闸门才解决了。3.3 消费端的事务边界与重试机制下游消费端创建订单时最怕的是同一条消息被重复消费。RocketMQ 提供的是“至少一次”语义也就是说正常情况下消息不会丢但极端情况下会重复投递。我们的解决方案是在订单表里建了(user_id, sku_id, batch_id)唯一索引这个 batch_id 就是秒杀开始的时间和随机数的组合从源头上保证同一个用户在同一场秒杀里只能创建一次订单。消费逻辑里还有一个非常重要的细节消息消费要确保事务边界清晰。创建订单、扣除数据库库存、写秒杀结果表这三步必须在一个本地事务里任何一步失败都整体回滚并抛出异常触发 RocketMQ 的重试。RocketMQ 默认重试 16 次每次重试间隔递增如果不做这个兜底消息在消费失败时会被直接丢弃用户明明秒杀到了却永远看不到订单那就闹出大问题了。4. 库存防超卖Redis Lua 与数据库乐观锁双层兜底防超卖是整个秒杀系统的灵魂。库存只有 100 件结果卖出去了 130 件领导和用户同时找上门这属于最不能容忍的线上事故。防超卖的原理层其实很简单就是“任何一步扣减操作都必须是原子性的”难的是在分布式框架下把原子性落到每个环节。4.1 为什么“先查再改”一定会出问题很多初学者在写扣减库存时用的是“先 SELECT 库存量再用 IF 判断库存 0然后 UPDATE”这套逻辑。这在单机低并发下不出问题但并发一上来就会错得离谱。原因是“先查再改”是两步操作两步之间有时间窗口线程 A 查到库存有 1 件线程 B 也查到库存有 1 件两个线程同时执行 UPDATE最后库存变成 -1超卖发生了。数据库事务的默认隔离级别是“读已提交”如果不用加锁手段这个时间窗口根本堵不住。有人会说“那我用 synchronized 锁住方法不就行了”这个想法在单体应用里勉强可行但在微服务架构下完全无效因为不同服务的实例跑在不同 JVM 里synchronized 锁不住别的节点。正确的做法有两层。第一层是 Redis 加 Lua 脚本做高并发的预扣减第二层是数据库的乐观锁更新做最终兜底。两层缺一不可。4.2 秒杀库存的 Lua 原子扣减脚本实现Redis 里我用了一个简单的 Hash 结构存储商品库存seckill:stock:{skuId}里面包含total总库存、sold已售量、version版本号。为什么用 Hash 不用 String因为在同一个 key 里既存总数又存已售量可以在 Lua 脚本里同时校验和更新不需要两次 get 和两次 set 的往返。Lua 脚本的扣减逻辑是这样的任何人都可以直接复用-- KEYS[1] 商品库存Key -- ARGV[1] 秒杀数量 -- ARGV[2] 用户ID用于做幂等标记 if redis.call(hexists, KEYS[1], ARGV[2]) 1 then return -1 -- 该用户已参与过秒杀直接拒绝 end local sold tonumber(redis.call(hget, KEYS[1], sold)) local total tonumber(redis.call(hget, KEYS[1], total)) if sold tonumber(ARGV[1]) total then return 0 -- 库存不足 end redis.call(hincrby, KEYS[1], sold, ARGV[1]) redis.call(hset, KEYS[1], ARGV[2], 1) return 1 -- 扣减成功这个脚本的关键在于Redis 服务端执行 Lua 脚本是原子性的整个脚本在执行期间不会插入任何其他命令。所以“检查用户是否已经秒杀过 检查库存 修改库存 记录用户”这四步是全有一体的从根本上消灭了竞态条件。正常情况下库存扣减脚本要控制在5 毫秒以内完成我们压测时单台 Redis 可以支持每秒数万次这样的扣减操作远远满足秒杀场景的需求。需要额外注意的是脚本返回 -1、0、1 三种状态要给前端不同的提示分别对应“重复秒杀”“已抢完”和“成功排队”语义一定要清晰方便调用方做差异化处理。4.3 数据库表的乐观锁兜底设计Redis 里的预扣减并不能百分之百保证最终数据一致因为 Redis 是缓存层存在宕机丢数据的极端情况而且订单创建失败时已经预扣的库存需要回滚。所以我必须引入数据库作为最终一致性的保障。订单表的设计上我用了seckill_order表核心字段如下字段类型说明idbigint主键user_idbigint用户IDsku_idbigint商品IDbatch_idvarchar秒杀场次标识statustinyint0已创建 1已支付 2已取消create_timedatetime创建时间而这个表上最关键的是uk_user_batch (user_id, batch_id)唯一索引它保证了同一用户在同一秒杀场次里永远只能有一条订单记录即使上游因为各种原因发了重复请求数据库层面也能硬挡住。库存表的减扣 SQL 是这样写的UPDATE seckill_stock SET stock stock - 1, version version 1 WHERE sku_id ? AND stock 0注意这个 SQL 有两个必须点一是用stock 0作为条件这样即使并发请求同时到达数据库的行锁也会让它们排队执行后执行的请求会因为 stock 等于 0 而更新不到任何行二是版本号机制它不只是“乐观锁”的象征更是为了后续做数据对账时判断是否有操作冲突。受影响行数等于 0 时就说明库存已扣完这时候哪怕 Redis 还在对外说“还有库存”也必须立刻停止销售并对 Redis 做同步修正。这里我有一个非常深刻的教训一开始我以为只要数据库有乐观锁Redis 预扣减可有可无结果压测时数据库在 2000 并发下就已经出现了大量锁等待超时订单超时率达到 30%。加了 Redis 预扣减后数据库的实际并发被限制在几百以内一切回归正常。Redis 是挡子弹的数据库是兜底的两者职责不同缺一不可。5. 缓存设计击穿、穿透与雪崩的秒杀版解法秒杀系统的数据访问模式极其特殊对商品详情和库存状态的请求几乎集中在同一时间、同一个热点 key 上这种集中式访问让缓存问题被无限放大。我在项目中遇到过缓存击穿导致数据库被打挂的事故当时 Redis 里商品的 key 恰好过期几万个请求同时穿透到数据库进而引发了整个服务的雪崩直到把所有热点 key 的过期时间统一改成“永不过期 人工过期”才稳定下来。缓存场景里最常见的问题是三类穿透查询不存在的数据、击穿热 key 过期瞬间、雪崩大量 key 同时过期。秒杀场景里商品详情是热数据库存扣减是高频写操作逻辑都围绕热点展开。击穿的解决方案业内通常用“互斥锁重建缓存”但我不推荐在秒杀链路中用线程锁因为等待锁会占用线程资源。更推荐的方式是给热 key 设置不同的过期时间同时在 value 里内嵌一个逻辑过期时间。读请求发现逻辑过期后先返回旧数据同时后台异步去数据库更新缓存用户感知层面完全无感。这个方案叫“逻辑过期 异步刷新”压测下来既不会阻塞请求线程又保证了缓存的数据新鲜度。雪崩问题看起来和击穿类似但成因是“批量 key 同时过期”应对方式也更粗暴过期时间加随机数。比如商品详情 key 的统一基础过期时间是 30 分钟再加一个 0 到 300 秒的随机值。这样即使同一批商品同时被加载进缓存过期的时刻也会错开不会形成同时打向数据库的压力峰值。至于穿透问题一是把空结果也保留在缓存里比如“该商品已下架”的标记二是用布隆过滤器先过滤掉不可能存在的 key但考虑到秒杀商品的 key 本身就在预热清单里穿透问题出现的概率很小所以不做过度防护。还有一个很重要的细节秒杀商品详情缓存里的库存字段更新时机一定要和 Redis 预扣减同步。你可以选择在秒杀开始前将商品详情缓存写入 Redis秒杀期间不主动去查询数据库的商品信息。我当时把商品详情和库存扣减拆成了两个 key详情 key 在前端用来展示库存 key 用在 Lua 脚本里这样即使库存频繁扣减导致当前值变化商品详情依然能稳定读取避免缓存频繁失效拖垮读性能。6. 幂等设计、防重复提交与秒杀结果的实时查询秒杀链路中有几个容易被忽略但一旦出错就会炸的点同一用户重复提交、用户重复查询结果、订单状态混乱。这些问题的本质是“同一个操作在同一个时刻被多次执行”需要靠幂等机制来解决。6.1 基于令牌的防重复提交机制用户点击秒杀按钮时前端会先向服务端请求一个随机 token这个 token 被存在 Redis 里并设置了 5 分钟有效期。用户真正提交秒杀请求时必须带上这个 token后端 Lua 脚本里会顺带检查并删除 token保证一个 token 只能用一次。这个过程相当于给每个用户发了一张“票”不管你重复点击多少次真正能进场的只有拿了票的那一次。前端拿不到 token 时按钮一直处于置灰状态这样可以同时避免手动绕开前端的“裸请求”。这个机制要放对程度token 本身不适合做限流它只是幂等验证的一种方式真正的流量闸门还是网关限流和 Redis 预扣减。6.2 秒杀结果的推拉结合查询方案秒杀是异步链路用户点击后结果可能在几十毫秒到几秒内才产生所以前端不能靠同步等待拿到结果必须用“推拉结合”的方式。我们提供了两个接口GET /seckill/result/poll和 WebSocket/ws/seckill/result。轮询接口的返回状态有三态排队中、成功、失败。WebSocket 是服务器主动推送给已登录的用户。这里有一个很关键的细节轮询接口的刷新频率一定要控制。每秒一次是最合适的不要为了“结果出的快”而把前端轮询改成几百毫秒一次那样会把查询接口打爆。我们的压测数据显示10 万用户同时轮询每秒再峰值为 10 万 QPS但结果查询接口完全无压力因为它只查 Redis 里的一个短时效 key不查数据库。结果查询和订单创建的逻辑必须保持一致用户秒杀成功但订单尚未落库时结果查询接口应该返回“排队中”而非“成功”否则用户会选择去支付但支付时发现订单不存在体验极差。所有状态都以订单流水表的状态字段为准这个表也是通过监听通知 Topic 来更新的。7. 秒杀系统的高可用部署集群、压测与降级预案软件所有的架构设计最终要落到实际部署上别以为写完了代码就万事大吉。我们的项目在正式上线前做了超过一周的反复压测和调优那些“看起来没问题”的配置在真实流量冲击下逐一露馅。部署架构和压测过程是决定秒杀系统能不能真正抗住流量的最后一道防线。7.1 集群规模与资源评估秒杀当天预估最大参与人数是 50 万设定同时在线 10 万我们最终确定的部署规模是服务实例数配置说明Gateway 网关44C8G负责限流与转发秒杀商品服务22C4G只做只读查询压力小库存服务24C8G高频 Redis 操作订单服务84C8G消费 MQ 写数据库RocketMQ34C8G三个节点Redis616G3主3从 哨兵MySQL216C32G一主一从 半同步复制8 个订单服务实例基于这样的考虑每个实例消费 Topic 的 2 个分片单实例下单吞吐量控制在 200 TPS 以内整体 1600 TPS配合 Redis 的拦截完全够用。这些实例在配置上要注意一个细节Gateway 的线程池连接数别设太大我见过有人把它设成 2000结果所有线程都阻塞在转发上CPU 完全跑满响应时间数据全线漂移。反向去思考秒杀的场景网关应该“宁可少放不可放多”连接数设置在 500 以内超出的请求直接返回“排队中”的效果。7.2 压测中发现的两个经典瓶颈压测工具用的是 JMeter 和 Gatling 混合压测模拟 10 万并发用户。发现的问题很有代表性分享出来供大家避坑。第一个瓶颈是 Redis 连接数被打满。开压后库存服务的 Redis 连接数瞬间冲到了 2 万单实例撑不住大量请求报 “Cannot get a connection from pool”。排查后发现是连接池配置不合理最小空闲连接数设置过小最大连接数设置过大导致高并发下连接反复创建销毁。最后把空闲连接数调到 50、最大连接数调到 200并用 Lettuce 的共享连接模式问题彻底解决。第二个瓶颈是订单服务的 GC 问题。消费 MQ 时大量高频创建对象老年代频繁触发 Full GC每次停顿上百毫秒消费者处理速率暴跌。解决方案有两种一是调整消费者线程数为 CPU 核数的二分之一减少同时处理的任务量二是把订单 ID、用户 ID 这些高频对象的包装类尽量改成基本类型并复用 DTO 对象减少内存开销。这两个问题都是只有在真实压测下才会暴露出来的类型光看文档永远发现不了。奉劝所有人秒杀系统上线前不压测等于裸奔那些流量模型和并发数字只有在压测环境里才能得到真实的“照妖镜”。7.3 降级与熔断预案高可用不能只靠“性能足够”还要设计好“性能不够时怎么办”。我在网关层配置了 Sentinel 的熔断规则如果订单服务异常比例超过 20%熔断十秒直接返回秒杀失败不再往底层转发。这个设计的逻辑在于秒杀活动的目标是“扛过去”如果底层服务已经处于不稳定状态继续往里灌流量只会加速崩溃。库存服务层面也做了降级策略如果 Redis 可用性出现异常系统自动进入“直接数据库扣减”的降级模式。虽然数据库扣减的 TPS 远低于 Redis 方案但至少用户请求不会被拒绝只是速度变慢兑换成“排队中”的提示把用户的等待感控制在可接受范围内。另外秒杀活动结束或库存耗尽时网关层会通过一个开关直接拦截所有秒杀请求不再打到下游服务。这个开关放在了配置中心里运维只需修改配置即可一键熔断不用重启服务。秒杀活动结束后把开关置为关闭下游服务立刻回归轻载状态整个系统的恢复速度非常快。8. 总结与实战体会我做完这个 SpringCloud 秒杀项目后最深的感触是秒杀系统拼的从来不是代码写得有多花哨而是对“流量漏斗”每一层的理解和转化是否到位。客户端挡一波、网关挡一波、Redis 挡一波、MQ 削峰、数据库兜底每一层都精确完成自己的拦截任务整个系统自然稳定。个人建议做秒杀项目时先不要急于敲代码先把这些问题的答案写下来你预计的最大 QPS 是多少数据库能扛住多少并发消息队列的消费能力是否匹配防超卖的双层方案分别是什么如果 Redis 挂了怎么办这些问题的答案直接决定了架构的雏形。不要用“上线了再说”的心态去做秒杀秒杀没有“线上再调”的机会因为流量只来这一次。如果有条件强烈建议做一次全链路的压测复盘把网关、Redis、MQ、MySQL 的所有监控指标全部拉出来看一遍找出瓶颈点然后针对性地做调整。我在项目里就是因为反复压测才发现 Redis 连接池和 GC 这两个隐患否则真正上线那天这两颗暗雷足以让整个活动功亏一篑。做秒杀系统就像修一座大坝平时看起来平平无奇洪峰来的时候才知道它到底坚不坚固。希望这篇文章能帮你少走一些弯路让你在面对“秒杀”这两个字时比曾经的我们从容一点。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询