《游戏服务端编程实践》8.3.3 重连与状态恢复

帧同步战斗断线后,玩家需要带着战斗 ID、最后确认帧和会话凭证回到同一场战斗。本篇比较快照恢复与全量重放,讨论断线期间的冻结、托管和踢出,并给出幂等的 Resume 实现,以及快照过期、帧号超前和重复重连的测试,最后总结恢复流程的常见错误与边界。

一、重连不是重新登录

登录建立的是账号会话,重连恢复的是“战斗内的实体”。两者失败时表现完全不同:登录失败可以回大厅,重连失败意味着玩家还占着战斗里的位置,却收不到任何帧。

如果重连走完整登录流程,玩家会被当作新会话创建,战斗侧要么找不到旧实体,要么创建第二个实体。正确做法是把“战斗 ID + 玩家 ID”作为战斗实体的唯一键,重连只做状态恢复,不重新创建。

二、重连请求必须带的四样东西

字段作用缺失后果
战斗 ID定位实例与快照恢复到另一场战斗或找不到
玩家 ID绑定实体与权限把别人的角色恢复给自己
最后确认帧决定从哪里开始补重放起点错误,状态漂移
会话凭证证明这是原玩家任意客户端可顶号

最后确认帧不是“客户端当前帧”,而是客户端最后一次收到服务器确认的帧。它可能落后当前服务器帧很多,恢复逻辑要据此判断是补输入、拉快照还是拒绝。

三、快照加帧号,还是重放全部输入

方式数据量恢复速度适用
全量快照 + 当前帧继续一个快照快常规重连
从快照帧重放输入快照 + 增量输入中需要精确复现
重放全部历史输入整场输入日志慢,随时间增长调试、回放
只发当前状态最小最快无法保证确定性时不可用

常规重连用“最近快照 + 从快照帧到当前帧的输入”即可。重放全部历史只适合录像和排障,长时间战斗的输入日志可能比快照大几个数量级,恢复时间不可控。

快照必须有年龄上限。客户端带来的最后确认帧如果比服务器保留的快照还旧,说明缺口已经超出可重放窗口,应明确返回“快照过期”,让客户端走重新入场,而不是假装能从旧状态补齐。

四、断线期间:冻结、托管还是踢出

策略玩家体验对战斗的影响适用
冻结角色原地不动可预测,但可能被集火短断线、回合制
AI 托管角色继续行动结果可能与玩家意图不符长断线、可接受托管
踢出并保留实体允许重连占用位置,需超时清理大多数实时战斗
立即移除无法恢复队伍少人,状态突变断线即判负模式

无论选哪种,都必须让其他玩家知道当前状态。悄悄让角色继续行动、又不通知任何人,是最差的选择:队友以为有人操作,对手以为在挂机。

五、失败场景:重复重连创建两个实体

移动网络切换时,客户端可能在旧连接尚未超时就发起新连接。如果服务端每次 Resume 都无条件创建实体,战斗里会出现两个同玩家 ID 的角色:一个由旧连接驱动,一个由新连接驱动。输入重复执行,伤害翻倍,排名和结算全部错乱。

幂等是唯一可靠的解法:以“战斗 ID + 玩家 ID”为键记录活跃会话,重复请求返回同一份恢复结果并标记 Duplicate,不增加实体计数。只有显式 Drop 或超时清理后,下一次重连才创建新会话。

六、Go 实现:快照加帧号的幂等 Resume

ReconnectManager 校验战斗、凭证、帧号窗口,再用活跃会话表保证重复重连不创建第二个实体。返回的状态做深拷贝,避免调用方修改影响管理器内部快照。

// file: ch08/lockstep/reconnect.go
// tier: L1
// verify: go test ./ch08/lockstep/

package lockstep

import (
	"errors"
	"sync"
)

var (
	ErrBattleNotFound   = errors.New("reconnect: battle not found")
	ErrBadSession       = errors.New("reconnect: bad session token")
	ErrSnapshotExpired  = errors.New("reconnect: snapshot expired")
	ErrResumeFrameAhead = errors.New("reconnect: confirmed frame ahead of server")
)

// BattleSnapshot 是服务器定期保存的战斗状态,Frame 表示该状态对应的帧。
type BattleSnapshot struct {
	BattleID uint64
	Frame    Frame
	State    []byte
	Token    string
}

type ResumeRequest struct {
	BattleID      uint64
	PlayerID      uint64
	LastConfirmed Frame
	Token         string
}

