likes
comments
collection
share

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

作者站长头像
站长
· 阅读数 46

引言

大部分前端开发者(全干工程师)越来越频繁地接触到服务器端开发,尤其是使用相对熟悉的 JS 来构建 Node.js 服务。然而,对于大多数前端开发者而言,深入掌握和运维线上正在运行的 Node.js 不是一件易事。内存泄漏即是其中大魔王之一。所以本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V8 内存管理和介绍内存泄漏的常见原因,并提供线上排查工具和真实案例分析,帮助开发者编写相对可靠的 Node.js 服务。

简介

内存泄漏是什么

内存泄漏指的是应用程序在运行过程中,无法正确释放不再使用的内存,导致内存的持续增长,轻则造成系统内存的浪费,重则导致程序运行速度减慢甚至系统崩溃等严重后果。这在长时间运行的 Node.js 服务中尤为明显,尤其当应用需要处理大量并发请求时,内存泄漏的问题会被加速放大。

常见的内存泄漏原因

  1. 不正确的闭包使用

如在 setTimeout、setInterval 的闭包中引用函数作用域外的变量,只要当前定时器没有被 clear,引用的变量也不会被 GC

  1. EventEmitter 没有移除监听器

EventEmitter 提供发布订阅功能,如果订阅后没有在相应位置,如析构函数中添加移除监听器的操作,就会造成内存泄漏。这与 setInterval 极为相似,假设 event 不再 emit 事件,但此时如果仍有事件订阅,订阅函数里面引用函数域外的变量将不会被 GC。

  1. 未释放的全局变量

在顶层作用域定义变量或在 global 上挂载变量,除非人为将已定义的变量置为 null,不然这些变量的生命周期将和当前进程共存亡

  1. 长寿对象持有短寿对象的引用

经常发生在缓存策略中,比如在子作用域中临时存储某个对象,但之后没有进行移除引用,就会造成内存泄漏,现在和 EventEmitter 结合起来看一个 case:

const http = require('http')
const EventEmitter = require('events')

// 长寿对象
const eventManager = new EventEmitter()

const server = http.createServer((req, res) => {
  // 短寿对象 模拟 UUID
  const requestId = Math.random().toString(36).substring(2)
  const requestHandler = () => {
    // 内存泄漏点:requestId 和 req.header 将不会被释放
    console.log(`Processing request ${requestId}, header: ${req.headers}`)
  }
  eventManager.on('request', requestHandler)
  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.end('Hello, world!\n')
})

server.listen(3000, () => {
  console.log('Server running at http://127.0.0.1:3000/')
})

// 模拟事件触发
setInterval(() => {
  eventManager.emit('request')
}, 1000)

每个请求进来后会在 eventManager 添加监听器,监听器中引用了短寿对象 requestId 和 req.headers,并且没有添加 removeListener 的操作,所以当这个请求结束后本该销毁的两个变量还是被闭包引用,需要添加如下代码才能让所有对象被正常 GC:

const server = http.createServer((req, res) => {
  //...
  res.on('finish', () => {
    eventManager.removeListener('request', requestHandler);
  });
  //...
})

5. 缓存没有定期清理

典型的长寿对象持有短寿对象的引用 case,例如 LRU 算法,存储最近使用过的数据,如果没有及时清理或设置上限阈值,可能会导致 OOM。

养成有申请内存就要有销毁内存的意识,会大大降低泄漏的概率,销毁内存可以是显式将某个对象置为 null 以此将引用次数 -1、也可以是调用类销毁事件,如 fd.close 或 clearInterva、也可以是取消订阅事件,如 event.off 等等。

浅入 V8 内存管理

在进行内存泄漏排查之前,浅入 Node.js 背后强大支柱之一 V8 的内存管理,有助于扩展后续排查泄漏的思路和提高编码意识。

V8 内存空间

V8 将内存划分为几个不同的区域,通过 v8.getHeapSpaceStatistics 即可拿到,每个区域用于存储不同类型的数据:

