☰
SpringCloud秒杀系统架构设计:限流削峰与防超卖实战指南
2026/10/15 4:43:49 网站建设 项目流程

秒杀这个场景,我在不同项目里至少完整落地过三次,从最初单纯靠加机器扛流量,到后面形成一整套 SpringCloud 微服务秒杀架构,最大的感受是:秒杀系统真正难的不是“高并发”三个字,而是流量集中、热点突出、库存一致这三件事凑在一起。很多团队翻车,不是并发太高,而是架构设计没想清楚每一层到底拦什么、放什么。这篇文章我会把秒杀架构的设计思路、防超卖实现、限流与削峰细节、以及压测阶段踩过的坑完整讲一遍,适合正在做秒杀方案、或者准备对现有系统做高并发改造的开发者,内容偏实战,直接可以往自己项目里迁移。

1. 秒杀流量的三个反直觉特征:为什么一堆服务一起上还是扛不住

先别急着画架构图。很多团队拿到秒杀需求后的第一反应,是横向扩容网关、扩容下游服务、把数据库连接池调大,觉得只要机器够多就能顶住。但实际上,秒杀场景的流量模型和平常的系统压力完全不是一个量级,有三个非常反直觉的特征,决定了你必须在架构上做拦截式设计,而不是靠堆资源硬抗。

1.1 瞬时流量峰值:平均 QPS 完全骗人

秒杀的商品可能就 1000 件,但参与人数可能几万甚至几十万。商品开抢的那一秒,流量瞬间达到峰值,然后快速衰减。比如某次活动,日常 Gateway 平均 QPS 只有两千,秒杀那一秒直接冲到八万,峰值是平时的四十倍。如果你拿“平均 QPS 四五千”去规划资源,那这一秒就会被打穿。

所以秒杀架构的第一条原则:全部设计都必须围绕峰值流量来做,而不是平均流量。并且峰值流量只持续几秒到几十秒,后续的请求大多是查结果、看订单、轮询状态,这部分流量也要有效承接,否则用户体验会很差。

1.2 热点商品的数据倾斜:流量不是均匀分摊的

高并发系统最怕的是流量都打在一个点上。秒杀商品天然是热点中的热点,所有用户同时访问同一个 SKU 的详情、库存、下单接口,这个 key 会承受几乎全部压力。缓存集群如果不做热点分散,同一个 Redis key 的访问量能把单节点 CPU 打满,其他节点还闲着。

我自己遇到过一次很典型的缓存击穿,原因是预热逻辑漏了一个隐藏商品,秒杀开始后这个 key 在缓存里不存在,流量直接穿透到数据库,数据库 CPU 瞬间跑满,整个系统跟着抖动。所以设计阶段就要把“热点 key 多副本 + 提前预热”写进方案,而不是等上线后再补救。

1.3 库存一致和用户体验是对立的

用户看到“有货”不等于系统一定要保证他能买到,但系统绝对不能出现“显示有货,下单却失败”“卖超了”这类问题。秒杀的库存一致性要求如下:

  • 不超卖:数据库库存不能为负数,且已售数量不能超过总库存。
  • 不重复下单:同一个用户同一场活动只能买一件。
  • 快速反馈:用户点了抢购,系统要在几百毫秒内给出排队结果。

这三条要求决定了你不能用最简单的数据库行锁方案扛流量,也不能为了性能完全放弃一致性。正确做法是分层处理:前面用 Redis 做高吞吐预扣,后面用数据库做最终一致兜底。这个双保险机制是秒杀系统的核心,后面我会专门展开讲。

2. 四层拦截架构:从网关到数据库,每一层到底拦什么

完整秒杀系统的流量链路大概是:客户端请求 → 网关层(校验、限流) → 业务服务层(用户校验、Redis 预扣、发送 MQ) → 异步消费(生成订单、数据库扣减) → 结果回写。每一层都有明确职责,核心思想是让绝大多数请求在入口处就被拦截掉,能走到数据库的请求越少越好。

