《游戏服务端架构·规模化、全球化与成本》1.3 容量模型与余量

余量不是 30%,而是“掉一个可用区、来一次活动、发一次坏版本”时还能不能接住。本文对比自底向上与自顶向下两种容量模型,给出 CPU、内存、带宽、连接四层的估算方法,把 N+1、故障域、活动、发布四类余量写成可校验的故障场景,并用 Go 实现分层利用率、瓶颈定位、余量校验与扩容触发时间计算。

一次没有结论的容量评审

《群岛纪元》第三赛季上线前,运维给出了一份容量表:CPU 峰值 58%,内存 61%,带宽 43%,连接数 37%。结论是“余量充足”。

赛季开服 20 分钟,跨时区的玩家同时上线,世界地图单位数从 180 万涨到 260 万。CPU 到了 79%,地图扫描 p99 从 240ms 涨到 900ms,行军结算队列开始堆积。运维说的“余量充足”没有错,但它回答的是“当前负载下还有多少空闲”,不是“来一次活动、掉一个可用区、发一次坏版本,还能不能接住”。

容量评审真正要回答的是后者。余量不是一个百分比,而是一组故障场景下的生存能力。

两种模型:自底向上与自顶向下

容量估算有两条路径,它们应该互相校验,而不是二选一。

模型起点算法适合盲区
自底向上单房间/单连接成本单房成本 × 房间数竞技、房间制忽略入口与共享组件
自顶向下业务事件总量事件速率 × 单事件成本SLG、全球同服单事件成本难标定

自底向上适合《裂隙战场》:一个 5v5 房间的 tick 成本、内存、广播量都能测,乘上房间数就是主体负载。它的盲区是登录、匹配、观战、排行榜这些共享组件,它们不随房间数线性增长。

自顶向下适合《群岛纪元》:先估计全球同时在线、每人在大地图上的单位数、扫描频率,再折算成 CPU 和带宽。它的盲区是单事件成本,尤其是“一次地图扫描”这种跨多个地图块的复合操作,很难用一个常数概括。

实践里两边都算,然后解释差异。如果自底向上算出 12000 CPU-ms/s,自顶向下算出 18000 CPU-ms/s,差异通常来自共享组件、重试和广播放大。解释不了差异,就说明模型缺了一层。

四个资源层

不管用哪种模型,最后都要落到四个可购买、可观测的层:

层单位主要来源常见误判
CPUCPU-ms/秒tick、扫描、序列化用核数代替毫秒预算
内存MB房间状态、地图块、缓存忽略峰值与碎片
带宽字节/秒广播、观战、资源下发只算出向、不算重传
连接个长连接、网关会话忽略 NAT 超时与重连

CPU 用“毫秒每秒”而不是“核”,是因为游戏负载的瓶颈经常是单核内的帧预算,不是总核数。16 核跑满 8 核的活,和 8 核跑满 8 核的活,对 tick 的影响完全不同。

内存要区分“常驻”和“峰值”。房间创建时的临时对象、地图扫描的中间结果、赛季结算的批量加载,都会让峰值明显高于稳态。

带宽要算重传和协议头。一个 120 字节的广播包,加上包头、加密、重传,实际占用可能是 160 字节。

连接数要算重连。移动网络切换、客户端崩溃、网关重启,都会在短时间内制造连接风暴。

余量不是百分比,是故障场景

“留 30% 余量”这句话无法验证。30% 是相对什么?是 CPU 的 30%,还是成本的 30%?是扛住一次活动,还是扛住一个可用区?

更可操作的表达是四个场景:

场景容量变化需求变化它证明什么
N+1失去一个故障域,容量乘 2/3不变单点故障不倒
故障域余量失去最大单区,容量按实际扣除不变跨区调度可行
活动余量不变需求乘 1.6活动洪峰不倒
发布余量坏版本吃掉 20% 容量不变回滚窗口内不倒

这四个场景不是相加关系,而是要分别校验。一个系统可能扛得住 N+1,却扛不住活动;也可能活动没问题,但坏版本一上就雪崩。

N+1 的边界

N+1 的前提是流量能切走。如果《群岛纪元》的大地图分片不能跨区迁移,失去一个区就是失去那部分玩家,N+1 只是“剩下的区不崩”,不是“玩家无感”。

活动余量的边界

活动余量假设需求是可预测的。买量导入、联动、补偿发放都可能超出历史峰值。活动余量应该用“上次同类型活动的实际峰值 × 1.6”这种保守口径,而不是“去年平均”。

发布余量的边界

发布余量假设坏版本的问题能被及时发现。如果坏版本只是内存泄漏,20 分钟内看不出来,80% 的容量也撑不到回滚。发布余量必须配合灰度和快速回滚,不能单独存在。

