本页目录

性能与内存:从现象找到保留链与热点

区分 CPU、排队和外部等待,理解可达性与内存指标,通过有界缓存、事件循环观测和算法实验建立诊断证据。

L2 · 能交付约 15 分钟阅读含示例、练习与验收

建议先读:进程与工作线程:把计算放到合适的边界流与背压:让快生产者等待慢消费者

本页内容

目标与前置#

团队助手运行一段时间后变慢,可能是文档越来越大、缓存无界增长、监听器没有释放,也可能只是外部服务延迟增加。目标不是先调几个运行参数,而是建立一个能排除假设的诊断流程。前置是事件循环、流背压和工作线程,你已经知道任务在哪里执行,现在要测量它为什么慢、对象为什么还留在内存里。

前端性能调试常围绕渲染帧和交互,Node 服务还要关注吞吐、请求分位延迟、排队长度以及长期资源趋势。平均响应时间很好,不代表最慢的一批用户没有等待很久;CPU 很低,也不代表服务没有瓶颈,可能所有请求都在等待连接池或外部系统。指标的含义必须与业务阶段对应。

先把总耗时拆开#

一个任务的总耗时可以来自等待排队、执行 JavaScript、等待 I/O 和调度延迟。CPU 分析主要告诉你采样时线程在哪里消耗计算,不会直接把远端等待解释成哪个函数慢。若某个函数总耗时很长但 CPU 占用很低,先检查它是否在等待外部操作,而不是立刻重写循环。

事件循环延迟表示调度机会被推迟的程度,可能来自长同步工作或其他运行时活动。它与请求延迟相关但不相同:一个请求可能因远端慢而延迟很高,事件循环却十分空闲。需要同时观察请求阶段和运行时指标,才能避免把所有慢请求都误判为主线程阻塞。

测量会影响被测系统。大量控制台输出、过密采样和调试器暂停都可能改变时序。先用小规模可重放实验验证假设,再在受控环境用接近真实的输入测量。报告要包含输入规模、运行版本、并发条件和测量方式,不能只给一个脱离上下文的毫秒数字。

垃圾回收判断可达性,不判断业务价值#

一个对象只要还能从运行时的根引用追踪到,就可能继续保留。模块级 Map 持有缓存项,事件监听器闭包持有请求对象,定时器回调持有文档 Buffer,这些都是常见保留链。变量离开当前函数作用域,不代表对象已经不可达;闭包可以把相关引用带到更长的生命周期中。

闭包本身不是泄漏。它只是保留所需环境的机制,问题在于长寿命对象是否继续持有本应结束的短寿命数据。一个任务完成后移除对应监听,或者从缓存删除条目,才能切断相关引用。垃圾回收不会替你知道这份文档已经过期,也不会自动理解缓存只应该保留最近十项。

释放引用后,内存指标也不一定立刻下降。垃圾回收有自己的时机,运行时可能保留已申请的堆空间供后续复用,系统分配器也可能暂不把内存返还给操作系统。因此验收清理逻辑应优先检查引用与资源数量,再结合长期内存趋势,而不是要求一次 clear 后 RSS 立即回到原值。

process.memoryUsage 的几个量#

process.memoryUsage() 返回以字节为单位的内存统计对象。heapUsed 主要表示已使用的 JavaScript 堆,heapTotal 表示已分配给堆的空间,rss 表示进程驻留内存,external 涉及与 JavaScript 对象关联的外部内存,arrayBuffers 统计相关 ArrayBuffer 内存且通常包含在 external 中。不能把这些字段简单相加得到总量。

Buffer 的主要数据存储可能体现在堆外相关指标中,所以只看 heapUsed 可能漏掉大量字节占用。Worker 还使部分统计范围需要更仔细理解:同一进程的驻留内存与某线程的堆信息不是完全相同的观察对象。比较指标时应确认来自哪个执行单元以及采样时刻。

该 API 适合周期观察,但频率不应高到成为新的负担。只需要驻留内存时还有更直接的 rss 查询方式。指标精度也不意味着每个字节都能归因到你的某个数组;真正找保留原因,需要堆快照、分配信息与代码生命周期一起分析。