2.1 网关层:请求校验与全局限流的第一道闸门

SpringCloud 微服务架构里,第一道入口通常是 SpringCloud Gateway。这里不要做复杂业务,只做四件事:

  1. 校验请求合法性(签名、token、活动状态)。
  2. 按用户维度和商品维度做限流。
  3. 将秒杀请求路由到独立的秒杀服务,避免影响普通下单链路。
  4. 对静态资源(商品图片、活动页面)走 CDN 或本地缓存。

限流我建议在网关层做全局限流,因为它是所有请求的统一入口。这里有一个很容易忽略的点:限流为什么要做“用户维度 + 全局维度”两级?因为如果只做用户维度,脚本可以换账号绕过;如果只做全局维度,一个正常用户的重试请求也会被误杀。两级限流配合,才能既挡住脚本,又不误伤正常用户。

我在网关层用的限流方式是 Redis 计数器脚本,配合 Lua 保证原子性。下面的代码是每用户每商品单位时间窗口内最多允许 N 次请求的简单实现:

-- KEYS[1] = 限流 key,如 seckill:rate:userId:goodsId -- ARGV[1] = 时间窗口(秒) -- ARGV[2] = 窗口内最大请求数 local current = redis.call('GET', KEYS[1]) if current and tonumber(current) >= tonumber(ARGV[2]) then return 0 end redis.call('INCR', KEYS[1]) redis.call('EXPIRE', KEYS[1], ARGV[1]) return 1

网关 Filter 中调用这个脚本,返回 0 直接丢弃请求并提示“操作太频繁”。实测中这种方式单机 Redis 就能支撑每秒几万次计数,远高于直接调接口的频率,瓶颈几乎不会出现在这里。

2.2 业务服务层:微服务拆分的职责边界

网关做完拦截之后,真正处理核心业务的是秒杀服务。建议单独拆一个 seckill-service,不要跟商品管理、订单管理等普通服务混在一起。理由很简单:秒杀场景的流量模型和普通业务完全不同,单独拆出来方便独立扩缩容、独立降级,不影响主链路。

SeckillService 内部的核心流程是:

  1. 校验用户登录态、活动状态、是否已购买。
  2. 用 Redis Lua 脚本原子预扣库存。
  3. 预扣成功则生成一个秒杀令牌,把“下单指令”发送到消息队列。
  4. 立刻返回“排队中”给用户,前端用轮询或推送获取最终结果。

注意,这里的秒杀服务本身不直接操作数据库扣库存,更不直接插入订单。它只负责“判断资格 + 预扣缓存库存 + 投递消息”,真正的数据库写入交给异步消费者完成。这么做的原因很直接:数据库承受不了瞬时高峰写入,但 MQ 队列可以缓冲。

2.3 异步消费层:数据库压力削峰的关键

异步消费者从 MQ 里拿消息,逐条处理真正的下单逻辑:事务里先做数据库条件扣减库存,再插入订单记录,然后写入 Redis 的秒杀结果缓存。由于消费者消费速度是可控的,数据库的写入速率也可以控制在安全范围。

我在项目中选择了 RabbitMQ 作为消息中间件,原因比较简单成熟稳定,社区资料多。选型时也考虑过 RocketMQ,它的事务消息和延时消息能力更强,但团队当时对 RabbitMQ 更熟。如果从零开始并且团队没有历史包袱,RocketMQ 处理秒杀这类场景会更顺手,尤其是延时队列可以直接实现“超时未支付关闭订单”这类需求。

MQ 消费端有两个必须处理的细节:第一是消息幂等,消费者收到重复消息时不能重复下单;第二是消息不丢失,消费失败要重试,重试多次后进入死信队列人工处理。幂等我通常用订单表唯一索引解决,也就是 (user_id, goods_id) 加唯一约束,重复插入自然失败。

2.4 为什么这样分层:每一层拦截的意义

整个链路设计可以用一句话概括:能在入口拦截的绝不放进服务里,能缓存解决的绝不打数据库,能异步处理的绝不同步等待。