空间名称用途描述
new_space存储新创建的对象,垃圾GC 较为频繁,大小可通过 --max-semi-space-size 调整
old_space存储存活时间较长的对象大部分数据由 new_space 晋升而来,大小可通过 --max-old-space-size 调整
new_large_object_space存储新创建的大对象大于 kLargeObjectSizeThreshold 则认为是一个大对象,kLargeObjectSizeThreshold*=* (1 << 17) / 2
large_object_space存储存活时间较长的大对象由 new_large_object_space 晋升而来
code_space存储 JIT 编译生成的机器代码-
code_large_object_space存储大块的编译代码大于 (1 << (kPageSizeBits - 1)) 则认为是大块,kPageSizeBits = 18
shared_space存储多个 V8 实例之间共享的对象-
shared_large_object_space存储多个V8实例之间共享的大对象-
read_only_space存储只读数据,如常量、不可变对象-

GC Traces

Scavenge

首先我们来验证当新生代空间不足时就会触发 Scavenge 算法,如下例子:

// node --trace-gc xx.js
const v8 = require ( 'v8' );  function  printSpaceSize () {  const res = v8. getHeapSpaceStatistics (). filter ( item => item. space_name === 'new_space' || item. space_name === 'old_space' ). reduce ( ( total, item ) => {  return total + item. space_name + ':' + item. space_used_size + ' '  }, '' )  console . log ( 'v8 heap:' , res) }  const largeArray = []; for ( let i = 1 ; i < 100000 ; i++) { largeArray. push ({ index : i, data : new  Array ( 2000 ). fill ( '*' ) });  printSpaceSize () } 

用 Node 执行时加上 --trace-gc 即可输出 GC 具体信息,如所示:

v8 heap: new_space:16478472 old_space:488619736 
[11991:0x140008000]      700 ms: Scavenge 482.5 (508.9) -> 480.8 (516.2) MB, 4.79 / 0.00 ms  (average mu = 0.907, current mu = 0.907) allocation failure; 
v8 heap: new_space:7392616 old_space:495972064 

可以看到回收前空间大小为:new_space:16478472(15.7 M)、old_space:488619736(465 M)

回收后空间大小为:new_space:7392616(7M)、old_space:495972064(473M)

473M ≈ 465+15.7-7,初步印证了 Scavenge 算法中所描述的当 new_space 不足时触发 Scavenge 算法,这里默认设定了 16M,并采用对象晋升策略,经过多次垃圾回收依然还存活的对象,会被移动到 old_space 中。

详细解释下这一行数据的意思:

[11991:0x140008000]      700 ms: Scavenge 482.5 (508.9) -> 480.8 (516.2) MB, 4.79 / 0.00 ms  (average mu = 0.907, current mu = 0.907) allocation failure; 
具体值解释
11991当前进程的 PID
0x140008000Isolate (JS heap instance)
700 ms以进程启动为时间点,开始 GC 相对时间
ScavengeGC 的类型,有:Start、Scavenge、Mark-Compact、Minor Mark-Sweep
482.5GC 前对象在堆内存占用的大小,单位 MB
(508.9)GC 前总使用堆内存大小,单位 MB
480.8GC 后对象在堆内存占用的大小,单位 MB
(516.2)GC 后总使用堆内存大小,单位 MB
4.79 / 0.00 ms (average mu = 0.907, current mu = 0.907)4.79 ms 表示这次 GC 花费的总时间,0.00 ms:表示垃圾回收操作花费的专用时间,比如暂停主线程,但由于 Scavenge 比较轻量,所以趋近于 0mu(Mutator Utilization),指在一定时间范围内,Mutator(即应用程序代码)实际运行的时间占总时间的比率,越趋近于 1 越好
allocation failure执行这次 GC 的原因- allocation failure 表示有对象尝试申请内存但失败,需要 GC 释放一些再尝试

加上 --max-semi-space-size=32 参数再次验证,如下图所示,new_space 在趋近 32M 时触发 Scavenge。

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

Mark-Compact

