Node.js服务性能分析

260 阅读4分钟

当Node.js服务出现性能问题时,可以通过以下的方法进行排查:

基础检查

# 查看监听特定端口进程PID
lsof -i:8080

# 监控进程资源占用情况
top -pid {pid}

# 查看磁盘IO性能状况
# -c 重复显示的次数 -w 每隔多少秒输出一次
iostat -c 2 -w 1

请求延迟高

👉🏻 测试示例代码

排查方法

  • 启用V8内置的分析器
node --cpu-prof app.js

在进程正常退出后,会当前目录生成 *.cpuprofile 文件。该文件可以通过 Chrome DevTools 查看,也可以通过一些火焰图可视化工具查看。

这里我们选择用 VSCode 查看(需要安装火焰图插件)文件,如下图:

Screenshot 2025-05-14 at 12.38.54.png

🌟 关注Self Time

  • 启用事件循环跟踪
node --trace-event-categories=node.perf,node.async_hooks app.js

运行后会自动生成 node_trace.log 文件,可以用 chrome://tracing 查看。如下图:

Screenshot 2025-05-14 at 12.50.12.png

🌟 关注Wall Duration

  • 使用 clinic 性能分析工具
clinic flame -- node app.js

在退出进程后,会自动生成结果文件 *flame.html。通过浏览器打开:

Screenshot 2025-05-14 at 17.17.33.png

  • 使用 process.cupUsage()
const startUsage = process.cpuUsage();
// ...执行关键操作
const endUsage = process.cpuUsage(startUsage);
console.log(`CPU Time: user ${endUsage.user / 1000} system ${endUsage.system / 1000}`);

// 输出结果
// CPU Time: user 49.644 system 0.227

优化方案

根据上述排查结果可知,函数 pbkdf2Sync 执行相当耗时。它是一个同步函数,会阻塞事件循环,导致系统吞吐量下降。为了解决该问题,我们改为使用异步版本的 pbkdf2 函数:

  crypto.pbkdf2(password, user.salt, 10000, 512, 'sha512', (err, buf) => {
    if (user.password === buf.toString('hex')) {
      res.status(200).json({
        code: 0,
        message: 'Success.',
        data: {
          user: _.omit(user, ['password', 'salt']),
          jwt: 'jwt',
        },
      });
    } else {
      res.status(401).json({ message: 'Unauthorized.' });
    }
  });

进行负载测试:

优化前:

Screenshot 2025-05-14 at 12.59.16.png

优化后: Screenshot 2025-05-14 at 12.59.00.png

可以看到,优化过后,服务并发能力大幅度提高!

内存泄漏

👉🏻 测试示例代码

排查方法

  • 使用 process.memoryUsage()
function showMem() {
  const mem = process.memoryUsage();

  function format(bytes) {
    return Math.floor(bytes / 1024 / 1024) + 'MB';
  }

  console.log(`Memory Usage: heapTotal ${format(mem.heapTotal)} | heapUsed ${format(mem.heapUsed)} | rss ${format(mem.rss)}`);
}

showMem();
// ...执行关键操作

输出如下:

Screenshot 2025-05-14 at 15.31.30.png

可以看到,每次调用都使导致内存增长。

  • 使用 heapdump 模块
// 在文件头部引入模块
const heapdump = require('heapdump');

在进程启动后,通过向进程发送 SIGUSR2 信号,就会生成一份堆内存的快照。该文件可以通过 Chrome DevTools 查看,如下图:

Screenshot 2025-05-14 at 16.13.00.png

通过分析可知,有大量的 "leak" 字符串存在,这些字符串就是一直未能得到回收的数据。

  • 使用 clinic 性能分析工具
clinic heapprofiler -- node app.js

在退出进程后,会自动生成结果文件 *heapprofiler.html。通过浏览器打开:

Screenshot 2025-05-14 at 16.50.47.png

从上图可知,已经定位到了一次 “最大的内存分配” 以及发生分配的代码行,下方也清晰地显示了调用链路。

负载测试工具

  • wrk
# 发送POST请求
wrk -c 20 -t 4 -d 100s data.lua http://localhost:8080

data.lua

wrk.method = "POST"
wrk.body   = '{"username":"Aric","password":"12345678"}'
wrk.headers["Content-Type"] = "application/json"
  • ab
# 发送POST请求
ab -k -c 20 -n 250 -p data.json -T 'application/json' http://localhost:8080

总结

以上就是在对 Node.js 服务进行本地排查时最常用的手段。在生产环境中,通常需要通过APM工具(New Relic等),结合日志系统进行分析。

优化建议

在使用 Node.js 构建服务时:

  1. 注意对内存的使用,尽量不要在本地存储大对象,如果确实需要,可以通过一些淘汰机制限制内存使用。更推荐使用专业地内存数据库,如 memcachedredis 等。
  2. 针对CPU密集型任务,可以使用 Worker threads 去执行,避免长时间阻塞事件循环。
  3. 尽量将一些操作异步化,通过回调的形式或者事件通知(借助消息队列组件等)获取结果。

测试代码

高延迟示例:

const express = require('express');
const crypto = require('node:crypto');
const _ = require('lodash');
const { faker } = require('@faker-js/faker');

const salt = crypto.randomBytes(128).toString('base64');

const users = [
  {
    userId: faker.string.uuid(),
    username: 'aric',
    email: 'aric.vvang@gmail.com',
    userRole: 'ADMIN',
    salt,
    password: crypto.pbkdf2Sync('12345678', salt, 10000, 512, 'sha512').toString('hex'),
  },
];

const app = express();

app.use(express.json());

app.post('/auth', (req, res, next) => {
  const {
    username, password,
  } = req.body;

  if (!password) {
    res.status(400).json({ message: '"password" is not to be allowed empty.' });
    return;
  }

  const user = users.find((o) => o.username === username)
  if (!user) {
    res.status(404).json({ message: 'user is not registered.' });
    return;
  }

  const encryptPassword = crypto.pbkdf2Sync(password, user.salt, 10000, 512, 'sha512');

  if (!crypto.timingSafeEqual(Buffer.from(user.password, 'hex'), encryptPassword)) {
    res.status(401).json({ message: 'Unauthorized.' });
    return;
  }

  res.status(200).json({
    code: 0,
    message: 'Success.',
    data: {
      user: _.omit(user, ['password', 'salt']),
      jwt: 'jwt',
    },
  });
});

app.listen(8080, () => console.log('Server running at http://localhost:8080/'));

['SIGINT', 'SIGTERM'].forEach((signal) => process.on(signal, () => process.exit(0)));

内存泄漏示例:

const express = require('express');

const leakArray = [];

const app = express();

app.get('/leak', (req, res, next) => {
  leakArray.push(new Array(1024 * 1024).fill('leak'));

  res.status(200).json({ message: 'Success' });
});

app.listen(8080, () => console.log('Server running at http://localhost:8080/'));