ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

秒杀系统从0到100万QPS:高并发架构演进全复盘

秒杀系统从0到100万QPS:高并发架构演进全复盘

做秒杀系统这几年,我最大的感受是:每一个上过线的秒杀方案,几乎都是在用户流量冲垮系统之后才被迫想出来的。标题里提到的“从 0 到 100 万 QPS”,听起来像是一条线性增长的曲线,实际走一遍才会明白,这中间每一次架构演进,都是在跟物理资源较劲、跟数据一致性较劲、也跟自己的认知局限较劲。

这篇文章我想用一次完整的技术复盘形式,把秒杀系统从最早的单机应用,一路做到百万级 QPS 的演进过程拆开来讲。内容会覆盖单体架构、缓存加速、消息队列削峰、限流防刷、库存拆分、全链路异步化,以及压测和监控这些配套基建。适合正在做电商后端、对高并发感兴趣的开发者,也适合那些被领导临时安排“下周上线一个秒杀活动”的运维和架构同学。我会把每一版方案的选型理由、关键代码、踩坑经验都拆开讲清楚,尽量让你看完之后能直接对照自己的项目落地。

1. 秒杀系统为什么难:一场蓄谋已久的流量海啸

1.1 秒杀场景的真实业务画像

秒杀系统的业务特征,和日常的电商交易完全不一样。日常购物是均匀流量,用户在一天不同时段零散下单,系统承受的是平稳的请求流。秒杀则是典型的瞬时洪峰:活动开始那一瞬间,几十万甚至上千万用户同时按下“立即抢购”,请求在同一秒内涌入。数据库每秒写入量瞬间从几百跳到几十万,系统在几十秒内要处理平时一整天的流量。

更麻烦的是库存极其有限。常规秒杀库存可能只有几百到几千件,意味着超过 99% 的请求注定“买不到”。但这 99% 的请求同样会消耗 CPU、内存、数据库连接,它们不会因为注定失败就自动消失。这就引出了秒杀系统的第一个核心矛盾:用大量的系统资源去处理注定失败的请求,还要保证少量成功请求的数据正确性。

其次是热点高度集中。所有用户访问的是同一个商品详情页,扣减的是同一行库存记录,写的是同一张订单表。这不是普通的读多写少场景,而是“一热到底”的场景:一个商品击穿所有层级的缓存,一个库存字段成为整个分布式系统争抢的锁。

1.2 秒杀技术难点的三个层次

把秒杀的技术难点拆开,大致可以分成三个层次:

第一层是资源瓶颈。单机应用有天然的 QPS 上限,一台普通配置的应用服务器处理动态请求的能力在几千到一万 QPS 之间,数据库的读写能力更低。流量超过这个阈值,系统直接不可用。

第二层是数据一致性。库存不能超卖,同一用户不能重复购买,订单创建和库存扣减必须一致。这些约束在高并发场景下,所有简单的方案都会失效。比如先查库存再扣减,并发下一定会超卖;比如用数据库行锁保证一致,性能又扛不住。

第三层是用户体验。秒杀页面要在一秒内打开,点击“抢购”后要尽快知道结果,不能白等。用户不会关心你系统内部多复杂,他们只关心按钮响应快不快、结果准不准。所有异步化、削峰填谷的设计,最终都要在这条体验红线下寻找平衡。

1.3 重新定义秒杀系统的性能指标

谈秒杀性能,不能只盯着 QPS 这一个数字。QPS 是入口流量指标,但真正能反映系统健康度的是“有效请求率”——最终成功完成业务处理的请求数占总请求数的比例。一个系统可能入口 QPS 一百万,但真正能抢到的只有几百人,剩下绝大多数请求如果都能在网关层被快速拦截、在缓存层被快速返回,系统就是健康的。反之,如果一百万个请求全部落到了数据库,哪怕只成交几百单,数据库也会瞬间被打死。

所以秒杀系统的架构设计,一句话总结就是:把有限的资源用在确定有价值的请求上,让无效流量在最外层就被识别和拦截。后面的每一版架构演进,本质上都是在让这一判断执行得更早、更高效。

