复盘会上的三个数字
假设一次季度复盘,三张图放在一起: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 高数倍。
- 忽略单副本故障。副本数是最低下限,不是高可用方案;还要考虑冗余和故障转移。
- 把模型当压测。模型只能判断量级和矛盾,不能证明单机吞吐。
- 成本只算计算。存储保留期和出网带宽经常是账单里增长最快的部分。
- 假设永远线性扩展。数据库、缓存和下游依赖通常先成为瓶颈。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。