同一份产品,三次评审给出三个 QPS
「回声」是一个图片社区。准备第一次大促时,团队开了三次容量评审会,三个人拿同一份产品,算出了三个完全不同的 QPS:1389、1146 和 5093。
第一个数字来自市场报告:200 万 DAU,每人每天 20 个请求,峰值系数 3,除以 86400 秒。第二个数字来自数据团队:真正完成鉴权的去重用户只有 150 万,网关日志里每人每天 12 个请求,但峰值系数不是 3 而是 5.5。第三个数字来自增长团队的预测:大促当天 220 万 DAU,每人 20 个请求,峰值集中在晚上八点到次日八点,活跃窗口只有 43200 秒。
三个数字都“有依据”,但最大和最小相差 4.4 倍。按 1389 QPS 买机器,大促当晚就会排队;按 5093 QPS 买,平时有七成机器在空转。问题不在谁算错了公式,而在每个人对四个参数的取值口径不同,却都把结果写成了小数点后两位。
四个参数各自的标定方法
QPS 估算不是一道算术题,而是四次口径选择。每个参数都要能回答“这个数字从哪张表、哪段时间、哪条 SQL 来”。
| 参数 | 应该取什么 | 常见取法 | 偏差方向 | 标定来源 |
|---|---|---|---|---|
| DAU | 当天至少成功鉴权一次的去重用户 | 注册用户数、月活、市场预测 | 高估 | 鉴权日志按自然日 count(distinct user_id) |
| RPU | 打到服务端入口的请求数除以 DAU | 客户端埋点事件数 | 高估,一次曝光可能报多个事件 | 网关访问日志与服务端埋点按接口对齐 |
| 峰值系数 | 5 分钟粒度小时峰值除以当日均值 | 拍一个 2 到 3 | 低估 | 连续四周的小时曲线,含周末与大促 |
| 活跃秒数 | 用户首个请求到最后一个请求的窗口 | 直接写 86400 | 低估峰值 | 用户级会话首末时间,取产品主要区域的分布 |
DAU 最容易掺进“注册过就算”的水分。市场口径的 200 万和鉴权口径的 150 万差 25%,足以让容量差出四分之一。更隐蔽的是把月活除以 30 当 DAU:月活里包含大量只打开过一次的沉默用户,他们贡献的请求远低于中位数用户。
RPU 的坑在“客户端事件”和“服务端请求”不是一回事。一次首页曝光,客户端可能上报曝光、点击、停留三个事件,但服务端只处理一个聚合请求;反过来,一次下单在服务端会打商品、库存、优惠、订单四个接口,客户端只报一个“下单”事件。用错口径,RPU 可能高估三倍,也可能低估一半。
峰值系数必须从真实小时曲线读,而不是从“互联网产品峰值一般是三倍”这种经验里抄。一个早高峰通勤产品和一个晚间娱乐产品的曲线形状完全不同。把连续四周的 5 分钟粒度 QPS 画出来,取每天最高一小时的平均值,再除以当天均值,才能得到一个可复核的系数。
活跃秒数是最容易被忽略的参数。消费者产品通常不是 24 小时均匀产生请求:如果 80% 的请求集中在 12 小时内,用 86400 秒会把峰值低估一半。反过来,面向全球用户的产品,活跃窗口确实接近 86400 秒,此时再用 12 小时就会高估。这个参数没有通用值,只有产品自己的会话分布。
把这些口径写成一张假设表,评审时就能逐行追问来源,而不是对着一个总数争论:
# 容量假设表:所有数字都是案例假设值,不是线上实测
workload: 回声首页与下单入口
window: 2026-10-20/2026-10-26
dau:
low: 1800000
high: 2200000
source: auth_log 按自然日去重
requests_per_user:
low: 12
high: 20
source: 网关访问日志 / DAU
peak_factor:
low: 3
high: 5
source: 5 分钟粒度小时峰值 / 当日均值
active_seconds:
low: 43200
high: 86400
source: 用户首个与最后一个请求的时间差
假设表要和代码一起进版本库。三个月后再看,最危险的不是数字过时,而是没人记得当时的 86400 是怎么来的。
评审时按顺序问六个问题:这个 DAU 是哪一天、哪个口径的去重?RPU 数的是网关请求还是客户端事件?峰值系数覆盖了几周、含不含大促?活跃窗口是按哪个地区算的?下游扇出有没有单独列?如果真实值落到区间上界,哪台设备会先满?
区间不是保守,而是把误差放回它该在的位置
既然四个参数都有不确定性,更诚实的做法是给每个参数一个区间,再看结果的范围。把上面的分歧写成一组输入:DAU 180 万到 220 万,RPU 12 到 20,峰值系数 3 到 5,活跃秒数 43200 到 86400。
按“流量最小、窗口最长”组合,最小 QPS 是 750;按“流量最大、窗口最短”组合,最大 QPS 约 5093;各参数取中值时约 1975。结果不是一个数,而是一条 750 到 5093 的带子。
这条带子比单点更有用的原因有三个。第一,它把争论从“谁的数字对”变成“哪个区间可以接受”,评审会上可以直接问“如果真实值落在上界,我们的机器够不够”。第二,它能指出哪个参数最值得去测:在上面这组区间里,活跃秒数带来 2 倍跨度,峰值系数和 RPU 各带来 1.67 倍,DAU 只有 1.22 倍,先去校准会话窗口的收益最大。第三,它给容量决策留出了明确的余量依据,而不是在单点上再加一个拍脑袋的 30%。
具体公式在1.2 用户量、请求量与成本的三条曲线 里已经推导过,这里不再重复。本节关心的是输入怎么来:同样的公式,喂进拍脑袋的参数,只会得到精确的错误。
还要区分“用户请求”和“内部放大”。首页一个请求可能扇出到用户、关注、推荐、广告、配置五个下游,网关 QPS 是 2000,下游合计可能是 10000。容量表要按调用链分别列,不能拿入口 QPS 直接乘一个模糊系数,否则数据库连接池会先于应用实例耗尽。
失败场景:按平均 QPS 买机器
「回声」最终选了 1389 QPS 作为采购依据,又加了 15% 余量,按 1600 QPS 准备机器。这个数字对应的是“平均日”的晚高峰,不含大促。
大促当晚八点,优惠券入口开放,实际入口 QPS 在 90 秒内冲到 4000。应用实例的 CPU 打到 100%,请求开始在网关排队;排队又拉长了每个请求的持有时间,连接池被占满,健康检查开始超时,负载均衡把一些实例摘掉,剩余实例的压力更大。
监控上看到的是 p99 从 180 毫秒涨到 9 秒,客户端 3 秒超时,约 30% 的下单请求失败。值班同学手动扩了 20 个实例,但从拉镜像、启动、预热缓存到接入流量花了四分钟。这四分钟里,失败已经发生,用户已经离开。
当晚的时间线大致如下,所有数字都是案例假设,用来还原“扩容来不及”的节奏:
19:59 入口 1800 QPS,CPU 55%,p99 180ms
20:00 入口 2600 QPS,触发扩容
20:01 入口 3400 QPS,新实例开始拉镜像
20:03 入口 4000 QPS,p99 涨到 4.2s
20:04 连接池打满,健康检查开始超时
20:06 首批新实例接入,p99 回落到 900ms
20:09 扩容完成,排队逐步消化
事后复盘,采购数字本身没有算错,错在把它当成了上界。1389 是“正常日晚高峰”的点估计,不是“大促瞬时”的上界;15% 余量覆盖不了 2.9 倍的瞬时放大。如果当初用区间估算,最大 QPS 5093 会直接暴露这个缺口,团队至少会提前做限流、排队和预热,而不是在事故现场加机器。
还有一种常见的自我安慰:把弹性策略当成兜底。弹性确实能跟随趋势,但它有采集窗口、触发延迟和冷启动,这些时间在时间线上是可见的六分钟,不是配置里的一句“已开启”。
点估计还会掩盖“哪个环节先满”的问题。按 1389 采购时,团队只看了应用 CPU,没有算数据库连接池、下游限流和缓存带宽;实际事故里,连接池比 CPU 更早成为瓶颈。区间估算配合调用链拆分,才能把“哪里先崩”一起摆到桌面上。
用 Bounds 把区间估算写成代码
把上面的口径固化成代码,输入四个 Range,输出最小值、最大值和中值。函数只做组合计算,不替调用方选择“该信哪个数”;区间非法时直接报错,避免把负数或倒置区间悄悄算成一个看似合理的结果。
这里的结果类型叫 BoundsResult,因为同一个包里已经有一个表示单点估算的 Result,重名会编译失败。命名上的这个小让步,恰好提醒我们:区间估算和单点估算是两种不同的东西,不该共用一个类型。
// file: internal/platform/capacity/sensitivity.go
// tier: L1
// verify: go test ./internal/platform/capacity/
package capacity
import "fmt"
// Range 是一个参数的可能取值区间。容量评估用区间而不是单点,是为了让
// 假设的误差显式传播到结果,而不是藏进一个看起来精确的数字里。
type Range struct {
Low float64
High float64
}
// Inputs 是四个最常被拍脑袋的参数:DAU、每用户请求数、峰值系数和
// 有效活跃秒数。最后一个不是 86400,而是用户真正产生请求的时段长度。
type Inputs struct {
DAU Range
RequestsPerUser Range
PeakFactor Range
ActiveSeconds Range
}
// BoundsResult 同时给出最保守与最激进的估算,NominalQPS 取各区间中值。
type BoundsResult struct {
MinQPS float64
MaxQPS float64
NominalQPS float64
}
// Bounds 用区间上下界的组合给出 QPS 的最小值与最大值。
// 最小 QPS 取“流量最小、活跃时段最长”的组合,最大 QPS 取相反组合。
func Bounds(in Inputs) (BoundsResult, error) {
switch {
case in.DAU.Low <= 0 || in.DAU.High <= 0:
return BoundsResult{}, fmt.Errorf("capacity: DAU must be positive")
case in.DAU.Low > in.DAU.High:
return BoundsResult{}, fmt.Errorf("capacity: DAU low %.3f > high %.3f", in.DAU.Low, in.DAU.High)
case in.RequestsPerUser.Low < 0 || in.RequestsPerUser.High <= 0:
return BoundsResult{}, fmt.Errorf("capacity: requests per user must be positive")
case in.RequestsPerUser.Low > in.RequestsPerUser.High:
return BoundsResult{}, fmt.Errorf("capacity: requests per user low %.3f > high %.3f", in.RequestsPerUser.Low, in.RequestsPerUser.High)
case in.PeakFactor.Low < 1 || in.PeakFactor.High < 1:
return BoundsResult{}, fmt.Errorf("capacity: peak factor must be >= 1")
case in.PeakFactor.Low > in.PeakFactor.High:
return BoundsResult{}, fmt.Errorf("capacity: peak factor low %.3f > high %.3f", in.PeakFactor.Low, in.PeakFactor.High)
case in.ActiveSeconds.Low <= 0 || in.ActiveSeconds.High <= 0:
return BoundsResult{}, fmt.Errorf("capacity: active seconds must be positive")
case in.ActiveSeconds.Low > in.ActiveSeconds.High:
return BoundsResult{}, fmt.Errorf("capacity: active seconds low %.3f > high %.3f", in.ActiveSeconds.Low, in.ActiveSeconds.High)
}
minQPS := in.DAU.Low * in.RequestsPerUser.Low / in.ActiveSeconds.High * in.PeakFactor.Low
maxQPS := in.DAU.High * in.RequestsPerUser.High / in.ActiveSeconds.Low * in.PeakFactor.High
dauMid := (in.DAU.Low + in.DAU.High) / 2
rpuMid := (in.RequestsPerUser.Low + in.RequestsPerUser.High) / 2
peakMid := (in.PeakFactor.Low + in.PeakFactor.High) / 2
secMid := (in.ActiveSeconds.Low + in.ActiveSeconds.High) / 2
nominal := dauMid * rpuMid / secMid * peakMid
return BoundsResult{MinQPS: minQPS, MaxQPS: maxQPS, NominalQPS: nominal}, nil
}
实现里有三个值得说明的选择。最小值用 DAU.Low × RPU.Low ÷ ActiveSeconds.High × PeakFactor.Low,最大值用相反的上下界,这样得到的是组合意义上的边界,而不是对每个参数独立取极值后再平均。中值取每个区间中点,只用于和单点估算对照,不作为采购依据。校验把负数、倒置区间、零活跃秒和小于 1 的峰值系数都挡在计算之前。
测试:边界组合与非法区间
测试覆盖三类情况:一组典型区间的手算结果、活跃窗口缩短到 8 小时后峰值翻三倍,以及六种非法输入。测试里的期望值都写成注释里的算式,评审时可以逐项复核。
// file: internal/platform/capacity/sensitivity_test.go
// tier: L1
// verify: go test ./internal/platform/capacity/
package capacity
import (
"math"
"testing"
)
func nearSensitivity(a, b float64) bool { return math.Abs(a-b) < 1e-6 }
func TestBoundsCombinesExtremes(t *testing.T) {
got, err := Bounds(Inputs{
DAU: Range{Low: 1_000_000, High: 1_200_000},
RequestsPerUser: Range{Low: 18, High: 22},
PeakFactor: Range{Low: 2.5, High: 3.5},
ActiveSeconds: Range{Low: 43_200, High: 57_600},
})
if err != nil {
t.Fatal(err)
}
// 最小:1e6 × 18 / 57600 × 2.5 = 781.25 QPS。
if !nearSensitivity(got.MinQPS, 781.25) {
t.Fatalf("min qps = %f", got.MinQPS)
}
// 最大:1.2e6 × 22 / 43200 × 3.5 ≈ 2138.888889 QPS。
if !nearSensitivity(got.MaxQPS, 2138.888889) {
t.Fatalf("max qps = %f", got.MaxQPS)
}
// 中值:1.1e6 × 20 / 50400 × 3 ≈ 1309.52381 QPS。
if !nearSensitivity(got.NominalQPS, 1309.52381) {
t.Fatalf("nominal qps = %f", got.NominalQPS)
}
}
func TestBoundsUsesShortActiveWindow(t *testing.T) {
// 活跃时段从 24 小时缩到 8 小时,峰值会变成三倍。
full, err := Bounds(Inputs{
DAU: Range{Low: 100_000, High: 100_000},
RequestsPerUser: Range{Low: 10, High: 10},
PeakFactor: Range{Low: 1, High: 1},
ActiveSeconds: Range{Low: 86_400, High: 86_400},
})
if err != nil {
t.Fatal(err)
}
short, err := Bounds(Inputs{
DAU: Range{Low: 100_000, High: 100_000},
RequestsPerUser: Range{Low: 10, High: 10},
PeakFactor: Range{Low: 1, High: 1},
ActiveSeconds: Range{Low: 28_800, High: 28_800},
})
if err != nil {
t.Fatal(err)
}
if !nearSensitivity(short.MaxQPS, full.MaxQPS*3) {
t.Fatalf("short window max = %f, want %f", short.MaxQPS, full.MaxQPS*3)
}
}
func TestBoundsRejectsBadRanges(t *testing.T) {
ok := Inputs{
DAU: Range{Low: 1_000, High: 2_000},
RequestsPerUser: Range{Low: 5, High: 8},
PeakFactor: Range{Low: 2, High: 3},
ActiveSeconds: Range{Low: 28_800, High: 43_200},
}
bad := []Inputs{
{DAU: Range{Low: 0, High: 2_000}, RequestsPerUser: ok.RequestsPerUser, PeakFactor: ok.PeakFactor, ActiveSeconds: ok.ActiveSeconds},
{DAU: Range{Low: 3_000, High: 2_000}, RequestsPerUser: ok.RequestsPerUser, PeakFactor: ok.PeakFactor, ActiveSeconds: ok.ActiveSeconds},
{DAU: ok.DAU, RequestsPerUser: Range{Low: -1, High: 8}, PeakFactor: ok.PeakFactor, ActiveSeconds: ok.ActiveSeconds},
{DAU: ok.DAU, RequestsPerUser: ok.RequestsPerUser, PeakFactor: Range{Low: 0.5, High: 3}, ActiveSeconds: ok.ActiveSeconds},
{DAU: ok.DAU, RequestsPerUser: ok.RequestsPerUser, PeakFactor: ok.PeakFactor, ActiveSeconds: Range{Low: 0, High: 43_200}},
{DAU: ok.DAU, RequestsPerUser: ok.RequestsPerUser, PeakFactor: ok.PeakFactor, ActiveSeconds: Range{Low: 50_000, High: 43_200}},
}
for i, in := range bad {
if _, err := Bounds(in); err == nil {
t.Fatalf("case %d: want error", i)
}
}
if _, err := Bounds(ok); err != nil {
t.Fatalf("valid input rejected: %v", err)
}
}
注意第二个测试:DAU、RPU、峰值系数都不变,只把活跃秒数从 86400 改成 28800,最大 QPS 就应该正好变成三倍。这类“只动一个参数”的测试比笼统的 golden number 更能说明模型的行为。
运行命令与真实输出
cd .local/checks/webapp-lab
go test ./internal/platform/capacity/ -run 'TestBounds' -v -count=1
=== RUN TestBoundsCombinesExtremes
--- PASS: TestBoundsCombinesExtremes (0.00s)
=== RUN TestBoundsUsesShortActiveWindow
--- PASS: TestBoundsUsesShortActiveWindow (0.00s)
=== RUN TestBoundsRejectsBadRanges
--- PASS: TestBoundsRejectsBadRanges (0.00s)
PASS
ok webapp/internal/platform/capacity 0.223s
-run 只匹配 TestBounds 开头的三个测试,避免把同包的存储、成本与弹性测试混进本节输出。0.223s 是一次本机运行的结果,耗时会随机器和负载变化,不能当成性能结论。
小结
DAU 到 QPS 的估算,难点不在乘除,而在四个参数的口径:DAU 要用当天鉴权去重,RPU 要用服务端入口请求,峰值系数要从小时曲线读,活跃秒数要看会话分布而不是默认 86400。
单点估算会把四层不确定性压成一个看似精确的数字。区间估算保留这些不确定性,指出哪个参数最敏感,也让容量决策有明确的上界可讨论。案例里的 750 到 5093 不是精确预测,而是一张“最坏能坏到哪”的地图。
下一节把 QPS 换成账单:请求进来之后,存储、带宽和机器各自怎么计费,哪些项目最容易被漏掉。
阅读导航:上一节:目录与学习路径 · 下一节:12.2 存储、带宽与机器成本 。
常见错误与边界
- 拿注册用户数或月活当 DAU。口径每放宽一层,容量就会高估一截。
- 把 86400 秒当成活跃窗口。请求集中在 12 小时内的产品,峰值会被低估一半。
- RPU 混用客户端埋点和服务端请求。一次曝光多个事件、一次下单多个接口,两个方向都会错。
- 峰值系数凭经验写 2 或 3。不同品类的小时曲线形状差异很大,必须从真实数据读。
- 只报一个单点数字,不附区间和来源。评审无法判断这个数字是测出来的还是猜出来的。
- 忘记内部放大。入口 2000 QPS 的首页可能扇出到五个下游,连接池会先耗尽。
- 用平均 QPS 买机器,再拍一个固定余量。平均值和瞬时峰值不是一回事,15% 覆盖不了大促。
- 把案例假设值当成实测数据。本文的 DAU、QPS 和耗时都是案例假设,真实容量必须按自己的日志重算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。