技术分享 · 核心网关重写 · 行为一致性与安全上线

本文分享某游戏服务器基建 AI 重构实践:将原有 Go 实现的 ApiGateway 重构为 Rust。

网关是全服 HTTP 统一入口,承载登录、支付、商城流量,负责鉴权、限流、熔断、灰度分流,转发至下游数十类后端服务;依托 AI Coding 搭建幂等测试保障稳定,顺利全量上线。落地效果:性能提升 5 倍,平均延迟降 3%,P90 延迟降 10%。

从 Go 到 Rust:一次核心网关重写,如何安全上线,我们实践下来容器规模减少 84%,难点却不在“更快”,而在“不改变原有行为”。先说结果:

服务器容器数量 / 成本
降低 84%
线上实际性能
Rust 是 Go 的 5 倍
切换当日平均延迟
−3%
P90 −10% · 在线人数约 +5%
请求失败率
持平
与上周同期相比
AI 代码占比
100%
AI 产出 · 人工决策和验收

把一个核心网关换成另一种语言,真正困难的是证明:它不仅更快,而且没有悄悄改变原来的规则。登录校验、限流、熔断、灰度分流,任何一处细微差异,都可能落到真实用户身上。

这篇文章主要回答三个问题:

  1. 优化 Go 之后,重写是否仍然值得?
  2. 如何证明新旧实现对外行为一致?
  3. AI 写完代码后,人应该把精力放在哪里?

一、为什么要动一个不能出问题的入口

ApiGateway 是客户端 HTTP 请求的统一入口。登录、支付、组件商城、云存档、联机大厅、好友社交等两千多个协议,都要先经过它,再转发到下游服务。

这意味着两件事:

  • 它慢一点,几乎所有业务都会跟着变慢;
  • 它错一点,影响的不是单个功能,而是整条服务链路。

旧版使用 Go,已经稳定运行多年。低负载下没有明显问题,但在高并发压测和线上异常流量下,几个长期隐藏的瓶颈被放大了:

  • 兜底路由每次请求都重新编译正则,制造大量临时对象和 GC 压力;
  • UID、IP 两类限流依赖全局锁,高并发时请求在锁上排队;
  • 熔断框架为每个请求创建额外的 goroutine、channel 和 timer,并存在状态表无界增长的问题;
  • Redis 连接池过小,压力上来后频繁重新建连。

其中有些是存量实现的问题,并不是 Go 语言的问题。如果直接拿旧版与 Rust 对比,再把全部收益归因于语言选择,结论并不公平。

所以我们先问了一个更严格的问题:把 Go 版常规可做的优化补齐后,Rust 还剩多少优势?

二、先优化 Go,再决定是否重写

1. 两种口径必须分开

第一轮 4 核真实协议混合压测中,现网 Go 的饱和吞吐约为 15,357 QPS,Rust 为 84,676 QPS,相差 5 倍。

但这组数字包含了旧版中几个明显的实现问题。为避免“Rust 赢在 Go 写得差”的争议,我们给 Go 补上了任何团队都会做的常规优化:

  • 正则预编译;
  • 扩大 Redis 连接池;
  • 将监控计数从全局锁改为原子操作;
  • 调整 GOGC;
  • 预分配缓冲区、复用对象、缓存 JSON 值。

我们没有把重写级优化算进公平口径,例如替换整个熔断框架、改用事件循环网络库,或大幅改变请求对象的生命周期。因为做到这一步,投入已经接近重新实现一个网关。

优化过程中的实测结果如下:

图 1 · Go 常规优化后,差距由 5× 收敛到 4.0×
4 核真实协议混合压测 · 橙色为 Go 常规优化,蓝色为 Rust;灰色仅作参考,不计入 4.0× 口径。
0 30k 60k 90k 吞吐天花板(QPS)· 4 核 Go 基线(现网版) 15,357 · 差 5× 修复明显问题 17,258 · 差 4.9× +GOGC=400 20,071 · 差 4.2× +预分配/对象池 ~21,300 · 差 4.0×(本轮常规优化最好结果) (参考)特殊层优化 25,420 · 差 3.3× —— 不计入公平口径 Rust(原样) 84,676
版本 4 核 QPS 每核 QPS Rust / Go
现网 Go 15,357 约 3.8k 5×
修复明显问题后 17,258 约 4.3k 4.9×
再调整 GOGC 20,071 约 5.0k 4.2×
本轮常规优化后的最好结果 约 21,300 约 5.3k 4.0×
Rust 84,676 约 21.2k 1×

因此,本文后续会同时保留两种说法:

  • 5 倍:Rust 相对现网 Go 的实际差距;
  • 4.0 倍:剥离常规可修问题后,更适合讨论技术选型的差距。

