本文目录

[[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
  1. 初始化循环时间: 初始化 now
  2. 运行到期定时器: 运行到期计时器。所有活动计时器会判断调用时间是否在 now 之前,是的话会在此阶段调用定时器回调。
  3. 事件循环是否被激活?:如果事件循环是激活( alive )的(存在 active loop / ref'd handles / active requests / closing handles ),就开始进入循环,否则跳过循环结束本次任务。
  4. 调用 pending callbacks: 调用挂起的回调。 大部分 I/O 回调都是在 poll for I/O 阶段立即调用 ,但是某些情况下这些回调会被推迟到下一个事件循环周期。如果发生了这种推迟的情况,这些回调将在这里被调用。
  5. 运行 idle handles: 调用 idle handles 回调。
  6. 运行 perpare handles: perpare handles 在循环阻塞 I/O 之前立即调用他们的回调。通常是 NodeJS 内部调用,用于预分配资源、资源初始化等。
  7. Poll for I/O:
  8. 计算 I/O 阻塞时间: 使用算法计算超时时间。在阻塞 I/O 之前计算应该在多少时间后超时。如果当前没有其他任务需要执行,就永不超时,否则将最近需要执行的任务的调用时间作为超时时间。
  9. 循环阻塞 I/O: 按照超时时间阻塞 I/O ,此过程中如果有 I/O 回调被触发,会被立即执行
  10. 运行 check handles: 执行需要立即处理的回调,比如 setImmediate
  11. 调用 close callbacks: 比如 关闭 socket 、关闭 I/O 之类的关闭事件触发回调。
  12. 更新循环时间: 更新 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 的 AST
  • Validation: 验证阶段。分为内置校验与自定义校验,内置校验会校验变量是否定义、字段是否存在、片段是否存在等等。
  • 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 语法为例

  1. 对象值语义定义为别名
  2. 删除所有 ,"
  3. 将查询条件 (${arg1}: ${alias1}, ${arg2}: ${alias2}) 放到对应字段上
  4. 通过指令 @include@skip@deprecate 管理字段
  5. 使用 ...${fragment} 复用数据结构
  6. 在最外层补上需要进行的操作: 查询 ( 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 等等存储器