☰
100万QPS秒杀架构全解析:从入口拦截到异步落地的系统设计
2026/10/8 11:36:46 网站建设 项目流程

兄弟们,聊到高并发秒杀,很多人第一反应就是“100万QPS”这个数字。我面试过不少候选人,简历上写“支撑过百万QPS秒杀”,结果一问细节,要么支支吾吾,要么就是“用了Redis防超卖”一句话带过。说实话,100万QPS这个量级,不是靠某个中间件或者某段骚代码就能堆出来的,它是一整套从入口到数据落地的系统性工程。

很多朋友对“高并发”和“秒杀”的理解,往往只停留在“Redis缓存+MQ削峰”这个概念层面。但真正面对双11零点、某顶流明星限量周边开售这种场景时,你会发现哪怕一个毫秒级的GC停顿、一次MySQL主从延迟,都可能导致雪崩。所以这篇帖子,我想把100万QPS秒杀架构这件事,从物理世界的带宽算起,一直拆到数据库底层,把我踩过的坑、验证过的参数、权衡过的取舍,一次性讲透。

这篇文章不是给刚入门的朋友看概念,而是给那些已经写过CRUD、玩过Redis、被线上故障教育过,想真正搞懂大规模高并发架构背后“为什么”的工程师。咱们直接开始。

1. 从物理极限到架构蓝图:100万QPS意味着什么

在看任何架构图之前,先做一道小学数学题,否则后面全是空中楼阁。

1.1 用带宽和TCP连接算的第一笔账

100万QPS,假设每个请求的请求体加上响应体,平均按2KB流量算(这已经是极度压缩后的静态化数据量了),那么一秒钟需要传输:

  • 100万(请求数) × 2KB(双向流量) = 2GB/s的吞吐量。
  • 换算成带宽,大概是16Gbps(注意大小写)。

这意味着什么?一台普通千兆网卡的物理机(1Gbps),满打满算也就125MB/s,连零头都不到。你需要至少16台物理机的网卡全部打满,还不算任何协议开销和转发损耗。也就是说,整个机房在那一秒,光网卡吞吐量就得预留出20%以上的冗余,这还没算负载均衡器之间的内网流量。

再看TCP连接。如果是短连接模型,每秒新建100万个TCP连接,意味着每秒要处理100万次三次握手和四次挥手。Linux内核在默认配置下,每秒新建连接数撑死也就5-10万,而且新建连接会带来严重的CPU开销和TIME_WAIT堆积。所以,100万QPS架构下的第一个铁律就是:必须使用长连接,或者HTTP/2多路复用,彻底砍掉握手成本。

1.2 每个环节的真实承压能力

算完物理账,再看软件栈的承压能力。这是我在压测环境里一点点调出来的经验值,不同机器配置会有浮动,但量级基本靠谱:

环节单机/单实例能力参考瓶颈点
Nginx/LVS5-10万QPS(性能调优后)网卡软中断、epoll处理线程
Redis单实例10万+ QPS(读操作,pipeline下更高)单线程CPU、网络IO
MySQL单库3000-8000 QPS(简单查询)SQL解析、锁竞争、IO刷盘
业务容器(Java)2000-8000 QPS(带完整业务逻辑)线程上下文切换、GC、数据库连接池

看到没?数据落地环节和前置环节之间,差了整整两个数量级。这决定了你在做架构时必须有一个坚定的信念:请求在到达数据库之前,能拦截多少就拦截多少,能挡回去就挡回去,数据库永远只能处理"最关键的那笔写操作"。

1.3 业务场景决定了架构边界

还有一点必须想清楚:秒杀系统和其他高并发系统不一样的地方在于,它追求的不是"循序渐进的处理完所有请求",而是"在极短时间内挡掉99.9%的无效流量,只放行真正能买东西的人"。

因为我做的就是这种瞬时流量峰值的场景,所以整个架构设计的核心思想就八个字:前置过滤,异步落地。后面所有章节的内容,都是围绕这八个字展开的。

2. 流量漏斗:入口层与接入层的分层拦截策略

一台物理机撑不住,我们就用几十台机器把流量分散开。但入口层的核心不在于"把流量分给谁",而在于"在哪个环节干掉多少流量"。

2.1 DNS与CDN层:把静态流量拦在门外

秒杀页面最大的特点就是:商品信息、活动规则、倒计时页面,这些数据在活动开始前千篇一律,跟用户状态无关。这部分流量大概占整体请求的80%以上。我见过最好的静态化方案,是把整个秒杀商品页打成静态资源放CDN,动态数据通过异步接口单独拉取。

