nodejs学习2: 服务器资源及监控指标

85 阅读14分钟

对于一个后端开发来说,要时刻关注服务器资源CPU、内存、硬盘。

那怎么获取到资源的信息呢?

其实通过 node 的原生 api 就可以做到。

CPU

os.cpus()

我们创建一个nest服务:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import * as os from 'os';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
  @Get('status')
  status() {
    return os.cpus();
  }
}

image.png

返回的数组元素个数就是 cpu 数。

那具体的属性是什么意思呢?

times.user、times.sys、times.idle 分别代表用户代码占用的 cpu 时间、系统代码占用的 cpu 时间,空闲的 cpu 时间。

基于这些就能算出 cpu 的使用率、空置率来。

@Get('status')
status() {
    const cpus = os.cpus();
    const cpuInfo = cpus.reduce(
      (info, cpu) => {
        info.cpuNum += 1;
        info.user += cpu.times.user;
        info.sys += cpu.times.sys;
        info.idle += cpu.times.idle;
        info.total += cpu.times.user + cpu.times.sys + cpu.times.idle;
        return info;
      },
      { user: 0, sys: 0, idle: 0, total: 0, cpuNum: 0 },
    );
    const cpu = {
      cpuNum: cpuInfo.cpuNum,
      sys: ((cpuInfo.sys / cpuInfo.total) * 100).toFixed(2),
      used: ((cpuInfo.user / cpuInfo.total) * 100).toFixed(2),
      free: ((cpuInfo.idle / cpuInfo.total) * 100).toFixed(2),
    };
    return cpu;
}

用 reduce 方法累加 cpu 的数量、user、sys、idle 以及总的 cpu 时间。

然后 cpu 的系统使用率就是 sys/total,用户使用率是 user/total 而空置率就是 idle/total。

image.png

内存

memoryUsage 核心作用

process.memoryUsage() 是 Node.js 内置的 process 模块提供的 API,用于实时获取当前 Node.js 进程的内存使用详情,返回一个包含多个关键指标的对象,是排查内存泄漏、评估服务内存占用的核心工具。

它不需要安装任何依赖,直接调用即可。

const memoryInfo = process.memoryUsage();
console.log('内存使用详情:', memoryInfo);

// 输出示例(单位:字节)
// {
//   rss: 45000000,        // 常驻集大小
//   heapTotal: 18000000,  // 堆总内存
//   heapUsed: 9000000,    // 堆已使用内存
//   external: 1200000,    // 外部内存
//   arrayBuffers: 100000  // ArrayBuffer 占用内存
// }

所有字段默认单位是 字节 (Bytes) ,可转换为 MB(除以 1024*1024)更易读,各字段含义如下:

字段名中文释义核心说明
rss常驻集大小 (Resident Set Size)Node.js 进程占用的物理内存总大小(包括堆、栈、外部库、代码段等),是操作系统视角的内存占用
heapTotalV8 堆总内存V8 引擎为 JavaScript 对象分配的总堆内存空间(已申请但未必全部使用)
heapUsedV8 堆已使用内存V8 引擎实际用于存储 JavaScript 对象(变量、函数、数组等)的内存,排查内存泄漏的核心指标
external外部内存V8 管理的、不在 V8 堆中的内存(如 Buffer 数据、C++ 扩展占用的内存)
arrayBuffersArrayBuffer 内存所有 ArrayBufferSharedArrayBuffer 占用的内存(属于 external 的子集)

heapTotal 代表 V8 引擎为 JavaScript 对象分配的总堆内存空间(已向操作系统申请但未必全部使用)。V8 采用动态扩容机制,当满足以下条件时,heapTotal 会自动增大:

  1. 堆内存不足:当前 heapUsed(已使用堆内存)接近 heapTotal 时,V8 会判断现有堆空间不够用,主动向操作系统申请更多内存,导致 heapTotal 上升;
  2. 垃圾回收(GC)后仍不足:V8 会先触发垃圾回收,尝试释放无用内存。如果 GC 后 heapUsed 依然接近 heapTotal 上限,就会触发堆扩容;
  3. 大对象分配:程序中创建大数组、大对象(比如一次性加载大量数据)时,V8 会直接扩容堆空间以容纳这些对象。