这不是“Rust 的语言上限比 Go 高四倍”。它只说明,在这个网关、这套功能和本轮约定的优化范围内,继续沿用 Go 标准栈的收益已经不足以抵消重写后的差距。

2. Rust 版只抓住两个原则

Rust 版没有追求到处使用复杂技巧,核心设计只有两个。

第一,尽量消除跨线程共享状态。

每个工作线程维护自己的限流和统计状态,连接尽量固定在同一线程处理。这样,大多数请求不需要争抢全局锁。需要保持全进程语义的地方,则按用户稳定分片,而不是简单把阈值除以线程数。

后一个选择来自实测。我们曾尝试按线程数平分限流阈值,结果正常用户更容易被误杀,多连接攻击者反而获得更高额度。设计文档推荐的方案,被测试数据直接否决了。

第二,让无效请求尽早、尽可能便宜地结束。

限流、白名单和未知路由都放在转发前判断。只有通过全部检查的请求,才进入分配内存和访问上游的路径。

图 2 · Go 的共享状态与 Rust 的按工作线程状态
架构示意:尽量减少跨线程共享状态;需要全进程语义的限流按用户稳定分片,不能简单平分阈值。
Go 版 net/http · goroutine-per-conn · 全局共享 客户端流量 net/http accept 每连接 1 goroutine(栈 ≥8KB) 全局共享状态(锁竞争热点) Mutex ×2 限流 hystrix 注册表 监控 rolling 锁 每请求编译正则(DEFAULT) 请求/响应体全量双缓冲 Redis 池 = 4 条连接 GC + 调度器 86% CPU 在用户态 p99 卡在 2.5–15ms 量级 Rust 版 tokio 每核单线程 runtime · share-nothing 客户端流量 SO_REUSEPORT 内核按四元组哈希分发连接 worker / 核 ① 独占限流器(无锁) 独占连接池 + 熔断表 Rc<RefCell> 状态(!Send) worker / 核 ②③④… 同构、互不共享 async task(几百字节) 编译器保证不可跨线程 进程级只读共享:配置快照(ArcSwap)· 原子计数 转发路径零锁零分配:静态拒绝响应 · Bytes 切片复用 · 栈上路由键 内核态 55%(时间花在真正的网络 I/O)· p99 ≈ 240μs
图 3 · 请求越早被拒绝,付出的成本越低
限流、白名单和未知路由在转发前判断;通过全部检查后,才进入分配内存和访问上游的路径。
T1 就地解析 httparse 栈上定长数组 字段借用读缓冲 0 分配 T2 路由决策 栈上 256B KeyBuf 查表 api_disabled / 未知路由 快速拒绝 T3 限流 key 直接对字节流 FNV-1a 不构造 String 每核独占表 通过后:付分配成本 协议解密 → token 校验 → HTTP / gRPC 上游转发 Bytes 引用计数共享,不再拷贝 错误响应在编译期就是 static 字节串(reject.rs 全模块禁止出现 String / Vec / Box / HashMap 的构造)

这两个原则解释了性能提升,却还不能证明新版本可以上线。接下来才是整次重写最难的部分。

三、真正的难点:新实现必须认得旧规则

1. 重写不是重新设计

灰度期间,新旧版本会同时承接流量。此时最怕的不是新版本报错,而是它看起来正常,却与旧版做出了不同决定。

例如,旧版会根据用户 ID 尾号选择当天的灰度用户。这个过程依赖 Go 的随机数序列。如果 Rust 直接使用自己的随机数实现,同一天算出的用户集合就会不同,同一个用户可能在两版之间被分到不同后端。

所以 Rust 版没有“实现一个效果相近的随机算法”,而是复刻了 Go 版的输出序列,并用旧版直接生成的 3,710 组结果逐项比对。

熔断器也遇到了类似问题。滚动窗口边界、错误率的取整方式、半开探测时机,只差一个细节,就会改变某个请求是否被放行。第一版实现曾把窗口理解成 10 个桶,而旧版实际会计算 11 个桶;这个差异最终被对比测试抓到。

这些例子共同指向一个原则:

兼容性重写的标准,不是“新实现看起来合理”,而是“同一个输入,新旧实现给出同一个结果”。

2. 有些旧行为,即使不漂亮,也不能悄悄改

旧版中存在一些已经被业务依赖的历史行为:

  • 路径末尾有多个斜杠时,会全部移除;
  • 限流使用跳变窗口,超限请求仍会计数;
  • 白名单没有配置时默认放行;
  • 配置中的 JSON 字段名大小写不敏感;
  • 某个监控字段长期使用了错误的名称。

从“重新设计”的角度看,这些行为未必都合理。但在灰度阶段擅自修正,会让监控口径或业务行为突然变化,反而掩盖真正的问题。

