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

说明一个账号在多个区服拥有角色时的登录、选角与跨区迁移流程,区分区服 ID 与逻辑分片 ID,并给出角色列表服务、同区重名、迁移后旧会话失效的可运行 Go 实现与表驱动测试,同时比较三种迁移语义的一致性风险与迁移期锁定要求,并给出可执行操作清单。

一个账号在多个区服各有角色,是长线运营游戏的常态。新区开服、老区合服、渠道专服、跨服活动都会让“账号”和“角色”不再是简单的一对一。本节讨论三个动作:登录后如何拿到角色列表,选角后如何换取区服会话,跨区迁移后旧会话如何失效。

一、多角色的来源

多角色不是设计出来的功能,而是运营自然产生的结果:新区开服时玩家用同一账号去新服建角;合服时两个区的角色合并到一个逻辑区;渠道专服要求账号在渠道包内独立建角;跨服活动允许玩家在活动服临时创建角色;测试服与正式服共用账号体系。这些场景的共同点是账号身份不变,角色的归属区服变化。系统必须能回答两个问题:“这个账号在哪些区有角色”和“这个角色现在属于哪个区”。前者是角色列表,后者是路由信息。

二、账号、角色与区服的基数关系

关系基数唯一性存储位置
账号 → 角色1:N账号全局唯一账号库保存角色索引,角色库存角色数据
角色 → 区服1:1角色在某时刻只属于一个区角色记录带 region_id
账号 → 区服M:N同一账号可在多个区有角色由角色列表推导,不单独建表
区服 → 分片N:1多个区可共享一个物理分片区服目录维护映射

需要强调“角色 → 区服是 1:1”。一个角色不能同时在两个区在线,也不能把同一份存档复制到两个区各自发展。跨区迁移是移动,不是复制;跨服玩法是临时场景服或镜像,不应改变角色的归属区。角色名唯一性有三种常见范围:

范围规则优点代价
单区唯一同一 region_id 内不重名实现简单,合服前无冲突合服时需处理重名
租户内唯一同一租户所有区不重名跨区社交、排行榜体验好需要全局名字索引,写入更慢
全平台唯一所有租户不重名品牌一致,便于搜索索引最大,跨租户协调成本高

多数游戏选择单区唯一,因为角色名只在当前区被感知,跨服聊天显示“区服+角色名”即可消歧。若产品要求跨区按名字搜索,才需要租户内唯一,并接受名字索引成为写热点。

三、登录、选角、进区的三段式流程

3.1 登录只拿账号凭证

登录服只负责证明“你是账号的主人”,返回账号级凭证,不直接返回某个区的游戏会话:

客户端 -> 登录服:渠道凭证 / 密码 / 短信码
登录服 -> 账号中心:校验凭证
账号中心 -> 登录服:account_id + 账号状态
登录服 -> 客户端:account_token(短有效期)

account_token 的受众是角色列表服务,不是某个区服的网关。它不应包含 player_id,也不应绑定具体区服。这样玩家在选角阶段切换区服时,不需要重新走一遍渠道登录。

3.2 角色列表服务

客户端拿着 account_token 请求角色列表:角色列表服务校验 token 得到 account_id,按账号查询所有区的角色摘要,返回 {player_id, region_id, name, level, last_login_at}。角色列表是摘要,不是完整存档;完整背包、任务、技能数据留在区服,等选角完成后再加载。角色列表服务必须是新角色的唯一事实来源:新建角色成功后先写角色库,再让列表服务可见,最后才返回客户端。不能只在网关内存里追加记录,否则换一个网关或重启后角色就“消失”了。

3.3 选角换取区服会话

玩家选中角色后,客户端用 account_token + player_id + region_id 向区服网关换取游戏会话:网关先校验角色属于该账号且当前在该区,再创建会话并绑定 player_id 与连接,最后返回 session_token 和区服地址。这里要做三个校验:角色属于该账号,防止越权选择别人的角色;角色当前 region_id 与请求一致,防止用旧区地址登录已迁走的角色;账号状态允许登录,例如未被封禁、未注销。校验通过后网关才建立长连接。session_token 的受众是当前区服,不能拿去访问其他区,也不能调用账号级接口。

四、区服目录(Region Catalog)

区服目录维护“逻辑区”与“物理位置”的映射,至少包含 region_id、shard_id、endpoint、status、name_scope。其中 region_id 是玩家可见的区服标识,shard_id 是逻辑分片标识。