实验一:有界缓存的正确性与内存观察#

环境 Node 22.22.0,无依赖。保存为 memory-cache.mjs。缓存按最近使用顺序保留四项,数据全部在本例内创建,总分配规模很小。

memory-cache.mjs
import assert from 'node:assert/strict';
import process from 'node:process';

function createCache(limit) {
  const values = new Map();
  return {
    set(key, value) {
      values.delete(key);
      values.set(key, value);
      while (values.size > limit) values.delete(values.keys().next().value);
    },
    get(key) {
      if (!values.has(key)) return undefined;
      const value = values.get(key);
      values.delete(key);
      values.set(key, value);
      return value;
    },
    size() { return values.size; },
    clear() { values.clear(); }
  };
}
const cache = createCache(4);
for (let id = 0; id < 20; id += 1) cache.set(id, Buffer.alloc(64 * 1024, id));
assert.equal(cache.size(), 4);
assert.equal(cache.get(0), undefined);
assert.equal(cache.get(19)[0], 19);
const memory = process.memoryUsage();
console.log(`条目=${cache.size()}, heapUsed=${memory.heapUsed}, external=${memory.external}`);
cache.clear();
assert.equal(cache.size(), 0);
console.log('缓存引用已释放,不断言操作系统内存立即下降');

执行 node memory-cache.mjs,预期条目数四,内存数值随机器变化,最后输出释放信息。Map 的插入顺序用来表示最近使用,get 时删除再插入把条目移动到末尾,超过上限时删除最早条目。这里缓存值等大,因此条目上限能间接限制主要负载;真实文档大小不同,还需要总字节预算。

这个实验没有证明所有缓存都适合最近使用策略。热点分布、数据更新频率和计算成本可能需要不同方案;缓存键还必须包含租户、版本和权限相关维度,避免把别人的结果返回给当前用户。性能优化不能破坏数据边界,后续缓存章节会把这些条件放进业务接口。

CPU 时间、墙钟时间与事件循环利用率#

performance.now() 适合测量时间区间,得到的是经过的墙钟时间。process.cpuUsage(previous) 返回用户态与系统态 CPU 使用差值,单位是微秒。等待网络可能让墙钟增加很多而 CPU 增加很少;多线程工作还可能使进程累计 CPU 与单个区间长度产生不同关系,不能混用单位后直接比较。

monitorEventLoopDelay({ resolution }) 创建事件循环延迟直方图,enable 后开始采样,disable 后停止;延迟统计以纳秒表示,展示毫秒时要正确换算。resolution 控制采样间隔,不能把它理解为请求超时阈值。performance.eventLoopUtilization 提供事件循环活跃与空闲比例相关信息,适合观察时间窗口,不等于整台机器 CPU 百分比。

实验二:观察一次同步计算造成的调度延迟#

保存为 performance-loop.mjs。程序先给采样器时间建立观察,再执行一小段受控忙计算,最后输出指标;不要求某个固定性能数字。

performance-loop.mjs
import { performance, monitorEventLoopDelay } from 'node:perf_hooks';
import { setTimeout as delay } from 'node:timers/promises';
import process from 'node:process';
import assert from 'node:assert/strict';

const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
await delay(25);
const cpuStart = process.cpuUsage();
const utilizationStart = performance.eventLoopUtilization();
const started = performance.now();
const until = started + 50;
while (performance.now() < until) { /* 模拟主线程热点 */ }
await delay(30);
histogram.disable();
const cpu = process.cpuUsage(cpuStart);
const utilization = performance.eventLoopUtilization(utilizationStart);
assert.ok(Number.isFinite(histogram.max) && histogram.max > 0);
console.log(JSON.stringify({
  elapsedMs: Math.round(performance.now() - started),
  cpuMs: Math.round((cpu.user + cpu.system) / 1000),
  maxDelayMs: Math.round(histogram.max / 1000000),
  utilization: Number(utilization.utilization.toFixed(2))
}));

执行 node performance-loop.mjs,通常能看到明显大于采样间隔的最大延迟,具体数值受系统负载影响。断言只检查观测有效,不把某台机器的一次延迟变成通用性能标准。想比较变化,可以在练习副本中缩短忙计算,再重复运行多次,观察趋势,而不是要求每次单调变快。

