性能优化最危险的部分,是在没有证据时改动正确的代码。Node.js 服务变慢可能来自 CPU、内存、文件系统、数据库、网络、下游服务或事件循环阻塞。它们在监控面板上都可能表现为延迟升高,却需要完全不同的处理方式。
一套可靠流程从可复现开始:固定输入、并发、数据规模和运行环境;记录吞吐、P50/P95/P99、错误率、CPU 与内存;一次只改变一个变量。
事件循环延迟是第一条线索
Node.js 可以高效处理大量 I/O,是因为 JavaScript 回调在事件循环上协调工作。但一段长时间不让出控制权的同步计算,会让所有连接一起等待。
import { monitorEventLoopDelay } from 'node:perf_hooks'
const delay = monitorEventLoopDelay({ resolution: 20 })
delay.enable()
setInterval(() => {
console.log({ p99Ms: delay.percentile(99) / 1e6 })
delay.reset()
}, 10_000)
如果业务延迟与事件循环延迟同时上升,优先寻找同步加密、巨大 JSON、复杂正则、长循环或过多同步日志。把 I/O 改成异步并不能自动解决 CPU 计算;重计算应拆分、缓存或移入 Worker Thread。
用火焰图找热点
CPU profile 会按调用栈统计时间。火焰图中越宽的函数占用越多采样时间。不要只盯最顶层业务函数:序列化、日志格式化、校验库和重复对象转换都可能成为真正热点。
优化前先确认热点在稳定负载下反复出现。一次冷启动采样、开发模式结果或日志量不同的环境,很容易得出错误结论。
内存问题不等于内存泄漏
进程内存上升可能是缓存预热、请求峰值、Buffer、V8 堆扩容,也可能是对象被意外长期引用。观察垃圾回收后堆是否回到相近基线,比只看 RSS 更有意义。
连续获取两份 Heap Snapshot,比较增长对象的保留路径。常见原因包括:没有上限的 Map、未解绑监听器、永不结束的定时器、把请求对象放进全局数组,以及缓存只增不减。
缓存必须同时定义容量、过期和命中率。一个没有退出策略的缓存,本质上只是命名更好听的泄漏。
流与背压
处理大文件时,一次性读入内存会制造高峰。Stream 通过背压让生产速度服从消费速度:
import { pipeline } from 'node:stream/promises'
import { createReadStream, createWriteStream } from 'node:fs'
import { createGzip } from 'node:zlib'
await pipeline(
createReadStream('large.ndjson'),
createGzip(),
createWriteStream('large.ndjson.gz'),
)
pipeline 还会统一传播错误并清理流。手写 data 事件拼接 Buffer,常常绕过背压并放大内存占用。
数据库通常比 JavaScript 更慢
在 API 服务中,先检查慢查询、缺失索引、N+1、连接池等待和返回字段数量。应用层把循环从 4ms 优化到 2ms,如果查询仍花 300ms,对用户没有可感知价值。
为每个请求建立可追踪的阶段计时:排队、业务计算、数据库、外部 HTTP、序列化。只有分解总延迟,团队才能讨论同一件事。
建立性能预算
“尽量快”无法验收。更有效的约束是:目标负载下 P95 小于 200ms、事件循环 P99 延迟小于 50ms、稳定负载一小时后堆基线不持续增长、错误率低于某个阈值。
每次优化都要回到相同基准重测,并检查尾延迟与错误率。吞吐提升但 P99 恶化,可能只是系统更积极地积压请求。
排查顺序
- 让问题可以稳定复现。
- 判断瓶颈属于 CPU、内存、I/O 还是下游。
- 用 profile、trace 或快照定位具体路径。
- 做最小改动并保留前后数据。
- 在接近生产的负载与数据规模下复验。
- 把预算加入持续监控,避免回归。
性能工程不是一轮技巧清单,而是持续缩短“猜测”和“证据”之间的距离。