《游戏服务端架构·规模化、全球化与成本》7.3 合服、迁移与玩家可见性

《群岛纪元》两个服合并,公告发出十分钟就收到重名、公会名与排行榜投诉;《泡泡工坊》小服合并后邮件附件重复、好友列表指向不存在的角色。本节列出合服的全部冲突面,给出确定性 ID 映射、分批迁移、提交门禁与回滚边界,并讨论玩家在停服窗口里应当看到什么。

合服公告发出后的第一个小时

《群岛纪元》准备把 s-a 并入 s-main。公告发出十分钟,客服收到三类投诉:两个玩家都叫“北境的风”,其中一个是排行榜第三;一个公会说自己的名字在目标服已经存在,要求保留;还有玩家发现好友列表里多了一个同名但等级完全不同的角色。

《泡泡工坊》的小服合并更轻,但也出了问题:邮件按角色复制时没有按邮件 ID 去重,同一封补偿邮件出现了两次;好友关系只迁移了单向边,A 的列表里有 B,B 的列表里没有 A。

合服不是“把两张表导进一张表”。它是一次有停服窗口、有玩家可见副作用、且很难回头的迁移。这一节按冲突、窗口、映射、回滚、可见性五个部分展开。

冲突清单:先列全,再定顺序

合服的冲突面比大多数人预想的多。北辰的检查表如下:

对象冲突处理规则玩家可见
角色 ID两服 ID 段重叠目标服分配新 ID,源 ID 只作映射无感
角色名目标服已占用/批内重名保留先到者,后来者加 -旧ID 后缀发改名卡,邮件通知
公会名同名但非同一公会按公会聚合改名一次,成员共享结果会长邮件 + 公会公告
排行榜两套榜单合并按新赛季重置,历史赛季归档榜单页说明
邮件邮件 ID 冲突/重复导入以源邮件 ID 去重,附件按新物品 ID 重挂无感,数量不变
好友/黑名单单向边、目标角色不存在双向补齐;失效目标转为“跨服好友”并标记列表提示
拍卖行挂单两服挂单混在一起停服前下架并退邮件,不合并在途单邮件通知
赛季数据两服赛季进度不同以目标服赛季为准,源服数据归档只读赛季页说明

顺序很重要:先处理 ID,再处理引用 ID 的对象(邮件、好友、公会成员),最后处理展示层(排行榜、名字)。反过来做,会在改名之后找不到原来的引用。

这张表里最容易被漏掉的是“批内重名”。只检查目标服已有名字,不检查同批迁移里的重复,两个“北境的风”会同时写进目标服,唯一约束在半夜报警。

迁移窗口:把停服切成可回滚的段

合服需要停服,但停服时长不是唯一指标;更重要的是每个阶段是否可回滚。北辰把窗口分成六段:

T-14d  公告、预发改名卡、关闭合服服的角色创建
T-1d   关闭跨服报名、拍卖行挂单、公会创建
T0     停服,源服切只读,目标服停止写入
T0+5m  全量快照,记录源服角色数、资产总额、邮件数
T0+10m 分批迁移,每批示例 5000 个角色
T0+40m 校验:数量、ID 映射、资产总额、抽样登录
T0+50m 开服,目标服接受写入
T0+72h 回滚窗口关闭,源服快照转为归档
阶段目标时长(示例值)可回滚回滚动作
公告期14 天是取消合服,无数据变更
只读期10 分钟是源服恢复写入
迁移中30 分钟是丢弃已写目标数据,源服仍权威
校验期10 分钟是同上
开服后 72 小时72 小时有条件冻结目标服写入,整库恢复到快照
72 小时后—否只能向前修复

“开服后还能回滚”是一个危险的承诺。开服意味着玩家已经产生新数据,反序撤销迁移会把新数据一起丢掉。所以 72 小时内的回滚不是“撤销迁移”,而是“冻结写入 + 整库恢复 + 补偿公告”。超过窗口,回滚成本高于修复成本,只能向前修。

ID 与名字:确定性映射

