《游戏服务端架构·品类、状态与拓扑》8.2 分区分服、跨服与合服

《群岛纪元》东南亚版本按服开服三年,120 个服里六成日活不足 500,跨服战场又要让不同服的玩家同场竞技。本节比较继续开服、跨服互通与合服三条路线,把 ID 重映射、重名、公会会长和排行榜写成可校验的合服计划,并给出切服、回滚与客服追溯的执行顺序。

开服三年,从 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 适合同构跨服:匹配成功后,把双方角色、装备、战绩复制到房间服,战斗结束把结果写回各自主服。房间服不需要知道玩家的长期数据,主服也不需要知道战斗的每一帧。

跨服拍卖更适合共享跨服,但不要用分布式事务。可行做法是“托管 + 幂等订单”:

  1. 玩家上架时,主服冻结物品并生成 listing_id;
  2. 跨服索引只保存 listing_id、价格、服务器归属,不保存物品本体;
  3. 成交时,跨服服务生成 order_id,向卖家主服发“扣托管、记收入”,向买家主服发“扣款、发物品”;
  4. 两边都用 order_id 幂等,失败进入补偿队列;
  5. 每日按 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 的谁”。

切服顺序与回滚

合服执行应该像数据库迁移,而不是像一次大促活动:

  1. 冻结写入:源服进入只读,停止新建角色、充值、拍卖和公会操作。
  2. 导出快照:记录每个源服的行数、校验和、最大 ID。
  3. 生成计划:在快照上跑 BuildPlan,人工审核冲突与改名清单。
  4. 影子导入:把数据导入目标服的影子表,跑 Validate 和对账查询。
  5. 正式导入:在维护窗口内按块导入,记录 merge_batch_id。
  6. 切换别名:把源服的登录入口和旧链接指向目标服。
  7. 解冻与观察:目标服开放写入,观察 24 小时登录、支付、公会和邮件指标。
  8. 回滚窗口:源服保留只读副本 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 异步结算、离线收益与补算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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