维度region_idshard_id
面向对象玩家、运营、客服运维、存储、调度
是否出现在 UI是否
生命周期可合服、可改名随物理部署变化
唯一性全平台唯一集群内唯一
变更影响玩家感知强,需公告对玩家透明,可滚动迁移

一个 shard 可以承载多个 region,两个低活跃区合并在同一组数据库上;一个 region 在扩容时也可能从一个 shard 迁到另一个。如果代码把 region_id 当分片键直接取模,扩容或合服时就会大规模错路由。正确做法是让目录服务返回当前映射,业务只认 region_id。角色名索引的键通常由“范围 + 名字”组成:单区唯一用 tenant_id + region_id + lower(name),租户内唯一用 tenant_id + lower(name),全平台唯一用 lower(name)。比较前要统一去空格、大小写和全角半角。合服重名处理(加后缀、强制改名、保留活跃角色)也要提前设计。

五、跨区迁移的三种语义

语义迁移内容一致性风险适用场景
只迁角色档案等级、任务、技能等基础档案资产留在原区,玩家可能认为“东西丢了”分区调整、低价值角色
迁角色 + 部分资产档案 + 背包 + 货币,邮件和拍卖行另算跨库事务难,容易出现资产双花合服前的定向迁移
合服多个区的角色、社交、排行榜合并重名、ID 冲突、排行榜重算、邮件归属长线运营的常规操作

三种语义的共同要求是:迁移期间角色必须锁定,不能同时在源区和目标区登录。流程是:标记 migrating 并拒绝新的选角;等待当前会话断开或主动踢下线;复制或移动数据并校验条数与校验和;更新角色索引中的 region_id;标记 active 并允许在新选区角;保留源区只读快照一段时间用于回滚。只改数据库不改会话,是迁移事故的高发点。玩家在迁移前已经建立的连接仍持有旧的 player_id 和旧区上下文,继续写入会覆盖新区的数据。迁移完成后必须让旧会话失效,最可靠的方式是提升会话版本号或直接删除会话记录。

六、Go 最小实现

下面的 roster.go 实现了一个内存角色列表服务:支持创建角色、列出角色、选角换取区服会话、跨区迁移。它把“角色归属区服”“同区重名”“迁移后旧会话失效”写成显式规则。

// file: ch04/identity/roster.go
// tier: L1
// verify: go test ./ch04/identity/

package identity

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

type RegionID string
type ShardID string

// NameScope decides where a character name must be unique.
type NameScope int

const (
	NamePerRegion NameScope = iota
	NamePerTenant
)

// Region is a public zone. Shard is where the zone currently runs; several
// regions may share one shard, so neither value can replace the other.
type Region struct {
	ID        RegionID
	Shard     ShardID
	NameScope NameScope
}

type Character struct {
	PlayerRef
	Level     int
	UpdatedAt time.Time
}

// Selection is the region-scoped session obtained after choosing a character.
type Selection struct {
	AccountID AccountID
	PlayerID  PlayerID
	Region    RegionID
	Shard     ShardID
	Epoch     uint64
}

var (
	ErrRegionNotFound = errors.New("identity: region not found")
	ErrCharacterLimit = errors.New("identity: character limit reached")
	ErrNameInvalid    = errors.New("identity: character name is empty")
	ErrNameTaken      = errors.New("identity: character name already taken")
	ErrNotOwner       = errors.New("identity: character belongs to another account")
	ErrWrongRegion    = errors.New("identity: character is not in this region")
	ErrStaleSession   = errors.New("identity: selection session is stale")
)

// Roster keeps the account-to-character relationship. Character data stays
// with the region; this service only owns the roster projection.
type Roster struct {
	maxPerAccount int
	regions       map[RegionID]Region
	characters    map[PlayerID]*Character
	byAccount     map[AccountID][]PlayerID
	names         map[string]PlayerID
	sessions      map[AccountID]Selection
	nextPlayer    PlayerID
	epoch         uint64
}

func NewRoster(maxPerAccount int) *Roster {
	if maxPerAccount <= 0 {
		maxPerAccount = 4
	}
	return &Roster{
		maxPerAccount: maxPerAccount,
		regions:       make(map[RegionID]Region),
		characters:    make(map[PlayerID]*Character),
		byAccount:     make(map[AccountID][]PlayerID),
		names:         make(map[string]PlayerID),
		sessions:      make(map[AccountID]Selection),
	}
}