CDN层可以直接扛掉地域性的网络延迟,把源站的带宽压力释放出来。而且CDN在边缘节点就能根据活动规则把部分爬虫和异常IP挡下来,这种拦截发生在离用户最近的地方,代价最小。

2.2 网关层的"令牌桶"限流与全局开关

到了四层/七层负载均衡这一层,就需要做真正的流量控制策略了。我在网关层(基于OpenResty)做了三件事:

第一,全局限流。用Nginx的limit_req模块实现令牌桶算法,全局限流在80万QPS左右,超过部分直接返回"排队中"的降级页面。这里的80万不是随口说的,是给后端预留了20%的余量,防止突发流量把按100万设计的系统打穿。

第二,用户维度限流。网关层解析Cookie中的用户标识(不用解密,只做散列取模),把同一个用户的请求频率限制在5秒一次。这么做不是为了限流,而是防止网络超时后的自动重试,把只属于用户的重复请求给滤掉。

第三,全局开关和灰度。我做过一个近乎变态的开关设计:网关支持通过配置中心动态下发"放量比例",从0%到100%平滑调整。这样哪怕活动开始后发现问题,也不需要重启任何服务,直接把放量比例调到10%,线上影响面立刻可控。

2.3 一个容易忽略的参数:TCP的TIME_WAIT与backlog

在网关这台机器上,有个参数是压测时最先冒出来的问题:net.ipv4.tcp_tw_reuse和net.ipv4.ip_local_port_range。如果接入层直接用短连接,你会发现压测跑到一半,系统莫名其妙开始大量丢包、connect超时,用ss -s一看,TIME_WAIT把本地端口耗尽了。

我通常在压测前就会做这样一组内核参数调整:

# 注意:以下参数仅适用于压测和特定业务场景,需要在充分理解TCP状态机的前提下调整 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_tw_recycle = 1 # 仅在NAT设备后无用户时使用 net.ipv4.ip_local_port_range = 1024 65535 net.core.somaxconn = 65535

但说句实在话,真正解决连接问题的方案是长连接,而不是持续去调内核参数。参数只是把体力活变轻巧,架构上的长连接才是治本。

3. 商品库存的原子性设计:在Redis里解决超卖问题

秒杀系统的心脏,就是库存。库存不能超卖,这是商业底线,而每秒100万请求要在毫秒级内完成库存校验和扣减,传统数据库事务根本扛不住。所以,真正的战场在缓存层。

3.1 为什么要用Lua脚本保证原子性

在Redis中扣减库存,最容易犯的错误是"先GET再SET"。这么干在并发下一定出问题——线程A GET到库存是10,还没SET,线程B也GET到10,两人都把库存储成9,超卖的根源就在这里。

Redis是单线程执行的,它的EVAL命令可以保证一个Lua脚本在执行过程中不被其他命令插入。所以我把库存扣减逻辑写成了这样:

-- KEYS[1]: 库存key -- ARGV[1]: 购买数量 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock < tonumber(ARGV[1]) then redis.call('INCRBY', 'sec:fail:' .. KEYS[1], 1) return -1 end redis.call('DECRBY', KEYS[1], tonumber(ARGV[1])) return stock - tonumber(ARGV[1])

这段脚本把"判断库存是否充足"和"扣减库存"放在同一个原子操作里,不管多少并发过来,Redis内部都会串行执行,彻底消除了超卖的可能。

3.2 库存预热与分段存储

另一个关于Redis的实践是库存预热。活动开始前,把数据库里的库存总量一次性加载到Redis里,并设置一个永不过期但是带逻辑时间的Key。活动期间,所有库存判断和扣减都在缓存层面完成,数据库里的库存字段不做实时更新,只是在活动结束后用实际订单数反推覆盖。

当100万QPS全部打到同一个库存Key上时,这个Key会变成Redis里的"热Key",单实例Redis可能扛不住那么高的访问量。这时候就需要做分段库存:

  • 把1万个库存拆分成10个Key,每个Key存1000件,放入不同的Redis分片。
  • 用户请求时经过网关层,根据用户ID的哈希值路由到一个具体的库存分片。
  • 这10个分片的扣减逻辑完全独立,互不干扰。

这种"分片减库存"的设计在双11的多个大促场景里都验证过,效果稳定。它带来的副作用是"最后一件商品可能在不同分片里同时显示有货",但实际扣减时会精确保证总数不超卖,用户体验上几乎感知不到差异。

