👋小伙伴们!今天来跟大家分享一下在实际工作中遇到的 Node.js 内存泄漏问题以及解决方案。作为一名经验丰富的后端开发,我要告诉你:内存泄漏不可怕,可怕的是不知道如何定位它!让我们来看看生产中常遇到的内存泄漏案例。
内存泄漏对服务端程序的影响 💥
内心泄漏是什么? 简单来说,内存泄漏就像是你家有个水桶,水(内存)一直在往里灌,但是出水口(垃圾回收机制)堵住了,水就会越积越多,最后桶爆掉(程序崩溃)。在 Node.js 中,内存泄漏就是那些不再被使用的对象却还占用着内存空间,没有被垃圾回收器正确回收。写前端应用时可能我们没有那么关心过,大不了卡了刷页面,但是如果用 JS 写服务端内存泄漏也不要怕,大不了每天半夜重启服务器 😭
怎么 Profiling 内存呢? 本篇更多的是一些实践经验,基础的内存管理和理解可以看之前的一篇:
🎯 游戏服务器即将爆炸 😱
事故现场 :某天,我们的线上服务器突然报警:内存使用率 90%!检查日志发现是 WebSocket 连接数暴增,但实际在线用户数并不多。这是怎么回事呢?在这样下去要内存溢出了,快来排查一下吧!
如何收集实时指标然后形成上面的图呢?可以集成 Prometheus,方法非常简单:
- 程序里集成 Prometheus Client SDK,不写任何自定义 metric 的情况下,它会将程序的运行时状态暴露在
/metric。Tips: 如果项目不会上报自定义metric 的话,通过 node.js exporter 来采集 Node.js 应用指标更加方便。 - 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 页面找到我们的程序。
分别在程序运行的 5分,1 小时,2 小时,5 小时 (当然你可以根据你的情况选择更合适的时间点)做个内存快照
我们发现这内存确实是只增不降,服务运行2小时,模拟当前玩家数 1000 时占用的内存是 35.2 M。服务运行 5小时的时候,玩家数已经降到了300,但是内存占用却是 44.4 M。这内存泄漏很严重。按照这个情况,达到90% 也就是几天的样子。
接下来我们开始对比内存变化:
- 在 Memory 面板中选择 "Comparison"
- 选择两个快照进行对比
- 关注 "Delta" 列(显示内存变化)
- 按 "Delta" 列排序,查看增长最多的对象
看到这个程序的内存占用情况简直惊呆了,这不仅仅一处导致的内存泄漏,这么多变量随着时间增长一直在增长,没有被回收。
下面从第一个开始分析
从下面的 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(对象及其引用的总大小)
- 查找可疑的全局变量、事件监听器、定时器等
在快照2中,我们发现 Socket 是462,但是在快照4中,Socket 有2000多个
项目中用到了 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 存在内存泄漏。
定时器泄漏 本项目中定时器在第二次和第三次内存对照中,多了150个定时器。
还有以下其他几类引用是我们要敏感的:
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。
内存管理不是一次性的工作,而是需要在整个应用生命周期中持续关注和优化的过程,看到这里了点个赞吧 ❤️!