《游戏服务端架构·规模化、全球化与成本》12.1 《裂隙战场》的赛区与延迟

《裂隙战场》把赛区当成产品决策而不是机房清单:本文复盘北辰在延迟、赛事、观战与成本之间的四次让步,给出赛区候选打分、输入延迟预算、跨区判定、低峰赛区合并与六组表驱动测试的可运行 Go 实现,并说明如果重做一次,哪些跨区匹配会被直接放弃、哪些低峰成本不再摊薄。

赛区不是机房清单

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

这不是一个“再加一个机房”就能解决的问题。北辰当年把赛区当成云厂商区域列表的镜像:法兰克福、弗吉尼亚、新加坡,各放一组房间服,匹配服务按玩家账号国籍就近分配。真正上线后,三个假设逐一失效。第一,赛事把玩家集中到少数几个城市,按国籍分的赛区反而让同国籍的选手分散。第二,跨区队伍无法拆开,最差玩家的 RTT 决定了整局体验。第三,观战被当成独立业务,单独起了一套集群,结果对局区和观战区之间又多了一跳。

本章是案例章,不再解释延迟拆段、数据归属或状态所有权的定义。我们只看一个问题:如果今天重做一次,哪些决定会改,改的代价是什么。三款产品会给出三个不同答案,本节先看《裂隙战场》。

当年的方案,以及它错在哪

最初的赛区方案可以用一张表说清楚。

决定当年的做法上线后的结果
赛区数量3 个大区,跟云厂商区域走低峰每个区都空,赛事又挤在一个区
分配依据账号国籍 + 最近登录 IP跨国队伍被拆,留学生被分到“母国”区
跨区匹配默认允许,靠 RTT 排序高分段人少,跨区对局成为常态
观战独立集群,就近接入战斗状态复制两份,回放与实时观战不一致
赛事单独申请一批机器与日常池隔离,赛后整批闲置

五个决定里,只有“按 RTT 排序”方向是对的,但它被放在了一个错误的约束下:跨区对局没有硬上限,排序只是在坏选项里挑一个稍好的。真正的问题不是算法,而是产品没有声明“哪些对局可以跨区”。

如果重做一次,先写放弃清单

重做不是把所有东西推翻。北辰内部复盘时,先把“要放弃什么”写在架构评审的第一页,因为不写放弃项,赛区只会越加越多。

重做后的决定主动放弃的东西换来的边界
排位赛只允许同赛区全球秒排、跨区高分段对局最差 RTT 可控,公平性可验收
跨区只开活动白名单玩家自由组跨区队伍跨区流量可预测,可按活动排期
赛事复用区域池,只做预留赛事与日常的物理隔离赛后不用养一批闲置机器
观战跟随对局区观战玩家就近接入只有一份战斗状态,回放一致
低峰合并到两个区低峰处处 60ms 以内固定成本下降,接受低峰跨区排队

这张表里最疼的一条是第一行。放弃全球秒排意味着高分段玩家在深夜可能排不到人,运营必须准备“延长等待”或“跨区活动”两个出口。但如果不放弃,跨区对局就会变成默认路径,任何延迟优化都会被最差玩家的 190ms 抵消。

第二疼的是赛事隔离。赛事团队最初坚持独立集群,理由是“不能影响日常”。复盘数据表明,赛事峰值只占日常峰值的示例值 35%,独立集群在赛后两周的利用率不到 10%。复用区域池加预留,确实会让赛事和日常共享故障域,但换来的是把预算花在容量而不是闲置上。

输入延迟预算:60Hz 只留给同区

《裂隙战场》采用帧同步与状态同步混合:技能判定走服务端权威帧,位移和镜头反馈走客户端预测。这个选择在第 3 章已经讨论过,这里只关心它对赛区的约束。

服务端每 tick 消费一次输入,输入从客户端发出到被应用,至少要经过半程 RTT 和最多一个队列深度:

input_lag = RTT / 2 + queue_ticks × tick

北辰给两种模式写了不同的预算,全部是设计目标与示例值,不是线上实测。

模式tick最大队列输入预算最差 RTT 上限适用场景
排位 60Hz16.7ms1 tick100ms120ms同赛区排位、赛事决赛
活动 30Hz33.3ms1 tick120ms180ms跨区活动、表演赛
观战跟随对局不适用不约束输入250ms只读,不进战斗判定