角色 ID 的映射规则只有一句:按源服 LocalID 升序,从目标服预留段起始值开始连续分配。 不取哈希,不取随机数,不依赖插入顺序。原因不是技术偏好,而是可审计:出问题时能拿一张表复算,玩家问“我的新 ID 为什么是这个”时能解释。

哈希分片解决的是“数据落到哪个节点”,合服解决的是“旧 ID 对应哪个新 ID”,两者不是同一个问题;一致性哈希的细节在 4.2 一致性哈希迁移 里,本节不重复。

名字冲突的处理要满足三个条件:

  1. 不丢名字:每个角色最终都有唯一名字。
  2. 可解释:后缀规则固定,客服能一句话说明。
  3. 有补偿:被改名的玩家收到改名卡,而不是只看到名字变了。

北辰用 原名-旧ID 作为后缀,例如 北境的风-10231。如果后缀仍然冲突(极端情况),再追加序号。改名卡通过邮件发放,邮件正文写明原名字、新名字与改名入口。

公会名按公会聚合处理:同一公会的所有成员只产生一条改名记录,会长收到通知,公会公告同步更新。不能按成员逐条改名,否则同一个公会出现多个名字,公会战匹配和日志都会错乱。

排行榜重置是产品决策,不是技术决策。北辰的做法是新赛季从零开始,历史赛季归档到“往期赛季”页;玩家能看到自己在上个服的排名,但不会把两个服的积分直接相加。直接相加会产生“合服即通胀”的观感,也会让新玩家永远追不上。

分批、断点与回滚

迁移必须分批。批次大小是一个取舍:

批大小(示例值)单批耗时失败重跑成本适用
1000短低首次演练、问题定位
5000中中正式迁移默认值
20000长高数据量小、窗口紧

比批大小更重要的是断点粒度。按批记录“第几批完成”不够:一批 5000 个角色,第 4999 个失败,重跑会把前 4998 个再做一遍。正确做法是每个角色成功后立即记录旧 ID,重跑时跳过已记录的键。断点记录要和目标服写入在同一个事务里,否则会出现“记了没做”或“做了没记”。

提交门禁只有一条:全部映射应用成功才允许提交。 提交前,源服仍是权威;提交后,目标服成为权威。提交动作本身要幂等,重复提交不能产生第二套 ID。

回滚依赖应用日志。每成功迁移一个角色,记录旧 ID;回滚时反序撤销。反序不是形式主义:如果先删父对象再删子对象,外键或引用检查会失败;反序能保证先删后写入的依赖。

玩家可见性:不要静默变更

合服对玩家是一次“世界变了”的体验,所有变更都要提前说明:

事项提前多久渠道内容
合服时间与停服时长14 天登录公告、社区起止时间、补偿方案
改名规则7 天公告、FAQ什么情况会改名、如何领改名卡
排行榜重置7 天赛季页历史排名去哪看
公会名变更开服前会长邮件新名字、公会 ID 不变
好友关系处理开服前公告失效好友如何标记
停服补偿开服时邮件补偿内容与领取期限

补偿要覆盖“停服”和“改名”两类损失,但不要用补偿掩盖规则。示例值是停服每小时 100 钻石、改名卡 1 张;具体数值由运营定,技术侧只保证发放幂等、可查询、可补发。

可运行片段:确定性映射与冲突处理

internal/mergemigration 把上面的规则落成代码。mergemigration.go 负责 ID 映射、玩家重名与公会改名;同包的 batch.go 提供分批、断点与回滚日志,文章只展开前者。

// file: internal/mergemigration/mergemigration.go
// tier: L1
// verify: go test ./internal/mergemigration/
package mergemigration

import (
	"errors"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"
)

var (
	ErrInvalidPlan = errors.New("mergemigration: invalid plan")
	ErrIncomplete  = errors.New("mergemigration: plan not fully applied")
)

// Subject 是源服一个角色的迁移输入。LocalID 只在源服内唯一,
// 合服后必须换成目标服的新 ID。
type Subject struct {
	LocalID   int64
	Name      string
	GuildName string
	RankScore int64
	Mails     int
}

