《互联网产品的服务端架构·产品、流量与基础能力》12.1 DAU 到 QPS 的估算

从「回声」三次容量评审给出相差数倍的数字写起,拆解 DAU、每用户请求数、峰值系数与活跃秒数各自的标定来源和常见偏差,用区间估算替代单点,给出可测试的 Bounds 实现、非法区间测试与一次真实 go test 输出,并解释按平均 QPS 买机器为何会在晚高峰排队。

同一份产品,三次评审给出三个 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 和耗时都是案例假设,真实容量必须按自己的日志重算。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查