Skip to content

容量与弹性:先量出可用容量,再讨论扩多少实例 ​

“这台机器能跑一万 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[没有生成资源则记录未发出]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 分位数和零容量会被拒绝;计算器只是示例规模,未设计为处理无限大数值的生产规划服务。

依据 ​

  1. k6:Load test types:不同目标的测试类型,2026-10-13 核对。
  2. k6:Open and closed models:调度模型与协调遗漏。
  3. k6:Dropped iterations:无法启动迭代的度量。
  4. John D. C. Little:Little's Law as Viewed on Its 50th Anniversary,Operations Research 2011:定律、边界与适用条件。
  5. Google SRE Workbook:Managing Load:容量、负载与过载保护;Handling Overload:请求成本不一。
  6. Kubernetes:Horizontal Pod Autoscaling:算法细节、资源 requests、稳定窗口和 behavior。本文配置使用 autoscaling/v2,未运行集群验证。
  7. Kubernetes:StatefulSets:稳定身份与存储管理,非应用复制协议。
  8. 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.0

main.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

保留各书来源与许可 · 示例运行条件以正文为准