V8 10.8 开始 Mark-Sweep 名词被替换成 Mark-Compact,但本质没变,还是包括了 Sweep(清除)阶段,Node 18 版本会输出 Mark-Sweep,在 Node 20 版本以上会输出 Mark-Compact。

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

为了更突出的看出 Mark Compact 前后内存变化,先不断申请内存,然后在某个时刻将已申请内存引用都置空,下次 GC 走到 Marking 的时候它们就会被标记成可回收对象,如下例子所示:

// node --trace-gc --expose-gc --trace_gc_freelists xx.js 
const v8 = require('v8');
function printSpaceSize() {
  const res = v8.getHeapSpaceStatistics().filter(item => item.space_name === 'new_space' || item.space_name === 'old_space').reduce((total, item) => {
    return total + item.space_name + ':' + item.space_used_size + ' '
  }, '')
  console.log('v8 heap:', res)
}

let largeArray = [];
printSpaceSize()
for (let i = 1; i < 100000; i ++ ) {
  largeArray.push({ index: i, data: new Array(i).fill('*') });
  if (i === 10000) {
    printSpaceSize()
    largeArray = []; // 将 largeArray 清空,里面的对象将被标记为可回收
    global.gc() // 强制触发 GC
    printSpaceSize()
    debugger // 断点查看 GC 前后的内存大小
  }
}

在 Debug Terminal 模式下运行 node --trace-gc --expose-gc --trace_gc_freelists xx.js 后会在 19 行断点,控制台将会输出:

[71094:0x148008000]     1018 ms: Scavenge 383.9 (452.2) -> 382.6 (468.2) MB, 5.83 / 0.00 ms  (average mu = 0.867, current mu = 0.867) allocation failure; 
v8 heap: new_space:4622952 old_space:400833720
[71094:0x148008000]     1023 ms: Mark-Compact 387.0 (468.2) -> 4.2 (39.2) MB, 4.17 / 0.00 ms  (average mu = 0.954, current mu = 0.982) testing; GC in old space requested
v8 heap: new_space:392 old_space:4347064

在 1018 ms 时刻执行了一次 Scavenge 后,总堆内存为 468.2 M,对象内存 382.6 大部分都在 old_space

在 GC 前输出当前 V8 堆信息,new_space:4.4 M,old_space: 382.3 M

在 1023 ms 时刻执行了一次 Mark-Compact 后,总堆内存由 468.2 M 变为 39.2 M,对象内存由 387.0M 变为 4.2 M

在 GC 后输出当前 V8 堆信息,new_space:0.38 K,old_space: 4.15 M

由此可说明几点:

  1. 在示例中的前面 10000 次循环申请的内存总共是 382M ≈ 387 - 4.2 ≈ 382.3 + 4.4 - 4.15 - 0.38K

  2. Mark-Compact 不仅会将在 old_space 进行 sweeping 也会在 new_space 中进行 sweeping 操作。

隐藏参数 --trace_gc_freelists ,它输出当前存在已申请内存页和各个类别的空闲内存块的数量和可用大小,如下示例:

[24364:0x150008000] FreeLists statistics before collection:
422 pages. Free space: 15.9 MB (waste: 0.00). Usage: 87.7/103.7 (MB) -> 84.62%.
FreeLists global statistics: [category: length || total free KB]
[0: 404 || 9.47 KB], [1: 114 || 3.85 KB], [2: 136 || 7.09 KB], [3: 162 || 11.15 KB], 
[4: 21 || 1.72 KB], [5: 31 || 2.93 KB], [6: 29 || 3.33 KB], [7: 12 || 1.54 KB], [8: 33 || 4.72 KB],
[9: 18 || 2.88 KB], [10: 9 || 1.58 KB], [11: 13 || 2.46 KB], [12: 24 || 5.05 KB], [13: 5 || 1.10 KB], 
[14: 45 || 10.84 KB], [15: 90 || 32.15 KB], [16: 130 || 91.68 KB], [17: 29 || 41.46 KB], [18: 0 || 0.00 KB], [19: 0 || 0.00 KB], 
[20: 0 || 0.00 KB], [21: 0 || 0.00 KB], [22: 0 || 0.00 KB], [23: 80 || 16095.21 KB]