Node.js 默认限制 V8 堆内存(64 位系统约 1.4GB,32 位 0.7GB),可通过启动参数调整,适用于大内存场景:

# 将 V8 老年代堆内存上限调整为 4GB 
node --max-old-space-size=4096 your-app.js

系统内存

上面是nodejs的进程的内存,使用os可以获取服务器的内存情况。

bytesToGB(bytes) {
    const gb = bytes / (1024 * 1024 * 1024);
    return gb.toFixed(2);
}

getMemInfo() {
    const totalMemory = os.totalmem();
    const freeMemory = os.freemem();
    const usedMemory = totalMemory - freeMemory;
    const memoryUsagePercentage = (((totalMemory - freeMemory) / totalMemory) * 100).toFixed(2);
    const mem = {
      total: this.bytesToGB(totalMemory),
      used: this.bytesToGB(usedMemory),
      free: this.bytesToGB(freeMemory),
      usage: memoryUsagePercentage,
    };
    return mem;
}

// 结果GB
"mem": {
    "total": "24.00",
    "used": "23.73",
    "free": "0.27",
    "usage": "98.88"
 },

可对比进程内存与系统总内存,评估资源占用比例:

const processMem = (process.memoryUsage().rss / os.totalmem() * 100).toFixed(2);

内存泄露

内存泄漏指的是:Node.js 进程中已分配的内存,在不再被使用的情况下,无法被 V8 垃圾回收(GC)机制释放,导致 heapUsed 持续上涨,最终耗尽 V8 堆内存上限,触发 JavaScript heap out of memory 错误

V8 对堆内存有默认上限(可通过启动参数调整):

  • 64 位系统:默认约 1.4 GB(老年代堆);
  • 32 位系统:默认约 0.7 GB;

如果 heapTotal 达到上限后,heapUsed 仍持续增长,会抛出 JavaScript heap out of memory 错误(内存溢出)。

以下是最常见的内存泄露的场景:

1. 未清理的全局变量

Node.js 中未声明的变量会自动挂载到 global 对象上,成为全局引用,永远不会被 GC 回收。

// 错误示例:未声明变量,自动挂载到 global
function loadData() {
  // 没有 let/const/var,data 成为全局变量
  data = Array(1000000).fill({ name: 'leak' }); 
}

// 调用后,data 一直存在于全局,无法回收
loadData();

2. 未移除的事件监听器

EventEmitter 的事件监听如果重复绑定、且未在使用后移除,会保留回调函数的引用,导致关联对象无法回收。

const EventEmitter = require('events');
const emitter = new EventEmitter();

// 错误示例:每次调用都新增监听器,旧监听器无法回收
function addListener() {
  // 匿名回调引用了外部大对象
  const bigData = Array(1000000).fill('leak');
  emitter.on('data', () => {
    console.log(bigData);
  });
}

// 多次调用,监听器堆积,内存暴涨
for (let i = 0; i < 10; i++) {
  addListener();
}

3. 缓存未设置过期 / 清理策略

手动实现的缓存(比如对象 / Map)如果只加不减,会无限膨胀,导致内存泄漏。

// 错误示例:缓存只存不取,无限增长
const cache = new Map();

function setCache(key) {
  const data = Array(1000000).fill('leak');
  cache.set(key, data);
  // 没有清理逻辑,缓存永远不会被回收
}

// 持续添加缓存,内存耗尽
let key = 0;
setInterval(() => {
  setCache(key++);
}, 100);

修复:设置缓存过期时间,或限制缓存大小:

const cache = new Map();
const MAX_CACHE_SIZE = 100; // 限制缓存最大数量

function setCache(key) {
  const data = Array(1000000).fill('leak');
  
  // 超过上限时清理最早的缓存
  if (cache.size >= MAX_CACHE_SIZE) {
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey);
  }
  
  cache.set(key, data);
}

还有很多导致内存泄露的原因,比如未正确销毁的数据库连接、未结束的流(Stream)等等

内存泄漏的核心特征是 heapUsed 持续上涨且不回落」,可通过以下方式监控识别:

// 每5秒输出 heapUsed,观察趋势
setInterval(() => {
  const { heapUsed, heapTotal } = process.memoryUsage();
  console.log(`heapUsed: ${(heapUsed / 1024 / 1024).toFixed(2)} MB, heapTotal: ${(heapTotal / 1024 / 1024).toFixed(2)} MB`);
}, 5000);

判断标准

  • 正常情况:heapUsed 涨涨跌跌(GC 会定期释放);
  • 泄漏情况:heapUsed 持续上升,即使触发 GC 也只小幅回落,最终接近 heapTotal 上限。

下面来演示下内存持续上升的案例:

const http = require('http')

function computeTerm(term) {
  return computeTerm[term] || (computeTerm[term] = compute())
  function compute() {
    return Buffer.alloc(1e3)
  }
}


const requestLogs = []
const server = http.createServer((req, res) => {
  switch (req.url) {
    case '/cache':
      res.end(computeTerm(Math.random()))
      break;
    default:
      res.writeHead(404)
      res.end(JSON.stringify({ error: 'Resource not found' }))
  }
})

server.listen(5444)
console.log('Server listening to port 3000. Press Ctrl+C to stop it.')

package.json:

"test:cache": "clinic doctor --on-port 'autocannon -w 300 -c 100 -d 20 localhost:5444/cache' -- node index.js"

autocannon -w 300 -c 100 -d 20 localhost:5444/cache

  • c(concurrency) 100 : 压测时会建立 100 个持久的 TCP 连接,所有请求都通过这 100 个连接发送。注意,这里只是建立了TCP连接,并不是发送100个请求。

  • d(duration) 20 : 压测会持续 20 秒,20 秒后自动停止,然后输出汇总的压测结果(QPS、RT、错误数等)。

  • w 是 --workers 的简写,含义是启动多少个 worker 线程来发送请求。当你指定数值时,它的实际作用是 --rate,即限制每秒发送的总请求数

执行:

npm run test:cache

image.png

Latency: 平均延迟(Avg)8.59 ms✅ 非常优秀,用户几乎无感知。

Req/Sec: 平均 QPS24,428.06✅ 非常高,单接口 2.4 万 + QPS,性能强劲。

/cache 接口是缓存接口(无慢数据库查询),平均 RT 仅 8.59ms,根据公式:

如果去掉 -w 300,结果如下:

image.png

没有了限速,请求数量暴增。

我们主要是看内存泄露的问题:

image.png

可以看到rss一直在稳步上升的,这是因为我们在全局对象computeTerm上不断绑定属性,属性的值为一个大的二进制对象。

硬盘

这里用到 node-disk-info 这个包:

npm install --save node-disk-info

getDiskStatus() {
    const disks = nodeDiskInfo.getDiskInfoSync();

    const sysFiles = disks.map((disk) => {
      return {
        mounted: disk.mounted,
        filesystem: disk.filesystem,
        total: this.bytesToGB(disk.blocks) + 'GB',
        used: this.bytesToGB(disk.used) + 'GB',
        free: this.bytesToGB(disk.available) + 'GB',
        usage: ((disk.used / disk.blocks || 0) * 100).toFixed(2),
      };
    });
    return sysFiles;
  }

image.png

分别是路径、文件系统、总大小、已用大小、可用大小、已用百分比:

image.png

系统其他信息

@Get('status')
async status() {
    return {
      cpu: this.getCpuInfo(),
      mem: this.getMemInfo(),
      dist: await this.getDiskStatus(),
      sys: this.getSysInfo()
    }
}

getSysInfo() {
    return {
      computerName: os.hostname(),
      computerIp: this.getServerIP(),
      osName: os.platform(),
      osArch: os.arch(),
    };
}

getServerIP() {
    const nets = os.networkInterfaces();
    for (const name of Object.keys(nets)) {
      for (const net of nets[name]) {
        if (net.family === 'IPv4' && !net.internal) {
          return net.address;
        }
      }
    }
}

这里的 os.networkInterfaces 是拿到所有网卡信息:

image.png

从中过滤出非 IPv4 的外部网卡的 ip 来返回。

和我系统设置里的 ip 一样:

image.png

