本文分享某游戏服务器基建 AI 重构实践:将原有 Go 实现的 ApiGateway 重构为 Rust。
网关是全服 HTTP 统一入口,承载登录、支付、商城流量,负责鉴权、限流、熔断、灰度分流,转发至下游数十类后端服务;依托 AI Coding 搭建幂等测试保障稳定,顺利全量上线。落地效果:性能提升 5 倍,平均延迟降 3%,P90 延迟降 10%。
从 Go 到 Rust:一次核心网关重写,如何安全上线,我们实践下来容器规模减少 84%,难点却不在“更快”,而在“不改变原有行为”。先说结果:
把一个核心网关换成另一种语言,真正困难的是证明:它不仅更快,而且没有悄悄改变原来的规则。登录校验、限流、熔断、灰度分流,任何一处细微差异,都可能落到真实用户身上。
这篇文章主要回答三个问题:
- 优化 Go 之后,重写是否仍然值得?
- 如何证明新旧实现对外行为一致?
- 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 值。
我们没有把重写级优化算进公平口径,例如替换整个熔断框架、改用事件循环网络库,或大幅改变请求对象的生命周期。因为做到这一步,投入已经接近重新实现一个网关。
优化过程中的实测结果如下:
| 版本 | 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 版没有追求到处使用复杂技巧,核心设计只有两个。
第一,尽量消除跨线程共享状态。
每个工作线程维护自己的限流和统计状态,连接尽量固定在同一线程处理。这样,大多数请求不需要争抢全局锁。需要保持全进程语义的地方,则按用户稳定分片,而不是简单把阈值除以线程数。
后一个选择来自实测。我们曾尝试按线程数平分限流阈值,结果正常用户更容易被误杀,多连接攻击者反而获得更高额度。设计文档推荐的方案,被测试数据直接否决了。
第二,让无效请求尽早、尽可能便宜地结束。
限流、白名单和未知路由都放在转发前判断。只有通过全部检查的请求,才进入分配内存和访问上游的路径。
这两个原则解释了性能提升,却还不能证明新版本可以上线。接下来才是整次重写最难的部分。
三、真正的难点:新实现必须认得旧规则
1. 重写不是重新设计
灰度期间,新旧版本会同时承接流量。此时最怕的不是新版本报错,而是它看起来正常,却与旧版做出了不同决定。
例如,旧版会根据用户 ID 尾号选择当天的灰度用户。这个过程依赖 Go 的随机数序列。如果 Rust 直接使用自己的随机数实现,同一天算出的用户集合就会不同,同一个用户可能在两版之间被分到不同后端。
所以 Rust 版没有“实现一个效果相近的随机算法”,而是复刻了 Go 版的输出序列,并用旧版直接生成的 3,710 组结果逐项比对。
熔断器也遇到了类似问题。滚动窗口边界、错误率的取整方式、半开探测时机,只差一个细节,就会改变某个请求是否被放行。第一版实现曾把窗口理解成 10 个桶,而旧版实际会计算 11 个桶;这个差异最终被对比测试抓到。
这些例子共同指向一个原则:
兼容性重写的标准,不是“新实现看起来合理”,而是“同一个输入,新旧实现给出同一个结果”。
2. 有些旧行为,即使不漂亮,也不能悄悄改
旧版中存在一些已经被业务依赖的历史行为:
- 路径末尾有多个斜杠时,会全部移除;
- 限流使用跳变窗口,超限请求仍会计数;
- 白名单没有配置时默认放行;
- 配置中的 JSON 字段名大小写不敏感;
- 某个监控字段长期使用了错误的名称。
从“重新设计”的角度看,这些行为未必都合理。但在灰度阶段擅自修正,会让监控口径或业务行为突然变化,反而掩盖真正的问题。
我们的处理方式是把差异分成两类:
- 必须兼容的历史行为:先保持一致,全量后再单独评估;
- 必须消除的结构性风险:例如无界状态表、配置加载失败后继续运行、退出时不排空在途请求。
AI 不被允许静默决定某个差异属于哪一类。所有与旧版不一致的地方都进入差异清单,由负责人明确拍板。
四、AI 写代码,谁来证明它没有写错
这次重写的代码由 AI 产出,人工不逐行手写 Rust。人的主要工作转向了三件事:定义行为契约、决定风险边界、设计验收方式。
1. 先从旧代码中提取契约
项目没有从“把 Go 翻译成 Rust”开始,而是先把旧版的行为拆成可判定的规则。例如:限流 key 如何组成、配置缺失时如何回退、哪些错误计入熔断、监控字段如何输出。
每条规则都标注来源。这样,评审不再依赖“这段 Rust 看起来像不像原来的 Go”,而是可以逐条回答“它是否满足既定行为”。
2. 标准答案由旧版自己生成
验证体系最重要的一条纪律是:
标准答案必须由线上旧代码直接产出。新版本只负责对齐。
如果为了测试,再按照理解重写一份旧逻辑,那么测试代码和新实现可能犯同一个错误,最终得到一种虚假的“一致”。
验证分为四类:
- 黄金值对比:旧版直接输出限流、加解密、熔断、监控格式等结果,Rust 逐项复现;
- 独立实现互证:优化前后的两份实现对全部路由重复计算,确认优化没有改变结果;
- 真实进程测试:真正启动两版服务发送请求,检查模块正确但主流程未接入之类的问题;
- 真实流量回放:同一批请求分别发送给两版,只忽略时间戳、随机数等必然变化的字段,其余逐字节比较。
3. “自洽”不等于“一致”
有一次,Rust 版的拒绝响应通过了所有内部检查:响应头里的长度与正文完全匹配。
问题是,正文文案与旧版不同。新版本证明了自己“内部自洽”,却没有证明“对外兼容”。后来测试改为直接使用旧版导出的完整响应作为标准答案,才抓到这个差异。
同一类测试还发现过 Content-Length 与实际正文相差 3 字节的问题。在长连接下,客户端可能一直等待永远不会到来的内容。
这个案例让我们明确区分了两件事:
- 单元测试擅长证明实现内部是否一致;
- 新旧对比测试才负责证明迁移是否兼容。
4. 自动化也有边界
QA 阶段发现的主要问题,并不是“同一输入得到不同响应”这么简单,而是自动回放不容易覆盖的场景:
- 修改或删除配置后,行为是否正确更新;
- 异常参数走到错误分支时,是否仍保持相同协议;
- 网关输出的监控数据,能否继续被下游看板正确接收;
- 两版实现一致时,是否可能一致地存在安全问题。
最后一类尤其重要。对比测试只能回答“两版是否相同”,不能回答“两版是否都正确”。灰度期间的一次安全自查就发现了登录令牌校验的共同缺口。
因此,更准确的结论不是“自动化替代了人工”,而是三种手段各自负责不同边界:
- 自动化对比负责大规模、可重复的一致性检查;
- QA 负责配置变化、异常输入和完整业务链路;
- 安全审查负责发现新旧版本共同继承的问题。
5. 压测数字也必须先通过真实性检查
性能测试同样可能“全绿但结论错误”。最典型的一次是上游端口配置错误,请求全程走失败返回路径,却跑出了非常漂亮的 QPS。
从那以后,每轮测试都必须同时检查错误数、非 200 响应、限流拒绝以及网关日志中的 FAIL/TIMEOUT。任何一项非零,该轮数据直接作废。
其他踩过的坑还包括压测器自身触发限流、发压线程同时突刺、调试构建拖慢吞吐,以及假上游先于网关达到瓶颈。它们共同说明:压测不是把 CPU 打满就可信,必须证明测到的确实是目标链路。
最终采用的 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 版。
主要兼容性差异在 QA 阶段被前置消化;灰度期间继续进行日志观测、业务确认和安全检查。线上放量不是用来替代测试,而是验证那些无法在测试环境中完全复制的条件。
六、结果与复盘
全量切换后,承载相同线上流量所需的容器规模减少了 84%。
切换当天,在线人数比上周同期高约 5%,接口平均延迟下降 3%,P90 下降 10%,请求失败率持平。各下游服务、数据库和在线曲线均无异常。
这次重写最终留下三条最值得复用的经验。
1. 先证明行为一致,再讨论性能
核心系统重写不是一次自由发挥。先从旧实现提取契约,再决定哪些历史行为必须兼容、哪些结构风险必须消除。没有这个边界,性能越好,错误传播得越快。
2. 不要让一种验证方式承担全部责任
黄金值、真实进程、流量回放、人工 QA 和安全审查解决的是不同问题。对比测试可以证明“两版相同”,却不能证明“两版都正确”;单元测试可以证明“内部自洽”,却不能证明“与线上兼容”。
压测也一样。漂亮数字必须同时通过错误日志、响应状态和目标链路检查,否则很可能只是在更快地返回错误。
3. AI 负责规模化产出,人负责不可外包的判断
AI 适合完成大规模、机械化、要求持续对照的工作:提取契约、生成实现、构造黄金值测试、整理差异。
人仍然负责最关键的部分:
- 决定什么行为可以改变;
- 判断某个性能口径是否公平;
- 识别自动化没有覆盖的风险;
- 决定何时扩大流量、何时回退。
这次项目最反直觉的结论是:让 Rust 快起来并不是最难的,让它在所有重要细节上先表现得和 Go 一样,才是。
最终,用户没有感知到语言切换,流量更高时延迟反而更低,而承载同等流量的服务器容器数量减少了 84%。