你以为流很省内存,其实在背N份账

最近帮一个 Node.js 中间层排查 OOM。一个聚合接口,把三四个下游接口的数据拼一下吐给前端,单次请求的数据量也就两三 MB,看着毫无压力。但 Grafana 上常驻内存随 QPS 线性爬升,GC 触发得越来越频繁,终于在晚高峰把进程撑爆了。

翻代码,逻辑大致是这样:

```js
const responses = await Promise.all(
endpoints.map(url => fetch(url).then(r => r.json()))
)
return res.json(merge(responses))
```

看起来人畜无害。`fetch` 拿到的是流,`.json()` 一次性消费它,`merge` 拼好再 `res.json()` 序列化出去。整个链路里数据至少出现了三份:原始响应体、解析后的对象、合并后的对象。再加上序列化时的字符串缓冲,单请求峰值轻松到原始数据的四五倍。这还没算 V8 把这些短命对象塞进新生代、触发 Scavenge 的开销。

很多人对 Node.js 流有一个浪漫化的想象,觉得只要用了流,内存就一定是常数级的。这个印象是从 `fs.createReadStream` 那套 API 里来的,管道一接,内存平稳得像心电图。但实际业务里,流经常被"用了一半"——上游是流,下游却退化成了 buffer。`fetch().then(r => r.json())` 就是典型,流进了 buffer,buffer 进了对象,对象进了另一个对象,流的好处在这一步全丢了。

 

流不是免费的,流是被推迟的复杂度

 

把整段聚合改成真正的流式处理,理论上能让峰值内存稳在一个量级。但问题在于,流式处理的代价不在内存,在心理负担。

`merge` 这个操作,在 buffer 模式下是几行代码。一旦要流式做,你就得回答一堆本来不用回答的问题:几个流的到达顺序不确定,谁先到谁后到?某个流卡住,其他流的数据是缓冲还是丢弃?合并后的结构是数组还是流式 JSON?前端能不能消费流式响应,还是必须等一个完整 JSON?每个问题的答案,都会把代码复杂度推一个台阶。

我观察到一个规律:团队引入流,往往是冲着"省内存"去的,但真正决定流能不能用下去的,是下游消费方愿不愿意配合。BFF 层给前端吐数据,前端大部分场景拿到的还是一个完整 JSON,你内部多流式,出口处照样要 buffer 成一坨。这种"内流外不流"的结构,内存峰值该多高还是多高,复杂度倒是实打实地涨了。

所以我对流的态度很明确:它不是优化手段,它是架构选择。如果一个场景的数据确实大到单机内存扛不住,或者下游天然支持流式消费(比如文件下载代理、日志管道、大表导出),那流是唯一解,复杂度该认就认。但如果只是为了"显得高级"或"预防性优化",在 BFF 这种出口处全是 JSON 的场景里堆流,大概率是给自己挖坑。

 

真正吃内存的,是没被回收的对象

 

回到那个 OOM 案例。把流的事情先放一边,深挖下去发现,真正的内存杀手不是数据本身,而是一个被无意中"持有"的引用。

代码里有一个中间件,做请求维度的链路追踪,把每个请求的 trace 信息塞进一个模块级的 Map,key 是请求 id。请求结束时,理论上应该 delete 掉。但在某个异常分支里,delete 被跳过了。于是每来一个请求,Map 里就多一条,这些 trace 对象带着完整的请求上下文(包括那个几 MB 的响应数据),永远进不了老生代被回收,因为 Map 一直在引用它们。

这才是 Node.js 内存问题最常见的形态。不是数据太大,而是你以为会被回收的东西,被某个长生命周期的容器悄悄留住了。流的优化在这类问题面前毫无意义——你把瞬时峰值从 5 倍压到 1 倍,常驻该涨还是涨,因为常驻的根本不是瞬时数据。

诊断这类问题,堆快照比看代码管用。抓一段晚高峰的 heapdump,按 retained size 排序,那个该被 delete 的 Map 会赫然在列,底下挂着一串串请求上下文。这种时候,改一行 delete 比把所有接口重写成流收益大得多。

 

别把序列化当免费操作

 

顺带说一下 `JSON.stringify`。在聚合接口里,这玩意儿是隐性大头,却很少被认真对待。

`res.json()` 内部先 `JSON.stringify`,生成一个完整的字符串,再写到 socket。这意味着,在你以为"数据已经发出去了"的瞬间,内存里还躺着一份等长的字符串缓冲。对于一个返回 2 MB JSON 的接口,序列化瞬间峰值至少多 2 MB,V8 给大字符串的分配又常常落在老生代,GC 回收得更慢。

Node.js 后续版本里 `res.json` 的实现有一些优化,但"先序列化成完整字符串再写"这个基本盘没变。真要在吞吐上较真,流式 JSON 序列化(边构造边写、不落整串)才是出路,但这又回到了前面那个"出口处 buffer"的问题——你得自己控制序列化的节奏,标准库不会替你做。

我的判断是,绝大多数 BFF 接口不值得为这点优化动手。但你得分清楚两个场景:接口数量多、单接口数据小,GC 压力来自对象数量,优化点是减少分配(比如能用流式 JSON 就别先拼对象);接口数量少、单接口数据大,压力来自单次序列化的瞬时峰值,优化点是把大响应拆成分块或者换传输形态。搞混了方向,优化就是白费力气。

 

一个更朴素的结论

 

排查完那个 OOM,最终的修复是两件事:补上异常分支里的 Map 清理,把一个明显冗余的下游接口从聚合里去掉。流的方案讨论了一圈,没人动它。

这件事让我对一个长期被忽略的事实有了更具体的感受:Node.js 服务端性能问题的解,常常不在"更高级的技术",而在"把基本账算清"。流、Worker、缓存、连接池,这些概念本身没有错,但它们解决的是特定形态的问题。而在 BFF 这种"Pieces 拼装再吐出"的场景里,真正决定能不能扛住晚高峰的,是引用有没有被释放、序列化有没有被低估、几个接口是不是非聚合不可。

优化这件事,越往底层走,越像是在跟自己的认知偏差较劲。你觉得流省内存,它其实在背 N 份账;你觉得序列化是免费,它其实是隐性大头;你觉得 OOM 是数据太大,它其实是某个 Map 没清干净。认账,比换技术栈有用。

至于流什么时候值得用,我的答案一直没变:当下游消费方愿意陪你一起流的时候。在那之前,流只是把"瞬时高内存"换成了"长期高复杂度",而复杂度这种债,利息比内存高。

Total votes: 1

添加新评论