01. Node.js 运行时

36 阅读5分钟

01. Node.js 运行时

先别急着背框架。后端第一步,是搞懂 Node.js 为什么能持续处理请求,以及什么代码会把服务拖垮。

Node.js 的核心不是“会写异步”,而是理解这三个东西怎么配合:

  • V8:执行 JavaScript
  • libuv:负责事件循环、线程池、I/O 调度
  • Node 标准库:提供 httpfsstreamnet 等能力

核心认知

  • Node.js 不是“把浏览器里的 JavaScript 搬到后端”。
  • JavaScript 执行通常是单线程的,但 I/O 能并发推进,这也是 Node.js 适合做网络服务的原因。
  • 服务端进程会长期运行,所以稳定性、资源释放和错误处理比页面渲染更重要。

一条请求在 Node.js 里经历了什么

  1. 客户端建立 TCP 连接,发来 HTTP 请求。
  2. Node.js 的网络层收到请求,把它包装成 req / res 对象。
  3. 事件循环调度对应的回调或中间件。
  4. 你的代码可能去查数据库、读文件、访问 Redis。
  5. I/O 完成后,回调被重新放回事件循环继续执行。
  6. 最终写回响应,连接保持或关闭。

要点只有一句:Node.js 可以同时管理很多 I/O,但不能容忍你长时间霸占主线程。

必懂 4 件事

1. Event Loop
  • Node.js 不是一次只处理一个请求,而是依靠事件循环调度大量异步任务。
  • 只要你写了长时间的同步阻塞代码,整个进程都会被卡住。
  • 所以要警惕同步文件操作、超大 JSON 解析、死循环、重 CPU 计算。

最少要知道这些阶段的名字:

  • timers:执行 setTimeout / setInterval
  • pending callbacks
  • poll:等待和处理大部分 I/O 回调
  • check:执行 setImmediate
  • close callbacks

还要额外记住两个“优先队列”:

  • process.nextTick
  • Promise microtask

process.nextTick 和 Promise microtask 都会在阶段切换前优先清空,所以滥用也会饿死 I/O。

console.log('A');

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

console.log('B');

典型输出通常是:

A
B
nextTick
promise
timeout / immediate

你不需要死记每次谁先谁后,但必须知道:nextTick 和 Promise 回调优先级高于下一轮普通 I/O。

2. 什么叫阻塞主线程

下面这类代码在浏览器里也许只是卡一下页面,在服务端会直接拖慢所有用户请求:

import express from 'express';

const app = express();

app.get('/block', (_req, res) => {
  let total = 0;

  for (let i = 0; i < 1_000_000_000; i++) {
    total += i;
  }

  res.json({ total });
});

这段代码的坏处不是“写法丑”,而是它在循环期间完全占住了主线程。其他请求即使只是查一个轻量接口,也得排队。

遇到重 CPU 任务时,常见做法有三种:

  • 改算法,减少同步计算量
  • 拆成离线任务或消息队列
  • worker_threads 或独立服务处理计算任务
3. Stream 和 Buffer
  • Buffer 是二进制数据的容器。
  • Stream 是分块处理数据的方式,不必一次性把所有内容读入内存。
  • 文件上传、下载、反向代理、SSE、大模型流式输出都离不开它。

为什么服务端必须重视 Stream:

  • 大文件不能一次性读进内存
  • 上游和下游速度不一致时,需要背压控制
  • 文件、网络、压缩、代理都天然是流式场景

下面是一个标准的下载接口写法:

import express from 'express';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

const app = express();

app.get('/download', async (_req, res, next) => {
  try {
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', 'attachment; filename="report.csv"');

    const fileStream = createReadStream('./files/report.csv');
    await pipeline(fileStream, res);
  } catch (error) {
    next(error);
  }
});

错误写法通常是这样:

const content = await fs.promises.readFile('./files/report.csv');
res.send(content);

文件小时没问题,文件一大、并发一高,内存就会顶上去。

4. 进程与内存
  • 页面卡了可以刷新,服务卡了会影响所有请求。
  • 需要关注内存泄漏、未关闭连接、无限增长的缓存、未处理异常。
  • 最基本的观察项包括:rss、heap、错误日志、请求耗时。

一个最常见的泄漏例子:

import express from 'express';

const app = express();
const leaked: unknown[] = [];

app.get('/leak', (req, res) => {
  leaked.push({
    query: req.query,
    now: Date.now(),
  });

  res.json({ size: leaked.length });
});

只要这个数组不清,进程就会一直涨。真实项目里更隐蔽的版本包括:

  • 全局 Map 缓存从不淘汰
  • 长连接对象没有正确关闭
  • 定时器创建后不清理
  • 每个请求都把大对象挂在全局变量上

可以用最小代码观察内存:

setInterval(() => {
  const memory = process.memoryUsage();

  console.log({
    rssMB: Math.round(memory.rss / 1024 / 1024),
    heapUsedMB: Math.round(memory.heapUsed / 1024 / 1024),
    externalMB: Math.round(memory.external / 1024 / 1024),
  });
}, 5000);

最小实践

  • 写一个接口,用流返回大文件,而不是一次性读进内存。
  • 故意写一个同步阻塞接口,观察并发请求响应时间变差。
  • 打印 process.memoryUsage(),理解进程内存的变化。

常见误区

  • 误以为“异步 = 多线程”。不是,JavaScript 代码执行仍主要在主线程上。
  • 误以为 Promise.all 越多越快。一次把几千个任务并发打出去,可能先把数据库压垮。
  • 误以为 Node.js 不适合所有重任务。准确说法是:它不适合把重 CPU 任务长期放在主线程。

学会的标准

  • 你能解释为什么 fs.readFileSync 在服务端要慎用。
  • 你知道文件上传为什么不该默认整文件进内存。
  • 你知道 Node.js 的问题不只有“代码慢”,也可能是阻塞、资源泄漏和并发放大。