《互联网产品的服务端架构·产品、流量与基础能力》1.2 用户量、请求量与成本的三条曲线

把用户量、请求量与成本拆成三条曲线,用 100 万和 1000 万 DAU 两个完整算例演示从人均请求、峰值系数到副本数的推导,说明每个输入假设的来源与失效后果,并给出一段可测试的 Go 容量估算模型,文中还会分析只按平均 QPS 扩容的典型事故。

复盘会上的三个数字

假设一次季度复盘,三张图放在一起:DAU 从 100 万涨到 200 万,翻倍;网关统计的请求量只涨了 40%;云账单涨了 3 倍。三个人给出三种解释:产品说用户变多了,运维说请求没涨多少,财务说成本失控。

三个数字都没错,错在把它们当成同一条曲线。用户量、请求量、成本是三条不同的曲线,各自的驱动因素、增长速度和失效方式都不同。把它们混在一起讨论,就会得到“用户翻倍、账单翻三倍,但不知道钱花在哪”的结论。

本节把三条曲线拆开,用两个完整算例演示从 DAU 到副本数的推导,并说明每个输入假设从哪里来、错了会怎样。所有产品数字都是案例假设值,只有运行输出来自实际执行。

三条曲线与它们的输入假设

  • 用户量曲线:DAU/MAU,驱动因素是拉新、留存和季节性。它决定账户、存储、推送等“按人计费”的部分。
  • 请求量曲线:DAU × 人均请求 × 峰值系数 / 86400。它决定计算和连接数,而不是用户数本身。
  • 成本曲线:计算(副本数 × 单价)+ 存储(数据量 × 保留期)+ 带宽(响应体 × 请求量)。三条曲线里只有成本是“结果”,另外两条是“输入”。

这三个公式都很简单,难的是每个输入都要有来源。下面这张表把常用输入、可能的来源和“如果错了会怎样”放在一起。

输入假设常见来源如果错了会怎样
DAU产品后台的去重活跃口径副本数近似线性偏差
人均请求网关日志按端拆分漏掉后台轮询、推送确认、重试
峰值系数分时 QPS 曲线或压测晚高峰被打穿,平均 QPS 再准也没用
平均延迟压测的 p50,不是 p99并发数被低估,长尾请求排队
目标利用率容量策略与成本预算余量不足或过度采购
单副本核数机型规格与实测吞吐副本数看似变少,单点故障半径变大

表里每一行都能追溯到产品指标或压测结果,这是容量模型的底线。如果某个输入只能写“拍脑袋”,那就应该把它标成假设,并在上线后用真实数据替换,而不是把它混在一堆精确到小数点后两位的数字里假装可信。

算例一:100 万 DAU

输入(案例假设值):DAU 100 万,人均每天 20 次请求,峰值系数 3,平均延迟 50 毫秒,目标利用率 0.6,单副本 4 核。

  • 平均 QPS = 1,000,000 × 20 / 86400 ≈ 231.48。
  • 峰值 QPS = 231.48 × 3 ≈ 694.44。
  • 并发数 = 694.44 × 0.05 ≈ 34.72(Little’s Law:并发 ≈ QPS × 延迟秒数)。
  • 单副本能力 = 4 × (1000 / 50) × 0.6 = 48 QPS。
  • 副本数 = ceil(694.44 / 48) = ceil(14.47) = 15。

15 这个数字的含义是“在下述假设全部成立时,至少需要 15 个副本”。它不是“买 15 台机器就高枕无忧”,因为假设本身可能错,机器也可能坏。

算例二:1000 万 DAU

输入(案例假设值):DAU 1000 万,人均每天 50 次请求,峰值系数 4,平均延迟 80 毫秒,目标利用率 0.6,单副本 8 核。

  • 平均 QPS = 10,000,000 × 50 / 86400 ≈ 5787.04。
  • 峰值 QPS = 5787.04 × 4 ≈ 23148.15。
  • 并发数 = 23148.15 × 0.08 ≈ 1851.85。
  • 单副本能力 = 8 × (1000 / 80) × 0.6 = 60 QPS。
  • 副本数 = ceil(23148.15 / 60) = ceil(385.80) = 386。