2. 第一版架构:单体应用直接怼,就是一个字“干”

2.1 业务起步阶段的真实选择

很多团队做秒杀,最初的架构根本没有传说中那么复杂。早期流量小,商品数量少,活动频率低,一个单体应用加一台数据库就能跑完整个流程。我见过的最小可用方案是这样的:Spring Boot 应用对外提供“获取商品详情”和“提交秒杀订单”两个接口,MySQL 里一张商品表存库存,一张订单表记录购买记录,整个服务部署在一台 4 核 8G 的云服务器上。

购买流程简单直接:先查库存是否大于零,再执行 UPDATE 扣减库存,接着 INSERT 一条订单记录。代码逻辑非常清晰,但问题恰恰出在“清晰”上——这个流程在并发场景下会踩出一串连环坑。

2.2 单机架构的 QPS 天花板

第一版单机架构能扛多少流量,我用实际压测数据说话。4 核 8G 机器上的 Spring Boot 应用,单接口纯逻辑处理(不走数据库)大约能扛 3000-5000 QPS。一旦把“查库存 + 扣库存 + 写订单”这个完整链路走完,QPS 直接掉到 800-1200,因为数据库成为瓶颈。

MySQL 单机的写能力大约在 1000 TPS 左右,读能力稍好,缓存未命中的情况下也就 3000-5000 QPS。Tomcat 默认线程池配置一般是 200 个线程,意味着同时只能处理 200 个请求,其余线程排队等待。当 5000 个请求同时涌入,应用服务器和数据库的连接池被瞬间占满,大量请求在等待队列里超时,最终表现就是用户一直转圈、页面打不开,后台日志全是连接超时。

2.3 第一版踩过的三个经典坑

第一个坑,也是所有秒杀新手必踩的坑:超卖。两个用户同时发起购买请求,都读到库存等于 1,都判断“可以买”,都执行了扣减,库存变成 -1。这个问题的根因是“先查后扣”的竞态条件,解决办法是让“查和扣”变成原子操作,但第一版我们根本没意识到这是并发问题。

第二个坑,连接池耗尽。Tomcat 默认 200 个线程,数据库连接池默认 20 个连接。5000 个请求进来,前 20 个获得数据库连接,其余 1980 个在排队。每个数据库操作耗时从正常 5 毫秒飙到 500 毫秒,连接持有时间变长,排队队伍越来越长,最终形成雪崩效应:系统吞吐量不仅没上去,反而因为线程阻塞降到了 200 QPS 以下。

第三个坑,页面静态资源直接穿透到后端。活动开始前我们做了一个简单的 HTML 页面,里面塞了几个商品数据用模板引擎渲染,结果每个用户每次刷新页面,都会触发一次商品信息查询。流量一上来,商品查询接口的 QPS 比购买接口还高好几倍,数据库的读压力成为压垮系统的第一根稻草。

这一版架构给我们的直接教训是:单机应用只适合验证业务流程,根本不适合承载任何规模的秒杀流量。但这并不代表第一版没价值,它最大的价值是逼着我们搞清楚了瓶颈到底在哪——先是数据库读,后是数据库写,再是应用线程池,每解决一层瓶颈,系统就能往前走一步。

3. 第二版架构:缓存先行,把热点读请求挡在门外

3.1 动静分离:让页面不再是性能负担

第一版崩掉的直接诱因是页面渲染触发的数据库读压力。第二版做的第一件事就是动静分离。商品详情页在活动开始前完全静态化:商品标题、图片、规格参数、详情描述全部通过静态资源部署到 CDN,用户打开页面时 CDN 直接返回 HTML 文件,完全不经过应用服务器。

页面上剩余动态元素只剩两个:当前时间倒计时和“立即抢购”按钮的状态。这两个状态通过接口异步获取,活动结束时按钮置灰,页面变成只读状态,后续请求就不会再往后端转。

这一层改造的收益非常直接:原来打开页面需要经历 DNS 解析、连接应用服务器、查询数据库、渲染模板,耗时可能在 200-500 毫秒;静态化之后,页面打开时间压到 50 毫秒以内,而且就算有几十万人同时刷新页面,压力全部由 CDN 扛住,应用服务器和数据库完全感知不到。

