主题
容量与弹性:先量出可用容量,再讨论扩多少实例
“这台机器能跑一万 QPS”没有说明请求是什么、错误算不算、尾延迟多大,也没有说明压测端是否真的发出了计划流量。容量规划需要的是更完整的一句话:在指定请求混合、数据规模、依赖和故障条件下,系统能持续完成多少工作,并且仍满足响应质量要求。
这篇文章说明如何选择压测模型、识别协调遗漏、用 Little 定律检查数据,再把结果转成副本数和自动伸缩策略。文末用离散事件模型演示慢请求如何改变测量;它没有模拟网络,也不是某台机器的性能测试。
四类测试分别回答什么问题
| 测试 | 想知道什么 | 负载安排 | 最容易漏掉的东西 |
|---|---|---|---|
| 基准测试 benchmark | 一段实现或稳定场景改动前后有什么差异 | 可重复、尽量控制变量 | 微基准没有覆盖网络、竞争和真实数据分布 |
| 负载测试 load | 预期正常和峰值流量下是否达标 | 按生产请求混合逐级加载并保持 | 只看平均值,没看错误、尾延迟和后台积压 |
| 压力测试 stress | 超过容量后如何退化,恢复是否顺利 | 超过预期上限,再减回正常负载 | 只记崩溃点,没验证卸载和恢复 |
| 浸泡测试 soak | 长时间运行后是否积累问题 | 代表性负载持续足够长 | 内存、连接、磁盘、配额或任务队列缓慢增长 |
这些名称不是严格互斥的标准。一次测试可以在稳定负载下观察数小时,再短暂超载;关键是事先写清假设、过程和判定。k6 官方测试分类也区分正常负载、压力、峰值和持续时间,不应把所有负载生成都叫“基准”。[1]
开始前固定数据集、读写比例、热键分布、请求大小、超时和重试策略。预热缓存要记录;如果生产存在冷启动,也要单独测冷态。写操作使用隔离测试数据,设置最大速率、时长、并发和停止阈值,不把公网服务或真实用户业务当作可随意施压的靶场。
压测报告至少保留计划到达数、实际发出数、完成数、成功数、错误分类、超时和未发出请求,配合 p50/p95/p99、资源使用、队列等待及最老任务年龄。只给“成功请求的 p99”可能正好丢掉超时这一批最慢请求;必须注明样本口径。
开环与闭环,差别在下一次请求由谁决定
闭环模型维护固定数量的客户端。一个客户端等上一轮完成,可能再等思考时间,然后发下一轮;服务变慢,客户端自然发得更少。它适合模拟一组会等待的用户,却未必能模拟“外界每秒固定到来多少新订单”。
开环模型按外部计划安排到达,前面的请求慢了,新的请求仍会来。k6 的 arrival-rate executor 属于这种思路,而 constant-vus 的迭代由前一轮完成情况约束。工具名字本身不决定模型,具体 executor 和脚本行为才决定。[2]
正在绘制图示…
查看图源代码
flowchart LR
A[闭环客户端] --> B[发请求]
B --> C[等待完成]
C --> D[可选思考时间]
D --> B
E[开环时间计划] --> F[预定到达时刻]
F --> G[发请求并独立等待结果]
F --> H[没有生成资源则记录未发出]
协调遗漏为什么会让慢服务看起来没那么慢
设一个单执行者服务:第一个请求耗时 100 个时间单位,后面九个都耗时 1。开环每 10 个单位到一个请求;闭环只有一个客户端,收到结果才立即发下一个。文末模型实际输出:
text
open: [100 91 82 73 64 55 46 37 28 19] mean=59.5 p50=55
closed: [100 1 1 1 1 1 1 1 1 1] mean=10.9 p50=1开环第二个请求在 t=10 到来,要等第一个在 t=100 完成,再用 1 个单位,所以响应时间是 91。闭环第二个请求直到 t=100 才发,测到的只有 1。闭环没有记录那些在原本外部到达模型中本来应当等待的请求;若用它代表持续外部流量,就低估了拥塞。这是模型上的协调遗漏,不是某个 HTTP 客户端凭空丢了计时器。[2]
代码中的关系只有两行:
go
start := max(end, arrival)
end = start + cost
latency := end - arrival每次都从到达时刻计算延迟,排队就不会凭空消失。模型用 nearest-rank 计算分位数,因此十个值的 p50 取排序后第五个值;这不是所有统计软件唯一采用的插值定义。
开环工具也会受 CPU、网络、文件描述符和 worker/VU 上限约束。计划 1000 次却只发出 600 次,不表示服务轻松通过 1000/s。k6 会报告 dropped_iterations 等无法开始的迭代;应把生成端资源不足和服务端失败分开,但都不能从报告中抹掉。[3]
Little 定律是口径检查器,不能把 p99 塞进去算容量
Little 定律常写作 L = λW:
- L:统计边界内平均有多少项工作,包括约定纳入的排队和执行
- λ:进入这一边界的长期平均有效到达速率
- W:这些工作在这一边界内停留的平均时长
运用时需要稳定的长期平均、有限的量,并处理好观察窗口起止仍在系统内的工作;不能让队列无限增长却把短窗口的几个平均值当成稳态。定律并不要求到达必须服从泊松分布或服务时间服从指数分布,它比某个具体排队模型更一般。[4]
go
fmt.Printf("Little's Law example: L=%.1f\n", 1000*.010)实际输出:
text
Little's Law example: L=10.0假设系统稳定,平均到达 1000 次/秒,平均在系统停留 10 毫秒,平均在途工作就是 10。这里不是“需要十个 goroutine 就能撑住”;在途里可能含等数据库、等队列和 CPU 执行,而且到达与服务时间有波动。也不能拿 p99=200 毫秒代替平均 W,再宣称平均并发是 200。
若边界只统计已接受请求,就用被接受的速率;若把入口拒绝也算入,拒绝的停留时间也应进入相同口径。拿入口 1000/s 乘成功请求平均耗时,遇到大量拒绝或超时后很可能对不上。先修统计边界,再判断是否需要扩容。
把压测结果变成有故障余量的容量计划
先从测试中找“在目标错误率和尾延迟内,可以持续承受的单实例速率”,而不是最后一次没有崩溃的峰值。该速率已隐含指定资源、数据集、依赖容量及安全余量;换请求混合或版本后要重新校准。[5]
下面是明确假设下的规划算术:峰值 2400/s,每实例安全速率 600/s,要容忍任意一个实例失效,至少需要:
text
ceil(2400 / 600) + 1 = 5 个实例文末计算器实际输出:
text
replicas for one-instance loss: 5 <nil>这假设请求能均匀分配、剩余实例具有相同性能、共享依赖仍足够。它也只容忍一个实例,不等于容忍一个可用区。若分三个区部署,要在失去任一区后保留四个实例的能力,可以每区两个,共六个;还要验证跨区流量切换、数据库及配额是否支持。这里是理想化规划示例,不是云平台可用性保证。
| 预算维度 | 需要列出的量 | 为什么影响副本数 |
|---|---|---|
| CPU | 各请求类型平均 CPU 成本、峰值混合 | 请求成本变化会让相同 QPS 消耗不同 |
| 内存 | 基础占用、每在途请求、缓存、批次缓冲 | 排队和突发会抬高瞬时占用 |
| 数据库 | 活跃查询、连接上限、锁与 I/O | 扩应用实例可能反而压垮共享数据库 |
| 网络与外部 API | 带宽、连接、第三方配额 | 本地扩容不能增加对方额度 |
| 故障与发布 | 故障域、滚动发布同时下线数、冷启动 | 可用副本数低于配置数的时间必须被覆盖 |
如果一个请求会扇出十次依赖调用,容量规划还要把扇出和重试算进依赖流量。峰值均值、热点租户、热分片、队列消费速度都可能打破“线性增加实例”的假设。水平扩展的上限通常先被共享状态或共享依赖暴露出来。[5]
自动伸缩是一条有延迟的控制回路
正在绘制图示…
查看图源代码
flowchart LR
A[流量与资源消耗] --> B[采样与指标聚合]
B --> C[伸缩决策与稳定窗口]
C --> D[调度和创建实例]
D --> E[拉镜像 启动 预热]
E --> F[通过 readiness 接流量]
F --> A
从指标反映压力到新实例能接流量,中间每一步都花时间。假设在这段延迟内到达率比完成能力每秒多 100 次,持续 30 秒,积压就可能增加约 3000 次;这只是差额积分的简化计算,已经足以说明为什么“已经配置 HPA”不能代替预留容量和准入保护。
Kubernetes HPA 的基本比例关系是 期望副本数 = ceil(当前副本数 × 当前指标 / 目标指标)。实际控制器还处理容差、缺失指标、未就绪 Pod、稳定窗口和伸缩策略,因此不是每次都机械执行这个公式。CPU utilization 的百分比相对于资源 request,而非整台机器 CPU;配置和指标来源必须完整。[6]
例如四个实例平均 CPU utilization 为 90%,目标为 60%,简单比例给出六个实例。只有在该 CPU 指标确实跟业务瓶颈相关时,这个建议才有意义。如果请求都在等数据库,CPU 很低,CPU HPA 可能没有动作;队列最老任务年龄、在途数等指标可能更接近问题,但又需要稳定的测量和明确的伸缩语义。
伸缩策略需要上下限、扩容步幅和缩容稳定窗口。缩容过快会导致指标反弹、反复扩缩和缓存反复失效;缩容前还要停止接新任务、排空已有请求,再移除实例。节点不足、镜像拉取失败、配额不足、指标不可用时,副本目标和真实就绪副本数会分离,告警要监控这个差距。
文末 hpa.yaml 展示 autoscaling/v2 的 CPU 目标与缩容稳定窗口。它需要一个现有的 api Deployment、各容器 CPU requests,以及可用的资源指标服务。当前没有 Kubernetes 集群,这份配置只做阅读示例,未应用、未验证控制器行为。
无状态化的含义,是状态有明确归属
无状态应用实例不把正确性所依赖的状态只留在本机:登录会话、订单进度、持久任务和幂等记录应放在适当的共享或持久存储中,任何合适实例都能继续处理请求。它仍然可以有临时缓存和连接池,但丢掉这些内容只影响性能,不应丢业务事实。
把状态搬出去会新增网络依赖和一致性成本。会话存到共享数据库,不代表数据库也自动具有弹性。文件上传中的本地临时文件、WebSocket 连接、长事务和正在执行的任务,也使迁移不再只是“关掉一个 Pod”。应为每类状态定义迁移、恢复或明确失败语义。
| 状态 | 扩缩时的处理思路 | 必须验证 |
|---|---|---|
| 临时本地缓存 | 允许丢失,控制预热速率 | 冷态回源不会压垮数据库 |
| 用户会话 | 共享存储或可验证令牌,按安全要求设计 | 失效、撤销和版本兼容 |
| 后台任务 | 持久队列与执行租约,幂等消费 | worker 消失后的接管与重复 |
| 长连接 | 连接排空与客户端重连 | 重连风暴、消息连续性和超时 |
| 分片状态 | 再平衡、迁移和路由更新 | 迁移期间一致性与热点转移 |
StatefulSet 提供稳定身份、存储相关管理等能力;它不会替数据库实现复制协议或让分片迁移没有成本。容量与弹性最终要围绕业务状态设计,不能仅靠控制器名称判断。[7]
一份能够指导行动的压测结论
建议把结论写成:“版本 A,在数据集 D、请求混合 M 和依赖配置 C 下,持续接收 X/s,成功率与 p99 达标;超过 Y/s 开始拒绝;撤回到 X/s 后 Z 时间恢复;少一个故障域是否达标另有结果。”没有测过的项就留下未知,而不从单机跑分推出多区可靠性。
本次没有对任何服务施压。Go 1.27.2 / Linux amd64 运行的是十个请求的确定性队列模型、分位数算法和理想化副本计算;go test、go test -race、go vet 通过。输入负服务时间、空分位数、NaN 分位数和零容量会被拒绝;计算器只是示例规模,未设计为处理无限大数值的生产规划服务。
依据
- k6:Load test types:不同目标的测试类型,2026-10-13 核对。
- k6:Open and closed models:调度模型与协调遗漏。
- k6:Dropped iterations:无法启动迭代的度量。
- John D. C. Little:Little's Law as Viewed on Its 50th Anniversary,Operations Research 2011:定律、边界与适用条件。
- Google SRE Workbook:Managing Load:容量、负载与过载保护;Handling Overload:请求成本不一。
- Kubernetes:Horizontal Pod Autoscaling:算法细节、资源 requests、稳定窗口和 behavior。本文配置使用 autoscaling/v2,未运行集群验证。
- Kubernetes:StatefulSets:稳定身份与存储管理,非应用复制协议。
- Go 1.27.2 sort、math:模型使用的排序与取整;队列规则、计算输出均来自本文完整程序。
完整模型、测试与配置
前三个文件可在空目录用 Go 1.27.2 运行;HPA 是单独的未运行配置,不能拿它替代 Deployment 和集群前置条件。
sh
go run .
go test -v ./...
go test -race ./...
go vet ./...go.mod
go
module example.com/capacity
go 1.27.0main.go
代码块 · 67 行 · 点击收起 / 展开
go
package main
import (
"errors"
"fmt"
"math"
"sort"
)
// Latencies is a deterministic single-server FIFO model, in arbitrary time units.
// open arrivals occur at 0, gap, 2*gap...; closed arrivals wait for completion.
func Latencies(service []int, gap int, open bool) ([]int, error) {
if gap < 0 {
return nil, errors.New("negative gap")
}
end := 0
out := make([]int, 0, len(service))
for i, cost := range service {
if cost < 0 {
return nil, errors.New("negative service")
}
arrival := end
if open {
arrival = i * gap
}
start := max(end, arrival)
end = start + cost
out = append(out, end-arrival)
}
return out, nil
}
func Mean(xs []int) float64 {
var total int
for _, v := range xs {
total += v
}
if len(xs) == 0 {
return math.NaN()
}
return float64(total) / float64(len(xs))
}
func Percentile(xs []int, p float64) (int, error) {
if len(xs) == 0 || p <= 0 || p > 1 || math.IsNaN(p) {
return 0, errors.New("invalid percentile")
}
copy := append([]int(nil), xs...)
sort.Ints(copy)
return copy[int(math.Ceil(p*float64(len(copy))))-1], nil
}
func Replicas(peak, safeRate float64, lost int) (int, error) {
if peak <= 0 || safeRate <= 0 || lost < 0 || math.IsNaN(peak) || math.IsNaN(safeRate) || math.IsInf(peak, 0) || math.IsInf(safeRate, 0) {
return 0, errors.New("invalid capacity")
}
return int(math.Ceil(peak/safeRate)) + lost, nil
}
func main() {
service := []int{100, 1, 1, 1, 1, 1, 1, 1, 1, 1}
open, _ := Latencies(service, 10, true)
closed, _ := Latencies(service, 10, false)
op, _ := Percentile(open, .5)
cp, _ := Percentile(closed, .5)
fmt.Printf("open: %v mean=%.1f p50=%d\n", open, Mean(open), op)
fmt.Printf("closed: %v mean=%.1f p50=%d\n", closed, Mean(closed), cp)
fmt.Printf("Little's Law example: L=%.1f\n", 1000*.010)
n, err := Replicas(2400, 600, 1)
fmt.Println("replicas for one-instance loss:", n, err)
}main_test.go
代码块 · 42 行 · 点击收起 / 展开
go
package main
import (
"math"
"testing"
)
func TestOmittedQueueing(t *testing.T) {
cost := []int{100, 1, 1}
a, _ := Latencies(cost, 10, true)
b, _ := Latencies(cost, 10, false)
if a[1] != 91 || b[1] != 1 {
t.Fatal(a, b)
}
}
func TestInvalidInputs(t *testing.T) {
if _, e := Latencies([]int{-1}, 10, true); e == nil {
t.Fatal("negative service")
}
if _, e := Percentile(nil, .99); e == nil {
t.Fatal("empty percentile")
}
if _, e := Percentile([]int{1}, math.NaN()); e == nil {
t.Fatal("NaN")
}
if _, e := Replicas(1, 0, 0); e == nil {
t.Fatal("zero capacity")
}
}
func TestPercentileDoesNotMutate(t *testing.T) {
a := []int{9, 1, 2}
p, err := Percentile(a, .5)
if err != nil || p != 2 || a[0] != 9 {
t.Fatal(p, a, err)
}
}
func TestFailoverCapacity(t *testing.T) {
n, err := Replicas(2401, 600, 1)
if err != nil || n != 6 {
t.Fatal(n, err)
}
}hpa.yaml
yaml
# Illustrative only; not applied to a cluster.
# Requires Deployment/api, CPU requests on its containers, and resource metrics.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api
minReplicas: 3
maxReplicas: 12
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 20
periodSeconds: 60