《游戏服务端编程实践》5.2.1 玩家绑定与连接会话

从玩家可能同时拥有多条连接出发,讲清 accountID、playerID、connID 各自承担什么,设计认证、选角、在线、战斗四态会话机与非法迁移拒绝规则,并用带版本的绑定器处理重连顶替、旧连接写失败、迟到解绑和跨网关迁移;附可运行 Go 实现与并发测试。

一、一条连接不够:绑定的三个键

玩家在网关上的身份不是一条 TCP 连接。手机切后台、Wi-Fi 换 4G、双端同时登录,都会让同一角色拥有多条连接。绑定首先要选对键:

键唯一性生命周期适合承担
accountID账号级跨区、跨角色长期存在账号策略、封禁、商城
playerID角色级角色创建到删除会话绑定、状态归属、路由
connID单条连接建连到断开,重连即变读写、心跳、关闭
gatewayID网关实例实例启动到退出定位连接所在网关
sessionID一次登录登录到踢下线凭证校验、顶号

绑定表的主键必须是 playerID。用 accountID 当键,同一账号的第二个角色会把第一个角色顶掉;用 connID 当键,重连后找不到原来的角色状态。accountID 只做账号级判断,真正的连接归属记录在 playerID 上。同一账号允许多角色在线时,这一点尤其重要。

二、会话状态机与非法迁移

绑定关系之上还有一层会话状态。只记录“已绑定”不够,服务端要知道玩家是否选完角色、是否在战斗:

状态进入条件允许的下一步
认证态token 校验通过选角、断开
选角态收到选角请求在线、返回认证态
在线态角色数据加载完成战斗、切场景、断开
战斗态进入副本或对局在线、断线重连

状态迁移必须由服务端裁决。玩家还在选角态就发“进入战斗”,直接拒绝并回错误码,不要顺手帮他切到在线态再放行。每次迁移写一条审计记录(playerID、from、to、connID、version),出问题时才能还原是谁在什么连接上触发了迁移。断线时状态不直接删除:在线态转“离线保留”,战斗态转“战斗托管”,由重连或结算流程收敛。

三、重连语义:新连接顶替旧连接

重连不是“再绑一次”,而是一次带版本的顶替:

  1. 新连接完成认证,携带 playerID 与上次的 sessionID。
  2. 绑定器比较版本,新连接拿到 version+1,旧绑定被摘除。
  3. 向旧连接发送 REPLACED 通知,发送失败只记录,不阻塞新连接。
  4. 旧连接上的未完成请求按 version 作废,响应即使到达也丢弃。

未完成请求最容易被忽略。玩家在旧连接上发起购买,请求已到游戏服、响应还没回,此时新连接顶替。若响应仍写回旧连接,玩家看不到结果,却可能已经扣了钱。正确做法是让请求携带 (playerID, connID, version),响应回到发起时的连接;绑定器发现 version 已过期,就把响应转成“请重新查询订单”的提示。业务侧要有幂等键,不能把可靠性押在连接上。

四、跨网关重连与共享注册表

玩家从 A 网关掉线、从 B 网关重连时,两个网关的本地表互不知情。绑定关系必须放在共享注册表,并用 compare-and-swap 更新:

操作写入内容冲突处理
A 网关注册playerID -> (A, conn1, v1)已存在则读回比较
B 网关重连playerID -> (B, conn2, v2)仅当 v2 > v1 成功
A 网关断开删除 (A, conn1, v1)版本不符则放弃

本地缓存可以有,但只能加速:每次转发前校验版本,发现缓存落后就回源。网关重启后先拉取本网关名下的绑定做对账,把共享表里指向自己的连接恢复或关闭。只写本地表、靠广播“通知一下”的方案,在网络分区时会出现两个网关都认为自己在服务同一玩家。

五、失败场景:旧连接的迟到解绑

某玩家从 c1 重连到 c2,c1 的断开回调晚到 200ms,回调里按 playerID 执行解绑,把 c2 的绑定一起删了。玩家刚进游戏就被判定离线,后续消息全部路由失败。

根因是解绑只认玩家、不认连接和版本。修复方式是 Unbind(connID, version):先查当前绑定,只有 connID 与 version 都匹配才删除。c1 的回调携带 v1,而当前已是 v2,直接返回 false。所有断开、踢人、心跳超时路径都必须走带版本的解绑,不能保留“按玩家删除”的快捷方法。

六、Go 实现:带版本的 Binder

Binder 用 playerID 做主键,同时维护 connID 反查表。Bind 只接受首次上线,Rebind 负责顶替并递增版本,通知旧连接的失败放在 NoticeErr 里返回,不影响新绑定生效。