扩容触发线:按什么指标、提前多久

扩容触发线不是“CPU 超过 70%”。它要回答三个问题:

  1. 看哪一层的哪个指标?
  2. 从触发到新容量可用,需要多久?
  3. 扩容跟不上时,先牺牲什么?

第一,看瓶颈层,不看平均层。容量模型会给出当前需求下的瓶颈层;触发线应该盯瓶颈层的利用率,而不是四个层的平均值。

第二,提前量要覆盖扩容时长。如果新机器从申请到接流量需要 8 分钟,触发线必须保证从触发到饱和至少还有 8 分钟。用线性增长速率估算:

可等待时间 = (阈值 × 容量 - 当前需求) / 每分钟增长量

第三,扩容跟不上时要有牺牲顺序。以《裂隙战场》为例:

顺序牺牲项影响触发条件
1观战与回放少量玩家房间 CPU 超 80%
2非核心广播降频表现层网关消息速率超 70%
3匹配放宽分段对局质量匹配 QPS 超 80%
4登录排队新玩家登录 QPS 超 90%

牺牲顺序必须提前写进预案,并且和产品达成一致。故障现场再讨论“先关哪个功能”,通常会导致更大的事故。

备选方案与取舍

方案余量表达优点代价
A. 固定百分比统一留 30%简单无法回答扛住什么故障
B. 单场景 N+1只校验掉一个区聚焦可用性活动与发布场景缺失
C. 多场景校验四类场景分别算可解释、可验收需要维护容量模型

选 C。它的成本主要在模型维护,不在代码。模型要跟着玩法、房间成本、地图规模一起更新,否则会变成一份过期的文档。

边界有两条:

第一,不追求精确到个位数。容量模型的误差通常在 20% 以上,它的价值是找瓶颈和做场景校验,不是替代压测。

第二,场景数量控制在 3–5 个。场景太多会让人不知道先看哪个,最后又回到“看 CPU”。

数据模型与接口契约

代码把容量和需求都表达成四层向量:

  • Amount:CPU-ms/秒、MB、字节/秒、连接数;
  • Model:四层容量,每层必须为正;
  • LayerUtil:某一层的利用率;
  • Reserve:一次故障场景,包含容量损失与需求放大;
  • Verdict:是否通过、最差层、有效需求与有效容量。

关键契约是:未知容量不能写成 0。0 会被当成“无限”或“无穷瓶颈”,两种解释都会让模型给出错误结论。所以 New 拒绝任何一层小于等于 0 的容量。

可运行片段

下面的代码实现场景校验与扩容触发时间。Check 先扣除故障损失,再放大需求,最后找最差层;CheckAll 返回所有场景里最危险的那个。

// file: internal/capacitymodel/reserve.go
// tier: L1
// verify: go test ./internal/capacitymodel/
package capacitymodel

import (
	"fmt"
	"time"
)

// Reserve is a failure the remaining capacity must survive. It is deliberately
// not a percentage: each entry names what is lost or what arrives.
type Reserve struct {
	Name         string
	CapacityLoss float64 // fraction of capacity removed, e.g. one zone
	DemandSurge  float64 // multiplier on demand, e.g. an event
}

func (r Reserve) Validate() error {
	if r.Name == "" {
		return fmt.Errorf("capacitymodel: reserve needs a name")
	}
	if r.CapacityLoss < 0 || r.CapacityLoss >= 1 {
		return fmt.Errorf("capacitymodel: capacity loss %g outside [0,1)", r.CapacityLoss)
	}
	if r.DemandSurge != 0 && r.DemandSurge < 1 {
		return fmt.Errorf("capacitymodel: demand surge %g must be 0 or >= 1", r.DemandSurge)
	}
	return nil
}

var (
	LoseOneZone = Reserve{Name: "lose-one-zone", CapacityLoss: 1.0 / 3}
	EventSpike  = Reserve{Name: "event-spike", DemandSurge: 1.6}
	BadRelease  = Reserve{Name: "bad-release", CapacityLoss: 0.2}
)

type Verdict struct {
	Name     string
	Pass     bool
	Worst    LayerUtil
	Demand   Amount
	Capacity Amount
}

func Check(m Model, d Amount, r Reserve) (Verdict, error) {
	if err := r.Validate(); err != nil {
		return Verdict{}, err
	}
	surge := r.DemandSurge
	if surge == 0 {
		surge = 1
	}
	eff := Model{Capacity: m.Capacity.Scale(1 - r.CapacityLoss)}
	demand := d.Scale(surge)
	worst := eff.Bottleneck(demand)
	return Verdict{Name: r.Name, Pass: worst.Util <= 1, Worst: worst, Demand: demand, Capacity: eff.Capacity}, nil
}

