全球单世界不是一张大表
《群岛纪元》的卖点是“所有玩家在同一个世界”。联盟可以招募另一个时区的成员,王战在同一个地图上打,排行榜只有一个。这个卖点在宣传语里很轻,落到服务端却是一串硬约束:地图不能按玩家切服,联盟不能按创建地锁死,王战不能只在一个时区的晚上开放。
北辰第一版的做法很直接:一个全球主库、一张地图、一个 UTC 时钟。上线三个月后,三个问题同时出现。欧洲玩家点击联盟成员列表要等两秒,因为请求回源到了亚洲主库;亚洲玩家参加王战要在凌晨两点上线,因为时间窗按 UTC 写死;地图扫描的耗时随着赛季扩张从几十毫秒涨到几百毫秒,每次王战前都要临时加机器。
这一节仍然只谈取舍。世界分区、状态归属、数据驻留的定义在第 4 章和第 9 章已经讲过,这里看的是《群岛纪元》如果重做一次,会保留什么、放弃什么,以及哪些“全球同服”的功能其实可以降级。
当年的单世界:一个库、一张地图、一个时钟
把失败点摊开,会发现它们不是同一个问题。
| 决定 | 当年的做法 | 上线后的结果 |
|---|---|---|
| 世界状态 | 一个全球主库,所有分区共写 | 跨洲写 200ms,王战期间锁等待明显 |
| 联盟归属 | 按联盟创建时的区域固定 | 跨时区招募后,成员看不到彼此的集结 |
| 王战时间 | 固定 20:00 UTC | 亚洲凌晨、欧洲傍晚,永远有一个大区吃亏 |
| 地图扫描 | 每个 tick 全量扫描 | 扫描耗时随赛季地图扩张线性上升 |
| 排行榜 | 实时全球查询 | 每次刷新都跨区回源,峰值拖慢主库 |
| 支付与账号 | 与全球库同库 | 数据驻留审计无法通过 |
真正致命的不是“单库”本身,而是把所有东西都放进了同一个一致性边界。世界身份需要全球唯一,但联盟列表、聊天、排行榜、支付记录并不都需要全球强一致。把它们捆在一起,才让一个跨洲写拖慢整张地图。
重做一次:世界不拆,状态拆
重做后的第一句话是:世界身份保持全球唯一,状态按写入归属拆开。玩家仍然进入同一个世界,看到同一张地图,但每个分区的持久状态只有一个写入区域。
| 数据 | 归属 | 读路径 | 写路径 | 一致性 |
|---|---|---|---|---|
| 分区状态 | 分区所在数据区 | 本区主库 | 本区单写 | 强一致 |
| 联盟名册 | 全球汇总 | 本地只读副本 | 归属区串行 | 秒级最终一致 |
| 联盟聊天 | 成员所在区 | 本地 | 本区写,异步扇出 | 秒级最终一致 |
| 排行榜 | 全球汇总 | 本地缓存 | 异步聚合 | 分钟级最终一致 |
| 支付与账号凭证 | 玩家所在区 | 本区 | 本区单写 | 强一致,不出区 |
| 战报与回放 | 战斗发生区 | 本地 + CDN | 本区写 | 强一致 |
这张表最重要的不是技术选型,而是“放弃”那一列没有写出来的部分:放弃全球实时一致,放弃跨区事务,放弃一个时钟。换来的边界是每个分区只有一个写入者,跨区只读和异步汇总。
数据驻留的冲突也在这里被拆开。支付凭证、身份证件、账号找回记录属于“必须本地”的数据,任何时候都不离开玩家所在区;联盟名册、聊天、排行榜属于“可以全球汇聚”的数据,但只做只读副本或异步聚合。真正难的是中间那类:战报既要在本地保存,又要在全球排行榜上展示。北辰的选择是本地保存全文,全球只汇总不可逆的统计值,不回传原始战报。
世界分区:分片不跨数据区
地图分区(shard)的第一原则不是“平均”,而是“不跨数据区”。一个分片的所有分区状态必须落在同一个数据区,否则一次王战会同时触发跨区写和跨区锁。
北辰的分区算法按环带(ring)切分:同一数据区内的分区按 ring 排序,再切成连续区间。这样做的理由有三个。第一,扫描是按 ring 向外扩散的,连续区间能命中同一批缓存。第二,迁移的最小单位是一个分片,而不是一个分区,连续区间让迁移后的边界仍然清晰。第三,王战的前线通常沿 ring 推进,分片边界与战线大致对齐,减少跨分片结算。
示例值如下。
| 数据区 | 分区数 | 请求分片数 | 实际分片 | 每片分区数 | ring 范围 |
|---|---|---|---|---|---|
| ap | 120 | 3 | 3 | 40 | 1–40、41–80、81–120 |
| eu | 80 | 3 | 3 | 27、27、26 | 1–27、28–54、55–80 |
| na | 60 | 3 | 3 | 20 | 1–20、21–40、41–60 |
当请求的分片数大于分区数时,算法会把分片数压到分区数,而不是产生空分片。空分片仍然要占一个进程、一份监控和一份值班,却没有任何分区可管;这是最容易被忽略的固定成本。
分片边界确定后,不要因为某个区玩家变多就临时把分片搬到另一个数据区。搬分会改变数据归属,触发一轮迁移和缓存失效;正确的做法是在该数据区内增加分片,或者在新赛季重划 ring。
联盟归属:活跃成员决定偏好区,不决定数据归属
联盟是《群岛纪元》里最跨时区的对象。一个联盟可能 40 个欧洲成员、25 个亚太成员、15 个北美成员,没有任何一个“主区”能代表所有人。
北辰重做后的规则是:用最近 14 天活跃成员最多的数据区作为“偏好区”,偏好区只影响两件事——新占中立分区的顺序,以及联盟日常任务的默认刷新点。它不改变任何分区的数据归属,也不改变成员数据的存放位置。
active members by zone (14d)
eu: 40 ap: 25 na: 15
│
▼
preferred zone = eu
│
├─ claim neutral sectors in eu, value desc, capped per tick
└─ alliance roster stays a global aggregate, read-only copies elsewhere
偏好区是一个负载均衡提示,不是 residency 规则。把偏好区当成数据归属,会让联盟在成员结构变化时被迫迁移整片地图;把偏好区当成匹配池,又会让跨时区成员永远排在后面。北辰选择只让它影响“先占哪块地”,因为这是可逆的、低成本的,而且不会碰数据驻留。
联盟占地的上限也要写死。Claim 接受一个 limit,每 tick 最多认领这么多中立分区;没有上限,一个大联盟可以在一个维护窗口内吞掉整张地图,其他联盟的扫描和集结都会失去意义。
王战时间窗:窗口是产品规则,不是定时器
王战时间窗是《群岛纪元》里最容易被技术化的产品决定。第一版把它写成“每天 20:00 UTC”,理由是“统一、好实现”。结果是亚洲玩家永远在凌晨参战,欧洲玩家在晚饭时间,北美玩家在下午;三个大区里总有一个在吃亏。
重做后,时间窗按数据区声明,允许跨午夜。示例排期如下。
| 数据区 | 本地时间窗 | 说明 |
|---|---|---|
| ap | 20:00–23:00 | 亚太主要市场的晚间 |
| eu | 19:00–22:00 | 欧洲晚间,与亚太窗口重叠 1 小时 |
| na | 18:00–21:00 | 北美东部晚间,与欧洲窗口重叠 3 小时 |
窗口用本地墙钟小时表示,StartHour 大于 EndHour 时表示跨午夜。例如 22:00–02:00 写成 {22, 2},Contains(23) 和 Contains(1) 都为真,Contains(2) 为假(右开区间)。
这里有一个刻意的简化:忽略夏令时。窗口是产品规则,不是定时器;夏令时切换当天,运营手动调整一次,比在代码里引入时区数据库、再让测试依赖系统 tzdata 要可控。这个选择必须在文档里写清楚,否则玩家会在切换日发现王战晚了一小时。
窗口不仅决定王战开启,还决定状态迁移能否发生。Move 会检查源区和目标区是否处于王战窗口;任何一个区开着,迁移都会被拒绝。理由很实际:迁移会让该分区的缓存失效、联盟扫描图重算,王战期间做这件事,等于把一次维护事故塞进一场战斗。
扫描成本:预算决定地图大小
《群岛纪元》的联盟地图不是一次性加载的,而是按 tick 扫描。每个 tick 有一个固定的扫描预算,联盟按分区价值从高到低扫描自己拥有和中立的分区;预算用完就停,剩下的进入下一 tick 的 backlog。
这个设计的关键不是算法,而是承认“地图不能无限大”。示例值如下。
| 地图分区数 | 每 tick 预算 | 完整扫描所需 tick | backlog 风险 |
|---|---|---|---|
| 240 | 60 | 4 | 低 |
| 400 | 60 | 7 | 中,王战前需要预热 |
| 800 | 60 | 14 | 高,必须增加分片或提高预算 |
backlog 的增长比扫描耗时更值得监控。扫描耗时是常数(受预算约束),但 backlog 会随着地图扩张而增长;当 backlog 超过一个完整扫描周期,联盟就会看到过期的地图,集结和防守决策都会失真。
提高预算不是免费的。扫描预算直接占用 tick 时间,预算越高,战斗结算的余量越小。北辰的选择是把预算写进赛季配置,地图扩张时优先增加分片,而不是无限提高单 tick 预算。分片可以并行,预算是串行的。
扩容与迁移:新区域开服
《群岛纪元》开新数据区时,最危险的动作不是加机器,而是把已有分区迁过去。迁移会改变数据归属,触发缓存失效、跨区只读副本重建和联盟扫描图重算。
北辰的迁移规则有三条。第一,迁移冷却 72 小时(示例值),同一个分片在冷却期内不能再次迁移,防止运营为了追热点反复搬。第二,王战窗口内禁止迁移,源区和目标区任何一个开着都不行。第三,迁移以分片为单位,迁移期间该分片只读,玩家可以看到地图但不能发起改变归属的操作。
freeze shard
├─ reject writes, keep reads
├─ copy durable state to target zone
├─ switch ownership record
├─ invalidate caches and rebuild read replicas
└─ unfreeze, resume writes in target zone
这套流程放弃了“无缝迁移”。迁移窗口内该分片只读,玩家会感知到几秒到几十秒的不可写。北辰认为这个代价可以接受,因为迁移是低频运维动作,而无缝迁移需要双写、冲突合并和回滚协议,复杂度和出错概率都高得多。
迁移完成后,联盟的偏好区不会自动改变。偏好区按活跃成员重新计算,可能仍然是原区;这没关系,偏好区只影响占地顺序,不影响数据归属。真正需要检查的是跨区只读副本是否已经追上,以及扫描图是否已经按新归属重建。
代码:internal/archglobal
下面的 world.go 把世界分区、偏好区计算和联盟占地写成纯函数。window.go 里是同包的时区窗口、扫描预算和迁移守卫;分片边界、偏好区和占地顺序都可以单独测试,不需要起数据库。
// file: internal/archglobal/world.go
// tier: L1
// verify: go test ./internal/archglobal/
// Package archglobal models the world layout of Archipelago Era: map sectors
// are partitioned into shards, a shard never crosses a data zone, alliances
// claim sectors near their active members, and war windows gate state moves.
package archglobal
import (
"errors"
"fmt"
"sort"
"time"
)
var ErrBadInput = errors.New("archglobal: invalid input")
// Zone is a data zone. It is the unit of residency and the unit that owns a
// shard; it is deliberately coarser than a single datacenter.
type Zone string
// Sector is one block of the world map. Home is the zone that stores the
// durable state for the sector; Owner is the alliance that controls it.
type Sector struct {
ID string
Ring int // distance from the world center, used for contiguous shards
Home Zone
Owner string // alliance ID; empty means neutral
Value int // war score weight, never negative
}
// Shard is a contiguous ring range inside one zone.
type Shard struct {
ID string
Zone Zone
RingFrom int
RingTo int
Sectors []string
}
// Plan partitions every sector into contiguous shards inside its home zone.
// A zone with fewer sectors than requested shards yields one shard per sector
// rather than an empty shard, because an empty shard still costs a process.
func Plan(sectors []Sector, shardsPerZone int) ([]Shard, error) {
if shardsPerZone <= 0 {
return nil, fmt.Errorf("%w: shards per zone must be positive", ErrBadInput)
}
if len(sectors) == 0 {
return nil, fmt.Errorf("%w: no sectors", ErrBadInput)
}
seen := map[string]bool{}
byZone := map[Zone][]Sector{}
for _, s := range sectors {
if s.ID == "" || s.Home == "" {
return nil, fmt.Errorf("%w: sector id and home zone are required", ErrBadInput)
}
if s.Ring < 0 || s.Value < 0 {
return nil, fmt.Errorf("%w: sector %s has negative ring or value", ErrBadInput, s.ID)
}
if seen[s.ID] {
return nil, fmt.Errorf("%w: duplicate sector %s", ErrBadInput, s.ID)
}
seen[s.ID] = true
byZone[s.Home] = append(byZone[s.Home], s)
}
zones := make([]Zone, 0, len(byZone))
for z := range byZone {
zones = append(zones, z)
}
sort.Slice(zones, func(i, j int) bool { return zones[i] < zones[j] })
out := make([]Shard, 0, len(zones)*shardsPerZone)
for _, z := range zones {
group := byZone[z]
sort.Slice(group, func(i, j int) bool {
if group[i].Ring != group[j].Ring {
return group[i].Ring < group[j].Ring
}
return group[i].ID < group[j].ID
})
n := shardsPerZone
if n > len(group) {
n = len(group)
}
base, extra := len(group)/n, len(group)%n
offset := 0
for i := 0; i < n; i++ {
size := base
if i < extra {
size++
}
part := group[offset : offset+size]
ids := make([]string, 0, size)
for _, s := range part {
ids = append(ids, s.ID)
}
out = append(out, Shard{
ID: fmt.Sprintf("%s-%02d", z, i+1),
Zone: z,
RingFrom: part[0].Ring,
RingTo: part[len(part)-1].Ring,
Sectors: ids,
})
offset += size
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// Member is one alliance member. LastActive is the last login or march order.
type Member struct {
ID string
Zone Zone
LastActive time.Time
}
// Alliance is a cross-timezone group of players.
type Alliance struct {
ID string
Members []Member
}
// PreferredZone returns the zone with the most recently active members. It is
// a load-balancing hint for claims, not a residency rule: the alliance still
// spans every zone its members live in.
func PreferredZone(a Alliance, now time.Time, activeWithin time.Duration) (Zone, error) {
if a.ID == "" {
return "", fmt.Errorf("%w: alliance id is empty", ErrBadInput)
}
if activeWithin <= 0 {
return "", fmt.Errorf("%w: active window must be positive", ErrBadInput)
}
counts := map[Zone]int{}
for _, m := range a.Members {
if m.Zone == "" {
return "", fmt.Errorf("%w: member %s has no zone", ErrBadInput, m.ID)
}
if now.Sub(m.LastActive) > activeWithin {
continue
}
counts[m.Zone]++
}
var bestZone Zone
best := 0
for z, n := range counts {
if n > best || (n == best && (bestZone == "" || z < bestZone)) {
bestZone, best = z, n
}
}
if best == 0 {
return "", fmt.Errorf("%w: alliance %s has no active member", ErrBadInput, a.ID)
}
return bestZone, nil
}
// Claim returns neutral sectors in the preferred zone, highest value first.
// Limit is a hard cap: an alliance cannot absorb the whole map in one tick.
func Claim(world []Sector, a Alliance, now time.Time, activeWithin time.Duration, limit int) ([]string, error) {
if limit <= 0 {
return nil, fmt.Errorf("%w: claim limit must be positive", ErrBadInput)
}
zone, err := PreferredZone(a, now, activeWithin)
if err != nil {
return nil, err
}
candidates := make([]Sector, 0, len(world))
for _, s := range world {
if s.Home == zone && s.Owner == "" {
candidates = append(candidates, s)
}
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].Value != candidates[j].Value {
return candidates[i].Value > candidates[j].Value
}
return candidates[i].ID < candidates[j].ID
})
out := make([]string, 0, limit)
for i := 0; i < len(candidates) && i < limit; i++ {
out = append(out, candidates[i].ID)
}
return out, nil
}
六组表驱动测试覆盖:分区连续性与空分片压缩、偏好区的活跃计数与平局、占地上限与所有权过滤、跨午夜窗口与下一次开启、扫描预算与 backlog、迁移的王战窗口与冷却守卫。
失败模式与兜底
| 失败 | 现象 | 兜底 |
|---|---|---|
| 偏好区没有活跃成员 | 占地任务无法开始 | 返回错误,由调用方回退到联盟创建区 |
| backlog 持续增长 | 联盟地图过期 | 告警;增加分片或临时提高预算 |
| 迁移中王战开启 | 分区只读时间被拉长 | 迁移前检查窗口;窗口内拒绝 |
| 跨区只读副本落后 | 成员列表看到旧数据 | 标注数据时间;写操作回源归属区 |
| 联盟成员结构剧变 | 偏好区频繁切换 | 偏好区按天计算,不按 tick 重算 |
| 数据驻留审计 | 支付记录被跨区查询 | 本地强一致,全球只汇总统计值 |
前两项是容量与调度问题,中间两项是迁移与复制问题,最后两项是合规问题。它们的共同边界是:全球同服不等于全球单库;能异步的异步,能只读的只读,必须本地的绝不出区。
重做一次会保留什么
第一,保留全球唯一的世界身份。玩家仍然在同一个世界、同一张地图上,这是产品的核心卖点,不能因为工程复杂就退回分服。
第二,保留“一个分区一个写入者”。这是所有一致性讨论的锚点;没有它,跨区事务会迅速蔓延。
第三,保留按活跃成员计算偏好区。它比“联盟创建地”更接近玩家真实分布,也比“盟主所在地”更难被操纵。
第四,保留本地时间窗。全球统一 UTC 窗口看似公平,实际是让一个大区永远吃亏。
第五,保留迁移守卫。王战窗口内不迁移、冷却期内不重复迁移,这两条规则能挡掉大部分运营事故。
常见错误与边界
第一,把全球同服理解成全球单库。世界身份可以唯一,存储不必唯一。
第二,按联盟创建地固定归属。成员结构会变,固定归属会让跨时区招募失去意义。
第三,用一个 UTC 时间窗服务所有大区。总有一个大区在凌晨参战。
第四,在王战期间迁移分区。缓存失效和扫描图重算会把一场战斗变成一次事故。
第五,把扫描预算当成可以无限提高的参数。预算挤占的是战斗结算的 tick 余量。
第六,把偏好区当成数据归属。偏好区只影响占地顺序,不应该触发数据搬迁。
小结
《群岛纪元》的全球同服,重做一次的核心不是“把世界拆开”,而是“把一致性边界拆开”。世界仍然是一个,地图仍然是一张,但每个分区只有一个写入区,联盟名册和排行榜降级为异步汇总,支付与账号数据留在本地。放弃的是全球实时一致和单库事务,换来的是可分区、可迁移、可审计的世界。
下一节看《泡泡工坊》。它没有全球单世界的包袱,也没有竞技产品的延迟硬约束;它的难题是另一端的:用一个很小的团队和预算,把一款休闲产品送出海,同时不把自己拖进多区域的固定成本。
阅读导航:上一节:12.1 《裂隙战场》的赛区与延迟 · 下一节:12.3 《泡泡工坊》的轻量出海 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。