// Mapping 是一个角色的新旧 ID 与最终名字。Renamed 为 true 时,
// 玩家会收到一张改名卡,而不是被静默改名。
type Mapping struct {
	OldID   int64
	NewID   int64
	Name    string
	Renamed bool
	Reason  string
}

// GuildRename 是按公会聚合的改名结果;同名公会的所有成员共用一条记录。
type GuildRename struct {
	Old     string
	New     string
	Renamed bool
}

// Plan 是一次合服的完整映射。Commit 之前源服仍是权威,
// Committed 为 true 表示已经越过分批迁移的切换点。
type Plan struct {
	Source    string
	Target    string
	Mappings  []Mapping
	Guilds    []GuildRename
	MailTotal int
	RankReset bool
	Committed bool
	CreatedAt time.Time
}

// BuildPlan 按 LocalID 升序生成新 ID,并处理玩家名与公会名冲突。
// targetPlayers 与 targetGuilds 是目标服已经占用的名字,比较不区分大小写。
func BuildPlan(source, target string, subjects []Subject, targetPlayers, targetGuilds []string, startID int64, now time.Time) (*Plan, error) {
	switch {
	case source == "" || target == "":
		return nil, fmt.Errorf("%w: empty source or target", ErrInvalidPlan)
	case source == target:
		return nil, fmt.Errorf("%w: source equals target", ErrInvalidPlan)
	case startID <= 0:
		return nil, fmt.Errorf("%w: start id must be positive", ErrInvalidPlan)
	case len(subjects) == 0:
		return nil, fmt.Errorf("%w: no subjects", ErrInvalidPlan)
	}

	seenID := make(map[int64]bool, len(subjects))
	for _, s := range subjects {
		if seenID[s.LocalID] {
			return nil, fmt.Errorf("%w: duplicate local id %d", ErrInvalidPlan, s.LocalID)
		}
		seenID[s.LocalID] = true
	}

	ordered := append([]Subject(nil), subjects...)
	sort.Slice(ordered, func(i, j int) bool { return ordered[i].LocalID < ordered[j].LocalID })

	usedPlayer := make(map[string]bool, len(targetPlayers)+len(ordered))
	for _, name := range targetPlayers {
		usedPlayer[strings.ToLower(name)] = true
	}
	mailTotal := 0
	mappings := make([]Mapping, 0, len(ordered))
	for i, s := range ordered {
		name := strings.TrimSpace(s.Name)
		if name == "" {
			return nil, fmt.Errorf("%w: empty name for local id %d", ErrInvalidPlan, s.LocalID)
		}
		m := Mapping{OldID: s.LocalID, NewID: startID + int64(i), Name: name}
		if usedPlayer[strings.ToLower(name)] {
			m.Name = uniqueName(name, s.LocalID, usedPlayer)
			m.Renamed = true
			m.Reason = "name_conflict"
		}
		usedPlayer[strings.ToLower(m.Name)] = true
		mailTotal += s.Mails
		mappings = append(mappings, m)
	}

	guilds, err := buildGuilds(ordered, targetGuilds, source)
	if err != nil {
		return nil, err
	}

	return &Plan{
		Source:    source,
		Target:    target,
		Mappings:  mappings,
		Guilds:    guilds,
		MailTotal: mailTotal,
		RankReset: true,
		CreatedAt: now,
	}, nil
}

func uniqueName(base string, oldID int64, used map[string]bool) string {
	candidate := base + "-" + strconv.FormatInt(oldID, 10)
	for i := 2; used[strings.ToLower(candidate)]; i++ {
		candidate = base + "-" + strconv.FormatInt(oldID, 10) + "-" + strconv.Itoa(i)
	}
	return candidate
}