库存只有 1000 件,但来了 10 万个请求。如果这 10 万个请求全部打到数据库,数据库大概率直接打爆。但是通过网关限流先扔掉一部分,通过 Redis 预扣库存快速拒绝一批抢不到的请求,真正进入 MQ 的可能只有一千多个,最后数据库处理的并发量完全可控。这就是分层拦截的价值:不是并发来了硬接,而是让每个请求“过五关”,走不到数据库的就提前结束。

3. Redis 预扣与数据库条件更新的双保险:防超卖的核心实现

防超买是秒杀系统的技术核心,也是面试里被问得最多的一块。我先说你最容易想到的“常规方案”为什么行不通,再说我的双保险实现。

3.1 为什么不直接用数据库行锁

最朴素的做法是:下单接口直接操作数据库,加行锁,先查库存,够则减库存并创建订单。代码如下:

SELECT stock FROM seckill_stock WHERE goods_id = ? FOR UPDATE; UPDATE seckill_stock SET stock = stock - 1 WHERE goods_id = ?; INSERT INTO seckill_order(user_id, goods_id) VALUES (?, ?);

问题在于:行锁保证了同一时刻只有一个事务在改这条记录,数据库不会超卖。但秒杀是瞬时万级甚至十万级并发,每个请求都要拿数据库连接、等待行锁。数据库行锁等待会让连接很快耗尽,请求响应时间飙升,最终表现为整个数据库不可用。

还有另一个问题是数据库行锁的粒度太粗。一次秒杀只有一个商品,行锁集中在同一行记录上,并发能力被锁粒度卡死,加再多的应用节点都没用,瓶颈在数据库这一行。所以秒杀系统必须要有一个“高吞吐的前置判断”,把大部分请求挡在数据库之外。

3.2 Redis Lua 脚本原子预扣:把库存判断放在缓存层

Redis 单线程执行 Lua 脚本,脚本执行期间不会被其他命令插入,天然具备原子性。预扣库存的核心脚本如下:

-- KEYS[1] = 秒杀商品库存 key,例如 seckill:stock:1001 -- ARGV[1] = 本次扣减数量,通常为 1 local stock = tonumber(redis.call('GET', KEYS[1]) or '0') if stock < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1

这个脚本做的事情是:先取当前缓存库存,如果 >= 扣减数量,则原子减库存,返回 1;否则返回 0。由于 Redis 是单线程模型,这段逻辑不会存在并发问题,两个请求同时进来也会排队执行,不会出现都看到库存为 1、都扣减成功的情况。

但是预扣只解决“Redis 里的数字不超卖”,不能解决“数据库最终库存不超卖”。因为如果消息重复消费、或者某个消费者扣减失败,Redis 库存和数据库库存会不一致。数据库仍然是最终防线,必须用一个条件更新语句保证物理库存不为负。

3.3 数据库条件更新:最终防线的实现

数据库防超卖的写法不是“先查后更新”,而是把判断放在 UPDATE 条件里:

UPDATE seckill_stock SET stock = stock - 1 WHERE goods_id = #{goodsId} AND stock > 0;

这句 SQL 的执行效果是:如果当前库存大于 0,则扣减 1,返回受影响行数 1;如果库存已经是 0,则不更新,返回受影响行数 0。在同一个事务里,再插入订单记录:

