《游戏服务端架构·规模化、全球化与成本》1.2 机器人压测与流量回放

机器人压测最常见的结果是“通过了”,最常见的原因是机器人替服务器排了队。本文比较脚本化、录制回放、模型驱动三类行为模型的真实度与成本,讨论影子集群、只读副本与压测标记账号的隔离做法,并给出时间压缩、并发放大与依赖打桩的处理边界;最后用 Go 实现速率曲线、开放环到达与排队占比统计。

一场“通过”了的压测

《泡泡工坊》上线前两周,团队做了一次 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 容量模型与余量 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查