背景:
公司的一个node服务在某台ECS服务器上经常发生内存或CPU告警(部署到其它服务器的不存在),且有一定概率会造成机器宕机。本文记录一下笔者排查的思路。
分析:
既然只在某台服务器上发生,那么自然会想到是不是这台服务器的环境存在什么问题, 遂进行排查: 正常服务器的配置(内存8G): 发生内存告警的服务器配置(内存4G): 内存确实是存在差距的,但是转念一想,我们在业务上的体量也是不一样的呀,8G服务器所在地区的业务体量是4G服务器所在的N倍(N > 2)。 而且,我们的这个node服务本质是一个网关,不会处理太多业务逻辑,也就不需要太多的常驻内存。也就是说,一定存在严重的内存泄漏问题。
找运维同学,要了一下出问题时候机器的运行情况。 这张图是内存报警时候的图,机器还没挂掉。挂掉的时候,CPU、内存、BPS全部打满了...
我们使用devTools分析一波。 开启测试环境Node服务的inspect模式,便于观测内存的变化。 node --inspect-brk=0.0.0.0:9229 dist/index.js
:::danger 一定要保证在网络安全的环境下使用这种方式,如果你的服务器是外网可访问的,那么任何人都可以调试你的服务。 ::: 在chrome打开 chrome://inspect/#devices
配置好之后,你会看到 Remote Target 下方出现了一条,点击inspect即刻打开devTool 点击后,切换到Memory,你会看到如下图的面板 三个选项的区别和使用场景: **Heap snapshot(堆快照): ** 将当前的Node服务的堆栈情况生成一个快照。通过快照我们可以清晰某一刻,系统中都有哪些数据在内促中。我们也可以通过隔一段时间打一个快照,对比两个快照的内存变化,从而分析系统的内存监控情况。
当你对系统内存泄漏的信息不多时,可以使用打快照的方式,对比两个时间节点的内存变化。
Allocation instrumentation on timeline(内存仪表盘): 与Heap snapshot
不同的是,启用这个模式后,分析面板会多一个时间线,且分析会一直进行下去。指点你点击终止。 Timeline 中,蓝色柱代表未被回收的内存(可能存在内存泄漏),灰色代表被GC已经回收的内存。 选中对应的柱状图,即可看到当时的内存情况。
当你知道某些操作可能会引发内促泄漏的时候,可以用这个用来验证。
Allocation sampling(分配抽样): 像内存仪表盘一样,它也会一直运行。 不同的是,它记录的是内存分配的过程,而不是每个时间点内存的所有情况。 它周期性的对内存分配进行抽样,对性能的损耗更小,适合需要长时间收集的场景。
当你想观测系统的内存分配情况时候,可以使用这种方式。
有了分析工具,笔者决定先使用 Heap snapshot
模式,先对刚启动的服务打一个快照,并模拟用户,在前端页面点一点(为了更加真实,笔者这里使用了压测工具,将事先写好的接口并行调用)。调用了100次之后再次打了一个快照,神奇的事情出现了,内促竟然从33.1MB
变成了37.4MB
。 查看两个快照之间分配的对象发现,有 (string)
的占用了3.4MB的内存(Shallow Size),占用了内存增长的大部分。
Memory面板名词解释,部分引用自zhuanlan.zhihu.com/p/80792297
(string) 字符串原始值。指字符串的引用。除了明确使用 new String创建的字符串外,其它的均属于(string)
(array) 通常指的是数组索引属性的集合, 它是指那些由于元素增加或删除而发生变化的索引.
Array 代表实际的Array对象实例,指数组对象的整个生命周期变化------新数组的创建或现有数组的回收
Contructor - 表示使用此构造函数创建的所有对象
Distance - 显示使用节点最短简单路径时距根节点的距离
Shallow Size - 显示通过特定构造函数创建的所有对象浅层大小的总和。浅层大小是指对象自身占用的内存大小(一般来说,数组和字符串的浅层大小比较大)
Retained Size - 显示同一组对象中最大的保留大小。某个对象删除后(其依赖项不再可到达)可以释放的内存大小称为保留大小。
New - Comparison 特有 - 新增项
Deleted - Comparison 特有 - 删除项
Delta - Comparison 特有 - 增量 (Delta = New - Deleted)
Alloc. Size - Comparison 特有 - 内存分配大小
Freed Size - Comparison 特有 - 释放大小
Size Delta - Comparison 特有 - 内存增量 (Size Delta = Alloc.Size - Freed Size)
点开进一步分析,发现这个string正是我们刚才接口请求的响应体。定位到之指示的apiProxy.service.js
对应的代码片段:
typescript
public static proxy(ctx: KoaContext, path: string, method: string, headers: AnyObject): Promise<any> {
const {
port,
hostname,
} = ApiProxyService.getRoute(path, headers);
return new Promise((resolve, reject) => {
const options: RequestOptions = {
hostname: hostname,
path: path,
method: method,
port: port,
headers: headers,
};
const request = http.request(options, (res) => {
if (res.statusCode !== 200) {
...
return;
}
let data = '';
res.on('data', (chunk) => {
data = data + chunk;
});
res.on('end', () => {
...
});
});
request.on('error', (e) => {
...
});
request.end();
// 自动定位到了这里
process.on('uncaughtException', function (err) {
console.warn(err.stack);
console.warn('NOT exit...');
});
});
}
从代码上看,在 proxy
中增加了 一个 uncaughtException
事件,相当于每个请求,都会增加一个全局事件,当这个事件触发时候,会将之前监听的所有事件全部触发。笔者将监听 uncaughtException
的事件放置到了全局,确保应用启动只触发一次,再次inspect Memory后,发现一切恢复了正常!
遗留问题:
从Memory
面板来看,每次请求后,内存中都会将接口响应的字符串存下来。字符串的内存泄漏是本次的罪魁祸首,重复监听只是次要的。 但是从代码层面分析
typescript
process.on('uncaughtException', function (err) {
console.warn(err.stack);
console.warn('NOT exit...');
});
uncaughtException
事件只会造成重复监听,它的回调函数中也没有对外部的引用,不会触发闭包, 理论上来讲不会和响应体字符串产生关联。 求各位看官答疑解惑。