一、一条连接不够:绑定的三个键
玩家在网关上的身份不是一条 TCP 连接。手机切后台、Wi-Fi 换 4G、双端同时登录,都会让同一角色拥有多条连接。绑定首先要选对键:
| 键 | 唯一性 | 生命周期 | 适合承担 |
|---|---|---|---|
| accountID | 账号级 | 跨区、跨角色长期存在 | 账号策略、封禁、商城 |
| playerID | 角色级 | 角色创建到删除 | 会话绑定、状态归属、路由 |
| connID | 单条连接 | 建连到断开,重连即变 | 读写、心跳、关闭 |
| gatewayID | 网关实例 | 实例启动到退出 | 定位连接所在网关 |
| sessionID | 一次登录 | 登录到踢下线 | 凭证校验、顶号 |
绑定表的主键必须是 playerID。用 accountID 当键,同一账号的第二个角色会把第一个角色顶掉;用 connID 当键,重连后找不到原来的角色状态。accountID 只做账号级判断,真正的连接归属记录在 playerID 上。同一账号允许多角色在线时,这一点尤其重要。
二、会话状态机与非法迁移
绑定关系之上还有一层会话状态。只记录“已绑定”不够,服务端要知道玩家是否选完角色、是否在战斗:
| 状态 | 进入条件 | 允许的下一步 |
|---|---|---|
| 认证态 | token 校验通过 | 选角、断开 |
| 选角态 | 收到选角请求 | 在线、返回认证态 |
| 在线态 | 角色数据加载完成 | 战斗、切场景、断开 |
| 战斗态 | 进入副本或对局 | 在线、断线重连 |
状态迁移必须由服务端裁决。玩家还在选角态就发“进入战斗”,直接拒绝并回错误码,不要顺手帮他切到在线态再放行。每次迁移写一条审计记录(playerID、from、to、connID、version),出问题时才能还原是谁在什么连接上触发了迁移。断线时状态不直接删除:在线态转“离线保留”,战斗态转“战斗托管”,由重连或结算流程收敛。
三、重连语义:新连接顶替旧连接
重连不是“再绑一次”,而是一次带版本的顶替:
- 新连接完成认证,携带 playerID 与上次的 sessionID。
- 绑定器比较版本,新连接拿到 version+1,旧绑定被摘除。
- 向旧连接发送 REPLACED 通知,发送失败只记录,不阻塞新连接。
- 旧连接上的未完成请求按 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 删除,旧连接的迟到回调删掉新连接。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。