一句“在线十万”埋下的坑
《裂隙战场》上线前,市场部把“同时在线 10 万”印在了海报上。技术团队拿到的版本更具体:大区按 10 万 CCU 设计,单房间 10 人,预计 1 万个房间同时运行。
赛季首日,CCU 峰值到了 9.8 万,离设计上限还有余量,值班同学却开始收到告警:匹配排队 40 秒、房间 tick p99 从 42ms 涨到 110ms、跨区观战推流超时。CCU 曲线看起来完全正常。
问题不在数字本身,而在这个数字被当成了承载能力的全部。9.8 万 CCU 背后是 1.2 万个房间、每分钟 3.1 万次登录尝试、每秒 240 万条广播消息,以及一批集中在 20 分钟内的赛季奖励结算请求。CCU 只是这些负载的一个投影。
这一节要做的,是把“能承载多少人”翻译成一组可以分别测量、分别扩容、分别设告警的指标。
一个数字回答不了六个问题
容量评审时,下面六个问题经常被一个 CCU 数字掩盖:
- 能同时开多少个房间?房间是独立 tick 的成本单元,不是玩家。
- 每个房间的 tick 还有多少预算?CPU 没满,不代表单帧没超时。
- 服务器每秒要发多少条消息?广播扇出会放大输入。
- 登录和匹配入口能扛住多快的到达速率?这是 QPS,不是在线数。
- 峰值持续多久?持续 3 分钟和持续 3 小时,扩容策略完全不同。
- 这些数字按什么维度统计?全区、单服、单玩法,不能混着看。
把这六个问题写成指标,就得到一张比 CCU 更接近真相的承载清单。
把“承载”拆成六类指标
| 指标 | 单位 | 回答的问题 | 典型采集点 |
|---|---|---|---|
| CCU | 人 | 当前有多少连接 | 会话注册表 |
| PCU | 人 | 规划口径下同时在线峰值 | 会话注册表 + 峰值窗口 |
| 房间数 | 个 | 同时运行多少个独立模拟单元 | 房间管理器 |
| tick 预算 | ms/帧 | 单房间一次推进还剩多少时间 | 房间循环 |
| 消息速率 | 条/秒 | 服务端每秒产生多少下行消息 | 网关与广播层 |
| 登录/匹配 QPS | 次/秒 | 入口每秒接受多少新请求 | 登录服、匹配服 |
| 广播扇出 | 人/条 | 一条输入要复制给多少接收者 | 房间成员表 |
前三个是“规模”,中间两个是“单位成本”,最后两个是“入口速率”。它们的变化并不同步:CCU 平稳时,房间数可能因为玩法改版翻倍;房间数不变时,广播扇出可能因为观战功能翻三倍。
CCU 与 PCU 不是同一个数
CCU 是某一时刻的连接数,PCU 是规划口径下的峰值。两者之间至少差一个峰值系数,还差一个统计窗口。
如果产品平时 CCU 是 3 万,周末峰值系数是 1.8,那么规划 PCU 是 5.4 万。拿 3 万去申请机器,周末就会排队;拿 5.4 万去申请,平时又会有大量空闲。容量规划从来不是选一个数,而是选一个要覆盖的时间分位。
房间数要按玩法分开
《裂隙战场》的排位房和自定义房,单房成本可能差一个数量级:自定义房允许观战、允许暂停、允许非标准地图。把“1 万个房间”当成同质负载,扩容时会发现 CPU 花在少数重房间上。
tick 预算要看分位,不看平均
一个房间平均 tick 40ms,不代表它不会每隔几秒抖到 120ms。竞技玩家对抖动的敏感度远高于对平均值的敏感度。tick 预算应该按 p99 甚至 p999 统计,并且按房间类型分组。
消息速率要算扇出
一个 10 人房间、30Hz 输入、每条输入广播给全部 10 人,服务端消息速率是:
10 玩家 × 30 条/秒 × 10 接收者 = 3000 条/秒
1 万个这样的房间就是 3000 万条/秒。这个数字和 CCU 完全不是一回事,但它是网关和序列化层真正要扛的负载。
登录 QPS 与在线人数会脱节
在线人数是存量,登录 QPS 是流量。开服、整点活动、客户端强制更新后重登,都会让 QPS 在几分钟内冲高,而 CCU 还没来得及变化。只盯 CCU 的告警,往往在登录服已经排队后才响。
三款产品的指标重心不同
北辰三款产品不能共用一张承载看板:
| 产品 | 第一指标 | 第二指标 | 峰值形态 | 失控后果 |
|---|---|---|---|---|
| 《裂隙战场》 | 房间数 × tick p99 | 广播消息速率 | 晚间双峰,持续 2–3 小时 | 房间超时、操作不跟手 |
| 《群岛纪元》 | 大地图在线单位数 | 地图扫描耗时、离线结算队列 | 跨时区叠加,接近全天平台 | 扫描变慢、行军结算延迟 |
| 《泡泡工坊》 | 开局速率 | 同时房间数、匹配 QPS | 买量后 10 分钟内陡升 | 匹配排队、开局失败 |
《裂隙战场》的第一约束是单房间 tick。房间数上来后,CPU 可能还有余量,但每个房间分到的时间片变少,p99 先坏。
《群岛纪元》是全球同服 SLG,第一约束不是连接数,而是大地图上的单位总数和扫描频率。一个玩家带 5 支部队,10 万在线就是 50 万个移动实体;地图扫描从 200ms 涨到 800ms,玩家看到的是“行军变慢”。
《泡泡工坊》是休闲产品,单局 3–5 分钟,第一约束是开局速率。买量导入时,几分钟内涌进来的新玩家会同时点击“开始”,匹配 QPS 的峰值可能是平时的 20 倍,而 CCU 的上涨要滞后几分钟。
峰值口径:1 分钟还是 5 分钟
同一组数据,窗口不同,结论不同。假设每 1 分钟采样一次,连续 5 个样本是:
| 分钟 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| CCU | 10 | 10 | 100 | 10 | 10 |
- 1 分钟峰值是 100;
- 5 分钟峰值是 (10+10+100+10+10)/5 = 28;
- 3 分钟峰值是 40。
如果容量按 5 分钟峰值 28 去规划,第 3 分钟的那次 100 就会打穿。反过来,如果所有告警都按 1 分钟瞬时峰值,一次正常的 GC 抖动就会触发扩容。
合理的做法是两套口径并存:扩容决策看 5 分钟窗口,故障告警看 1 分钟窗口,并且把两个窗口都写进指标定义,而不是口头约定“看峰值”。
换算:在线人数不是登录速率
登录 QPS 和 CCU 之间可以用 Little 定律换算:
并发数 = 到达速率 × 平均停留时间
如果《泡泡工坊》平均每秒新增 8 个会话,平均一局加大厅停留 30 分钟,那么稳态 CCU 大约是:
8 × 1800 秒 = 14400
反过来,已知目标 CCU 是 14400、平均停留 30 分钟,登录入口至少要能接住 8 次/秒的持续到达;再乘一个开服峰值系数,才是登录服的容量目标。
这个换算的边界是“稳态”。开服瞬间不是稳态,大量玩家同时到达,必须单独按洪峰 QPS 设计,不能拿 Little 定律的结果当上限。
指标台账:谁采集、按什么维度、用什么窗口
指标一旦没有归属,就会变成“大家都看,没人负责”。台账至少要有五列:
| 指标 | 采集方 | 维度 | 窗口 | 告警线 |
|---|---|---|---|---|
| 房间 tick p99 | 房间服务 | 玩法 / 地图 / 区服 | 1 分钟 | 超过帧预算 60% |
| 广播消息速率 | 网关 | 房间类型 / 区服 | 1 分钟 | 超过设计值 70% |
| 登录 QPS | 登录服 | 渠道 / 区服 | 10 秒 | 超过入口容量 60% |
| 大地图单位数 | 世界服务 | 服务器 / 地图块 | 5 分钟 | 超过扫描预算 70% |
| 同时房间数 | 房间管理器 | 玩法 / 区服 | 1 分钟 | 超过池容量 80% |
| 匹配排队时长 p95 | 匹配服 | 玩法 / 分段 | 5 分钟 | 超过 20 秒 |
注意“窗口”这一列:登录 QPS 用 10 秒窗口,才能抓住开服洪峰;大地图单位数用 5 分钟窗口,才能过滤掉玩家上下线的噪声。窗口不是越短越好,而是要和被测量对象的物理时间尺度匹配。
备选方案与取舍
团队当时讨论过三种做法:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| A. 单 CCU 看板 | 只采集 CCU,按 CCU 扩容 | 实现最快,老板看得懂 | 房间、tick、入口洪峰全部不可见 |
| B. 主机资源看板 | 只看 CPU、内存、带宽 | 贴近机器,扩容直接 | 不知道业务为什么涨,无法提前 |
| C. 复合指标台账 | 六类指标分维度、分窗口采集 | 能定位瓶颈、能提前触发 | 采集与口径治理成本高 |
选 C,但加两条边界:
第一,不追求“全量指标”。每款产品只保留 6–8 个能直接驱动决策的指标,其余进日志或采样,不进告警。
第二,所有指标必须有唯一定义。同名指标在不同团队用不同窗口,比没有指标更危险,因为大家会以为自己看的是同一件事。
数据模型与接口契约
代码里用四个类型表达上面的约定:
Sample:一个时间点、一个值,要求等间隔采样;Peak:窗口平均值、窗口起止、窗口起点下标;Dimension:产品、区服、房间类型三元组;Ledger:按维度存序列,拒绝混用采样步长。
关键契约是:PeakWindow 只接受等间隔样本,窗口必须是步长的整数倍。这样“1 分钟采样、5 分钟窗口”才有确定含义,不会因为缺样本悄悄变成“最近 5 个点”。
可运行片段
下面的包实现滑窗峰值、Little 定律换算、峰值系数和按维度隔离的台账。它是本节所有口径的可执行版本。
// file: internal/capacitymetrics/metrics.go
// tier: L1
// verify: go test ./internal/capacitymetrics/
package capacitymetrics
import (
"errors"
"fmt"
"sort"
"time"
)
// Kind names a metric. Two teams quoting the same Kind must also agree on the
// window, the dimension and the sampling step, or they are quoting two metrics.
type Kind string
const (
CCU Kind = "ccu"
PCU Kind = "pcu"
Rooms Kind = "rooms"
TickMS Kind = "tick_ms"
MsgPS Kind = "msg_per_sec"
LoginQPS Kind = "login_qps"
)
// Sample is one reading of one metric. At must be in a single fixed zone and
// samples must be evenly spaced; gaps change what a window means.
type Sample struct {
At time.Time
Value float64
}
// Peak is the highest window average, not the highest single sample. Start and
// End are the first and last sample times inside the winning window.
type Peak struct {
Value float64
Start time.Time
End time.Time
Index int
}
var (
ErrNoSamples = errors.New("capacitymetrics: no samples")
ErrWindow = errors.New("capacitymetrics: window must be a positive multiple of step")
ErrSpacing = errors.New("capacitymetrics: samples must be evenly spaced at step")
)
// PeakWindow averages samples over a sliding window of window/step samples and
// returns the highest average. A one-minute spike diluted by a five-minute
// window is the difference between a capacity plan and an incident report.
func PeakWindow(samples []Sample, step, window time.Duration) (Peak, error) {
if step <= 0 || window <= 0 || window%step != 0 {
return Peak{}, ErrWindow
}
if len(samples) == 0 {
return Peak{}, ErrNoSamples
}
n := int(window / step)
if n > len(samples) {
return Peak{}, fmt.Errorf("%w: window needs %d samples, have %d", ErrWindow, n, len(samples))
}
for i := 1; i < len(samples); i++ {
if gap := samples[i].At.Sub(samples[i-1].At); gap != step {
return Peak{}, fmt.Errorf("%w: gap %s at index %d", ErrSpacing, gap, i)
}
}
sum := 0.0
for i := 0; i < n; i++ {
sum += samples[i].Value
}
best := Peak{Value: sum / float64(n), Start: samples[0].At, End: samples[n-1].At}
for i := n; i < len(samples); i++ {
sum += samples[i].Value - samples[i-n].Value
if avg := sum / float64(n); avg > best.Value {
best = Peak{Value: avg, Start: samples[i-n+1].At, End: samples[i].At, Index: i - n + 1}
}
}
return best, nil
}
// ConcurrentUsers applies Little's law: concurrency = arrival rate × dwell.
// perSecond is logins per second and dwell is the mean session length.
func ConcurrentUsers(perSecond float64, dwell time.Duration) float64 {
if perSecond <= 0 || dwell <= 0 {
return 0
}
return perSecond * dwell.Seconds()
}
// PeakFromAverage turns an average CCU into a planning peak. factor must come
// from the same product and the same time band as avg.
func PeakFromAverage(avg, factor float64) float64 {
if avg <= 0 || factor <= 0 {
return 0
}
return avg * factor
}
// FanoutMessages is the server-side message rate a room produces: every player
// sends inputHz inputs per second and every input is delivered to recipients.
func FanoutMessages(players int, inputHz float64, recipients int) float64 {
if players <= 0 || inputHz <= 0 || recipients <= 0 {
return 0
}
return float64(players) * inputHz * float64(recipients)
}
// Dimension is the axis a number is comparable on. Numbers from different
// dimensions are not a trend; they are two unrelated numbers.
type Dimension struct {
Product string
Shard string
RoomType string
}
// Ledger stores one series per dimension and refuses to mix sampling steps.
type Ledger struct {
step time.Duration
series map[Dimension][]Sample
}
func NewLedger(step time.Duration) (*Ledger, error) {
if step <= 0 {
return nil, ErrWindow
}
return &Ledger{step: step, series: map[Dimension][]Sample{}}, nil
}
func (l *Ledger) Step() time.Duration { return l.step }
func (l *Ledger) Add(d Dimension, at time.Time, v float64) error {
s := l.series[d]
if len(s) > 0 {
if gap := at.Sub(s[len(s)-1].At); gap != l.step {
return fmt.Errorf("%w: gap %s at %s", ErrSpacing, gap, d)
}
}
l.series[d] = append(s, Sample{At: at, Value: v})
return nil
}
func (l *Ledger) Peak(d Dimension, window time.Duration) (Peak, error) {
return PeakWindow(l.series[d], l.step, window)
}
func (l *Ledger) Dimensions() []Dimension {
out := make([]Dimension, 0, len(l.series))
for d := range l.series {
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Product != out[j].Product {
return out[i].Product < out[j].Product
}
if out[i].Shard != out[j].Shard {
return out[i].Shard < out[j].Shard
}
return out[i].RoomType < out[j].RoomType
})
return out
}
读代码:三个函数解决三个口径问题
PeakWindow 用前缀和滚动窗口,复杂度是 O(n),返回的不只是最大值,还有窗口的起止时间。这样告警里可以直接写“峰值出现在 20:03–20:07”,而不是只给一个数。
ConcurrentUsers 把到达速率和停留时间显式分开。调用方必须自己给“每秒到达”和“平均停留”,不能把 CCU 反推成 QPS 再当输入,否则会循环论证。
Ledger 的价值在 Add 的校验:同一维度必须严格按步长追加,缺一个点就报错。很多“峰值算错”的事故不是算法错,而是采样器重启后少了一个点,滑窗把两个不相邻的时间段拼在了一起。
失败模式与兜底
窗口挑选。 用 5 分钟窗口汇报,数字好看;用 1 分钟窗口考核,数字难看。解法是把两个窗口都固定写进报表,禁止临时换窗口。
维度混用。 把《裂隙战场》的排位房和自定义房加在一起算平均 tick,会同时低估重房间和高估轻房间。解法是维度必须出现在指标名里。
采样缺口。 采集器重启、网络抖动都会造成缺点。Ledger.Add 选择报错而不是补零,因为补零会制造一个虚假的低谷,让峰值窗口偏移。
平均值替代分位。 tick 平均 40ms、p99 110ms 的房间,体验由后者决定。所有 tick 指标默认看 p99。
扇出漏算。 只算玩家输入条数,不算广播接收者,会把网关负载低估一个数量级。消息速率必须按“输入 × 接收者”计算。
入口与存量脱节。 CCU 平稳不代表登录 QPS 平稳。开服、活动、强制更新期间,入口指标要独立告警。
兜底策略是:当某个指标缺失或口径不一致时,容量评审直接判为“不可评估”,而不是用相邻指标猜。猜出来的容量比没有容量更危险。
常见错误与边界
第一,把 CCU 当承载上限。CCU 只是连接数,房间、tick、消息、入口都可能先饱和。
第二,用全区数据做单服决策。全球同服的区服维度尤其容易混。
第三,只看峰值不看持续时间。持续 3 分钟的峰值可以靠弹性扛,持续 3 小时的峰值必须靠容量。
第四,把平均值写进 SLO。SLO 应该用分位,且要声明窗口。
第五,指标没有 owner。没有采集方的指标,故障时没人能解释它为什么跳。
第六,换算公式跨品类套用。竞技、SLG、休闲的停留时间和峰值形态完全不同,系数不能借用。
小结
承载能力不是一个人数,而是一组按维度、按窗口定义的指标。CCU 回答“现在有多少人”,房间数和 tick 回答“服务端在模拟什么”,消息速率回答“网络层要发多少”,登录与匹配 QPS 回答“入口能接多快”,峰值窗口回答“这个数能信多久”。
这一节的 capacitymetrics 包把峰值窗口、Little 定律换算和维度隔离做成了可执行约束。下一节讨论这些指标怎么在压测里被真实地产生出来,以及为什么很多压测测到的是排队,而不是容量。
阅读导航:上一节:目录与阅读路径 · 下一节:1.2 机器人压测与流量回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。