Skip to content

容量评估与秒杀设计:流量涨 100 倍怎么办 ​

「加机器」是最容易说出口、也最容易失效的答案。实测一个 2 CPU 的 MySQL 做主键点查,并发到 16 时吞吐已经到顶(约 10.8 万 QPS),再加到 256,吞吐掉到 8.7 万,p99 却从 0.28ms 涨到 57ms。超过拐点之后,增加并发只会让所有人都更慢。

本文分两部分:先讲怎么算出系统现在能扛多少、瓶颈在哪,再讲秒杀这类极端场景的分层设计。实验在 MySQL 8.4.11 上完成。

一、先说结论 ​

  • 每个组件都有吞吐拐点,超过之后吞吐不再增长而延迟急剧上升。容量评估要找的是拐点,不是理论峰值。
  • 先确认流量的性质:正常增长、突发热点、还是攻击。三者的应对完全不同,前两者扩容,后者要拦截。
  • 扩容有优先级:无状态服务最容易,缓存次之,数据库最难。评估时按「最难扩的那一层」倒推整体容量。
  • 秒杀的本质是漏斗:库存 1000 件,就不该让 100 万请求都打到数据库。每一层挡掉一部分,最终到数据库的请求应该和库存同一量级。
  • 限流、降级、熔断三件事要提前演练,尤其是限流阈值——拍脑袋定的阈值在真实流量面前通常不对。

二、先算清楚现在能扛多少 ​

2.1 找拐点,而不是看峰值 ​

14,286并发 1p99 0.12ms107,494并发 8p99 0.09ms107,974并发 16p99 0.28ms102,582并发 64p99 1.5ms94,508并发 128p99 47.79ms86,773并发 256p99 57.56msMySQL 8.4.11 · 2 CPU 容器 · 10 万行表 · 数据全部在 Buffer Pool 中 · 每个线程一条连接闭环压测
图 1 · 并发 16 时吞吐已到顶(107,974 QPS,p99 0.28ms);继续加到 256,吞吐不升反降到 86,773,p99 涨到 57.56ms。拐点之后继续加并发只会让延迟变差

同一个 MySQL(限制 2 CPU,10 万行表全部在 Buffer Pool 中)、同一条主键点查,每个线程一条连接、收到结果再发下一个,不同并发下的实测:

并发QPSp50p99
114,2860.07ms0.12ms
8107,4940.03ms0.09ms
16107,9740.06ms0.28ms
32106,8420.12ms0.73ms
64102,5820.28ms1.50ms
12894,5080.62ms47.79ms
25686,7731.43ms57.56ms

并发 8 到 16 之间是这台机器的拐点。之后增加并发,请求只是在排队:吞吐不再增长,甚至开始下降,p99 从不到 1ms 涨到几十毫秒。拐点的位置取决于 CPU 核数和每次请求的开销,换一台机器会不同,但曲线的形状是一样的。

这条曲线的意义在于:容量不是一个数字,而是「在可接受的延迟下能达到的吞吐」。如果业务要求 p99 小于 1ms,这台机器的容量是 32 并发下约 10.7 万 QPS;把并发继续往上加,不会多出吞吐,只会把 p99 推到几十毫秒。

压测时要做的就是画出这条曲线,方法见 线程池参数怎么定 中的压测流程。

2.2 按最难扩容的那层倒推 ​

层扩容难度典型上限
无状态应用容易,加实例即可受下游连接数限制
本地缓存随实例一起扩内存
Redis中等,分片扩容需要迁移单实例约 10 万 QPS 量级(实测 2 CPU 容器内 GET 约 22 万)
数据库主库最难,写入无法水平扩展实测 2 CPU 下点查约 10.8 万 QPS,主键更新最高约 1.1 万
第三方接口不由自己控制对方给的配额

所以「QPS 涨 100 倍」的第一个问题是:这 100 倍里有多少会落到数据库? 如果读多写少且缓存命中率高,应用层加机器就能扛;如果是写入涨 100 倍,扩容解决不了,必须改架构(异步化、分库分表、或者根本不接这个流量)。

2.3 先判断流量性质 ​

类型特征应对
业务自然增长曲线平滑,日环比增长常规扩容、优化、容量规划
营销或热点带来的突增有明确的时间点和入口提前扩容、预热缓存、限流保护
爬虫与刷单请求分布异常,UA/IP 集中,转化率极低识别与拦截,不要为它们扩容
DDoS流量特征与业务无关上游清洗、云厂商防护,应用层扛不住