type ResumeResult struct {
	Frame     Frame
	State     []byte
	Replayed  int32
	Duplicate bool
}

type sessionKey struct {
	battleID uint64
	playerID uint64
}

// ReconnectManager 用“快照 + 帧号”恢复玩家,并把重复重连做成幂等操作。
type ReconnectManager struct {
	mu       sync.Mutex
	maxGap   int32
	battles  map[uint64]BattleSnapshot
	active   map[sessionKey]struct{}
	entities int
}

// NewReconnectManager 的 maxGap 是允许从快照重放的最大帧数,超过即要求重新拉取快照。
func NewReconnectManager(maxGap int32) *ReconnectManager {
	return &ReconnectManager{
		maxGap:  maxGap,
		battles: make(map[uint64]BattleSnapshot),
		active:  make(map[sessionKey]struct{}),
	}
}

func (m *ReconnectManager) SetSnapshot(s BattleSnapshot) {
	m.mu.Lock()
	m.battles[s.BattleID] = cloneSnapshot(s)
	m.mu.Unlock()
}

// Advance 推进服务器权威帧,并把战斗状态更新为新的快照。
func (m *ReconnectManager) Advance(battleID uint64, frame Frame, state []byte) {
	m.mu.Lock()
	if b, ok := m.battles[battleID]; ok && frame.After(b.Frame) {
		b.Frame = frame
		b.State = cloneBytes(state)
		m.battles[battleID] = b
	}
	m.mu.Unlock()
}

func (m *ReconnectManager) Resume(req ResumeRequest) (ResumeResult, error) {
	m.mu.Lock()
	defer m.mu.Unlock()

	b, ok := m.battles[req.BattleID]
	if !ok {
		return ResumeResult{}, ErrBattleNotFound
	}
	if req.Token == "" || req.Token != b.Token {
		return ResumeResult{}, ErrBadSession
	}
	if req.LastConfirmed.After(b.Frame) {
		return ResumeResult{}, ErrResumeFrameAhead
	}
	if b.Frame.Sub(req.LastConfirmed) > m.maxGap {
		return ResumeResult{}, ErrSnapshotExpired
	}

	key := sessionKey{battleID: req.BattleID, playerID: req.PlayerID}
	if _, exists := m.active[key]; exists {
		return ResumeResult{Frame: b.Frame, State: cloneBytes(b.State), Duplicate: true}, nil
	}
	m.active[key] = struct{}{}
	m.entities++
	return ResumeResult{
		Frame:    b.Frame,
		State:    cloneBytes(b.State),
		Replayed: b.Frame.Sub(req.LastConfirmed),
	}, nil
}

// Drop 标记玩家离线;重复调用不会把实体计数减成负数。
func (m *ReconnectManager) Drop(battleID, playerID uint64) {
	m.mu.Lock()
	key := sessionKey{battleID: battleID, playerID: playerID}
	if _, ok := m.active[key]; ok {
		delete(m.active, key)
		m.entities--
	}
	m.mu.Unlock()
}

func (m *ReconnectManager) ActiveEntities() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.entities
}

func cloneSnapshot(s BattleSnapshot) BattleSnapshot {
	s.State = cloneBytes(s.State)
	return s
}

func cloneBytes(b []byte) []byte {
	if b == nil {
		return nil
	}
	out := make([]byte, len(b))
	copy(out, b)
	return out
}

maxGap 是允许从快照重放的最大帧数。超过它返回 ErrSnapshotExpired,客户端必须重新拉取快照;最后确认帧超前于服务器帧返回 ErrResumeFrameAhead,通常意味着客户端状态被篡改或帧号串场。

七、测试:过期、超前与重复重连

测试覆盖正常恢复、快照过期、帧号超前、凭证错误、战斗不存在,以及重复重连的幂等性和 Drop 后的重新入场。

// file: ch08/lockstep/reconnect_test.go
// tier: L1
// verify: go test ./ch08/lockstep/

package lockstep

import (
	"errors"
	"testing"
)

func newTestManager(t *testing.T) *ReconnectManager {
	t.Helper()
	m := NewReconnectManager(30)
	m.SetSnapshot(BattleSnapshot{BattleID: 7, Frame: 100, State: []byte("snap-100"), Token: "tok"})
	m.Advance(7, 110, []byte("snap-110"))
	return m
}

