主题
系统容量估算:把请求、字节和等待时间算到同一张账上
“每天有一亿次请求”并不能直接回答要几台机器。请求有大有小,会在某些时段集中;一次用户操作可能读十次数据库;一台实例坏掉后,剩下的实例还要接住它的流量。估算要做的是把这些关系写清,让需要实测的数字露出来。
本文沿用一个假设的团队任务服务:10 万日活,每人每天读取 20 次、创建 2 次。先算 QPS、存储、带宽,再把它们和延迟、并发、队列联系起来。所有业务数字都是教学输入;文末计算器能重算。另有一组本机延迟测量,只说明指定环境中观察到的结果。
先统一单位和统计边界
| 量 | 单位与含义 | 经常混淆的东西 |
|---|---|---|
| QPS / RPS | 每秒查询数/请求数 | 不等于同时在执行的请求数,也不必等于业务操作数 |
| 吞吐 | 完成的操作/s、行/s 或字节/s | 发出了多少和成功完成多少应分开 |
| 延迟 | 一次操作的耗时,常用 ms | 服务时间、排队时间、客户端总时间不同 |
| 存量 | B、GB、GiB | GB = 10^9 B;GiB = 2^30 B |
| 带宽 | bit/s,通常报 Mbit/s 或 Gbit/s | 1 B = 8 bit;MB/s 不能直接与 Mbit/s 比 |
| 并发量 | 指定边界内尚未完成的请求数 | goroutine 数、连接数、在途请求数不总相同 |
| 利用率 | 已用资源/可用资源 | 磁盘装载率和 CPU 利用率不是同一种预算 |
定义“请求”时写出入口。浏览器一次点击可能发 3 个 API,每个 API 又并行调用 4 个服务;若分别在边缘、应用和数据库计数,得到的三个 QPS 都可能正确。需要哪一级容量,就算哪一级工作量。
QPS:平均值给规模,峰值决定接不接得住
全天平均读 QPS = 100000 × 20 ÷ 86400 = 23.15。
全天平均创建 QPS = 100000 × 2 ÷ 86400 = 2.31。
假设峰均比 10,读峰值约 231.48/s,创建峰值约 23.15/s,合计 254.63/s。这不是由日活推导出的自然规律,而是需要用真实流量替换的假设。若业务只在 8 小时活跃,先按 8 小时平均,再讨论这 8 小时内的峰均比,避免重复放大。
短时间窗口很重要。每分钟平均 1000 QPS,可能是持续每秒 1000,也可能是前 5 秒每秒 12000,后 55 秒零请求。队列、连接池和限流器看到的行为完全不同。聚合会掩盖短突发和尾延迟,SRE 的指标聚合说明给出了相同问题的分析。
还要把入口流量转换成内部工作量。假设一页列表执行 1 次任务查询和 1 次成员查询,缓存未命中率为 m,则仅这两项数据库查询速率约为读 QPS × 2 × m。若使用逐条查询负责人信息的 N+1 写法,页长 20 时可能从 2 次变成 21 次;“API QPS 没变”不能说明数据库负载没变。
重试会再放大流量。每次失败后最多重试两次,最坏发出三次;三层都各自最多发三次,极端情况下下游可能收到 27 次尝试。是否真会达到这个上界取决于重试条件和截止时间。容量表应分开正常尝试率与故障恢复尝试率,并给重试一个全链路预算。
存储:业务字节只是第一层
本案例每天新增 20 万任务,每任务假设 1000 B,保存 365 天:
业务存量 = 100000 × 2 × 365 × 1000 = 73,000,000,000 B = 73 GB,约 67.99 GiB。
| 层 | 本例假设 | 计算后容量 |
|---|---|---|
| 业务记录 | 73 GB | 73 GB |
| 索引、行头及其他记录开销 | 合计为业务记录的 1.5 倍 | 109.5 GB |
| 在线副本 | 共 3 份,包含主副本 | 328.5 GB |
| 磁盘目标装载率 | 70% | 需配置约 469.29 GB |
最后一行是除以 0.7,不是乘以 1.3。328.5 GB 的数据占总空间 70%,总空间才是 469.29 GB。上述倍数是估算占位符,要用目标数据库上的实际表与索引大小校准。这里的三份副本是集群合计,不是每台机器再放三份。
此外至少列出五笔独立账:WAL/操作日志保留、备份及异地副本、消息积压、临时排序和索引重建、审计与幂等记录。索引构建或压缩合并可能短时同时保留旧、新数据;磁盘快满时才临时扩容常常来不及。压缩率依数据而变,已经压缩的图片和文本不能共用一个系数。
存量也不总是“每天新增 × 天数”。如果有删除、TTL、更新产生的旧版本和清理滞后,应写成:旧存量 + 新增 + 暂未回收版本 − 实际回收量。逻辑删除不代表空间已经归还。
计算器真实输出:
text
days=365 logical=73.00 GB provisioned=469.29 GB
days=730 logical=146.00 GB provisioned=938.57 GB保留时间翻倍只使这部分存储翻倍,没有直接使峰值 QPS 翻倍。容量变化能否进一步拖慢查询,取决于索引、工作集和访问分布。
带宽:算方向、算大小,再算放大
假设列表响应平均包含 20 条、每条 200 B,再加 500 B 的应用层开销,平均响应 4500 B。在读峰值下:
出口速率 = 231.48 请求/s × 4500 B/请求 × 8 bit/B ≈ 8.33 Mbit/s。
这里的 500 B 是教学假设,不代表真实 HTTP、TLS、TCP、IP 开销总和。生产估算应从实际编码后的响应和网络监控校验压缩率、协议开销及重传。请求体入口、数据库复制、消息副本复制、备份和跨区流量分别计算,不能把公网出口等同于所有网卡流量。
正在绘制图示…
查看图源代码
flowchart LR
C["客户端"] -->|"请求体:入口字节"| A["API"]
A -->|"列表响应:出口字节"| C
A -->|"查询与写入"| D[("主数据库")]
D -->|"日志复制"| R[("副本")]
D -->|"备份与增量"| B[("备份存储")]
A -->|"每事件大小 × 扇出"| Q["通知队列"]
大响应容易让带宽先达到上限。假设某下载接口每次返回 10 MB,100 请求/s 就是约 1 GB/s,即 8 Gbit/s 的应用数据,尚未算协议开销。此时增加 CPU 核数未必有用;需要减少返回量、支持范围读取,或把内容交给适当的对象存储与缓存路径。这是单位换算结论,不是某种网卡的实测性能。
延迟和并发:Little 定律用平均数,不用 p99
在边界一致、长期稳定的系统中,平均在途量 L = 平均吞吐 λ × 平均驻留时间 W。原始结果见 Little 1961。这里的驻留时间要包括所选边界内的等待;如果只测数据库执行时间,就不能拿它推整个 API 的在途数。
假设峰值期间也达到稳定状态,吞吐 254.63/s,平均响应 0.08 s,则平均在途约 20.37 个。它不表示连接池必须设成 21,更不表示最大并发为 21。请求可能共用 HTTP/2 连接,一个请求可能同时占两个下游连接,而且瞬时并发会超过平均数。
go
fmt.Printf("mean inflight=%.2f\n", (readQPS+writeQPS)*peak*.08)text
mean inflight=20.37不要把式子里的 W 偷换成 p99,得到“p99 并发”。Little 定律约束的是平均量,分位数之间没有这种简单乘法关系。系统一直积压时,“到达率”也不等于“完成吞吐”,要先看队列是否稳定。
延迟指标至少同时看 p50、p95、p99、超时和错误。下面是人为构造的 1000 个样本:980 个为 10 ms,20 个为 1000 ms。采用 nearest-rank 定义,先排序,p 分位取第 ceil(n×p) 个数:
go
return ys[int(math.Ceil(float64(len(ys))*p))-1]text
synthetic latency mean=29.8 p50=10 p95=10 p99=1000 ms平均值只有 29.8 ms,仍有 2% 的请求等了 1 秒。这个输出是样本计算,不是压测。实例 p99 也不能直接取平均变成集群 p99;要聚合原始样本或兼容的直方图桶。串行步骤的单次延迟可以相加,但各步骤的 p99 不能保证相加后刚好等于整体 p99。
当请求并行等待很多下游时,慢尾会被放大。若每个下游独立地有 1% 概率超过某阈值,等待 100 个下游全部完成,至少一个超阈值的概率是 1−0.99^100,约 63.4%。独立性是强假设;共同依赖往往使尾部相关。原始论文 The Tail at Scale讨论了扇出系统的尾延迟问题。
常见操作的延迟量级,怎样查才不会背错
没有一张能跨年份、硬件、拓扑和负载保持正确的“内存多少纳秒、磁盘多少毫秒”万能表。更有用的是先识别一次操作跨过了哪些边界,再用当前环境校准量级。
| 操作 | 主要时间花在哪 | 必须交代的测量条件 |
|---|---|---|
| 算术、哈希、内存访问 | 指令、缓存命中、访存、分支 | 输入大小、冷热缓存、CPU、编译优化;极短操作用批量测量减轻时钟开销 |
| mutex / channel | 无竞争快速路径或调度等待 | goroutine 数、竞争比例、持锁时长;不能用无竞争结果估热点锁 |
| 文件 Write | 用户态到内核、页缓存 | 是否写到内存文件系统、是否要求持久化、每次大小 |
| 文件 Sync | 文件系统与存储栈的同步语义 | 文件系统、存储介质、缓存与虚拟化;调用成功不等于已做掉电实验 |
| 同机或同区域 RPC | 编解码、调度、协议、网络、排队 | 连接是否复用、是否 TLS、payload、并发、服务端工作量 |
| 跨区域请求 | 传播、路由和排队 | 两端位置、RTT 实测、丢包、握手次数 |
| 数据库点查、扫描、提交 | 缓存、索引、锁、I/O、日志提交 | 行数、访问分布、事务隔离、冷热数据、持久化与复制配置 |
有个很容易误导人的本机结果。示例启动本地 HTTP server,预热 10 次后顺序采 200 次;再对一个临时文件反复写 4 KiB 并 Sync。Go 1.27.2、Linux/amd64,2026-10-13 的一次实测如下:
text
HTTP loopback keepalive n=200 p50=50.47 p95=140.46 p99=350.90 us
4KiB WriteAt+Sync n=200 p50=0.31 p95=0.33 p99=0.34 us第二行不能叫“磁盘同步延迟”。紧接着检查 df -T /tmp,文件系统是 tmpfs。它把文件保存在虚拟内存中,卸载会丢失,并不是普通磁盘持久化路径,见 Linux tmpfs 文档。这正说明:代码里调用了 Sync,不足以证明测到了预想的硬件。Go 的 File.Sync 文档说明 API 意图,实际介质与故障保证仍需核对。
HTTP 结果也只代表空处理函数、复用连接、无 TLS、同机回环、顺序请求。200 个样本的 p99 只靠最慢几个样本决定,单次运行不足以做稳定容量规划。本文没有测 CPU cache、真实磁盘掉电、云数据库或跨区域网络,因此不为它们填写伪造数值。
队列、缓存和冗余:容量最容易漏掉的三处
队列只能吸收暂时的生产与消费差额。假设每秒到达 1200 个任务,只能处理 1000 个,持续 3 秒:
text
second=1 backlog=200
second=2 backlog=400
second=3 backlog=600
drain with 800 arrivals/s: 3 seconds最后一行假设后续每秒仍到达 800 个,因此只能用剩余 200/s 清理积压,600÷200=3 秒。若按 600÷1000 算成 0.6 秒,就是忘了新任务继续进来。真实系统还要检查最老消息年龄、每条处理成本、重试与队列上限;单看消息数不够。
缓存预算要看命中率下降时的回源。保持同样读峰值,只改命中率:
text
hit=99% DB reads=2.31/s
hit=90% DB reads=23.15/s
hit=0% DB reads=231.48/s从 99% 掉到 90%,命中率只差 9 个百分点,回源却变成十倍。这里假设每次未命中只发一个数据库查询;实际批量查询和热点合并会改变倍数。冷启动和缓存故障应作为独立容量场景,而不是被日均命中率掩盖。
冗余也有算术约束。假设三台相同实例,每台在目标延迟下可稳定处理 1000 QPS,流量 1400 QPS,平时每台平均利用 46.7%。失去一台后,剩下两台各承担 70%。如果平时三台都用到 70%,失去一台就需要各跑 105%。这里 1000 QPS 必须来自目标负载下的验证,不能用某个框架的空 handler 基准替代。
把估算变成测量计划
先画一张参数表,标注“业务已确认”“日志统计”“假设”“待测”。然后用小、中、大三组场景改峰值、行宽、扇出、保留期和缓存命中率,观察最先变坏的资源。不要给所有输入统一乘一个“保险系数”,否则无法解释哪里不确定。
压测时需要保持输入模型真实:读写比例、热点、对象大小、索引规模、冷启动、超时与重试都在内。只等上一个请求完成才发下一个的闭环压测,会在系统变慢时自动降低发送速率,可能漏看排队风险;固定到达率压测则要确认发压端本身跟得上,并统计失败、超时和未发送请求。所有压测都应在授权的隔离环境内、设流量与时长上限。
最终交付的容量判断应像这样:在某组数据和负载下,某配置在指定 p99 和错误率目标内承载多少;失去一个实例后是否仍满足目标;增长到什么程度必须扩容;还剩哪些假设未验证。估算决定该测什么,实测决定现在能承诺什么。
依据与验证边界
- Little, 1961, A Proof for the Queuing Formula: L = λW:均值之间的排队关系。
- Google SRE, Service Level Objectives:窗口、分布和指标边界。
- Dean 与 Barroso, The Tail at Scale:大规模扇出中的尾延迟。
- Linux tmpfs、Go 1.27.2 os:本地文件实验的解释边界。
- 2026-10-13,Go 1.27.2、Linux/amd64。计算器、nearest-rank 测试、单位/N−1 算术检查、普通测试、race 和 vet 均通过。延迟采样程序实际运行一次;这不是负载测试,也没有测出任何生产集群容量。
完整计算器、测试和采样程序
目录中放 go.mod、main.go、main_test.go,再建立 cmd/latency/main.go。运行后 HTTP server 会关闭,临时文件会删除;文件只写 4 KiB,同机 HTTP 请求总计 210 次。
sh
go run .
go run ./cmd/latency
df -T /tmp
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...go.mod
go.mod
module example.com/capacity
go 1.27.0main.go
代码块 · 52 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"math"
"slices"
)
func percentile(xs []float64, p float64) float64 {
if len(xs) == 0 || p <= 0 || p > 1 {
panic("invalid percentile input")
}
ys := slices.Clone(xs)
slices.Sort(ys)
return ys[int(math.Ceil(float64(len(ys))*p))-1]
}
func main() {
const users, reads, writes, peak = 100000., 20., 2., 10.
readQPS := users * reads / 86400
writeQPS := users * writes / 86400
fmt.Printf("read avg=%.2f peak=%.2f write avg=%.2f peak=%.2f\n", readQPS, readQPS*peak, writeQPS, writeQPS*peak)
for _, days := range []float64{365, 730} {
logical := users * writes * days * 1000
provisioned := logical * 1.5 * 3 / .7
fmt.Printf("days=%.0f logical=%.2f GB provisioned=%.2f GB\n", days, logical/1e9, provisioned/1e9)
}
fmt.Printf("egress=%.2f Mbit/s\n", readQPS*peak*4500*8/1e6)
fmt.Printf("mean inflight=%.2f\n", (readQPS+writeQPS)*peak*.08)
xs := make([]float64, 1000)
for i := range xs {
xs[i] = 10
if i >= 980 {
xs[i] = 1000
}
}
total := 0.
for _, x := range xs {
total += x
}
fmt.Printf("synthetic latency mean=%.1f p50=%.0f p95=%.0f p99=%.0f ms\n", total/float64(len(xs)), percentile(xs, .5), percentile(xs, .95), percentile(xs, .99))
for _, hit := range []float64{.99, .90, 0} {
fmt.Printf("hit=%.0f%% DB reads=%.2f/s\n", hit*100, readQPS*peak*(1-hit))
}
// Deterministic queue model: arrivals then service within each 1 s bucket.
backlog := 0
for sec := 1; sec <= 3; sec++ {
backlog += 1200
backlog -= 1000
fmt.Printf("second=%d backlog=%d\n", sec, backlog)
}
fmt.Printf("drain with 800 arrivals/s: %.0f seconds\n", float64(backlog)/(1000-800))
}main_test.go
go
package main
import "testing"
func TestPercentile(t *testing.T) {
xs := []float64{3, 1, 2, 100}
if percentile(xs, .5) != 2 || percentile(xs, 1) != 100 || xs[0] != 3 {
t.Fatal(xs)
}
}
func TestUnits(t *testing.T) {
if 1000*8 != 8000 {
t.Fatal("byte to bit")
}
// Losing one node leaves N-1 nodes; aggregate utilization must fit them.
n, q, c := 3., 1400., 1000.
if q/((n-1)*c) != .7 {
t.Fatal("N-1 calculation")
}
}cmd/latency/main.go
代码块 · 64 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"os"
"slices"
"time"
)
func measure(name string, fn func() error) {
const n = 200
for range 10 {
if err := fn(); err != nil {
panic(err)
}
}
ds := make([]float64, n)
for i := range ds {
start := time.Now()
if err := fn(); err != nil {
panic(err)
}
ds[i] = float64(time.Since(start).Nanoseconds()) / 1000
}
slices.Sort(ds)
q := func(p float64) float64 { return ds[int(math.Ceil(float64(n)*p))-1] }
fmt.Printf("%s n=%d p50=%.2f p95=%.2f p99=%.2f us\n", name, n, q(.5), q(.95), q(.99))
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "ok") }))
defer srv.Close()
c := srv.Client()
c.Timeout = time.Second
defer c.CloseIdleConnections()
measure("HTTP loopback keepalive", func() error {
resp, err := c.Get(srv.URL)
if err != nil {
return err
}
_, err = io.Copy(io.Discard, resp.Body)
closeErr := resp.Body.Close()
if err != nil {
return err
}
return closeErr
})
f, err := os.CreateTemp("", "design-latency-*")
if err != nil {
panic(err)
}
defer os.Remove(f.Name())
defer f.Close()
data := make([]byte, 4096)
measure("4KiB WriteAt+Sync", func() error {
if _, err := f.WriteAt(data, 0); err != nil {
return err
}
return f.Sync()
})
}