《游戏服务端架构·规模化、全球化与成本》3.1 全球延迟预算与路由

《裂隙战场》的跨区观战和《群岛纪元》的全球集结,把端到端延迟拆成接入、区域间与存储三段。本文给出三款产品的示例预算表、客户端测速与登录下发网关列表的组合方式,以及跨区组队把两个区域绑在一起时的降级边界,并附 internal/latencybudget 的 Go 实现与五组表驱动测试。

一次跨区观战暴露的三个延迟

《裂隙战场》的周末赛事有一个固定场景:五排队伍里四人在欧洲,一人在成都。队长在柏林创建房间,匹配服务把对局放在法兰克福。成都玩家进入观战后,客户端 RTT 显示 190ms,技能按下到画面反馈明显迟滞;运维看网关指标,接入段只有 45ms,区域间链路 120ms,剩下 25ms 花在结算查询上。三个数字都“不算离谱”,合起来却超过了这款竞技产品能接受的上限。

同一周,《群岛纪元》的全球集结也收到类似反馈。SLG 玩家对 300ms 的点击延迟并不敏感,但跨区查询联盟成员列表时,页面要等两秒。排查后发现请求并没有走本地只读副本,而是回源到了另一个大洲的主库。对《泡泡工坊》来说,问题又不一样:休闲玩家从点开房间到进入准备界面,感知最强的是首包,而不是战斗中的持续 RTT。

这三件事说明,“延迟高”不是一个可以统一优化的指标。接入、区域间、存储调用由不同团队负责,失效方式也不同。全球化的第一步不是选机房,而是把端到端 RTT 拆成可归属的段,再为每款产品写一张预算表。

端到端 RTT 的三段拆分

在服务端语境里,玩家看到的 RTT 至少由三段组成:

client ── access ──▶ gateway ── region ──▶ game/storage
         (最后一公里)          (区域间骨干)      (区域内调用)
段谁负责典型示例值超预算时的表现
access 接入客户端网络、边缘网关、ISP20–60ms首包慢、重连多、弱网抖动
region 区域间骨干网、专线、跨区代理80–180ms技能反馈迟、跨区观战音画不同步
storage 存储本区域数据库、缓存、队列1–30ms结算卡顿、列表加载慢、超时重试

这条拆分有一个容易忽略的前提:三段必须来自同一次请求的采样。把网关的 P50、跨区链路的 P99 和数据库的慢查询日志拼在一起,会得到一个谁都不认识的数字。生产上更稳妥的做法是在请求入口生成一个采样 ID,三段各自上报,再由离线任务按 ID 聚合。

另一个边界是:游戏 RTT 不等于 HTTP 延迟。竞技游戏的输入是持续发送的,服务端按 tick 消费;SLG 的一次点击可能触发多次异步查询;休闲游戏更关心建立连接和首包的耗时。预算表必须写清楚它约束的是哪一种交互,否则“120ms”会成为一句无法验收的口号。

三款产品的预算表(示例值)

下面的数字全部是设计目标与示例值,不是线上实测结果。北辰内部用它们做容量评审和降级演练,实际值需要按版本、地区和网络类型分别测量。

产品玩家可见目标access 上限region 上限storage 上限超预算动作
《裂隙战场》120ms50ms40ms30ms跨区房间拒绝;同区降到 30Hz 输入
《群岛纪元》500ms120ms180ms200ms异步化;列表读本地副本
《泡泡工坊》250ms100ms100ms50ms首包超时则换网关;房间排队

三点说明。第一,三个分段上限之和可以大于总目标,因为峰值通常不在同一时刻出现;但每一段都不能单独超过总目标,否则一次局部抖动就能击穿玩家体验。第二,竞技产品的预算是硬约束,超了要拒绝或降级;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 数据驻留与跨境合规 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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