一次跨区观战暴露的三个延迟
《裂隙战场》的周末赛事有一个固定场景:五排队伍里四人在欧洲,一人在成都。队长在柏林创建房间,匹配服务把对局放在法兰克福。成都玩家进入观战后,客户端 RTT 显示 190ms,技能按下到画面反馈明显迟滞;运维看网关指标,接入段只有 45ms,区域间链路 120ms,剩下 25ms 花在结算查询上。三个数字都“不算离谱”,合起来却超过了这款竞技产品能接受的上限。
同一周,《群岛纪元》的全球集结也收到类似反馈。SLG 玩家对 300ms 的点击延迟并不敏感,但跨区查询联盟成员列表时,页面要等两秒。排查后发现请求并没有走本地只读副本,而是回源到了另一个大洲的主库。对《泡泡工坊》来说,问题又不一样:休闲玩家从点开房间到进入准备界面,感知最强的是首包,而不是战斗中的持续 RTT。
这三件事说明,“延迟高”不是一个可以统一优化的指标。接入、区域间、存储调用由不同团队负责,失效方式也不同。全球化的第一步不是选机房,而是把端到端 RTT 拆成可归属的段,再为每款产品写一张预算表。
端到端 RTT 的三段拆分
在服务端语境里,玩家看到的 RTT 至少由三段组成:
client ── access ──▶ gateway ── region ──▶ game/storage
(最后一公里) (区域间骨干) (区域内调用)
| 段 | 谁负责 | 典型示例值 | 超预算时的表现 |
|---|---|---|---|
| access 接入 | 客户端网络、边缘网关、ISP | 20–60ms | 首包慢、重连多、弱网抖动 |
| region 区域间 | 骨干网、专线、跨区代理 | 80–180ms | 技能反馈迟、跨区观战音画不同步 |
| storage 存储 | 本区域数据库、缓存、队列 | 1–30ms | 结算卡顿、列表加载慢、超时重试 |
这条拆分有一个容易忽略的前提:三段必须来自同一次请求的采样。把网关的 P50、跨区链路的 P99 和数据库的慢查询日志拼在一起,会得到一个谁都不认识的数字。生产上更稳妥的做法是在请求入口生成一个采样 ID,三段各自上报,再由离线任务按 ID 聚合。
另一个边界是:游戏 RTT 不等于 HTTP 延迟。竞技游戏的输入是持续发送的,服务端按 tick 消费;SLG 的一次点击可能触发多次异步查询;休闲游戏更关心建立连接和首包的耗时。预算表必须写清楚它约束的是哪一种交互,否则“120ms”会成为一句无法验收的口号。
三款产品的预算表(示例值)
下面的数字全部是设计目标与示例值,不是线上实测结果。北辰内部用它们做容量评审和降级演练,实际值需要按版本、地区和网络类型分别测量。
| 产品 | 玩家可见目标 | access 上限 | region 上限 | storage 上限 | 超预算动作 |
|---|---|---|---|---|---|
| 《裂隙战场》 | 120ms | 50ms | 40ms | 30ms | 跨区房间拒绝;同区降到 30Hz 输入 |
| 《群岛纪元》 | 500ms | 120ms | 180ms | 200ms | 异步化;列表读本地副本 |
| 《泡泡工坊》 | 250ms | 100ms | 100ms | 50ms | 首包超时则换网关;房间排队 |
三点说明。第一,三个分段上限之和可以大于总目标,因为峰值通常不在同一时刻出现;但每一段都不能单独超过总目标,否则一次局部抖动就能击穿玩家体验。第二,竞技产品的预算是硬约束,超了要拒绝或降级;SLG 的预算更像分级目标,超了可以先异步化,不能直接拒绝玩家上线。第三,休闲产品的预算要单独看首包,战斗中的 RTT 反而不是主要矛盾。
预算表还需要一个“谁有权降级”的字段。网络团队只能上报读数,不能替产品决定把 tick 从 60 降到 30;这个决定必须由玩法负责人预先声明。代码里的 Evaluate 因此把降级开关作为显式参数,而不是在超预算时自动选择一个“看起来合理”的动作。
路由的三种手段与失效表现
业界常见的路由手段可以归为三类,它们不是互斥的,但职责必须分清。
| 手段 | 做法 | 优点 | 失效表现 |
|---|---|---|---|
| 客户端测速 | 客户端并发探测若干区域入口,上报 RTT | 最接近玩家真实路径 | 冷启动慢、探测流量、结果可被篡改 |
| DNS / Anycast | 用域名解析或任播把玩家带到最近入口 | 客户端无改造 | 解析缓存、BGP 选路不等于玩家 RTT |
| 登录下发网关列表 | 登录服务返回签名过的区域与入口列表 | 可带负载、版本、维护状态 | 列表过期、区域切换后仍用旧入口 |
北辰的组合是“登录下发列表 + 客户端测速 + 服务端裁决”。DNS 只负责把玩家带到登录服务,不承担精细选路。登录服务返回的列表包含区域、入口地址、当前负载档位和过期时间,并用平台密钥签名;客户端可以缓存,但必须在过期或连续失败时重新拉取。
客户端测速的结果不能直接当作最终选择。它是一份“候选”,服务端仍要检查区域是否在可服务列表、版本是否匹配、负载是否超过阈值,并加上主页区域惩罚。否则,玩家改一个本地配置就能把自己送进人数最少但跨区最远的房间,或者反过来把攻击流量引向某个区域。
Anycast 的边界尤其要说清楚:它按网络前缀选路,不按玩家到游戏服务器的真实 RTT 选路。两个玩家解析到同一个入口,不代表他们的游戏路径相同;跨区专线、运营商互联和本地环路都会让结果偏离。Anycast 适合做接入层的第一跳,不适合做“哪个区域最适合这局游戏”的最终判断。
就近不等于就近
跨区组队会把两个区域绑在一起。五人队伍里四人在欧洲、一人在亚洲,无论房间放在哪边,总有一边的玩家承担跨区 RTT。此时“就近”不再是单玩家最近,而是“让最差的那个玩家仍可接受”。
| 场景 | 绑定关系 | 选点规则 | 不能做的事 |
|---|---|---|---|
| 竞技五排 | 同队玩家必须在同一房间 | 最小化最差 RTT;超过硬上限则拒绝跨区组队 | 把队伍拆到两个房间再合并结算 |
| 跨区观战 | 观战流跟随对局区域 | 观战入口就近,数据回源对局区域 | 为观战单独起一套战斗逻辑 |
| 全球交易 | 交易双方可能跨区 | 交易在主数据归属区串行化 | 两个区域同时扣同一份资产 |
| SLG 联盟 | 联盟成员分布全球 | 世界状态归属固定区域,成员只读 | 按成员所在地动态迁移世界 |
第一行是《裂隙战场》的硬约束:对局必须单区域,跨区队伍要么接受最差 RTT,要么被匹配服务拒绝。第二行说明观战可以就近接入,但不能复制战斗状态,否则回放和实时观战会出现两个版本。第三行是经济系统的边界,交易串行化点必须唯一,不能因为“玩家在本地”就把扣款放到本地。第四行是《群岛纪元》的选择:世界状态固定归属,联盟成员跨区只读,读延迟用缓存和异步推送吸收。
这张表解释了为什么“区域划分”不能只看地图。区域的边界由写归属决定,而不是由玩家到机房的直线距离决定。一个区域可以服务多个国家,也可以只服务一款产品的一个世界;关键是每个需要串行化的状态只有一个写入区域。
选定方案与边界
北辰最终采用四条规则。
第一,登录服务下发签名列表,列表按产品隔离。列表里包含区域标识、入口、负载档位、协议版本和过期时间;客户端只负责测速和排序,不负责最终裁决。
第二,服务端用“RTT + 负载惩罚 + 非主页区域惩罚”打分。主页区域是玩家账号最近常驻的区域,不是国籍;惩罚值由产品配置,竞技产品更高,SLG 可以更低。这样既尊重玩家习惯,又允许运营在容量紧张时引导分流。
第三,预算检查发生在建房、匹配和周期性健康检查三个点。建房时拒绝明显超预算的组合;匹配时把最差玩家 RTT 作为约束;运行中只做降级和告警,不在对局中途迁移房间。中途迁移会让所有玩家同时卡顿,代价远高于让这一局跑完。
第四,热路径禁止跨区存储调用。结算、背包、排行榜的写操作必须落在所属区域;跨区只允许读副本和异步汇总。发现跨区写,应当在网关层拒绝并打点,而不是靠数据库超时兜底。
代码:internal/latencybudget
下面的 budget.go 把预算校验与超预算裁决写成纯函数。select.go 负责候选区域打分,测试覆盖了负载惩罚、主页加成、边界值和非法输入。
// file: internal/latencybudget/budget.go
// tier: L1
// verify: go test ./internal/latencybudget/
package latencybudget
import (
"errors"
"fmt"
"time"
)
// Segment names one hop in the end-to-end RTT budget. Each hop has a different
// owner and a different failure mode, so a single aggregate number cannot tell
// an operator which half of the path to fix.
type Segment string
const (
SegmentAccess Segment = "access"
SegmentRegion Segment = "region"
SegmentStorage Segment = "storage"
)
var (
ErrBadBudget = errors.New("latencybudget: invalid budget")
ErrBadSample = errors.New("latencybudget: invalid sample")
)
// Budget is the design target for one product profile. Total is the
// player-visible target. The three caps bound each hop so an overrun can be
// attributed instead of hidden inside an aggregate.
type Budget struct {
Access time.Duration
Region time.Duration
Storage time.Duration
Total time.Duration
}
// Validate rejects budgets that cannot be enforced. Every cap must be
// positive and no cap may exceed the total, otherwise a single hop could pass
// its own check while still breaking the player-visible target.
func (b Budget) Validate() error {
if b.Total <= 0 {
return fmt.Errorf("%w: total must be positive", ErrBadBudget)
}
for name, cap := range map[Segment]time.Duration{
SegmentAccess: b.Access,
SegmentRegion: b.Region,
SegmentStorage: b.Storage,
} {
if cap <= 0 {
return fmt.Errorf("%w: %s cap must be positive", ErrBadBudget, name)
}
if cap > b.Total {
return fmt.Errorf("%w: %s cap %s exceeds total %s", ErrBadBudget, name, cap, b.Total)
}
}
return nil
}
// Action is what the gateway may do when a reading is over budget. Degrading
// is a product decision, not a networking one: it is allowed only for profiles
// that already declared a cheaper mode.
type Action string
const (
ActionAllow Action = "ALLOW"
ActionReduceRate Action = "REDUCE_RATE"
ActionReject Action = "REJECT"
)
// Reading is one measured hop. The caller samples access and region RTT, and
// records the storage call observed during the same request.
type Reading struct {
Segment Segment
RTT time.Duration
}
// Report explains a verdict. Over lists the hops that individually exceeded
// their caps, which is not the same as the total being over.
type Report struct {
Action Action
Used time.Duration
Limit time.Duration
Over []Segment
Reason string
}
// Evaluate applies a budget to one set of readings. A profile that declared a
// degraded mode gets ActionReduceRate; every other profile is rejected. The
// function never silently accepts a partial reading: all three segments are
// required so the total has a defined meaning.
func Evaluate(b Budget, readings []Reading, degrade bool) (Report, error) {
if err := b.Validate(); err != nil {
return Report{}, err
}
seen := map[Segment]bool{}
var used time.Duration
var over []Segment
for _, r := range readings {
if r.Segment != SegmentAccess && r.Segment != SegmentRegion && r.Segment != SegmentStorage {
return Report{}, fmt.Errorf("%w: unknown segment %q", ErrBadSample, r.Segment)
}
if seen[r.Segment] {
return Report{}, fmt.Errorf("%w: duplicate segment %q", ErrBadSample, r.Segment)
}
if r.RTT < 0 {
return Report{}, fmt.Errorf("%w: negative rtt for %q", ErrBadSample, r.Segment)
}
seen[r.Segment] = true
used += r.RTT
var cap time.Duration
switch r.Segment {
case SegmentAccess:
cap = b.Access
case SegmentRegion:
cap = b.Region
case SegmentStorage:
cap = b.Storage
}
if r.RTT > cap {
over = append(over, r.Segment)
}
}
for _, s := range []Segment{SegmentAccess, SegmentRegion, SegmentStorage} {
if !seen[s] {
return Report{}, fmt.Errorf("%w: missing segment %q", ErrBadSample, s)
}
}
report := Report{Used: used, Limit: b.Total, Over: over}
switch {
case used <= b.Total && len(over) == 0:
report.Action = ActionAllow
report.Reason = "within budget"
case used > b.Total && degrade:
report.Action = ActionReduceRate
report.Reason = fmt.Sprintf("over budget by %s", used-b.Total)
case used > b.Total:
report.Action = ActionReject
report.Reason = fmt.Sprintf("over budget by %s", used-b.Total)
case degrade:
report.Action = ActionReduceRate
report.Reason = "hop cap exceeded"
default:
report.Action = ActionReject
report.Reason = "hop cap exceeded"
}
return report, nil
}
Evaluate 要求三段读数齐全,缺一段就报错,而不是把缺失当成零。这个选择看起来苛刻,实际是为了避免“只测了网关就宣布达标”。Select 先过滤不可用、超 RTT 上限和超负载的候选,再打分;同分时按区域名排序,保证同一份探测结果在不同节点上得到同一个答案。
失败模式与兜底
| 失败 | 现象 | 兜底 |
|---|---|---|
| 探测结果被篡改 | 玩家被送到异常区域 | 服务端复核候选列表与签名;对声称 RTT 设下限 |
| 列表过期 | 入口已下线仍被使用 | 短 TTL;连接失败后强制刷新;保留一个保底入口 |
| 最近区域满载 | 排队时间上升 | 负载惩罚;溢出到次近区域;明确告知玩家 |
| 区域间链路抖动 | 跨区玩家 RTT 突增 | 只在新对局选点时规避;运行中降级或等待 |
| 误发跨区存储查询 | p99 飙升、超时重试 | 网关拒绝跨区写;查询层加区域断言 |
前两项属于“路由输入不可信”,后三项属于“运行期偏离预算”。它们的共同处理原则是:路由错误在新连接上修正,运行期错误在当前会话内降级。把两者混在一起,会出现“为了修一个玩家的路由,把整局玩家迁移走”的高代价操作。
常见错误与边界
第一,只看平均 RTT。平均值会掩盖跨区玩家的长尾,预算验收至少要看 P95 和 P99。
第二,把三个分段上限直接相加当作总预算。峰值不重合时这样会过度保守,峰值重合时又不够;总目标要单独定义。
第三,按地理位置选区域。地图距离不等于网络路径,必须用实测 RTT 或可信的探测数据。
第四,对局中途迁移房间。除了成本,迁移还会破坏回放、观战和结算的连续性;跨区问题应当在新对局选点时解决。
第五,把 Anycast 当成延迟路由。它解决的是入口可达性,不是“这局游戏放哪里”。
第六,让客户端决定最终区域。客户端只能提供候选和测量值,裁决必须在服务端完成。
小结
全球延迟预算的核心不是一张更细的表,而是把 RTT 变成可归属、可验收、可降级的约束。接入段归网络与客户端,区域间归骨干与选点,存储段归数据归属;三款产品按自己的交互模型设定不同目标,竞技产品硬拒绝,SLG 异步化,休闲产品看首包。路由手段可以组合,但最终裁决必须在服务端,且必须承认“就近”会被跨区组队、观战和交易改写。
延迟预算确定之后,下一个问题不是“把服务器放哪”,而是“哪些数据允许离开所在区域”。这会把讨论从网络指标带到数据分类、跨境机制和账号注销的传导路径上。
阅读导航:上一节:2.3 缩容、排空与成本 · 下一节:3.2 数据驻留与跨境合规 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。