解读:当前已申请 422 内存页,空闲空间有 15.9M(浪费了 0,和内存碎片成正相关),总共 103.7 M 已使用 87.7M,使用率 84.64 %,总共有 24 个内存块种类,每个种类的内存块大小不一致,以满足不同的内存分配需求(减少内存碎片),Free Space 15.9 M * 1024 = 9.47 + 3.85 + ... + 16095.21(所有内存种类的可用空间之和)

--trace_gc_object_stats :可输出 live 和 dead 对象

Minor Mark-Sweep

它是一个轻量级的标记-清除过程,用于新生代的垃圾回收。触发条件是:

  1. 内存不足:当新生代内存不足以容纳新分配的对象,并且无法通过 Scavenge 回收足够的内存时,V8 会触发 Minor Mark-Sweep。
  2. 内存碎片严重:如果新生代内存中存在大量碎片,导致无法找到足够大的连续内存块来容纳新对象时,V8 也可能会触发 Minor Mark-Sweep。

暂时没有模拟出最小示例触发 Minor Mark-Sweep

ArrayBuffer 内存管理

前面讲的都是普通对象的内存管理,比如 JS 中创建一个对象或稀疏数组,对象可以动态添加属性,V8 引擎会根据需要重新分配内存以适应新的属性,稀疏数组也一样。ArrayBuffer 是用于处理二进制数据的对象,一般在上传下载文件时会用到,在创建时即分配连续、固定内存,这样有利于空间利用,减少碎片化,且读取性能较快,但对于较大的 ArrayBuffer,可能会直接分配在大对象空间(Large Object Space)中。下表是普通对象和 ArrayBuffer 在内存管理上的对比。

普通 JavaScript 对象ArrayBuffer
内存分配- 新生代分配内存- V8 堆外内存,直接从操作系统请求连续内存- 分配复杂度高
内存管理- V8 自动处理- V8 追踪和管理(需手动管理引用)- 大内存块管理复杂
Chrome DevTool Memory- 因为是分配到 JS Heap 中,可以在 Memory 面板动态观察到内存的涨跌趋势和大小- 由于是 V8 堆外内存,无法被 Memory 面板动态捕捉到,但可以通过 Heap Snapshot 捕获到,下面会详细讲到 Heap Snapshot
垃圾回收- 新生代使用 Scavenge 算法- 老生代使用标记-清除、标记-整理、增量标记-清除- 垃圾回收频率高- V8 会跟踪 ArrayBuffer 的引用,当没有任何引用指向 ArrayBuffer 时,其内存会被标记为可回收,并在下一次垃圾回收时释放
性能影响- 高效的内存分配和回收- 适合频繁创建和销毁的小对象- 内存分配和释放开销大- 适合大量二进制数据处理
优点- 内存管理简单高效- 垃圾回收机制高效- 适合常规数据处理- 能处理大数据量的二进制数据- 数据访问效率高
缺点- 大数据量处理效率低- 内存占用不连续可能导致碎片- 内存分配和释放开销大- 需要手动管理引用避免内存泄漏

内存泄漏排查

如遇到线上内存泄漏,为了减少影响面,建议先回滚大部分实例,留一至两实例保留现场用以排查。

常用工具和方法简介

Chrome DevTools - Memory

如果你可以在本地稳定复现,首推用 node --inpsect 启动后打开 Chrome DevTools 来实时观察内存上涨和录制内存快照,因为本地的运行内存一般都比较大,录制内存快照时不太会 OOM。还以一个简单的例子来了解 Memory 面板的各个功能。

// node --inspect xx.js
global.my_arrays = []
setInterval(() => {
  const arr = Array(10000).fill('1')
  global.my_arrays.push(arr);
}, 100)
观测内存变换

在通过node --inspect xx.js 启动后,打开浏览器的控制台,在左上角有个 Node.js Icon,点进去后,切换至「Memory」面板,就可以看到当前 JS 堆(不包括 Buffer)大小的实时变换。

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

