一次冗余配置传递引发的OOM转载
介绍
本章使用一个开发人员经常忽略的生产环境中的 OOM(内存不足)示例来展示如何发现和分析 Node.js 应用程序 OOM,定位有问题的代码并修复 OOM 问题。我希望本章可以帮助到你。
本手册首发于GitHub https://github.com/aliyun-node/Node.js-Troubleshooting-Guide
最小代码复制
因为内存泄漏和CPU占用率高的问题不同,把有问题的代码和排查描述结合起来可能会更直观。因此,本章开头的代码片段最少。通过运行代码并结合稍后描述的分析步骤,您可能会发现更多信息。该示例基于Egg.js:
'使用严格';常量控制器 = 要求('鸡蛋')。控制器;const DEFAULT_OPTIONS = { 记录器:控制台 };类 SomeClient {
构造函数(选项) {
this.options = 选项;
}
async fetchSomething() {
return this.options.key;
}
}常量客户 = {};function getClient(options) {
if (!clients[options.key]) {
clients[options.key] = new SomeClient(Object.assign({}, DEFAULT_OPTIONS, options));
}
返回客户端[options.key];
}类 MemoryController 扩展控制器 {
async index() {
const { ctx } = this;
const options = { ctx, key: Math.random().toString(16).slice(2) };
常量数据 = 等待 getClient(options).fetchSomething();
ctx.body = 数据;
}
}module.exports = 内存控制器;
将 Post 请求路由器添加到app/router.js
:
router.post('/memory', controller.memory.index);
以下是有问题的 Post 请求的演示:
'使用严格';常量 fs = 要求('fs');
常量 http = 要求('http');const postData = JSON.stringify({
// 一个比较大的字符串(大约 2 MB)可以放在 body.txt
数据中: fs.readFileSync('./body.txt').toString()
});function post() {
const req = http.request({
method: 'POST',
host: 'localhost',
port: '7001',
path: '/memory',
headers: {
'Content-Type': 'application/ json',
'内容长度': Buffer.byteLength(postData)
}
}); req.write(postData); req.end(); req.on('error', function (err) {
console.log(12333, err);
});
}设置间隔(发布,1000);
以最少的代码复制运行演示服务器后,在客户端运行此 Post 请求,并每秒发起一次 Post 请求。在平台控制台中,可以看到堆内存使用量不断增加。
故障排除
收到Node.js性能平台的进程内存告警后,登录控制台,访问应用首页,根据告警在对应实例中查找有问题的进程。
报告顶部的默认信息前面已经解释过了,这里不再赘述。现在,我们来看看可疑节点的一些信息: 结果显示,18 个对象占据了堆大小的 96.38%。显然,有必要进一步检查这些对象。通过单击对象名称,您可以看到有关这 18 个system/Context
对象的详细信息:
在这个例子中,我们访问支配树,其中这 18 个系统/上下文对象是根节点。展开每个对象后,您可以看到每个对象的实际内存使用情况。在上图中,问题显然是由第一个对象引起的。我们进一步展开,看看相关资料:
显然,实际上消耗太多堆空间的是 451 个 SomeClient 实例。此时,需要从两个角度判断这是否真的是内存异常的原因:
- 如果当前 Node.js 应用程序逻辑正常,单个进程真的需要 451 个 SomeClient 实例吗?
- 如果一个进程确实需要这么多 SomeClient 实例,那么每个实例使用 1.98 MB 的空间是否正常合理?
对于第一个问题,我们在实际生产场景中重新确认了代码逻辑,发现这么多Client实例其实是必须的。所以重点主要是判断每个实例使用1.98MB空间是否合理。如果合理,Node.js 应用程序中单个进程的默认 1.4 GB 最大堆大小不适用于此场景。这种情况下需要开启Flag来增加最大堆空间。
单击以进一步展开这些 SomeClient 实例并查看对象信息:
SomeClient 本身只有 1.98 MB。但是,它下面的 Object@428973(options 属性)占用了 1.98 MB。展开这个 Object@428973 对象后,可以看到 Object@428919(ctx 属性)是 SomeClient 实例占用太多堆空间的原因。
您可以进一步看到对于任何其他 SomeClient 实例都是如此。此时,您需要检查代码以确定将options.ctx
属性挂载到 SomeClient 实例是否也是合理的。单击此有问题的对象:
转到该对象的关系图:
Search 视图与 Dom 视图不同。Search 视图显示从堆块解析的原始对象图,因此肯定存在边缘信息。通过使用边名和对象名,很容易判断该对象对应的代码。
但是,在本例中,仅以 Object@428973 开头的原始内存图无法让您找到对应的代码。毕竟,两者Object.ctx
都是Object.key
常见的 JavaScript 对象。因此,尝试切换到 Retainer 视图:
您可以看到以下信息:
这里的 Retainer 和 Chrome DevTools 中的 Retainer 是一样的。Retainer 代表堆内存中的原始父引用关系。如本例所示,如果一个可疑对象和展开该对象后的详细信息不足以找到问题代码,您可以展开该对象的 Retainers 视图并查看其父节点路径,以便轻松定位问题代码。
通过在 Retainers 视图中使用此有问题对象的父引用链接,您可以轻松找到创建此对象的代码:
function getClient(options) {
if (!clients[options.key]) {
clients[options.key] = new SomeClient(Object.assign({}, DEFAULT_OPTIONS, options));
}
返回客户端[options.key];
}
结合 SomeClient 可以看到,在用于初始化key
的参数中,实际上只用到了属性。options
其他属性是冗余配置,不需要。
代码修复和确认
当您知道问题的真正原因时,解决问题相对简单。您可以为 SomeClient 单独生成 options 参数,并从 options 输入参数中获取所需的数据,以确保不存在冗余数据:
函数 getClient(options) {
const someClientOptions = Object.assign({ key: options.key }, DEFAULT_OPTIONS);
if (!clients[options.key]) {
clients[options.key] = new SomeClient(someClientOptions);
}
返回客户端[options.key];
}
重新发布并运行此应用程序后,堆内存下降到只有几十 MB。至此,内存异常问题已经完美解决。
结论
本章介绍如何使用 Node.js 性能平台解决在线应用程序中的内存泄漏问题。严格来说,本章描述的问题并不是真正的内存泄漏。开发者在传递配置时直接采用全赋值,或多或少会遇到这个问题。我们可以从这个问题中吸取教训:在编写公共组件模块时,永远不要相信用户的输入参数;只保留和传递我们需要避免许多问题的参数。