此外,我们还通过 os.homename、os.platform、os.arch 分别拿到了主机名、操作系统、操作系统架构等信息。

监控指标

QPS

QPS(Queries Per Second)是衡量 Node.js 服务(及各类后端服务)处理能力的核心性能指标,中文译作每秒查询率,简单来说就是服务器每秒能够成功处理的请求数量。它是评估服务吞吐量、并发能力的关键指标。

QPS 聚焦于 “成功处理” 的请求数,计算公式:

image.png

比如:10 秒内 Node.js 服务成功处理了 10000 个 HTTP 请求,那么 QPS = 10000 / 10 = 1000。

一般每天80%的访问都会集中在20%的时间内,这20%的时间叫做峰值时间。

image.png

也就是峰值QPS一般是QPS的4倍左右。

峰值 QPS 不是 “瞬间偶尔达到的最高值”,而是在服务在无明显性能降级(RT 不超过预设阈值、错误率 < 1%、饱和度可控)的前提下,能持续承受的最大 QPS。

举个例子:

某 Node.js 服务瞬间 QPS 冲到 2000,但此时 RT 从 50ms 飙升到 3s,错误率(504 超时)达到 5%,这不是有效峰值 QPS

该服务稳定运行时能持续处理 1500 QPS,且 RT < 200ms、错误率 < 0.1%,这 1500 才是峰值 QPS

QPS 与之前讲的 USE 方法论的关联

  • QPS 升高 → 若 CPU 利用率超过 80% → 事件循环饱和度上升(请求排队)→ 最终 QPS 无法继续提升,甚至错误数(5xx)增加;

  • QPS 过低 → 可能是资源闲置(CPU 利用率 < 50%),或服务存在性能瓶颈(如数据库慢查询导致请求处理耗时久)。

RT 响应时间 与 并发数 Concurrent Requests

RT(Response Time,响应时间)是衡量 Node.js 服务用户体验和性能效率的核心指标,中文译作响应时间,简单来说就是从客户端发起请求到接收到服务端完整响应的总耗时。它直接反映服务的 “快慢”,是用户能直观感知的性能指标。

image.png

对 Node.js 服务而言,我们通常关注服务端 RT(排除网络传输耗时),即:

image.png

示例:

  • 客户端 10:00:00.000 发起请求 → 服务端 10:00:00.001 接收 → 服务端 10:00:00.050 返回 → 客户端 10:00:00.055 接收;
  • 整体 RT:55ms(用户感知);
  • 服务端 RT:49ms(服务可优化的部分);
  • 网络耗时:6ms(客户端↔服务端传输,非服务端可控)。

并发数指同一时刻,服务端正在处理的请求数量(包括 “正在执行” 和 “等待 I/O 完成” 的请求)。

对 Node.js 来说:由于单线程 + 异步 I/O 的特性,并发数 ≠ 线程数(比如 Node.js 单进程能同时处理 1000 个并发请求,而不是只能处理 1 个)。

