开服三年,从 120 个服到 8 个世界
《群岛纪元》在东南亚先以分服形态上线。开服节奏是每周两组,每组两个服,一年下来 S1 到 S120。分服在当时是正确选择:支付渠道、客服团队、运营活动都按服组织,单个服出问题不会拖垮全区。但三年后,数据变得难看:
- 120 个服里,约六成日活低于 500(示例值,来自运营周报);
- 新玩家打开选服列表,看到 100 多个名字,第一反应是“这游戏是不是快凉了”;
- 跨服战场需要把不同服的玩家放进同一场战斗,匹配池按服切分后,深夜场要等 3 分钟以上(示例值)。
团队面临三条路:继续开新服、把跨服互通做深、或者合服。三条路不是互斥的,但预算和风险完全不同。本节以《群岛纪元》的合服项目为主线,讨论开服节奏、分区命名、跨服战场与拍卖,以及合服时真正麻烦的部分:ID 重映射、重名、公会会长、排行榜、邮件和客服追溯。
先说结论:合服不是“把两张表导进一张表”,而是一次带映射表、校验和回滚窗口的数据迁移项目。 代码只能解决其中一部分;剩下的是运营规则和客服流程。
三条路线,先算总账
| 路线 | 做法 | 对玩家的感知 | 工程成本 | 运营成本 |
|---|---|---|---|---|
| 继续开新服 | 每周固定开服,老服自然衰减 | 选择更多,鬼服也更多 | 低,复制模板 | 高,每服都要活动与客服 |
| 跨服互通 | 保留分服,战场/拍卖/聊天跨服 | 人多了,但主城仍在小服 | 中高,要建共享层 | 中,跨服规则要单独设计 |
| 合服 | 把多个源服并入目标服 | 世界变大,重名与排行变化 | 高,一次迁移 | 高,公告、补偿、客服 |
| 迁移到全球同服 | 所有服并入全球地图 | 世界最大,但改动最深 | 最高,等于重做拓扑 | 最高,合规与延迟都要重谈 |
《群岛纪元》的选择是组合拳:老服合服、活动跨服、新市场直接全球同服。 合服解决“鬼服”与选服列表过长;跨服战场解决“合服后仍然不够热闹”的时段问题;全球同服只用于新开的欧美市场,不把已有分服硬迁进去。
这个组合的关键是承认三种形态的服务对象不同:合服优化的是“世界人口”,跨服优化的是“玩法匹配池”,全球同服优化的是“新市场的产品承诺”。把它们混成一个“大一统架构”,会让每一次改动都同时触碰数据、合规和运营。
开服节奏与分区命名
开服节奏不是技术参数,是增长策略。开得太快,每个服都填不满;开得太慢,新玩家挤在老服,进度差太大。常见的分段策略是:
| 阶段 | 开服间隔 | 目标 | 风险 |
|---|---|---|---|
| 冷启动 | 每周 1–2 服 | 验证留存与付费 | 服太少,排行榜被少数人垄断 |
| 成长期 | 每周 2–4 服 | 承接买量 | 鬼服开始出现 |
| 成熟期 | 按需开服 + 合服 | 维持世界人口 | 合服频率过高,玩家疲惫 |
| 衰退期 | 停止开服,只合服 | 延长生命周期 | 客服与历史数据成本上升 |
分区命名要遵守一条硬规则:内部 ID 与显示名分离。 shard_id 一旦分配就永不改变,显示名可以改。合服后,目标服保留自己的 shard_id,源服的 shard_id 进入“已退役”状态,但旧显示名要作为别名保留至少一个赛季:
shard_id=1001 display_name="S1 晨曦" status=active
shard_id=1002 display_name="S2 远航" status=merged_into:1001
shard_id=1003 display_name="S3 群岛" status=merged_into:1001
玩家输入旧服名或旧链接时,应该被路由到目标服并看到“S2 已并入 S1”的提示,而不是 404。客服工单里也仍然会用旧服名,内部 ID 是唯一能把工单、日志和账号串起来的键。
跨服战场与跨服拍卖
跨服互通有两种实现形态,选型取决于“共享的是匹配池还是状态”:
| 形态 | 共享内容 | 数据边界 | 适用玩法 | 主要风险 |
|---|---|---|---|---|
| 同构跨服 | 只在战斗期间复制参战状态 | 各服保留主数据 | 5v5、副本、跨服 Boss | 进出场状态复制失败 |
| 共享跨服 | 独立跨服库保存战场/拍卖状态 | 跨服库是临时权威 | 跨服拍卖、赛季战场 | 跨服库与主库对账 |
《裂隙战场》的 5v5 适合同构跨服:匹配成功后,把双方角色、装备、战绩复制到房间服,战斗结束把结果写回各自主服。房间服不需要知道玩家的长期数据,主服也不需要知道战斗的每一帧。
跨服拍卖更适合共享跨服,但不要用分布式事务。可行做法是“托管 + 幂等订单”:
- 玩家上架时,主服冻结物品并生成
listing_id; - 跨服索引只保存
listing_id、价格、服务器归属,不保存物品本体; - 成交时,跨服服务生成
order_id,向卖家主服发“扣托管、记收入”,向买家主服发“扣款、发物品”; - 两边都用
order_id幂等,失败进入补偿队列; - 每日按
order_id对账,差额告警。
这里的边界是:跨服层可以决定“谁买到”,但不能直接改玩家库存。 库存的权威永远在主服。否则一次跨服库故障会同时污染多个服的玩家资产。
合服:真正的工作在 ID 之外
合服项目最容易低估的不是数据量,而是引用关系。玩家 ID、公会 ID、邮件发送者、好友、黑名单、拍卖挂单、赛季积分、客服工单,全部指向旧 ID。只把玩家表导过去,引用就断了。
先看检查清单:
| 对象 | 检查项 | 失败后果 |
|---|---|---|
| 玩家 | ID 冲突、重名、封禁状态、支付订单 | 登录错号、重名无法区分 |
| 公会 | ID 冲突、重名、会长是否活跃、联盟关系 | 公会失去会长、联盟断裂 |
| 排行榜 | 赛季积分、历史最高、并列名次 | 排名重复或奖励多发 |
| 邮件 | 未领取附件、发送者引用、过期时间 | 附件丢失、发送者显示未知 |
| 赛季 | 当前赛季进度、结算状态、通行证 | 赛季奖励重复或漏发 |
| 拍卖 | 挂单、托管物品、成交未结算 | 物品消失或双卖 |
| 日志 | 旧服 ID、旧玩家 ID、操作时间 | 客服无法追溯 |
| 工单 | 工单与账号/角色的关联 | 客服重复赔付 |
ID 重映射:块分配,而不是重新编号
最安全的做法是给每个源服分配一个互不重叠的新 ID 块,块内按旧 ID 排序映射。这样目标服的 ID 空间可以预先规划,导入时也不会与已有玩家冲突。
目标服已有玩家: 1..999,999
源服 S2 块: 1,000,000..1,099,999
源服 S3 块: 1,100,000..1,199,999
源服 S4 块: 1,200,000..1,299,999
块分配有三个好处:导入可以按块并行;冲突检测只需比较块区间;回滚时可以按块删除。坏处是 ID 会变得稀疏,但对 64 位整数不是问题。
下面的 merge 包把块分配、玩家/公会/会长重映射和重名处理写成一个可校验的计划。它用 Ref{Shard, ID} 作为旧记录的唯一键,因为不同服的玩家 ID 一定可能重复:
// file: internal/merge/merge.go
// tier: L1
// verify: go test ./internal/merge/
package merge
import (
"errors"
"sort"
)
var (
ErrBadTarget = errors.New("merge: empty target or zero base")
ErrNoSources = errors.New("merge: no source shards")
ErrDuplicateShard = errors.New("merge: duplicate source shard")
ErrDuplicateOldID = errors.New("merge: duplicate id inside a shard")
ErrZeroID = errors.New("merge: id zero is reserved")
ErrEmptyName = errors.New("merge: empty display name")
ErrMissingLeader = errors.New("merge: guild leader is not a player of the shard")
ErrMissingBlock = errors.New("merge: source shard has no id block")
ErrMissingMapping = errors.New("merge: source id has no mapping")
ErrDuplicateDest = errors.New("merge: duplicate destination id")
ErrBlockMismatch = errors.New("merge: destination id falls outside its block")
ErrBlockOverlap = errors.New("merge: id blocks overlap")
ErrNameConflict = errors.New("merge: destination name is not unique")
)
// Ref identifies an old row before the merge. IDs collide across shards, so a
// bare uint64 is never a safe key.
type Ref struct {
Shard string
ID uint64
}
type Player struct {
ID uint64
Name string
}
type Guild struct {
ID uint64
Name string
Leader uint64
}
type Input struct {
Shard string
Players []Player
Guilds []Guild
}
// Block is a half-open [Start, Start+Count) allocation for one source shard.
type Block struct {
Start uint64
Count uint64
}
func (b Block) Contains(id uint64) bool { return id >= b.Start && id < b.Start+b.Count }
type Conflict struct {
Kind string
Name string
Refs []Ref
}
type Plan struct {
Target string
PlayerBlocks map[string]Block
GuildBlocks map[string]Block
Players map[Ref]uint64
Guilds map[Ref]uint64
Leaders map[Ref]uint64
PlayerRenames map[Ref]string
GuildRenames map[Ref]string
Conflicts []Conflict
}
func sortedInputs(inputs []Input) []Input {
out := append([]Input(nil), inputs...)
sort.Slice(out, func(i, j int) bool { return out[i].Shard < out[j].Shard })
return out
}
func sortRefs(refs []Ref) {
sort.Slice(refs, func(i, j int) bool {
if refs[i].Shard != refs[j].Shard {
return refs[i].Shard < refs[j].Shard
}
return refs[i].ID < refs[j].ID
})
}
// BuildPlan gives every source shard a disjoint block, remaps players, guilds
// and guild leaders, and resolves duplicate display names deterministically.
func BuildPlan(target string, base uint64, inputs []Input) (Plan, error) {
if target == "" || base == 0 {
return Plan{}, ErrBadTarget
}
if len(inputs) == 0 {
return Plan{}, ErrNoSources
}
in := sortedInputs(inputs)
if err := validateInputs(in); err != nil {
return Plan{}, err
}
p := Plan{
Target: target, PlayerBlocks: map[string]Block{}, GuildBlocks: map[string]Block{},
Players: map[Ref]uint64{}, Guilds: map[Ref]uint64{}, Leaders: map[Ref]uint64{},
PlayerRenames: map[Ref]string{}, GuildRenames: map[Ref]string{},
}
cursor := base
for _, src := range in {
ps := append([]Player(nil), src.Players...)
sort.Slice(ps, func(i, j int) bool { return ps[i].ID < ps[j].ID })
p.PlayerBlocks[src.Shard] = Block{Start: cursor, Count: uint64(len(ps))}
for i, pl := range ps {
p.Players[Ref{src.Shard, pl.ID}] = cursor + uint64(i)
}
cursor += uint64(len(ps))
}
for _, src := range in {
gs := append([]Guild(nil), src.Guilds...)
sort.Slice(gs, func(i, j int) bool { return gs[i].ID < gs[j].ID })
p.GuildBlocks[src.Shard] = Block{Start: cursor, Count: uint64(len(gs))}
for i, g := range gs {
ref := Ref{src.Shard, g.ID}
p.Guilds[ref] = cursor + uint64(i)
p.Leaders[ref] = p.Players[Ref{src.Shard, g.Leader}]
}
cursor += uint64(len(gs))
}
playerNames := map[string][]Ref{}
guildNames := map[string][]Ref{}
takenPlayers := map[string]bool{}
takenGuilds := map[string]bool{}
for _, src := range in {
for _, pl := range src.Players {
ref := Ref{src.Shard, pl.ID}
playerNames[pl.Name] = append(playerNames[pl.Name], ref)
takenPlayers[pl.Name] = true
}
for _, g := range src.Guilds {
ref := Ref{src.Shard, g.ID}
guildNames[g.Name] = append(guildNames[g.Name], ref)
takenGuilds[g.Name] = true
}
}
p.Conflicts = resolve("player_name", playerNames, p.PlayerRenames, takenPlayers)
p.Conflicts = append(p.Conflicts, resolve("guild_name", guildNames, p.GuildRenames, takenGuilds)...)
sort.Slice(p.Conflicts, func(i, j int) bool {
if p.Conflicts[i].Kind != p.Conflicts[j].Kind {
return p.Conflicts[i].Kind < p.Conflicts[j].Kind
}
return p.Conflicts[i].Name < p.Conflicts[j].Name
})
if err := p.Validate(inputs); err != nil {
return Plan{}, err
}
return p, nil
}
几个设计点:
Ref带Shard。只用uint64做主键,源服 S2 的玩家 1 和 S3 的玩家 1 会在导入时互相覆盖。- 输入按服名排序,块分配因此是确定性的。同一个输入重复生成计划,应该得到完全相同的映射;否则回滚和重放都不可信。
- 玩家先分配,公会再分配,块首尾相接。这样目标 ID 在玩家和公会之间也不冲突,虽然它们通常在不同的表里。
Leaders单独保存公会会长的新 ID。会长本身也是玩家,必须走同一套重映射;漏掉它,公会就会指向一个不存在的玩家。- 重名不是“加个后缀就完了”。计划里要记录冲突集合与改名结果,运营才能批量发改名卡、客服才能解释“为什么我的名字变了”。
- 重名消解在同包
names.go,validate.go会对计划做二次校验:每条旧记录都有映射、目标 ID 唯一且落在块内、块不重叠、改名后没有重名。计划生成和计划校验分开,是为了让“从备份重建计划”和“校验导入文件”走同一条路径。
重名、排行榜与赛季
重名处理要定一条能向玩家解释的规则。常见做法是“先注册的保留原名,后来者加后缀”,但跨服合并时“先注册”要以创建时间为准,不能以导入顺序为准。更稳妥的运营规则是:
- 目标服玩家保留原名;
- 源服玩家如果与目标服重名,获得
原名#服名的临时名; - 所有被改名的玩家获得一张免费改名卡,有效期 30 天;
- 改名卡到期后,临时名仍然可用,不会强制再改。
排行榜不能“把两个榜拼起来再排序”。正确做法是从源数据重算,并且明确赛季边界:如果合服发生在赛季中途,通常选择“当前赛季冻结、下赛季合并”,而不是把两个赛季的积分直接相加。积分相加会让早期开服的玩家获得时间优势,也会让赛季奖励的发放口径无法解释。
邮件与客服追溯
邮件是最容易被漏掉的引用。未领取的附件要迁移,发送者要重映射;系统邮件的发送者可能是“运营”而不是玩家,这类引用不需要改。过期邮件不要直接删,转入归档表,客服仍然可以按旧 ID 查询。
客服追溯需要一张长期保留的映射表:
merge_batch_id | source_shard | old_id | new_id | kind | created_at
2026-10-S1S4 | S2 | 1 | 1000000| player | 2026-10-23
2026-10-S1S4 | S2 | 10 | 1000500| guild | 2026-10-23
这张表至少保留 180 天,覆盖支付争议和赛季奖励申诉的窗口。没有它,客服只能看到“玩家 1000000”,无法回答“他原来是 S2 的谁”。
切服顺序与回滚
合服执行应该像数据库迁移,而不是像一次大促活动:
- 冻结写入:源服进入只读,停止新建角色、充值、拍卖和公会操作。
- 导出快照:记录每个源服的行数、校验和、最大 ID。
- 生成计划:在快照上跑
BuildPlan,人工审核冲突与改名清单。 - 影子导入:把数据导入目标服的影子表,跑
Validate和对账查询。 - 正式导入:在维护窗口内按块导入,记录
merge_batch_id。 - 切换别名:把源服的登录入口和旧链接指向目标服。
- 解冻与观察:目标服开放写入,观察 24 小时登录、支付、公会和邮件指标。
- 回滚窗口:源服保留只读副本 7 天;如果出现阻断性问题,恢复别名并回滚导入。
回滚的关键不是“把数据搬回去”,而是“别名能切回来”。只要源服副本还在、写入被冻结、映射表还在,回滚就是一次路由切换;如果没有保留源服,回滚就变成了第二次迁移。
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/merge
$ go test -count=1 ./internal/merge/
ok gsarchlab/internal/merge 0.482s
5 个测试全部通过,覆盖跨服 ID 冲突、块映射、会长重映射、重名消解、非法输入和计划篡改。时间为本机一次运行结果,后续运行可能显示 (cached)。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 部分导入 | 目标服玩家数少于源服之和 | 按块校验行数与校验和,缺块重导 |
| ID 冲突 | 两个玩家登录到同一个角色 | 块分配 + Validate 唯一性校验 |
| 重名未处理 | 排行榜出现两个同名玩家 | 计划记录冲突,导入前强制改名 |
| 公会失去会长 | 会长未随公会迁移或会长账号被封 | 迁移前检查会长状态,必要时转移会长 |
| 排行榜重复 | 同一玩家在两个榜上各领一次奖励 | 按新 ID 去重,奖励发放记录唯一键 |
| 邮件附件丢失 | 玩家未领取的附件消失 | 邮件与附件一起迁移,按旧 ID 对账 |
| 拍卖挂单悬空 | 物品已托管但订单找不到 | 冻结挂单,迁移后按 listing_id 重建或退款 |
| 客户端缓存旧服 | 登录后仍请求旧服接口 | 旧服别名返回目标服路由,客户端强制刷新 |
| 客服查不到旧角色 | 工单里的旧 ID 无法定位 | 保留映射表 180 天,提供查询工具 |
决策矩阵:什么时候不该合服
合服不是默认答案。下面这张表用于判断当前问题到底该用哪种手段:
| 症状 | 优先手段 | 不该做的事 |
|---|---|---|
| 单服日活低,但玩家不愿换环境 | 跨服活动、跨服聊天 | 强行合服 |
| 选服列表过长,新玩家流失 | 合服 + 新服推荐位 | 继续按周开服 |
| 深夜匹配慢 | 跨服匹配池 | 为匹配再开新服 |
| 目标市场要求数据驻留 | 区域全球同服 | 把数据合到境外 |
| 赛季奖励口径混乱 | 先修赛季边界 | 用合服掩盖结算问题 |
| 客服工单无法追溯 | 补映射表与日志 | 直接删旧库 |
一个反直觉的结论是:当问题是“玩法不够热闹”时,跨服通常比合服便宜;当问题是“世界人口太少”时,合服才有效。 两者的成本结构不同,不能互相替代。
常见错误
第一,只迁玩家表,不迁引用。公会会长、邮件发送者、好友、拍卖挂单全部指向旧 ID,漏一个就会产生悬空引用。
第二,用自增 ID 重新编号。导入顺序稍变,映射就变;回滚和重放无法复现。
第三,重名靠数据库唯一索引硬顶。导入会在第一个重名处失败,或者更糟,静默丢弃一条记录。
第四,排行榜直接合并。两个赛季的积分相加,既不公平,也无法解释奖励口径。
第五,没有回滚窗口。源服一关,任何阻断性问题都只能靠第二次迁移解决。
第六,把跨服库当权威。跨服层可以决定成交,但不能直接改玩家库存;否则一次故障污染多个服。
第七,客服映射表只保留 7 天。支付争议和赛季申诉的窗口通常远长于 7 天。
小结
分区分服解决的是“世界人口怎么分”,跨服解决的是“匹配池怎么共享”,合服解决的是“老服怎么退场”。三者可以组合,但不能互相替代。合服的核心产物不是一张导入脚本,而是一份可校验的计划:块分配、引用重映射、重名规则、排行榜重算、邮件迁移、客服映射表和回滚窗口。
本节的可运行产物是 internal/merge:BuildPlan 生成确定性映射,Validate 对计划做二次校验,测试覆盖 ID 冲突、重名、会长悬空和计划篡改。下一节转向时间:异步结算、离线收益与补算,讨论玩家离开三天后回来,资源、消耗和运营补偿应该如何计算。
阅读导航:上一节:8.1 全球同服的大地图与分片 · 下一节:8.3 异步结算、离线收益与补算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。