《游戏服务端编程实践》5.1.2 连接管理与认证中转

按建连、首包认证、会话绑定、心跳、关闭重连五个阶段拆解游戏网关的连接管理,说明网关如何验证登录服 token 而不泄露密钥,给出连接注册表的字段设计、并发安全与清理策略,并用可运行的 Go 注册表实现和测试覆盖重复绑定、未认证超时与并发注册。

网关最繁忙的代码不是消息转发,而是连接管理。一个玩家从点击“进入游戏”到出现在主城,连接要经历建连、首包认证、会话绑定、心跳维持和最终的关闭或重连。任何一步缺少边界,都会留下幽灵连接、重复登录或认证绕过。

本节按生命周期拆开这五个阶段,重点讲两件事:认证如何在网关完成但不把登录服密钥扩散出去,以及连接注册表如何在高并发下保持正确、在异常断开后及时清理。最后给出一个可运行的 ConnRegistry,用测试锁住重复绑定、超时清理和并发注册这三条最容易回退的不变量。

一、连接生命周期

一条游戏长连接通常经历五个阶段。每个阶段都要有明确的超时和失败处理,不能依赖“客户端会自己断开”。

阶段网关动作默认边界
建连接受 TCP/WebSocket,分配 connID,记录来源 IP 与网关 ID建连本身受 accept 队列与文件描述符限制
首包认证读取认证包,校验 token,提取 accountID5 秒内未收到合法认证包则关闭
会话绑定写入 accountID、playerID,标记已认证一个连接只能绑定一个玩家
心跳更新 lastSeen,回收空闲连接30 秒心跳,90 秒无心跳关闭
关闭/重连从注册表移除,通知登录服或游戏服关闭回调必须幂等

上面的时间数字是工程默认值,口径为公网移动网络下的常见配置:首包 5 秒给 TCP 重传和一次弱网重试留余量;心跳 30 秒兼顾流量与发现断线的速度;90 秒是三次心跳的容忍窗口。具体项目应根据客户端网络质量和服务器成本调整,但三个值必须写进配置,而不是散落在代码常量里。

建连阶段最容易被忽略的是资源上限。每个连接至少占用一个文件描述符、一段读缓冲、一个写缓冲和若干 goroutine 栈。攻击者可以在不发送任何认证包的情况下建立大量连接,如果网关不限制未认证连接数,内存会先于带宽耗尽。

二、认证中转

游戏登录通常分两段:登录服验证账号密码或第三方凭证,签发一个短期 token;客户端带着 token 连接网关,网关验证 token 后建立游戏会话。网关不应该自己校验密码,也不应该持有登录服的签名私钥。

2.1 两种验证方式

方式做法优点缺点适用
本地验签登录服用私钥签名 JWT,网关用公钥验签无网络往返、吞吐高吊销不及时,依赖短过期时间token 有效期 5–15 分钟
中心校验网关把 token 发给登录服或会话服务校验可实时吊销、可查会话状态每次认证多一次 RPC高风险场景、需要即时踢人

多数项目采用混合方案:网关本地验签,token 有效期较短;同时缓存登录服的吊销列表或会话版本号,踢下线时通过消息通道通知所有网关。这样常规认证没有 RPC,异常场景又能及时生效。

2.2 密钥与 token 的边界

登录服的签名私钥只能存在于登录服或密钥管理服务中,不能下发到网关,更不能写进客户端。网关持有的公钥可以公开,泄露它不会导致 token 被伪造。如果使用对称密钥(HMAC),则所有网关都持有同一把密钥,任何一台被攻破都会导致全站 token 可伪造,这种方案只适合内网且网关数量很少的场景。

token 的传输也有边界。正确做法是放在首包的应用层字段里,通过 TLS 传输;不要把 token 拼在 URL 查询参数上,因为 URL 会进入访问日志、浏览器历史和代理缓存。WebSocket 场景尤其要注意:握手 URL 会被大量中间件记录,token 放 URL 等于主动泄露。

2.3 认证结果里应该有什么

网关验证 token 后,至少需要拿到 accountID、playerID、token_expire_at 和 session_version。accountID 用于连接归属,playerID 用于路由,过期时间用于定期复核,会话版本用于踢下线。不要相信客户端在认证包里自报的 playerID,否则玩家可以伪装成任意角色。

三、连接注册表

连接注册表是网关的“当前连接事实”。它不需要持久化,但必须并发安全、可查询、可清理。最小字段如下:

字段含义用途
connID网关内唯一连接 ID定位连接、关闭连接
accountID账号 ID踢下线、统计账号连接数
playerID角色 ID消息路由、重复登录检测
gatewayID网关实例 ID跨网关查询、故障定位
authenticated是否完成首包认证限制未认证连接、拒绝转发
createdAt建连时间首包超时清理
lastSeen最近心跳时间空闲连接回收

注册表至少需要四个操作:Add 注册新连接,Bind 绑定账号与角色,Remove 移除连接,Lookup 查询连接。生产环境还会加 LookupByPlayer、CountByAccount 等索引,但索引越多,清理时越容易漏删,必须保证所有索引在同一个临界区内更新。

并发安全的关键不是“加锁”,而是“锁的粒度与顺序”。注册表用一把读写锁足够支撑单实例数万连接,因为大多数操作是 O(1) 的 map 访问。不要在每个连接上再套一把锁,否则清理时需要按固定顺序获取多把锁,容易死锁。

清理是注册表最容易出问题的地方。连接可能因为客户端崩溃、网络中断、网关重启、认证超时、被踢下线等多种原因结束,每条路径都必须调用 Remove。更稳妥的做法是把 Remove 设计成幂等操作,并在连接处理函数的 defer 中调用,而不是依赖每个业务分支记得清理。

四、首包超时与未认证限额

首包超时和未认证限额是一对配套参数。只设超时,不设限额,攻击者仍可以在超时窗口内建立海量连接;只设限额,不设超时,慢速攻击者可以长期占住名额。

默认值可以这样定:首包超时 5 秒,口径为公网移动网络 P99 建连加首包约 1.5–3 秒,留出重传余量;未认证连接上限为单实例目标连接数的 10%,例如目标 10,000 连接时上限 1,000。两个值都要可配置,并在监控上暴露“当前未认证连接数”和“首包超时次数”。

认证失败后的重试要有退避。客户端连续失败时,网关可以返回统一的失败码,并要求客户端按 1 秒、2 秒、4 秒、8 秒递增重试,上限 30 秒。服务端同时按账号和 IP 做失败计数,超过阈值后短期拒绝,避免攻击者用重试放大登录服压力。退避参数不要下发给客户端后就不管,服务端仍要独立限流,因为客户端可以被修改。

五、Go 实现:连接注册表

下面的 conn.go 实现了注册表的四个核心操作,并加入首包超时清理、未认证限额和心跳更新。它只用标准库,重点展示不变量而不是网络细节。

// file: ch05/gateway/conn.go
// tier: L1
// verify: go test ./ch05/gateway/

package gateway

import (
	"errors"
	"sync"
	"time"
)

// ConnID is unique inside one gateway process. It is not a session ID: the
// login service issues sessions, the gateway only tracks sockets.
type ConnID string

var (
	ErrConnExists   = errors.New("gateway: connection already registered")
	ErrConnNotFound = errors.New("gateway: connection not found")
	ErrUnauthLimit  = errors.New("gateway: unauthenticated connection limit reached")
	ErrAlreadyBound = errors.New("gateway: connection already bound to another player")
	ErrInvalidBind  = errors.New("gateway: account and player IDs are required")
	ErrInvalidConn  = errors.New("gateway: connection ID is required")
)

// Conn is the gateway-side view of one client connection.
type Conn struct {
	ID            ConnID
	AccountID     uint64
	PlayerID      uint64
	GatewayID     string
	Authenticated bool
	CreatedAt     time.Time
	LastSeen      time.Time
}

// ConnRegistry owns connection facts for one gateway process. It is not a
// session store; authentication still comes from the login service.
type ConnRegistry struct {
	mu          sync.RWMutex
	conns       map[ConnID]*Conn
	maxUnauth   int
	authTimeout time.Duration
	now         func() time.Time
}

// NewConnRegistry builds a registry. The defaults (1024 unauthenticated
// connections, 5s first-packet timeout) are conservative starting values.
func NewConnRegistry(maxUnauth int, authTimeout time.Duration) *ConnRegistry {
	if maxUnauth <= 0 {
		maxUnauth = 1024
	}
	if authTimeout <= 0 {
		authTimeout = 5 * time.Second
	}
	return &ConnRegistry{
		conns:       make(map[ConnID]*Conn),
		maxUnauth:   maxUnauth,
		authTimeout: authTimeout,
		now:         time.Now,
	}
}