3.3 本地缓存兜底:给Redis减负的最后一招

Redis的性能再高,也架不住100万QPS全部穿透到它身上。所以我在业务容器里做了一个"本地缓存前置"的环节:业务容器启动时,从Redis把库存数量的变化区间加载到JVM本地内存中,比如库存总量5万件的活动,本地缓存一个"剩余库存"的近似值。

这个本地缓存的逻辑是:

  1. 如果本地预估库存已经小于0,直接返回已售罄,不再请求Redis。
  2. 如果本地预估库存还有余量,则把请求转发给Redis执行真实的Lua扣减。

这个方案的代价是"可能多放进来一些请求",但结合后面的异步削峰设计,整体系统完全能接受这种误差。它的收益是巨大的——90%的流量在业务容器本地就直接被挡掉了,Redis收到的请求量级直接降一个维度。

4. 订单落库的异步化改造:把100万条写请求压缩成1万条

前面说了,请求在经过入口拦截、网关限流、本地缓存+Redis扣减之后,能活着走到"创建订单"这一步的流量已经很少了。但这部分流量依然是峰值,如果每笔订单都同步写数据库,MySQL一样死给你看。

4.1 为什么不能直接同步写MySQL

一次订单INSERT事务,粗算下成本:SQL解析、检查约束、索引更新、InnoDB的redo log刷盘、binlog记录。顺利的话也要1-2毫秒。1万并发同时写,MySQL要处理10-20秒的事务排队,连接池最先被打满,锁等待让延迟指数级上升。

所以,永远不要把订单数据直接怼到数据库。正确姿势是先把订单落到消息队列,或者一个队列表,然后由消费者异步批量刷库。

4.2 队列表的细节设计

我用过Kafka,也用过RocketMQ,它们都很成熟。但这里想聊一个更"轻量"但同样有效的方案——队列表。它也是很多大厂在秒杀场景下的终极兜底方案,因为不依赖额外中间件,且天然具备事务能力。

队列表的结构核心就几个字段:

字段说明
order_id订单号,唯一索引
user_id用户ID
item_id商品ID
status状态:0待处理,1已落库,2重试中
create_time入队时间
retry_count重试次数

关键操作逻辑是:订单服务在同一个事务里,先扣减Redis库存(通过Lua),再把订单数据INSERT到队列表。这里的order_id是幂等键,秒杀场景下同一个用户同一场活动只允许一条记录,靠数据库唯一索引保证,比在Redis里反复检查强一万倍。

4.3 刷库的"攒批"策略

队列表存在的意义,就是让下游消费者可以批量处理。我实现过一个典型的批量刷库任务:一个后台线程每100毫秒扫描一次队列表,把状态为0的数据一次性取1000条出来,批量INSERT到订单表。

100毫秒批量刷一次,算出每秒能处理1万条事务;如果并发峰值实在太高,就把扫描间隔缩短到50毫秒。这样做的核心理念叫"攒批",把1000次随机IO合并成一次顺序IO,吞吐量直接提升一个数量级。

而且队列表本身还用到了MySQL的分区表设计,按create_time做RANGE分区。历史数据可以直接TRUNCATE掉,不会拖垮查询性能。

5. 防作弊、幂等与风控:静默拦截掉50%的"坏流量"

说个容易被忽视但真实占比惊人的数据:在真正的秒杀活动中,至少50%的请求是机器请求或重复请求——脚本、黄牛、爬虫、羊毛党。如果不做风控,这些人不仅抢走商品,还会让系统的QPS压力翻倍。

5.1 用户维度的风控规则引擎

风控这件事,在架构上的定位是"旁路"而不是"主路"。不能因为风控系统的故障导致正常用户无法下单,所以风控整条链路都做了降级开关。

风控规则的来源分几个层次:

  • 用户等级与历史行为:新注册账号、历史订单异常、收货地址频繁变更,这些用户直接打上高危标签。
  • 设备指纹:同一台设备在短时间内切换多个账号,或同一个设备指纹出现在多个城市,基本可以判定为黄牛。
  • IP维度:同一IP段下用户请求量异常稠密,或在短时间内横跨多个城市,需要加重校验。

这些规则在风控服务里计算,打上标签后异步推给网关层。网关层在放行前查一下标签,如果是高危用户,直接返回"活动太火爆啦"而不进入后面的业务链路。

5.2 幂等的另一种表达:数据库唯一索引

在秒杀架构里,"防止同一用户重复下单"这件事不能靠分布式锁,分布式锁在超高并发下也是一把性能杀手。我用的最实在的方案就是数据库唯一索引。