按表计算,同区 80ms RTT 的输入延迟是 40 + 16.7 = 56.7ms;跨区 150ms RTT 在 60Hz 下是 75 + 16.7 = 91.7ms,仍在 100ms 预算内,但已经接近上限。真正击穿预算的是 190ms 的跨区观战:95 + 16.7 = 111.7ms,超过 100ms,而且观战者还会额外承受视频流缓冲。

所以重做后的规则很直接:60Hz 只用于同赛区排位;跨区活动统一降到 30Hz;任何最差 RTT 超过 180ms 的组合直接拒绝。放弃的是“跨区也能打 60Hz”的幻想,换来的是一个可以写进匹配服务的硬约束。

这里有一个容易忽略的边界:RTT / 2 是单向延迟的近似,不是测量值。真实网络的上行和下行并不对称,跨区链路的上行往往更差。预算表因此留了余量,生产上仍要用同一次请求的采样来校准,而不是拿 ping 的平均值直接套公式。

候选打分:最差玩家决定赛区

确定了预算,下一步是从候选赛区里选一个。北辰的规则是“先硬过滤,再打分”,顺序不能反。

硬过滤有四条:赛区不能处于下线状态;剩余房间数必须大于零;如果对局包含跨区玩家,产品必须允许跨区;最差 RTT 和输入延迟都必须在预算内。四条中任何一条不满足,候选直接出局,不参与打分。

打分只看一个数:所有玩家里最差的那个 RTT。这样可以避免“平均 80ms”掩盖一个 200ms 的跨区玩家。主页赛区只作为奖励项,不从候选里删除非主页赛区;奖励值由产品配置,竞技产品给小,SLG 可以给大。

下面是一组示例值。四个欧洲玩家、一个亚太玩家,RTT 表为欧亚 150ms、欧北美 90ms、亚太北美 180ms;策略为 60Hz、队列 1、输入预算 100ms、最差 RTT 上限 200ms、主页奖励 20ms。

候选最差 RTT输入延迟主页奖励得分结果
eu-1150ms91.7ms4 × 20 = 80ms70ms选中
ap-1150ms91.7ms1 × 20 = 20ms130ms落选
na-1180ms106.7ms0—输入预算出局

eu-1 和 ap-1 的最差 RTT 相同,差别只在主页奖励。这个设计有争议:奖励过大时,20ms 的偏好可以覆盖 30ms 的真实延迟差异。北辰后来给奖励加了上限,不超过最差 RTT 上限的四分之一;超过这个比例,主页奖励就会变成一种隐性的国籍路由。

跨区判定:三种结果,不是两个

很多实现把跨区判定写成布尔值:允许或不允许。北辰重做后改成三种结果,因为“允许”里还分活动跨区和排位跨区。

QUEUED
  └─ score candidates
       ├─ same region ──────────────▶ ROOM_CREATED
       ├─ cross + event window ─────▶ ROOM_CREATED (30Hz)
       └─ cross + ranked / no window ─▶ REJECTED (告知原因)

排位赛永远走第一或第三条。活动跨区只在活动时间窗内开放,窗口外即使玩家手动组队也拒绝。观战不参与这套判定:观战者跟随对局所在赛区,只读状态,不进入候选打分。

这个状态机有一个刻意的选择:拒绝发生在建房之前,而不是进房之后。中途把玩家迁移到另一个赛区,代价远高于让他在队列里多等几十秒;迁移会破坏回放、观战和结算的连续性,也会让已经建立的对局状态出现两个版本。

成本:赛区越多,低峰越贵

赛区数量不是架构水平的指标。每多一个赛区,就多一份固定成本:数据库、缓存、监控、值班和最小机器池。低峰时这些成本不会按比例下降。

北辰用一个简单的容量公式做首轮估算:所需赛区数等于峰值房间数除以“单区容量 × 目标利用率”,向上取整。示例值如下。

时段房间数单区容量目标利用率所需赛区实际利用率
周末峰值120010080%1580%
工作日下午32010080%480%
深夜9010080%245%

如果深夜仍保留 15 个赛区,利用率会掉到 6% 左右。固定成本不会因为房间少而消失,这就是“赛区越多越贵”的真正含义。

重做后的做法是两步。第一步,用 RegionsNeeded 算出峰值和低峰各需要多少赛区,把差额写进值班手册。第二步,用 MergePlan 生成低峰排空建议:按已提交房间数从少到多排序,逐个尝试关闭,直到剩余容量在目标利用率下刚好装下所有房间。

