关于 performance-optimization
面向开发者和编码 Agent 的性能优化技能,强调依据性能分析而非猜测来修改。内容涵盖前后端问题诊断、重新测量和回归防护。
主要功能
- 01先测量再优化
- 02诊断前后端瓶颈
- 03按测得原因选择修复
- 04复测结果并回退回归
- 05添加 CI 与线上监控防护
适用场景
- 01调查页面加载或交互缓慢
- 02追查 API 响应或数据库查询延迟
- 03检查修改是否引发性能回归
- 04设置性能预算和监控
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ addyosmani/ agent-skills --skill performance-optimization安装后在 Claude Code 中输入/performance-optimization确认已加载。
附带文件references/optimization-patterns.md
详细介绍
性能优化
概述
先测量,再优化。没有测量的性能工作就是猜测,而猜测会导致过早优化,徒增复杂性,却无法改善真正重要的指标。先进行性能分析,找出实际瓶颈,修复后再次测量。只优化测量结果证明确实重要的部分。
适用场景
- 规格中有性能要求(加载时间预算、响应时间 SLA)
- 用户或监控报告性能缓慢
- Core Web Vitals 分数低于阈值
- 怀疑某项更改导致了性能回退
- 构建需要处理大型数据集或高流量的功能
不适用场景: 没有证据表明存在问题时,不要进行优化。过早优化会增加复杂性,其代价可能超过带来的性能收益。
Core Web Vitals 目标
| 指标 | 良好 | 需要改进 | 差 |
|---|---|---|---|
| LCP(最大内容绘制) | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP(下一次绘制交互) | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS(累积布局偏移) | ≤ 0.1 | ≤ 0.25 | > 0.25 |
优化工作流
1. MEASURE → Establish baseline with real data
2. IDENTIFY → Find the actual bottleneck (not assumed)
3. FIX → Address the specific bottleneck
4. VERIFY → Measure again; keep or revert
5. GUARD → Add monitoring or tests to prevent regression
步骤 1:测量
两种互补的方法——两者都要使用:
- 合成测试(Lighthouse、DevTools「Performance」选项卡): 条件可控、结果可复现。最适合在 CI 中检测回归和隔离特定问题。
- RUM(web-vitals 库、CrUX): 真实条件下的真实用户数据。必须用它来验证修复是否确实改善了用户体验。
前端:
// Synthetic: Lighthouse in Chrome DevTools (or CI)
// Chrome DevTools → Performance tab → Record
// Chrome DevTools MCP → Performance trace
// RUM: Web Vitals library in code
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
后端:
// Response time logging
// Application Performance Monitoring (APM)
// Database query logging with timing
// Simple timing
console.time('db-query');
const result = await db.query(...);
console.timeEnd('db-query');
从哪里开始测量
根据症状决定首先测量什么:
What is slow?
├── First page load
│ ├── Large bundle? --> Measure bundle size, check code splitting
│ ├── Slow server response? --> Measure TTFB in DevTools Network waterfall
│ │ ├── DNS long? --> Add dns-prefetch / preconnect for known origins
│ │ ├── TCP/TLS long? --> Enable HTTP/2, check edge deployment, keep-alive
│ │ └── Waiting (server) long? --> Profile backend, check queries and caching
│ └── Render-blocking resources? --> Check network waterfall for CSS/JS blocking
├── Interaction feels sluggish
│ ├── UI freezes on click? --> Profile main thread, look for long tasks (>50ms)
│ ├── Form input lag? --> Check re-renders, controlled component overhead
│ └── Animation jank? --> Check layout thrashing, forced reflows
├── Page after navigation
│ ├── Data loading? --> Measure API response times, check for waterfalls
│ └── Client rendering? --> Profile component render time, check for N+1 fetches
└── Backend / API
├── Single endpoint slow? --> Profile database queries, check indexes
├── All endpoints slow? --> Check connection pool, memory, CPU
└── Intermittent slowness? --> Check for lock contention, GC pauses, external deps
步骤 2:找出瓶颈
各类别中常见的瓶颈:
前端:
| 症状 | 可能原因 | 调查方法 |
|---|---|---|
| LCP 缓慢 | 图片过大、阻塞渲染的资源、服务器缓慢 | 检查网络瀑布图、图片大小 |
| CLS 偏高 | 图片未设置尺寸、内容延迟加载、字体偏移 | 检查布局偏移归因 |
| INP 较差 | 主线程上的 JavaScript 负担过重、DOM 更新量大 | 在 Performance 跟踪记录中检查长任务 |
| 初始加载缓慢 | Bundle 过大、网络请求过多 | 检查 Bundle 大小、代码拆分情况 |
后端:
| 症状 | 可能原因 | 调查方法 |
|---|---|---|
| API 响应缓慢 | N+1 查询、缺少索引、查询未优化 | 检查数据库查询日志 |
| 内存增长 | 引用泄漏、缓存无界增长、负载过大 | 分析堆快照 |
| CPU 峰值 | 同步执行的繁重计算、正则表达式回溯 | CPU 性能分析 |
| 延迟偏高 | 缺少缓存、重复计算、网络跳转过多 | 沿调用栈跟踪请求 |
步骤 3:修复瓶颈
只修复步骤 2 找到的那一项,其他内容一概不动。以下反模式是最常见的问题;每项都说明了对应规则和识别特征,并链接到 references/optimization-patterns.md 中的完整修复示例。遇到对应代码时再打开所需内容,不要提前阅读。
后端
- N+1 查询。 每行执行一次查询是最常见的后端瓶颈。不要在循环中逐条查询,而是在同一个查询中获取关联数据(join/include)。模式。
- 无界数据获取。 每个列表端点都应使用限制数量和稳定排序进行分页。模式。
- 查询未使用索引。 “添加索引”只是猜测;
EXPLAIN ANALYZE才是衡量依据。预期使用索引却出现Seq Scan、rows=估算值偏差一个数量级,以及扫描节点上方出现Sort节点,这些情况分别需要不同的修复方案;rows=估算不准确意味着统计信息已过期,因此应运行ANALYZE,而不是添加索引。根据查询的形态创建索引(先放等值列,再放范围列或排序列)。普通索引无法帮助查询低选择性的主导值(部分索引可用于罕见值)、前置通配符(需要 trigram 或全文检索),或对列应用函数的查询(为表达式创建索引,例如WHERE lower(email) = ?);而且每个索引都会增加每次写入的开销。之后重新检查执行计划;如果索引没有改变计划,就撤销它。模式。 - 连接池耗尽。 典型表现:所有端点同时变慢,大量时间花在等待连接而非执行上,并且数据库显示大多数会话处于空闲状态。每个进程使用一个连接池,并调整大小,确保
instances × max不超过数据库的连接上限。连接池越大并不意味着速度越快;它只是将队列转移到更难观察的数据库中。对于实例数量无界的场景(无服务器、自动扩缩容),应通过代理(pgbouncer、RDS Proxy)进行多路复用,而不是提高max。模式。 - 缺少缓存。 缓存生成成本高、读取频率远高于变化频率的数据;缓存已经很快的查询只会增加一次网络往返,并带来数据过期问题,却没有任何收益。应有意识地选择缓存层(进程内、共享、CDN)。所有会影响响应的输入都应纳入缓存键(租户、区域设置、权限、功能标志):缓存键遗漏查看者信息,可能导致一个用户的数据被提供给另一个用户。选择一种失效策略(TTL、基于事件或标签、版本化键),并明确说明可接受的数据过期时间窗口。防止热点键引发惊群:在一个请求重新计算时提供过期数据,或者将并发未命中请求合并到同一个进行中的 Promise。绝不要缓存必须保持最新的数据(余额、权限、结账时的库存)。模式;请求合并、写入策略和负缓存见
../../references/performance-checklist.md。
前端
- 缺少图片优化。 每张图片都应声明
width和height(CLS)。LCP 图片应设置fetchpriority="high",通过<picture>使用现代格式(AVIF、WebP),并使用srcset/sizes切换分辨率;首屏以下的图片应设置loading="lazy"和decoding="async"。模式。 - 不必要的重新渲染。 在渲染过程中创建的对象或函数每次都会产生新的引用,导致接收它的每个子组件重新渲染。将常量提取到渲染过程之外;只有当性能分析表明开销较大时,才使用
React.memo和useMemo,因为过度使用本身也有成本。模式。 - 包体积过大。 现代打包工具会自动对 ESM 命名导入进行 tree-shaking;真正有效的方法是按路由拆分代码,并在
Suspense后延迟加载体积大、极少使用的功能。修改导入方式前先进行性能分析。模式。
第 4 步:验证(保留或回退)
修复在重新测量之前都只是一个假设。本步骤决定它能否保留。
**按照测量基线的方式重新测量:**使用相同的命令、相同的条件、相同的固定预算(墙上时钟时间、样本数或请求数)。如果基线是在冷缓存下测量的,而结果是在热缓存下测量的,那么测到的是缓存,而不是你的改动。
**一次只改一件事。**三个优化一起上线,只会得到一个数字,你无法确定它们各自的效果。如果必须一起发布,请先分别测量。
**战胜噪声,而不只是比较均值。**重复测量,并将差值与多次运行的方差进行比较。在 ±5% 的方差范围内取得 3% 的提升并不算提升,只是换了一个样本。
然后严格作出决定:
| 与基线相比的结果 | 操作 |
|---|---|
| 超过阈值,测试通过 | **保留。**提交时在说明中写上改动前后的数值。 |
| 在噪声范围内(无可测量的变化) | 回退。 |
| 变差 | 回退。 |
| 有所改善,但有测试失败 | **回退。**这是披着胜利外衣的回归。 |
**“没有变化”意味着回退,而不是保留。**团队往往会跳过这一步:改动已经写好了,删掉又觉得浪费,于是未经测量就合入代码库,复杂度也随之累积,却从未带来任何收益。保留下来的代码,就意味着你要永远维护它。让它证明自己的价值。
**正确性是指标的前提。**测试套件必须通过,并且指标必须有所改善。通过删掉产品需要的工作来取胜的“优化”(跳过验证、缓存必须保持最新的数据、移除起关键作用的 await)是回归,不是胜利。
记录每次尝试,包括回退的尝试
回退的工作不会留在 git 历史中,这正是同一个行不通的想法会在下个季度再次被尝试的原因。记一份简短的记录,让被舍弃的想法不再重现:
| 想法 | 基线 → 结果 | 结论 | 原因 |
|---|---|---|---|
| 对行组件进行记忆化 | INP 240ms → 235ms | 已回退 | 在噪声范围内(±15ms)。行不是瓶颈。 |
| 对列表进行虚拟化 | INP 240ms → 90ms | 已保留 | 跟踪记录中的长任务消失了。 |
| 预连接到 API 源 | LCP 2.8s → 2.8s | 已回退 | 本来就是同源。 |
可以在 PR 描述中添加一个小节,或者在代码库中添加 PERF.md。关键是让下一个人(或下一个 Agent)在提出实验前先阅读记录,避免重复进行已经失败的尝试。
第 5 步:防止回归
要守护用户真正能感受到的指标,而不是所有可用的指标。使用证明修复有必要的同一个 LCP、INP、p95 延迟或其他主要指标。
对于面向用户的场景,使用以下两个互补层:
- **合成测试 CI 门禁:**通过性能预算,在合并前捕获可复现的回归。对噪声较大的测量进行重复测试,或比较中位数/趋势,避免正常的多次运行方差导致门禁检查不稳定。
- **线上监控:**在 RUM 数据中,针对有意义的 p75 变动发出警报。使用带归因信息的
web-vitals数据定位原因;将 CrUX 的滚动时间窗口作为确认依据,而不是即时警报。
任一防护机制触发时,都应返回第 1 步,建立新的基线,然后再提出其他修复方案。
设置预算并强制执行:
JavaScript bundle: < 200KB gzipped (initial load)
CSS: < 50KB gzipped
Images: < 200KB per image (above the fold)
Fonts: < 100KB total
API response time: < 200ms (p95)
Time to Interactive: < 3.5s on 4G
Lighthouse Performance score: ≥ 90
在 CI 中强制执行:
# Bundle size check
npx bundlesize --config bundlesize.config.json
# Lighthouse CI
npx lhci autorun
另请参阅
如需详细的性能检查清单、优化命令和反模式参考,请参阅 ../../references/performance-checklist.md。
常见借口
| 借口 | 事实 |
|---|---|
| “我们之后再优化” | 性能债务会不断累积。现在就修复明显的反模式,把微优化留待以后。 |
| “在我的机器上运行很快” | 你的机器不等于用户的机器。应在有代表性的硬件和网络环境下进行性能分析。 |
| “这个优化显而易见” | 如果没有测量,你就无从得知。先进行性能分析。 |
| “用户不会注意到 100ms 的延迟” | 研究表明,100ms 的延迟会影响转化率。用户比你想象的更敏感。 |
| “框架会处理性能问题” | 框架能防止某些问题,但无法解决 N+1 查询或过大的资源包。 |
| “查询很慢,加个索引就行” | 先查看执行计划。索引可能已经存在但无法使用,而且每个索引都会永久增加写入开销。 |
| “直接缓存就行” | 缓存一个本来就很快的调用毫无益处,还会引入数据过期问题。只缓存开销大、且读取远多于写入的内容。 |
| “把连接池调大,我们快没连接了” | 连接池大到超出数据库的处理能力,只会把队列转移到更隐蔽的地方。找出是什么占用了连接。 |
| “效果不大,但也没坏处” | 没有收益的改动就该回退。你要永远为它承担维护成本,却没有得到任何回报。 |
| “代码都写好了,还是留着吧” | 这是沉没成本。测量结果不会在意编写这项改动花了多久。 |
| “改进显而易见,不用重新测量” | 那么重新测量成本很低,还能证明这一点。未经测量的所谓收益,正是无效复杂性被引入的方式。 |
危险信号
- 没有性能分析数据支持的优化
- 数据获取中存在 N+1 查询模式
- 添加索引时,没有前后查询计划来证明其合理性
- 缓存键遗漏了响应所依赖的输入(租户、区域设置、查看者)
- 缓存没有明确说明过期时间窗口,也没有失效策略
- 连接耗尽时调大连接池,却没有找出是什么占用了连接
- 列表端点没有分页
- 图片没有尺寸、延迟加载或响应式尺寸设置
- 资源包大小不断增加,却没有审查
- 生产环境中没有性能监控
- 到处使用
React.memo和useMemo(过度使用和使用不足一样糟糕) - 保留优化,却没有重新测量来证明其合理性
- 将多项优化合并到一次测量中,导致无法确定单项改动的效果
- 所谓的“收益”需要通过修改、跳过或删除测试才能实现
- 因为无人记录首次尝试,导致同一项失败的优化被重复尝试
验证
每次进行与性能相关的改动后:
- 有改动前后的测量结果(具体数值)
- 采用与基线相同的方式重新测量结果(相同命令、相同条件)
- 改进幅度超过多次运行间的波动,而不只是均值有所改善
- 未超过基线的改动已回退,而不是作为无效改动保留
- 所有尝试都有记录,无论保留还是回退,避免重复尝试无效方案
- 已识别并解决具体瓶颈
- 核心 Web 指标符合“良好”阈值
- 资源包大小没有显著增加
- 新增的数据获取代码中没有 N+1 查询
- 新增索引有前后查询计划作为依据,且已考虑其写入成本
- 新增缓存已说明缓存键的依据以及数据如何过期
- 已为测得的用户可感知指标设置合成监控预算或实测监控,以便发现性能回退
- 现有测试仍然通过(优化没有破坏行为)