一次“三张表对不上”的容量评审
「回声」扩容评审上摆着三张表。产品团队按 DAU 50 万、人均每天 12 次请求算,得到平均 69.4 QPS,再乘峰值系数 3.2,写的是 222 QPS。运维团队按“用户活跃 12 小时”重算,写的是 444 QPS。财务团队只关心账单,按 200 核跑满一个月估出 14600 元,却没有把 20 TB 存储和 30 TB 出流量算进去。
三张表都没有算错乘法,它们错在选了不同的函数和不同的参数口径。产品把全天 86400 秒当成了活跃时段,运维用对了 43200 秒,财务漏掉了存储与带宽两行。更麻烦的是,没有人写清楚哪些数字是案例假设、哪些来自监控,评审就无法判断该信哪一张。
这份附录不重复正文的推导,只做四件事:给出“问题到函数”的对照表,标定每个参数的来源,用三个可复现的算例走完输入、过程、结果和单位,最后列出最容易踩的坑。下文所有业务数字均为案例假设值,不是线上实测;Go 版本为 1.26.0,模块名为 webapp。
速查总表:先选函数,再代数字
容量问题先分类,再找函数。把“峰值 QPS”和“月成本”混在一个表里算,最后一定对不上账。
| 要回答的问题 | 函数 | 公式(案例口径) | 最容易算错的项 |
|---|---|---|---|
| 峰值 QPS、并发与副本数 | Estimate(Input) | avg = DAU × RPU / 86400;peak = avg × factor;concurrency = peak × latency / 1000;perReplica = cores × (1000 / latency) × util;replicas = ceil(peak / perReplica) | 用 86400 代替活跃秒数;把并发数当成 QPS |
| 参数不确定时的 QPS 区间 | Bounds(Inputs) | min = DAU.low × RPU.low / ActiveSeconds.high × Peak.low;max 取相反边界;nominal 取各区间中值 | 把 nominal 当成 min 与 max 的中点;活跃秒数上下界方向写反 |
| 存储长期稳态 | SteadyState(Growth) | CurrentGB + DailyWriteGB × RetentionDays × Replication | 只乘 30 天;漏掉副本或保留天数 |
| N 天后的存储量 | AfterDays(Growth, days) | CurrentGB + DailyWriteGB × days × Replication | 以为它会模拟过期删除;把 days 当成保留天数 |
| 月度计算、存储、带宽成本 | Monthly(Usage, Price) | Compute = Cores × Hours × CoreHour;Storage = StorageGB × GBMonth;Transfer = TransferGB × GBTransfer;Total 为三项之和 | 30 天还是 730 小时;TB 与 GB 混用;出流量按峰值估 |
| 下一轮实例数 | Decide(Scaler, now, current, util, Policy) | util > target 加 Step;util < target/2 减 Step;结果夹在 [Min, Max];冷却期内不动 | 把 target 当硬阈值;忽略死区和冷却时间 |
Estimate 和 Bounds 回答的是不同问题:前者在给定一组点估计时给副本数,后者在参数只能给区间时给风险边界。评审里常见的错误,是拿 Bounds 的最大值直接去买机器,或者拿 Estimate 的单点结果去承诺 SLA,两者都把“估算”当成了“保证”。
参数标定表:每个数字都要有出处
同一套公式,参数来源不同,结果能差一倍。下表是这七个参数的建议口径。
| 参数 | 建议来源 | 典型误差与校准方式 |
|---|---|---|
| DAU | 最近 30 天的日活峰值,而不是月均值 | 活动日、投放日会显著高于均值;用低/高两个值保留区间 |
| RequestsPerUser | 网关访问日志中“每活跃用户每日请求数” | 要把健康检查、重试、预取剔除;BFF 聚合会让服务端请求数高于客户端点击 |
| PeakFactor | 5 分钟粒度的峰值 QPS 除以当日平均 QPS | 不要固定写 2 或 3;新功能上线首周要重新测 |
| ActiveSeconds | 产品真实产生请求的时段长度,12 小时记 43200 | 最容易直接写 86400,等于假设用户全天均匀访问 |
| RetentionDays | 产品需求、合规要求与备份策略共同决定 | 软删除、版本控制、快照会额外占用;保留期翻倍,存储近似翻倍 |
| Replication | 对象存储冗余、跨可用区、跨区域容灾逐层相加 | 只写“三副本”往往漏掉跨区域复制这一独立计费项 |
| TargetUtil | 压测中延迟开始抬升的拐点,常用 0.6 | 超过 0.8 后长尾延迟通常明显变差;不要拿 CPU 平均利用率代替 |
参数标定不是一次性工作。DAU、RPU 和 PeakFactor 会随版本、季节和投放变化;保留天数与副本数则会随合规和容灾等级变化。每次容量评审至少重新确认一遍来源,并把“假设值”与“监控值”分开标注。
三个算例:从输入到单位
三个算例分别覆盖 QPS、存储和成本。每个算例都先写输入与单位,再写公式,最后给出结果;命令与真实输出统一放在下一节。
算例一:回声的峰值 QPS
输入是案例假设:DAU 50 万,人均每天 12 次请求,峰值系数 3.2,活跃时段 12 小时即 43200 秒。单点输入下 Bounds 的上下界重合,峰值 QPS 为:
500000 × 12 / 43200 × 3.2 = 444.444 QPS
如果把活跃秒数写成 86400,平均 QPS 是 69.444,峰值只有 222.222,正好差 2.000 倍。这个 2 倍不是小数误差,而是把“用户只在半天内访问”误设成“全天均匀访问”。峰值系数描述的是活跃时段内的短时波动,活跃秒数描述的是全天窗口,两者不能互相替代。
参数有区间时,Bounds 会给出更有用的边界。案例假设 DAU 45 万到 55 万、RPU 10 到 14、峰值系数 2.8 到 3.6、活跃时段 10 到 14 小时,算得最小 250.000 QPS、最大 770.000 QPS、标称 444.444 QPS。注意标称值不是最小与最大的中点,中点是 510.000;标称值来自各区间中值的组合,它回答“典型情况”,不回答“风险中点”。
算例二:图片存储的稳态占用
输入是案例假设:图片每天新增 40 GB,保留 180 天,三副本,当前存量按 0 计。SteadyState 的口径是“写入量与过期删除量相等时的总占用”:
40 GB/天 × 180 天 × 3 副本 = 21600 GB = 21.094 TB(按 1 TB = 1024 GB)
如果只按 30 天估,会得到 3600 GB;如果把保留期缩到 90 天,是 10800 GB;如果只存一份副本,是 7200 GB。三个因子中,保留天数与副本数经常被漏掉,而它们对账单的影响往往大于日增量本身。
副本数要乘在“写入 × 保留天数”之后,不能先乘进日增量再重复加一次。AfterDays 则是另一个函数:它算的是 N 天内的全部写入加当前存量,不模拟过期删除,所以不能用它回答长期稳态。
算例三:200 核一个月的账单
输入是案例假设:200 核、每月 730 小时、20 TB 存储、30 TB 出流量;单价假设为每核时 0.10 元、每 GB 月 0.02 元、每 GB 出流量 0.05 元。按 1 TB = 1024 GB 折算,存储 20480 GB,出流量 30720 GB。
计算:200 × 730 × 0.10 = 14600.00 元
存储:20480 × 0.02 = 409.60 元
带宽:30720 × 0.05 = 1536.00 元
总计:14600.00 + 409.60 + 1536.00 = 16545.60 元
计算占 88.24%,存储与带宽合计不到 12%。这个结构说明:在这个案例里,先优化机器利用率比先谈存储单价更有效;但如果图片日增或出流量翻几倍,结论会反转。月份口径也要统一:730 小时是 365 × 24 / 12 的平均值,按 30 天 × 24 小时算只有 720 小时,计算成本是 14400.00 元,比 730 小时少 200.00 元(总成本相应为 16345.60 元)。差异不大,但写进预算表就要说清楚用的是哪一个。
弹性速查:target、step 与 cooldown
Decide 的三个参数不是独立的旋钮,它们共同决定系统的反应速度和稳定性。
TargetUtil是目标利用率,0.6 表示按 60% 负载运行,留 40% 的突发余量。它同时定义了死区:利用率高于 target 才扩容,低于 target/2 才缩容,中间区间不动。Step是每次动作的实例数。step 越大,收敛越快,但越容易越过目标值;step 越小,越平稳,但面对快速上涨可能来不及。CooldownSeconds是两次动作之间的最小间隔。冷却期内Decide直接返回当前实例数,扩和缩都冻结。
一组可用的初始值是:Min 4、Max 40、TargetUtil 0.6、Step 2、CooldownSeconds 300。Min 取 4 是为了在单可用区故障时仍保留冗余;Max 取 40 是案例假设的配额上限;Step 2 约等于 20 个稳态实例的 10%,单次动作不会造成大幅过冲;300 秒长于常见的 1 分钟指标窗口和 2 到 3 个采集周期,能避免同一波流量触发连续动作。
按这套策略手算几个决策:当前 10 个实例、利用率 0.90,下一轮 12 个;利用率 0.50 落在死区,保持 10 个;利用率 0.20,下一轮 8 个;如果 30 秒前刚扩过容,即使利用率 0.95 也保持 10 个;到达 Max 40 后继续过载,结果仍是 40。最后一种情况说明弹性策略不能替代容量上限,过载时需要限流、降级或预扩容。
step 与 cooldown 要一起调。粗略的最大变化速率是 Step / CooldownSeconds,案例里是 2 / 300,即每分钟 0.4 个实例、每小时 24 个。如果业务流量能在 10 分钟内翻倍,这个速率不够,应该提前预扩容,而不是把 cooldown 降到 30 秒;后者会让系统在阈值附近反复扩缩。
实现与验证:两个纯函数
sensitivity.go 用区间而不是单点表达假设,目的是让误差显式传播。elastic.go 把扩缩容决策做成纯函数,时间由参数注入,测试不需要 sleep。存储与成本函数同样是纯函数,输入、输出和错误分支都可以直接测。
// 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
}
// file: internal/platform/capacity/elastic.go
// tier: L1
// verify: go test ./internal/platform/capacity/
package capacity
import "fmt"
// Policy 是弹性策略的全部可调参数。阈值、步长和冷却时间必须一起调,
// 单独把阈值调低只会换来更频繁的抖动。
type Policy struct {
Min int
Max int
TargetUtil float64 // 目标利用率,0.6 表示按 60% 负载运行
Step int // 每次扩缩容的实例数
CooldownSeconds int
}
// Scaler 保存上一次真正发生扩缩容的时间,供冷却判断使用。
type Scaler struct {
LastScaleAt int64
}
// Decide 返回下一次应运行的实例数。它不执行扩缩容,只做纯决策,
// 便于在测试里把时间当参数注入,而不是靠 sleep。
func Decide(s *Scaler, now int64, current int, util float64, p Policy) (int, error) {
switch {
case s == nil:
return 0, fmt.Errorf("capacity: scaler must not be nil")
case now < 0:
return 0, fmt.Errorf("capacity: now must not be negative")
case p.Min < 1:
return 0, fmt.Errorf("capacity: min must be at least 1")
case p.Max < p.Min:
return 0, fmt.Errorf("capacity: max must be >= min")
case p.TargetUtil <= 0 || p.TargetUtil > 1:
return 0, fmt.Errorf("capacity: target utilization must be in (0,1]")
case p.Step < 1:
return 0, fmt.Errorf("capacity: step must be at least 1")
case p.CooldownSeconds < 0:
return 0, fmt.Errorf("capacity: cooldown must not be negative")
case current < 0:
return 0, fmt.Errorf("capacity: current must not be negative")
case util < 0:
return 0, fmt.Errorf("capacity: utilization must not be negative")
}
// 从未扩缩容时 LastScaleAt 为 0,不应被误判为刚动作过。
if s.LastScaleAt > 0 && now-s.LastScaleAt < int64(p.CooldownSeconds) {
return current, nil
}
next := current
switch {
case util > p.TargetUtil:
next = current + p.Step
case util < p.TargetUtil/2:
next = current - p.Step
}
if next < p.Min {
next = p.Min
}
if next > p.Max {
next = p.Max
}
if next != current {
s.LastScaleAt = now
}
return next, nil
}
先跑包测试,确认这些函数的边界行为没有被改坏。
cd /Users/bingrong.yan/projects/birdor/plumephp/.local/checks/webapp-lab
go test ./internal/platform/capacity/
ok webapp/internal/platform/capacity 0.649s
再跑三个算例。下面这段命令与输出是一次本机真实运行,耗时随机器变化;所有业务输入都是案例假设值。
cd /Users/bingrong.yan/projects/birdor/plumephp/.local/tmp
python3 - <<'PY'
# 区间算例:Bounds 的最小/标称/最大(上下界组合,不是 min 与 max 的中点)
lo = 450_000 * 10 / 50_400 * 2.8
hi = 550_000 * 14 / 36_000 * 3.6
nom = 500_000 * 12 / 43_200 * 3.2
print(f"bounds_min={lo:.3f} bounds_nominal={nom:.3f} bounds_max={hi:.3f} midpoint={((lo + hi) / 2):.3f}")
# 算例一:回声的峰值 QPS(单点输入等价于 Bounds 的上下界重合)
dau, rpu, peak, sec = 500_000, 12, 3.2, 12 * 3600
qps = dau * rpu / sec * peak
print(f"active_seconds={sec} daily_requests={dau * rpu}")
print(f"peak_qps={qps:.3f}")
print(f"avg_qps_86400={dau * rpu / 86400:.3f} peak_qps_86400={dau * rpu / 86400 * peak:.3f}")
print(f"ratio_vs_86400={qps / (dau * rpu / 86400 * peak):.3f}")
# 算例二:图片存储稳态(SteadyState,CurrentGB=0)
daily, days, rep, current = 40, 180, 3, 0
total = current + daily * days * rep
print(f"retained_gb={daily * days * rep} steady_gb={total} steady_tb={total / 1024:.3f}")
print(f"if_90_days={daily * 90 * rep} if_one_replica={daily * days}")
# 算例三:200 核一个月的账单(Monthly)
cores, hours, storage_tb, transfer_tb = 200, 730, 20, 30
core_hour, gb_month, gb_transfer = 0.10, 0.02, 0.05
storage_gb, transfer_gb = storage_tb * 1024, transfer_tb * 1024
compute = cores * hours * core_hour
storage = storage_gb * gb_month
transfer = transfer_gb * gb_transfer
print(f"storage_gb={storage_gb} transfer_gb={transfer_gb}")
print(f"compute={compute:.2f} storage={storage:.2f} transfer={transfer:.2f} total={compute + storage + transfer:.2f}")
print(f"compute_share={compute / (compute + storage + transfer) * 100:.2f}%")
print(f"if_30_days={cores * 30 * 24 * core_hour:.2f}")
PY
bounds_min=250.000 bounds_nominal=444.444 bounds_max=770.000 midpoint=510.000
active_seconds=43200 daily_requests=6000000
peak_qps=444.444
avg_qps_86400=69.444 peak_qps_86400=222.222
ratio_vs_86400=2.000
retained_gb=21600 steady_gb=21600 steady_tb=21.094
if_90_days=10800 if_one_replica=7200
storage_gb=20480 transfer_gb=30720
compute=14600.00 storage=409.60 transfer=1536.00 total=16545.60
compute_share=88.24%
if_30_days=14400.00
小结
容量估算速查的顺序是固定的:先按问题选函数,再给每个参数标来源,能用区间就不用单点,最后把单位写进结果。Bounds 管不确定性,Estimate 管副本数,SteadyState 和 AfterDays 管存储,Monthly 管三项账单,Decide 管弹性决策;它们各自只回答一个问题,混用就会得到“看起来精确、实际对不上”的数字。
三个算例的结论都很朴素:活跃秒数从 86400 改成 43200,峰值 QPS 翻倍;保留天数与副本数会把 40 GB/天的写入放大到 21.094 TB;200 核的账单里计算占 88.24%,但存储和带宽翻倍时结论会变。把这些数字连同假设一起写进评审材料,比单独写一个“预计 500 QPS”有用得多。
更完整的推导在 /inet-base-12-1-dau-to-qps/、/inet-base-12-2-storage-bandwidth-cost/ 和 /inet-base-12-3-capacity-headroom/,三条曲线的直觉在 /inet-base-01-2-scale-curves/。本附录只负责把公式、参数和算例放在一页里,方便评审时快速对照。
常见错误与边界
- 把 86400 当成活跃秒数。它假设用户全天均匀访问,会把峰值低估成一半;活跃 12 小时应写 43200。
- 月成本按 30 天算却按 730 小时报价。30 天 × 24 小时是 720 小时,365 × 24 / 12 才是 730 小时,预算表要统一口径。
- 副本数乘错位置。副本应乘在“日增 × 保留天数”之后;先乘日增再重复加一次,会得到没有物理意义的数字。
- 出流量单价按峰值 QPS 估。带宽账单按月度 GB 计,应该用月总出流量乘单价,而不是峰值 QPS 乘单价。
- 把并发数当成 QPS。并发是同时在处理的请求数,QPS 是每秒完成的请求数,两者相差一个延迟因子;
Estimate里concurrency = peak × latency / 1000不能省。 - 按平均 QPS 买机器。平均 69.4 QPS 的案例峰值是 444.4 QPS,按平均值配容量会在每天的高峰过载。
- 忽略内部放大。网关的一次客户端请求可能变成 BFF 的多次服务端调用,重试还会再放大;
RequestsPerUser要按服务端入口统计。 - 忽略长尾 key 和热点。平均存储和平均 QPS 会掩盖单个大对象、单个热 key;容量评审要单独看 P99 对象大小和最高频 key 的访问占比。
- 把
Bounds的 nominal 当成 min 与 max 的中点。案例里 min 250、max 770、中点 510,nominal 是 444.444,三者含义不同。 - 只给一个点估计,不写区间和单位。没有区间的容量数字无法评审,没有单位的数字无法对账。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。