《游戏服务端编程实践》4.2.1 Session 与 Token 模型

从登录后的凭证生命周期出发,对比服务端 Session、自包含 Token 与短 Token 加会话索引的混合模型,在吊销、扩展、断线重连、踢下线、跨区和泄露止损六个维度上给出取舍,并用带 TTL、绝对寿命与滑动续期的 Go 会话表实现和测试把结论落到代码。

一、登录态到底存在哪里

玩家在登录界面输入完凭证,服务端校验通过,接下来每一次请求都要回答同一个问题:凭什么相信“你还是刚才通过认证的那个人”?

答案只有两类。第一类是把这次登录记在服务端,客户端只拿到一个没有含义的编号,服务端凭编号查记录,这就是 Session。第二类是服务端把“你是谁、什么时候过期”写进一份带签名的凭证,交给客户端保存,下次请求原样带回,服务端验签即可,这就是 Token。

很多团队把它当成二选一的技术口味问题,实际上两者解决的是不同层级的问题。登录态回答“这个账号是否已经通过认证”,连接态回答“这条 socket 现在归谁使用”。一条长连接可能因为切网、手机锁屏、网关发布而断开重连十几次,但登录态不应跟着每次断线消失;反过来,登录态有效也不代表当前连接可信,网关仍然要校验这条连接绑定的是哪个账号、哪个角色、哪个区服。

先把这两个层级分开,后面的模型选择才不会混在一起讨论。

二、三种模型

工程上真正落地的只有三种:完全有状态的服务端 Session、完全无状态的自包含 Token,以及短 Token 加服务端会话索引的混合模型。它们没有绝对优劣,差别集中在六个维度上。

维度服务端 Session自包含 Token混合(短 Token + 会话索引)
吊销成本删一条记录,下一次请求立即失效到期前无法单独撤销,必须额外维护黑名单删索引立即生效,Token 本身不必回收
横向扩展需要共享存储或粘性路由,否则换节点就丢会话任意节点持有公钥或共享密钥即可验签验签在本地完成,只有敏感操作才查索引
断线重连会话仍在,带上会话 ID 即可恢复重连后继续携带同一 Token,直到过期短 Token 可能已过期,需要 Refresh Token 换新
踢下线删除会话,再通知网关断开连接写黑名单并断开连接,或等 Token 自然过期删除索引,网关下一次校验即拒绝
跨区会话要按区服隔离,跨区要迁移或重新登录Token 里必须写清区服与角色,避免拿东区凭证进西区索引按区分片,Token 只携带路由所需的最小线索
泄露后止损服务端发现异常即可立即失效只能等过期或拉黑 jti,窗口等于 Token 剩余寿命立即删索引,窗口最短,代价是多一次存储查询

逐行看这张表,可以得到几个不那么直觉的结论。

第一,Session 的“吊销便宜”是有前提的:所有校验方都能访问同一份会话存储。如果会话只存在某台网关的内存里,删掉本机记录并不妨碍另一台网关继续认可同一份副本,吊销成本会突然变得比 Token 还高。

第二,Token 的“无状态”也是有前提的:业务能接受“签发后到过期前不可撤销”。游戏里封号、踢人、改密后强制下线都是高频操作,纯无状态 Token 很难独立承担,通常要补一层黑名单,于是又回到有状态。

第三,混合模型不是折中路线,而是把两种能力拆开:短 Token 负责“高频、只读、跨服务”的验签,会话索引负责“低频、写操作、需要立即生效”的吊销。多数中大型游戏最终都会走到这里。

三、长连接把问题拆成两层

游戏和普通 Web 最大的区别是长连接。玩家登录成功后,客户端与网关建立 WebSocket 或 TCP 连接,之后几个小时的交互都走这条连接。于是“登录”和“在线”变成了两个可以分别失败的状态。

推荐的分工是:

  • 登录服负责校验账号凭证、二次验证、风控,然后签发登录凭证。它不持有玩家连接。
  • 网关持有连接,负责心跳、限速、断线检测。它拿到登录凭证后,向会话存储换取一条带 account_id、region_id、player_id 的会话。
  • 场景服、战斗服不直接信任客户端,只信任网关转发的已认证上下文,并且每次关键操作都校验会话仍然有效。
  • 会话存储是登录服、网关和后台管理共用的那一份事实来源,踢人、封号、改密都改这里。