我们的处理方式是把差异分成两类:

  • 必须兼容的历史行为:先保持一致,全量后再单独评估;
  • 必须消除的结构性风险:例如无界状态表、配置加载失败后继续运行、退出时不排空在途请求。

AI 不被允许静默决定某个差异属于哪一类。所有与旧版不一致的地方都进入差异清单,由负责人明确拍板。

四、AI 写代码,谁来证明它没有写错

这次重写的代码由 AI 产出,人工不逐行手写 Rust。人的主要工作转向了三件事:定义行为契约、决定风险边界、设计验收方式。

1. 先从旧代码中提取契约

项目没有从“把 Go 翻译成 Rust”开始,而是先把旧版的行为拆成可判定的规则。例如:限流 key 如何组成、配置缺失时如何回退、哪些错误计入熔断、监控字段如何输出。

每条规则都标注来源。这样,评审不再依赖“这段 Rust 看起来像不像原来的 Go”,而是可以逐条回答“它是否满足既定行为”。

2. 标准答案由旧版自己生成

验证体系最重要的一条纪律是:

标准答案必须由线上旧代码直接产出。新版本只负责对齐。

如果为了测试,再按照理解重写一份旧逻辑,那么测试代码和新实现可能犯同一个错误,最终得到一种虚假的“一致”。

验证分为四类:

  1. 黄金值对比:旧版直接输出限流、加解密、熔断、监控格式等结果,Rust 逐项复现;
  2. 独立实现互证:优化前后的两份实现对全部路由重复计算,确认优化没有改变结果;
  3. 真实进程测试:真正启动两版服务发送请求,检查模块正确但主流程未接入之类的问题;
  4. 真实流量回放:同一批请求分别发送给两版,只忽略时间戳、随机数等必然变化的字段,其余逐字节比较。

3. “自洽”不等于“一致”

有一次,Rust 版的拒绝响应通过了所有内部检查:响应头里的长度与正文完全匹配。

问题是,正文文案与旧版不同。新版本证明了自己“内部自洽”,却没有证明“对外兼容”。后来测试改为直接使用旧版导出的完整响应作为标准答案,才抓到这个差异。

同一类测试还发现过 Content-Length 与实际正文相差 3 字节的问题。在长连接下,客户端可能一直等待永远不会到来的内容。

这个案例让我们明确区分了两件事:

  • 单元测试擅长证明实现内部是否一致;
  • 新旧对比测试才负责证明迁移是否兼容。

4. 自动化也有边界

QA 阶段发现的主要问题,并不是“同一输入得到不同响应”这么简单,而是自动回放不容易覆盖的场景:

  • 修改或删除配置后,行为是否正确更新;
  • 异常参数走到错误分支时,是否仍保持相同协议;
  • 网关输出的监控数据,能否继续被下游看板正确接收;
  • 两版实现一致时,是否可能一致地存在安全问题。

最后一类尤其重要。对比测试只能回答“两版是否相同”,不能回答“两版是否都正确”。灰度期间的一次安全自查就发现了登录令牌校验的共同缺口。

因此,更准确的结论不是“自动化替代了人工”,而是三种手段各自负责不同边界:

  • 自动化对比负责大规模、可重复的一致性检查;
  • QA 负责配置变化、异常输入和完整业务链路;
  • 安全审查负责发现新旧版本共同继承的问题。

5. 压测数字也必须先通过真实性检查

性能测试同样可能“全绿但结论错误”。最典型的一次是上游端口配置错误,请求全程走失败返回路径,却跑出了非常漂亮的 QPS。

从那以后,每轮测试都必须同时检查错误数、非 200 响应、限流拒绝以及网关日志中的 FAIL/TIMEOUT。任何一项非零,该轮数据直接作废。

其他踩过的坑还包括压测器自身触发限流、发压线程同时突刺、调试构建拖慢吞吐,以及假上游先于网关达到瓶颈。它们共同说明:压测不是把 CPU 打满就可信,必须证明测到的确实是目标链路。

最终采用的 4 核真实协议混合压测结果如下:

图 4 · 两版分别阶梯加压,直到无法达到目标 QPS
4 核真实协议混合压测 · 两版各自阶梯加压,目标不同;下表列出实际达成值及 p99。
0 20k 40k 60k 80k 100k 0 20k 40k 60k 80k 实际吞吐 QPS 目标 QPS(开环定速) 100% 达成参考 Go 15,357(20k 目标达成 77%) Rust 84,676(100k 目标达成 85%) Go(GOMAXPROCS=4) Rust(--workers 4)
目标 QPS Go 实际 Rust 实际 Go p99 Rust p99
2,000 / 10,000 2,000 10,000 400μs 240μs
5,000 / 30,000 5,000 29,999 800μs 432μs
10,000 / 50,000 10,000 49,997 14.7ms 2.2ms
15,000 / 70,000 15,000 69,984 >32ms 9.9ms
20,000 / 100,000 15,357 84,676 — 9.1ms

