《游戏服务端架构·品类、状态与拓扑》5.3 会话、重连、顶号与连接迁移

顶号、地铁断网和跨网关迁移看起来是三件事,底层却是同一条会话状态机。本节用 epoch 作为 fencing token,定义 CONNECTED 到 CLOSED 的合法迁移、重连窗口与幂等恢复,并给出顶号踢旧连接、迁移后拒绝旧连接写入、重复 resume 不重复提升 epoch 的可运行实现。

三件事,一条状态机

《裂隙战场》的客服每周都会收到三类工单:

  • 玩家在手机和模拟器同时登录,手机上的角色被踢下线;
  • 玩家进地铁断网二十秒,出来后发现战斗已经结束,自己还在原地;
  • 网关发布后,一部分玩家的连接从 gw-a 迁到 gw-b,有人能继续打,有人被判定为“重复登录”。

三件事看起来分别是顶号、重连和迁移,底层却是同一个问题:同一时刻,谁拥有这个账号的写权限?

连接 ID 不能回答这个问题。旧连接可能还活着,TCP 没断,消息还在路上;新连接已经建立,但还没完成认证。会话层必须有一个单调递增的版本号,明确“从哪一刻起,旧连接写什么都算迟到”。这个版本号就是 epoch,也叫 fencing token。

状态机

CONNECTED --authenticate--> AUTHENTICATED --bind--> BOUND
BOUND --drain--> DRAINING --flush--> RESUMABLE --resume--> BOUND
任意非 CLOSED 状态 --close--> CLOSED
状态含义允许业务写入典型停留时间
CONNECTED连接已建立,身份未知否毫秒级
AUTHENTICATED身份已验证,尚未绑定账号否一次绑定调用
BOUND已绑定账号与网关,正常游戏是分钟到小时
DRAINING停止写入,等待在途消息排空否秒级
RESUMABLE已排空,等待客户端恢复否重连窗口内
CLOSED终止,不再接受任何写入否保留审计记录

epoch 是写权限的版本号

epoch 有三个约束:

  1. 单调递增:同一账号的每次绑定、恢复、迁移都加一,不能回退。
  2. 服务端生成:客户端只能回传它收到的 epoch,不能自己造。
  3. 随会话持久化:不能只存在连接对象里;连接断开后,epoch 仍要能被下一次恢复读到。

业务消息进入时,会话层做一次检查:

if session.state != BOUND: reject
if message.epoch != session.epoch: reject
accept

顶号:新连接赢,旧连接立即失效

顶号的规则是产品规则,不是技术规则:同一账号的新绑定是否踢掉旧绑定。竞技游戏通常踢,因为双端同时操作会破坏公平;部分休闲产品允许多端在线,那就不能叫顶号,而是多会话模型,会话表要按设备维度拆开。

《裂隙战场》选择踢。实现上,Bind 在写入新绑定前检查账号是否已有会话:

  • 有,就把旧会话置为 CLOSED,原因记为 kicked;
  • 新会话的 epoch 在账号维度上加一,保证比旧会话大;
  • 旧连接的下一次写入返回 ErrNotWritable,客户端收到明确的关闭原因,而不是静默超时。

这里有一个容易忽略的点:epoch 必须按账号递增,而不是按会话 ID 递增。顶号会创建新的会话 ID;如果每个会话从 1 开始计数,旧连接的 epoch 可能和新连接相同,fencing 就失效了。

重连窗口

流程是:

  1. 网关准备下线或检测到连接异常,调用 Drain,会话进入 DRAINING,记录 ResumeUntil;
  2. 在途消息排空后,调用 MarkResumable,进入 RESUMABLE;
  3. 客户端带着会话 ID、epoch 和请求 ID 调用 Resume;
  4. 会话层校验状态、epoch、窗口和目标网关,通过后提升 epoch,回到 BOUND。

重连窗口的长度是产品取舍。窗口太短,玩家在关键团战时被迫重开;窗口太长,服务端要为大量“可能回来”的会话保留状态,顶号和迁移的复杂度也会上升。示例值 30 秒适合一局十分钟的竞技游戏;SLG 可以更长,但必须和离线补算的 lastSeen 口径分开。

Resume 必须是幂等的。客户端没收到响应时会重试,如果每次重试都提升 epoch,第一次成功的响应就会被第二次覆盖。实现用 requestID 做重放键:同一个请求 ID 再次到达时,直接返回上次的绑定结果,不再提升 epoch。

迁移:换网关,不换会话

