01. Node.js 运行时
先别急着背框架。后端第一步,是搞懂 Node.js 为什么能持续处理请求,以及什么代码会把服务拖垮。
Node.js 的核心不是"会写异步",而是理解这三个东西怎么配合:
V8:执行 JavaScriptlibuv:负责事件循环、线程池、I/O 调度- Node 标准库:提供
http、fs、stream、net等能力
核心认知
- Node.js 不是"把浏览器里的 JavaScript 搬到后端"。
- JavaScript 执行通常是单线程的,但 I/O 能并发推进,这也是 Node.js 适合做网络服务的原因。
- 服务端进程会长期运行,所以稳定性、资源释放和错误处理比页面渲染更重要。
一条请求在 Node.js 里经历了什么
- 客户端建立 TCP 连接,发来 HTTP 请求。
- Node.js 的网络层收到请求,把它包装成
req/res对象。 - 事件循环调度对应的回调或中间件。
- 你的代码可能去查数据库、读文件、访问 Redis。
- I/O 完成后,回调被重新放回事件循环继续执行。
- 最终写回响应,连接保持或关闭。
要点只有一句:Node.js 可以同时管理很多 I/O,但不能容忍你长时间霸占主线程。
必懂 4 件事
1. Event Loop
- Node.js 不是一次只处理一个请求,而是依靠事件循环调度大量异步任务。
- 只要你写了长时间的同步阻塞代码,整个进程都会被卡住。
- 所以要警惕同步文件操作、超大 JSON 解析、死循环、重 CPU 计算。
最少要知道这些阶段的名字:
timers:执行setTimeout/setIntervalpending callbackspoll:等待和处理大部分 I/O 回调check:执行setImmediateclose callbacks
还要额外记住两个"优先队列":
process.nextTick- Promise microtask
process.nextTick 和 Promise microtask 都会在阶段切换前优先清空,所以滥用也会饿死 I/O。
ts
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');
典型输出通常是:
txt
A
B
nextTick
promise
timeout / immediate
你不需要死记每次谁先谁后,但必须知道:nextTick 和 Promise 回调优先级高于下一轮普通 I/O。
2. 什么叫阻塞主线程
下面这类代码在浏览器里也许只是卡一下页面,在服务端会直接拖慢所有用户请求:
ts
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:
- 大文件不能一次性读进内存
- 上游和下游速度不一致时,需要背压控制
- 文件、网络、压缩、代理都天然是流式场景
下面是一个标准的下载接口写法:
ts
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);
}
});
错误写法通常是这样:
ts
const content = await fs.promises.readFile('./files/report.csv');
res.send(content);
文件小时没问题,文件一大、并发一高,内存就会顶上去。
4. 进程与内存
- 页面卡了可以刷新,服务卡了会影响所有请求。
- 需要关注内存泄漏、未关闭连接、无限增长的缓存、未处理异常。
- 最基本的观察项包括:
rss、heap、错误日志、请求耗时。
一个最常见的泄漏例子:
ts
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 缓存从不淘汰
- 长连接对象没有正确关闭
- 定时器创建后不清理
- 每个请求都把大对象挂在全局变量上
可以用最小代码观察内存:
ts
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 的问题不只有"代码慢",也可能是阻塞、资源泄漏和并发放大。