一场“通过”了的压测
《泡泡工坊》上线前两周,团队做了一次 2 万机器人的压测。报告很漂亮:成功率 99.97%,开局 p95 320ms,CPU 峰值 61%。评审会上没有人反对上线。
上线首日,买量导入 3 万新玩家。10 点 12 分,匹配服开始排队;10 点 18 分,开局失败率升到 8%;10 点 25 分,值班同学把匹配并发上限翻倍,才勉强稳住。
事后复盘,问题出在机器人的行为模型:每个机器人打完一局后固定休息 3 秒再点下一局,而真实玩家在大厅里平均 0.8 秒就会点“开始”。机器人用“思考时间”把自己关在了一个闭环里,服务器处理得越快,它们发得越快,最终稳定在一个服务器能承受的速率上。它们测的是“服务器能同时服务多少机器人”,不是“玩家到达得有多快”。
这就是闭环压测的经典陷阱:它会把过载隐藏成排队,再把排队隐藏成“稳定”。
三种行为模型:真实度与成本
| 模型 | 做法 | 真实度 | 成本 | 适合回答 |
|---|---|---|---|---|
| 脚本化 | 固定脚本按固定节奏发请求 | 低 | 最低 | 接口能否跑通、容量下限 |
| 录制回放 | 录制真实会话,按时间轴重放 | 中 | 中 | 真实操作序列下的资源消耗 |
| 模型驱动 | 用状态机/行为模型生成决策 | 高 | 高 | 新玩法、新活动的容量 |
脚本化机器人的价值是“能跑”。它适合冒烟、回归、接口契约验证,不适合回答“能承载多少人”,因为它的节奏是写死的,不会对服务器延迟做出反应。
录制回放的价值是“像真的”。它保留了真实玩家的操作顺序、间隔和异常路径。它的边界是:录制的流量只覆盖录过的场景,新玩法没有历史流量可放。
模型驱动的价值是“能外推”。它把玩家行为抽象成状态机和到达过程,可以生成没上线过的活动流量。它的代价是要维护行为模型,模型错了,压测结果会错得很自信。
选型建议不是三选一,而是分层:
- 日常回归用脚本化,成本低、反馈快;
- 版本验收用录制回放,覆盖真实路径;
- 大版本与新活动上线前用模型驱动,专门压新链路。
压测隔离:别让机器人污染生产
压测流量最危险的地方不是量大,而是它看起来像真玩家。排行榜、成就、经济日志、风控规则都会被机器人改写。隔离至少有三层:
| 隔离方式 | 隔离对象 | 优点 | 边界 |
|---|---|---|---|
| 影子集群 | 整套服务与数据 | 最干净,可压全链路 | 成本高,配置容易漂移 |
| 只读副本 | 读路径 | 成本低,接近真实数据 | 写路径压不到 |
| 压测标记账号 | 账号与业务数据 | 可与其他隔离方式叠加 | 标记必须贯穿所有写入点 |
影子集群的关键不是“另起一套”,而是“配置同源”。如果影子集群的匹配参数、房间池大小、缓存策略和生产不一致,压测数字就没有迁移价值。
只读副本适合压查询和推荐类链路,但它会漏掉写放大。SLG 的行军结算、MMO 的背包写入,都必须在可写的隔离环境里压。
压测标记账号是最容易被低估的一层。标记必须从登录令牌一路传到经济、排行、风控、日志,任何一处漏传,机器人就会进入真实排行榜。实践里更稳的做法是给压测账号一个独立的分区键,让所有写入天然落到隔离区,而不是靠每个写入点记得判断标记。
流量回放:时间、并发与依赖
录制回放不是“把包再发一遍”。至少有三件事要处理:
时间压缩
真实流量可能录了 6 小时,压测窗口只有 40 分钟。直接压缩时间轴会把原本分散的请求挤到一起,制造出虚假的洪峰。正确做法是保留关键时间结构:登录潮、整点活动、休息低谷的相对间隔,只压缩平峰段。
并发放大
1 万条录制会话要放大到 10 万个虚拟玩家,不能简单复制 10 份。复制会让同一时刻的请求完全同步,形成“方阵效应”,测出来的峰值比真实世界更尖。放大时应该给每个副本加随机相位偏移,并保持到达过程的总速率符合目标曲线。
外部依赖打桩
支付、推送、第三方登录、广告归因这些依赖,在压测环境里通常不可用或不该被压。打桩要保证三件事:延迟分布接近真实、失败率可配置、幂等语义一致。只返回 200 OK 的桩会把重试逻辑和超时路径全部藏掉。
怎么判断“测出来的不是排队”
闭环压测的机器人会等服务器返回再发下一个请求。服务器越慢,机器人发得越慢,系统看起来越“稳定”。要识别这种情况,需要把“到达”和“完成”分开记录:
- 开放环到达:按计划速率发请求,不等上一个完成;
- 闭环到达:收到响应后再按思考时间发下一个;
- 排队占比:端到端时间里,等待服务槽位的比例。
如果排队占比很低,说明测到的是服务能力;如果排队占比很高,说明测到的是队列长度,数字再好看也不能当容量。
还有一个更隐蔽的问题叫“协调遗漏”:压测工具在事件循环被阻塞时,会漏记本应发生的请求,导致延迟分位数偏低。解决办法是用固定频率的开放环调度器,而不是“完成一个再发一个”。
备选方案与取舍
| 方案 | 到达模型 | 能回答的问题 | 风险 |
|---|---|---|---|
| A. 闭环机器人 | 收到响应后再发 | 单用户视角的体验 | 过载被排队掩盖 |
| B. 开放环压测 | 按固定速率发 | 入口与队列的真实压力 | 可能压垮无关依赖 |
| C. 混合回放 | 录制路径 + 开放环节奏 | 真实路径下的容量 | 实现与治理成本最高 |
选 C,但分阶段落地:
第一阶段只做开放环的入口压测,把登录、匹配、开局三个入口的 QPS 上限测出来。这一阶段不需要复杂行为模型,脚本化请求就够。
第二阶段引入录制回放,覆盖房间内操作和结算链路。这一阶段要重点看排队占比,而不是只看成功率。
第三阶段才做模型驱动,用于新玩法和新活动。模型必须用线上数据校准,否则只能当压力源,不能当容量结论。
数据模型与接口契约
压测计划用一条分段线性曲线表达:
Point{At, Users}:某个时刻计划有多少虚拟玩家;Curve.UsersAt(t):线性插值,曲线外做钳制;Plan:曲线、思考时间、最大并发、总时长、调度步长;Report:发出、成功、失败、端到端延迟、服务延迟、排队延迟。
契约有三个硬约束:
第一,Think 必须大于零。思考时间为零的闭环机器人等价于无限速率,会把压测变成自欺欺人。
第二,Duration 不能短于曲线长度。截断曲线会让压测在爬坡中途结束,得到的“峰值”不是计划峰值。
第三,报告必须同时保留服务延迟和排队延迟。只有端到端延迟,无法区分“服务器慢”和“排队久”。
可运行片段
下面的模拟器把到达速率、服务槽位和排队时间分开建模。它不依赖真实网络,适合在评审前快速验证一条压测计划会不会退化成排队测试。
// file: internal/botloadgen/simulate.go
// tier: L1
// verify: go test ./internal/botloadgen/
package botloadgen
import (
"container/heap"
"errors"
"math"
"sort"
"time"
)
var ErrServer = errors.New("botloadgen: server function is nil")
// ServerFunc returns the service time and whether the action succeeded.
type ServerFunc func(seq int) (time.Duration, bool)
// Report separates service time from queue time on purpose: a test that only
// reports end-to-end latency cannot tell a slow server from a saturated queue.
type Report struct {
Issued int
Succeeded int
Failed int
Latencies []time.Duration
Service []time.Duration
Queue []time.Duration
}
func (r *Report) finish() {
sort.Slice(r.Latencies, func(i, j int) bool { return r.Latencies[i] < r.Latencies[j] })
sort.Slice(r.Service, func(i, j int) bool { return r.Service[i] < r.Service[j] })
sort.Slice(r.Queue, func(i, j int) bool { return r.Queue[i] < r.Queue[j] })
}
func (r Report) SuccessRate() float64 {
if r.Issued == 0 {
return 0
}
return float64(r.Succeeded) / float64(r.Issued)
}
// Quantile uses the nearest-rank definition, which is what an SLO usually
// quotes. p is clamped into (0, 1].
func (r Report) Quantile(p float64) time.Duration {
n := len(r.Latencies)
if n == 0 {
return 0
}
if p <= 0 {
return r.Latencies[0]
}
if p > 1 {
p = 1
}
idx := int(math.Ceil(p*float64(n))) - 1
if idx < 0 {
idx = 0
}
if idx >= n {
idx = n - 1
}
return r.Latencies[idx]
}
// QueueShare is the fraction of end-to-end time the cluster made players wait
// instead of work. Above roughly 0.2, the test is measuring a queue, not the
// server's capacity.
func (r Report) QueueShare() float64 {
var total, queued time.Duration
for i := range r.Latencies {
total += r.Latencies[i]
queued += r.Queue[i]
}
if total == 0 {
return 0
}
return float64(queued) / float64(total)
}
type slots []time.Duration
func (s slots) Len() int { return len(s) }
func (s slots) Less(i, j int) bool { return s[i] < s[j] }
func (s slots) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s *slots) Push(x any) { *s = append(*s, x.(time.Duration)) }
func (s *slots) Pop() any { old := *s; v := old[len(old)-1]; *s = old[:len(old)-1]; return v }
// Simulate runs a deterministic closed-loop bot schedule against a pool of
// MaxInflight server slots. Each arrival takes the earliest free slot, so the
// reported queue is real concurrency pressure rather than a scheduling artifact.
func Simulate(p Plan, srv ServerFunc) (Report, error) {
if err := p.Validate(); err != nil {
return Report{}, err
}
if srv == nil {
return Report{}, ErrServer
}
free := make(slots, p.MaxInflight)
heap.Init(&free)
var r Report
seq := 0
carry := 0.0
for t := time.Duration(0); t < p.Duration; t += p.Tick {
rate, err := ArrivalRate(p.Curve.UsersAt(t), p.Think)
if err != nil {
return Report{}, err
}
carry += rate * p.Tick.Seconds()
n := int(carry)
carry -= float64(n)
for i := 0; i < n; i++ {
at := t
start := at
if free[0] > start {
start = free[0]
}
queue := start - at
svc, ok := srv(seq)
seq++
if svc < 0 {
svc = 0
}
free[0] = start + svc
heap.Fix(&free, 0)
r.Issued++
if ok {
r.Succeeded++
} else {
r.Failed++
}
r.Latencies = append(r.Latencies, queue+svc)
r.Service = append(r.Service, svc)
r.Queue = append(r.Queue, queue)
}
}
r.finish()
return r, nil
}
配套的 Curve、BuildCurve 与 ArrivalRate 在同包的 curve.go 里:曲线负责“计划发多快”,ArrivalRate 负责把“多少机器人 × 多久想一次”换算成每秒动作数。
读代码:队列是怎么被单独量出来的
Simulate 维护一个大小为 MaxInflight 的最小堆,堆里是每个服务槽位的空闲时间。每个动作到达时,取最早空闲的槽位:
- 如果槽位已经空闲,排队时间为零;
- 如果槽位还在忙,动作必须等到它空闲,等待时间就是排队时间。
这个模型刻意把“服务时间”交给 ServerFunc,把“并发上限”交给 Plan。这样同一组服务时间可以在不同并发上限下重跑,直接看出容量拐点。
QueueShare 是判断“测的是不是排队”的核心指标。它等于所有动作排队时间之和除以端到端时间之和。示例值:排队占比 0.05 时,端到端延迟基本反映服务能力;排队占比 0.6 时,端到端延迟主要由队列决定,此时再加机器人只会让队列更长。
Quantile 使用最近秩定义,和多数 SLO 报表一致。注意它作用于已经排好序的端到端延迟,不区分成功与失败;失败请求的延迟要单独看,否则快速失败会把 p95 拉得很好看。
失败模式与兜底
机器人同步。 所有机器人同时启动、同时点击,形成真实世界不存在的尖峰。兜底是给每个机器人加随机相位,并让到达过程服从目标曲线。
思考时间过长。 为了让报告好看,把思考时间调到 3 秒,等于主动降低到达速率。兜底是把思考时间写进压测计划并和线上分布对比,偏离超过阈值就判为无效压测。
依赖桩太快。 支付桩固定 1ms 返回,真实支付 p99 是 800ms,压测会低估结算链路。兜底是桩必须带延迟分布和失败率。
压测数据泄漏。 机器人写进真实排行榜、真实经济。兜底是分区键隔离,而不是依赖每个写入点判断标记。
只看成功率。 排队 40 秒但最终成功,成功率仍是 100%。兜底是把排队占比和 p99 一起列为验收门槛。
用平均值验收。 平均延迟 200ms、p99 3 秒的压测,体验已经坏了。兜底是所有验收指标用分位。
常见错误与边界
第一,把压测通过当成容量结论。压测只证明“在这个行为模型和这个隔离环境里能跑”,不证明“真实玩家到达时能跑”。
第二,闭环压测不加开放环对照。没有开放环,就无法判断系统是在服务还是在排队。
第三,忽略协调遗漏。工具越忙,漏记越多,延迟越好看。
第四,录制回放不做时间结构保留。简单压缩会制造假洪峰。
第五,并发放大用整数复制。同步副本会让峰值变尖,而不是变大。
第六,压测环境配置漂移。影子集群的池大小、缓存、超时和生产不一致,结论不可迁移。
第七,机器人行为不覆盖失败路径。只压成功路径,会把重试放大和雪崩藏起来。
小结
机器人压测的核心不是“造多少机器人”,而是“用什么到达模型、在什么隔离环境、测的是服务还是队列”。脚本化、录制回放、模型驱动各有真实度和成本,应该按验证阶段分层使用。
这一节的 botloadgen 把计划曲线、开放环到达、服务槽位和排队占比拆开,让“测出来的不是排队”成为一个可以计算的判据。下一节把这些测量结果收进容量模型,讨论余量到底该留多少、按什么故障留。
阅读导航:上一节:1.1 承载指标:不要只看在线人数 · 下一节:1.3 容量模型与余量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。