生产环境的问题排查是每个后端开发者都必须面对的挑战。与本地调试不同,线上问题往往具有突发性、隐蔽性和紧迫性等特点。本文将完整记录一次Node.js服务内存泄漏问题的排查过程,从告警触发到根因定位,再到修复验证,希望能为大家处理类似问题提供参考。
一、问题发现
1.1 监控告警触发
某天凌晨2点,Prometheus监控告警群突然弹出一条告警:「Node.js服务内存使用率超过85%,且持续增长」。我立刻登录Grafana查看,发现该服务的内存使用曲线呈现出明显的上升趋势,从正常的200MB一路飙升到接近2GB(容器内存限制)。
查看服务日志,发现Node.js进程被系统OOM Killer强制终止,然后被PM2自动重启。可怕的是,重启后的服务内存仍然在持续增长,这意味着问题不是偶然的内存峰值,而是持续性的内存泄漏。
1.2 初步分析
首先确认了以下几点:
- 该服务近一周没有发布过新版本,排除了近期代码变更引入的问题
- 其他相同版本的服务运行正常,说明不是普遍性的代码缺陷
- 问题服务处理的请求量确实比其他实例高,且请求类型以WebSocket长连接为主
初步判断问题可能与WebSocket连接管理有关。
二、诊断过程
2.1 生成堆快照
Node.js内置了heapdump模块,可以在运行时生成堆内存快照。我通过以下命令生成了问题服务的堆快照:
// 在应用代码中动态启用
const heapdump = require('heapdump');
// 通过HTTP接口触发
app.get('/debug/heapdump', (req, res) => {
const filename = `heap-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(filename, (err) => {
if (err) return res.status(500).send(err);
res.send(`Heap dump written to ${filename}`);
});
});
生成快照后,通过kubectl cp将文件从Pod中拷贝到本地,用Chrome DevTools的Memory面板打开分析。
2.2 堆快照分析
在Chrome DevTools中,我首先关注的是「Retained Size」(保留大小)最大的对象。发现Socket实例的数量异常多,达到了15万个,占用了超过800MB的内存。
进一步分析这些Socket实例的引用链,发现它们都被一个名为activeConnections的Map对象持有。这个Map的key是userId,value是对应的Socket实例。
2.3 代码审查
定位到问题代码:
// websocket/manager.js
class WebSocketManager {
constructor() {
this.activeConnections = new Map();
}
addConnection(userId, socket) {
this.activeConnections.set(userId, socket);
socket.on('message', (data) => {
this.handleMessage(userId, data);
});
// 缺少连接关闭时的清理逻辑!
}
handleMessage(userId, data) {
// 处理消息...
}
}
module.exports = new WebSocketManager();
问题非常明显:当客户端断开连接时,服务端没有从activeConnections中移除对应的Socket实例。这导致每个断开的连接都会在内存中永久驻留,形成典型的内存泄漏。
三、修复方案
3.1 添加连接关闭处理
修复代码如下:
// websocket/manager.js
class WebSocketManager {
constructor() {
this.activeConnections = new Map();
}
addConnection(userId, socket) {
this.activeConnections.set(userId, socket);
socket.on('message', (data) => {
this.handleMessage(userId, data);
});
// 修复:添加关闭和错误事件处理
socket.on('close', () => {
this.removeConnection(userId);
});
socket.on('error', (error) => {
console.error(`Socket error for user ${userId}:`, error);
this.removeConnection(userId);
});
// 添加心跳检测,清理死连接
this.startHeartbeat(userId, socket);
}
removeConnection(userId) {
const socket = this.activeConnections.get(userId);
if (socket) {
// 清理所有监听器
socket.removeAllListeners();
// 关闭socket(如果还未关闭)
if (socket.readyState === 1) { // OPEN
socket.close();
}
this.activeConnections.delete(userId);
}
}
startHeartbeat(userId, socket) {
const heartbeat = setInterval(() => {
if (socket.readyState !== 1) {
clearInterval(heartbeat);
this.removeConnection(userId);
return;
}
socket.ping();
// 超时检测
const timeout = setTimeout(() => {
console.warn(`Heartbeat timeout for user ${userId}`);
clearInterval(heartbeat);
this.removeConnection(userId);
}, 5000);
socket.once('pong', () => {
clearTimeout(timeout);
});
}, 30000);
// 存储引用以便清理
socket._heartbeat = heartbeat;
}
handleMessage(userId, data) {
// 处理消息...
}
// 添加主动清理方法
cleanup() {
for (const [userId, socket] of this.activeConnections) {
this.removeConnection(userId);
}
}
}
// 进程退出时清理
process.on('SIGTERM', () => {
webSocketManager.cleanup();
process.exit(0);
});
module.exports = new WebSocketManager();
3.2 添加监控指标
修复后还增加了相关监控指标,以便及时发现类似问题:
// 使用prom-client添加自定义指标
const client = require('prom-client');
const activeConnectionsGauge = new client.Gauge({
name: 'websocket_active_connections',
help: '当前活跃的WebSocket连接数'
});
const connectionErrorsCounter = new client.Counter({
name: 'websocket_connection_errors_total',
help: 'WebSocket连接错误总数'
});
// 定期上报
setInterval(() => {
activeConnectionsGauge.set(webSocketManager.activeConnections.size);
}, 5000);
四、验证与复盘
4.1 修复验证
修复代码经过Code Review后发布到生产环境。通过Grafana持续观察:
- 发布前:内存持续增长,4小时后达到容器限制被OOM
- 发布后:内存稳定在250MB左右,长时间运行无增长
- WebSocket连接数与活跃用户数匹配,断开后及时释放
4.2 经验总结
这次故障排查让我总结了以下几点经验:
- 事件监听必须配对清理:添加事件监听器时,必须考虑对应的清理逻辑。特别是WebSocket、EventEmitter等场景,close/error事件的处理不能遗漏。
- 心跳机制很重要:长连接服务必须有心跳检测机制,及时发现并清理死连接,防止资源泄漏。
- 监控是发现问题的眼睛:如果没有Prometheus的内存告警,这个问题可能会在更晚的时候以更严重的方式爆发。完善的监控体系是生产环境稳定的基石。
- 建立应急响应流程:这次从告警触发到问题定位用了约1小时,修复上线用了2小时。事后我们梳理了故障应急响应流程:发现→定位→修复→验证→复盘,并建立了on-call值班制度。
五、内存泄漏的常见模式
基于这次经历和后续学习,我总结了Node.js中常见的内存泄漏模式:
| 模式 | 描述 | 预防措施 |
|---|---|---|
| 未清理的事件监听 | 添加监听器后未移除 | 使用once()或手动removeListener |
| 闭包引用 | 闭包中引用了大对象 | 及时释放不再需要的引用 |
| 全局变量累积 | 全局缓存无限增长 | 设置缓存大小限制和过期策略 |
| 定时器未清理 | setInterval/setTimeout未清除 | 组件销毁时清除定时器 |
| 流未关闭 | 文件流、网络流未正确关闭 | 使用pipeline,确保finally中关闭 |
六、预防策略
除了修复具体的问题,建立长期的预防机制更为重要:
- 代码审查:在CR阶段重点关注资源创建和释放的配对逻辑
- 静态分析:使用ESLint等工具检测潜在的资源泄漏问题
- 自动化测试:在CI流程中加入内存泄漏检测测试,使用Node.js的
--inspect模式监控测试过程中的内存变化 - 定期压力测试:模拟高并发场景,观察内存变化趋势
- 完善监控告警:除了内存使用率,还应监控连接数、句柄数、GC频率等指标
生产环境的稳定性需要我们时刻保持警惕。一次看似简单的内存泄漏,背后可能隐藏着设计上的缺陷。通过建立完善的监控体系、规范的编码习惯、以及系统化的故障处理流程,我们可以最大程度地降低生产故障的影响。希望本文的分享能对你有所帮助。