☰
SpringCloud微服务秒杀架构:高并发削峰与防超卖实战
2026/10/14 21:58:48 网站建设 项目流程

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用户ID
sku_idbigint商品ID
batch_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 这两个隐患,否则真正上线那天,这两颗暗雷足以让整个活动功亏一篑。

做秒杀系统,就像修一座大坝,平时看起来平平无奇,洪峰来的时候才知道它到底坚不坚固。希望这篇文章能帮你少走一些弯路,让你在面对“秒杀”这两个字时,比曾经的我们从容一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询