网关最繁忙的代码不是消息转发,而是连接管理。一个玩家从点击“进入游戏”到出现在主城,连接要经历建连、首包认证、会话绑定、心跳维持和最终的关闭或重连。任何一步缺少边界,都会留下幽灵连接、重复登录或认证绕过。
本节按生命周期拆开这五个阶段,重点讲两件事:认证如何在网关完成但不把登录服密钥扩散出去,以及连接注册表如何在高并发下保持正确、在异常断开后及时清理。最后给出一个可运行的 ConnRegistry,用测试锁住重复绑定、超时清理和并发注册这三条最容易回退的不变量。
一、连接生命周期
一条游戏长连接通常经历五个阶段。每个阶段都要有明确的超时和失败处理,不能依赖“客户端会自己断开”。
| 阶段 | 网关动作 | 默认边界 |
|---|---|---|
| 建连 | 接受 TCP/WebSocket,分配 connID,记录来源 IP 与网关 ID | 建连本身受 accept 队列与文件描述符限制 |
| 首包认证 | 读取认证包,校验 token,提取 accountID | 5 秒内未收到合法认证包则关闭 |
| 会话绑定 | 写入 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 |
TestConcurrentRegistration | 256 个并发注册 | 全部成功,数量与查询一致 |
并发测试建议在 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 是否还活着;游戏服可以有自己的业务心跳,用于判断玩家是否在操作。两者目的不同,不要用一个心跳同时承担连接保活和挂机检测。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。