You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Node.js GPS追踪器应用内存泄漏求助:V8堆快照分析

内存泄漏排查求助:Node.js GPS追踪器Socket应用内存飙升问题

我们开发了一款用于GPS追踪器的Node.js Socket通信应用,核心功能为监听GPS追踪器的TCP连接、读取数据包、解析GPS数据并存储至数据库。应用运行初期内存占用约150MB,但数天后会飙升至10GB以上——并非持续增长,而是触发某事件后以每秒5MB的速度快速上涨。

已完成的排查工作

  • 优化过事件处理逻辑
  • 通过snapshot指令生成V8堆快照,GenereFichierConnexionActive指令生成活跃连接文件
  • 活跃连接文件显示仅约10个活跃连接,状态正常
  • 对比应用启动1小时与内存异常时的堆快照,发现Chrome DevTools中Mapping构造函数实例新增2869个且无回收记录

我们并非堆分析专家,特此求助排查该内存泄漏的原因,或提供其他可能的问题方向。核心SocketServer类代码如下:

import dotenv from "dotenv"
import ContextTrame from "../../context/ContextTrame";
import TeltonikaTrame from "../../trames/TeltonikaTrame";
import RedirectionTrame from "../../trames/RedirectionTrame";
import NS10Trame from "../../trames/NS10Trame";
import net, {Socket} from 'net';
import prisma from "../../lib/db";
import * as v8 from "v8";
import fs from 'fs';

dotenv.config();

const args = process.argv.slice(2);
const portIndex = args.indexOf("-p");

let port = 3000;
const activeConnection = new Map();
const TIMEOUT = 5 * 60 * 1000; // 5 minutes 

if (portIndex !== -1 && args[portIndex + 1]) {
    port = parseInt(args[portIndex + 1], 10);
}

class SocketServer
{
    private static socketServer = net.createServer(SocketServer.onConnect);
    private PORT: number = port;

    public constructor()
    {
        SocketServer.socketServer.listen(this.PORT, () => {
            console.log(`socket server bound on port ${this.PORT}`);
        });
    }

    /**
     * Méthode appelée lors de la connexion d'un client-backup.
     * @param socket Socket du client-backup.
    */
    public static onConnect(socket: Socket): void
    {
        console.log('a user connected : ' + socket.remoteAddress + ':' + socket.remotePort);
        socket.setTimeout(TIMEOUT);
        socket.on('data', (data) => SocketServer.onData(socket, data));
        socket.on('error', (err) => SocketServer.onError(socket, err));
        socket.on('close', (hadError) => SocketServer.onClose(socket,hadError));
        socket.on('end', () => SocketServer.onEnd(socket));
        socket.on('timeout', () => SocketServer.onTimeout(socket));
        activeConnection.set(socket,{ lastActiveTime: Date.now() }); 
    }

    /**
     * Méthode appelée lors de la réception de données.
     * @param socket Socket du client-backup.
     * @param data Données reçues.
    */
    private static onData(socket: any, data: Buffer): void
    {
        // On maj la dateheure d'activité
        activeConnection.get(socket).lastActiveTime = Date.now();

        // Sanapshot de la mémoire ram avec V8
        if(Buffer.from(data).toString() === 'snapshot') {
            console.log('snapshot received'); // ! Comments
            v8.writeHeapSnapshot(`./snapshots/heap_snapshot-${Date.now()}.heapsnapshot`);
        }

         // Si réception de la commande de snapshot.
         if(Buffer.from(data).toString() === 'GenereFichierConnexionActive') {
            this.generateActiveConnectionsFile();
        }

        const contextTrame = new ContextTrame();

        // Si la trame est un IMEI.
        if(Buffer.from(data).toString('hex').substring(0, 4) === '000f') {
            console.log('IMEI received'); // ! Comments
            socket.imei = Buffer.from(data).toString('ascii').substring(2);
            socket.write(Buffer.from('01', 'hex'));
        } else {
            // Si la trame est une trame Teltonika.
            if (socket.imei !== undefined) {
                console.log('Teltonika trame received'); // ! Comments
                contextTrame.setStrategy(new TeltonikaTrame(socket, data));
            } else {
                const hexString = Buffer.from(data).toString('hex').toUpperCase();
                const last12Characters = hexString.substring(hexString.length - 12);
                // Si la trame est une trame de redirection.
                if (last12Characters === '0D0A0D0A0D0A') {
                    console.log('Redirection trame received'); // ! Comments
                    contextTrame.setStrategy(new RedirectionTrame(data));
                } else {
                    // Si la trame est une trame NS10.
                    if (parseInt( Buffer.from(data).toString('ascii').substring(0, 1)) == 2) {
                        console.log('NS10 trame received'); // ! Comments
                        contextTrame.setStrategy(new NS10Trame(data));
                    }
                }
            }
        }

        contextTrame.isStrategySet() &&
        contextTrame.cut().then((res: boolean) => {
            if (res) {
                console.log('trame cuted'); // ! Comments
                contextTrame.insert().then((res: boolean) => {
                    if (res) {
                        console.log('trame inserted'); // ! Comments
                        contextTrame.redirect();
                    } else {
                        console.log('trame not inserted'); // ! Comments
                    }
                });
            } else {
                console.log('trame not cuted'); // ! Comments
            }
        });
    }