迁移的规则只有一条:先让旧连接失去写权限,再让新网关接受写入。 实现上不需要复杂的握手,只需要在 Migrate 时提升 epoch、修改 GatewayID:

  • 旧连接继续发消息,epoch 不匹配,返回 ErrStaleEpoch;
  • 新网关用新 epoch 接受写入;
  • 会话状态仍是 BOUND,不经过 DRAINING,因为迁移的目标是“无缝换节点”,不是“下线再上线”。

目标网关在接客之前必须已经把会话加载到本地。否则新连接到了,会话还查不到,会出现“迁移成功但无法写入”的空窗。生产实现通常把“加载会话”放在迁移请求的前置步骤,加载成功后才切换路由。

代码:会话注册表

下面的实现把状态机、epoch、顶号、重连、迁移和写权限检查放在同一个注册表里。它用互斥锁保护内存状态;生产环境把同样的字段放进共享存储,用 epoch 做 compare-and-set 即可,状态迁移不变。

// file: internal/session/registry.go
// tier: L1
// verify: go test ./internal/session/
package session

import (
	"fmt"
	"sync"
	"time"
)

// Registry is an in-memory directory. A production implementation stores the
// same fields in a shared store with compare-and-set on Epoch.
type Registry struct {
	mu        sync.Mutex
	window    time.Duration
	now       func() time.Time
	seq       uint64
	sessions  map[string]*Session
	bound     map[string]string
	lastEpoch map[string]uint64
	replay    map[string]Session
}

func NewRegistryWithClock(resumeWindow time.Duration, now func() time.Time) *Registry {
	if now == nil {
		now = time.Now
	}
	return &Registry{
		window: resumeWindow, now: now,
		sessions: make(map[string]*Session), bound: make(map[string]string),
		lastEpoch: make(map[string]uint64), replay: make(map[string]Session),
	}
}

// Connect allocates an unauthenticated session for a gateway connection.
func (r *Registry) Connect(gatewayID string) (Session, error) {
	if gatewayID == "" {
		return Session{}, fmt.Errorf("%w: empty gateway", ErrBadState)
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	r.seq++
	s := &Session{ID: fmt.Sprintf("s-%d", r.seq), GatewayID: gatewayID, State: Connected}
	r.sessions[s.ID] = s
	return *s, nil
}

// Authenticate attaches an account but does not yet take over the account.
func (r *Registry) Authenticate(id, accountID string) (Session, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return Session{}, ErrNotFound
	}
	if accountID == "" || !CanTransition(s.State, Authenticated) {
		return Session{}, ErrBadState
	}
	s.AccountID = accountID
	s.State = Authenticated
	return *s, nil
}

// Bind completes login. If the account already has a session, that session is
// closed as kicked and the new binding gets a higher epoch.
func (r *Registry) Bind(id string) (Session, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return Session{}, ErrNotFound
	}
	if !CanTransition(s.State, Bound) {
		return Session{}, ErrBadState
	}
	if oldID := r.bound[s.AccountID]; oldID != "" && oldID != id {
		if old := r.sessions[oldID]; old != nil {
			r.closeLocked(old, ReasonKicked)
		}
	}
	s.Epoch = r.nextEpoch(s.AccountID)
	s.State = Bound
	s.ResumeUntil = time.Time{}
	r.bound[s.AccountID] = id
	return *s, nil
}

// Drain stops writes but keeps the binding for a possible resume.
func (r *Registry) Drain(id string) (Session, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return Session{}, ErrNotFound
	}
	if !CanTransition(s.State, Draining) {
		return Session{}, ErrBadState
	}
	s.State = Draining
	s.ResumeUntil = r.now().Add(r.window)
	return *s, nil
}

// MarkResumable is called after the transport has flushed its last message.
func (r *Registry) MarkResumable(id string) (Session, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return Session{}, ErrNotFound
	}
	if !CanTransition(s.State, Resumable) {
		return Session{}, ErrBadState
	}
	if r.now().After(s.ResumeUntil) {
		r.closeLocked(s, ReasonExpired)
		return Session{}, ErrResumeExpired
	}
	s.State = Resumable
	return *s, nil
}

