最初的判断很简单:这个接口 90% 的时间都花在 JSON 的序列化与反序列化上,而不是业务逻辑。既然要动,就干脆把整条热路径换成一门能精确控制内存的语言。
但我们给自己划了条线:不追求全部重写,只替换真正吃 CPU 的那 300 行。剩下两万多行 Go 代码一行不动——重写的收益必须大于它带来的风险。
火焰图里藏着什么
在动手之前,我们先跑了 48 小时的持续采样。火焰图里最高的那座山不是数据库,也不是网络,而是 encoding/json 里的反射调用:每一次请求都要重新解析一次结构体标签。
如果你不能在优化前量化它,那你也很难在优化之后证明它真的有效。
这轮采样给出了三个明确结论:
- P99 延迟 112ms,其中 74ms 花在序列化上;
- 峰值 CPU 有 38% 被垃圾回收吃掉;
- 数据库耗时只占 9%,根本不是瓶颈。
有了这三条,后面所有决策都不需要拍脑袋。
换掉反射解析
反射的问题在于它每次都要重新读取结构体标签。改成编译期生成解析代码之后,这部分开销直接归零:
use std::time::Instant;
pub fn parse_payload(buf: &[u8]) -> Payload {
let start = Instant::now();
let payload = simd_json::from_slice(buf)?;
// 反射解析换成 SIMD,单次调用 41 微秒 -> 6 微秒
metrics::observe("parse", start.elapsed());
Ok(payload)
}
换成 SIMD 版的解析器之后,单次调用耗时从 41 微秒降到 6 微秒。整条链路的 P99 从 112ms 掉到 8ms 左右,峰值 CPU 占用也降了近四成。
那些我们最后放弃的优化
不是所有优化都值得做。下面三件事我们评估过,最后全部放弃:
- 把整个服务迁到 Rust —— 收益集中在那 300 行里,迁移剩下的部分只会增加维护成本。
- 手写 SIMD 指令 —— 编译器自动向量化已经够用,手写版在老机器上反而更慢。
- 用异步运行时重写 IO —— 压测显示瓶颈根本不在 IO,改了也不会更快。
上线之后
灰度两周,错误率没有变化,P99 稳定在 8ms 上下。最大的收获其实不是性能,而是我们终于有了一条可复现的压测基线:以后任何一次重构,都能在合并前就知道它到底是变快了还是变慢了。
代码和压测脚本都已经放到仓库里,欢迎直接跑一遍推翻我的结论。