Skip to content

Commit 3e47a1b

Browse files
author
huangyijun
committed
update 4.11 ~ 4.17 context
1 parent 5499edd commit 3e47a1b

File tree

2 files changed

+353
-1
lines changed

2 files changed

+353
-1
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,6 @@
6161
* 2017.3.30 & 2017.3.31: 空余继续研究heap-snapshot的解析和目标数据获取。
6262
* 2017.4.1 ~ 2017.4.4: 放假。
6363
* 2017.4.5: 研究了如何对heapsnapshot进行constructor展示。
64-
* 2017.4.10: cpu和memory快照数据量过大,研究了如何使用tcp压缩后传输大数据和解析。
64+
* 2017.4.10: cpu和memory快照数据量过大,研究了如何使用tcp压缩后传输大数据和解析。
65+
* 2017.4.11 ~ 2017.4.14: 学到了一招处理big json的方式:流式读取解析 + 全部抽象成一维数组,能极大减少耗费的内存和提高处理速度,Easy-Monitor 最新版全部用这种方式进行了重构,能处理大 heapsnapshot 文件。
66+
* 2017.4.15 ~ 2017.4.16: 休息日私事
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
# 轻松排查线上Node内存泄漏问题
2+
3+
## I. 三种比较典型的内存泄漏
4+
5+
### 一. 闭包引用导致的泄漏
6+
7+
这段代码已经在很多讲解内存泄漏的地方引用了,非常经典,所以拿出来作为第一个例子,以下是泄漏代码:
8+
9+
10+
```js
11+
'use strict';
12+
const express = require('express');
13+
const app = express();
14+
15+
//以下是产生泄漏的代码
16+
let theThing = null;
17+
let replaceThing = function () {
18+
let leak = theThing;
19+
let unused = function () {
20+
if (leak)
21+
console.log("hi")
22+
};
23+
24+
// 不断修改theThing的引用
25+
theThing = {
26+
longStr: new Array(1000000),
27+
someMethod: function () {
28+
console.log('a');
29+
}
30+
};
31+
};
32+
33+
app.get('/leak', function closureLeak(req, res, next) {
34+
replaceThing();
35+
res.send('Hello Node');
36+
});
37+
38+
app.listen(8082);
39+
40+
```
41+
42+
js中的闭包非常有意思,通过打印heapsnapshot,在chrome的dev tools中展示,会发现闭包中真正存储本作用域数据的是类型为 ```closure``` 的一个函数(其__proto__指向的function)的 ```context``` 属性指向的对象。
43+
44+
这个例子中泄漏引起的原因就是v8对上述的 ```context``` 选择性持有本作用域的数据的两个特点:
45+
46+
* 父作用域的所有子作用域持有的闭包对象是同一个。
47+
* 该闭包对象是子作用域闭包对象中的 ```context``` 属性指向的对象,并且其中只会包含所有的子作用域中使用到的父作用域变量。
48+
49+
50+
### 二. 原生Socket重连策略不恰当导致的泄漏
51+
52+
这种类型的泄漏本质上node中的events模块里的侦听器泄漏,因为比较隐蔽,所以放在第二个例子,以下是泄漏代码:
53+
54+
```js
55+
const net = require('net');
56+
let client = new net.Socket();
57+
58+
function connect() {
59+
client.connect(26665, '127.0.0.1', function callbackListener() {
60+
console.log('connected!');
61+
});
62+
}
63+
64+
//第一次连接
65+
connect();
66+
67+
client.on('error', function (error) {
68+
// console.error(error.message);
69+
});
70+
71+
client.on('close', function () {
72+
//console.error('closed!');
73+
//泄漏代码
74+
client.destroy();
75+
setTimeout(connect, 1);
76+
});
77+
78+
```
79+
80+
泄漏产生的原因其实也很简单:```event.js``` 核心模块实现的事件发布/订阅本质上是一个js对象结构(在v6版本中为了性能采用了new EventHandles(),并且把EventHandles的原型置为null来节省原型链查找的消耗),因此我们每一次调用 ```event.on``` 或者 ```event.once``` 相当于在这个对象结构中对应的 ```type``` 跟着的数组增加一个回调处理函数。
81+
82+
那么这个例子里面的泄漏属于非常隐蔽的一种:```net``` 模块的重连每一次都会给 ```client``` 增加一个 ```connect事件``` 的侦听器,如果一直重连不上,侦听器会无限增加,从而导致泄漏。
83+
84+
### 三. 不恰当的全局缓存导致的泄漏
85+
86+
这个例子就比较简单了,但是也属于在失误情况下容易不小心写出来的,以下是泄漏代码
87+
88+
```js
89+
'use strict';
90+
const easyMonitor = require('easy-monitor');
91+
const express = require('express');
92+
const app = express();
93+
94+
const _cached = [];
95+
96+
app.get('/arr', function arrayLeak(req, res, next) {
97+
//泄漏代码
98+
_cached.push(new Array(1000000));
99+
res.send('Hello World');
100+
});
101+
102+
app.listen(8082);
103+
```
104+
105+
如果我们在项目中不恰当的使用了全局缓存:主要是指只有增加缓存的操作而没有清除的操作,那么就会引起泄漏。
106+
107+
这种缓存引用不当的泄漏虽然简单,但是我曾经亲自排查过:Appium自动化测试工具中,某一个版本的日志缓存策略有bug,导致搭建的server跑一段时间就重启。
108+
109+
## II. 常规排查方式
110+
111+
### 一. heapdump/v8-profiler + chrome dev tools
112+
113+
目前node上面用于排查内存泄漏的辅助工具也有一些,主要是:
114+
115+
* heapdump
116+
* v8-profiler
117+
118+
这两个工具的原理都是一致的:调用v8引擎暴露的接口:
119+
```v8::Isolate::GetCurrent()->GetHeapProfiler()->TakeHeapSnapshot(title, control)```
120+
然后将获取的c++对象数据转换为js对象。
121+
122+
这个对象中其实就是一个很大的json,通过chrome提供的dev tools,可以将这个json解析成可视化的树或者统计概览图,通过多次打印内存结构,compare出只增不减的对象,来定位到泄漏点。
123+
124+
### 二. Easy-Monitor工具自动定位疑似泄漏点
125+
126+
我之前项目中遇到疑似的内存泄漏基本都是这样排查的,但是排查的过程中也遇到了几个比较困扰的问题:
127+
128+
* 只能在线下进行,而线上情况复杂,有些错误线下很难复现
129+
* 总是需要多次插工具打印,然后对比,比较麻烦
130+
131+
所以后面花了点时间,详细解析了下v8引擎输出的heapsnapshot里面的json结构,做了一个轻量级的线上内存泄漏排查工具,也是之前的Easy-monitor性能监控工具的一个补完。
132+
133+
对如何测试自己项目线上js代码性能,以及找出js函数可优化点感兴趣的朋友可以参看这一篇:
134+
135+
* [轻量级易部署Node性能监控工具:Easy-Monitor](https://cnodejs.org/topic/58d0dd8b17f61387400b7de5)
136+
137+
本文下一节主要是以第I节中的三种非常典型的内存泄漏状况,来使用新一版的Easy-Monitor进行简单的定位排查。
138+
139+
## III. 使用[Easy-Monitor](https://github.com/hyj1991/easy-monitor)快速定位泄漏点
140+
141+
### 一. 安装&嵌入项目
142+
143+
Easy-Monitor的使用非常简单,安装启动总共三步
144+
145+
#### 1.安装模块
146+
147+
```
148+
npm install easy-monitor
149+
```
150+
151+
#### 2.引入模块
152+
153+
```
154+
const easyMonitor = require('easy-monitor');
155+
easyMonitor('你的项目名称');
156+
```
157+
158+
#### 3.访问监控页面
159+
160+
打开你的浏览器,输入以下地址,即可看到进程相关信息:
161+
162+
```
163+
http://127.0.0.1:12333
164+
```
165+
166+
### 二. 内存泄漏排查使用方式
167+
168+
Easy-Monitor可以实时展示内存分析信息,所以在线上使用也是没有问题的,下面就来使用此工具分析第I节中出现的问题。
169+
170+
#### 1.闭包泄漏
171+
172+
在闭包泄漏的代码中,按照上面的步骤引入easy-monitor,然后不停在浏览器中访问:
173+
174+
```
175+
http://127.0.0.1:8082/leak
176+
```
177+
178+
那么几次后通过top或者别的自带内存监控工具能看到内存明显上升:
179+
180+
![closure_mem_stat.jpeg](//dn-cnode.qbox.me/Fgmg1RSn5JvskDIAdmW43zHygZLX)
181+
182+
这里我本地访问多次后,已经飙升到211MB。
183+
184+
此时,我们可以在Easy-Monitor的首页,点击对应Pid后面的 ```MEM``` 链接,即可自动进行当前业务进程的堆内内存快照打印以及泄漏点分析:
185+
186+
![index_mem.jpeg](//dn-cnode.qbox.me/FnFrVXTtIRlneHnfJWuK7X-0bgy7)
187+
188+
大约等待10s左右,页面即会呈现出解析的结果。最上面的 ```Heap Status``` 一栏呈现的内容是一个对当前堆内内存解析后的概览,大概看看就行了,比较重要的泄漏点定位在下面的 ```Memory Leak``` 一栏。
189+
190+
我对疑似的内存泄漏点推测是从计算得到的 ```retainedSize``` 着手的:**泄漏的感知首先是内存无故增加,且只增不减,那么当前堆内内存结构中从 ```(GC roots)``` 节点出发开始,占据的 ```retainedSize``` 最大的就可能是疑似泄漏点的起始。**
191+
192+
遵循这个规则,```Memory Leak``` 第一个子栏目得到的是疑似泄漏点的概览:
193+
194+
![closure_mem_point_index.jpeg](//dn-cnode.qbox.me/Fk_Ujb1sH0Ah5vCIjugez7f8wPt_)
195+
196+
这里按照 ```retainedSize``` 大小做了从大到小的排序,可以看到,这几个点基本上占据了90%以上的堆内内存大小。
197+
198+
好了,下面的子栏目则是对这里面的5个疑似泄漏点构建 **引力图**,来找出泄漏链条,原理和前面一样:**占据总堆内内存 ```retainedSize``` 最大的对象下面一定也有占据其 ```retainedSize``` 最大的节点**
199+
200+
![closure_mem_force.jpeg](//dn-cnode.qbox.me/FmGdeY1c_5QEMjmOFaePN7fU-DRD)
201+
202+
根据引力图可以很清晰看到 ```retainedSize``` 最大的疑似泄漏链条,颜色和大小的一部分含义:
203+
204+
* 蓝色表示疑似的泄漏节点
205+
* 紫色表示普通节点
206+
* 最大的节点表示的是当前疑似泄漏链条的根节点
207+
208+
这里的展示用了Echarts2,所有的节点都可以点击展开/折叠。当我们把鼠标移动到疑似泄漏链条的最后一个子节点时,引力图下面会用文字显示出当前的泄漏链条的详细指向信息 ```Reference List``` ,这里简单的解析下其内容:
209+
210+
```bash
211+
[object] (Route::@122187) ' stack
212+
---> [object] (Array::@124261) ' [0]
213+
---> [object] (Layer::@124265) ' handle
214+
---> [closure] (closureLeak::@124169) ' context
215+
---> [object] (system / Context::@84427) ' theThing
216+
---> [object] (Object::@122271) ' someMethod
217+
---> [closure] (someMethod::@122275) ' context
218+
---> [object] (system / Context::@122269) ' leak
219+
---> [object] (Object::@122113) ' someMethod
220+
---> [closure] (someMethod::@122117) ' context
221+
---> [object] (system / Context::@122111)
222+
```
223+
224+
每一行表示一个节点:**[类型] \(名称::节点唯一id\) ' 属性名称或者index**
225+
因为测试代码用了Express框架,熟悉Express框架源码的小伙伴都能看出来了:
226+
227+
* 根节点是初始化express时构造的 ```Route``` 的实例。
228+
*```Route``` 实例的 ```stack``` 属性对应的数组的第一个元素,即这里的 ```[0]``` 对应的元素,其实也就是一个中间件,所以是 ```Layer``` 的一个实例。
229+
* 该中间件的 ```handle``` 属性指向 **```closureLeak``` 函数**,这里开始出现我们自己编写的Express框架外的代码了,简单分析下也很容易明白这个中间件其实就是我们编写的 ```app.get``` 部分。
230+
* ```closureLeak``` 函数持有了上级作用域产生的闭包对象,这个闭包对象中 ```retainedSize``` 最大的变量为 ```theThing```
231+
* ```theThing``` 持有了 ```someMethod``` 的引用,**```someMethod``` 又通过上级作用域的闭包对象持有了 ```leak``` 变量,```leak``` 变量又指向 ```theThing``` 变量指向的上一次的老对象,这个老对象中依旧包含了 ```someMethod``` ...**
232+
233+
通过这个引力图和下面提供的 ```Reference List``` 分析,其实很容易发现泄漏点和泄漏原因:正是因为第I节中提到的v8引擎作用域生成和持有闭包引用的规则,那么 ```unused``` 函数的存在,导致了 ```leak``` 变量被 ```replaceThing``` 函数作用域生成的闭包对象存储了,那么 ```theThing``` 每一次指向的新对象里面的 ```someMethod``` 函数持有了这个闭包对象,因此间接持有了上一次访问 ```theThing``` 指向的老对象。所以每一次访问后,老对象永远因为被持有永远无法得到释放,从而引起了泄漏。
234+
235+
这里也把关键词整理出来,方便大家项目全局搜索排查:**Leak Key**
236+
237+
#### 2.Socket重连泄漏
238+
239+
同样的方式,第I节中的代码保存后执行,注意 ```connect``` 操作的端口填写一个本地不存在的端口,来模拟触发客户端的断线重连。
240+
241+
那么这段代码跑大概一分钟左右,即开始产生比较明显的泄漏现象。同样打开easy-monitor监控页面进行堆内存分析,得到如下结果:
242+
243+
![socket_mem_index.jpeg](//dn-cnode.qbox.me/FskCsNhsPFQnxMPquniyvJNovA-2)
244+
245+
这个图很容易看出来,占据 ```retainedSize``` 最大的对象正是 ```socket``` 对象,几乎占到了堆内总内存的 50% 以上。
246+
247+
接着往下看引力图,如下所示:
248+
249+
![socket_mem_force.jpeg](//dn-cnode.qbox.me/Fm62hb6Ga3inyqI8eJugQvj-sE62)
250+
251+
其中的 ```Reference List``` 如下:
252+
253+
```bash
254+
[object] (Socket::@97097) ' _events
255+
---> [object] (EventHandlers::@97101) ' connect
256+
---> [object] (Array::@102511)
257+
```
258+
这里熟悉Node核心模块 ```events``` 的小伙伴就能感到熟悉,```_events``` 正是存储订阅事件/事件回调函数的属性,那么这边很显然是原生的socket触发断线重连时,会不停增加 ```connect``` 事件的处理,如果服务器一直挂掉,即客户端无法断线重连成功,那么内存就会不断增加导致泄漏。
259+
260+
题外插一句,我翻了下net.js的代码,这里的 ```connect``` 事件是以 ```once``` 的方式添加的,所以只要重连过程中能够连上一次,这部分侦听器增加的内存就能够被回收掉。
261+
262+
#### 3.全局缓存泄漏
263+
264+
这个是最简单的原因了,大家可以使用Easy-Monitor自行尝试一番~
265+
266+
## IV. 如何修改避免泄漏
267+
268+
### 一. 断掉闭包中的泄漏变量引用链条
269+
270+
根据第III节中的解析,明白了这种泄漏的原理,就比较容易对代码进行修改了,断掉 ```unused``` 函数对 ```leak``` 变量的引用,那么 ```replaceThing``` 函数作用域的闭包对象中就不会有 ```leak``` 变量了,这样 ```someMethod``` 即不会再对老对象间接产生引用导致泄漏,修改后代码如下:
271+
272+
```js
273+
'use strict';
274+
const express = require('express');
275+
const app = express();
276+
const easyMonitor = require('easy-monitor');
277+
easyMonitor('Closure Leak');
278+
279+
let theThing = null;
280+
let replaceThing = function () {
281+
let leak = theThing;
282+
//断掉leak的闭包引用即可解决这种泄漏
283+
let unused = function (leak) {
284+
if (leak)
285+
console.log("hi")
286+
};
287+
288+
theThing = {
289+
longStr: new Array(1000000),
290+
someMethod: function () {
291+
console.log('a');
292+
}
293+
};
294+
};
295+
296+
app.get('/leak', function closureLeak(req, res, next) {
297+
replaceThing();
298+
res.send('Hello Node');
299+
});
300+
301+
app.listen(8082);
302+
303+
```
304+
305+
### 二. 断线重连时去掉老侦听器
306+
307+
修改主要目的是在重连时去掉连接失败时添加的 ```connect``` 事件,修改后代码如下:
308+
309+
```js
310+
const net = require('net');
311+
const easyMonitor = require('easy-monitor');
312+
easyMonitor('Socket Leak');
313+
let client = new net.Socket();
314+
315+
function callbackListener() {
316+
console.log('connected!');
317+
});
318+
319+
function connect() {
320+
client.connect(26665, '127.0.0.1', callbackListener}
321+
322+
connect();
323+
324+
client.on('error', function (error) {
325+
// console.error(error.message);
326+
});
327+
328+
client.on('close', function () {
329+
//console.error('closed!');
330+
//断线时去掉本次侦听的connect事件的侦听器
331+
client.removeListener('connect', callbackListener);
332+
client.destroy();
333+
setTimeout(connect, 1);
334+
});
335+
336+
```
337+
338+
### 三.
339+
340+
修改和测试大家可以自行尝试一番。
341+
342+
## V. 结语
343+
344+
做这个工具也让自己对于v8的内存管理有了更深入的认识,收获挺大的,下一步的计划是优化代码逻辑和前台呈现界面,提高易用性和开发者的体验。
345+
346+
Easy-Monitor新版本下依旧支持线上部署和多项目cluster部署,最后项目的git地址在:
347+
348+
[Easy-Monitor](https://github.com/hyj1991/easy-monitor)
349+
350+
如果大家觉得有帮助或者不错,欢迎给个star 💕~

0 commit comments

Comments
 (0)