从 100 万到 1000 万,DAU 涨了 10 倍,人均请求涨了 2.5 倍,峰值系数从 3 到 4,延迟从 50 到 80 毫秒,单副本从 4 核到 8 核,副本数从 15 涨到 386,约 25.7 倍。如果只看 DAU,会低估接近 2.6 倍;如果只看平均 QPS,会低估峰值系数带来的 4 倍。这就是为什么三条曲线必须一起看。

还要注意,386 台副本只是计算层的下限。真实系统还要加数据库、缓存、消息队列、负载均衡和冗余,成本曲线通常比副本数涨得更快,因为存储和带宽往往不能像无状态计算那样线性扩展。

只按平均 QPS 扩容会发生什么

假设团队只看了算例一的平均 QPS 231,觉得 15 个副本“绰绰有余”,于是按平均值的 3 倍留余量,采购了约 21 个副本,总能力约 1008 QPS。这个数字看起来覆盖了峰值 694,但忽略了两件事。

第一,峰值系数不是常数。案例假设晚高峰是平均值的 3 倍,但活动、推送和客户端集中刷新会把瞬时峰值推到 4 倍甚至更多。假设某次活动瞬时峰值 4.5 倍,即约 1041 QPS,已经超过 1008 的总能力;如果叠加发布时的滚动重启,可用副本更少,雪崩会从最热的一个接口开始。

第二,扩容不是瞬时的。基于 CPU 的自动扩缩容从触发到新副本就绪,假设需要 60–120 秒;这段时间里请求继续堆积,超时重试又放大流量。结果不是“慢一点”,而是“越扩越堵”。

第三个常见错误是把缓存命中率当常量。假设平时命中率 90%,回源 QPS 约 69;大促缓存集中失效时命中率掉到 40%,回源 QPS 变成约 417,是平时的 6 倍。数据库连接池先耗尽,然后所有依赖数据库的接口一起超时。缓存命中率不是架构参数,而是随流量结构、过期策略和热点变化的状态量,必须按最坏情况做预算。

第四个错误是只算请求、不算连接。长连接、WebSocket 和流式响应占用的是连接数和内存,不是 QPS。一个 10 万在线的私信服务,即使每秒请求不多,也可能因为每条连接的内核缓冲和心跳开销,先把网关的内存吃满。容量模型如果只有 QPS 一个维度,就会漏掉这类资源。

用一段 Go 代码把容量模型固定下来

容量讨论最容易变成“我觉得”。把公式写成一个纯函数,至少能让所有人对着同一组输入争论:改哪个假设、结果变多少,一眼可见。

下面的 Estimate 接收显式输入,返回平均 QPS、峰值 QPS、并发数、单副本能力和副本数。它刻意不做自动扩缩容、缓存命中和多级依赖的建模,因为这些属于后续章节;这里只保留一个可以手算核对的下限模型。

// file: internal/platform/capacity/capacity.go
// tier: L1
// verify: go test ./internal/platform/capacity/

package capacity

import (
	"fmt"
	"math"
)

// Input 是对容量的显式假设。每一项改动都必须能追溯到产品指标或压测结果。
type Input struct {
	DAU               int
	RequestsPerUser   float64 // 每个用户每天的请求数
	PeakFactor        float64 // 峰值 QPS / 平均 QPS
	AvgLatencyMs      float64
	TargetUtilization float64 // 单副本目标利用率,0.6 表示留 40% 余量
	CoresPerReplica   int
}

type Result struct {
	AvgQPS        float64
	PeakQPS       float64
	Concurrency   float64
	PerReplicaQPS float64
	Replicas      int
}

// Estimate 用“平均 QPS × 峰值系数 → 并发数 → 副本数”的口算模型给出容量下限。
// 它不替代压测,只用来判断量级和发现自相矛盾的假设。
func Estimate(in Input) (Result, error) {
	switch {
	case in.DAU <= 0:
		return Result{}, fmt.Errorf("capacity: DAU must be positive")
	case in.RequestsPerUser <= 0:
		return Result{}, fmt.Errorf("capacity: requests per user must be positive")
	case in.PeakFactor < 1:
		return Result{}, fmt.Errorf("capacity: peak factor must be >= 1")
	case in.AvgLatencyMs <= 0:
		return Result{}, fmt.Errorf("capacity: latency must be positive")
	case in.TargetUtilization <= 0 || in.TargetUtilization > 1:
		return Result{}, fmt.Errorf("capacity: utilization must be in (0,1]")
	case in.CoresPerReplica <= 0:
		return Result{}, fmt.Errorf("capacity: cores per replica must be positive")
	}

	avgQPS := float64(in.DAU) * in.RequestsPerUser / 86400
	peakQPS := avgQPS * in.PeakFactor
	concurrency := peakQPS * in.AvgLatencyMs / 1000
	perReplica := float64(in.CoresPerReplica) * (1000 / in.AvgLatencyMs) * in.TargetUtilization
	replicas := int(math.Ceil(peakQPS / perReplica))
	if replicas < 1 {
		replicas = 1
	}

	return Result{
		AvgQPS:        avgQPS,
		PeakQPS:       peakQPS,
		Concurrency:   concurrency,
		PerReplicaQPS: perReplica,
		Replicas:      replicas,
	}, nil
}

