一、登录态到底存在哪里
玩家在登录界面输入完凭证,服务端校验通过,接下来每一次请求都要回答同一个问题:凭什么相信“你还是刚才通过认证的那个人”?
答案只有两类。第一类是把这次登录记在服务端,客户端只拿到一个没有含义的编号,服务端凭编号查记录,这就是 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 里,用一把互斥锁保护,重点不在存储介质,而在三个判定:
- 空闲超时(IdleTTL):一段时间没有活动就失效,避免玩家关掉客户端后会话永久占位。
- 绝对寿命(MaxLifetime):从创建时刻算起,无论怎么续期都不能越过。没有这一条,滑动续期等于永不过期。
- 续期取两者更早的那个:每次
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 冲突。会话存储本身也会成为单点,必须有超时、降级和容量预案,不能假设它永远可用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。