这样拆分之后,断线重连的处理会清晰很多。客户端重连时先出示重连票据,网关恢复连接绑定;票据失效则回到登录流程。重连票据负责“接回原来的连接上下文”,登录凭证负责“证明账号身份”,两者不要混用。重连的具体时序可以对照 /game-server-in-action-332/,消息在网关之间如何转发可以对照 /game-server-in-action-333/。

反过来,如果让网关自己做登录、自己存会话,短期看少了一次 RPC,长期会付出三种代价:网关无法随意扩缩容,因为缩容会带走会话;多网关之间踢人不同步;登录服的风控与审计拿不到完整的在线视图。

把这几层状态摊开看,归属会更清楚:

状态归属生命周期典型失效方式
连接态网关一条 socket 从建立到关闭断链、心跳超时、网关发布
登录态会话存储空闲超时与绝对寿命共同约束登出、踢人、改密、过期
角色态世界服或场景服角色在线期间下线、切场景、跨服迁移
账号态账号库长期存在封禁、注销、安全事件

四层状态的生命周期完全不同,硬塞进一个对象里,最后一定会在某一层需要独立失效时卡住。比如封号只需要账号态和登录态失效,不应该把角色存档删掉;网关重启只影响连接态,不应该让所有玩家重新输入密码。

四、会话表 + TTL + 滑动续期

下面给出一个可以直接跑的最小实现。它把会话放在进程内的 map 里,用一把互斥锁保护,重点不在存储介质,而在三个判定:

  1. 空闲超时(IdleTTL):一段时间没有活动就失效,避免玩家关掉客户端后会话永久占位。
  2. 绝对寿命(MaxLifetime):从创建时刻算起,无论怎么续期都不能越过。没有这一条,滑动续期等于永不过期。
  3. 续期取两者更早的那个:每次 Touch 只把过期时间推到 now+IdleTTL,但不得超过 CreatedAt+MaxLifetime。
// file: ch04/token/session.go
// tier: L1
// verify: go test ./ch04/token/
package token

import (
	"errors"
	"sync"
	"time"
)

// Session 是登录服签发、网关校验的一张有状态凭证。
// 它只保存“这次登录是谁、属于哪个区、还能活多久”,不保存角色档案。
type Session struct {
	ID        string
	AccountID uint64
	RegionID  string
	CreatedAt time.Time
	LastSeen  time.Time
	ExpiresAt time.Time
}

// SessionConfig 同时约束空闲超时与绝对寿命:
// Touch 只能延长空闲超时,永远不能越过 CreatedAt+MaxLifetime。
type SessionConfig struct {
	IdleTTL     time.Duration
	MaxLifetime time.Duration
	Now         func() time.Time
}

var (
	ErrSessionNotFound = errors.New("token: session not found")
	ErrSessionExists   = errors.New("token: session already exists")
	ErrSessionExpired  = errors.New("token: session expired")
)

// SessionStore 是进程内会话表。生产环境把 map 换成 Redis,
// 但 TTL 与滑动续期的判定逻辑不变。
type SessionStore struct {
	mu       sync.Mutex
	cfg      SessionConfig
	sessions map[string]Session
}

// NewSessionStore 要求 IdleTTL 与 MaxLifetime 都为正数。
func NewSessionStore(cfg SessionConfig) *SessionStore {
	if cfg.Now == nil {
		cfg.Now = time.Now
	}
	return &SessionStore{
		cfg:      cfg,
		sessions: make(map[string]Session),
	}
}