这里只能粗略看出 JS 堆大小涨跌的趋势,可以点击「Take snapshot」来录制一个快照,进而分析存活对象和他们之间的引用关系。

了解术语

要读懂堆快照,先了解下 Shallow Size、Retained Size、Distance 和 Dominator Tree 这四个术语。

  1. Shallow Size:直译”浅大小“,表示仅对象本身占用的内存大小,不包括它引用的其他对象的内存,它能帮助你理解单个对象的内存占用,Shallow Size 往往都很小
  2. Retained Size:直译”持有大小“,当某个对象被删除时,被垃圾回收器释放的内存总量,包括该对象本身及其独占引用的所有对象的大小总和,Retained Size 往往都比较大,通常是由很多 Shallow Size 组成

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

以上面 Demo为例:

Array(10000).fill('1'): 申请了一万个数组并填充'1',从上图可以看出来,一个包含 '1' 字符串的数组被分配的 24 字节,而且字符串在这里已经是最小分配单位,所以 Shallow Size 和 Retained Size 相等。

一个字符串被分配 24 字节,10000 * 24 = 240000,比快照中显示 80016 大多了,这里应该是 V8 做的优化,父引用的 Retained Size 的值并不是简单地将所有子引用 Retained Size 累加得来,比如同一类型原型上的方法可以复用等等

const arr = Array(10000).fill('1'):申请的一万个数组赋值给了 arr,对应 @60925 那一行,看到 my_array[0] 数组本身的 Shallow Size 为 32 字节,子元素的总和(Retained Size) 为 80016,所以 my_array[0] 的总和为 80016 + 32 = 80048,依次类推,我们在 setInterval 中不断地申请和 push,最终 my_array的 Retained Size 达到了 6084352 字节。

  1. Distance:直译“距离”,表示当前对象与根对象的距离,比如 global 是最顶层对象,Distance 为 1,global.a = {b : 1} ,那么 a 的 Distance 是 2,b 的 Distance 是 3。

为了更直观看出 Distance,再建一个 Demo 如下:

    // node --inspect xx.js
    global.my_object = {
      a: {
        b: {
          c: Array(10000).fill('1')
        }
      }
    }
    class Foo {
      constructor() {
        this.arr = Array(10000).fill('1')
      }
    }

    const foo = new Foo()
    setInterval(() => {
      // 将 foo 的引用挂载至 setInterval 闭包中,不然引用引用计数为 0,会被回收
      console.log(foo) 
    }, 1000)

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

从上图可以看出,上半部分的是正向的引用关系,即 a -> b -> c -> Array(10000),对应 Distance 2 -> 3 -> 4 -> 5,选中某个对象时,下半部分将是反向的引用关系

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 VNodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

为了让 Foo 实例不被回收,在 setInterval 回调中引用, foo 挂载到 setInterval 创建的异步上下文中,上下文挂载到 setInterval 的匿名函数回调,匿名函数挂载到 Timeout 实例的 _onTimeout 属性上(setTimeout 和 setInterval 都是基于 Timeout 类创建的),可以看到挂载的对象越靠近根对象,Distance 越靠近 1。像极了多叉树,没错,不过这里叫支配树。

  1. 支配树(Dominator Tree)

支配树是基于支配关系构建的树形结构,其每个节点的父节点是直接支配它的节点。说人话就是父子节点之间是强绑定关系,如释放子节点 10 K,则父节点 Retained Size 减少 10K,如释放父节点,则子节点也被跟着释放。

这不就是”引用关系“吗?为啥整了新名词叫”支配关系“,因为引用可以同时被多个父节点引用,如下示例:

class C {
  constructor() {
    this.arr = Array(10000).fill('1')
  }
}
// 为了显示更直观,定义两个不同类名
class D {
  constructor() {
    this.arr = Array(10000).fill('1')
  }
}
const c = new C()
const d = new D()
global.root = {
  A: [c, d],
  B: [c],
}

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