// file: ch05/forward/binding.go
// tier: L1
// verify: go test ./ch05/forward/

package forward

import (
	"errors"
	"sync"
)

// Conn 是网关侧连接;Write 由网络层注入,nil 表示只绑定不发送。
type Conn struct {
	ID        string
	GatewayID string
	Write     func(msg []byte) error
}

type Binding struct {
	AccountID uint64
	PlayerID  uint64
	Conn      *Conn
	Version   uint64
}

// Replaced 记录一次顶替:Binding 是旧绑定,NoticeErr 是通知旧连接的失败。
type Replaced struct {
	Binding   *Binding
	NoticeErr error
}

var (
	ErrNilConn      = errors.New("nil conn")
	ErrConnBound    = errors.New("conn already bound")
	ErrPlayerOnline = errors.New("player already online, use Rebind")
	ErrInvalidOwner = errors.New("account and player are required")
)

// Binder 以 playerID 为连接键;accountID 只用于审计,同一账号允许多个角色在线。
type Binder struct {
	mu       sync.RWMutex
	byConn   map[string]*Binding
	byPlayer map[uint64]*Binding
	versions map[uint64]uint64
	notify   func(old *Binding) error
}

func NewBinder(notify func(old *Binding) error) *Binder {
	return &Binder{byConn: map[string]*Binding{}, byPlayer: map[uint64]*Binding{}, versions: map[uint64]uint64{}, notify: notify}
}

func validate(conn *Conn, accountID, playerID uint64) error {
	if conn == nil || conn.ID == "" {
		return ErrNilConn
	}
	if accountID == 0 || playerID == 0 {
		return ErrInvalidOwner
	}
	return nil
}

// Bind 只用于首次上线;已有绑定必须走 Rebind,避免误顶掉在线连接。
func (b *Binder) Bind(conn *Conn, accountID, playerID uint64) (*Binding, error) {
	if err := validate(conn, accountID, playerID); err != nil {
		return nil, err
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	if _, ok := b.byConn[conn.ID]; ok {
		return nil, ErrConnBound
	}
	if _, ok := b.byPlayer[playerID]; ok {
		return nil, ErrPlayerOnline
	}
	b.versions[playerID]++
	binding := &Binding{AccountID: accountID, PlayerID: playerID, Conn: conn, Version: b.versions[playerID]}
	b.byConn[conn.ID], b.byPlayer[playerID] = binding, binding
	out := *binding
	return &out, nil
}

// Rebind 让新连接顶替旧连接并递增版本;通知旧连接失败不影响新绑定生效。
func (b *Binder) Rebind(conn *Conn, accountID, playerID uint64) (*Replaced, error) {
	if err := validate(conn, accountID, playerID); err != nil {
		return nil, err
	}
	b.mu.Lock()
	if cur, ok := b.byConn[conn.ID]; ok {
		same := cur.AccountID == accountID && cur.PlayerID == playerID
		b.mu.Unlock()
		if same {
			return nil, nil // 同一连接重复注册是幂等的
		}
		return nil, ErrConnBound
	}
	b.versions[playerID]++
	binding := &Binding{AccountID: accountID, PlayerID: playerID, Conn: conn, Version: b.versions[playerID]}
	old := b.byPlayer[playerID]
	if old != nil {
		delete(b.byConn, old.Conn.ID)
	}
	b.byConn[conn.ID], b.byPlayer[playerID] = binding, binding
	var oldCopy *Binding
	if old != nil {
		snapshot := *old
		oldCopy = &snapshot
	}
	b.mu.Unlock()
	res := &Replaced{Binding: oldCopy}
	if oldCopy != nil && b.notify != nil {
		res.NoticeErr = b.notify(oldCopy)
	}
	return res, nil
}

// Unbind 只在 connID 与 version 仍是当前绑定时解绑,迟到回调不会删掉新连接。
func (b *Binder) Unbind(connID string, version uint64) bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	cur, ok := b.byConn[connID]
	if !ok || cur.Version != version {
		return false
	}
	delete(b.byConn, connID)
	if b.byPlayer[cur.PlayerID] == cur {
		delete(b.byPlayer, cur.PlayerID)
	}
	return true
}

func (b *Binder) Lookup(playerID uint64) (*Binding, bool) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	binding, ok := b.byPlayer[playerID]
	if !ok {
		return nil, false
	}
	out := *binding
	return &out, true
}

func (b *Binder) LookupConn(connID string) (*Binding, bool) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	binding, ok := b.byConn[connID]
	if !ok {
		return nil, false
	}
	out := *binding
	return &out, true
}

七、测试:顶替、写失败与并发重连

