赛区不是机房清单
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 上限 | 适用场景 |
|---|---|---|---|---|---|
| 排位 60Hz | 16.7ms | 1 tick | 100ms | 120ms | 同赛区排位、赛事决赛 |
| 活动 30Hz | 33.3ms | 1 tick | 120ms | 180ms | 跨区活动、表演赛 |
| 观战 | 跟随对局 | 不适用 | 不约束输入 | 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-1 | 150ms | 91.7ms | 4 × 20 = 80ms | 70ms | 选中 |
| ap-1 | 150ms | 91.7ms | 1 × 20 = 20ms | 130ms | 落选 |
| na-1 | 180ms | 106.7ms | 0 | — | 输入预算出局 |
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 (告知原因)
排位赛永远走第一或第三条。活动跨区只在活动时间窗内开放,窗口外即使玩家手动组队也拒绝。观战不参与这套判定:观战者跟随对局所在赛区,只读状态,不进入候选打分。
这个状态机有一个刻意的选择:拒绝发生在建房之前,而不是进房之后。中途把玩家迁移到另一个赛区,代价远高于让他在队列里多等几十秒;迁移会破坏回放、观战和结算的连续性,也会让已经建立的对局状态出现两个版本。
成本:赛区越多,低峰越贵
赛区数量不是架构水平的指标。每多一个赛区,就多一份固定成本:数据库、缓存、监控、值班和最小机器池。低峰时这些成本不会按比例下降。
北辰用一个简单的容量公式做首轮估算:所需赛区数等于峰值房间数除以“单区容量 × 目标利用率”,向上取整。示例值如下。
| 时段 | 房间数 | 单区容量 | 目标利用率 | 所需赛区 | 实际利用率 |
|---|---|---|---|---|---|
| 周末峰值 | 1200 | 100 | 80% | 15 | 80% |
| 工作日下午 | 320 | 100 | 80% | 4 | 80% |
| 深夜 | 90 | 100 | 80% | 2 | 45% |
如果深夜仍保留 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 《群岛纪元》的全球同服 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。