3.2 二级缓存策略:本地缓存挡一层,Redis 挡二层

商品信息是秒杀场景下读取频率最高的数据。第二版我们设计了两级缓存来挡读流量:

第一级是进程内本地缓存。应用服务器启动时把商品 ID、名称、图片等极少变动的基础信息加载到本地内存,处理请求时先查本地缓存,命中直接返回。本地缓存的访问速度是纳秒级的,每秒可以支撑几十万次读取,但问题是每台服务器保存一份副本,数据更新时需要广播或者容忍短暂不一致。

第二级是 Redis 分布式缓存。本地缓存未命中的请求,再去查询 Redis。Redis 单实例读性能大概在 8-10 万 QPS,加上集群能力后读能力还能继续扩展。数据库只在 Redis 未命中时访问一次,且回源后立即回填缓存。

这一版上线后,商品查询接口的数据库 QPS 从峰值 3000 降到了不到 100,整个页面的读压力基本被缓存层吸收干净。

3.3 缓存穿透、击穿与雪崩:老生常谈但必须处理

缓存上线后,随之而来的是三个经典的缓存问题。

缓存穿透:用户持续请求一个不存在的商品 ID,缓存和数据库都没有数据,每次都直接打到数据库。解决方法是缓存空值,并设置较短的过期时间,或者用布隆过滤器在缓存前拦截不存在的 ID。

缓存击穿:某个热点商品缓存过期的一瞬间,并发请求同时发现缓存未命中,全部穿透到数据库。由于秒杀场景天然就是热点商品,这个问题简直是量身定制。解决办法是对热点数据设置“逻辑永不过期”:缓存不设置过期时间,后台线程定期检查商品库存并主动更新缓存。

缓存雪崩:大量商品缓存同时过期,导致所有请求瞬间落到数据库。解决办法是在设置缓存过期时间时加入随机值,避免过期时间成片重叠。

这三个问题处理完后,系统的读链路才算真正稳定。但与此同时我们发现,写链路——也就是真正的下单流程——依然停留在最原始的“数据库直接扣减”阶段,它成为整个系统新的天花板。缓存解决了“大多数人浏览”的问题,但解决不了“少数人抢购”时候的数据争抢问题。

4. 第三版架构:削峰、限流、防超卖三件套

4.1 消息队列削峰填谷:从“直接打库”到“排队进场”

当秒杀流量真正形成规模,直接写数据库的方案无论如何优化都会触顶。我们在第三版引入了消息队列,核心思路是把“提交请求”和“处理订单”两个动作拆开,用队列缓冲瞬时流量。

用户点击“立即抢购”后,请求先进入消息队列(我们用的是常见的 RocketMQ / Kafka 这类成熟组件),后端消费者以恒定速率从队列里拉取消息,创建订单、扣减库存。这样数据库承受的写入压力从“峰值 10 万 TPS”变成“均匀的 2000 TPS”,数据库在可控范围内完成所有写入。

有个细节值得注意:消息队列的消费者线程数要和数据库的处理能力对齐。消费者开少了,消息积压越来越严重,用户迟迟拿不到结果;消费者开太多了,数据库连接数超限,照样出问题。我们当时的做法是先设置一个保守的消费者线程池,压测观察数据库指标,确认稳定后再逐步增加并发度,直到数据库 CPU 达到一个安全阈值(比如 60%)就停止加量。

4.2 库存预扣与 Redis Lua 脚本:原子性是防超卖的解药

引入消息队列后,下单流程变成两步:请求进队列,消费者异步处理。但这带来一个新问题——库存什么时候扣?

如果等消费者从队列取出消息后再扣库存,存在一个问题:大量无效购买请求已经占用了队列资源。比如 100 万人同时抢 1000 件商品,消息队列里会堆积 100 万条消息,消费者逐条处理,处理到 1001 条时库存已经为零,剩余 99 万条消息变成了无效消息,白白消耗资源。