// AddRegion registers a public region and its current placement.
func (r *Roster) AddRegion(region Region) {
	r.regions[region.ID] = region
}

func (r *Roster) nameKey(region Region, tenant TenantID, name string) string {
	normalized := strings.ToLower(strings.TrimSpace(name))
	if region.NameScope == NamePerTenant {
		return string(tenant) + "|tenant|" + normalized
	}
	return string(tenant) + "|region|" + string(region.ID) + "|" + normalized
}

// Create adds a character in one region. A character belongs to exactly one
// region at a time.
func (r *Roster) Create(accountID AccountID, tenant TenantID, regionID RegionID, name string, now time.Time) (Character, error) {
	region, ok := r.regions[regionID]
	if !ok {
		return Character{}, ErrRegionNotFound
	}
	if strings.TrimSpace(name) == "" {
		return Character{}, ErrNameInvalid
	}
	if len(r.byAccount[accountID]) >= r.maxPerAccount {
		return Character{}, ErrCharacterLimit
	}
	key := r.nameKey(region, tenant, name)
	if _, exists := r.names[key]; exists {
		return Character{}, ErrNameTaken
	}

	r.nextPlayer++
	ch := &Character{
		PlayerRef: PlayerRef{
			ID:        r.nextPlayer,
			AccountID: accountID,
			Tenant:    tenant,
			Region:    string(regionID),
			Name:      strings.TrimSpace(name),
		},
		Level:     1,
		UpdatedAt: now.UTC(),
	}
	r.characters[ch.ID] = ch
	r.byAccount[accountID] = append(r.byAccount[accountID], ch.ID)
	r.names[key] = ch.ID
	return *ch, nil
}

// List returns the account's characters in stable player-ID order.
func (r *Roster) List(accountID AccountID) []Character {
	ids := append([]PlayerID(nil), r.byAccount[accountID]...)
	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })

	out := make([]Character, 0, len(ids))
	for _, id := range ids {
		if ch, ok := r.characters[id]; ok {
			out = append(out, *ch)
		}
	}
	return out
}

// Select exchanges one character for a region-scoped session. Selecting the
// same account again invalidates the previous selection.
func (r *Roster) Select(accountID AccountID, playerID PlayerID, regionID RegionID) (Selection, error) {
	ch, ok := r.characters[playerID]
	if !ok {
		return Selection{}, ErrNotOwner
	}
	if ch.AccountID != accountID {
		return Selection{}, ErrNotOwner
	}
	if ch.Region != string(regionID) {
		return Selection{}, ErrWrongRegion
	}
	region := r.regions[regionID]
	r.epoch++
	sel := Selection{
		AccountID: accountID,
		PlayerID:  playerID,
		Region:    regionID,
		Shard:     region.Shard,
		Epoch:     r.epoch,
	}
	r.sessions[accountID] = sel
	return sel, nil
}

// Validate checks that a selection still points at the live character and has
// not been invalidated by another selection or a transfer.
func (r *Roster) Validate(sel Selection) error {
	cur, ok := r.sessions[sel.AccountID]
	if !ok || cur.Epoch != sel.Epoch || cur.PlayerID != sel.PlayerID {
		return ErrStaleSession
	}
	ch, ok := r.characters[sel.PlayerID]
	if !ok || ch.Region != string(sel.Region) {
		return ErrStaleSession
	}
	return nil
}

// Transfer moves one character profile to another region. It never moves the
// account's other characters and it invalidates the account's selection.
func (r *Roster) Transfer(accountID AccountID, playerID PlayerID, target RegionID, now time.Time) error {
	ch, ok := r.characters[playerID]
	if !ok {
		return ErrNotOwner
	}
	if ch.AccountID != accountID {
		return ErrNotOwner
	}
	toRegion, ok := r.regions[target]
	if !ok {
		return ErrRegionNotFound
	}
	if ch.Region == string(target) {
		return nil
	}

	fromRegion := r.regions[RegionID(ch.Region)]
	oldKey := r.nameKey(fromRegion, ch.Tenant, ch.Name)
	newKey := r.nameKey(toRegion, ch.Tenant, ch.Name)
	if owner, exists := r.names[newKey]; exists && owner != ch.ID {
		return ErrNameTaken
	}

	delete(r.names, oldKey)
	ch.Region = string(target)
	ch.UpdatedAt = now.UTC()
	r.names[newKey] = ch.ID
	delete(r.sessions, accountID)
	return nil
}