    /**
     * Méthode appelée lors d'une erreur.
     * @param socket Socket du client-backup.
     * @param err Erreur.
    */
    private static onError(socket: any, err): void
    {
        console.log(err);
        socket.destroy(); // On supprime le socket pour être sur qu'il soit bien fermé
    }

    private static onClose(socket: any, hadError: boolean): void
    {
        activeConnection.delete(socket);
    }

    private static onEnd(socket: any): void
    {
        console.log("Client à fermer la connection : "+socket.address());
        socket.destroy(); // On supprime le socket pour être sur qu'il soit bien fermé
    }

    private static onTimeout(socket: any): void
    {
        console.log("Socket tiemout : "+socket.address());
        socket.destroy(); // On supprime le socket pour être sur qu'il soit bien fermé
    }

    /**
     * Méthode appelée lors de l'envoi de données.
     * @param socket Socket du client-backup.
     * @param message Données à envoyer.
    */
    public write(socket: any, message: Buffer): void
    {
        socket.write(message);
    }

    // Function to generate text file containing active connections
    private static generateActiveConnectionsFile(): void
    {
        const filename = './snapshots/connexionActive.txt';
        let fileContent = '';

        activeConnection.forEach((data, socket) => {
            fileContent += `Socket: ${socket.remoteAddress}:${socket.remotePort}`;
            fileContent += ` --> Dernière data reçu : ${new Date(data.lastActiveTime).toLocaleString()}\n`;
        });

        fs.writeFile(filename, fileContent, err => {
            if (err) {
                console.error('Erreur pendant la création du fichier des connexions actives:', err);
            } else {
                console.log('Fichier des connexions actives généré :', filename);
            }
        });
    }
}

export { SocketServer };

排查方向建议

  1. 追踪Mapping实例的保留引用
    在Chrome DevTools堆快照中,选中这些Mapping实例,查看保留路径(Retainers),定位持有它们引用的对象——通常是全局变量、闭包或长期存在的容器(如Map/Set)阻止了垃圾回收。

  2. 检查策略类的资源泄漏
    每次接收数据都会创建ContextTrame及对应策略类实例,需确认:

    • 策略类是否持有Socket引用,且Socket关闭后未释放
    • 是否创建了定时器、事件监听器但未在资源释放时移除
    • 数据库交互(如insert())是否存在未处理的Promise,导致上下文被长期持有
  3. Socket连接的引用清理

    • 虽然onClose中会从activeConnection删除Socket,但需确认socket.destroy()是否彻底清理了所有事件监听器,可补充socket.removeAllListeners()避免残留监听器持有引用
    • 检查策略类或其他模块是否额外持有Socket引用,导致Socket实例无法被GC回收,进而activeConnection条目无法彻底清理
  4. 修复Promise链的异常处理
    当前contextTrame.cut().then(...)嵌套Promise未添加catch块,未处理的reject会导致上下文保留。建议添加异常捕获:

    contextTrame.isStrategySet() &&
    contextTrame.cut()
      .then((res: boolean) => {
        // 原有逻辑
      })
      .catch(err => {
        console.error('Trame处理异常:', err);
      });
    
  5. 检查Buffer的长期缓存
    代码中多次调用Buffer.from(data),若策略类中缓存了这些Buffer且未及时释放,会导致内存增长。需检查各Trame类是否存在不必要的Buffer长期存储。

  6. 验证Prisma连接池配置
    确认Prisma客户端是否存在连接泄漏,比如闲置连接未被回收、批量查询积累了大量未处理实例。可调整连接池的idleTimeout等参数,确保闲置资源被及时释放。

内容的提问来源于stack exchange,提问作者SCR Informatiques

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.28 15:49:50