这里 CPU 时间与利用率回答不同问题。CPU 时间告诉你进程消耗了多少计算时间,利用率说明该窗口内事件循环的活跃比例,直方图显示调度延迟分布。三者一起支持“主线程忙工作影响调度”的解释,但仍不能代替真实请求负载下的测量。

从热点定位到算法改进#

CPU profile 通过采样帮助寻找时间主要落在哪些调用栈上。Node 的 --cpu-prof 可以在进程结束时写出 CPU 分析文件,配合 --cpu-prof-dir 和文件名选项把输出放到明确目录。分析文件可能包含源码路径和函数信息,应该作为诊断资产管理,而不是随意上传。实际采样结果需要结合输入理解,热点不一定意味着函数设计错误。

先考虑算法与重复工作,再考虑线程或更大堆限制。团队助手若对每篇文档都重新扫描全部任务计算统计,数据增加后复杂度可能迅速上升。改成一次遍历建立索引,通常比把原算法搬到 Worker 更直接。Worker 改善执行位置,算法改进减少必须执行的工作,两者可以配合。

基准要验证输出一致。一个“快版本”若遗漏大小写归一化或租户范围,可能只是少做了必要工作。先用明确输入验证语义,再比较多个规模的时间趋势。不要仅根据一个小数组的一次耗时决定生产选型,计时器精度、优化预热和垃圾回收都会影响短实验。

练习:减少重复扫描并生成可分析的负载#

实现两种标题计数:慢版本对每个不同标题扫描完整输入,快版本单次遍历累计。验证结果一致,然后打印两个耗时。不要断言某次快版本一定更快,正确性断言与性能观察要分开。完整文件也可作为 CPU profile 的受控输入。

参考答案:完整计数与诊断入口
profile-counts.mjs
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';

const titles = Array.from({ length: 20000 }, (_, index) => `标题-${index % 200}`);
function repeatedScan(values) {
  return new Map([...new Set(values)].map((title) => [title, values.filter((value) => value === title).length]));
}
function singleScan(values) {
  const counts = new Map();
  for (const title of values) counts.set(title, (counts.get(title) ?? 0) + 1);
  return counts;
}
let started = performance.now();
const slow = repeatedScan(titles);
const slowMs = performance.now() - started;
started = performance.now();
const fast = singleScan(titles);
const fastMs = performance.now() - started;
assert.deepEqual(fast, slow);
assert.equal(fast.get('标题-0'), 100);
console.log(JSON.stringify({ items: titles.length, groups: fast.size, slowMs, fastMs }));

执行 node profile-counts.mjs,应得到两万个输入、两百个分组,结果断言通过。若要生成分析文件,在自己的新练习目录执行 node --cpu-prof --cpu-prof-name=counts.cpuprofile profile-counts.mjs,会新增一个 CPU profile 文件;在支持该格式的开发者工具中打开,观察重复扫描与单次扫描的调用栈。诊断文件不是应用运行依赖。

缓存增长和内存泄漏如何区分#

缓存本来就会保留数据,因此初期内存上涨不一定是缺陷。要看它是否有明确上限、淘汰条件和可接受的稳定区间。如果输入种类持续增加,缓存条目也持续增加,没有任何回收策略,那么这可能是设计上的无界状态,即使每个条目都仍“可达”,也会最终耗尽资源。垃圾回收不会修正容量设计。

真正的泄漏往往表现为业务已经结束,但引用链没有结束。例如请求对应的监听器还挂在全局事件源上,计时器仍引用旧的文档对象,或者一个诊断数组不断保存历史错误及其上下文。可以重复同样的一组创建与释放操作,观察每轮结束后对象数量是否回到基线。稳定重复实验比对着一次内存峰值猜测更有说服力。

缓存淘汰后还有其他引用也很常见。调用方取得缓存值后把它存进另外一个集合,缓存本身清空并不代表数据已不可达。排查时要沿保留链看实际拥有者,而不是只检查最初创建对象的模块。这个过程与前端组件卸载后仍被事件回调引用的情况相通,只是长期服务器更容易把小问题累积成大故障。