// Add registers a freshly accepted socket. Sockets above the unauthenticated
// limit are refused before they can consume a buffer or a handler goroutine.
func (r *ConnRegistry) Add(id ConnID, gatewayID string) error {
	if id == "" {
		return ErrInvalidConn
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.conns[id]; ok {
		return ErrConnExists
	}
	if r.unauthenticatedLocked() >= r.maxUnauth {
		return ErrUnauthLimit
	}
	now := r.now()
	r.conns[id] = &Conn{ID: id, GatewayID: gatewayID, CreatedAt: now, LastSeen: now}
	return nil
}

// Bind promotes a socket to authenticated and attaches the player. Repeating
// the same binding is idempotent; switching identity on a live socket is not.
func (r *ConnRegistry) Bind(id ConnID, accountID, playerID uint64) error {
	if accountID == 0 || playerID == 0 {
		return ErrInvalidBind
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	c, ok := r.conns[id]
	if !ok {
		return ErrConnNotFound
	}
	if c.Authenticated {
		if c.AccountID == accountID && c.PlayerID == playerID {
			c.LastSeen = r.now()
			return nil
		}
		return ErrAlreadyBound
	}
	c.AccountID = accountID
	c.PlayerID = playerID
	c.Authenticated = true
	c.LastSeen = r.now()
	return nil
}

// Remove drops a connection from the registry. It reports whether the
// connection existed so close callbacks can be idempotent.
func (r *ConnRegistry) Remove(id ConnID) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.conns[id]; !ok {
		return false
	}
	delete(r.conns, id)
	return true
}

// Lookup returns a snapshot; callers must not hold a pointer across calls.
func (r *ConnRegistry) Lookup(id ConnID) (Conn, bool) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	c, ok := r.conns[id]
	if !ok {
		return Conn{}, false
	}
	return *c, true
}

// Touch records heartbeat activity for an authenticated connection.
func (r *ConnRegistry) Touch(id ConnID) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	c, ok := r.conns[id]
	if !ok || !c.Authenticated {
		return false
	}
	c.LastSeen = r.now()
	return true
}

// ExpireUnauthenticated removes sockets that did not finish authentication in
// time. Authenticated connections are never removed here; they close through
// heartbeat, logout or an explicit kick.
func (r *ConnRegistry) ExpireUnauthenticated() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	now := r.now()
	removed := 0
	for id, c := range r.conns {
		if !c.Authenticated && now.Sub(c.CreatedAt) >= r.authTimeout {
			delete(r.conns, id)
			removed++
		}
	}
	return removed
}

// Len returns the total number of live sockets.
func (r *ConnRegistry) Len() int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return len(r.conns)
}

// Unauthenticated returns how many sockets are still waiting for a token.
func (r *ConnRegistry) Unauthenticated() int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.unauthenticatedLocked()
}

func (r *ConnRegistry) unauthenticatedLocked() int {
	n := 0
	for _, c := range r.conns {
		if !c.Authenticated {
			n++
		}
	}
	return n
}

代码里有四个设计取舍。第一,Bind 对同一账号、同一角色的重复调用是幂等的,但拒绝把已认证连接改绑到另一个玩家,防止会话劫持。第二,Add 在未认证连接达到上限时直接拒绝,避免攻击者在认证前消耗更多资源。第三,ExpireUnauthenticated 只清理未认证连接,已认证连接由心跳和显式关闭负责。第四,Lookup 返回结构体副本,调用方不能通过指针绕过锁修改注册表。

注册表没有按 playerID 建索引,因为“同一玩家是否已在线”通常要跨网关判断,应该由登录服的会话服务负责,而不是每个网关各自维护一份不完整的视图。网关只需要知道自己这条连接绑定了谁。

六、测试与不变量

conn_test.go 覆盖了八类行为:

测试验证点期望
TestAddLookupRemove注册、查询、移除查询字段正确,重复移除返回 false
TestBindIsIdempotentButCannotMovePlayer重复绑定与改绑重复绑定成功,改绑返回 ErrAlreadyBound
TestBindRequiresIDs非法参数与不存在连接分别返回 ErrInvalidBind、ErrConnNotFound
TestExpireOnlyUnauthenticated超时清理边界只清理未认证连接,已认证连接保留
TestUnauthenticatedLimit未认证限额超限拒绝,绑定后释放名额
TestDuplicateAddIsRejected重复 connID返回 ErrConnExists
TestTouchOnlyAuthenticated心跳更新未认证连接不可 Touch,已认证连接更新 lastSeen
TestConcurrentRegistration256 个并发注册全部成功,数量与查询一致