MergePlan 故意只看 Committed,不看 Reserved。这不是遗漏,而是边界:赛事预留由活动日历单独保护,调用方必须先过滤掉被预留的赛区,再把剩下的赛区交给合并计划。把预留塞进合并算法,会让“关闭哪个区”同时依赖赛事排期,测试和复盘都会变得不可解释。

合并还有一条硬规则:只排空没有进行中对局的赛区。已经开局的房间跑完再下线,中途迁移会让这一局的所有玩家同时卡顿。排空是一个运营动作,不是调度器的自动行为;自动合并看似省人,实际会在赛事或突发流量时关掉不该关的区。

代码:internal/riftregion

下面的 region.go 把候选打分、跨区判定、容量估算和低峰合并写成纯函数。policy.go 里是同包的 Region、Player、Policy 与 RTT 表;表缺失时返回错误而不是零,避免“没测过就当没延迟”。

// file: internal/riftregion/region.go
// tier: L1
// verify: go test ./internal/riftregion/
package riftregion

import (
	"fmt"
	"sort"
	"time"
)

// Decision is the chosen region together with the evidence used to choose it.
type Decision struct {
	RegionID    string
	Zone        Zone
	WorstRTT    time.Duration
	WorstPlayer string
	InputLag    time.Duration
	CrossRegion bool
	Score       time.Duration
	Reasons     []string
}

// Admit picks the region with the lowest worst-player score. It rejects a
// candidate when any player would exceed the fairness cap or the input budget.
// The result is deterministic: ties are broken by region ID.
func Admit(regions []Region, players []Player, table *Table, p Policy) (Decision, error) {
	if err := p.validate(); err != nil {
		return Decision{}, err
	}
	if len(regions) == 0 || len(players) == 0 {
		return Decision{}, fmt.Errorf("%w: need regions and players", ErrBadInput)
	}
	best := Decision{}
	found := false
	for _, r := range regions {
		if err := r.validate(); err != nil {
			return Decision{}, err
		}
		if r.Free() == 0 {
			continue
		}
		d, ok, err := evaluateRegion(r, players, table, p)
		if err != nil {
			return Decision{}, err
		}
		if !ok {
			continue
		}
		if !found || d.Score < best.Score || (d.Score == best.Score && d.RegionID < best.RegionID) {
			best = d
			found = true
		}
	}
	if !found {
		return Decision{}, ErrNoRegion
	}
	return best, nil
}

func evaluateRegion(r Region, players []Player, table *Table, p Policy) (Decision, bool, error) {
	var worst time.Duration
	var worstPlayer string
	cross := false
	bonus := time.Duration(0)
	for _, pl := range players {
		rtt, err := table.RTT(pl.Zone, r.Zone)
		if err != nil {
			return Decision{}, false, err
		}
		if pl.Zone != r.Zone {
			cross = true
		}
		if rtt > worst {
			worst = rtt
			worstPlayer = pl.ID
		}
		if pl.Home == r.ID {
			bonus += p.HomeBonus
		}
	}
	if cross && !p.AllowCrossRegion {
		return Decision{}, false, nil
	}
	if worst > p.MaxWorstRTT {
		return Decision{}, false, nil
	}
	lag := InputLag(worst, p.MaxQueueTicks, p.Tick)
	if lag > p.InputBudget {
		return Decision{}, false, nil
	}
	score := worst - bonus
	if score < 0 {
		score = 0
	}
	return Decision{
		RegionID:    r.ID,
		Zone:        r.Zone,
		WorstRTT:    worst,
		WorstPlayer: worstPlayer,
		InputLag:    lag,
		CrossRegion: cross,
		Score:       score,
		Reasons:     []string{fmt.Sprintf("worst rtt %s, input lag %s", worst, lag)},
	}, true, nil
}

// RegionsNeeded returns how many identical regions a peak needs at a target
// utilization. A 100 percent target is rejected: no headroom is left for one
// region to fail.
func RegionsNeeded(peakRooms, perRegion, targetPercent int) (int, error) {
	if peakRooms <= 0 || perRegion <= 0 {
		return 0, fmt.Errorf("%w: rooms and capacity must be positive", ErrBadInput)
	}
	if targetPercent <= 0 || targetPercent >= 100 {
		return 0, fmt.Errorf("%w: target must be in (0,100)", ErrBadInput)
	}
	usable := perRegion * targetPercent / 100
	if usable == 0 {
		return 0, fmt.Errorf("%w: target leaves no usable capacity", ErrBadInput)
	}
	return (peakRooms + usable - 1) / usable, nil
}