// CheckAll returns the reserve with the highest resulting utilisation, which is
// the one that actually sets the safe operating limit.
func CheckAll(m Model, d Amount, rs []Reserve) (Verdict, error) {
	if len(rs) == 0 {
		return Verdict{}, fmt.Errorf("capacitymodel: no reserves")
	}
	var worst Verdict
	for i, r := range rs {
		v, err := Check(m, d, r)
		if err != nil {
			return Verdict{}, err
		}
		if i == 0 || v.Worst.Util > worst.Worst.Util {
			worst = v
		}
	}
	return worst, nil
}

// TimeToThreshold estimates when a layer reaches threshold×capacity at a
// linear growth rate. reached reports that it is already there.
func (m Model) TimeToThreshold(d Amount, l Layer, threshold, growthPerMinute float64) (time.Duration, bool, error) {
	if threshold <= 0 {
		return 0, false, fmt.Errorf("capacitymodel: threshold must be positive")
	}
	limit := threshold * m.Capacity.Get(l)
	cur := d.Get(l)
	if cur >= limit {
		return 0, true, nil
	}
	if growthPerMinute <= 0 {
		return 0, false, nil
	}
	minutes := (limit - cur) / growthPerMinute
	return time.Duration(minutes * float64(time.Minute)), false, nil
}

同包的 amount.go 提供 Amount、Model、利用率、瓶颈定位,以及从房间成本和人均成本生成需求的 FromRooms、FromUsers。

读代码:瓶颈是怎么被找出来的

Bottleneck 遍历四层利用率,返回最大值。它刻意不返回“绝对数值最小”的层,因为 100 个连接和 100MB 内存没有可比性,只有利用率可以比较。

MaxScale 回答“当前需求还能涨几倍”。示例值:CPU 利用率 0.6、内存 0.4、带宽 0.3、连接 0.2,那么 MaxScale 是 1/0.6 ≈ 1.67。它不考虑故障损失,适合做趋势判断;做故障校验要用 Check。

Check 的顺序很重要:先算有效容量 Capacity × (1 - CapacityLoss),再算放大后的需求 Demand × DemandSurge。如果反过来先放大需求再扣容量,数学上等价,但报表上容易让人误以为“损失的是需求”。

TimeToThreshold 用线性增长估算触发时间。它的输出是一个下界:真实增长通常不是线性的,活动开始时会更陡。所以触发线要留额外提前量,不能把估算值当精确时间。

失败模式与兜底

容量填 0。 有人把“未知”写成 0,模型算出无穷大利用率或无穷小瓶颈。兜底是 New 直接报错,强制填一个可解释的上限。

只算稳态。 用平均在线算容量,忽略开服和活动洪峰。兜底是所有需求都乘峰值系数,并用上一节的峰值窗口校验。

余量相加。 认为“N+1 留 33%、活动留 20%、发布留 20%,一共留 73%”。这些场景是分别校验,不是相加;相加会过度采购,也会掩盖真正的瓶颈。

瓶颈层选错。 用总 CPU 核数判断,而不是单核帧预算。兜底是容量模型里 CPU 用毫秒每秒,并且按房间类型分组。

扩容触发太晚。 触发线按 90% 设置,扩容要 8 分钟,结果新容量到位前已经饱和。兜底是用 TimeToThreshold 反推触发线。

没有牺牲顺序。 扩容跟不上时临时决定关什么,通常先关错。兜底是把牺牲顺序写进预案并演练。

常见错误与边界

第一,把容量模型当压测替代品。模型找瓶颈,压测验真值,两者不能互相替代。

第二,用核数代替 CPU 毫秒预算。核多不代表单帧不超时。

第三,忽略共享组件。登录、匹配、排行榜不随房间数线性增长,自底向上模型最容易漏。

第四,N+1 假设流量可切。不能跨区迁移的分片,失去一个区就是失去那部分玩家。

第五,活动余量用历史平均。应该用同类型活动的峰值乘保守系数。

第六,发布余量不配回滚。坏版本可能撑不到回滚完成。

第七,触发线只看单指标。瓶颈会转移,扩容后 CPU 降了,带宽可能成为新瓶颈。

小结

容量模型的价值不是给出一个精确数字,而是把“还能撑多久、先坏在哪、扛不住时先牺牲什么”变成可计算、可评审的结论。自底向上和自顶向下要互相校验,四个资源层要分别表达,余量要写成故障场景而不是百分比。

这一节的 capacitymodel 把分层利用率、瓶颈定位、场景校验和触发时间做成了可执行代码。下一章进入弹性调度,讨论这些容量结论如何变成实际的分配、迁移与回收动作。

阅读导航:上一节:1.2 机器人压测与流量回放 · 下一节:2.1 专用服、容器与调度器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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