所以我们把扣减库存的动作提前到了入队之前。用户请求到达应用服务器后,先在 Redis 执行库存扣减,成功才允许消息入队,失败直接返回“已抢完”。Redis 的单线程模型天然保证了扣减操作的原子性,在 Redis 使用 Lua 脚本可以一步完成“检查库存充足 + 扣减库存 + 返回结果”,从机制上杜绝了超卖。

-- 扣减库存的 Lua 脚本 local stock = tonumber(redis.call('GET', KEYS[1])) local purchase = tonumber(ARGV[1]) if stock >= purchase then redis.call('DECRBY', KEYS[1], purchase) return 1 end return 0

这段脚本看起来简单,实际解决了秒杀场景里最难的数据竞争问题。用户请求到达应用服务器,执行这段脚本,返回 1 表示扣减成功,返回 0 直接结束。整个过程在 Redis 内部原子完成,不会出现两个请求同时读到库存 1、同时扣减的情况。数据库里的库存字段这时候就只是一个存档值,最终通过异步消费者把 Redis 的扣减结果同步到 MySQL。

4.3 限流策略:令牌桶、滑动窗口与排队等待

库存扣减前置后,系统已经能保证“成功就是成功,失败就是失败”,但还有一个问题没解决:如何防止少数用户把流量刷爆。

有些用户会写脚本,在活动开始前一秒用多台设备、多个账号同时发起请求,试图用量取胜。如果不做限流,这些脚本请求就会占用大量系统资源,把正常用户挤掉。我们当时的防线有三层:

第一层是网关层限流。Nginx 级别配置令牌桶算法,按 IP 限制请求速率,超过速率直接返回 503。这一层能挡掉明显异常的流量,但误杀率也高,同一个公司局域网出口 IP 可能被误伤,所以阈值要留足余量。

第二层是用户维度限流。同一个账号、同一个手机号、同一个设备指纹,只允许一次有效购买请求,后续请求一律直接返回“您已参与”。这一层要在应用服务器实现,先查 Redis 里是否已有这个用户的购买记录。为了性能和原子性双重保障,也建议用 Lua 脚本来做。

第三层是排队等待机制。不直接拒绝,而是返回“前方拥挤,请稍后重试”,让用户过几秒再点击。这一层本质上是把请求分散到时间轴上,用一次“软失败”换取后续请求的成功率。

三层限流配合下来,入口 100 万 QPS 流量被大幅削减,真正落到 Redis 做库存扣减的请求下降到几千到几万,系统压力完全可控。这里我强调一下:限流不是不让你做业务,而是把流量控制在系统能安全处理的范围内,宁可让部分用户稍后重试,也不能让系统全盘崩溃。

4.4 网关层拦截:Nginx 与 OpenResty 的高效过滤

在应用服务器处理限流之前,还有一层更快、更前置的拦截:网关层。

我们在 Nginx 层直接做了几件小事:第一,拦截活动开始前和结束后的所有请求,活动时间外的请求一秒都不放行;第二,检测 User-Agent 特征,拦截明显的脚本爬虫请求;第三,做上面说过的 IP 级令牌桶限流。

更进一步,我们用了 OpenResty(Nginx 加 Lua 扩展)实现网关层的动态拦截逻辑。比如把一个“黑名单用户名单”加载到 Nginx 层共享内存中,用户被判定为恶意刷单后,后续请求在 Nginx 层直接返回 403,根本不会进入应用服务器。这层逻辑如果用应用服务器来做,每个请求都要经过一次网络传输,耗时增加 10-30 毫秒;在 Nginx 层做,几乎零成本。

到这里,第三版架构的轮廓已经完全清晰了:静态化页面吸收浏览流量,缓存吸收查库存流量,限流控制入口并发,Redis 库存预扣保证不超卖,消息队列削峰让数据库平稳写入。这套方案上线后,我们的秒杀系统已经可以从容应对几十万 QPS 的入口流量,成交数据也完全正确。但距离“100 万 QPS”这个目标,还有几个深层问题需要解决。

5. 第四版架构:百万 QPS 的终极形态

5.1 分层治理:接入层、逻辑层、数据层的彻底解耦

接近百万 QPS 的流量规模,第一件事是把系统的每一层都拆到足够细,并保证每一层都能独立扩展。