// Create 在登录成功后写入一条会话。
func (s *SessionStore) Create(id string, accountID uint64, regionID string) (Session, error) {
	if id == "" {
		return Session{}, errors.New("token: empty session id")
	}
	if s.cfg.IdleTTL <= 0 || s.cfg.MaxLifetime <= 0 {
		return Session{}, errors.New("token: invalid session ttl")
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.sessions[id]; ok {
		return Session{}, ErrSessionExists
	}

	now := s.cfg.Now()
	sess := Session{
		ID:        id,
		AccountID: accountID,
		RegionID:  regionID,
		CreatedAt: now,
		LastSeen:  now,
		ExpiresAt: earliest(now.Add(s.cfg.IdleTTL), now.Add(s.cfg.MaxLifetime)),
	}
	s.sessions[id] = sess
	return sess, nil
}

// Get 返回会话副本;调用方修改副本不会影响存储。
func (s *SessionStore) Get(id string) (Session, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	sess, ok := s.sessions[id]
	if !ok {
		return Session{}, ErrSessionNotFound
	}
	if !s.cfg.Now().Before(sess.ExpiresAt) {
		delete(s.sessions, id)
		return Session{}, ErrSessionExpired
	}
	return sess, nil
}

// Touch 滑动续期。绝对寿命到顶后,会话被删除并返回 ErrSessionExpired。
func (s *SessionStore) Touch(id string) (Session, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	sess, ok := s.sessions[id]
	if !ok {
		return Session{}, ErrSessionNotFound
	}
	now := s.cfg.Now()
	deadline := sess.CreatedAt.Add(s.cfg.MaxLifetime)
	if !now.Before(deadline) {
		delete(s.sessions, id)
		return Session{}, ErrSessionExpired
	}
	sess.LastSeen = now
	sess.ExpiresAt = earliest(now.Add(s.cfg.IdleTTL), deadline)
	s.sessions[id] = sess
	return sess, nil
}

// Revoke 立即删除会话,用于登出与踢下线。重复调用返回 ErrSessionNotFound。
func (s *SessionStore) Revoke(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.sessions[id]; !ok {
		return ErrSessionNotFound
	}
	delete(s.sessions, id)
	return nil
}

// Len 返回当前仍保存在内存中的会话数,包含尚未被清理的过期项。
func (s *SessionStore) Len() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.sessions)
}

// Sweep 主动清理过期会话,供后台任务周期调用。
func (s *SessionStore) Sweep() int {
	s.mu.Lock()
	defer s.mu.Unlock()

	now := s.cfg.Now()
	removed := 0
	for id, sess := range s.sessions {
		if !now.Before(sess.ExpiresAt) {
			delete(s.sessions, id)
			removed++
		}
	}
	return removed
}

func earliest(a, b time.Time) time.Time {
	if a.Before(b) {
		return a
	}
	return b
}

几个实现细节值得说明。

Get 与 Touch 返回的是 Session 值拷贝,调用方修改副本不会污染存储。Get 发现会话已过期时会顺手删除,这是懒删除;Sweep 则供后台任务周期清理,避免大量过期键长期占用内存。Create 对重复 ID 直接报错,而不是静默覆盖,因为会话 ID 重复通常意味着随机源或调用方出了问题。

Touch 在绝对寿命到顶时删除会话并返回 ErrSessionExpired,而不是返回一个已经过期的会话。这个区分很重要:调用方拿到错误就必须重新登录,不能继续把旧上下文透传给场景服。

配套测试覆盖了七种行为:创建后可读、重复创建被拒绝、空闲超时后读取返回过期、Touch 延长空闲时间、Touch 到绝对寿命后被拒绝、续期被绝对寿命截断、Revoke 后立即不可读。另有一个 200 并发的创建与续期测试,用来暴露锁粒度或 map 读写错误;并发正确性最终还要靠在 CI 里加 -race 复核。

五、失败场景:续期没有上限

某款手游的会话实现只有一层 30 分钟 TTL,网关每次收到心跳就调用 Touch 把过期时间往后推 30 分钟。上线初期一切正常,因为玩家很少连续在线超过几小时。

问题出现在一次长挂机活动。部分玩家用脚本保持心跳,会话连续续期四十多天。攻击者从一个泄漏的客户端日志里拿到了会话 ID,直接复用,服务端因为会话仍然有效而放行。运营后来封禁账号,只删除了数据库里的账号状态,却不知道会话还活在三台网关的内存里;玩家改密后,旧会话也没有被吊销,攻击者继续在线。

复盘时能看到三个叠加的根因:

  • 只有空闲 TTL,没有绝对寿命,续期没有上限。
  • 会话存在网关本地,后台的封禁操作只改了数据库,没有触达网关。
  • 改密、封号没有级联到会话层,登录态与账号安全事件脱节。

