账号系统常被当成“注册登录”的同义词。真正进入项目后会发现,登录只是入口,后面还跟着角色选择、区服路由、渠道归因、设备风控和封禁申诉。把这些关系塞进一张 user 表,前期开发很快,后期每加一个区服或渠道都要改表、改查询、改缓存。本节先把身份拆成四层,再讨论主键与设备绑定。
一、为什么不能只有一张 user 表
一张最朴素的账号表通常包含 id、username、password_hash、server_id、nickname、device_id。它在一个区、一个渠道、一个角色时能跑,但下面几个场景会同时出现:
- 玩家在 A 区有角色,又去 B 区开新角色,
server_id只能填一个; - 同一账号既能在 iOS 登录,也能在模拟器登录,
device_id只能存一个; - 渠道要求账号与 openid 一一对应,而 openid 会随渠道包变化;
- 发行商把游戏转交给另一个租户,需要迁移账号,但角色数据留在原区;
- 玩家换手机后要求解绑旧设备,结果发现
device_id同时承担了登录凭证。
这些需求指向同一个结论:账号、角色、租户、设备是四种生命周期不同的实体,不能用一行记录表达。拆开不是过度设计,而是承认它们的变化频率和唯一性范围不同。
| 实体 | 一句话定义 | 唯一性范围 | 典型生命周期 |
|---|---|---|---|
| 账号 Account | 玩家在发行体系中的身份 | 全局或租户内 | 数年,跨游戏、跨区稳定 |
| 角色 Player | 某个区服中的游戏进度载体 | 区服内 | 数月到数年,可迁移 |
| 租户 Tenant | 发行商、渠道或大区边界 | 平台级 | 长期,决定数据归属 |
| 设备 Device | 一次安装产生的实例 | 安装级 | 数天到数月,频繁更换 |
判断一个字段该不该和账号放在一起,先问它“换区之后还成立吗”。换区后仍然成立的是账号属性,换区后要重算的是角色属性。
二、四层身份模型
2.1 账号
账号回答“这是谁”,应跨游戏、跨区稳定,通常由登录服或统一账号中心签发。账号记录只保存身份本身和登录所需的最小信息:账号 ID、租户、创建时间、状态、密码摘要或第三方绑定索引。昵称、等级、背包都不属于账号。
账号 ID 必须由服务端生成,不能直接使用渠道 openid、手机号或邮箱。外部标识会变、会复用、会泄露,而且不同渠道的 openid 命名空间互不相同。正确做法是建立映射表 (provider, external_id) -> account_id,让账号 ID 成为系统内部唯一的稳定引用。
2.2 角色
角色回答“在这个世界里是谁”,绑定区服,拥有等级、位置、背包、任务等游戏数据。一个账号在同一区服可以有一个或多个角色,上限由产品决定。
角色 ID 只需在区服内唯一,不需要全局唯一。这样每个区服可以独立分片、独立合服、独立扩容,不会因为全局自增序列成为写瓶颈。角色名的唯一性范围则要单独讨论:单区唯一、租户内唯一还是全平台唯一,取决于是否支持跨服社交和同名检索。
2.3 租户
租户回答“这份数据归谁运营”,可能是发行商、渠道大区、平台账号体系,也可能是合规要求下的数据隔离单元。租户不是区服:一个租户可以运营多个区服,一个区服也可能在迁移后更换租户。租户边界至少影响账号唯一性(同一手机号能否在两个租户各注册一个账号)、数据可见性(客服能否跨租户查询角色)和计费分成(充值订单归属哪个租户)。如果系统早期没有显式租户字段,后期补租户往往要全表回填,并处理“同一 openid 在两个渠道包里指向同一账号”的历史脏数据。
2.4 设备
设备回答“这次安装在哪里”,通常由客户端生成安装 ID,再附带平台、机型、系统版本、屏幕参数等指纹信息。设备是最弱的一层身份:卸载重装会变,系统升级可能变,模拟器可以批量伪造。
设备的价值在于风控和体验,而不是认证:
- 风控:判断异地登录、批量注册、工作室多开;
- 体验:记住上次登录方式、推送离线消息、快速切换账号;
- 客服:定位“换手机后登不上”的具体安装实例。
把设备当登录凭证,等于把“我知道这台设备”当成“我是账号主人”。攻击者拿到设备指纹或安装 ID 后即可冒用,玩家换机时又会被锁在门外。
三、账号与角色的主键策略
3.1 全局 account_id 与区服内 player_id 分离
推荐的最小模型是:account_id 全局唯一,由登录服签发,永不复用;player_id 区服内唯一,由该区服的分配器签发。两者通过角色表关联:
player(player_id PK, account_id, region_id, name, level, ...)
查询“我的角色列表”时,按 account_id 找到该账号在所有区服的角色摘要;查询“这个角色的存档”时,直接按 player_id 路由到对应区服。两条路径各走各的索引,不需要把角色数据复制到账号库。
3.2 为什么不能用自增 player_id 当账号 ID
常见错误是“账号表的主键就是第一次创建的角色 ID”。这会导致换区即换号,玩家去新区建角后充值记录、好友关系、封禁状态全部断裂;删除角色即删账号,而角色可以重建、账号不应消失;ID 空间被区服绑架,合服或回档时角色 ID 可能重排;安全边界也会混淆,角色 ID 出现在战斗日志、排行榜、聊天消息里,暴露越广越不适合作为长期身份主键。
账号 ID 与角色 ID 必须是两个独立的分配序列。即使某个账号的第一个角色恰好也叫 1,也不能让两个 ID 在业务上互相替代。
3.3 ID 类型选择
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
uint64 自增 | 紧凑、索引快、易比较 | 依赖中心分配器,需防预测 | 单平台、内部账号中心 |
字符串(如 acc_7f3a...) | 可带前缀、易排障、跨系统安全 | 占用空间大,索引略慢 | 多租户、对外 API |
| UUID / ULID | 去中心化、几乎不冲突 | 可读性差,随机 UUID 索引局部性差 | 跨国多活、离线签发 |
游戏后端常见组合是“内部 uint64 + 对外字符串编码”。内部用整数做外键和分片键,对外暴露时加前缀或做 Base62 编码,避免玩家从 ID 推测注册规模和在线人数。无论选哪种,都要明确:ID 一旦签发就不复用,删除账号只改状态,不回收 ID。
四、租户边界如何落到代码
租户字段不能只存在于配置里,必须进入数据模型和查询条件。最小要求是:
- 账号表带
tenant_id,并在(tenant_id, external_provider, external_id)上建唯一索引; - 角色表带
tenant_id,即使角色已经通过区服间接归属租户,也要冗余一份用于审计; - 所有管理后台查询默认带租户过滤,跨租户查询必须走单独的授权接口;
- 设备绑定校验账号与设备属于同一租户,拒绝跨租户拼接。
跨租户绑定是典型的高危操作。攻击者可能用一个租户的合法账号去绑定另一个租户的设备,借此把脏数据或风控标记带过去。正确做法不是“先绑定再校验”,而是在写入前同时读取账号与设备的租户字段,不一致直接拒绝。
五、设备与账号的关系
5.1 设备指纹只是弱身份
设备指纹由多个不稳定信号拼成:安装 ID、IDFV、Android ID、机型、系统版本、时区、字体列表等。它能提高“同一台设备”的判断概率,但不能证明“同一个人”。
因此设备数据只应存哈希,不应存明文原始值。原因有三点:原始指纹可能包含硬件序列号等个人可识别信息,泄露后合规风险高;数据库导出、日志打印、客服截图都可能把明文带出去;哈希后仍可用于比对和风控,不影响主要用途。
本文示例使用带域分隔的 SHA-256:sha256("gsp/device/v1\x00" + raw)。域分隔避免同一个原始值在其他业务中被彩虹表命中。生产环境还应加盐或使用 HMAC,密钥与数据库分离。
5.2 换机、模拟器、多开
| 场景 | 现象 | 处理策略 |
|---|---|---|
| 换机 | 新安装 ID,旧设备不再活跃 | 允许账号绑定多个设备,旧设备可解绑 |
| 重装 | 安装 ID 变化,指纹部分相似 | 不依赖安装 ID 做登录,要求重新认证 |
| 模拟器 | 机型参数异常、传感器缺失 | 标记风险,限制注册与交易,不直接封号 |
| 多开 | 同一设备多个安装实例 | 按设备指纹聚合,限制单设备账号数 |
| 关键原则是:设备可以“多对一”绑定到账号,但一个设备实例在同一时刻只能属于一个账号。解绑设备只影响登录便利性和风控画像,不应删除角色,也不应改变账号状态。 |
六、Go 最小实现
下面的 model.go 把四层身份压缩成可测试的最小模型。它不连接数据库,重点展示不变量:账号可绑定多个设备、设备不能跨账号移动、跨租户绑定被拒绝、解绑不影响角色。
// file: ch04/identity/model.go
// tier: L1
// verify: go test ./ch04/identity/
package identity
import (
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"time"
)
// AccountID identifies an account across tenants and regions.
type AccountID uint64
// PlayerID identifies a character inside one region. It is never used as an
// account ID: the two ID spaces are allocated independently.
type PlayerID uint64
// TenantID is a publisher, channel or operating boundary.
type TenantID string
// DeviceID is an installation instance, not a person.
type DeviceID string
var (
ErrAccountNotFound = errors.New("identity: account not found")
ErrDeviceBound = errors.New("identity: device already bound to another account")
ErrDeviceNotFound = errors.New("identity: device not bound to account")
ErrTenantMismatch = errors.New("identity: account and device belong to different tenants")
)
// Device is a weak identity. FingerprintHash is a one-way hash; the raw
// fingerprint must never be persisted or used as a login credential.
type Device struct {
ID DeviceID
Tenant TenantID
Platform string
FingerprintHash string
BoundAt time.Time
LastSeenAt time.Time
}
// PlayerRef is the identity-layer view of a character. Level, inventory and
// position stay in the region that owns the character.
type PlayerRef struct {
ID PlayerID
AccountID AccountID
Tenant TenantID
Region string
Name string
}
// Account groups identities that survive device and region changes.
type Account struct {
ID AccountID
Tenant TenantID
CreatedAt time.Time
devices map[DeviceID]Device
players map[PlayerID]struct{}
}
// Registry is an in-memory reference implementation of the identity rules.
// A production implementation replaces the maps with a transactional store.
type Registry struct {
accounts map[AccountID]*Account
devices map[DeviceID]AccountID
players map[PlayerID]*PlayerRef
nextAccount AccountID
nextPlayer PlayerID
}
func NewRegistry() *Registry {
return &Registry{
accounts: make(map[AccountID]*Account),
devices: make(map[DeviceID]AccountID),
players: make(map[PlayerID]*PlayerRef),
}
}
// NewAccount allocates a global account ID. IDs are opaque and never reused.
func (r *Registry) NewAccount(tenant TenantID, now time.Time) *Account {
r.nextAccount++
a := &Account{
ID: r.nextAccount,
Tenant: tenant,
CreatedAt: now.UTC(),
devices: make(map[DeviceID]Device),
players: make(map[PlayerID]struct{}),
}
r.accounts[a.ID] = a
return a
}
// NewPlayer allocates a region-scoped player ID for an existing account.
func (r *Registry) NewPlayer(accountID AccountID, region, name string) (PlayerRef, error) {
a, ok := r.accounts[accountID]
if !ok {
return PlayerRef{}, ErrAccountNotFound
}
r.nextPlayer++
p := &PlayerRef{
ID: r.nextPlayer,
AccountID: accountID,
Tenant: a.Tenant,
Region: region,
Name: name,
}
r.players[p.ID] = p
a.players[p.ID] = struct{}{}
return *p, nil
}
// BindDevice attaches an installation to an account. Repeating the same
// binding is idempotent; moving a device to another account is rejected.
func (r *Registry) BindDevice(accountID AccountID, dev Device, now time.Time) error {
a, ok := r.accounts[accountID]
if !ok {
return ErrAccountNotFound
}
if dev.Tenant != a.Tenant {
return ErrTenantMismatch
}
if owner, ok := r.devices[dev.ID]; ok {
if owner != accountID {
return ErrDeviceBound
}
cur := a.devices[dev.ID]
cur.LastSeenAt = now.UTC()
a.devices[dev.ID] = cur
return nil
}
dev.BoundAt = now.UTC()
dev.LastSeenAt = now.UTC()
a.devices[dev.ID] = dev
r.devices[dev.ID] = accountID
return nil
}
// UnbindDevice removes one installation. Characters and the account survive.
func (r *Registry) UnbindDevice(accountID AccountID, deviceID DeviceID) error {
a, ok := r.accounts[accountID]
if !ok {
return ErrAccountNotFound
}
if _, ok := a.devices[deviceID]; !ok {
return ErrDeviceNotFound
}
delete(a.devices, deviceID)
if owner, ok := r.devices[deviceID]; ok && owner == accountID {
delete(r.devices, deviceID)
}
return nil
}
// PlayersOf returns a stable snapshot of the account's characters.
func (r *Registry) PlayersOf(accountID AccountID) ([]PlayerRef, error) {
a, ok := r.accounts[accountID]
if !ok {
return nil, ErrAccountNotFound
}
out := make([]PlayerRef, 0, len(a.players))
for id := range a.players {
if p, ok := r.players[id]; ok {
out = append(out, *p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// HashFingerprint produces the only form of a device fingerprint that may be
// persisted. A fingerprint is a hint, never a login credential.
func HashFingerprint(raw string) string {
sum := sha256.Sum256([]byte("gsp/device/v1\x00" + raw))
return hex.EncodeToString(sum[:])
}
代码里有三个值得注意的设计:Account.devices 与 Registry.devices 是双向索引,前者回答“这个账号有哪些设备”,后者回答“这个设备属于谁”;PlayerRef 只保存身份层需要的字段,等级和背包留在区服;HashFingerprint 是唯一允许落库的设备指纹形式,调用方没有机会把明文写进 Device。
七、不变量与表驱动测试
model_test.go 用表驱动覆盖最容易回退的规则:
| 不变量 | 测试用例 | 期望 |
|---|---|---|
| 首次绑定成功 | first bind succeeds | 返回 nil |
| 重复绑定幂等 | duplicate bind is idempotent | 返回 nil,不产生第二条记录 |
| 设备不能换账号 | device cannot move to another account | ErrDeviceBound |
| 跨租户绑定拒绝 | cross-tenant bind is rejected | ErrTenantMismatch |
| 解绑保留角色 | TestUnbindDeviceKeepsPlayersAndReleasesDevice | 角色数量不变,设备可被其他账号绑定 |
| 指纹不可逆 | TestHashFingerprintIsNotReversibleInput | 输出为 64 位摘要且确定 |
| ID 空间独立 | TestAccountAndPlayerIDSpacesAreIndependent | 第二个账号看不到第一个账号的角色 |
这些测试刻意不依赖数据库。只要模型层的不变量成立,换成 SQL 或 Redis 实现时就可以复用同一组用例,把存储细节替换掉。
八、失败场景分析:一次跨租户绑定事故
某游戏从渠道 A 迁移到渠道 B 运营。迁移脚本为了“保留玩家习惯”,把渠道 A 的设备记录整体复制到渠道 B 的账号下,但没有更新 device.tenant。上线后出现两类故障:
- 渠道 B 的新玩家在渠道 A 的旧设备上登录,系统把设备绑定到 B 账号,随后渠道 A 的风控规则读到该设备,误判为跨区盗号并冻结账号;
- 客服在 B 租户后台解绑设备时,按
device_id直接删除记录,误删了 A 租户仍在使用的一条绑定,导致 A 租户玩家被强制下线。
根因不是脚本写错了一行,而是模型允许“设备没有租户”或“绑定不校验租户”。如果 BindDevice 在写入前比较 dev.Tenant 与 account.Tenant,第一类故障会在迁移阶段直接失败;如果解绑以 (account_id, device_id) 为条件而不是只按 device_id,第二类故障不会波及其他租户。租户隔离要落在写路径的校验里,不能只靠读路径的过滤。
九、落地检查清单
- 账号 ID 由服务端签发,未直接使用 openid、手机号或邮箱;角色 ID 与账号 ID 是两套独立序列;
- 角色表带
region_id,账号表不带server_id;租户字段进入账号、角色、设备三张表,写路径校验租户一致; - 设备指纹只存哈希,原始值不落库、不打日志;
- 一个账号可绑定多个设备,一个设备同一时刻只属于一个账号;解绑不删除角色。
下一节讨论一个账号在多个区服都有角色时,登录、选角和跨区迁移如何落地,见 /game-server-in-action-412/。账号绑定后的安全策略则在 /game-server-in-action-413/ 展开。
小结
游戏后端的身份不是一个 user 表,而是四层生命周期不同的实体:账号跨区稳定,角色绑定区服,租户决定数据归属,设备只是安装实例。主键设计的关键是让 account_id 与 player_id 各自独立。设备可以多对一绑定账号,但不能作为登录凭证,也不能跨账号或跨租户移动。把这些不变量写进模型和表驱动测试,比在上层业务里反复补 if 更可靠。
常见错误与边界
错误一:把 openid 直接当账号主键。 openid 只在特定渠道和 appid 下稳定,换包、换渠道或换租户后可能失效,而且会暴露渠道结构。应建立 (provider, external_id) -> account_id 映射。
错误二:把角色 ID 当账号 ID。 角色可删除、可迁移、可合服重排,账号不能。两者混用会导致换区即换号、删角色即丢账号。
错误三:设备号明文入库。 设备指纹可能包含硬件标识,明文存储会扩大泄露面。至少做带域分隔的哈希,生产环境再加盐或 HMAC。
错误四:解绑设备时连带删除角色。 设备只是登录入口,角色是游戏资产。解绑应只删除绑定关系,角色和账号保持原状。
边界一:一个设备能否绑定多个账号? 本文模型限制为“同一时刻只属于一个账号”,这是最常见的安全默认值。网吧、家庭共享机等场景可以放宽为“允许多个账号先后绑定,但同一时刻只有一个活跃绑定”。
边界二:账号能否跨租户存在? 多数发行体系要求账号归属单一租户。若业务确实需要跨租户统一账号,应单独建全局账号层,再由租户层做授权映射,而不是直接去掉租户字段。
边界三:设备指纹冲突怎么办? 指纹相似不等于同一设备。模拟器批量伪造时,宁可把信号交给风控评分,也不要用指纹直接封号或合并账号。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。