代码里有几个设计选择值得说明。

  • 所有输入都有校验,且错误信息指出具体字段。容量模型最常见的失败不是算错,而是把 0 或负数悄悄带进公式。
  • 平均 QPS 用 86400 秒折算,意味着输入是“每天”的口径。如果产品指标是“每个活跃日”或“每个会话”,要先换算,否则会系统性偏差。
  • 单副本能力用“核数 × 每秒可处理请求数 × 目标利用率”估算,隐含假设是请求受 CPU 或延迟约束、且近似线性。它不适用于锁竞争、GC 停顿或下游阻塞主导的场景。
  • 副本数向上取整,并保证至少为 1。这是工程上的保守选择:模型给出的是下限,不是精确值。

模型不替代压测。它的价值有两个:一是把假设显式化,让“为什么是 386 台”可以被追问;二是发现自相矛盾的输入,例如延迟 500 毫秒却声称单机能扛几千 QPS。真正的容量结论仍然要来自压测和线上观测。

测试:把两个算例钉在代码里

测试用两个算例固定模型行为:100 万 DAU 期望 15 个副本,1000 万 DAU 期望 386 个副本;第三个测试覆盖非法输入。这样以后有人调整公式,至少会先看到这两个数字发生变化。

// file: internal/platform/capacity/capacity_test.go
// tier: L1
// verify: go test ./internal/platform/capacity/

package capacity

import (
	"math"
	"testing"
)

func almostEqual(a, b float64) bool { return math.Abs(a-b) < 1e-6 }

func TestEstimateHundredMillionDAU(t *testing.T) {
	got, err := Estimate(Input{
		DAU:               1_000_000,
		RequestsPerUser:   20,
		PeakFactor:        3,
		AvgLatencyMs:      50,
		TargetUtilization: 0.6,
		CoresPerReplica:   4,
	})
	if err != nil {
		t.Fatal(err)
	}
	if !almostEqual(got.AvgQPS, 231.481481) {
		t.Fatalf("avg qps = %f", got.AvgQPS)
	}
	if !almostEqual(got.PeakQPS, 694.444444) {
		t.Fatalf("peak qps = %f", got.PeakQPS)
	}
	if !almostEqual(got.Concurrency, 34.722222) {
		t.Fatalf("concurrency = %f", got.Concurrency)
	}
	// 单副本 = 4 核 × (1000/50) × 0.6 = 48 QPS;694.44/48 向上取整 = 15。
	if got.Replicas != 15 {
		t.Fatalf("replicas = %d, want 15", got.Replicas)
	}
}

func TestEstimateAddsReplicasWhenLoadExceedsOneNode(t *testing.T) {
	got, err := Estimate(Input{
		DAU:               10_000_000,
		RequestsPerUser:   50,
		PeakFactor:        4,
		AvgLatencyMs:      80,
		TargetUtilization: 0.6,
		CoresPerReplica:   8,
	})
	if err != nil {
		t.Fatal(err)
	}
	wantPerReplica := 8 * (1000.0 / 80) * 0.6
	if !almostEqual(got.PerReplicaQPS, wantPerReplica) {
		t.Fatalf("per replica = %f, want %f", got.PerReplicaQPS, wantPerReplica)
	}
	// 峰值 23148.15 QPS;单副本 60 QPS;23148.15/60 向上取整 = 386。
	if got.Replicas != 386 {
		t.Fatalf("replicas = %d, want 386", got.Replicas)
	}
}

