|
| 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 | + |
| 181 | + |
| 182 | +这里我本地访问多次后,已经飙升到211MB。 |
| 183 | + |
| 184 | +此时,我们可以在Easy-Monitor的首页,点击对应Pid后面的 ```MEM``` 链接,即可自动进行当前业务进程的堆内内存快照打印以及泄漏点分析: |
| 185 | + |
| 186 | + |
| 187 | + |
| 188 | +大约等待10s左右,页面即会呈现出解析的结果。最上面的 ```Heap Status``` 一栏呈现的内容是一个对当前堆内内存解析后的概览,大概看看就行了,比较重要的泄漏点定位在下面的 ```Memory Leak``` 一栏。 |
| 189 | + |
| 190 | +我对疑似的内存泄漏点推测是从计算得到的 ```retainedSize``` 着手的:**泄漏的感知首先是内存无故增加,且只增不减,那么当前堆内内存结构中从 ```(GC roots)``` 节点出发开始,占据的 ```retainedSize``` 最大的就可能是疑似泄漏点的起始。** |
| 191 | + |
| 192 | +遵循这个规则,```Memory Leak``` 第一个子栏目得到的是疑似泄漏点的概览: |
| 193 | + |
| 194 | + |
| 195 | + |
| 196 | +这里按照 ```retainedSize``` 大小做了从大到小的排序,可以看到,这几个点基本上占据了90%以上的堆内内存大小。 |
| 197 | + |
| 198 | +好了,下面的子栏目则是对这里面的5个疑似泄漏点构建 **引力图**,来找出泄漏链条,原理和前面一样:**占据总堆内内存 ```retainedSize``` 最大的对象下面一定也有占据其 ```retainedSize``` 最大的节点**: |
| 199 | + |
| 200 | + |
| 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 | + |
| 244 | + |
| 245 | +这个图很容易看出来,占据 ```retainedSize``` 最大的对象正是 ```socket``` 对象,几乎占到了堆内总内存的 50% 以上。 |
| 246 | + |
| 247 | +接着往下看引力图,如下所示: |
| 248 | + |
| 249 | + |
| 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