生产环境的问题排查是每个后端开发者都必须面对的挑战。与本地调试不同,线上问题往往具有突发性、隐蔽性和紧迫性等特点。本文将完整记录一次Node.js服务内存泄漏问题的排查过程,从告警触发到根因定位,再到修复验证,希望能为大家处理类似问题提供参考。

一、问题发现

1.1 监控告警触发

某天凌晨2点,Prometheus监控告警群突然弹出一条告警:「Node.js服务内存使用率超过85%,且持续增长」。我立刻登录Grafana查看,发现该服务的内存使用曲线呈现出明显的上升趋势,从正常的200MB一路飙升到接近2GB(容器内存限制)。

查看服务日志,发现Node.js进程被系统OOM Killer强制终止,然后被PM2自动重启。可怕的是,重启后的服务内存仍然在持续增长,这意味着问题不是偶然的内存峰值,而是持续性的内存泄漏。

1.2 初步分析

首先确认了以下几点:

初步判断问题可能与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.2 经验总结

这次故障排查让我总结了以下几点经验:

  1. 事件监听必须配对清理:添加事件监听器时,必须考虑对应的清理逻辑。特别是WebSocket、EventEmitter等场景,close/error事件的处理不能遗漏。
  2. 心跳机制很重要:长连接服务必须有心跳检测机制,及时发现并清理死连接,防止资源泄漏。
  3. 监控是发现问题的眼睛:如果没有Prometheus的内存告警,这个问题可能会在更晚的时候以更严重的方式爆发。完善的监控体系是生产环境稳定的基石。
  4. 建立应急响应流程:这次从告警触发到问题定位用了约1小时,修复上线用了2小时。事后我们梳理了故障应急响应流程:发现→定位→修复→验证→复盘,并建立了on-call值班制度。

五、内存泄漏的常见模式

基于这次经历和后续学习,我总结了Node.js中常见的内存泄漏模式:

模式 描述 预防措施
未清理的事件监听 添加监听器后未移除 使用once()或手动removeListener
闭包引用 闭包中引用了大对象 及时释放不再需要的引用
全局变量累积 全局缓存无限增长 设置缓存大小限制和过期策略
定时器未清理 setInterval/setTimeout未清除 组件销毁时清除定时器
流未关闭 文件流、网络流未正确关闭 使用pipeline,确保finally中关闭

六、预防策略

除了修复具体的问题,建立长期的预防机制更为重要:


生产环境的稳定性需要我们时刻保持警惕。一次看似简单的内存泄漏,背后可能隐藏着设计上的缺陷。通过建立完善的监控体系、规范的编码习惯、以及系统化的故障处理流程,我们可以最大程度地降低生产故障的影响。希望本文的分享能对你有所帮助。