两边在饱和时 CPU 都已打满,且 err、non200、rejected、FAIL/TIMEOUT 均为 0。

五、最后一关:逐步替换全服入口

通过测试不等于可以一次性切换。

负载均衡层的灰度规则数量有限,而需要覆盖的协议有两千多个。如果只使用粗粒度路径前缀,一条规则可能同时包含普通查询和支付购买;一旦升高比例,高低风险接口会被一起放量。

我们的处理方式分三步。

1. 高风险接口单独控制

登录、兑换、充值、购买等核心写接口从粗粒度规则中提前拆出,按照风险分波次验证。只有这些接口完成灰度后,对应的通用路径规则才允许继续放量。

2. 用算法提高有限规则的覆盖率

脚本从多份路由配置中提取协议,再用贪心集合覆盖挑选最有效的路径规则。100 条规则最终覆盖了 95.2% 的可灰度接口。

未覆盖的长尾接口被明确记录并继续走旧版,而不是为了追求“全覆盖”使用过宽规则。

3. 每一级都可以立即回退

每批规则按 1% → 10% → 50% → 100% 逐步提升。每一级都观察:

  • FAIL/TIMEOUT 是否为 0;
  • 延迟是否与预期一致;
  • 业务指标和用户反馈是否正常。

出现异常时,把比例调回 0 即可立即停止新流量进入 Rust 版。

图 5 · 从 QA 验收到全量切换
以 QA 验收启动日为第 1 天。主要兼容性差异在 QA 阶段前置消化;灰度期间继续观测、业务确认和安全检查,每级均可回退。
第 1~5 天 QA 验收:8 场景逐字相同门禁 主要兼容性差异前置修复 第 7 天 线上灰度启动 波次正则上池 第 7~35 天 · 分波灰度 核心协议逐波 1→10→50→100 AI 拉线上日志对比(9 个日子) 通配解锁 只读→社交→活动→内部调用→计费 100 条规则覆盖 95.2% 第 35 天 全量切换 · Go 版下线 容器规模 −84% 观察口径:按请求数统计 · FAIL/TIMEOUT=0 · p50/p99 同量级 · 业务回报正常 回滚:规则 ratio 改 0,即时生效(约一个月灰度期内未触发整体回滚)

主要兼容性差异在 QA 阶段被前置消化;灰度期间继续进行日志观测、业务确认和安全检查。线上放量不是用来替代测试,而是验证那些无法在测试环境中完全复制的条件。

六、结果与复盘

全量切换后,承载相同线上流量所需的容器规模减少了 84%。

图 6 · 同等流量下,容器规模减少 84%
同等线上流量 · 切换前为 100% 基线,切换后约为 16%;每格代表基线规模的 1%,不是一个容器。
切换前 · 基线 100% 切换后 · 剩 16% −84% 同流量 同等流量 · 容器规模 −84% 在线人数较上周同期约 +5% 平均延迟 −3% · P90 −10% 请求失败率持平 每格 = 切换前容器规模的 1%

切换当天,在线人数比上周同期高约 5%,接口平均延迟下降 3%,P90 下降 10%,请求失败率持平。各下游服务、数据库和在线曲线均无异常。

这次重写最终留下三条最值得复用的经验。

1. 先证明行为一致,再讨论性能

核心系统重写不是一次自由发挥。先从旧实现提取契约,再决定哪些历史行为必须兼容、哪些结构风险必须消除。没有这个边界,性能越好,错误传播得越快。

2. 不要让一种验证方式承担全部责任

黄金值、真实进程、流量回放、人工 QA 和安全审查解决的是不同问题。对比测试可以证明“两版相同”,却不能证明“两版都正确”;单元测试可以证明“内部自洽”,却不能证明“与线上兼容”。

压测也一样。漂亮数字必须同时通过错误日志、响应状态和目标链路检查,否则很可能只是在更快地返回错误。

3. AI 负责规模化产出,人负责不可外包的判断

AI 适合完成大规模、机械化、要求持续对照的工作:提取契约、生成实现、构造黄金值测试、整理差异。

人仍然负责最关键的部分:

  • 决定什么行为可以改变;
  • 判断某个性能口径是否公平;
  • 识别自动化没有覆盖的风险;
  • 决定何时扩大流量、何时回退。

这次项目最反直觉的结论是:让 Rust 快起来并不是最难的,让它在所有重要细节上先表现得和 Go 一样,才是。

最终,用户没有感知到语言切换,流量更高时延迟反而更低,而承载同等流量的服务器容器数量减少了 84%。

从 Go 到 Rust · 先证明行为一致,再讨论性能。