// MergePlan suggests which regions can be drained during a low-peak window.
// It greedily removes the least-loaded region while the remaining capacity at
// the target utilization still holds every committed room. The plan is a
// starting point, not an optimal packing: drain order still has to respect
// events and network topology.
func MergePlan(regions []Region, targetPercent int) (kept, drained []string, err error) {
	if targetPercent <= 0 || targetPercent >= 100 {
		return nil, nil, fmt.Errorf("%w: target must be in (0,100)", ErrBadInput)
	}
	if len(regions) == 0 {
		return nil, nil, fmt.Errorf("%w: no regions", ErrBadInput)
	}
	active := make([]Region, 0, len(regions))
	committed := 0
	for _, r := range regions {
		if err := r.validate(); err != nil {
			return nil, nil, err
		}
		if r.Down {
			if r.Committed > 0 {
				return nil, nil, fmt.Errorf("%w: down region %s still holds rooms", ErrBadInput, r.ID)
			}
			drained = append(drained, r.ID)
			continue
		}
		active = append(active, r)
		committed += r.Committed
	}
	sort.Slice(active, func(i, j int) bool {
		if active[i].Committed != active[j].Committed {
			return active[i].Committed < active[j].Committed
		}
		return active[i].ID < active[j].ID
	})
	live := active
	for len(live) > 1 {
		capacity := 0
		for _, r := range live[1:] {
			capacity += r.Capacity
		}
		if capacity*targetPercent/100 < committed {
			break
		}
		drained = append(drained, live[0].ID)
		live = live[1:]
	}
	for _, r := range live {
		kept = append(kept, r.ID)
	}
	sort.Strings(kept)
	sort.Strings(drained)
	return kept, drained, nil
}

六组表驱动测试覆盖:输入延迟公式、主页奖励打破平局、五类拒绝条件、RTT 表的对称与缺失、容量向上取整、低峰合并的保留与排空。测试只用标准库。

失败模式与兜底

失败现象兜底
RTT 表缺一条链路候选被静默当成 0ms直接报错;上线前用探测任务补全
赛事突发预留区被日常房间占满Reserved 计入 Free;赛前锁定
单赛区故障该区房间无法创建故障区 Free 归零;新对局溢出到次近区
低峰合并后回峰排队时间突然上升保留一个赛区的余量;合并只做建议
跨区队伍被拒玩家反复重试明确告知原因和可选活动窗口

前两项是输入问题,后三项是运行期问题。共同原则是:路由错误在新连接上修正,运行期错误在当前会话内降级。把两者混在一起,就会出现“为了修一个玩家的路由,把整局玩家迁移走”的高代价操作。

重做一次会保留什么

第一,保留 RTT 表作为唯一事实来源。地理距离、国籍、IP 归属都只能作为补充信号,不能替代测量。

第二,保留确定性。候选同分时按赛区 ID 排序,保证同一份输入在不同节点上得到同一个答案;否则回放和排障会变成猜谜。

第三,保留硬拒绝。竞技产品的预算超了就是超了,不能用“平均还行”糊过去。拒绝发生在建房前,而不是对局中。

第四,保留合并计划,但不保留自动合并。计划是给人看的,关闭赛区必须经过值班确认。

常见错误与边界

第一,把赛区数量当成全球化程度。三个空闲赛区不如两个跑满的赛区。

第二,用国籍或地图距离代替 RTT。跨国队伍和留学生会把这两个代理指标打穿。

第三,让客户端决定最终赛区。客户端可以测速、可以排序,但裁决必须在服务端,否则改一个本地配置就能绕开预算。

第四,对局中途迁移。迁移会破坏回放、观战和结算,代价远高于让这一局跑完。

第五,把主页奖励设得过高。奖励一旦超过真实差异,就会变成隐性的地域歧视。

第六,在低峰把所有赛区都关掉。至少要留一个能承接突发流量的区,并确认数据库和缓存不会被冷启动拖垮。

小结

《裂隙战场》的赛区问题,表面是“机房放哪”,实际是“愿意放弃哪些对局”。重做一次,北辰放弃全球秒排、放弃赛事物理隔离、放弃观战就近接入、放弃低峰处处低延迟;换来的是可验收的最差 RTT、可预测的跨区流量和可解释的低峰成本。

下一节看《群岛纪元》。它的约束几乎相反:玩家要的是全球单世界,而不是把玩家分开。同一个“延迟预算”原则,会得到一个完全不同的答案。

阅读导航:上一节:11.3 回滚、开关与灰度 · 下一节:12.2 《群岛纪元》的全球同服 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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