三件事,一条状态机
《裂隙战场》的客服每周都会收到三类工单:
- 玩家在手机和模拟器同时登录,手机上的角色被踢下线;
- 玩家进地铁断网二十秒,出来后发现战斗已经结束,自己还在原地;
- 网关发布后,一部分玩家的连接从
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 有三个约束:
- 单调递增:同一账号的每次绑定、恢复、迁移都加一,不能回退。
- 服务端生成:客户端只能回传它收到的 epoch,不能自己造。
- 随会话持久化:不能只存在连接对象里;连接断开后,epoch 仍要能被下一次恢复读到。
业务消息进入时,会话层做一次检查:
if session.state != BOUND: reject
if message.epoch != session.epoch: reject
accept
顶号:新连接赢,旧连接立即失效
顶号的规则是产品规则,不是技术规则:同一账号的新绑定是否踢掉旧绑定。竞技游戏通常踢,因为双端同时操作会破坏公平;部分休闲产品允许多端在线,那就不能叫顶号,而是多会话模型,会话表要按设备维度拆开。
《裂隙战场》选择踢。实现上,Bind 在写入新绑定前检查账号是否已有会话:
- 有,就把旧会话置为
CLOSED,原因记为kicked; - 新会话的 epoch 在账号维度上加一,保证比旧会话大;
- 旧连接的下一次写入返回
ErrNotWritable,客户端收到明确的关闭原因,而不是静默超时。
这里有一个容易忽略的点:epoch 必须按账号递增,而不是按会话 ID 递增。顶号会创建新的会话 ID;如果每个会话从 1 开始计数,旧连接的 epoch 可能和新连接相同,fencing 就失效了。
重连窗口
流程是:
- 网关准备下线或检测到连接异常,调用
Drain,会话进入DRAINING,记录ResumeUntil; - 在途消息排空后,调用
MarkResumable,进入RESUMABLE; - 客户端带着会话 ID、epoch 和请求 ID 调用
Resume; - 会话层校验状态、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 客户端预测与服务端裁决 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。