📈 Node.js 性能优化大揭秘!瓶颈全攻克,吞吐量狂飙 🚀 第二篇 内存泄漏定位与解决全攻略

658 阅读7分钟

👋小伙伴们!今天来跟大家分享一下在实际工作中遇到的 Node.js 内存泄漏问题以及解决方案。作为一名经验丰富的后端开发,我要告诉你:内存泄漏不可怕,可怕的是不知道如何定位它!让我们来看看生产中常遇到的内存泄漏案例。

内存泄漏对服务端程序的影响 💥

内心泄漏是什么? 简单来说,内存泄漏就像是你家有个水桶,水(内存)一直在往里灌,但是出水口(垃圾回收机制)堵住了,水就会越积越多,最后桶爆掉(程序崩溃)。在 Node.js 中,内存泄漏就是那些不再被使用的对象却还占用着内存空间,没有被垃圾回收器正确回收。写前端应用时可能我们没有那么关心过,大不了卡了刷页面,但是如果用 JS 写服务端内存泄漏也不要怕,大不了每天半夜重启服务器 😭

怎么 Profiling 内存呢? 本篇更多的是一些实践经验,基础的内存管理和理解可以看之前的一篇:

你有关心过你的 Node.js 程序内存占用情况吗

🎯 游戏服务器即将爆炸 😱

事故现场 :某天,我们的线上服务器突然报警:内存使用率 90%!检查日志发现是 WebSocket 连接数暴增,但实际在线用户数并不多。这是怎么回事呢?在这样下去要内存溢出了,快来排查一下吧!

image.png

如何收集实时指标然后形成上面的图呢?可以集成 Prometheus,方法非常简单:

  1. 程序里集成 Prometheus Client SDK,不写任何自定义 metric 的情况下,它会将程序的运行时状态暴露在 /metric。Tips: 如果项目不会上报自定义metric 的话,通过 node.js exporter 来采集 Node.js 应用指标更加方便。
  2. Prometheus Server 设置一下,来采集这个 /metric 即可
global: scrape_interval: 15s # 抓取指标的时间间隔,这里设置为15秒
scrape_configs: 
    - job_name: 'nodejs_app' 
      static_configs: 
        - targets: ['localhost:3000'] # 替换为你的Node.js应用实际运行的地址和端口

通过对事发地的走访,我了解到这是一个多人在线游戏服务器,玩家的操作数据(如移动位置、使用技能等)需要实时同步到服务器,服务器接收这些数据后进行处理,更新游戏世界状态,并将相关信息广播给其他在线玩家,以保证所有玩家看到的游戏画面是一致的。但是今天的服务器数据统计在线玩家并不多,为什么内存已经到了告警值呢? 上工具!

下面做几张内存快照来对比,尝试找到那些只增不减的变量,它就是内存泄漏的罪魁祸首!

通过 node --inspect server.js 启动程序, 打开 Chrome 的 inspect 页面找到我们的程序。

image.png

分别在程序运行的 5分,1 小时,2 小时,5 小时 (当然你可以根据你的情况选择更合适的时间点)做个内存快照

image.png

我们发现这内存确实是只增不降,服务运行2小时,模拟当前玩家数 1000 时占用的内存是 35.2 M。服务运行 5小时的时候,玩家数已经降到了300,但是内存占用却是 44.4 M。这内存泄漏很严重。按照这个情况,达到90% 也就是几天的样子。

接下来我们开始对比内存变化:

  1. 在 Memory 面板中选择 "Comparison"
  2. 选择两个快照进行对比
  3. 关注 "Delta" 列(显示内存变化)
  4. 按 "Delta" 列排序,查看增长最多的对象

image.png

看到这个程序的内存占用情况简直惊呆了,这不仅仅一处导致的内存泄漏,这么多变量随着时间增长一直在增长,没有被回收。

下面从第一个开始分析

image.png

从下面的 Object 中我们定位到第一个泄漏的是 messageHistory 对象,代码中的这个变量是无限push的,没有控制长度

const player = this.players.get(playerId); 
if (player) { 
    player.messageHistory.push({ message, timestamp: Date.now() }); 
}

按照同样的分析方法,发现了 allPositions 也是类似的情况,无限记录。虽然这部分数据就是实时同步用的,没有必要存到数据库,但是应该控制他的增长速度,

// ❌ 错误做法:无限增长的数组
array.push(item);

