很多团队第一次听到“网关”,会把它理解成 Nginx 或 Envoy 的另一个名字:监听端口、转发字节、顺便做点 TLS 卸载。这个理解在 HTTP 业务里大致够用,放到游戏长连接里却会漏掉最关键的部分。玩家从点击登录到进入战斗,中间要经过身份确认、连接绑定、消息路由、频率限制和协议版本协商,这些都不是“反向代理”四个字能覆盖的。
本节先把网关的职责边界讲清楚,再讨论三类部署拓扑、引入网关的真实成本,以及哪些项目根本不该上网关。最后给出一个可运行的 Go 拓扑检查器,把“什么该放网关”从口头约定变成部署前会失败的规则。
一、网关不是“反向代理”的同义词
反向代理的核心问题是“把请求转给哪个上游”,它假设请求彼此独立、可以随时换一条连接。游戏网关面对的是有状态的长连接:一个 socket 在认证后绑定到具体账号和角色,后续每条消息都依赖这份绑定关系。转发错一次,玩家可能看到别人的背包,或者两个区服同时写同一份存档。
| 维度 | 通用反向代理 | 游戏网关 |
|---|---|---|
| 连接模型 | 短连接或连接池,请求无状态 | 长连接,连接与玩家绑定 |
| 路由依据 | Host、Path、Header | accountID、playerID、roomID、协议号 |
| 认证 | 可选,通常透传 Header | 必选,首包认证并建立会话 |
| 失败影响 | 单个请求 5xx,可重试 | 整条连接断开,需重连与状态恢复 |
| 状态 | 尽量无状态 | 连接态必须有,业务态必须没有 |
| 限流粒度 | IP、路由 | 账号、设备、消息类型、房间 |
游戏网关至少要承担六项职责:
- 连接终结:持有 TCP/WebSocket/TLS 连接,处理半包、粘包、心跳和空闲超时;
- 鉴权:校验登录服签发的 token,确认连接属于哪个账号;
- 会话绑定:把 connID 与 accountID、playerID、gatewayID 关联起来,供后续消息路由;
- 路由:按玩家、房间或消息类型选择后端服务与实例;
- 限流:在进入业务逻辑前拦住登录风暴、消息轰炸和异常包;
- 协议适配:处理客户端版本差异、压缩、加密和前后端协议转换。
这六项里,只有“协议适配”可能与业务相关,其余都是接入层职责。判断一段逻辑该不该放网关,可以问一句:它需不需要读写玩家的长期数据?需要,就属于游戏服;不需要,才可能属于网关。
二、三类部署拓扑
游戏接入没有唯一正确答案,常见的是三类拓扑。下面的规模数字是规划起点,不是压测结论:口径为单区峰值在线人数,按 4 vCPU / 8 GiB 的 Go 网关实例、30 秒心跳、每连接 8–16 KiB 缓冲区估算,实际容量必须用目标机器和真实包长实测。
| 拓扑 | 典型规模 | 优点 | 代价 |
|---|---|---|---|
| 客户端直连游戏服 | 单区峰值 ≤ 2,000,且后端本身是长连接服务 | 少一跳、部署简单、故障面小 | 扩缩容会断连,安全与限流逻辑分散在各服 |
| 网关 + 后端服务 | 单区峰值 2,000–50,000 | 连接与业务解耦,后端可无状态扩缩容 | 多一跳,网关成为新的容量与故障点 |
| 边缘接入 + 网关分层 | 单区峰值 > 50,000,或多地域、多运营商 | 就近接入、抗 DDoS、连接收敛 | 链路长、排障难、两层都要维护 |
2.1 客户端直连游戏服
小体量项目最容易被“架构图好看”带偏。一个单区几千人在线的游戏,如果游戏服本来就是长连接服务,再套一层网关只会增加部署单元和故障点。直连的前提是:后端能自己处理 TLS、认证、心跳和限流,且扩缩容频率低。它的最大问题是连接与进程绑定,滚动更新时玩家必然掉线,需要靠停机窗口或重连机制兜底。
直连还有一个隐藏前提:安全边界必须清晰。游戏服直接暴露在公网时,认证、限流、异常包过滤都要自己实现,任何一处遗漏都会变成攻击面。很多团队后期补网关,不是因为性能扛不住,而是因为安全团队要求统一入口。如果早期就预计会有这个要求,可以先把认证与限流做成独立中间件,未来搬到网关时不必重写业务代码。
2.2 网关 + 后端服务
这是最常见的形态。网关负责“连接”,后端负责“世界”。后端可以是无状态的战斗服、房间服或有状态的地图服,网关按 playerID 或 roomID 把消息送到正确实例。好处是后端可以独立扩缩容,网关也可以独立扩容;代价是每条消息多一次序列化与网络往返,且网关必须知道后端拓扑。
落地时要把“连接扩缩容”和“世界扩缩容”分开看。网关按连接数扩容,通常是无状态副本加负载均衡;游戏服按在线玩家或房间数扩容,涉及玩家迁移和状态交接。两者的发布节奏不应绑在一起,否则每次改战斗逻辑都要让网关跟着重启,白白制造掉线。
2.3 边缘接入 + 网关分层
当玩家分布跨地域、跨运营商,或者需要清洗大流量攻击时,会在网关前再加一层边缘接入。边缘层通常由云厂商的加速网络、四层负载均衡或专用接入集群承担,负责就近接入和粗粒度防护;游戏网关负责认证、会话和精细路由。两层都做连接终结时,要明确“真实客户端 IP”和“连接超时”由谁负责,否则日志和风控会互相打架。
三、何时不需要网关
下面这些条件同时成立时,引入网关大概率是负收益:
- 单区峰值在线低于两千,且未来半年没有明显增长计划;
- 游戏服本身已经维护长连接,认证与心跳逻辑完整;
- 只有一个后端服务,没有跨服务路由需求;
- 运维只有一两个人,多一个组件就多一份值班负担;
- 停机更新可以接受,或者已有成熟的重连与补偿机制。
反过来,出现以下任一信号,就该考虑网关:后端需要频繁滚动更新、同一账号要跨多个服务路由、需要按账号而不是按 IP 限流、客户端协议版本开始分叉、或者安全团队要求统一入口。
还有一个常被忽略的判断依据:团队是否具备独立维护接入层的能力。网关涉及 TLS 证书轮换、连接数监控、心跳参数调优、重连风暴处置,这些工作与业务开发是两种技能。如果没有人能对“凌晨两点网关连接数异常”负责,那么引入网关只是把故障从游戏服转移到一个没人熟悉的组件上。
四、网关引入的成本
网关不是免费的抽象层,它至少带来四类成本。
| 成本 | 具体表现 | 缓解手段 |
|---|---|---|
| 多一跳 | 同可用区通常增加 0.1–0.5 ms 单向时延(工程经验区间,需本地实测) | 网关与后端同区部署,减少跨区调用 |
| 故障点 | 网关进程崩溃会断开该实例上的全部连接 | 多实例、优雅下线、客户端抖动重连 |
| 状态不同步 | 网关缓存的会话与登录服、游戏服不一致 | 会话以登录服为准,网关只缓存短 TTL 路由 |
| 发布耦合 | 网关协议变更要求客户端与后端同时升级 | 协议版本协商、灰度发布、双版本窗口 |
其中“状态不同步”最容易被低估。网关为了路由会缓存 playerID 到后端的映射,这份缓存一旦过期,消息就会送到旧实例。正确做法是给缓存设置短 TTL,并在后端返回“玩家不在这里”时主动失效,而不是把网关缓存当成事实源。
容量规划也要把网关算进去。假设单区峰值 30,000 在线,网关单实例稳定承载 10,000 条连接,那么至少需要 3 个实例;再按 N+1 冗余部署 4 个,单实例故障只影响 25% 的连接。这个数字必须来自压测:连接内存、心跳频率、消息包长、TLS 握手开销都会改变结果。不要用“一台机器能开 100 万端口”这类理论值做容量决策。容量评审至少要包含网关实例数、单实例连接上限、故障冗余和重连风暴四个变量。
五、职责归属表
把职责写进表格,比在评审会上争论“这个逻辑放哪”更有效。下面是一份可以直接用于项目评审的归属建议。
| 职责 | 网关 | 游戏服 | 说明 |
|---|---|---|---|
| TLS 终结 | 负责 | 不负责 | 网关统一证书与加密套件,后端走内网明文或 mTLS |
| 心跳与空闲超时 | 负责 | 可选 | 网关维持连接活性,游戏服只关心业务心跳 |
| 首包认证 | 负责 | 不负责 | 网关验签后把 accountID/playerID 注入内部消息 |
| 消息路由 | 负责 | 不负责 | 按 playerID/roomID 选择后端实例 |
| 全局限流 | 负责 | 可选 | 网关按账号、IP、消息类型限流,游戏服做业务级限制 |
| 访问日志 | 负责 | 负责 | 网关记连接与路由,游戏服记业务事件,靠 traceID 关联 |
| 游戏逻辑 | 禁止 | 负责 | 伤害计算、掉落、任务状态都属于游戏服 |
| 玩家长期数据 | 禁止 | 负责 | 网关不得落库玩家背包、等级、位置 |
| 会话事实源 | 不负责 | 登录服负责 | 网关只缓存路由,不判定登录态是否有效 |
这张表有两个容易误读的地方。第一,“网关无状态”指的是没有业务状态,不是没有连接状态;连接注册表、路由缓存都是状态,只是它们可以随进程消失而重建。第二,“游戏服负责日志”不意味着网关不记日志,接入层日志恰恰是排障的第一现场。
第三个误读是“限流只能放网关”。网关适合做粗粒度、低成本的限流,例如单 IP 建连速率、单账号每秒消息数;游戏服仍需要业务级限制,例如同一副本每秒释放技能次数。两层限流不是重复建设,而是不同粒度的防线:网关挡住洪水,游戏服挡住异常业务行为。
六、Go 实现:拓扑规则检查
下面的 role.go 把上面的职责表压缩成可执行的检查。它不连接任何服务,只回答一个问题:这份部署方案是否把不该放的职责放到了网关。
// file: ch05/gateway/role.go
// tier: L1
// verify: go test ./ch05/gateway/
package gateway
import "fmt"
// Owner identifies which tier may implement a concern.
type Owner string
const (
OwnerGateway Owner = "gateway"
OwnerGame Owner = "game"
)
// Concern is one cross-cutting responsibility in the access path.
type Concern string
const (
ConcernTLS Concern = "tls"
ConcernHeartbeat Concern = "heartbeat"
ConcernAuth Concern = "auth"
ConcernRouting Concern = "routing"
ConcernRateLimit Concern = "ratelimit"
ConcernAudit Concern = "audit"
ConcernGameLogic Concern = "game_logic"
)
var requiredConcerns = []Concern{
ConcernTLS,
ConcernHeartbeat,
ConcernAuth,
ConcernRouting,
ConcernRateLimit,
ConcernAudit,
ConcernGameLogic,
}
// TrafficPath records how client traffic reaches the game server.
type TrafficPath string
const (
Direct TrafficPath = "direct"
GatewayTier TrafficPath = "gateway"
EdgeAndGateway TrafficPath = "edge+gateway"
)
// Placement is the reviewed answer to "who owns what".
type Placement struct {
Owners map[Concern]Owner
GatewayStoresPlayer bool
Path TrafficPath
}
// Violation is a topology rule breach. Code stays stable for dashboards.
type Violation struct {
Code string
Detail string
}
func (v Violation) Error() string { return fmt.Sprintf("%s: %s", v.Code, v.Detail) }
const (
CodeMissingOwner = "missing_owner"
CodeBusinessAtGateway = "business_at_gateway"
CodeStateAtGateway = "state_at_gateway"
CodeUnknownPath = "unknown_path"
)
// ValidatePlacement checks the rules that should fail a deployment review.
func ValidatePlacement(p Placement) []Violation {
var out []Violation
for _, c := range requiredConcerns {
if _, ok := p.Owners[c]; !ok {
out = append(out, Violation{CodeMissingOwner, string(c)})
}
}
if p.Owners[ConcernGameLogic] == OwnerGateway {
out = append(out, Violation{CodeBusinessAtGateway, "game_logic must stay in the game server"})
}
if p.GatewayStoresPlayer {
out = append(out, Violation{CodeStateAtGateway, "gateway must not persist player state"})
}
switch p.Path {
case Direct, GatewayTier, EdgeAndGateway:
default:
out = append(out, Violation{CodeUnknownPath, string(p.Path)})
}
return out
}
// Recommend returns a planning default for one region. The thresholds are not
// measured limits: 2k online still fits one process, and 50k is roughly where
// a separate edge tier starts to pay for its extra hop.
func Recommend(peakOnline int, backendKeepsLongConn bool) TrafficPath {
switch {
case peakOnline <= 2000 && backendKeepsLongConn:
return Direct
case peakOnline <= 50000:
return GatewayTier
default:
return EdgeAndGateway
}
}
代码里有三个关键点。ValidatePlacement 先检查必填职责是否都有归属,再检查两条硬规则:游戏逻辑不能在网关,网关不能保存玩家状态。Recommend 把拓扑选择变成可测试的函数,阈值只是规划默认值,真实项目应按压测结果调整。Violation.Code 保持稳定,便于接入发布流水线或监控看板。
七、测试与不变量
role_test.go 用表驱动覆盖了规则回退:
| 测试 | 输入 | 期望 |
|---|---|---|
TestValidPlacementHasNoViolation | 完整且合规的职责表 | 无违规 |
TestBusinessLogicAtGatewayIsRejected | 游戏逻辑归网关 | business_at_gateway |
TestGatewayStoringPlayerStateIsRejected | 网关保存玩家状态 | state_at_gateway |
TestMissingConcernIsReported | 缺少认证职责 | missing_owner: auth |
TestUnknownPathIsReported | 拓扑类型写成 mesh | unknown_path |
TestRecommend | 六组规模与后端形态 | 与阈值表一致 |
这些测试的价值不在于覆盖率,而在于把“网关不写业务”从代码规范变成构建失败。新同学把一段掉落计算塞进网关时,只要职责表更新,检查就会报错。
实际接入流水线时,可以把 ValidatePlacement 的输出转成发布门禁:出现 business_at_gateway 或 state_at_gateway 直接阻断,出现 missing_owner 要求补充评审记录。规则本身不复杂,难的是让团队接受“部署方案也要被测试”。一旦这条门禁建立,架构评审就从“凭经验争论”变成了“看检查结果”。
八、失败场景
8.1 网关重启导致全部玩家掉线
某项目为了修一个内存泄漏,在晚高峰滚动重启网关。单实例承载 8,000 条连接,四个实例均匀分布,重启一个理论上只影响 25% 的玩家。实际却出现了全服卡顿:被断开的 8,000 名玩家在同一秒重连,登录服 QPS 瞬间涨到平时的 40 倍,认证队列积压,连没被重启的玩家也开始超时。
根因不是重启本身,而是重连没有削峰。可行的处置是:下线前先停止接受新连接,向客户端发送“服务器维护,N 秒后重连”的通知;客户端在 N 秒上叠加 0–30 秒随机抖动;登录服对同一账号的重连做令牌桶限流;网关保留短时间的会话恢复票据,避免重复走完整登录流程。重启不可避免,重连风暴可以设计。
8.2 网关与后端版本不匹配
网关和后端通常由不同团队维护。一次协议升级中,网关开始发送带 trace_id 的新消息头,后端仍是旧版本,解析时把未知字段当成消息体,导致玩家移动指令被丢弃。监控上表现为“连接正常、心跳正常、玩家不动”,排障难度很高。
正确做法是在连接建立时做版本握手:网关与后端交换 protocol_version 和 feature_flags,不兼容就拒绝接入并告警,而不是带着错误假设继续转发。发布顺序上,先让后端支持新旧两个版本,再升级网关,最后移除旧版本,至少保留一个发布窗口的兼容期。
排查版本不匹配时,按以下顺序检查:
- 网关进程实际加载的协议版本,而不是配置文件里写的版本;
- 后端声明支持的最小版本与最大版本区间;
- 消息头是否被中间层截断或重写,尤其是自定义字段;
- 灰度批次中是否有新旧网关同时连接同一后端组。
这四项里,第一项和第四项最容易出错:配置热加载失败会让网关继续用旧版本,灰度批次混跑则会让问题只在部分玩家身上出现。
九、落地检查清单
- 职责表已评审,TLS、心跳、认证、路由、限流、日志都有明确归属;
- 网关不包含游戏逻辑,不持久化玩家长期数据;
- 网关只缓存短 TTL 的路由信息,会话有效性以登录服为准;
- 拓扑选择有容量依据,而不是照搬大厂架构图;
- 网关支持优雅下线,客户端重连带随机抖动;
- 网关与后端有协议版本握手和灰度发布方案。
检查清单不是一次性文档。每次拓扑变更、每次协议升级、每次容量调整,都应该重新过一遍清单,并把结论记录到变更单里。
下一节讨论连接建立后的具体管理:首包认证、连接注册表、超时清理与重连,见 /game-server-in-action-512/。消息进入网关之后如何选择后端,则在 /game-server-in-action-513/ 展开。
小结
游戏网关不是反向代理的别名,它是长连接的接入层:终结连接、验证身份、绑定会话、选择后端、限制流量、适配协议。三类拓扑各有适用规模,直连适合小体量长连接后端,网关加后端适合大多数中型项目,边缘接入加网关适合大规模与多地域场景。引入网关的代价是多一跳、多故障点、状态不同步和发布耦合,这些成本必须在设计阶段就写进容量与预案。把职责归属做成可执行检查,比事后在故障复盘里争论“这段代码谁写的”更有价值。
常见错误与边界
错误一:网关做业务逻辑。 一旦网关开始计算伤害、掉落或任务进度,它就从接入层变成了第二个游戏服,扩缩容、回档和灰度都会失控。网关只做与连接和路由相关的事。
错误二:宣称网关无状态,却在内存里保存玩家数据。 连接注册表和路由缓存可以随进程消失,玩家背包、等级、位置不行。判断标准是“进程重启后这份数据能否从事实源重建”。
错误三:所有流量都必须过网关。 运维通道、内部服务调用、离线批处理不需要经过玩家网关。把所有流量都塞进同一层,会让网关的故障半径覆盖整个系统。
错误四:为了架构图好看而引入网关。 单区小服直连更简单。网关应该解决具体问题,而不是证明团队会画分层图。
边界一:网关能否做协议转换? 可以,但要限制在接入协议与内部协议的边界上,例如 WebSocket 转内部 RPC。不要把 A 游戏的业务协议翻译成 B 游戏的业务协议,那属于业务逻辑。
边界二:网关能否缓存玩家状态? 可以缓存只读、短 TTL、可重建的摘要,例如玩家所在的后端实例。写入型状态必须回到游戏服,并由游戏服负责一致性。
边界三:边缘接入层能否替代网关? 边缘层擅长就近接入和流量清洗,但不理解 playerID 与房间路由。两者职责不同,大规模项目通常需要配合使用,而不是二选一。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。