实现中有四个关键点:Roster 只保存角色摘要,不保存完整存档;names 索引的键由 nameKey 统一生成,单区唯一和租户内唯一只差一个分支;Select 每次递增 epoch,同一账号重新选角会让旧选择失效;Transfer 在修改任何状态之前先检查目标区重名,失败时角色留在原区。

七、测试覆盖

roster_test.go 覆盖了多角色与跨区最容易出错的路径:

场景测试用例期望
未知区服创建TestCreateEnforcesLimitAndRegionErrRegionNotFound
空角色名 / 角色上限TestCreateEnforcesLimitAndRegionErrNameInvalid / ErrCharacterLimit
同区重名(忽略大小写)TestNameUniquenessScopeErrNameTaken
不同区可同名TestNameUniquenessScope创建成功
租户内唯一TestNameUniquenessScope跨区同名被拒绝
迁移后旧区不可选TestSelectThenTransferInvalidatesOldRegionErrWrongRegion
迁移后旧会话失效TestSelectThenTransferInvalidatesOldRegionErrStaleSession
迁移重名回滚TestTransferNameConflictKeepsOldRegion角色仍在原区

这些测试都从空 Roster 开始,不共享状态。跨区迁移涉及“源区、目标区、会话”三方,如果测试之间复用数据,失败很难定位。

八、失败场景分析:网关本地缓存导致新角色看不到

某游戏在网关进程里缓存“账号 → 角色列表”,TTL 设为 10 分钟。上线新角色创建功能后,玩家创建角色成功,但返回选角界面仍看不到新角色;换一个网关重新登录又正常。

排查发现三个问题叠加:创建接口只删除了当前网关的缓存,没有通知其他网关;缓存键是 account_id,删除时却用了 account_id + region_id;部分网关把空列表也缓存了,导致“第一次查询没有角色”被记住十分钟。

修复方案不是把 TTL 调短,而是明确缓存职责:角色列表服务是唯一事实来源,写路径“先写库,再删除缓存”,删除失败要重试;网关不缓存角色列表,只缓存静态区服目录;缓存键统一由同一个函数生成;创建角色成功后返回新角色摘要,客户端立即更新本地列表。这个案例的教训是:角色列表是读多写少但写后必须立即可见的数据,不适合放在实例最多、最难失效的网关层。

九、迁移操作清单

  • 角色已标记 migrating,源区和目标区都拒绝新的选角;在线会话已断开或会话版本已失效;
  • 目标区角色名索引可用,重名策略已确定;存档复制完成且条数、校验和一致;
  • 角色索引中的 region_id 已更新,旧索引已删除;源区保留只读快照并公告回滚窗口;
  • 客服后台能按 player_id 查到迁移前后的归属记录。

账号绑定与安全策略会影响迁移期间的登录校验,见 /game-server-in-action-413/。身份四层模型的基础概念在 /game-server-in-action-411/。

小结

多角色与跨区的核心不是“多建一张表”,而是把登录、选角、进区拆成三个独立阶段:登录只证明账号身份,角色列表回答“有哪些角色”,选角才换取区服会话。角色与区服是 1:1,跨区迁移是移动而非复制;region_id 面向玩家,shard_id 面向运维,两者不能互相替代。迁移完成后必须让旧会话失效,否则旧连接会继续写入,造成数据覆盖。

常见错误与边界

错误一:角色列表缓存在网关本地。 网关实例多、失效难,创建角色后其他网关看不到。角色列表应由独立服务提供,网关只做转发。

错误二:跨区迁移只改数据库不改会话。 旧连接仍持有旧区上下文,继续写入会覆盖新区数据。迁移必须踢下线或提升会话版本。

错误三:合服后角色重名没有策略。 合服前要统计冲突、确定改名规则、通知玩家。上线后再补救,客服成本远高于提前处理。

错误四:用 region_id 直接做分片取模。 合服或扩容后映射变化,大量请求会错路由。分片应走目录服务,业务只认 region_id。

边界一:一个账号在同一区能有多个角色吗? 可以,但上限要明确。本文示例默认上限为 4,产品可以调整。上限影响角色列表 UI、名字索引和迁移批量操作。

边界二:合服后角色 ID 会变吗? 理想情况下不变。若必须重排,要保留旧 ID 到新 ID 的映射,并确保好友、邮件、排行榜中的引用同步更新。直接复用旧 ID 给新角色,会造成历史引用错乱。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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