如上图所示,如果释放 B,因为 C 还被 A 引用,所以 C 不会被释放,此时 B 和 C 不是强绑定(支配)关系,但如果释放 C,则 root 的 Retained Size 立即释放 C 的 Resident Size ,表示 root 和 C 是支配关系。其中 Shallow Size(简写成 SZ) 和 Resident Size(简写成 RZ) 的所有对应关系如下:

  • RZ_D = SZ_D
  • RZ_C = SZ_C
  • RZ_B = SZ_B
  • RZ_A = SZ_A + RZ_D
  • RZ_root = RZ_A + RZ_B + RZ_C + SZ_root

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

通过上图的快照再次验证:

  • RZ_A(80952) = SZ_A(64) + SZ_D(80888)

  • SZ_A(56) = SZ_B(56)

  • RZ_root(162152) = RZ_A(80952) + RZ_B(56) + RZ_C(80888) + SZ_root(40 + 216(map 分配的大小))

对快照原始数据感兴趣的同学可以看这篇文章

分析快照

当有异常状态下内存快照和了解完术语后,接下来是分析并找出异常或泄漏对象。如下是一个泄漏的例子:

// node --inspect xx.js
global.my_arrays = []
function leak_fun() {
  const arr = Array(10000).fill('1')
  global.my_arrays.push(arr);
}
setInterval(() => {
  leak_fun()
}, 500)

录制完快照或直接下载上面快照文件(需解压)后,在 Memory 面板点击上传 Icon

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

Summary

左上角默认是 Summary 视图,也是用的最多的视图。首先排序 Retained Size,排在前面的大对象可暂标记为嫌疑对象,进程分析对象的引用关系,比如 Timeout 实例占用内存是否较大或实例数过多。

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

在这个简单 case 中,排序直接就能看到 global 和 global.my_arrays 很突出,单纯一个对象的占用空间就达整体的 34%。继续下钻:

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 VNodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

可以看到这里的 Distance 值相同的对象特别多且在同一个对象下,说明是在同一个作用域下被循环创建但未被 GC。在复杂场景下,快照也可以定位到具体对象对应的文件位置,比如上述 Demo 分为两个文件:

  1. 文件 my_class.js
class MyClass {
  constructor() {
    this.arr = Array(10000).fill('1')
  }
}
const A = new MyClass()
exports.MyClass = MyClass
exports.A = A

2. 文件 main.js

const MyClass = require('./my_class')
global.my_arrays = []
// 实例 A 是在另一个文件中实例化并导出
global.my_arrays.push(MyClass.A)
function leak_fun() {
  const my_class = new MyClass.MyClass()
  global.my_arrays.push(my_class)
}

setInterval(() => {
  leak_fun()
}, 500)

继续运行 node --inspect main.js 录制快照后,选中 my_arrays 下第 0 个对象,那是在另一个 js 文件中实例化并导出,此时可以看到快照的下方有注明对象是在 my_class.js 文件 初始化,这对排查泄漏的根因非常有帮助。

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V
Containment

堆快照解析出来的内存关系图(支配树)从上往下的直接展示,它最外层的 Distance 都为 1,下一层都为 2,而 Summary 视图更像是将重要对象平铺出来,对用户来说,Summary 视图排查起来更友好一点。

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

Statistics

以饼状图的形式展示不同类型对象的内存占用情况,只能大概了解分布情况。

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

Comparison

该视图在排查泄漏也极为有用,首先录制一次正常状态下或泄漏前期时的内存快照,在泄漏中期再录制一次,然后一同上传至 Memory 面板,随即可以看到多了一个 Comparison 选项,如下所示:

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

和 Summary 视图类似,按照 Size Delta 降序后即可看到是哪些对象的占用大小相对前一个有所增加。

三种观测内存方式

Memory 面板提供三种方式供开发观测内存状况,各有千秋,下表是三种方式的对比:

