《游戏服务端编程实践》6.1.3 多角色与跨服机制

明确一个角色只有一个归属服和唯一写入者,比较跨服读取用只读快照与远程调用的取舍,给出快照版本校验、跨服期间本服修改、合服 ID 重映射与重名处理的规则,并附可运行的 Go CrossReader 与 MergePlan 测试,覆盖快照过期、跨服只读与重名冲突。

一、一个角色只能有一个写入者

多角色不是“同一个玩家有多个存档”这么简单。只要角色能跨服参战、跨服交易,就必须回答:数据归谁、谁能写、写冲突怎么判。唯一正确的起点是“一个角色只属于一个区服”,归属服持有权威数据和唯一写权限,其他服最多持有带版本的只读副本。

概念含义是否可写
归属服角色创建、存档、结算所在的区服是,唯一写入者
跨服副本跨服战、跨服交易中加载的快照否
全局账号跨区稳定的账号身份只由账号服务写
合服映射旧角色 ID 到新角色 ID 的迁移表只由迁移任务写

把“角色数据”复制到跨服服并允许它写回,是后面所有一致性问题的源头。跨服玩法只需要读当前战力、装备和外观;战斗结果、奖励发放应回到归属服结算,或由跨服服提交带幂等键的结算请求。

二、只读副本还是远程调用

方式延迟数据新鲜度适用
本地只读快照最低有版本上限,可能滞后跨服战开场加载、展示
远程只读调用多一次 RPC实时交易校验、关键数值确认
消息异步同步最低,但最终一致滞后不可控排行、观战、非关键展示

跨服战适合开场拉一次快照,之后在本地跑战斗;交易和排行榜结算必须走远程校验或回到归属服,不能只信本地副本。快照必须带 version,跨服会话建立时记录所需版本,读到更旧的副本要拒绝并重新拉取。

三、跨服期间本服修改了怎么办

玩家在跨服战场里,本服可能同时发生签到、邮件领取、装备强化。处理规则要事先写死:

  1. 跨服期间,归属服仍然可以写存档,但涉及战斗属性的变更要打上“跨服中”标记。
  2. 跨服服只读快照,不把战斗中的临时状态写回归属服。
  3. 战斗结束提交结算请求,携带 player_id、快照版本和幂等键。
  4. 归属服校验版本:版本一致则应用奖励;版本落后则按业务规则合并或拒绝,并记录冲突。

不要用“跨服期间锁死角色”作为默认方案。锁角色会让玩家无法领取邮件、无法使用兑换码,体验很差;更好的做法是缩小写冲突范围,只对真正互斥的字段加版本校验。

四、合服迁移的不只是角色表

合服最常见的事故是“角色能登录,但好友全没了”。角色表只是迁移清单的第一项。

数据风险处理
角色 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。
  • 快照不带版本,跨服战读到开战前的旧属性。
  • 用全局锁保证一致,跨服高峰期把归属服拖死。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist