全局缓存导致的 Node.js 线上内存泄漏

image

背景

M 同学反映自己负责的 Next.js 项目疑似内存泄漏, 临近 618 需要尽快解决! 通过查看 Easy-Monitor 上的「堆内存趋势」曲线📈在一直上涨且不会下降就基本确定了是内存泄漏

问题排查

M 同学也迅速定位到了造成内存泄漏的 commit, 我仔细 review 了一下并没有发现全局变量缓存、闭包引用等高危操作

Easy-Monitor 下载快照

💡 下载的两个快照要是同一个实例的同一个进程

接着就只能从 Easy-Monitor 上间隔一段时间前后下载了两个堆快照,最后通过 Chrome Devtool Memory 面板的 Comparison 功能进行对比, 发现 StyleRule 对象净新增了 57042 个 ⚠️ !

image

这里的一个小技巧是不要总盯着碎片化的(array)Object(string)以及系统的(system)system / Context 等对象的内存变化, 这些对象既不好定位又不容易看懂, 它们通常只是某个对象的属性值, 受其他对象的泄漏而增长可能性大

所有我们需要优先关注 App 应用中使用到的对象, 比如上图中只在该项目中出现的 StyleRule 对象

使用 devtoolx 分析

Chrome Devtool Memory 面板冗余信息多、可读性也较差, 推荐大家使用开源的 devtoolx 进行下一步的分析

1
2
3
npm install devtoolx -g

devtoolx -s <heapsnapshot file> [-p <port>]

尴尬的是开始跑 devtoolx 命令时遇见了下面的报错
image
好吧, 我还是使用 lldb 先定位 devtoolx 启动失败的问题, 结果发现通过 lldb 启动 devtoolx 又能够正常跑起来

此时排除了 devtoolx 不能识别该 v8 版本的 .heapsnapshot 文件以及系统调用 api 兼容性问题(松了口气, 还是能用 devtoolx ~)

1
lldb -- /usr/local/bin/node /usr/local/bin/devtoolx -s /Users/duoxiaokai/Downloads/u-b259269e-6bd4-4336-8fc6-f04478496a47-u-x-heapdump-27-20230606-738634.heapsnapshot

看了一下 devtoolx 的代码, 猜想可能是打开 .heapsnapshot 文件失败了, 于是增加了如下代码再编译运行日志显示 ParseError: Operation not permitted

1
2
3
4
5
6
7
8
9
    std::ifstream jsonfile(parser->filename_);
+ if (!jsonfile.is_open()) {
+ std::cout << "\nfailed to open " << parser->filename_ << '\n';
+ std::cerr << "ParseError: " << strerror(errno);
+ std::exit(1);
+ return;
+ }
json profile;
jsonfile >> profile;

所以把 .heapsnapshot 文件从 Downloads 目录移了出来就愉快的跑了起来, 上面的代码也提交了一个 devtoolx/pull/18/, 最后作者发布了 devtoolx@1.0.2 版本 ❤️
image
image

回归正题, 通过 devtoolx 分别对两个快照分析发现了

  1. 对象 Object(674385) 的内存由 1.34MB 涨到了 34.95 MB ⚠️, 一展开发现是 StyleRule 的父对象
  2. StyleRule 对象的引用关系是 StyleSheet.RuleList.xxx.StyleRule

此时我们可以看看 Chrome Devtool Memory 面板的 Summary 功能查看是否有更多 Object(674385) 对象的信息, 最终确认了 StyleRule 对象的引用关系是 StyleSheet.RuleList.map.StyleRule
image
接着使用 Chrome Devtool Memory 面板的 Comparison 功能查看发现 StyleRule 的父对象 StyleSheetRuleList 并没有新增

定位泄漏点

根据引用关系定位到了 npm 包 jss 的代码, 我们缩小范围直击 RuleList 对象在何种情况会新增子对象 StyleRule 即可

于是乎发现 RuleList 对象的 register 函数每调用一次会在 this.map 对象上挂载一个 StyleRule 对象, 这妥妥的是缓存泄漏啊 ?
image

当我本地运行该项目也是印证了 Object.keys(this.map).length 一直在增长

问题分析

你和我说一个较为流行的仓库 cssinjs/jss 会内存泄漏我是不太会相信, 至少可能性很小, 大概率还是业务项目的使用姿势有问题

让我们看看官方给的 Server-Side Rendering 使用的 demo, demo 代码很容易猜想到该代码的目的, 即每一次调用 render 函数需要先 new SheetsRegistry(), 然后通过 JssProvider 传递给子孙组件进行依赖收集。renderToString 函数运行结束即收集到了运行到的组件需要的样式, 最后通过 sheets.toString() 给吐出来

这个行为和 react-loadable 收集动态模块一毛一样 ~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React from 'react'
import {renderToString} from 'react-dom/server'
import {JssProvider, SheetsRegistry} from 'react-jss'
import Button from './Button'

export default function render() {
const sheets = new SheetsRegistry()

const app = renderToString(
<JssProvider registry={sheets}>
<Button />
</JssProvider>
)

return '' +
`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Server-side rendering with rehydration</title>
<link rel="stylesheet" href="../../example.css" />
<style type="text/css" id="server-side-styles">
${sheets.toString()}
</style>
</head>
<body>
<a href="https://github.com/cssinjs/examples/tree/gh-pages/react-ssr" title="View on Github" class="github-fork-ribbon" target="_blank">View on Github</a>
<div id="app">${app}</div>
<script src="./app.js"></script>
</body>
</html>`
}

而 M 同学本次刚好用到了内部组件库二次封装的 jss 组件, node_modules 中相关的代码是这样

1
exports.sheetsRegistry = new jss_1.SheetsRegistry();

这样串联起来就能破案了。官方是希望每次请求都新 new 一个 SheetsRegistry 进行依赖收集, 在 render 函数结束 SheetsRegistry 对象出了作用域就被 GC 了。而二次封装的 jss 组件却单例化缓存了一个 SheetsRegistry 对象, 导致每个请求都是同一个 SheetsRegistry 对象在收集依赖且由于全局引用不会被释放造成了本次的内存泄漏

image