Heap snapshotAllocation instrumentation on timelineAllocation sampling
功能捕捉特定时刻的堆内存快照一段时间内持续记录内存分配和释放事件,对定期 Heap snapshot 的补充一段时间内记录每个函数分配的内存大小
数据内容所有对象的数量、大小、类型、引用关系内存分配和释放的时间、大小、频率、类型、引用关系采样的内存分配事件,包括分配位置和频率
时间维度静态动态动态(采样)
粒度所有对象详细,所有对象和调用栈较粗,采样事件
内存泄漏检测非常有效可以检测,但主要用于动态分析较有效,但精度可能不如 Heap Snapshot
性能分析较少使用有效,实时分析较有效,适合长时间分析
优点- 详细的内存分布和引用链- 适合内存泄漏检测- 静态分析,无持续开销- 实时分析内存使用动态- 详细的内存分配和调用栈- 适合堆性能优化- 低开销- 适合长时间监控- 快速定位内存分配热点
劣势- 捕捉时可能导致短暂卡顿- 生成的快照文件可能很大- 持续跟踪,资源消耗较高- 记录数据量可能比快照文件更大- 粒度较粗,不适合精细分析- 可能漏掉一些分配事件
适用场景- 内存泄漏检测- 内存使用优化- 对比两个快照之间的差异- 动态内存分析- 内存泄漏检测- 长时间内存监控- 快速性能调优- 内存分配热点分析

在线录制

如果本地不太好复现,而且线上已经有内存泄露的迹象,可以直接进入 WebShell 对 Nodejs 进程进行信号通信,并执行录制内存快照的代码。以下是和 Node.js 进程通信并录制内存快照的代码:

// get_heap_snapshot.js
const WebSocket = require('ws')
const http = require('http')
const net = require('net')
const utils = require('node:util')
const sleep = utils.promisify(setTimeout)

// 接收目标 nodejs 进程的 pid
const targetPid = Number(process.argv[2])
if (!targetPid || typeof targetPid !== 'number') {
  throw new Error('target pid is not a number')
}
const DefaultPort = 9229
let host = '127.0.0.1'
// 探测 Inspector 服务是否已经启动
function detect(host = '127.0.0.1') {
  return new Promise((resolve, reject) => {
    // 优先检测 127.0.0.1 再检测 ::1
    const socket = net.connect(DefaultPort, host)
    socket.on('connect', () => {
      console.log(`connect ${host}:${DefaultPort} success`)
      socket.destroy()
      resolve('ok')
    })
    socket.on('error', (err) => {
      reject(err)
    })
  })
}
async function loopDetection() {
  let i = 10
  // 开始探测是否启动成功
  while (i--) {
    try {
      if (await detect()) {
        return
      }
      if (await detect('::1')) {
        host = '::1'
        return
      }
      break
    } catch (error) {
      console.log('detect error', error)
    }
    await sleep(500)
  }
}

const Filename = Date.now() + '.heapsnapshot'

async function getSnapshot() {
  // 给目的进程发送信号,启动 Inspector 服务
  process.kill(Number(process.argv[2]), 'SIGUSR1')
  await loopDetection()
  // 获取 Inspector 信息
  http.get(`http://${host}:9229/json`, (res) => {
    let chunk
    res.on('data', (data) => {
      chunk = chunk ? Buffer.concat([data, chunk]) : data
    })
    res.on('end', () => {
      const data = JSON.parse(chunk.toString())
      // 建立 ws 连接,开始通信
      const ws = new WebSocket(`ws://${host}:9229/${data[0].id}`)
      let id = 1
      const callbacks = {}
      function send(data, cb) {
        // 用来对应通信的 id,避免多次回调紊乱
        data.id = id++
        if (typeof cb === 'function') {
          callbacks[data.id] = cb
        }
        ws.send(JSON.stringify(data))
      }
      ws.on('open', () => {
        console.log('ws open sucessfully')
        send({
          method: 'Runtime.evaluate',
          params: {
            includeCommandLineAPI: true,
            awaitPromise: true,
            expression: `
                        const v8 = require('v8');
                    if (typeof v8.writeHeapSnapshot === 'function') {
                        v8.writeHeapSnapshot('${Filename}');
                    } else {
                        throw new Error('Your Node.js version do not support v8.writeHeapSnapshot API.');
                    }
                          `,
          },
        })
        // 关闭 Inspector
        send({
          method: 'Runtime.evaluate',
          params: {
            includeCommandLineAPI: true,
            expression: `
                                (function() {
                                    try {
                                        require('inspector').close();
                                    } catch (e) {}
                                })();
                            `,
          },
        })
      })
    })
  })
}
getSnapshot()