通俗比喻:餐厅里同时吃饭的顾客数(有的顾客在等上菜(I/O),有的在吃(执行逻辑),都算 “并发”。

image.png

若 Node.js 服务的平均 RT = 50ms(0.05s),同时能稳定处理 100 个并发请求 → QPS = 100 / 0.05 = 2000;

若平均 RT 升高到 100ms(0.1s),并发数仍为 100 → QPS = 100 / 0.1 = 1000(RT 翻倍,QPS 减半)。

下面我们使用autocannon进行压测下:

// server.js
const http = require('http')

// 可通过参数控制 RT:node server.js 50 → RT=50ms
const rtMs = parseInt(process.argv[2]) || 50

const server = http.createServer((req, res) => {
  // 模拟 RT(异步 sleep,不阻塞事件循环)
  setTimeout(() => {
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(
      JSON.stringify({
        code: 200,
        msg: `RT=${rtMs}ms`,
        concurrency: server._connections, // 当前并发数(Node.js 内置属性)
      }),
    )
  }, rtMs)
})

server.listen(3000, () => {
  console.log(`服务启动,模拟 RT=${rtMs}ms,监听 3000 端口`)
})

启动:

node index.js 50

再开一个命令行,package.json:

"test": "autocannon -c 100 -d 10 http://localhost:3000"

压测结果:

image.png

  • Latency(延迟): 可以看到平均的延时是51.5ms,这个和我们设置的50ms相近。
  • Req/Sec: 平均QPS为1920,100(并发数) / 0.05s = 2000,这个和2000相近。

现在我们把RT=100ms压测下:

node index.js 100

image.png

可以看到都相应的减半了。

并发数是 QPS 的 “容器” :Node.js 单进程的并发数上限由事件循环能力、文件描述符(fd)等决定(默认可打开的 fd 数约 1024,可调整),并发数越高,理论 QPS 越高(但有上限);

RT 是 QPS 的 “限速器” :哪怕并发数能到 1000,若 RT 是 1s,QPS 也只有 1000/1=1000;若 RT 降到 10ms,QPS 能到 1000/0.01=10000;

上面提到的文件描述符是什么东西呢?

文件描述符(File Descriptor,简称 fd)是操作系统给打开的文件 / 网络连接 / 设备分配的一个数字编号,你可以把它理解成:操作系统给每个 “正在使用的资源” 发的一张 “身份证号”

比如你在 Node.js 里:

  • 打开一个文件 fs.open('./test.txt') → 操作系统分配一个 fd(比如 3);
  • 建立一个 HTTP 连接(用户请求你的服务)→ 操作系统也会分配一个 fd(比如 4);
  • 连接 Redis/MySQL → 每个连接也会对应一个 fd。

你操作这些资源(读文件、发网络数据)时,操作系统不是直接操作资源,而是通过这个数字编号(fd)来找到对应的资源 —— 就像你去快递站取件,不用报包裹详情,报取件码(编号)就行。

为什么 fd 数量会限制 Node.js 并发数?

Node.js 处理的每一个 HTTP 请求,本质上都是一个网络连接,而每个网络连接都会占用一个 fd。

  • 操作系统默认给进程设置了 fd 上限(比如 Linux 默认为 1024)→ 意味着你的 Node.js 进程最多同时打开 1024 个 “资源”(包括网络连接、文件、数据库连接等);
  • 如果你的服务都是处理 HTTP 请求,那这 1024 个 fd 基本都被网络连接占用 → 理论上单进程最多同时处理~1000 个并发请求(还要留少量 fd 给文件 / 日志等);
  • 超过这个数,新的请求会被操作系统拒绝,Node.js 根本收不到,自然没法处理。

在 Node.js 中操作 MySQL(或其他数据库 / 网络连接)时,你几乎不需要直接获取和操作 fd —— 因为 Node.js 的数据库驱动(如 mysql2/mysql)已经帮你封装了 fd 的管理,你只需要调用驱动提供的 “关闭连接” 方法即可。

fd 是操作系统层面的底层编号,Node.js 对网络 / 数据库连接的封装(比如 net.Socket)会帮你管理 fd:

  • 当你用 mysql2 建立连接时,驱动会创建一个 TCP 套接字(net.Socket),操作系统给这个套接字分配 fd;
  • 驱动内部会通过 fd 和 MySQL 通信,但不会把 fd 暴露给你 —— 就像你用手机打电话,不用管手机内部的信号编号,只需要点 “挂断” 按钮就行。

如果想看看 fd 是多少,可以通过 mysql2 驱动的底层套接字对象获取,示例如下:

const mysql = require('mysql2');

// 1. 创建 MySQL 连接
const connection = mysql.createConnection({
  host: 'localhost',
  user: 'root', // 替换成你的 MySQL 用户名
  password: '123456', // 替换成你的密码
  database: 'test' // 替换成你的数据库名
});

// 2. 连接成功后,获取底层套接字 → 再获取 fd
connection.connect((err) => {
  if (err) {
    console.error('连接失败:', err);
    return;
  }
  console.log('MySQL 连接成功');

  // 核心:connection._socket 是底层 TCP 套接字对象,fd 存在这里
  const fd = connection._socket.fd;
  console.log('MySQL 连接对应的 fd 编号:', fd); // 输出比如 10、12 等数字
});

// 3. 错误处理
connection.on('error', (err) => {
  console.error('MySQL 连接错误:', err);
});