本文目录
[[toc]]
NodeJS 的性能优势
- 采用单线程 + 事件循环的组合,避免了多线程中线程切换以及锁竞争的开销
- 单线程减少了内存开销( Java 每个线程约 2MB ,而 Nodejs 只需要几十 KB)
- 通过
Cluster模块(用于创建多进程的核心模块,支持 主进程 + 子进程 架构)可以轻量级扩展,通过多个进程并行处理请求,提高吞吐量。 - 采用非阻塞 I/O 模型,处理 I/O 时不需要新开线程去处理,只需要在单线程中异步调用即可。
NodeJS 的事件循环
NodeJS 分为同步任务与异步任务,底层实现中,异步任务都是由 libuv 支持,这个库对跨平台底层能力进行了抽象,比如 socket 、 线程 、 I/O 等。
所以 NodeJS 的事件循环也与 libuv 的事件循环是一致的。
事件循环细分阶段
整个 NodeJS 事件循环 分为以下阶段:
Timers: 执行setTimeout()和setInterval()的回调I/O Callbacks: 处理异步 I/O 回调Idle, prepare: 内部使用,不常见Poll: 轮询新的 I/O 事件,执行与 I/O 相关的回调Check: 执行setImmediate回调Close Callbacks: 处理关闭的回调,如socket.on('close', () => {})
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ nextTick
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ nextTick
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘
│ nextTick ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ nextTick └───────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │
│ └─────────────┬─────────────┘
│ nextTick
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘事件循环流程
NodeJS 底层使用的是 libuv 实现的异步 I/O ,所以整套事件流程也与 libux 中的事件循环 一致
handle: 表示能够在活动时执行某些操作的长期对象。request: 通常代表短暂的操作。
整体流程图如下:
graph TD
A[初始化循环时间] --> B[运行到期定时器]
B --> C{事件循环是否被激活?}
C -->|否| D[结束]
C -->|是| E[调用 pending callbacks]
E --> F[运行 idle handles]
F --> G[运行 perpare handles]
G --> H[Poll for I/O]
H --> I[运行 check handles]
I --> J[调用 close callbacks]
J --> K[更新循环时间]
K --> B- 初始化循环时间: 初始化
now。 - 运行到期定时器: 运行到期计时器。所有活动计时器会判断调用时间是否在
now之前,是的话会在此阶段调用定时器回调。 - 事件循环是否被激活?:如果事件循环是激活( alive )的(存在
active loop/ref'd handles/active requests/closing handles),就开始进入循环,否则跳过循环结束本次任务。 - 调用 pending callbacks: 调用挂起的回调。 大部分 I/O 回调都是在 poll for I/O 阶段立即调用 ,但是某些情况下这些回调会被推迟到下一个事件循环周期。如果发生了这种推迟的情况,这些回调将在这里被调用。
- 运行 idle handles: 调用 idle handles 回调。
- 运行 perpare handles: perpare handles 在循环阻塞 I/O 之前立即调用他们的回调。通常是 NodeJS 内部调用,用于预分配资源、资源初始化等。
- Poll for I/O:
- 计算 I/O 阻塞时间: 使用算法计算超时时间。在阻塞 I/O 之前计算应该在多少时间后超时。如果当前没有其他任务需要执行,就永不超时,否则将最近需要执行的任务的调用时间作为超时时间。
- 循环阻塞 I/O: 按照超时时间阻塞 I/O ,此过程中如果有 I/O 回调被触发,会被立即执行
- 运行 check handles: 执行需要立即处理的回调,比如
setImmediate。 - 调用 close callbacks: 比如 关闭 socket 、关闭 I/O 之类的关闭事件触发回调。
- 更新循环时间: 更新
now值,开始下一轮循环。
process.nextTick()
process.nextTick() 不属于事件循环的一部分,只要在每个事件循环阶段完成了,就会触发 process.nextTick()
文件监听
fs.watch: 通过操作系统底层 API 监听文件变更,需要拥有文件的读权限,可能存在操作系统兼容性fs.watchFile: 通过定时轮询检查文件最后修改时间,全系统适配,但是在大量监听文件或者目录时,有性能问题。
性能优化
如何调试
NodeJS 应用通过 node --inspect-brk entry.js 运行,此时 NodeJS 应用会被断点中断,不会执行。
使用 Chrome 打开 chrome://inspect 进行远程调试,可以连接上前面打开调试模式的 NodeJS 应用,如果跨网络可以指定 IP + 端口。
通过 Chrome 的调试面板,录制 CPU 、 内存等的开销情况,同时打流压测,收集数据分析耗时操作。
内存优化
Buffer 规定分配内存的最小单位为 8KB ,整体流程如下:
flowchart TD
A[创建 Buffer 请求] --> B{Buffer 大小判断}
B -->|"小于等于 4KB"| C[检查当前 slab 剩余空间]
B -->|"大于 4KB"| D[直接分配独立 SlowBuffer]
C --> E{剩余空间是否足够?}
E -->|是| F[从当前 slab 分配内存]
E -->|否| G["创建新 slab 池<br>( 8KB ArrayBuffer )"]
G --> H[更新 poolOffset 为 0]
F --> I["更新 poolOffset<br>poolOffset += size"]
I --> J[内存校准<br>8 字节对齐]
J --> K[返回 FastBuffer 实例]
D --> L[返回独立 SlowBuffer 实例]
style A fill:#f9f,stroke:#333
style D fill:#f96,stroke:#333
style K fill:#6f9,stroke:#333
style L fill:#6f9,stroke:#333同理,在遇到内存瓶颈的时候,可以考虑使用自定义内存池的策略来优化内存使用,也就是时间换空间。
转为 C++ 代码
node-gyp 编译的模块只能在特定的操作系统与 NodeJS 版本中运行。
编译可以查看官方文档 C++ addons 章节
编译脚本固定名称为 binding.gyp
{
"targets": [
{
// 编译出来的插件名
"target_name": "addon",
// 声明 C++ 模块的入口文件
"sources": [ "hello.cc" ]
}
]
}一个 hello World 的示例:
// hello.cc
#include <node.h>
namespace demo {
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::NewStringType;
using v8::Object;
using v8::String;
using v8::Value;
// 输出 'world'
void Method(const FunctionCallbackInfo<Value>& args) {
// 获取当前 JS 线程的执行栈
Isolate* isolate = args.GetIsolate();
// 获取 JS 函数的返回值引用
args.GetReturnValue()
// 设置返回值为什么内容
.Set(
// 在 isolate 中创建字符串 'world'
// 并将其设置为函数返回值
String::NewFromUtf8(
isolate,
"world",
NewStringType::kNormal
)
.ToLocalChecked());
}
// 模块初始化函数,定义模块有哪些 exports
void Initialize(Local<Object> exports) {
// 定义模块导出一个 hello 函数,对应的 C++ 中定义的 Method 函数
NODE_SET_METHOD(exports, "hello", Method);
}
// NODE_MODULE 是 C++ 宏,编译时替换为模块定义的函数
// NODE_GYP_MODULE_NAME 也是宏,编译时替换为 binding.gyp 配置的 target_name
// 这里声明了模块入口,以及初始化模块需要调用哪个函数
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}使用子线程 (worker_threads)
通过 worker_threads 开启子线程进行计算,可以有效降低性能开销。
子线程不能单独分配资源,使用的内存空间与主线程共享,不适合处理高内存占用的任务,适合处理 CPU 密集任务。
使用子进程 (child_process)
通过 child_process 创建子进程,可以避免线程间共享资源问题,子进程将获得独立的内存空间进行计算,比子线程更加自由。
由于资源不共享,需要与主进程通信时,尽量避免传输大文件、大内容。
通过 cluster 可以为每个子进程绑定一个 CPU 核心,提高 CPU 占用的时间片,以获得更快的执行效率
---
title: cluster 父子通信时序图
---
sequenceDiagram
participant Master
participant Worker
Master->>+Worker: cluster.fork()
activate Worker
Worker-->>Master: online 事件
Master->>Worker: send(任务/句柄)
Worker->>Worker: 处理任务
Worker-->>Master: send(结果)
Master->>Worker: send(关闭信号)
Worker-->>Master: exit 事件
deactivate Worker稳定性
错误上报
// 监听异常
process.on('uncaughtException', (err) => {
// 上报异常
console.error(err)
// 这里如果不退出, nodejs 并不会结束该进程。
// 但是可能此时进程已经崩溃,无法正常工作了,如果不退出,反而可能会有异常行为
process.exit(1)
})子进程自动拉起
// 监听子进程退出
// 可以替换成 child_process
cluster.on('exit', () => {
// 需要加上时延以及最大重试次数,避免一拉起就崩溃,将 CPU 干崩
setTimeout(() => {
// 拉起子进程
cluster.fork()
}, 5000)
})监控内存泄露自动重启
setInterval(() => {
// 可以更加精细化的控制,比如内存连续增长,按照业务需要定制检测策略
// 这里检查内存占用量超过阈值
if (process.memoryUsage().rss > (70 * 1024 * 1024)) {
// 上报内存泄露
console.log('leak')
// 结束进程
process.exit(1)
}
}, 5000)监控僵尸进程
通过心跳包可以监控子进程是否被挂起或者陷入死循环,变成僵尸进程。
// 累计丢失的心跳包
let missedPing = 0
// 心跳包阈值
const maxMissedPing = 5
// 创建子进程
const childProcess = cluster.fork()
childProcess.on('message', (msg) => {
if (msg === 'pong') {
missedPing--
}
})
const interval = setInterval(() => {
childProcess.send('ping')
missedPing++
// 心跳包丢失超过阈值,关闭线程
if (missedPing > maxMissedPing) {
// 清理定时器,避免给不存在的进程继续发消息
clearInterval(interval)
// 必要的时候可以上报
console.error('missed ping over max')
// 关闭子线程
// childProcess 已经没有能力响应外部事件了
// 所以这里调用操作系统的 kill 强制关闭
process.kill(childProcess.process.pid)
}
}, 300)GraphQL
GraphQL 基本可以拆分为以下环节:
parsing: 解析阶段。根据词法分析、语法分析生成 Query String 的 ASTValidation: 验证阶段。分为内置校验与自定义校验,内置校验会校验变量是否定义、字段是否存在、片段是否存在等等。Execution: 执行阶段。开始执行查询功能,根据预先定义的解析函数,获取字段值,并合并为响应结果
工作流程
---
titile: 'GraphQL 工作流程图'
---
graph TD
A(发起 Request) --> B[HTTP Server]
B -->|解析 request Body| C[获取 GraphQL Query String]
C --> D[解析 Query String]
D -->|生成| E[AST]
E --> F[Validator]
F -->|验证通过?| G{Valid?}
G -->|是| H[Executor]
G -->|否| I[返回错误]
H --> J[Resolve Fields]
J --> K[类型系统处理]
K --> L{需要子字段?}
L -->|是| M[递归解析]
L -->|否| N[序列化结果]
M --> N
N --> O[结果合并]
O --> P[JSON Response]
I --> P
P --> Q[返回客户端]
classDef process fill:#e1f5fe,stroke:#039be5;
classDef decision fill:#fff3e0,stroke:#fb8c00;
classDef data fill:#f0f4c3,stroke:#cddc39;
class A,B,C,Q process;
class D,F,H,J,K,L,M,N,O process;
class G decision;
class E,P data;Query String
Query String 本身借鉴了许多主流语言的语法
以 JSON 语法为例
- 对象值语义定义为别名
- 删除所有
,与" - 将查询条件
(${arg1}: ${alias1}, ${arg2}: ${alias2})放到对应字段上 - 通过指令
@include、@skip、@deprecate管理字段 - 使用
...${fragment}复用数据结构 - 在最外层补上需要进行的操作: 查询 (
query) 、 修改 (mutation) 、 等待服务端推送 (subscription)
也就是如下示例:
# 查询需要携带参数 groupID
query GetGroupWithUsers($groupID: ID!) {
# 将查询参数传递给 group
group(id: $groupID) {
id
name
description
users {
id
name
email
# 嵌套的关联数据查询
posts {
id
title
content
}
friends {
id
name
}
}
}
}整个 Query String 只有在参数部分才会涉及类型定义,字段的类型由服务端内置定义好,不会再修改,所以 posts 字段自动会解析为数组,而不需要客户端管理类型。
至于字段的来源,到底是 读写数据库、 文件 I/O 、 rpc 、 从其他 api 获取 , GraphQL 并不关注,客户端也无需关注,只有编写对应字段 resolver 的服务端开发,才需要关注。
N + 1 问题
N + 1 问题指,在查询所有文章数据的时候,需要先获取所有文章 ID ,即 1 次查询,再根据每个 ID 获取所有文章,即 N 次查询。
N + 1 问题在 RESTful 中即为大量的 http 请求并发,在 GraphQL 中则是对应大量 resolver 的调用,不论 resolver 是操作数据库、 rpc 、 文件 I/O 、调用其他 api ,只要频繁触发都会有性能问题。
解决问题读问题的关键在于,如何缓存查询值(写),如何收集写操作(写):
- 请求错误时自定义处理机制:
- 移除删除缓存(可能导致缓存穿透)
- 缓存错误值(可能导致临时错误被持久化)
- 手动计数缓存
- 缓存值与参数关联
- 在下次查询时能索引到前面缓存的值
- 缓存淘汰机制
- 批量处理
GraphQL 官方提供了 dataLoader 实现上述功能,并且该 loader 不止可以应用于 GraphQL ,包括 RESTful API 也可以复用,本身是一层抽象出来的异步操作缓存。
其他
- GraphQL 由于 Query String 并不能声明返回值类型,所以不一定就需要返回 JSON ,也可以返回 text 、 html 等。
- GraphQL 本质就是先根据 DSL 查询数据( Resolver )再生成代码的过程,由于 DSL 抽象了整个查询过程,所以 Resolver 甚至不需要是后端程序,可以是前端的全局 state 、 IndexedDB 、 LocalStorage 等等存储器