接入层采用负载均衡设备加 Nginx 集群,负载均衡设备负责流量分发和健康检查,Nginx 集群负责按域名、按路径做路由转发。这一层所有节点都是无状态的,流量大了直接加机器即可。

逻辑层(应用服务器)按照业务拆成独立服务:商品查询服务、秒杀入口服务、订单处理服务、用户校验服务。每个服务独立部署、独立扩缩容。商品查询服务面对的流量最大,部署的节点最多;订单处理服务接收的是消息队列推送的数据,实例数则可以相对少一些。服务之间通过 RPC 框架通信,接口超时时间要设置得足够短,避免一个慢服务拖垮整条链路。

数据层的拆分是重头戏。Redis 从单集群拆成多集群,每个集群负责独立业务域,比如库存集群、用户购买记录集群、限流计数集群。MySQL 从单库拆成多库:订单库、库存库、用户库分离,每个库再按主键做水平分片,订单表按用户 ID 取模分到不同库。这样单个数据库的写入吞吐从几千 TPS 降到了几百 TPS,安全系数大大提高。

5.2 库存拆分与分布式锁替代:让热点不再集中

第三版解决了“正确性”问题,但性能上还有一个明显的隐患:所有库存扣减都集中在 Redis 的同一个 key 上。Redis 单线程模型下,单个 key 的 QPS 上限大约在 10 万级别,扣减操作再快,也会在百万 QPS 场景下成为瓶颈。

解决思路是库存分片。假设库存 1000 件,拆成 10 个 key,每个 key 存 100 件库存,用户请求根据某种规则(比如用户 ID 哈希、随机数)路由到其中一个 key 执行扣减。这样理论上 Redis 的扣减能力提升了 10 倍。

但库存分片引出一个新问题:某个分片的库存提前扣完了,但其他分片还有库存,导致明明总库存还有,用户却抢不到。我们的处理方案是:如果一个分片扣减失败,就做“二次路由”,去尝试另一个仍有库存的分片;如果所有分片都无库存,再返回“已抢完”。这个方案有一点代价——极端情况下库存分布不均会在 Redis 上产生额外的查询请求,但对整体性能的影响远小于单 key 争抢的瓶颈。

同时,在数据库层面,我们不再依赖 MySQL 行锁来保证不超卖(这是第二版到第三版之间用的方案,吞吐量太低),而是让数据库成为 Redis 扣减结果的“落库存档”。Redis 扣成功,消费者异步更新数据库库存;Redis 扣失败,数据库不产生任何操作。这样的设计里,数据库只是记录结果,不参与实时决策,性能自然就上去了。

5.3 全链路异步化:从“同步等待”到“最终一致”

百万 QPS 场景下,同步调用的每一毫秒延迟都会在流量放大效应下变成致命的性能缺口。第四版我们把整条业务链路的设计思路换成了“异步优先、最终一致”。

用户请求到达入口时,入口只做三件事:校验用户基本信息、执行限流判断、调用 Redis 预扣库存。全部通过后,返回“排队成功,正在处理”,同时把订单创建消息发给消息队列。用户界面收到“排队成功”后,前端轮询查询订单状态接口,直到买到或售完的结果返回。

这就是典型的“请求受理”与“业务处理”分离模型。用户感知的是立即得到受理反馈(哪怕要等几秒钟看结果),系统内部则在后台异步排队处理。承诺一致性从“强一致”降级为了“最终一致”,但用户在等待体验上并没有明显的下降——因为轮询结果通常会在 1-3 秒内返回,这还在用户可接受的等待范围内。

支撑这个模型的关键是消息队列必须足够稳定,并且做到消息不丢失。生产环境里有两类消息:库存预扣成功后的“订单创建消息”和预扣失败后的“流量统计消息”。前者直接决定用户成败,必须开启生产者同步发送确认机制,保证消息真正到达 broker 后才返回成功给前端;后者丢失无所谓,不会影响业务正确性。

5.4 多级缓存与降级容灾:百万流量下的保命符

系统规模到了百万 QPS,任何一个小环节的故障都会被放大成雪崩。第四版我们在缓存和容灾上做了两个关键增强。