// ✅ 正确做法:使用循环缓冲区
if (array.length >= maxSize) {
    array.shift();
}
array.push(item);

可以使用一个 Circular Buffer

class CircularBuffer {
    constructor(maxSize) {
        this.maxSize = maxSize;
        this.items = [];
    }
    
    add(item) {
        if (this.items.length >= this.maxSize) {
            this.items.shift();
        }
        this.items.push(item);
    }
}

修复代码如下

const history = player.messageHistory;
history.messages.push({ message, timestamp: Date.now() }); 
if (history.messages.length > history.maxSize) { 
    history.messages.shift(); 
}

接着来分析第二类问题,Socket 泄漏。

在刚开始进行内存分析的时候可能一头雾水,但是随着对 Node.js的深入做内存分析会越来越简单。靠直觉就能定位很多可疑对象,边分析我们也可以边总结一些排查步骤,比如:

  • 对比多个堆快照,关注增长的对象
  • 查看对象的 Retainers(持有者)链,找出内存无法释放的原因
  • 注意 Shallow Size(对象本身大小)和 Retained Size(对象及其引用的总大小)
  • 查找可疑的全局变量、事件监听器、定时器等

image.png

在快照2中,我们发现 Socket 是462,但是在快照4中,Socket 有2000多个

image.png

项目中用到了 Socket.io, 从这个数据我们可以得知:随着玩家断开 Socket,但是 Socket 对象并没有按照预期减少,在展开可疑的对象 Socket 时,查看其属性和引用关系。看到有 on(用于添加监听器的方法)相关的属性或者函数引用,并且这些引用在后续快照中没有被清理。

// ❌ 错误做法:监听器没有清理 socket.on('event', handler); 

// ✅ 正确做法:保存引用并清理 
const handlers = { event: handler }; 
socket.on('event', handler); 
Object.entries(handlers).forEach(([event, handler]) => { 
    socket.off(event, handler); 
});

定位到代码并修复

  const player = this.players.get(playerId);
        if (player) {
            // 清理事件监听器
            Object.entries(player.handlers).forEach(([event, handler]) => {
                player.socket.off(event, handler);
            });

            // 清理房间状态
            player.socket.leave(this.roomId);
            this.players.delete(playerId);
            this.gameState.positions.delete(playerId);
            this.gameState.scores.delete(playerId);
        }

前面我们找到一些排查思路,比如观察持续增长,但未被回收的对象, 这里有一些常见的引用链排查点

全局变量 glboal 可以看全局对象是不是持续增长,判断是不是全局对象有内存泄漏情况,比如本项目的全局变量 allGameMessages 存在内存泄漏。

image.png image.png

定时器泄漏 本项目中定时器在第二次和第三次内存对照中,多了150个定时器。

image.png

还有以下其他几类引用是我们要敏感的:

  • Closure: 闭包引用
  • event listeners: 事件监听器
  • WeakMap/WeakSet: 弱引用容器

这次服务器爆炸的主要原因就是对资源没有释放,导致随着时间推移,玩家越来越多,占用资源越多,通过当玩家离开游戏时对这些变量的销毁,整个程序的内存最终降下来了。

本次case中的经验中我们总结了以下经验:

  • 及时清理事件监听器
  • 正确管理定时器
  • 合理使用弱引用
  • 实现必要的销毁方法
  • 定期进行内存使用分析

总结 📝

做 Node.js 内存分析也有一些工具类库,但是可视化比较复杂,文本使用了最便捷的方式 Chrome DevTool 来可视化的定位内存问题,我们可以有效地发现和解决内存泄漏问题。但更重要的是要在日常开发中养成良好的编码习惯,从源头预防内存泄漏的发生,考虑到这个对象会不会被回收,举个例子

// 不好的做法
const userSessions = new Map();

function manageUserSession(user) {
    const session = { loginTime: Date.now(), activities: [] };
    userSessions.set(user, session);
    
    // 即使用户登出,Map 仍然保持对 user 对象的引用
}

// 好的做法
const userSessions = new WeakMap();

function manageUserSession(user) {
    const session = { loginTime: Date.now(), activities: [] };
    userSessions.set(user, session);
    
    // 当用户对象不再被引用时,session 数据会被自动清理
}

这里即使 user 不再使用了但垃圾回收器也无法回收这个对象,正确的做法应该使用 WeakMap。

内存管理不是一次性的工作,而是需要在整个应用生命周期中持续关注和优化的过程,看到这里了点个赞吧 ❤️!