版本更新日,凌晨 3 点。
公告写着"停机维护 5 分钟"。
运维敲下重启命令的那一秒:
在线人数 128,400 → 0 (2 秒内) 登录 QPS 800 → 47,000 (30 秒后) 登录服 CPU 100%,全线超时 实际恢复 ★ 41 分钟5 分钟的维护,变成了 41 分钟的事故。
复盘会上,架构图被投到大屏上——客户端的连线直接连到了 6 个后端服务。
一个资深工程师看了三秒,说了一句:
“你们的玩家,是直接站在后厨里点菜的。”
第一幕:先分清三个"网关"
中文里"网关"这个词被严重超载了。三个完全不同的东西,用了同一个名字。
| 名字 | 在哪一层 | 干什么 | 本文讲哪个 |
|---|---|---|---|
| 默认网关(Default Gateway) | 网络层 | 你家路由器的 IP,192.168.1.1,帮你把包送出局域网 | ❌ |
| API 网关(API Gateway) | 应用层 | HTTP 请求的统一入口:鉴权、限流、路由 | ✅ 部分 |
| 游戏网关服(Gate Server / 接入层) | 应用层 | 长连接的统一入口,游戏服务器架构的核心组件 | ✅主角 |
后两个才是我们要说的。它们的角色可以用三个身份概括:
🛃 海关 —— 检查你的证件,决定放不放行 🗣 翻译官 —— 把外面的语言翻译成里面的语言 🛎 前台 —— 记住你是谁,帮你转接到正确的部门第二幕:海关——它挡住了什么
没有网关,你的后厨对全世界开放
❌ 直连架构 客户端 ──┬──→ 登录服 (公网端口 8001) ├──→ 大厅服 (公网端口 8002) ├──→ 战斗服 (公网端口 8003) ├──→ 聊天服 (公网端口 8004) ├──→ 商城服 (公网端口 8005) └──→ 好友服 (公网端口 8006)这张图有六个致命问题:
| 问题 | 后果 |
|---|---|
| 6 个公网暴露点 | 攻击面 ×6,每个都要独立做防护 |
| 6 套鉴权逻辑 | 一处写错就是漏洞;改一次要改 6 遍 |
| 6 条长连接 | 手机要维持 6 条 TCP,6 份心跳,电池哭了 |
| 后端 IP 写死在客户端 | 想换机器?等发版 |
| 任何一个服重启 | 玩家立刻掉线 ←序幕的事故 |
| 内网协议暴露在公网 | 逆向一下,你的内部结构全公开 |
加上网关之后
✅ 网关架构 ┌───────────────────────────┐ 客户端 ──→ LB ──→ │ 网关集群(唯一暴露点) │ 1 条连接 │ 鉴权 · 限流 · 路由 · 翻译 │ └────────────┬──────────────┘ 内网(不暴露公网) ┌──────────┬───────┼───────┬──────────┐ 登录服 大厅服 战斗服 聊天服 商城服海关具体查什么:
① 身份证 —— Token 校验(JWT 验签 / Redis 查会话) ② 违禁品 —— 包长度上限、字段范围、非法 opcode ③ 走私 —— 签名校验、时间戳窗口、nonce 去重(防重放) ④ 人流控制 —— 单 IP 限流、单账号限流、单接口限流 ⑤ 黑名单 —— 封号、封 IP、风控规则🎯海关最大的价值在这一句:
一次检查,后端全信任。
网关校验通过后,给消息打上可信的
playerId标记,
后端服务直接相信它,不再重复校验。内网消息头 = [playerId][sessionId][gatewayId][payload] ↑ 这个 playerId 是网关盖的章,后端不再怀疑⚠️ 前提是:内网必须真的是内网。
一旦有人能直连后端端口,这套信任体系就全面崩塌。
所以后端服务绝不能监听0.0.0.0,必须绑内网 IP + 安全组限制。
第三幕:翻译官——它翻译了什么
翻译一:外网协议 → 内网协议
外面的世界很乱:
| 客户端 | 用什么 | 为什么 |
|---|---|---|
| PC 端 | TCP + 自研二进制 | 网络稳定,简单可靠 |
| 手机端 | KCP / 可靠 UDP | 弱网下延迟低 30~40% |
| H5 / 小游戏 | WebSocket | 浏览器只给这个 |
| 主机端 | 平台 SDK 的专有通道 | 平台要求 |
里面的世界很整齐:
统一的内网 RPC(gRPC / 自研 TCP / 消息队列)网关站在中间,把四种方言翻译成一种官话。
┌── 外网(乱) ──┐ ┌── 网关 ──┐ ┌─ 内网(齐)─┐ TCP + 二进制 ─────→ ─────→ KCP + 二进制 ─────→ 解密 ─────→ 统一 WebSocket ─────→ 解包 ─────→ gRPC 平台通道 ─────→ 转换格式 ─────→💡这带来一个巨大的解耦收益:
后端逻辑服完全不知道玩家是从哪种客户端来的。明年要上 H5 版本?只在网关加一个 WebSocket 接入器,后端一行不改。
翻译二:版本兼容
玩家不会同时更新。灰度期间,线上永远有新旧两个版本的客户端。
// 网关做版本适配,后端只认最新协议switch(conn.ProtoVersion){case3:msg=UpgradeV3ToV4(msg);break;// 老客户端,补字段case4:break;// 最新,直接过default:returnReject("版本过低,请更新");}不这么做的后果:每个后端服务都要写一遍if (version < 4),散落在几十个文件里,没人敢删。
翻译三:TLS 终结
客户端 ──[ TLS 加密 ]──→ 网关 ──[ 明文/轻量加密 ]──→ 后端 ↑ ★ 加解密的 CPU 开销集中在这一层 ★ 证书只需要在网关更新 ★ 后端服务完全不用懂 TLS证书到期是运维事故的高发区。集中在网关,只需要换一个地方。
第四幕:前台——它记住了什么
连接与会话,必须分开
这是网关设计里最核心的一个概念。
连接(Connection):一个 TCP socket / 一个 UDP 会话 ★ 脆弱,会断,绑在某台网关上 会话(Session): playerId、当前在哪个房间、权限、状态 ★ 持久,存在 Redis 里,和连接解绑// 网关本地:只管连接classConnection{Socketsocket;stringsessionId;// ★ 只存一个 IDlonglastRecvTime;}// Redis:存真正的会话// session:{sessionId} = { playerId, roomId, gatewayId, loginTime, ... }为什么必须分开?
玩家从 WiFi 切到 5G → 连接断了 ❌ → ★ 会话还在 ✅ → 重连时带上 sessionId,0.3 秒恢复,游戏世界毫无感知如果会话存在网关内存里:网关一重启,所有人的状态就没了——这就是序幕事故的根因之一。
路由表:前台的通讯录
玩家的一条消息进来 → 网关看 opcode → 决定转给谁 opcode 0x1001 (移动) → 该玩家所在的【房间服 #17】 opcode 0x2001 (聊天) → 聊天服(任意一台,无状态) opcode 0x3001 (买东西) → 商城服(任意一台) opcode 0x4001 (组队邀请) → 好友服两类路由,处理方式完全不同:
| 类型 | 例子 | 怎么路由 |
|---|---|---|
| 无状态服务 | 聊天、商城、邮件 | 随便挑一台,轮询/最少连接 |
| 有状态服务 | 房间服(战斗) | 必须固定到那一台(房间在谁那儿就发给谁) |
扇出(Fan-out):网关最被低估的能力
这是一个能省掉 90% 内网带宽的设计。
❌ 逻辑服直接广播 房间服 ──→ 玩家1 ──→ 玩家2 ... ──→ 玩家100 ★ 发 100 份一模一样的数据 ✅ 网关扇出 房间服 ──→ 网关A("这份发给你这边的 25 人") ──→ 网关B(25 人) ──→ 网关C(25 人) ──→ 网关D(25 人) ★ 只发 4 份 ↓ 网关本地复制 25 份发出去算一笔账(100 人房间,30Hz,快照 400 字节):
| 房间服出口带宽 | |
|---|---|
| 直接广播 | 100 × 30 × 400 B =1.2 MB/s / 房间 |
| 网关扇出(4 个网关) | 4 × 30 × 400 B =48 KB/s / 房间 |
一台房间服跑 30 个房间:288 Mbps → 11.5 Mbps。降到 1/25。
🔥这个优化的精妙之处:
复制数据这件事,越靠近出口做,越便宜。
在房间服做,要过一次内网;在网关做,直接就出去了。
第五幕:射击游戏里的六个战场
战场一:后端热更新,玩家不掉线(序幕结案)
目标:逻辑服发版,玩家无感知。
关键在于:断开的是"网关↔后端",不是"玩家↔网关"。
① 大厅服要更新 ↓ ② 网关检测到大厅服下线 → ★ 不断开玩家连接 ↓ ③ 玩家发来的大厅请求 → 网关暂存到队列(最多 3 秒) ↓ ④ 新版本大厅服起来,注册到服务发现 ↓ ⑤ 网关把队列里的请求重放过去 ↓ ★ 玩家只感觉"卡了一下",没掉线但战斗房间不能这么玩。房间服带着完整对局状态,不能中途重启。
房间服的发版策略:
① 新版本房间服上线,开始接收【新房间】 ② 老版本房间服停止接收新房间,★ 把手上的局打完 ③ 最后一局结束(最多 15 分钟)→ 下线这叫"生命周期对齐发版"——利用对局本身是短生命周期的特点,等它自然结束,而不是打断它。
结果:同样的更新,在线人数曲线上看不出任何凹陷。
战场二:开服洪峰,网关当闸门
现象:新赛季 0 点,12 万人同时涌入。
没有网关时:所有人直接冲向登录服和大厅服 → 数据库连接池耗尽 → 雪崩。
网关的四道闸门:
① 连接层限流 单 IP 每秒最多 5 个新连接(防单机脚本刷) 全局新连接速率上限 3000/s(超过的直接排队) ② 排队系统 超过承载量 → 不拒绝,发号 返回 { queuePos: 8420, eta: 62s } ★ 玩家能接受排队,不能接受"未知错误" ③ 舱壁隔离 登录路由 / 大厅路由 / 商城路由 各有独立的后端连接池 ★ 登录打满时,商城照常卖 ④ 熔断降级 大厅服超时率 > 50% → 熔断,返回明确的"服务繁忙" ★ 不让客户端盲目重试,火上浇油特别注意第①条里的一个细节:
// ❌ 只按 IP 限流:网吧/公司出口一个 IP,全被误杀// ✅ IP 限流 + 账号限流 + 设备指纹,多维度if(!ipLimiter.TryAcquire(ip)&&!IsKnownGateway(ip))Reject();战场三:背压——网关自己别被压垮
现象:网关内存持续上涨,最后 OOM 重启,带走 3 万玩家。
根因:某个玩家的网络极慢(2G 网络),网关往他的 socket 写数据写不出去,发送队列无限堆积。
房间服 30Hz 推快照 → 网关往慢速客户端写 → 写不出去,塞进队列 → 队列越来越长 → ★ 一个玩家吃掉几百 MB 内存三层防御:
// ① 队列上限 + 丢弃策略if(conn.sendQueue.Count>MAX_QUEUE){// ★ 状态快照是【可丢弃】的——丢旧的,保留最新的conn.sendQueue.DropOldestUnreliable();metrics.BackpressureDrops++;}// ② 区分可靠 / 不可靠// 位置快照:队列满了随便丢,下一帧就来了// 结算消息:绝不能丢,宁可断开这个连接// ③ 超限踢人if(conn.sendQueue.Count>HARD_LIMIT){conn.Close("网络状况过差");// 保护整个网关}💡一条重要原则:
网关必须假设"有些客户端就是很慢",并且保证它拖不垮其他人。
这叫"故障隔离"——一个玩家的问题,不应该变成一万个玩家的问题。
战场四:大厅到战斗的"重定向"
匹配成功了,玩家要从大厅转到战斗房间。
方案 A:换一条连接(不推荐)
网关告诉客户端:"去连 battle-server-17.game.com:9017" ↓ ★ 战斗服暴露公网 → 攻击面扩大 ★ 重新建连 + TLS 握手 → 1~2 秒空白 ★ NAT/防火墙可能连不上那个端口方案 B:连接不变,只换路由(推荐)
客户端连接保持不变 ↓ 网关收到匹配结果:该玩家分配到【房间服 #17 的 room_8891】 ↓ ★ 网关更新这条连接的路由表:战斗类消息 → 房间服 #17 ↓ 玩家毫无感知,连接从头到尾就一条收益:
| 方案 A | 方案 B | |
|---|---|---|
| 进战斗耗时 | 1.5~3 s | < 100 ms |
| 公网暴露点 | 多 N 个 | 仍然只有网关 |
| 弱网成功率 | 87% | 99.6% |
战场五:网关也是最好的观测点
所有流量都经过网关 → 它天然是最完整的数据源。
每条消息都能打点: playerId · opcode · 处理耗时 · 后端服务 · 成功/失败 · traceId → 按 opcode 统计 P99 延迟:哪个接口慢了,一眼看出 → 按后端服务统计错误率:哪台机器有问题 → 链路追踪:一个玩家的投诉,能还原他完整的请求路径一个实战用法:玩家报"卡",5 分钟定位
查 traceId → 发现 opcode 0x1001(移动)的网关→房间服 RTT 是 180ms → 而正常是 2ms → 该房间服跑在一台跨机房的机器上(调度失误) → 迁移,问题解决没有网关时,你得分别去 6 个服务翻日志,而且它们的日志格式还都不一样。
战场六:DDoS 防护——收敛攻击面
网关是唯一的公网入口,意味着它也是唯一的攻击目标。
分层防护:
┌ 云厂商清洗(流量型攻击:SYN Flood、UDP Flood) │ ★ 这一层必须靠云厂商,自己扛不住 T 级流量 ├ LB 层(四层):连接数限制、SYN Cookie ├ 网关层(七层): │ · 握手前不分配大对象(防资源耗尽) │ · ★ 未认证连接给极短超时(3 秒内不完成握手就踢) │ · ★ 未认证连接的资源配额单独隔离 │ · 包长度硬上限,解析前先检查 └ 业务层:账号级风控、行为异常检测"未认证连接单独隔离"这一条很关键:
// ❌ 未认证连接和正常玩家共用一个连接池// → 攻击者建 10 万个空连接,正常玩家连不进来// ✅ 分池constintMAX_PENDING=5000;// 未认证连接上限constintAUTH_TIMEOUT_MS=3000;// 3 秒内必须完成认证第六幕:有状态网关的三个难题
网关保存着连接,所以它天然有状态。这带来三个必须解决的问题。
难题一:优雅下线(Draining)
网关要重启,怎么让玩家少受影响?
① 从负载均衡摘除(不再接新连接) ↓ ② ★ 已有连接继续正常服务 ↓ ③ 向客户端推送"建议重连"信号 客户端在【合适的时机】重连(比如一局结束后、在大厅时) ↓ ④ 等待 N 分钟,或等连接数降到阈值以下 ↓ ⑤ 强制关闭剩余连接(客户端自动重连到别的网关) ↓ ⑥ 进程退出⚠️第③步是关键:主动告诉客户端"该换个地方了",而不是粗暴踢掉。
一个正在战斗中的玩家被踢 vs 一个在大厅挂机的玩家被踢——体验天差地别。
难题二:重连要回到同一个网关吗
答案:不需要,而且最好不要。
只要会话存在 Redis 里,重连到【任意一台网关】都能恢复 ↓ ★ 网关本身变成了"准无状态" ★ 可以随意扩缩容但有一个例外:如果玩家在战斗中,房间服正在往旧网关推数据。
解法:会话里记着gatewayId,重连后:
// 新网关接管后,通知房间服更新路由awaitroomService.UpdatePlayerGateway(playerId,myGatewayId);// 房间服后续的推送,改发到新网关难题三:网关挂了怎么办
单台网关承载 5 万连接 → 挂了就是 5 万人同时重连 ↓ ★ 必须防惊群客户端侧:
// 指数退避 + 随机抖动intdelay=Math.Min(500*(1<<retry),15000);delay+=Random.Range(0,delay);// ★ 抖动是关键服务端侧:
① 单台网关不要承载太多(建议 3~5 万,而非 20 万) ② 网关数量冗余(N+2) ③ 会话在 Redis,重连成本低 ④ 限流闸门保护后端Checklist
架构
- ⭐公网只暴露网关,后端服务绑内网 IP
- 连接与会话分离,会话存 Redis,不存网关内存
- 无状态服务随机路由,有状态服务(房间)固定路由
- 大厅→战斗用路由切换,不是重新建连
海关(安全)
- Token 校验集中在网关,后端信任内网标记
- 包长度硬上限(解析前检查)
- 签名 + 时间戳 + nonce 防重放
- 未认证连接独立配额 + 短超时
- 多维度限流(IP + 账号 + 设备),注意网吧共享 IP
翻译官(协议)
- 多端协议(TCP/KCP/WebSocket)在网关统一
- 版本适配集中在网关,后端只认最新协议
- TLS 在网关终结,证书集中管理
性能
- ⭐广播用网关扇出,逻辑服只发一份
- 背压保护:发送队列上限 + 丢弃不可靠数据 + 超限踢人
- 小包合并,心跳搭车
- 零拷贝转发(能不反序列化就不反序列化)
运维
- 优雅下线:摘 LB → 推送重连建议 → 等待 → 强制关闭
- 后端发版不断玩家连接;房间服按对局生命周期发版
- 单网关连接数控制在 3~5 万
- 客户端重连指数退避 + 随机抖动
- 网关埋点:opcode 级延迟、后端错误率、traceId 全链路
收尾
回到那句话——“你们的玩家,是直接站在后厨里点菜的。”
没有网关的架构,本质上是让每一个后端服务都去面对整个互联网:
各自做鉴权、各自扛攻击、各自处理协议兼容、各自暴露一个端口。
而其中任何一个重启,玩家就掉线。
网关做的事,说白了就是在"混乱的外面"和"整齐的里面"之间,立一道墙,再开一扇门。
门口站着海关:查证件、查违禁品、控制人流。
门内站着翻译官:把四种方言,翻译成一种官话。
翻译官旁边是前台:记着你是谁,帮你转接到正确的部门。于是后端的每一个服务,都可以安心地假设:
“能走到我面前的,都是自己人,说的都是我听得懂的话。”
这份"安心",就是网关全部的价值。
它不产生任何游戏逻辑,不计算一次伤害,不渲染一个像素。
但它决定了——
你发一次版,是 5 分钟,还是 41 分钟。