从服务症状逐步缩小假设#

先确认慢的是所有请求还是某一类请求,是平均值上升还是少量尾部请求异常。如果只有大文档路径慢,输入规模和算法值得优先检查;如果所有请求同时抖动,主线程阻塞、垃圾回收或公共连接池更值得关注;如果只有某个外部服务调用慢,应检查外部等待和重试。分类能避免在不相关模块里做大量改动。

接着比较变更前后的相同负载。保持输入、并发和环境尽量一致,只改变一个关键因素,例如缓存容量或批次大小。若一次同时换框架、升级依赖、增加线程和修改查询,结果即使变好,也难以知道原因,后续回归更难定位。性能工作同样需要清晰因果,而不只是追求一个漂亮数字。

最后检查优化是否转移成本。减少主线程时间可能增加 Worker 的复制和内存,降低数据库查询次数可能增加缓存陈旧风险,提高批次大小可能改善吞吐但恶化交互延迟。把代价写出来,可以让选择与业务目标对齐。团队助手优先保证用户操作及时反馈,批量后台导入则可能更重视吞吐,两者可以采用不同策略。

堆限制不是修复泄漏的默认方法#

提高可用堆上限可能暂时延后内存不足,但无界引用仍会继续增长,垃圾回收暂停也可能随堆规模改变。若已经证明业务需要更大工作集,可以调整容量并测量;若只是为了让错误暂时消失,问题通常会在更晚、更难恢复的时候出现。先理解工作集与保留链,再决定容量。

反过来,把堆限制调得过小也会制造频繁回收,让原本合理的负载变慢。容量规划需要留给运行时、Buffer、连接和原生依赖足够空间,不能把容器总内存全部分给 JavaScript 堆。RSS、堆和外部内存之间的区别,在这里直接影响部署参数,而不是纯粹的名词记忆。

让诊断能力成为日常接口#

为重要任务记录输入规模、排队时长、执行结果和耗时,比出问题后临时添加大量日志更容易比较趋势。指标标签不要直接使用每个文档的唯一编号,否则维度数量可能无界增长;任务编号适合日志关联,聚合指标更适合使用有限类别。可观测性本身也需要容量设计。

正常关闭时检查活动任务数和资源数量,有助于暴露隐藏泄漏。错误路径、取消路径和正常路径都应该进入相同的释放机制。把这些检查与功能验收一起执行,可以让性能问题更早表现为具体生命周期缺口,而不是等到线上运行几天后才看到一个模糊的内存曲线。

内存快照与生产观测边界#

堆快照适合追踪对象被谁保留,重点查看从长寿命对象到目标对象的引用链,以及某个对象连带保留了多少内存。对象本身很小,却可能通过字段保留一整份文档。只按对象自身大小排序,容易忽略真正的根因。监听器、缓存与闭包都应结合创建和释放位置分析。

采集快照可能暂停执行并显著增加额外内存,不应在已经逼近容量极限的线上进程中无准备尝试。更好的步骤是在可控副本中复现,确认输入和生命周期,再选择合适的诊断方式。本章没有实际生成生产堆快照,也不把本地小实验当成线上容量结论。

验收要求缓存条目有界且清理归零,事件循环实验输出有效指标,两个算法结果完全一致,并能解释为何没有使用固定毫秒阈值判断优劣。自测一:闭包是否天然等于泄漏?答案:不是,关键是保留链是否超出业务寿命。自测二:clear 后 RSS 不下降是否一定清理失败?答案:不一定,回收和归还时机不同。自测三:CPU profile 能直接解释所有外部等待吗?答案:不能,需要请求阶段与队列指标补充。

本章实际验证范围#

三个程序实际通过。一次事件循环观测为约五十毫秒最大延迟;这不是性能承诺。另实际执行 CPU profile 命令,生成 13526 字节文件并检查其 JSON nodes,随后清理临时目录;未作图形化热点分析或堆快照采集。

官方参考#

内存与 CPU 统计见 Process,延迟与利用率见 Performance hooks,分析文件选项见 命令行选项,保留链诊断见 理解和诊断内存

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