并发测试建议在 CI 中加 -race 运行。注册表的锁竞争不是性能瓶颈,但漏锁导致的 map 并发写会直接让进程崩溃,这类问题在压测前很难暴露。

七、失败场景:认证通过后注册表泄漏

某网关上线后,监控显示在线人数持续高于登录服的会话数,差值每天增长几千。排查发现,玩家切换角色时客户端会主动断开旧连接再建新连接,但旧连接走的是“协议层关闭”路径,没有触发 Remove;注册表里留下了大量已认证但实际已断开的记录。

这类泄漏的直接后果是内存增长和统计失真,更隐蔽的后果是重复登录判断失效:登录服认为玩家只在线一次,网关却认为同一 playerID 有多个连接,消息可能被送到已经断开的旧连接上。根因不是某个分支忘了清理,而是清理依赖业务代码自觉调用。

修复分三层:第一,所有连接处理函数用 defer registry.Remove(connID) 兜底,保证无论正常返回还是 panic 都会清理;第二,给已认证连接增加 lastSeen 扫描,超过心跳窗口的连接强制移除,作为第二道防线;第三,监控“注册表连接数”与“登录服会话数”的差值,超过阈值告警。三道防线中,defer 是主路径,扫描是兜底,监控是发现机制,缺一不可。

八、落地检查清单

  • 首包认证超时、心跳间隔、空闲超时都有明确配置,不散落为魔法数字;
  • 未认证连接有独立上限,超限在建连阶段拒绝;
  • 网关只持有验签公钥或校验接口,不持有登录服签名私钥;
  • token 通过 TLS 和应用层字段传输,不出现在 URL、日志或客户端明文存储中;
  • 连接注册表的 Add、Bind、Remove、Lookup 并发安全,Remove 幂等;
  • 所有连接处理路径都有 defer 清理,并有超时扫描与数量差值监控兜底;
  • 认证失败有服务端限流和客户端退避,退避参数不能替代服务端限流。

连接建立并绑定之后,下一条消息该发往哪个后端,是下一节的主题,见 /game-server-in-action-513/。如果还在决定要不要引入网关,可以先回到 /game-server-in-action-511/ 看职责归属。

小结

连接管理是网关的地基:建连时限制资源,首包时完成认证,绑定后维护会话,心跳时更新活性,关闭时幂等清理。认证中转的核心边界是“网关验证 token,但不持有登录服私钥”,token 只走 TLS 和应用层字段。连接注册表不需要持久化,但必须并发安全、字段完整、清理彻底。首包超时、未认证限额和失败退避三者配套,才能同时挡住慢速攻击和登录风暴。把这些不变量写进代码和测试,比在上线后靠日志猜“这条连接为什么还在”可靠得多。

常见错误与边界

错误一:认证前就允许转发。 有些实现为了降低首包延迟,在收到任意消息时先转发再异步认证。这等于给未认证连接开了业务入口,攻击者可以绕过登录直接调用游戏服接口。正确做法是认证完成前只允许认证包和心跳包,其余消息一律拒绝。

错误二:连接断开不清理注册表。 依赖每个业务分支调用 Remove 一定会漏。应使用 defer 兜底,并增加超时扫描和数量差值监控。注册表泄漏不会立刻报错,但会在几天后表现为内存上涨和统计失真。

错误三:token 放 URL。 WebSocket 握手 URL、HTTP 查询参数都会进入日志和中间件。token 应放在首包应用层字段或 Authorization Header 中,并且只通过 TLS 传输。

错误四:网关保存登录服私钥。 私钥扩散到网关后,任何一台网关被攻破都会导致全站 token 可伪造。网关只应持有公钥或调用校验接口。

边界一:网关能否缓存认证结果? 可以缓存短 TTL 的验证结果以减少重复验签,但缓存必须带过期时间和会话版本号,踢下线时能主动失效。不能把缓存当成永久登录态。

边界二:一个账号能否有多个连接? 取决于产品策略。单会话策略下,新连接建立时应踢掉旧连接;多端策略下,要限制最大连接数并按设备区分。无论哪种,都应由登录服的会话服务做最终裁决,网关只执行结果。

边界三:心跳应该由网关还是游戏服处理? 网关负责连接级心跳,判断 socket 是否还活着;游戏服可以有自己的业务心跳,用于判断玩家是否在操作。两者目的不同,不要用一个心跳同时承担连接保活和挂机检测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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