func buildGuilds(subjects []Subject, targetGuilds []string, source string) ([]GuildRename, error) {
	used := make(map[string]bool, len(targetGuilds))
	for _, name := range targetGuilds {
		used[strings.ToLower(name)] = true
	}
	names := make([]string, 0, len(subjects))
	seen := make(map[string]bool, len(subjects))
	for _, s := range subjects {
		name := strings.TrimSpace(s.GuildName)
		if name == "" {
			continue
		}
		key := strings.ToLower(name)
		if seen[key] {
			continue
		}
		seen[key] = true
		names = append(names, name)
	}
	sort.Strings(names)

	var out []GuildRename
	for _, name := range names {
		g := GuildRename{Old: name, New: name}
		if used[strings.ToLower(name)] {
			g.New = uniqueGuild(name, source, used)
			g.Renamed = true
		}
		used[strings.ToLower(g.New)] = true
		out = append(out, g)
	}
	return out, nil
}

func uniqueGuild(base, source string, used map[string]bool) string {
	candidate := base + "-" + source
	for i := 2; used[strings.ToLower(candidate)]; i++ {
		candidate = base + "-" + source + "-" + strconv.Itoa(i)
	}
	return candidate
}

// Commit 只在全部映射都应用后允许提交;否则源服继续保持权威。
func (p *Plan) Commit(applied int) error {
	if applied != len(p.Mappings) {
		return fmt.Errorf("%w: %d/%d mappings applied", ErrIncomplete, applied, len(p.Mappings))
	}
	p.Committed = true
	return nil
}

BuildPlan 先按 LocalID 排序,再连续分配新 ID,因此同样输入永远得到同样映射。玩家名用不区分大小写的集合判重,目标服已有名字和批内重名走同一条规则。公会名按去重后的公会列表处理,同名公会只产生一条 GuildRename。Commit 是提交门禁:应用数量不等于映射数量时返回 ErrIncomplete,源服继续保持权威。

本机验证命令与结果:

cd .local/checks/gsarch-lab
gofmt -l internal/mergemigration
go vet ./internal/mergemigration/
go test -count=1 ./internal/mergemigration/
ok  	gsarchlab/internal/mergemigration

gofmt -l 无输出,go vet 退出码 0,测试退出码 0。该包有 6 个表驱动测试,覆盖 ID 排序、玩家重名、公会改名、非法输入、分批断点续跑与提交/回滚。

失败模式与兜底

失败表现检测兜底
批内重名唯一约束冲突迁移写入失败率上升迁移前先跑全量判重
后缀再冲突改名后仍重复唯一约束再次报错追加序号,仍失败则转人工
公会名只改一半同公会多个名字公会列表出现重复 ID按公会聚合改名,成员共享
批次中途失败部分角色已迁移断点 Next 未推进按键续跑,不整批重来
开服后缓存旧 ID登录找不到角色旧 ID 查询命中率异常开服前清缓存,旧 ID 保留只读映射
好友单向边A 有 B、B 无 A好友一致性巡检双向补齐,失效目标标记
回滚后新数据丢失玩家投诉进度回退回滚窗口内写入量冻结写入后再恢复,补偿公告

最后一条是合服最贵的错误。回滚不是“点一下撤销”,它会丢掉窗口内玩家产生的所有新进度。所以回滚决策要有明确阈值:例如目标服写入错误率连续 5 分钟超过 1%,才触发整库恢复;否则优先向前修复。

小结

合服要先把冲突列全,再按“ID → 引用 → 展示”的顺序处理。ID 映射必须确定性、可复算;重名要保留、可解释、有补偿;公会名按公会聚合改名。迁移窗口分段,每段明确是否可回滚;断点按角色记录,提交前源服权威,提交后目标服权威。玩家可见性不能省:时间、规则、补偿都要提前说,开服后 72 小时的回滚是整库恢复,不是撤销迁移。

常见错误有五个:只查目标服重名、不查批内重名;公会按成员逐条改名;断点按批次记录;开服后还承诺“随时可回滚”;把排行榜直接相加。前三个会让迁移在半夜失败,后两个会让一次技术操作变成信任事故。

下一章进入可用性:备份、恢复与演练。合服的快照与回滚窗口,会在那里接到更通用的故障切换流程。

阅读导航:上一节:7.2 资产归属与跨服结算 · 下一节:8.1 备份、恢复与演练 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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