比如我们本地有个 array_leak.js 运行后会有一个 nodejs 进程,假如 pid 是 66640,那么运行上面代码 node get_heap_snapshot.js 66640后会在目标进程的目录生成一个内存快照

Nodejs:万字长文手摸手浅入内存泄漏本文旨在帮助那些刚刚涉足 Node.js 服务端开发。以前端开发者视角来浅入 V

但可能会卡在录制过程,其原因是录制内存快照本身是一件非常耗时且复杂的任务,需要的内存可能会根据当前堆内存翻倍,比较容易导致 OOM,以下有一些措施可以有效避免在录制过程中发生 OOM:

  • 如果一个实例启动两个进程时,系统运行内存会被平分,改为一个进程以独占所有内存

    • 某些情况,也可以在启动时显式添加 --max-old-space-size=8192 来给当前进程分配 8G 的虚拟
  • 在不断泄漏的前中期(内存负载 < 60%)就开始录制,留较多的内存给录制快照过程

排查步骤

上面讲了线上和线下的排查工具,那现在来讲讲遇到内存泄漏时,完整的排查步骤(思路):

  1. 确认是否发生了内存泄漏

如果我们发现 Node.js 应用的总内存占用曲线处于长时间的只增不降 ,并且在 QPS 比较比较平稳状态下堆内存趋势突破堆限制的 70% ,那么基本上可以确定是产生了泄漏。

  1. 查看 setTimeout 和 setInterval 存活个数

观测 setTimeout 和 setInterval 的存活个数是否符合预期,毕竟它两是泄漏根因的常客,可优先关注。当然,有些时候可能就几个存活个数,但关联了特别多的对象。

  1. 录制内存快照

本地录制快照方式:node --inspect xx.js,后进入 Memory 面板点击「Take Snapshot」

线上录制快照方式:上面通过信号通信的方式和正在运行 node.js 进程通信,并在主线程中执行代码

  1. 分析内存快照
  • 排序 Retained Size,排在前面的大对象可暂标记为嫌疑对象,进程分析对象的引用关系,比如 Timeout 实例占用内存是否较大或实例数过多

  • Distance 比较大且值相同的对象有特别多,可能是在某个循环中被一直创建,但没有被 GC。

  • 录制两份内存快照,一份来自正常状态或泄漏前期,一份来自泄漏中后期,利用 Comparison 视图来迅速找出泄漏点

  1. 复现和修复

找到可疑泄漏点后,试着通过最小示例复现问题,然后在测试环境中验证,最后在平台观测修复后内存指标,如果符合预期即可上线。

实践

报警配置

在闭包横行天下的 JS 中,难免会有内存泄漏,我们所能做的除了提高编码意识,还有配置报警来及时通知开发者线上的运行内存状况:

  1. 配置容器的内存阈值 && 配置 JS 进程的内存监控报警

正常情况下可以把内存阈值设置在 60 % - 70%,留一些 buffer。

  1. 配置 setTimeout & setInterval 存活个数阈值

setTimeout & setInterval 存活个数阈值不是一个通用值,所以没有纳入到报警模板,不过业务可以根据其 Metrics 来自定义报警。

压力测试

如果你的 Node 服务短时波峰诉求,可以压力测试以提前发现问题,可以在压测前录制一次内存快照,压测后再录制一次内存快照,对比前后对象增长点。当发现有些许异常时,可以再次压测并开启 Allocation sampling,会抓取在压测过程中每个函数分配的大小。

代码审查

  1. 在 Code Review 时重点关注定时器及长时间存活闭包函数,是否引用了外界对象
  2. 在创建每一个新实例时都要关注它什么时候需要被销毁和调用类 destroy 函数

引用地址

转载自:https://juejin.cn/post/7408163165416308751
评论
请登录