func TestResumeUsesSnapshotAndFrame(t *testing.T) {
	m := newTestManager(t)
	res, err := m.Resume(ResumeRequest{BattleID: 7, PlayerID: 42, LastConfirmed: 108, Token: "tok"})
	if err != nil {
		t.Fatal(err)
	}
	if res.Frame != 110 || res.Replayed != 2 || res.Duplicate {
		t.Fatalf("resume = %+v, want frame 110 replayed 2", res)
	}
	if string(res.State) != "snap-110" {
		t.Fatalf("state = %q", res.State)
	}
	if m.ActiveEntities() != 1 {
		t.Fatalf("entities = %d, want 1", m.ActiveEntities())
	}
	res.State[0] = 'X'
	again, err := m.Resume(ResumeRequest{BattleID: 7, PlayerID: 42, LastConfirmed: 108, Token: "tok"})
	if err != nil {
		t.Fatal(err)
	}
	if string(again.State) != "snap-110" {
		t.Fatalf("manager state leaked: %q", again.State)
	}
}

func TestResumeExpiredAndAhead(t *testing.T) {
	m := NewReconnectManager(5)
	m.SetSnapshot(BattleSnapshot{BattleID: 1, Frame: 100, State: []byte("s"), Token: "t"})
	m.Advance(1, 110, []byte("s"))

	if _, err := m.Resume(ResumeRequest{BattleID: 1, PlayerID: 1, LastConfirmed: 100, Token: "t"}); !errors.Is(err, ErrSnapshotExpired) {
		t.Fatalf("want ErrSnapshotExpired, got %v", err)
	}
	if _, err := m.Resume(ResumeRequest{BattleID: 1, PlayerID: 1, LastConfirmed: 111, Token: "t"}); !errors.Is(err, ErrResumeFrameAhead) {
		t.Fatalf("want ErrResumeFrameAhead, got %v", err)
	}
	if _, err := m.Resume(ResumeRequest{BattleID: 1, PlayerID: 1, LastConfirmed: 109, Token: "bad"}); !errors.Is(err, ErrBadSession) {
		t.Fatalf("want ErrBadSession, got %v", err)
	}
	if _, err := m.Resume(ResumeRequest{BattleID: 2, PlayerID: 1, LastConfirmed: 109, Token: "t"}); !errors.Is(err, ErrBattleNotFound) {
		t.Fatalf("want ErrBattleNotFound, got %v", err)
	}
}

func TestResumeDuplicateIsIdempotent(t *testing.T) {
	m := newTestManager(t)
	req := ResumeRequest{BattleID: 7, PlayerID: 42, LastConfirmed: 109, Token: "tok"}

	first, err := m.Resume(req)
	if err != nil {
		t.Fatal(err)
	}
	second, err := m.Resume(req)
	if err != nil {
		t.Fatal(err)
	}
	if first.Duplicate || !second.Duplicate {
		t.Fatalf("duplicate flags = %v, %v", first.Duplicate, second.Duplicate)
	}
	if m.ActiveEntities() != 1 {
		t.Fatalf("duplicate reconnect created %d entities", m.ActiveEntities())
	}

	m.Drop(7, 42)
	m.Drop(7, 42)
	if m.ActiveEntities() != 0 {
		t.Fatalf("entities after drop = %d", m.ActiveEntities())
	}
	third, err := m.Resume(req)
	if err != nil {
		t.Fatal(err)
	}
	if third.Duplicate {
		t.Fatal("resume after drop should be a fresh session")
	}
	if m.ActiveEntities() != 1 {
		t.Fatalf("entities after re-resume = %d", m.ActiveEntities())
	}
}

重复重连测试同时断言实体计数保持为 1。只检查返回值不够:返回 Duplicate 但仍创建实体,是最容易漏掉的实现错误。

八、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch08/lockstep && go vet ./ch08/lockstep/ && go test ./ch08/lockstep/ -count=1
ok  	gspractice/ch08/lockstep	0.371s

小结

重连恢复的是战斗实体,不是账号会话。请求必须带战斗 ID、玩家 ID、最后确认帧和会话凭证;恢复优先用最近快照加增量输入,超出窗口就明确拒绝。重复重连必须以会话键做幂等,否则两个实体同时驱动同一角色,状态和结算都会错乱。

常见错误与边界

  • 重连走完整登录流程,战斗侧创建第二个实体。
  • 不带最后确认帧,从错误的起点重放,状态持续漂移。
  • 快照没有年龄上限,用早已淘汰的状态补齐当前帧。
  • 重复重连不幂等,同一玩家出现两个角色。
  • 断线期间角色继续行动却不通知队友,体验和结算都失真。
  • 返回内部状态切片给调用方,调用方修改后污染后续恢复。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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