func TestEstimateRejectsBadInput(t *testing.T) {
	ok := Input{DAU: 1, RequestsPerUser: 1, PeakFactor: 1, AvgLatencyMs: 10, TargetUtilization: 0.5, CoresPerReplica: 1}
	bad := []Input{
		{DAU: 0, RequestsPerUser: 1, PeakFactor: 1, AvgLatencyMs: 10, TargetUtilization: 0.5, CoresPerReplica: 1},
		{DAU: 1, RequestsPerUser: 0, PeakFactor: 1, AvgLatencyMs: 10, TargetUtilization: 0.5, CoresPerReplica: 1},
		{DAU: 1, RequestsPerUser: 1, PeakFactor: 0.5, AvgLatencyMs: 10, TargetUtilization: 0.5, CoresPerReplica: 1},
		{DAU: 1, RequestsPerUser: 1, PeakFactor: 1, AvgLatencyMs: 0, TargetUtilization: 0.5, CoresPerReplica: 1},
		{DAU: 1, RequestsPerUser: 1, PeakFactor: 1, AvgLatencyMs: 10, TargetUtilization: 1.5, CoresPerReplica: 1},
		{DAU: 1, RequestsPerUser: 1, PeakFactor: 1, AvgLatencyMs: 10, TargetUtilization: 0.5, CoresPerReplica: 0},
	}
	for i, in := range bad {
		if _, err := Estimate(in); err == nil {
			t.Fatalf("case %d: want error", i)
		}
	}
	if _, err := Estimate(ok); err != nil {
		t.Fatalf("valid input rejected: %v", err)
	}
}

测试里的注释把推导过程写在了断言旁边,这是有意的。容量数字离开假设就没有意义,把“4 核 × (1000/50) × 0.6 = 48 QPS,694.44/48 向上取整 = 15”留在代码里,比留在某次会议的截图里更可靠。

第三个测试覆盖六种非法输入:DAU 为 0、人均请求为 0、峰值系数小于 1、延迟为 0、利用率大于 1、核数为 0。它同时也验证了一组合法输入不会被误拒。这类测试不产生业务价值,但能防止模型在参数缺失时给出一个看似合理的错误答案。

测试没有覆盖的是模型与现实之间的差距:它不验证 48 QPS 是否真的能达到,不验证 p99 延迟,也不验证缓存和数据库。那些需要压测环境和线上指标,不能靠单元测试替代。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/platform/capacity/ -count=1

一次本机运行的真实输出如下,耗时随机器变化:

ok  	webapp/internal/platform/capacity	0.341s

ok 表示三个测试全部通过。如果只想看两个算例的中间值,可以在测试里临时加 t.Logf,但不建议把调试输出留在提交里;需要长期观察时,应该把关键输入输出接到监控或容量报告,而不是靠测试日志。

小结

用户量、请求量和成本是三条曲线。用户量按人增长,请求量按“人均请求 × 峰值系数”增长,成本按计算、存储和带宽分别增长。把三条曲线混成一条,就会得到“DAU 翻倍、账单翻三倍,但不知道钱花在哪”的复盘。

容量模型的作用不是给出精确答案,而是把假设写全、把量级算对、把矛盾暴露出来。100 万 DAU 对应 15 个副本,1000 万 DAU 对应 386 个副本,这两个数字只在给定假设下成立;换一个峰值系数或延迟,结论就会变。

下一节讨论可用性、体验与合规的底线:即使容量算对了,系统仍然会因为依赖、预算和合规问题在用户那里“不可用”。

阅读导航:上一节:1.1 产品形态如何决定服务端形状 · 下一节:1.3 可用性、体验与合规的底线 。

常见错误与边界

  • 只按平均 QPS 扩容。峰值系数、发布滚动和重试放大都会让瞬时流量远超平均值。
  • 把缓存命中率当常量。命中率会随热点、过期策略和大促变化,必须按最坏情况预留回源能力。
  • 用 DAU 直接乘一个“经验 QPS”。人均请求、端类型和重试策略不同,结果可能差一个数量级。
  • 用 p50 延迟估算并发。决定排队的是长尾,p99 往往比 p50 高数倍。
  • 忽略单副本故障。副本数是最低下限,不是高可用方案;还要考虑冗余和故障转移。
  • 把模型当压测。模型只能判断量级和矛盾,不能证明单机吞吐。
  • 成本只算计算。存储保留期和出网带宽经常是账单里增长最快的部分。
  • 假设永远线性扩展。数据库、缓存和下游依赖通常先成为瓶颈。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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