测试覆盖四条路径:重复绑定被拒绝、Rebind 返回旧连接且版本递增、旧连接通知失败不影响新绑定、32 个并发重连最终只有一个赢家。

// file: ch05/forward/binding_test.go
// tier: L1
// verify: go test ./ch05/forward/

package forward

import (
	"errors"
	"fmt"
	"sync"
	"testing"
)

func newConn(id string) *Conn { return &Conn{ID: id, GatewayID: "gw-1"} }

func TestRebindSupersedesOldConn(t *testing.T) {
	b := NewBinder(nil)
	first, err := b.Bind(newConn("c1"), 100, 42)
	if err != nil {
		t.Fatal(err)
	}
	res, err := b.Rebind(newConn("c2"), 100, 42)
	if err != nil {
		t.Fatal(err)
	}
	if res.Binding == nil || res.Binding.Conn.ID != "c1" || res.Binding.Version != first.Version {
		t.Fatalf("replaced=%+v", res.Binding)
	}
	cur, ok := b.Lookup(42)
	if !ok || cur.Conn.ID != "c2" || cur.Version != first.Version+1 {
		t.Fatalf("current=%+v", cur)
	}
	if _, ok := b.LookupConn("c1"); ok {
		t.Fatal("old conn still resolvable")
	}
	if b.Unbind("c1", first.Version) {
		t.Fatal("stale unbind must not remove new binding")
	}
	if cur, ok := b.Lookup(42); !ok || cur.Conn.ID != "c2" {
		t.Fatalf("stale unbind broke new binding: %+v", cur)
	}
	if _, err := b.Bind(newConn("c3"), 100, 42); !errors.Is(err, ErrPlayerOnline) {
		t.Fatalf("error=%v, want ErrPlayerOnline", err)
	}
}

func TestRebindReportsOldConnWriteFailure(t *testing.T) {
	writeErr := errors.New("old conn closed")
	notified := 0
	b := NewBinder(func(old *Binding) error {
		notified++
		return fmt.Errorf("notify %s: %w", old.Conn.ID, writeErr)
	})
	if _, err := b.Bind(newConn("c1"), 100, 42); err != nil {
		t.Fatal(err)
	}
	res, err := b.Rebind(newConn("c2"), 100, 42)
	if err != nil {
		t.Fatalf("rebind must succeed: %v", err)
	}
	if !errors.Is(res.NoticeErr, writeErr) || notified != 1 {
		t.Fatalf("notice=%v notified=%d", res.NoticeErr, notified)
	}
	if cur, _ := b.Lookup(42); cur == nil || cur.Conn.ID != "c2" {
		t.Fatalf("current=%+v, want c2", cur)
	}
}

func TestConcurrentRebindKeepsSingleWinner(t *testing.T) {
	b := NewBinder(nil)
	if _, err := b.Bind(newConn("c0"), 100, 42); err != nil {
		t.Fatal(err)
	}
	const n = 32
	var wg sync.WaitGroup
	versions := make(chan uint64, n)
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			res, err := b.Rebind(newConn(fmt.Sprintf("c%d", i+1)), 100, 42)
			if err != nil || res == nil || res.Binding == nil {
				t.Errorf("rebind %d: %+v %v", i, res, err)
				return
			}
			versions <- res.Binding.Version
		}(i)
	}
	wg.Wait()
	close(versions)
	seen := map[uint64]bool{}
	for v := range versions {
		if seen[v] {
			t.Fatalf("duplicate version %d", v)
		}
		seen[v] = true
	}
	if len(seen) != n {
		t.Fatalf("versions=%d, want %d", len(seen), n)
	}
	cur, ok := b.Lookup(42)
	if !ok || cur.Version != uint64(n+1) {
		t.Fatalf("current=%+v, want version %d", cur, n+1)
	}
	b.mu.RLock()
	connCount := len(b.byConn)
	b.mu.RUnlock()
	if connCount != 1 {
		t.Fatalf("indexed conns=%d, want 1", connCount)
	}
}

八、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch05/forward && go vet ./ch05/forward/ && go test ./ch05/forward/ -count=1 -race
ok  	gspractice/ch05/forward	1.626s

小结

绑定的核心是“playerID 定归属、connID 定连接、version 定新旧”。accountID 只能做账号级判断;重连必须递增版本并顶替旧连接;解绑必须带连接与版本,否则迟到回调会误删新会话。把绑定放进共享注册表并用版本比较,才能覆盖跨网关重连。

常见错误与边界

  • 用 accountID 当连接键,同账号的第二个角色顶掉第一个角色。
  • 重连不递增版本,旧连接上的响应继续写回,业务状态被覆盖。
  • 解绑只按 playerID 删除,旧连接的迟到回调删掉新连接。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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