你的Node.js服务又崩了,日志里堆满"JavaScript heap out of memory"。你加了--max-old-space-size,重启,祈祷。三天后,同样位置,同样报错。

这不是运气问题。是你从未被教过内存到底怎么工作。

物理内存到V8堆:一段被跳过的旅程

内存条上的DRAM芯片是起点。操作系统把物理地址翻译成虚拟地址,每个进程以为自己独占整片空间。Node.js进程启动时,向操作系统申请一块地址池,这叫虚拟内存(Virtual Memory)。

虚拟内存分三块:代码段(你的JS和依赖)、栈(函数调用的临时数据)、堆(动态分配的对象)。堆是V8的地盘,但V8的堆只是进程堆的一部分。你的Buffer、TypedArray、甚至fs.readFile的缓存,都可能落在堆外。

V8堆内部更细:新生代(New Space)用Scavenge算法快速清理短命对象,老生代(Old Space)用Mark-Sweep-Compact对付长寿对象。Orinoco垃圾回收器(2018年后默认)允许并发标记,但压缩阶段仍需暂停——这就是你看到的卡顿。

新生代默认32MB(64位系统),老生代从0开始增长,直到触及--max-old-space-size或系统极限。很多人以为设成4096就安全了,但物理内存只有8GB的机器上,Node进程可能被OOM Killer直接干掉,连遗书都不留。

libuv与线程池:单线程的"外包团队"

libuv与线程池:单线程的"外包团队"

Node.js确实只有一个执行JS的线程,叫主线程(Main Thread)。但libuv——那个绑定V8和操作系统的中介——偷偷养了线程池。

默认4个线程,藏在你看不见的地方。fs.readFile、dns.lookup、crypto.pbkdf2,这些"异步"操作其实是把脏活丢给线程池,主线程继续跑事件循环(Event Loop)。线程干完活,把结果塞回任务队列,等主线程轮询到再执行回调。

线程池大小可调:UV_THREADPOOL_SIZE=128 node app.js。但别急着改。线程切换有成本——上下文切换(Context Switch)要保存寄存器、刷新缓存、让出CPU。线程太多,OS调度器疲于奔命,你的"优化"变成负优化。

CPU调度器用CFS(完全公平调度器)决定谁跑。每个线程有vruntime,跑越久优先级越低。Node主线程如果一直忙,vruntime暴涨,可能被抢占了——你的回调延迟就是这么来的。

事件循环的六个阶段:回调到底何时执行

事件循环的六个阶段:回调到底何时执行

timers阶段检查setTimeout/setInterval,I/O callbacks处理系统错误,idle/prepare内部用,poll阶段等I/O事件,check阶段跑setImmediate,close callbacks收尾。一轮结束,下一轮开始。

poll阶段最微妙。如果队列空了,有setImmediate就跳到check,没有就等I/O。你的数据库查询回调卡在这,因为前一个同步计算占满了主线程。事件循环不是魔法,是排队系统——队首的任务堵死后面所有人。

process.nextTick在阶段间隙插队,Promise微任务(Microtask)在宏任务后清空。滥用nextTick会饿死I/O,微任务递归会栈溢出。这些不是"最佳实践",是机制决定的必然。

Cluster与Worker Threads:两种并行路线

Cluster与Worker Threads:两种并行路线

Cluster模块 fork 多个进程,各带独立V8实例。端口共享靠操作系统,内存不共享,崩溃隔离。但进程间通信(IPC)走序列化/反序列化,大对象传递是灾难。

Worker Threads(Node 10.5+)共享ArrayBuffer,不共享JS对象。每个Worker有独立事件循环和V8隔离堆,但能在同一个进程地址空间操作内存。适合CPU密集型:图像处理、复杂计算。IO密集型别用,libuv线程池已经够用了。

SharedArrayBuffer + Atomics 能实现真正的锁机制,但内存模型复杂到让人头秃。99%的场景,队列+Worker池更简单。

诊断工具:从猜想到证据

诊断工具:从猜想到证据

--prof 生成V8日志,--prof-process 解析火焰图。但采样开销不小,生产环境慎用。

clinic.js 封装了doctor(事件循环诊断)、bubbleprof(异步流可视化)、heap-profiler(内存快照)。heap-profiler抓到的快照用Chrome DevTools打开,能看到对象引用链——你的内存泄漏通常藏在闭包或事件监听器里。

Linux上perf + 0x 能看内核态时间。如果sys时间高,可能是线程竞争或系统调用太频繁。strace -c 统计syscall次数,有时候瓶颈在 unexpected 的地方。

你的服务现在跑在什么阶段?poll空转还是mark-sweep卡顿?下次崩溃前,先确认是堆内存、外部内存,还是RSS被系统限制。数据不会撒谎,但你的假设会。