合服公告发出后的第一个小时
《群岛纪元》准备把 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 一致性哈希迁移 里,本节不重复。
名字冲突的处理要满足三个条件:
- 不丢名字:每个角色最终都有唯一名字。
- 可解释:后缀规则固定,客服能一句话说明。
- 有补偿:被改名的玩家收到改名卡,而不是只看到名字变了。
北辰用 原名-旧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 备份、恢复与演练 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。