在订单表或队列表上,把user_id + activity_id建一个联合唯一索引。同一个用户对同一个活动提交第二次CREATE时,数据库会因为唯一键冲突直接报错,根本不用在业务代码里做任何判断。一次INSERT失败的成本远低于一次分布式锁获取的成本。

5.3 前端防抖与验证码:被低估的流量削减器

不要小看前端这一层。秒杀按钮在点击之后必须置灰,5秒内不允许再次点击;活动页面上放置滑块验证或点选验证码,虽然会被部分脚本绕过,但依然可以卡掉大量初级脚本。

还有一个极其有效的做法:在活动开始前30秒,让前端每秒向服务器发送一次时间同步请求,服务器以自己时间为准,控制前端按钮在精确时刻变亮。这么做不仅让用户在体验上更公平,还能把请求的到达时间波动控制在一个很窄的窗口内,服务端可以提前把缓存、线程池、连接池都准备好了。

6. 超高并发下的稳定性保障:压测、熔断、降级和可观测

架构设计得再好,没有经过严密的压测验证,上线就是一场豪赌。100万QPS这种量级,任何环节的短板都会在那一秒内暴露无遗。

6.1 全链路压测:用更真实的方式制造100万请求

传统的JMeter单机压测,在超过10万并发时会受限于施压机的千兆网卡,根本压不出100万QPS的效果。我使用的方案是分布式压测集群:用几十台施压机,每台机器跑1-3万并发,通过控制台统一编排,把所有施压机的请求汇聚到目标系统。

压测的目的不是看"系统能不能撑住",而是找到圧测拐点。比如:

  • 网关层在多少QPS时CPU软中断开始飙升?
  • 某个Redis分片在多少OPS时开始出现slow log?
  • 业务容器在多少QPS时,GC时间占比超过5%?

把每个环节的"崩溃前夜"状态记录下来,就是线上运维时的报警阈值。

6.2 熔断与降级策略的预设

线上故障是常态,预案才是安全网。我的秒杀系统里预设了三层降级:

降级级别触发条件动作
L1某Redis分片不可用本地缓存完全放行,用数据库悲观锁兜底(概率极小)
L2订单落库积压严重关闭部分非核心风控规则,降低风控拦截率
L3系统整体过载网关层直接随机拒绝30%请求,确保核心链路逃生

说到这,熔断和降级的判断依据不是靠拍脑袋,而是靠第四点——可观测性。

6.3 一分钟以内的故障定位能力

做高并发架构,最怕的就是出问题后一头雾水。我的做法是给每一个关键服务、中间件、接口埋上四个维度的指标:QPS、RT(P99)、错误率、饱和度。通过Prometheus + Grafana统一展示。

秒杀活动期间,监控大屏上依次盯这几个信号:

  1. 网关层的P99延迟如果超过50ms,多半是后端线程池被拖垮了。
  2. Redis的blocked_clients如果出现持续上涨,说明大量客户端在等待IO,可能有大Key。
  3. 队列表积压量与消费速度的差值,如果差值持续扩大,说明消费者线程数不够或SQL出现了慢查询。

这些信号之间存在因果关系,排查线上问题不是到处翻日志,而是根据指标链路从上到下、从入口到出口逐层排查。

7. 百万QPS背后的工程哲学:取舍、防御与敬畏

说实话,100万QPS的数字再炫酷,落到工程上就是一个个务实的取舍。Redis扣减库存的Lua脚本、本地缓存兜底、队列表攒批刷库,这些方案单拎出来都不算黑科技,但组合在一起,它们就能在极端流量下形成一道坚不可摧的防线。

我做高并发最大的一次认知转变,是终于明白"处理流量"不是本事,"提前消灭流量"才是本事。整个秒杀架构的精髓,从来不在某个中间件用得多好,而在于你在一层又一层的链路中,到底替下游挡掉了多少本不需要它们去处理的请求。

还有一件事想提醒大家:千万不要为了追求100万QPS而去高配低用。如果你的业务峰值只有2万QPS,那用一台好点的物理机跑Nginx直连MySQL可能就够了。架构的复杂度不是越多越好,而是在够用的基础上,留出足够的逃生通道。

在这个行业里待久了,你会越来越敬畏流量峰值。它像一面放大镜,把你代码里所有被忽视的角落都照得清清楚楚。把基础功练扎实、把预案做充分,剩下的,就是在那零点几秒钟里,等着风暴来临,然后平静地喝一口水了。

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

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

立即咨询