缓存方面,把原本的两级缓存升级成了三级:本地缓存(应用内存,微秒级响应)、Redis 分布式缓存(毫秒级响应)、数据库(兜底)。热度最高的商品基础信息直接加载到每台应用服务器的本地内存中,保证 99% 的请求在应用进程内就能完成读取,完全不经过网络请求。

本地缓存的更新策略是“主动失效 + 定时刷新”:后台定时任务每隔几百毫秒检查一次商品库存变化,有变化就更新本地缓存;同时监听一个 Redis 通道,数据库库存变化时主动广播失效消息,让各节点尽快更新本地缓存。这套方案的收益极其明显:单机的本地缓存可以支撑每秒几万次读取,相当于每台服务器都是一个微型读缓存服务器,集群后整体读能力几乎无限扩展。

容灾方面,核心是“能降级就降级”。我们把所有非核心功能都做成了可开关的降级策略:秒杀结束后一键关闭入口;Redis 集群异常时切换为数据库降级模式(直接查库,性能下降但保底可用);消息队列堆积严重时触发限流保护,拒绝新增请求但保证存量消息正常处理。

每个秒杀活动上线前,我们都会做一次故障演练,模拟真实生产环境里的单个组件故障,验证降级策略是否真的生效。这一点我强烈建议每个团队都做,因为只在代码里写了降级逻辑、从来没演练过,真正出故障时大概率手忙脚乱,甚至可能把降级开关都找错地方。

5.5 监控、压测与容量规划:百万 QPS 不是拍脑袋算出来的

最后一块,也是从第一版到第四版全程都需要的:监控体系和压测能力。没有这两样,上面的所有架构优化都是空谈。

监控要覆盖整个链路,从最外层 Nginx 的请求数、错误率、响应耗时,到应用服务器的 JVM 指标、线程池状态,到 Redis 的命中率、慢查询,再到消息队列的积压数量和消费速率,最后到 MySQL 的慢查询、连接数、主从延迟。核心指标汇总到一个大屏上,活动运行期间所有运维和后端同事盯着一块屏看,任何指标异常都能在 1 分钟内发现。

这里有一个实践层面的建议:不要只监控平均值,要监控 P99 延迟。平均响应时间 200ms 听起来还挺健康,但如果 P99 是 3 秒,说明最差的那 1% 用户已经卡到快砸手机了。活动期间我们最关注的是 P99 和错误率,一旦 P99 超过设定的红线阈值,立即触发告警并要求负责人评估是否需要降级。

压测是唯一能在活动前发现容量瓶颈的手段。我们不等到活动当天才看系统能不能扛住,而是在上线前用压测工具按真实峰值流量进行多轮压测。做法是从低到高逐步加压:

  • 先按预计峰值的一半压,观察各层指标,定位第一个瓶颈。
  • 修复瓶颈后再压到预计峰值的 80%。
  • 最后压到预计峰值的 1.2 倍,留出安全余量。

每轮压测都要记录系统的极限 qps、延迟分布、资源利用率,并回填容量规划表。例如,一台 8 核 16G 的应用服务器能处理 1.2 万 QPS,那么 100 万 QPS 的入口流量理论上需要 80 台左右的应用服务器,再考虑到限流拦截率(假设 60% 被网关层拦掉),实际只需要 40 台。Redis 库存集群按单机 8 万 QPS 计算,配合库存分片,8-10 个分片节点能覆盖 100 万级别的扣减请求。这些数字都要靠压测得出,每台机器的真实能力只会比理论值低,不会更高。

6. 实战心得与常见问题速查

6.1 我踩过的几个大坑

第一个大坑:只做缓存没做降级,结果缓存一抖动,系统就崩。有一次活动刚开始,Redis 集群因为热点 key 过大导致网络抖动,所有缓存查询超时,应用服务器逐出大量异常,数据库回源请求暴涨,最后整个秒杀链路瘫痪了 5 分钟。后来我们才把降级策略补全,制定了“读 Redis 超时直接降级查库”的兜底方案。这里有个取舍:降级查库会让数据库压力变大,但总比整个页面打不开要好。