把攻击流量当成业务增长去扩容,是最贵的错误。所以监控里要能区分:按 IP、UA、用户维度的请求分布,以及「请求量涨了但订单量没涨」这种转化率异常。

三、秒杀:把流量做成漏斗 ​

入口流量100 万 QPSCDN 与静态化页面与资源不回源网关限流与人机校验按 IP、用户、接口限流缓存预扣库存Redis 原子扣减消息队列削峰异步下单数据库最终扣减与落单库存 1000 件时,数据库只需要处理约 1000 次扣减,其余请求在上游就该被拒绝
图 2 · 每一层都要挡掉一部分流量,真正到达数据库的应该只有和库存数量同一量级的请求

秒杀的特点是瞬时流量极高、有效请求极少:1000 件库存,100 万人抢,99.9% 的请求最终都是「已售罄」。设计目标不是让数据库扛住 100 万 QPS,而是让它只处理 1000 次扣减。

3.1 静态化与 CDN ​

商品详情页、活动页做成静态资源推到 CDN,页面上的库存数量用单独的接口异步获取,并允许几秒钟的延迟。这一层能挡掉绝大部分的页面请求。

3.2 入口限流与人机校验 ​

  • 按用户和 IP 限流:同一用户每秒最多一次请求,超过直接返回。
  • 活动开始前不放行:秒杀开始时间之前的请求全部拒绝,避免提前打满。
  • 人机校验:验证码、滑块、答题,既能挡脚本又能削峰。
  • 请求排队:给用户发一个排队号,后端按能力匀速处理,前端轮询结果。

3.3 缓存层预扣库存 ​

真正的扣减前,先在 Redis 里做一次原子扣减:

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

扣减失败的请求直接返回「已售罄」,不再向后传递。这一步把百万请求压缩到和库存同一量级。判断和扣减必须在同一段脚本里完成:先 GET 判断再 SET 的写法实测在 100 个名额上超卖了 400 个,几种正确写法的对比见 Redis 原子性边界。

要注意:Redis 的扣减结果必须能和数据库对上。常见做法是扣减成功后立刻写一条「预占记录」(带唯一键),异步落库;超时未支付则回补 Redis 和数据库两处库存。

3.4 消息队列削峰 ​

通过预扣的请求写入消息队列,下游按数据库能承受的速度消费。用户侧返回「排队中」,再通过轮询或推送告知结果。

这一步的取舍是体验换稳定:用户不能立刻看到下单成功。如果业务不能接受,就只能让数据库直接承接,那么前面的限流阈值必须卡在数据库容量之内。

3.5 数据库最终扣减 ​

sql
UPDATE sku_stock SET qty = qty - ? WHERE sku_id = ? AND qty >= ?;

即使前面所有环节都失效,这条 SQL 也能保证不超卖。热点行的并发限制见 表设计里的三个细节。

四、限流、降级与熔断 ​

这三个词经常混着说,实际职责不同:

手段作用对象目的
限流入口流量保护自己,超出容量的请求快速失败
熔断对下游的调用下游故障时快速失败,避免线程被拖死
降级功能本身牺牲非核心功能,保住核心链路

几条实践:

  • 限流阈值来自压测,不是拍脑袋。 阈值应该略低于拐点对应的吞吐。
  • 限流要分层:网关按接口粗粒度限,应用内按用户、按资源细粒度限。
  • 熔断要配合超时。 没有合理的超时,熔断器根本不会触发——请求都卡在等待里。
  • 降级开关要能一键生效,并且平时就在预发环境验证过。
  • 快速失败优于慢速成功:返回「稍后再试」比让用户等 30 秒再失败好得多。

五、常见误区 ​

  • 「加机器就能扛住」:无状态层可以,数据库写入不行;超过拐点后加并发只会让延迟变差。
  • 「压测到最高 QPS 就是容量」:要看那个 QPS 下的 p99 是否还能接受。
  • 「秒杀要让数据库扛住百万 QPS」:正确目标是让数据库只处理和库存同量级的请求。
  • 「限流会影响用户体验」:不限流的结果是所有人都超时,体验更差。
  • 「异常流量也是流量,先扩容再说」:为爬虫和攻击扩容,成本会失控。

小结 ​

容量评估的核心是找到拐点:在可接受的延迟下,每一层各能承受多少。有了这条曲线,限流阈值、扩容比例、降级时机都有了依据。而秒杀这类极端场景,解法不是让底层更强,而是层层过滤——CDN 挡住静态请求,限流挡住重复和异常请求,缓存挡住无效扣减,消息队列把尖峰摊平,最后到数据库的请求应该和库存数量在同一个数量级。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权