Introduction
随着 Node v11.0 release 版本的发布, Node 已经走过了很多年. 基于 Node 产生了很多服务端框架, 来帮助我们独立于后端进行前端工程的开发和部署.
业务逻辑的迁移, 以及各种 MV * 框架的服务端渲染模型的出现, 让基于 Node 的前端 SSR 策略更依赖服务器性能. 首屏直出性能以及 Node 服务的稳定性, 直接关系影响着用户体验. Node 作为服务端语言, 相比于 Java 和 PHP 这种老服务端语言来说, 对于整体性能的调控还是不够完善. 虽然有 sentry 这种报警平台来及时通知发生的错误, 但是不能够预防错误的发生. 如何防患于未然, 首先需要理解 Node.JS 性能监控的主要指标.
下面的代码均是基于 Egg 框架的, 如果对 Egg 不熟悉的小伙伴可以先去浏览一下文档
指标
服务器的资源瓶颈主要有下面几个:
CPU
内存
磁盘
I/O
网络
考虑到不同的 Node 环境, 其对于资源的需求类型也是不尽相同的. 如果 Node 只是用于前端 SSR 的话, 那么 CPU 和网络就会成为主要的性能瓶颈.
当然如果你需要使用 Node 来进行数据持久化相关的工作, 那么 I/O 和磁盘也会有很高的占用率.
即使是前端发展非常超前的公司, 也很少会用 Node 作为业务数据的支撑. 充其量当做 BFF 层来为前端提供数据服务, 并不直接接触持久化的数据. 所以磁盘和 I/O 很难成为当下前端性能的瓶颈.
即使存在使用 Node 进行数据持久化平台, 大多数也是实验性质的平台或者是内部平台. 不直接面向业务场景.
所以, 在大多数场景下, CPU, 内存以及网络就可以说是 Node 的主要性能瓶颈.
CPU 指标
CPU 负载和 CPU 使用率
顾名思义, 这两个指标都是用来评估系统当前 CPU 的繁忙程度的量化指标. CPU 负载和 CPU 使用率是从两个不同的角度来量化 CPU 的繁忙程度的.
CPU 负载: 进程角度
CPU 使用率: CPU 时间分配
进程是资源分配的最小单位.
这句话在操作系统的教科书上或者各位的考试卷上都多多少少出现过. 也就是, 系统按照进程级别来进行资源的分配, 一个 CPU 核心在一个时刻只能够为 4 个进程提供服务.
那么, CPU 的负载也就很好理解了. 在某个时间段内, 占用以及等待 CPU 的进程总数就是 CPU 在这个时间段内的负载(load average), 在大多数情况下, 我们称这个标准为 loadavg.
而 CPU 利用率(CPU utilization), 则是量化 CPU 时间占用状况的, 一般我们认为 CPU 利用率 = 1 - 空闲 CPU 时间(idle time) / CPU 总时间.
wiki 上已经解释的非常清楚了, 请自备梯子
量化 CPU 指标
那么这两个指标到底哪个才最能代表的系统的实际状态呢?
滑梯: CPU
人: 进程
假如有 4 个滑梯. 每个滑梯上最多可以塞得下 10 个人. 我们假设所有的人的大小一致. 那么, 可以得到如下的类比:
Loadavg = 0, 表示滑梯上一个人都没有
Loadavg = 0.5, 表示平均每个滑梯上的人都占了滑梯的一半, 也就是总共 20 个人在滑梯上, 由于 CPU 调度策略, 这些人一般会均匀分配(每个人都会挑人少的滑梯)
Loadavg = 1, 表示每个滑梯上都塞满人了, 没有任何空闲空间
Loadavg = 2, 表示不仅仅每个滑梯上都塞满了人, 还有 40 个人在后面等着
以上的类比都是基于瞬时的 loadavg 得到的.
一般对于 loadavg 的量化, 我们都是采用 3 个不同的时间标准来进行的. 1 分钟, 5 分钟以及 15 分钟.
1 分钟的指标是很难得到较为均衡的指标的. 因为 1 分钟时间太短, 可能某一秒的峰值就能够影响到 1 分钟时间段内的平均指标. 但是, 1 分钟内, 如果 loadavg 突然达到很高的值, 也可能是系统崩溃的前兆, 也是需要警惕的一个指标.
而 5 分钟和 15 分钟则是较为合适的评判指标. 当 CPU 在 5 分钟或者 15 分钟内都保持高负荷运作, 对于整个系统是非常危险的. 遇到过堵车的人都应该知道, 一旦发生了堵车, 只要堵塞不及时清理, 就会越堵越长. CPU 也是这样, 如果 CPU 上等待的进程阻塞的较多, 那么后面进入队列的任务就更加抢占不到资源, 也就会被一直阻塞了.
在 Mac 上可以在 root 权限下, 使用 sysctl -n vm.loadavg 来获得.
- // /App/lib/CPU.JS
- const os = require('os');
- // CPU 核心数
- const length = os.cpus().length;
- // 单核 CPU 的平均负载
- os.loadavg().map(load => load / length);
而 CPU 利用率则是不太好作为直接评判标准的数值. 由于进程阻塞在 CPU 上的原因不相同, 对于 CPU 密集型任务来说, CPU 利用率可以很好地表示当前 CPU 的工作情况, 但是对于 I/O 密集型的任务来说, CPU 空闲不代表 CPU 无事可做, 可能是任务被挂起, 去进行其他操作了.
但是, 对于进行 SSR 的 Node 系统来说, 渲染基本上可以理解为 CPU 密集型业务, 所以这个指标在一定程度上可以体现出当前业务环境的 CPU 性能.
- // /App/lib/CPU.JS
- const os = require('os');
- // 获取当前的瞬时 CPU 时间
- const instantaneousCpuTime = () => {
- let idleCpu = 0;
- let tickCpu = 0;
- const cpus = os.cpus();
- const length = cpus.length;
- let i = 0;
- while(i <length) {
- let CPU = cpus[i];
- for (let type in CPU.times) {
- tickCpu += CPU.times[type];
- }
- idleCpu += CPU.times.idle;
- i++;
- }
- const time = {
- idle: idleCpu / cpus.length, // 单核 CPU 的空闲时间
- tick: tickCpu / cpus.length, // 单核 CPU 的总时间
- };
- return time;
- }
- const cpuMetrics = () => {
- const startQuantize = instantaneousCpuTime();
- return new Promise((resolve, reject) => {
- setTimeout(() => {
- const endQuantize = instantaneousCpuTime();
- const idleDifference = endQuantize.idle - startQuantize.idle;
- const tickDifference = endQuantize.tick - startQuantize.tick;
- resolve(1 - (idleDifference / tickDifference));
- }, 1000);
- });
- };
- cpuMetrics().then(res => {
- console.log(res);
- // 0.074999
- });
结合上述两个指标, 可以大致得到系统的运行状态, 从而对于系统进行干预. 比如将 SSR 降级为 CSR.
内存指标
内存是一个非常容易量化的指标. 内存占用率是评判一个系统的内存瓶颈的常见指标. 对于 Node 来说, 内部内存堆栈的使用状态也是一个可以量化的指标.
- // /App/lib/memory.JS
- const os = require('os');
- // 获取当前 Node 内存堆栈情况
- const { rss, heapUsed, heapTotal } = process.memoryUsage();
- // 获取系统空闲内存
- const sysFree = os.freemem();
- // 获取系统总内存
- const sysTotal = os.totalmem();
- module.exports = {
- memory: () => {
- return {
- sys: 1 - sysFree / sysTotal, // 系统内存占用率
- heap: heapUsed / headTotal, // Node 堆内存占用率
- node: rss / sysTotal, // Node 占用系统内存的比例
- }
- }
- }
对于 process.memoryUsage()拿到的值有一些需要关注的地方:
我的 Node 启蒙书《深入浅出 Node.JS》这本书, 虽然版本已经落后了现在的 Node.JS 很多 release 了, 但是其中讲到的关于 V8 引擎的 GC 机制的内容, 仍然非常受用, 推荐大家买正版支持一下朴灵老师.
rss: 表示 node 进程占用的内存总量.
heapTotal: 表示堆内存的总量.
heapUsed: 实际堆内存的使用量.
external: 外部程序的内存使用量, 包含 Node 核心的 C++ 程序的内存使用量.
首先需要关注的是内存堆栈, 也就是堆内存的占用. 在 Node 的单线程模式下, C++ 程序 (V8 引擎) 会为 Node 申请一定的内存, 来作为 Node 线程的内存资源 heapTotal. 而在我们 Node 的使用过程中, 声明的新的变量都会使用这些内存来进行存储 heapUsed.
Node 的分代式 GC 算法会在一定程度上浪费部分内存资源, 所以当 heapUsed 达到 heapTotal 一半的时候, 就可以强制触发 GC 操作了 global.gc().gc 操作相关可以看下这篇文章. 对于系统内存的监控处理, 不能够仅仅像 Node 内存级别一样, 进行 GC 操作就可以, 而同样需要进行渲染降级. 70% ~ 80% 的内存占用就是非常危险的情况了. 具体的数值需要根据环境所在的宿主机来确定.
具体和 Node 内存 GC 策略以及分配规则相关的, 可以看 StrongLoop - Node.JS Performance Tip of the Week: Managing Garbage Collection.
QPS
严格意义上来说, QPS 不能够作为 web 监控的直接标准. 但是当服务器在高负载的情况下, 不能够得到和压测情况下接近的 QPS 的时候, 就需要考虑是某些其他原因导致了服务器的性能瓶颈. 一般在进行 Node 环境下的 SSR 的时候, 假设 Node-Cluster 最大线程数为 10, 那么可以并行进行 10 个页面的渲染, 当然这也取决于宿主 CPU 的核心数.
在将 Node 作为 SSR 的宿主环境的情况下, 可以很容易地记录到当前机器在一段时间内响应的请求数. 之前在做毕业论文的时候, 有尝试过对于 Web 站点进行压力测试的几种方式.
- ApacheBench https://httpd.apache.org/docs/2.4/programs/ab.html
- https://acme.com/software/http_load/
- Seige https://www.joedog.org/
这三个 Web 压测工具大同小异, 都能够进行并发请求测试, 对于 Web 站点进行多用户的并发访问, 并且记录到所有请求过程的响应时间, 并且重复进行请求, 可以很好地模拟 Node 环境在压力下的表现.
根据性能压测的结果, 以及对于需求的流量峰值的评估, 可以大致计算出需要多少台机器才能够保证 Web 服务的稳定性, 保证大多数用户能够在可接受的时间内得到响应.
测试
根据上述三个指标, 对于本地启动的环境进行压测.
本地启动的 Node 环境是基于 Egg 框架扩展的 React SSR 环境, 实际线上环境由于很多静态资源 (包括 JavaScript 脚本, CSS, 图片等) 都被推到了 CDN 上, 所以这些资源不会直接对环境产生压力, 而且生产环境和开发环境也存在很多流程上的区别, 所以实际性能要比本地启动的好很多. 这里为了测试方便, 所以直接在本地启动了 Egg 工程.
测试环境
本地可以使用 PM2 启动 Node 工程, 或者直接通过 Node 命令启动, 在本地测试环境尽量不要使用 webpack-dev-server 这样的开发环境启动, 这样可能会导致 Node 的 Cluster 模式不能够很好地运行, 监控线程阻塞掉页面渲染的线程. 基于 Egg 的环境可以使用 schedule 定时任务来定时打印环境监控日志. 具体使用可以看 Egg 的文档, 里面会写的比较详细. 然后自定义一个日志类型, 将监控日志独立于应用日志存储起来, 便于分析和可视化.
- // /App/schedule/monitor.JS
- const memory = require('../lib/memory');
- const CPU = require('../lib/cpu');
- module.exports = App => {
- return {
- schedule: {
- interval: 10000,
- type: 'worker',
- },
- async task(ctx) {
- ctx.App.getLogger('monitorLogger').info('你想打印的日志结果')
- }
- }
- }
- // /config/config.prod.JS
- const path = require('path');
- // 自定义日志, 将日志文件自定义到一个单独的监控日志文件中
- module.exports = appInfo => {
- return {
- customLogger: {
- monitorLogger: { file: path.resolve(__dirname, '../logs/monitor.log') }
- }
- }
- }
然后准备 siege 进行压测: Mac 上安装 siege https://www.jianshu.com/p/21b2beb08a8a
或者在 Mac 上可以更简单地使用 brew 来直接安装 siege. 推荐使用这种方法, 因为直接下载源码包编译的话, 可能会发生 libssl 库链接不上的问题, 导致不能够进行 https 请求.
测试和监控结果
在无请求访问情况下:
siege
配置 siege 的请求 URL 列表: 我们可以将想要 siege 请求的 URL 放在文件里面, 通过 siege 命令进行读取(这里需要注意, siege 只能够访问 http 站点, 如果站点强制 https 的话可能需要考虑其他方法).
urls 文件
执行: siege -c 10 -r 5 -f urls -i -b
-c: 模拟有 n 个用户同时访问
-r: 重复测试 n 次
-f: 指定测试 URL 的获取文件
-I: 指定随机访问 URL 获取文件中的 URL
-b: 请求无需等待
上面的 siege 命令就表示, 每次并发 10 个, 分别请求 urls 文件中的随机一个站点, 然后这样的并发一共执行 5 次, 并且无需等待直接访问.
可以看到, siege 对于服务端进行了 515 次命中, 因为服务端除了主页面还有一些静态资源需要请求, 这些命中包含页面, JavaScript 脚本, 图片以及 CSS 等, 平均每个资源的响应时间为 0.83 秒
请求结束时间为 20:29:37, 可以看到这个时间之后, CPU 的各项指标都开始下降, 而内存没有非常明显的变化.
再进行一次压力较大的测试:
执行: siege -c 100 -r 5 -f urls -i -b, 将并发数增加到 10 倍也就是 100 并发.
可以看到平均响应时间下降到了 3.85 秒, 非常明显. 而且 loadavg 相比第一次压测的时候, 有着非常明显的上升. 内存使用的变化不大,
因为测试环境的机器是虚拟机, 不会独占物理机的所有资源, 但是获取的 CPU 数却是物理机的 CPU 数. 由于之前我们对于每种参数都计算了单核的情况, 所以这里和 CPU 相关的结果需要和物理机核心数以及虚拟机占用的核心数相关.
有兴趣的小伙伴可以尝试一下机器的极限 ORZ. 或者在物理机上尝试一下压测. 我没有敢这么伤害我的小兄弟.
Conclusion
现在很多业务开始往前端进行迁移, BFF(backends for frontends)的概念有很多团队已经开始逐渐尝试去做了. 让后端专注于提供统一的数据模型, 然后将业务逻辑迁移到基于 Node.JS 的 BFF 层中, 让前端给自己提供 API 接口, 这样就剩下了很多前后端联调的成本, 让后端提供的 RPC 或者 HTTP 接口更加通用, 更少地修改后端工程, 加快开发的效率.
但是这样就非常依赖 Node 端的稳定性, 在 BFF 架构中, 一旦 Node 端发生错误导致阻塞, 则所有前端页面都会丢失服务, 造成很严重的后果, 所以 Node 端的监控越来越有意义. 结合一些传统平台比如 sentry 或者 zabbix 可以帮助构建一个稳定的前端部署环境.
参考
几种 Web 服务器性能压测工具 https://www.vpser.net/opt/webserver-test.html
- Node.JS Garbage Collection Explained
- Pattern: Backends For Frontends https://samnewman.io/patterns/architectural/bff/
- Node.JS Performance Monitoring - Part 1: The Metrics to Monitor
- Node.JS Performance Monitoring - Part 2: Monitoring the Metrics
- What is loadavg http://www.loadavg.com/2012/01/what-is-loadavg/
- Using LoadAvg for Performance Optimization
来源: https://juejin.im/post/5c71324b6fb9a049d37fbb7c