1. 首页
  2. /
  3. 60天Node.js
  4. /
  5. Day 57
🏆 阶段 6 · 测试 / 优化 / 面试

Day 57

性能优化:先定位,再改动

Day 55 把 SaaS 平台部署起来。今天沿着同一个项目继续:挑一条真实请求路径,找到耗时或资源占用的证据,提出一个改动,再用相同条件复测。

性能优化不是先加缓存、索引或压缩。没有基线时,很难知道改动有没有帮助,也容易把成本转移到数据库、内存或复杂度上。

今日目标

  • 区分延迟、吞吐量、错误率、CPU、堆内存和事件循环延迟
  • 用 Node.js CPU Profile 定位同步 CPU 热点
  • 用堆快照检查对象是否持续被引用
  • 用 PostgreSQL 执行计划判断查询是否需要改
  • 写一份包含环境、方法、结果和限制的性能记录

1. 先定义要测的请求

把请求路径拆成几段,先确认慢点在哪一层:

浏览器 → Next.js/tRPC → Node.js 业务逻辑 → PostgreSQL / Redis / 队列

先固定四件事:要测的路由、请求参数、数据规模和运行环境。比较改动前后时,尽量使用同一份本地数据、同一台机器和同一条请求。

指标它回答的问题
p50 / p95 延迟一半 / 95% 的请求在多久内完成
吞吐量一段时间内完成多少请求
错误率请求是否以失败结束
CPU 使用率 / Profile处理器时间花在哪些函数上
堆内存JavaScript 对象是否持续留存
事件循环延迟同步工作是否让其他回调排队

平均延迟可能掩盖尾部请求;吞吐量也不能单独说明用户体验。至少同时记录延迟分布和错误率。不要把一台开发机的一次结果写成生产容量。

2. Node.js:先看 CPU Profile

Node.js 的 JavaScript 回调通常在事件循环线程上运行。数据库等待可以让出执行机会,但一个很长的同步计算会挡住同一进程里的其他回调。

仓库包含一个不依赖第三方包的微基准示例:

node days/day-57/examples/lookup-benchmark.mjs

它用相同数据比较数组查找和可复用的 Set 查找,并先确认两种结果一致。脚本只测一个合成的内存操作,不代表 SaaS API、数据库或生产流量的性能;运行时间会受 Node.js 版本、CPU、系统负载和 JIT 预热影响。

计时把 Set 的创建放在循环外,因此示例假设这批 key 会重复使用。如果实际请求每次都要新建集合,也要把构建集合的成本纳入测量。

需要查看 CPU 采样时,在当天目录运行:

cd days/day-57
node --cpu-prof examples/lookup-benchmark.mjs

Node 会生成 .cpuprofile 文件。用 Chrome DevTools 的 Performance 面板载入它,查看调用树和 Self Time(函数自身消耗的采样时间)。优先检查在目标工作负载中占比明显的函数,再追到它做了多少重复工作。Profile 能指出采样热点,不会自动解释业务原因。

内存问题要比较多次快照

在本地调试环境中,通过 Node Inspector 连接应用,在 DevTools 的 Memory 面板对同一条操作流程做快照:

直接启动 Node 入口时,可以用 node --inspect=127.0.0.1:9229 path/to/entry.js;之后在 Chrome 打开 chrome://inspect 连接对应进程。把入口替换为实际要检查的服务。

  1. 空闲时记录基线;
  2. 重复执行同一批操作;
  3. 等待请求结束,再记录快照;
  4. 重复几轮,观察可疑对象的数量和 Retained Size 是否持续增加;
  5. 检查对象为何仍可从根引用到。

一次快照里的大对象不等于泄漏。要找的是本应释放、却被长期引用的对象。快照会暂停或扰动进程,应该在本地或专门的测试环境操作。

3. PostgreSQL:读执行计划,不猜索引

Day 46–55 的 SaaS 项目中,任务看板按项目、状态和排序字段取数据;Prisma schema 已为这个访问形状定义了联合索引 (project_id, status, order)。

可以在本地数据库里对类似查询查看计划:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, status, "order"
FROM tasks
WHERE project_id = '替换为项目 UUID'::uuid
  AND status = 'BACKLOG'
  AND deleted_at IS NULL
ORDER BY "order"
LIMIT 50;

ANALYZE 会实际执行查询,所以只对本地或可安全读取的数据运行。不同数据量和统计信息会产生不同计划;看到 Seq Scan 不自动表示有问题,小表顺序扫描可能更便宜。

检查计划时先看:

  • 实际行数与估算行数是否相差很大;
  • 哪个节点消耗时间和读取最多的 buffer;
  • 是否有不必要的排序、重复扫描或大量回表;
  • LIMIT 是否足够早地减少结果。

索引能加快某些读取,也会增加写入和存储成本。先拿实际查询和计划说清楚问题,再提出索引候选;改完后用同一数据和查询复测。项目里的连接池也要按「实例数 × 每实例连接上限」核对总连接数,不要只看单个进程的配置。

4. 响应优化:只改瓶颈所在的一段

  • 响应体过大:检查字段选择和分页;只有压缩收益超过 CPU 成本时才启用压缩。
  • 重复读取变化很少的数据:考虑缓存,并写清失效条件和允许的陈旧时间。
  • 静态资源被重复下载:评估缓存头、ETag 或 CDN;动态的、带权限的响应不能照搬静态缓存策略。
  • 查询扫太多数据:先看筛选、排序、索引和分页形状,再决定是否改 SQL 或 schema。
  • Node CPU 忙:根据 Profile 消除重复计算;只有计算确实适合隔离时,再考虑 Worker Threads。

每次只改一个主要因素,否则测到变化也很难归因。缓存和索引会引入一致性、失效和写入成本,不能因为“常见”就默认需要。

实践练习:完成一份可复查的优化记录

选一个你能在本地稳定重复的路径,例如 SaaS 任务看板或 health.ping。先确认服务已启动,并记下代码提交、Node.js 版本、数据库数据规模和测试方式。

完成以下步骤:

  1. 用同一组参数记录基线:延迟分布、吞吐量、错误率,以及能取得的 CPU 或查询计划数据。
  2. 从 Profile 或 EXPLAIN (ANALYZE, BUFFERS) 中挑出一个具体问题,写出改动假设。
  3. 只做一个改动;保持请求、数据和运行环境不变,再测一次。
  4. 记录结果、限制和是否值得保留该改动。若结果不稳定,如实写“无法判断”,不要挑选最好的一次。

报告可以按这个模板:

目标路径:
环境 / 提交 / 数据规模:
测试方法与重复次数:
基线:
观察到的瓶颈:
假设与单项改动:
复测结果:
限制(本机、数据量、并发、缓存状态等):
结论:

验收清单

  • 能说明本次测量的是哪个请求和哪段代码
  • 能解释 p95、吞吐量与错误率各自的作用
  • 能生成并打开 Node CPU Profile,指出一个采样热点
  • 能从执行计划读出实际行数、扫描节点和 buffer 信息
  • 优化记录包含可复现条件和限制,没有把本地数字说成生产数据

延伸思考

  1. 如果平均延迟下降但 p95 上升,可能有哪些请求分布变化?
  2. 一个索引让查询更快,却让任务移动写入变慢,怎样决定是否保留?
  3. 缓存命中率上升,但用户偶尔看到旧任务状态,应该补哪条一致性约束?

参考资料

⬅️ Day 56 | ➡️ Day 58

本文内容来自 源仓库 day-57/README.md