一、一个角色只能有一个写入者
多角色不是“同一个玩家有多个存档”这么简单。只要角色能跨服参战、跨服交易,就必须回答:数据归谁、谁能写、写冲突怎么判。唯一正确的起点是“一个角色只属于一个区服”,归属服持有权威数据和唯一写权限,其他服最多持有带版本的只读副本。
| 概念 | 含义 | 是否可写 |
|---|---|---|
| 归属服 | 角色创建、存档、结算所在的区服 | 是,唯一写入者 |
| 跨服副本 | 跨服战、跨服交易中加载的快照 | 否 |
| 全局账号 | 跨区稳定的账号身份 | 只由账号服务写 |
| 合服映射 | 旧角色 ID 到新角色 ID 的迁移表 | 只由迁移任务写 |
把“角色数据”复制到跨服服并允许它写回,是后面所有一致性问题的源头。跨服玩法只需要读当前战力、装备和外观;战斗结果、奖励发放应回到归属服结算,或由跨服服提交带幂等键的结算请求。
二、只读副本还是远程调用
| 方式 | 延迟 | 数据新鲜度 | 适用 |
|---|---|---|---|
| 本地只读快照 | 最低 | 有版本上限,可能滞后 | 跨服战开场加载、展示 |
| 远程只读调用 | 多一次 RPC | 实时 | 交易校验、关键数值确认 |
| 消息异步同步 | 最低,但最终一致 | 滞后不可控 | 排行、观战、非关键展示 |
跨服战适合开场拉一次快照,之后在本地跑战斗;交易和排行榜结算必须走远程校验或回到归属服,不能只信本地副本。快照必须带 version,跨服会话建立时记录所需版本,读到更旧的副本要拒绝并重新拉取。
三、跨服期间本服修改了怎么办
玩家在跨服战场里,本服可能同时发生签到、邮件领取、装备强化。处理规则要事先写死:
- 跨服期间,归属服仍然可以写存档,但涉及战斗属性的变更要打上“跨服中”标记。
- 跨服服只读快照,不把战斗中的临时状态写回归属服。
- 战斗结束提交结算请求,携带
player_id、快照版本和幂等键。 - 归属服校验版本:版本一致则应用奖励;版本落后则按业务规则合并或拒绝,并记录冲突。
不要用“跨服期间锁死角色”作为默认方案。锁角色会让玩家无法领取邮件、无法使用兑换码,体验很差;更好的做法是缩小写冲突范围,只对真正互斥的字段加版本校验。
四、合服迁移的不只是角色表
合服最常见的事故是“角色能登录,但好友全没了”。角色表只是迁移清单的第一项。
| 数据 | 风险 | 处理 |
|---|---|---|
| 角色 ID | 两服自增 ID 撞车 | 生成全局新 ID,保留旧 ID 映射 |
| 角色名 | 两服重名 | 先到先得,后来者加后缀并发放改名卡 |
| 排行榜 | 旧榜数据指向旧 ID | 按新 ID 重建,保留历史快照 |
| 邮件 | 附件归属旧角色 | 按映射改写收件人,过期邮件单独归档 |
| 好友/黑名单 | 双向关系只迁一边 | 双向改写,去重并保留拉黑原因 |
| 公会 | 公会 ID 与成员关系交叉 | 先迁公会,再迁成员,最后重建职位 |
迁移必须可重入:每一步记录进度,失败后从断点继续,而不是整批回滚重来。ID 映射表要在迁移前生成并冻结,迁移中和迁移后都不允许再分配同一个新 ID。
五、Go 实现
CrossReader 只接受不低于会话所需版本的快照;MergePlan 在复制数据前显式登记旧 ID、新 ID 和重名结果,冲突直接拒绝。
// file: ch06/model/cross.go
// tier: L1
// verify: go test ./ch06/model/
package model
import (
"errors"
"fmt"
)
var (
ErrSnapshotNotFound = errors.New("cross: snapshot not found")
ErrStaleSnapshot = errors.New("cross: snapshot version is stale")
ErrUnknownPlayer = errors.New("cross: player is not in the merge plan")
ErrMergeConflict = errors.New("cross: merge mapping conflict")
)
// CharacterSnapshot 是归属服给出的只读副本;跨服写操作必须回源服执行。
type CharacterSnapshot struct {
PlayerID uint64
AccountID uint64
Region string
Name string
Level uint32
Version uint64
}
// SnapshotSource 由归属服的只读接口实现;跨服不直连对方数据库。
type SnapshotSource interface {
Snapshot(playerID uint64) (CharacterSnapshot, error)
}
type CrossReader struct {
source SnapshotSource
requiredVer uint64
}
func NewCrossReader(source SnapshotSource, requiredVersion uint64) *CrossReader {
return &CrossReader{source: source, requiredVer: requiredVersion}
}
// Read 要求快照版本不低于跨服会话建立时的版本,防止读到开战前的旧数据。
func (r *CrossReader) Read(playerID uint64) (CharacterSnapshot, error) {
if r == nil || r.source == nil {
return CharacterSnapshot{}, ErrSnapshotNotFound
}
snap, err := r.source.Snapshot(playerID)
if err != nil {
return CharacterSnapshot{}, err
}
if snap.Version < r.requiredVer {
return CharacterSnapshot{}, fmt.Errorf("%w: got %d, need %d", ErrStaleSnapshot, snap.Version, r.requiredVer)
}
return snap, nil
}
// MemorySnapshots 是测试用只读源;Put 按值复制,读方改不到源数据。
type MemorySnapshots struct {
data map[uint64]CharacterSnapshot
}
func NewMemorySnapshots() *MemorySnapshots {
return &MemorySnapshots{data: make(map[uint64]CharacterSnapshot)}
}
func (m *MemorySnapshots) Put(s CharacterSnapshot) { m.data[s.PlayerID] = s }
func (m *MemorySnapshots) Snapshot(playerID uint64) (CharacterSnapshot, error) {
s, ok := m.data[playerID]
if !ok {
return CharacterSnapshot{}, ErrSnapshotNotFound
}
return s, nil
}
// MergePlan 在复制数据前显式记录 ID 与重名映射,迁移可审计、可重试。
type MergePlan struct {
fromRegion string
toRegion string
ids map[uint64]uint64
names map[uint64]string
usedNames map[string]uint64
}
func NewMergePlan(fromRegion, toRegion string) *MergePlan {
return &MergePlan{
fromRegion: fromRegion, toRegion: toRegion,
ids: make(map[uint64]uint64), names: make(map[uint64]string),
usedNames: make(map[string]uint64),
}
}
func (p *MergePlan) AddPlayer(oldID, newID uint64, name string) error {
if oldID == 0 || newID == 0 {
return ErrMergeConflict
}
if _, exists := p.ids[oldID]; exists {
return ErrMergeConflict
}
for _, mapped := range p.ids {
if mapped == newID {
return ErrMergeConflict
}
}
if name == "" {
name = fmt.Sprintf("player-%d", newID)
}
if _, taken := p.usedNames[name]; taken {
name = fmt.Sprintf("%s#%d", name, newID)
}
p.ids[oldID], p.names[oldID], p.usedNames[name] = newID, name, newID
return nil
}
func (p *MergePlan) Remap(oldID uint64) (uint64, error) {
newID, ok := p.ids[oldID]
if !ok {
return 0, fmt.Errorf("%w: %d", ErrUnknownPlayer, oldID)
}
return newID, nil
}
func (p *MergePlan) RemapName(oldID uint64) (string, error) {
name, ok := p.names[oldID]
if !ok {
return "", fmt.Errorf("%w: %d", ErrUnknownPlayer, oldID)
}
return name, nil
}
func (p *MergePlan) RemapSnapshot(s CharacterSnapshot) (CharacterSnapshot, error) {
if s.Region != p.fromRegion {
return CharacterSnapshot{}, fmt.Errorf("%w: region %q", ErrUnknownPlayer, s.Region)
}
newID, err := p.Remap(s.PlayerID)
if err != nil {
return CharacterSnapshot{}, err
}
name, err := p.RemapName(s.PlayerID)
if err != nil {
return CharacterSnapshot{}, err
}
s.PlayerID, s.Region, s.Name = newID, p.toRegion, name
return s, nil
}
RemapSnapshot 先校验来源区服,再改写 ID、区服和名称,版本保持不变。版本是快照新鲜度的依据,不能在迁移时重置,否则跨服端无法判断数据是否过期。
六、测试
测试覆盖版本相等可通过、版本落后被拒绝、快照是只读副本、ID 与重名映射、重复映射冲突,以及跨区服快照被拒绝。
// file: ch06/model/cross_test.go
// tier: L1
// verify: go test ./ch06/model/
package model
import (
"errors"
"testing"
)
func TestCrossReaderRejectsStaleSnapshot(t *testing.T) {
source := NewMemorySnapshots()
source.Put(CharacterSnapshot{PlayerID: 101, AccountID: 7, Region: "s1", Name: "旅人", Level: 30, Version: 7})
if snap, err := NewCrossReader(source, 7).Read(101); err != nil || snap.Version != 7 {
t.Fatalf("equal version should pass: %+v, %v", snap, err)
}
if _, err := NewCrossReader(source, 8).Read(101); !errors.Is(err, ErrStaleSnapshot) {
t.Fatalf("stale read error=%v, want ErrStaleSnapshot", err)
}
if _, err := NewCrossReader(source, 7).Read(999); !errors.Is(err, ErrSnapshotNotFound) {
t.Fatalf("missing player error=%v, want ErrSnapshotNotFound", err)
}
}
func TestCrossReaderReturnsReadOnlyCopy(t *testing.T) {
source := NewMemorySnapshots()
source.Put(CharacterSnapshot{PlayerID: 101, Region: "s1", Name: "旅人", Version: 3})
reader := NewCrossReader(source, 3)
snap, err := reader.Read(101)
if err != nil {
t.Fatal(err)
}
snap.Level, snap.Name = 99, "被跨服改坏"
again, _ := reader.Read(101)
if again.Level != 0 || again.Name != "旅人" {
t.Fatalf("source mutated through snapshot: %+v", again)
}
}
func TestMergePlanRemapsIDsAndNames(t *testing.T) {
plan := NewMergePlan("s1", "s2")
if err := plan.AddPlayer(101, 9001, "旅人"); err != nil {
t.Fatal(err)
}
if err := plan.AddPlayer(102, 9002, "旅人"); err != nil {
t.Fatal(err)
}
if got, err := plan.Remap(101); err != nil || got != 9001 {
t.Fatalf("Remap(101) = %d, %v", got, err)
}
if got, err := plan.RemapName(102); err != nil || got != "旅人#9002" {
t.Fatalf("colliding name = %q, %v", got, err)
}
if _, err := plan.Remap(999); !errors.Is(err, ErrUnknownPlayer) {
t.Fatalf("unknown id error=%v, want ErrUnknownPlayer", err)
}
}
func TestMergePlanRejectsDuplicateMappings(t *testing.T) {
plan := NewMergePlan("s1", "s2")
if err := plan.AddPlayer(101, 9001, "a"); err != nil {
t.Fatal(err)
}
if err := plan.AddPlayer(101, 9003, "b"); !errors.Is(err, ErrMergeConflict) {
t.Fatalf("duplicate old id error=%v", err)
}
if err := plan.AddPlayer(103, 9001, "c"); !errors.Is(err, ErrMergeConflict) {
t.Fatalf("duplicate new id error=%v", err)
}
}
func TestMergePlanRemapsSnapshot(t *testing.T) {
plan := NewMergePlan("s1", "s2")
if err := plan.AddPlayer(101, 9001, "旅人"); err != nil {
t.Fatal(err)
}
out, err := plan.RemapSnapshot(CharacterSnapshot{PlayerID: 101, Region: "s1", Level: 30, Version: 4})
if err != nil {
t.Fatal(err)
}
if out.PlayerID != 9001 || out.Region != "s2" || out.Version != 4 {
t.Fatalf("remapped snapshot=%+v", out)
}
if _, err := plan.RemapSnapshot(CharacterSnapshot{PlayerID: 101, Region: "s9"}); !errors.Is(err, ErrUnknownPlayer) {
t.Fatalf("wrong region error=%v", err)
}
}
七、运行验证
cd .local/checks/gs-practice-lab && gofmt -l ch06/model && go vet ./ch06/model/ && go test ./ch06/model/ -count=1
ok gspractice/ch06/model 0.259s
八、失败场景:两个服同时写同一角色
跨服战期间,玩家在战场服强化了装备,战场服把整行角色数据写回;同一时刻归属服处理邮件附件,也写了整行。后写入的一方覆盖了对方,强化等级或邮件附件凭空消失。根因不是数据库坏了,而是存在两个写入者。修复要收回写权限:战场服只提交“强化请求”,由归属服按版本号串行执行;所有写路径都必须带 expected_version,冲突时重试或拒绝。只要还有第二条写路径绕开版本校验,事故就会以别的形式回来。
小结
多角色与跨服的第一原则是数据所有权唯一:归属服写,其他服读。跨服读取用带版本的只读快照,关键操作回源校验;跨服期间的修改要缩小冲突范围,用版本号而不是全局锁。合服要迁移角色、名称、排行、邮件、好友和公会,并保证映射可重入、可审计。
常见错误与边界
- 两个服同时写同一角色,后写入的整行覆盖先写入的字段。
- 跨服服直接连对方数据库,绕过归属服的校验和审计。
- 合服只改角色表,好友、邮件、排行榜仍指向旧 ID。
- 快照不带版本,跨服战读到开战前的旧属性。
- 用全局锁保证一致,跨服高峰期把归属服拖死。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。