第二个大坑:消息队列消费能力没跟上,导致用户已经收到“购买成功”的推送,但订单状态在数据库里还是待处理。秒杀场景下用户等待时间是有限的,异步处理下单通常要控制在 1 秒内出结果。我们当时消费者的处理逻辑过于冗长——创建订单、扣减库存、更新用户积分、发短信通知全部同步串行执行,后来把发送短信、更新积分这些非关键动作全部改成异步执行,只保留创建订单和管理库存这两个核心步骤在消费者线程中同步完成,消费速率瞬间提升了好几倍。

第三个大坑:本地缓存更新不及时,导致页面显示还有库存,用户点击后却提示“已售罄”。这种场景下用户体验是非常差的。我们最终的处理方案是:秒杀期间页面上的库存显示改成“已售罄”做一个悲观展示,宁可少显示几件库存,也不要让用户白点一次。库存数据在活动期间本身就是近似值,接受的误差让用户体验牺牲最小,这才是合理的取舍。

6.2 常见问题排查思路速查表

问题现象可能原因排查思路
接口响应缓慢线程池耗尽、数据库慢查询、Redis 慢操作先看应用线程池活跃数,再看数据库慢查询日志,最后看 Redis 慢命令
库存显示还有但下单失败缓存与数据库库存不一致对比 Redis 与 MySQL 库存,检查本地缓存刷新任务是否正常
用户重复下单幂等校验漏处理检查用户 ID 幂等 key 的 SETNX 是否在请求链路最前置处
消息队列消息积压消费者速率低于生产速率看消费者消费耗时,优先优化消费者逻辑而非盲目加消费者数量
服务雪崩某个下游服务超时导致上层线程堆积立即开启降级开关,切断非关键链路,恢复后逐步放开

这几个问题在处理秒杀系统时反复出现,基本可以当成一个快速诊断清单来用。

6.3 给新做秒杀系统的同学几点实在建议

如果你们团队第一次做秒杀,我的建议不是直接上百万 QPS 架构,而是根据业务量级选择合适的方案。

如果预期流量在 1 万 QPS 以下,单体应用加 Redis 缓存加数据库行锁是完全可行的,没必要为了炫技引入一整套中间件。如果流量到了几万 QPS,再考虑引入消息队列做异步化,但库存扣减可以继续留在 Redis 原子上。只有当流量达到几十万甚至上百万 QPS,才值得做库存分片、全链路异步、服务拆分这类高级架构。

不要迷信集群数量,每加一层组件就多一分运维成本和故障概率。我见过一些团队,一个秒杀活动只有几千 QPS,却上了 MQ、Redis Cluster、分库分表——这不是架构演进,这是给自己挖坑。

还有一点最重要:日志打点一定要从一开始就做好。秒杀系统排查问题最怕的就是没有现场。我平时在新接一个秒杀项目时,第一件事不是看代码逻辑,而是先看日志里有没有打印请求 ID、用户 ID、库存余量、消息队列消息 ID 这些关键字段。有了这些,排查问题才不至于两眼一抹黑。

写在最后,也是踩坑踩出来的一点体会

做秒杀系统这么多年,我自己最深刻的一点体会是:架构不是设计出来的,是流量逼出来的。每一次演进都是被线上事故推着往前走——先有超卖,才有库存预扣;先有机器被打垮,才想到页面静态化;先有数据库写爆,才引入消息队列削峰。如果你正在做秒杀系统,不用焦虑自己的架构不够高级,只要它在当前流量下稳定正确,然后给未来留好扩展空间,就是好架构。

最后分享一个平时不太容易注意到的小技巧:秒杀活动上线后,把数据库里所有非核心数据表的慢查询告警阈值调低,比如从默认的 1 秒调到 200 毫秒。因为秒杀期间查询量大,慢查询一旦出现就会迅速叠加,等 1 秒的慢查询堆积成百上千条时,数据库基本已经危险了。提前在 200 毫秒量级发现并处理,能给系统争取宝贵的救援时间。这个习惯帮我避过好几次线上事故,建议你也试试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进