一个账号在多个区服各有角色,是长线运营游戏的常态。新区开服、老区合服、渠道专服、跨服活动都会让“账号”和“角色”不再是简单的一对一。本节讨论三个动作:登录后如何拿到角色列表,选角后如何换取区服会话,跨区迁移后旧会话如何失效。
一、多角色的来源
多角色不是设计出来的功能,而是运营自然产生的结果:新区开服时玩家用同一账号去新服建角;合服时两个区的角色合并到一个逻辑区;渠道专服要求账号在渠道包内独立建角;跨服活动允许玩家在活动服临时创建角色;测试服与正式服共用账号体系。这些场景的共同点是账号身份不变,角色的归属区服变化。系统必须能回答两个问题:“这个账号在哪些区有角色”和“这个角色现在属于哪个区”。前者是角色列表,后者是路由信息。
二、账号、角色与区服的基数关系
| 关系 | 基数 | 唯一性 | 存储位置 |
|---|---|---|---|
| 账号 → 角色 | 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_id | shard_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 覆盖了多角色与跨区最容易出错的路径:
| 场景 | 测试用例 | 期望 |
|---|---|---|
| 未知区服创建 | TestCreateEnforcesLimitAndRegion | ErrRegionNotFound |
| 空角色名 / 角色上限 | TestCreateEnforcesLimitAndRegion | ErrNameInvalid / ErrCharacterLimit |
| 同区重名(忽略大小写) | TestNameUniquenessScope | ErrNameTaken |
| 不同区可同名 | TestNameUniquenessScope | 创建成功 |
| 租户内唯一 | TestNameUniquenessScope | 跨区同名被拒绝 |
| 迁移后旧区不可选 | TestSelectThenTransferInvalidatesOldRegion | ErrWrongRegion |
| 迁移后旧会话失效 | TestSelectThenTransferInvalidatesOldRegion | ErrStaleSession |
| 迁移重名回滚 | 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 给新角色,会造成历史引用错乱。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。