修复并不复杂:增加 MaxLifetime,把会话收敛到共享存储,封禁与改密统一调用 Revoke,并且给会话 ID 使用密码学随机数而不是自增或时间戳。真正困难的是事后补上审计,因为存量会话已经无法判断哪些被复用。

这个案例的教训是:滑动续期本身不是问题,没有绝对上限才是问题。 任何“只要活跃就永久有效”的登录态,最终都会变成无法回收的凭证。

六、怎么选

把上面的分析压缩成一张决策清单:

  • 单区、小规模、需要精细踢人与在线统计:直接用服务端 Session,存储可以是 Redis,不必引入 JWT。
  • 多服务、多区、验签方不想每次查存储:用短寿命自包含 Token,配 Refresh Token 续期,关键操作再查会话索引。
  • 需要封号立即生效、又要跨服务验签:混合模型,Token 只带 account_id、region_id、jti 和过期时间,索引负责吊销。
  • 客户端是弱终端、经常切网:会话 ID 或 Token 都要支持重连票据,不要把连接对象序列化进凭证。
  • 有多个登录渠道、同一账号多端在线:会话表以 account_id 建二级索引,才能实现“踢掉其他端”。

一个常见的误区是“用了 JWT 就不需要会话表”。只要业务存在踢人、封号、改密强制下线,就一定需要某种可写的吊销状态;区别只是它叫 Session、黑名单还是 token_version。名字不重要,能否立即生效才重要。

还有一个容易被忽略的迁移问题:从纯 Session 迁到混合模型时,存量会话不能一夜之间作废,否则全服玩家会被强制重新登录。可行的做法是让网关同时接受两种凭证一段时间,新登录只签发新格式,旧会话按自然过期逐步退出,并记录两条路径的命中比例;确认旧路径归零后再删除兼容代码。

容量规划也要按“登录态条目数”而不是“在线连接数”来算。一个账号可能在手机、平板、模拟器上各有一条会话,封号时还要能一次列出全部条目。会话表如果只以会话 ID 为主键,后台就要全表扫描才能找到某个账号;因此生产实现通常额外维护 account_id -> session_id 的集合索引,并在 Revoke 时同步清理。

小结

登录态的本质是“服务端愿意在多长时间内、以什么代价相信一份凭证”。服务端 Session 把信任放在自己的存储里,吊销便宜但扩展要共享状态;自包含 Token 把信任写进签名,扩展便宜但吊销要额外机制;混合模型把验签和吊销拆开,是长连接游戏更常见的落点。

游戏还要额外区分连接态与登录态:网关持有连接,登录服签发凭证,场景服只信任网关转发并再次校验的上下文。实现层面,TTL 必须同时包含空闲超时和绝对寿命,滑动续期只能延长前者,不能突破后者。

常见错误与边界

  • 把会话存在网关本地:扩容、缩容、发布都会丢会话;多网关之间踢人不同步。会话必须放在所有校验方都能访问的共享存储里。
  • Token 没有过期时间:签发即永久有效,泄露后无法止损。至少要有 exp,并配合短寿命与刷新机制。
  • 续期没有上限:只有滑动 TTL,没有绝对寿命,活跃会话可以无限续命。必须设置 MaxLifetime。
  • 会话 ID 可预测:自增 ID、时间戳、账号哈希都不适合做会话标识,必须使用密码学随机数。
  • 把连接当登录态:连接断开就认为退出登录,或者连接还在就认为登录有效,两者都会导致状态错乱。
  • 吊销只删本地缓存:后台封禁、改密只改数据库,网关仍认可旧会话。吊销要触达共享存储并通知网关断链。
  • 多端策略缺失:不区分手机、平板、模拟器,导致“顶号”行为不可预期。会话表要能按账号列出全部活跃会话。

边界方面,进程内 SessionStore 只适合单机演示与测试;生产环境换成 Redis 后,Touch 的“读-改-写”需要原子化,否则并发续期可能把过期时间改回更早的值。跨区部署时,会话键要带区服前缀,避免不同区服的账号 ID 冲突。会话存储本身也会成为单点,必须有超时、降级和容量预案,不能假设它永远可用。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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