@Transactional public boolean createOrder(Long userId, Long goodsId) { int rows = stockMapper.decreaseStock(goodsId); if (rows == 0) { // 库存不足,抛异常或返回失败 return false; } SeckillOrder order = new SeckillOrder(); order.setUserId(userId); order.setGoodsId(goodsId); orderMapper.insert(order); return true; }

因为 UPDATE 本身会对行加排他锁,两个并发事务同时执行时,只有一个能更新成功,另一个会阻塞到前一个事务提交后再执行,但此时 stock 已经是 0,受影响行数为 0,插入订单的逻辑不会执行。这个方案在网络抖动或极端异常下依然能保证不超卖,这就是“数据库最终兜底”的含义。

3.4 超时未支付与库存释放:容易被忽略的补偿机制

秒杀通常有支付超时限制,比如 15 分钟未支付,订单关闭,库存要回补。这部分的正确做法是:在 Redis 预扣库存后,给这条预扣记录设置超时时间;数据库扣减库存成功则自然占用,失败则释放。

我采用的方案是 RabbitMQ 延迟消息:用户预扣库存成功后,投递一条延迟消息(例如延时 15 分钟),消费者收到后检查订单是否已支付,未支付则关闭订单并回补库存。回补库存也有两个动作:

-- 恢复 Redis 预扣库存 redis.call('INCR', KEYS[1])
UPDATE seckill_stock SET stock = stock + 1 WHERE goods_id = ?;

这里要注意,Redis 回补和数据库回补不是同一个时刻发生的,中间可能会有短暂不一致。我的做法是数据库先回补成功后再回补 Redis,并在两个动作之间做好日志记录,如果 Redis 回补失败,通过定时任务扫描订单状态,找出“已关闭但 Redis 库存未恢复”的订单做补偿。实际项目中,这种因为极端异常导致不一致的概率很低,但必须设计好补偿链路,否则库存会越积越少。

3.5 限购一次:用 Redis 标记位和数据库唯一键双重拦截

大多数秒杀活动都限制一人一件,我常用的防重复购买手段是两级拦截。第一级在 Redis 里设置用户购买标记,为了减少 key,可以设置过期时间为活动结束时间:

Boolean first = stringRedisTemplate .opsForValue() .setIfAbsent("seckill:bought:" + userId + ":" + goodsId, "1", Duration.ofSeconds(activityExpireSeconds)); if (!Boolean.TRUE.equals(first)) { // 已购买过 return fail("您已参与过本场秒杀"); }

这一级拦截非常快,但存在一个风险:如果用户第一次预扣成功后、发送 MQ 前服务宕机了,这个标记位已经写入,用户无法再次抢购,但这不影响超卖,只影响用户体验,属于可接受范围。

第二级在数据库订单表加唯一索引 (user_id, goods_id),这是防重复购买的最终保障。即使 Redis 标记丢失,数据库唯一索引也会拦截重复订单。这两级拦截配合使用,基本不会出现一人多单。

4. 限流、热点缓存、异步削峰的三个细节

架构搭好之后,真正决定能不能跑赢峰值的是三个细节:限流到底怎么做才不误杀,热点 key 怎么防击穿,MQ 消费端怎么保证不重复、不丢失。这三个问题每一个都是在压测阶段让我熬夜排查过的,顺序讲一下。

4.1 全局限流 + 服务端限流:两级哨兵的意义

网关全局限流解决的是“别让入口被打爆”,但服务端本身也要有自我保护机制。因为网关可能被绕过、限流规则可能配置失误,或者某个瞬间流量过大导致服务线程池被打满,Sentinel 之类的熔断限流组件在这里很有用。

我通常这样配置:

层级维度限流方式触发动作
网关层用户 + 商品维度Redis 计数器脚本直接拒绝,返回活动火爆
网关层全局 IP/用户维度滑动窗口拒绝高频脚本
服务层接口 QPSSentinel 限流抛出限流异常,快速失败
服务层依赖资源(Redis/MQ)Sentinel 熔断降级降级到提示稍后再试

有人觉得限流规则太多会误杀正常用户。我的经验是:宁可误杀少量正常请求,也不能让整个系统崩溃。秒杀场景本身就是一个“小概率抢到”的场景,用户对失败页面的接受度远高于对系统无响应的接受度。但要注意,限流后的提示文案要自然,比如“活动太火爆,请稍后再试”,而不是直接返回 500 或者空白页。

4.2 缓存预热与防击穿:秒杀前两小时就要做的事

秒杀开始前,需要把商品库存、活动状态、商品基本信息都提前加载到 Redis 中。缓存预热我一般用独立的预热任务,在活动开始前 10 分钟执行,检查 Redis 中是否已有 key,没有则从数据库加载。

预热本身不难,难在“热 key 的击穿防护”。如果活动开始瞬间,热点 key 的缓存刚好过期,所有请求会同时回源到数据库,数据库会被打崩。我的习惯是给热点缓存设置“不设置过期时间 + 后台异步刷新”,或者设置很长的过期时间,比如活动结束后 30 分钟,然后由后台任务定时刷新。绝对不要在秒杀高峰期间让热点 key 自然过期。

另外,对于单个热点 key,推荐做多副本缓存。例如把库存 key 拆成 seckill:stock:1001:0、seckill:stock:1001:1、seckill:stock:1001:2 等多个子 key,下单时随机取一个子 key 进行预扣。这样可以避免所有请求都打在同一 Redis key 上,再把压力传导给单节点。拆分子 key 后要注意总库存的统计,需要对所有子 key 求和,或者维护一个总库存 key 只读不扣。

4.3 MQ 消费端:幂等与不丢消息

消息队列削峰能力很强,但随之而来的问题是消费端可能收到重复消息。RabbitMQ 本身有确认机制,消费者手动 ack,消息处理成功后才确认,处理失败进入重试队列。这套机制保证了“至少一次”投递,但做不到“恰好一次”,所以消费端必须做幂等。

幂等判断有三种做法,我推荐优先用数据库唯一索引:

  1. 订单表加 (user_id, goods_id) 唯一索引,重复插入直接抛 DuplicateKeyException,捕获后视为重复消息,直接 ack。
  2. Redis 判断订单是否已存在(key 为 seckill:order:userId:goodsId),存在则跳过。
  3. 业务逻辑上先查询订单是否存在,存在则不再创建。

数据库唯一索引是最值得信任的,因为幂等最终要回到存储层,Redis 标记丢失了还有数据库兜底。这里顺便说另一个很容易犯的错:消费端不要把消息的 ack 逻辑放在 try 块里最后执行,要做到“处理成功才 ack,处理失败不 ack”——RabbitMQ 默认会在消费端抛出异常时重新投递该消息,这个机制天然支持消费失败无限重试,但要注意重试风暴问题,配置合理重试次数并设置死信队列。

4.4 结果回写与前端轮询:秒杀链路怎么闭环

用户提交抢购请求后,服务端返回的是一个“排队令牌”,具体下单结果由 MQ 消费完成后写入 Redis。前端需要轮询获取结果,轮询接口设计要轻量,不能查数据库。结果缓存结构通常这样设计:

seckill:result:userId:goodsId -> { "status": 1, "orderId": "123456" }

status 为 1 表示抢购成功,0 表示失败,-1 表示仍在处理中。前端每隔 2 到 3 秒轮询一次,拿到终态后停止轮询。轮询接口是对 Redis 的读取操作,压力很小,只要 Redis 正常,这个接口即便被高频调用也不会出问题。

4.5 服务降级预案:活动期间保护好下单主链路

秒杀期间的降级是必须提前想好的。我会在秒杀活动期间关闭一些非必要功能,比如优惠券计算、积分明细、个性化推荐,把这些服务的流量全部降级掉,把系统资源留给秒杀链路。具体做法是:秒杀服务与其他服务之间通过 Feign 调用时,设置超时时间短一点,加上 Sentinel 的 fallback,调用超时直接返回默认值,不因为某个辅助服务变慢而拖垮秒杀主链路。

5. 压测验证与实战踩坑:数字不会说谎

架构设计和代码实现只能保证“看起来合理”,上线前必须用压测把性能边界摸清楚。我这里说的压测不只是拿 JMeter 设置几个线程跑一跑,而是要模拟真实秒杀流量模型,并且把每一层的瓶颈找出来。

5.1 压测怎么做才接近真实

压测脚本需要模拟两种行为:

  1. 抢购请求:秒杀开始时,大量用户同时点击,这部分请求会在 1 到 2 秒内全部到达。
  2. 轮询请求:抢购提交后,前端开始轮询结果,这部分流量会在之后持续几十秒。

单独压接口没有意义,要把网关、Redis、MQ、数据库整条链路一起压。我用的 JMeter 参数大致这样:

压测项参数配置观察指标
并发线程数5000压测机本身不成为瓶颈
请求总时长30 秒QPS、响应时间
库存初始值1000是否超卖(订单数 <= 1000)
数据库记录提前记录库存压测后核对数据库库存 + 订单数
Redis 监控每秒采样内存、命令执行耗时

压测完成后,必须人工核对数据库订单数量是否等于实际售出的库存数量,以及有没有出现负数库存。我第一次做秒杀时就是因为只看了接口成功率,没核对订单数,差点把一次超卖事故带上线。

5.2 三个真实翻车点与修复过程

第一是 Redis 连接耗尽。压测初期,每来一个请求就 new 一个 Jedis 连接,没有用连接池,QPS 到两千的时候 Redis 连接先撑不住了。修复方式是把连接池最大连接数调到合理值,同时检查是否有人把 RedisTemplate 写成了每次创建新实例。高并发框架下,所有 Redis 操作必须复用连接池。

第二是 RabbitMQ 消费堆积。预扣库存后消息瞬间进入队列,消费端单线程处理,数据库扣减 + 订单插入一次大约耗时 20 到 50 毫秒,消费速度跟不上生产速度。修复方式是启动多个消费者实例,并把消费者的 prefetch 设置为 50,让每个消费者一次抓取多条消息批量处理,而不是一条一条取。注意,消费者并发增加后,数据库的写入压力也会增加,需要同步观察数据库连接池情况,找出两者的平衡点。

第三是数据库连接池被打满。即使做了 MQ 缓冲,消费者并发调大之后,数据库连接数还是很容易达到上限。我的经验是把数据库连接池最大连接数、最小空闲数都显式配置,并且设置合理超时时间。同时,秒杀订单表单独放在一个库或者一个 schema,和普通订单逻辑隔离,避免互相干扰。

5.3 日常容易漏掉的小问题

除了这三个大问题,还有一些小坑会在关键时刻给你一击。

  • 压测机本身不要成为瓶颈,网上有人说用单台 JMeter 压测到了几千 QPS 上不去了,其实是压测机的连接数和线程数不够,不是系统瓶颈。
  • Redis 和 MySQL 的密码、端口、超时时间必须在压测环境里和真实环境一致,否则压测结果不可信。
  • 秒杀商品详情页要静态化,不要每次请求都查数据库,可以放到 CDN 或者 Nginx 缓存中,只把参与动态判断的接口留给后端。

5.4 上线前检查清单

最后列一份我每次秒杀上线前都会过一遍的清单,给你直接抄作业:

  • 库存预热任务已执行,Redis 库存数量正确。
  • 用户购买标记已初始化,不会误判已购买。
  • MQ 消费者已启动,队列无积压。
  • 数据库库存表有唯一索引和扣减条件。
  • 网关限流规则已发布并验证。
  • 秒杀结束后库存余量核对脚本已准备。

6. 如果重新设计一次,我会先做什么

这篇文章写到这里,把秒杀系统从流量分析、分层架构、防超卖、限流削峰到压测踩坑都过了一遍。如果让我回到第一个秒杀项目开始的前一天,我会先去压测环境把 Redis Lua 预扣的吞吐量测出一个基准值,再根据这个数字反推网关限流的阈值和 MQ 消费者的数量。很多团队把时间花在讨论技术选型上,比如用 RabbitMQ 还是 RocketMQ,用 Sentinel 还是自研限流,这些都有得聊,但最终真正决定系统能不能扛住峰值的是“每一层能放多少流量进来”这个数字。把这个数字测准,再把防线一层层设置好,剩下的问题都是可解的问题。

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

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

立即咨询