// Resume rebinds a resumable session. requestID makes a retried resume return
// the same epoch instead of bumping it twice.
func (r *Registry) Resume(id string, epoch uint64, gatewayID, requestID string) (Session, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return Session{}, ErrNotFound
	}
	if requestID != "" {
		if prev, done := r.replay[id+"|"+requestID]; done {
			if prev.Epoch != s.Epoch {
				return Session{}, ErrStaleEpoch
			}
			if s.State != Bound {
				return Session{}, ErrBadState
			}
			return *s, nil
		}
	}
	if epoch != s.Epoch {
		return Session{}, ErrStaleEpoch
	}
	if !CanTransition(s.State, Bound) {
		return Session{}, ErrBadState
	}
	if r.now().After(s.ResumeUntil) {
		r.closeLocked(s, ReasonExpired)
		return Session{}, ErrResumeExpired
	}
	if gatewayID == "" {
		return Session{}, ErrBadState
	}
	s.Epoch = r.nextEpoch(s.AccountID)
	s.GatewayID = gatewayID
	s.State = Bound
	s.ResumeUntil = time.Time{}
	r.bound[s.AccountID] = id
	if requestID != "" {
		r.replay[id+"|"+requestID] = *s
	}
	return *s, nil
}

// Migrate moves a live binding to another gateway and increments the epoch.
// The old connection's next write fails with ErrStaleEpoch.
func (r *Registry) Migrate(id, targetGateway string) (Session, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return Session{}, ErrNotFound
	}
	if s.State != Bound {
		return Session{}, ErrBadState
	}
	if targetGateway == "" {
		return Session{}, ErrBadState
	}
	if targetGateway == s.GatewayID {
		return Session{}, ErrSameGateway
	}
	s.Epoch = r.nextEpoch(s.AccountID)
	s.GatewayID = targetGateway
	return *s, nil
}

// AuthorizeWrite is the fence every business message passes through.
func (r *Registry) AuthorizeWrite(id string, epoch uint64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return ErrNotFound
	}
	if s.State != Bound {
		return ErrNotWritable
	}
	if epoch != s.Epoch {
		return ErrStaleEpoch
	}
	return nil
}

// Close is idempotent.
func (r *Registry) Close(id string, reason EndReason) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return ErrNotFound
	}
	r.closeLocked(s, reason)
	return nil
}

func (r *Registry) Get(id string) (Session, bool) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	if !ok {
		return Session{}, false
	}
	return *s, true
}

func (r *Registry) nextEpoch(accountID string) uint64 {
	r.lastEpoch[accountID]++
	return r.lastEpoch[accountID]
}

func (r *Registry) closeLocked(s *Session, reason EndReason) {
	if s.State == Closed {
		return
	}
	s.State = Closed
	s.EndReason = reason
	s.ResumeUntil = time.Time{}
	if r.bound[s.AccountID] == s.ID {
		delete(r.bound, s.AccountID)
	}
}

类型与状态表在同一个包的另一份文件里,这里只展示操作部分。Bind 先关闭旧会话再提升 epoch,顺序不能反;Resume 先查重放键再校验 epoch 和状态;AuthorizeWrite 先看状态再看 epoch,已关闭的会话无论 epoch 对不对都不能写。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/session
$ go vet ./internal/session/
$ go test -count=1 ./internal/session/ -v
...
PASS
ok  	gsarchlab/internal/session	0.241s

gofmt -l 与 go vet 均无输出。7 个测试函数、42 个子用例,共 49 个 PASS,覆盖状态迁移、顶号、重连窗口、幂等恢复、迁移和写权限。耗时来自本机一次运行,随负载变化。

失败模式与兜底

失败表现兜底
重放请求在再次迁移后到达返回旧 epoch重放键绑定当前 epoch,不匹配就拒绝
迁移目标网关随后故障会话指向死节点重新路由并再次提升 epoch
两端互相顶号无限踢来踢去按账号限速,产品层提示“其他设备登录”
会话存储不可用无法判断写权限失败关闭,宁可拒绝也不放行

常见错误与边界

第一,用连接 ID 当写权限。连接 ID 不会随重绑定变化,旧连接无法被区分。

第二,顶号只断 TCP,不标记会话状态。旧连接可能在 TCP 关闭前发出最后一条消息。

第三,Resume 不幂等。网络重试会让会话被提升两次,第一次的响应作废。

第四,迁移不提升 epoch。旧网关和新网关可能同时写入同一会话。

第五,迁移后旧网关继续转发。必须在旧网关的路由层立即切断,不能只依赖客户端不再发送。

小结

顶号、重连和迁移的共同点是“谁拥有写权限”。会话状态机回答“现在能不能写”,epoch 回答“这一次写是不是来自当前绑定”。两者结合,旧连接无论是否还活着,都无法在迁移或顶号后写入。

internal/session 把这条规则写成了可测试的注册表。下一章进入战斗与状态同步:客户端预测、服务端裁决,以及在不同品类下如何选择状态同步与帧同步。

阅读导航:上一节:5.2 网关的职责边界与横向扩展 · 下一节:6.1 客户端预测与服务端裁决 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查