
秒杀这个场景我在不同项目里至少完整落地过三次从最初单纯靠加机器扛流量到后面形成一整套 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。这里不要做复杂业务只做四件事校验请求合法性签名、token、活动状态。按用户维度和商品维度做限流。将秒杀请求路由到独立的秒杀服务避免影响普通下单链路。对静态资源商品图片、活动页面走 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 内部的核心流程是校验用户登录态、活动状态、是否已购买。用 Redis Lua 脚本原子预扣库存。预扣成功则生成一个秒杀令牌把“下单指令”发送到消息队列。立刻返回“排队中”给用户前端用轮询或推送获取最终结果。注意这里的秒杀服务本身不直接操作数据库扣库存更不直接插入订单。它只负责“判断资格 预扣缓存库存 投递消息”真正的数据库写入交给异步消费者完成。这么做的原因很直接数据库承受不了瞬时高峰写入但 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/MQSentinel 熔断降级降级到提示稍后再试有人觉得限流规则太多会误杀正常用户。我的经验是宁可误杀少量正常请求也不能让整个系统崩溃。秒杀场景本身就是一个“小概率抢到”的场景用户对失败页面的接受度远高于对系统无响应的接受度。但要注意限流后的提示文案要自然比如“活动太火爆请稍后再试”而不是直接返回 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消息处理成功后才确认处理失败进入重试队列。这套机制保证了“至少一次”投递但做不到“恰好一次”所以消费端必须做幂等。幂等判断有三种做法我推荐优先用数据库唯一索引订单表加 (user_id, goods_id) 唯一索引重复插入直接抛 DuplicateKeyException捕获后视为重复消息直接 ack。Redis 判断订单是否已存在key 为 seckill:order:userId:goodsId存在则跳过。业务逻辑上先查询订单是否存在存在则不再创建。数据库唯一索引是最值得信任的因为幂等最终要回到存储层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 到 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 还是自研限流这些都有得聊但最终真正决定系统能不能扛住峰值的是“每一层能放多少流量进来”这个数字。把这个数字测准再把防线一层层设置好剩下的问题都是可解的问题。