Chrome Performance 与 Web Vitals 跨层性能取证手册
从“页面卡住”建立第一条证据链
客服录屏里,用户点击“提交”后两秒没有任何视觉反馈。后端 trace 只用了 80ms,于是问题被直接派给前端;前端又看到接口在 Network 中持续 1.8s,转回后端。真正的原因藏在 Main 轨道:点击前已有一段同步 JSON 处理占住主线程,网络请求虽已返回,回调和下一帧都只能排队。
Chrome Performance 记录的不是一张分数截图,而是一条客户端时间线。加载录制解释资源发现、网络、解析和首屏渲染;运行时录制解释输入延迟、事件处理、样式布局和下一帧;Memory 面板回答对象为何仍被引用;请求标识、Server-Timing、日志和分布式 trace 再把浏览器边界接到网关、应用与依赖。只有这些证据在同一场景和时间窗内对齐,才能判断慢来自主线程、传输、服务端处理还是负载升高后的排队。
第一次动手使用一份固定构建、固定测试数据和专用低权限账号。记录完整 Chrome 版本、viewport、网络与 CPU 限速、缓存状态和代理状态。自动刷新和容量阶梯只能指向回环地址或获批隔离环境,因为一次“采集”同样可能触发昂贵查询、写操作和业务埋点。
稳定的 Core Web Vitals 是 LCP、INP 和 CLS。它们把加载体验、交互响应和视觉稳定分开衡量:
| 指标 | 良好 | 需改进 | 较差 | 正式判断口径 |
|---|---|---|---|---|
| LCP | <= 2.5s | > 2.5s 且 <= 4s | > 4s | 真实页面访问的第 75 百分位,移动端与桌面端分开 |
| INP | <= 200ms | > 200ms 且 <= 500ms | > 500ms | 真实页面访问的第 75 百分位,移动端与桌面端分开 |
| CLS | <= 0.1 | > 0.1 且 <= 0.25 | > 0.25 | 真实页面访问的第 75 百分位,移动端与桌面端分开 |
这些阈值按真实页面访问的第 75 百分位判断,移动端与桌面端分开。一次本地录制不能声称“75% 用户达标”;Lighthouse 的 90~100、50~89、0~49 也是综合评分区间,不是 LCP、INP、CLS 的现场分位结论。阈值定义和统计方法应以 Core Web Vitals 阈值说明为准。
Lighthouse 是受控实验室审计,可以稳定复现加载问题,却不能观察真实用户整个页面生命周期中的交互分布。实验室里可用 TBT 发现加载期间的主线程阻塞风险,也可在 Performance 的 Live metrics 中执行代表性交互观察本地 INP;线上影响仍要用 CrUX 或自建 RUM 的现场分布确认。Chrome 的 Performance 功能参考同时说明了 Live metrics、现场 CrUX 对照、加载录制和运行时录制的入口。
Chrome Performance 和 Lighthouse 面板随 Chrome DevTools 提供,不需要单独部署服务。三层入口各有不同职责:
| 入口 | 用途 | 不应用来证明什么 |
|---|---|---|
| Performance Live metrics | 快速观察本地 LCP、CLS,并通过真实交互产生本地 INP | 不能代表所有用户的第 75 百分位 |
| Performance trace | 解释主线程、渲染、布局、网络和单次交互的因果链 | 不能单独证明后端容量上限 |
| Lighthouse / Lighthouse CI | 固定页面加载条件、发现回归、保留可比较报告 | 不能替代 RUM,也不能替代并发压测 |
浏览器侧入口:
Chrome DevTools -> Performance
Chrome DevTools -> Lighthouse需要可脚本化的基线时,在分支中安装开发依赖,让版本进入 lockfile,再由项目脚本调用:
mkdir -p artifacts/lighthouse
npm install --save-dev lighthouse @lhci/cli
npx lighthouse https://example.com/test-page \
--only-categories=performance \
--output=html \
--output=json \
--output-path=./artifacts/lighthouse/baseline--only-categories 决定审计类别,--output 可重复以保留机器可读 JSON 和人工报告,--output-path 决定制品位置。若只是临时验证且团队决定不接入,使用包管理器的卸载命令并恢复 lockfile 中这两项依赖;不要手工删除共享 node_modules。真正接入后,删除动作只针对本次构建 ID 的 artifact,基线和审计记录按留存策略处理。
不要把 Chrome 安装、Lighthouse CLI 和压力发生器理解成同一类部署对象。浏览器工具负责观察一个客户端,压测工具负责制造受控负载,后端观测系统负责解释服务行为;三者需要时间和请求标识对齐,而不是安装在同一台机器上就算完成接入。
先定义一次可比较的实验
任何性能数字都必须绑定实验条件。最低配置清单如下:
experiment:
page: https://example.com/test-page
scenario: search-and-open-detail
build: <commit-sha>
chrome: <full-version>
viewport: 1365x768
network: fast-4g
cpu_slowdown: 4x
cache_state: cold
account: perf-test-user
dataset: perf-dataset-v3
backend_release: <release-id>
load_stage: baseline这里的值只是格式示例,不是所有团队的统一标准。CPU slowdown 是相对于当前电脑性能的倍率,不能把一台高性能工作站的 4x 与普通 CI runner 的 4x 当成同一设备。Chrome 官方也明确指出,DevTools 无法真实模拟移动设备 CPU 架构。
建立跨层时钟和请求关联
浏览器、网关、应用和数据库所在主机必须进行时间同步。请求至少携带一个可检索的关联标识:
traceparent: 00-<trace-id>-<span-id>-01
X-Request-ID: <request-id>优先采用标准分布式追踪上下文;X-Request-ID 可作为团队已有系统的补充。不要让前端自行生成后被网关覆盖,也不要在响应、日志和 trace 中各用不同标识。验收标准是:从 Performance 的 Network 请求选中一条接口,可以在后端观测系统中检索到同一次请求。
分开冷缓存、热缓存和高负载
性能基线至少分三类,不得混在一个平均值里:
| 基线 | 状态 | 解决的问题 |
|---|---|---|
| 冷启动基线 | 浏览器缓存清空,服务或关键数据缓存按约定重置 | 首次访问代价多大 |
| 稳态基线 | 浏览器和服务缓存已预热,负载稳定 | 日常典型体验如何 |
| 容量阶梯 | 逐级增加并发或到达率 | 从哪一级开始排队、错误和体验共同恶化 |
缓存状态必须是实验变量,不能把“第一次很慢、第二次很快”取平均后宣称系统正常。预热方式、预热时长和命中率也应进入实验记录。
统一采样窗口
一次跨层实验建议分成四段:
静默段:确认没有其他任务干扰,采集空载资源基线。预热段:让 JIT、连接池、缓存和自动扩缩容进入预期状态。测量段:只在稳定负载下录制浏览器场景和服务指标。
恢复段:停止流量,观察队列、错误和资源是否恢复。
Performance trace 应只录一个明确用户场景,避免把数分钟压测全塞进浏览器 trace。容量曲线看长窗口,浏览器 trace 看该窗口内有代表性的一次加载或交互。
先在回环地址制造一条可解释的时间线。下面的 Node.js 18+ 服务只监听 127.0.0.1,端口和目录都由系统随机分配;页面提供正常交互、350ms 主线程阻塞和可回收的内存滞留三条路径。
LAB_DIR=$(mktemp -d "${TMPDIR:-/tmp}/chrome-perf.XXXXXX")
LAB_PID=""
cleanup() {
test -z "${LAB_PID:-}" || kill "$LAB_PID" 2>/dev/null || true
test -z "${LAB_PID:-}" || wait "$LAB_PID" 2>/dev/null || true
case "$LAB_DIR" in "${TMPDIR:-/tmp}"/chrome-perf.*) rm -rf -- "$LAB_DIR";; esac
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
cat > "$LAB_DIR/server.mjs" <<'EOF'
import http from 'node:http';
import { randomUUID } from 'node:crypto';
const page = `<!doctype html><meta charset="utf-8"><title>Performance lab</title>
<style>body{font:16px sans-serif;max-width:720px;margin:40px auto}button{margin:8px;padding:10px}#hero{font-size:32px}</style>
<h1 id="hero">Chrome Performance Lab</h1>
<button id="good">正常请求</button><button id="block">阻塞后请求</button>
<button id="leak">保留脱离 DOM 的对象</button><button id="clear">释放引用</button>
<p id="status">ready</p><script>
const retained = [];
const statusNode = document.querySelector('#status');
async function load() {
performance.mark('api-start');
const response = await fetch('/api');
statusNode.textContent = await response.text();
performance.measure('api-roundtrip', 'api-start');
}
document.querySelector('#good').addEventListener('click', load);
document.querySelector('#block').addEventListener('click', async () => {
const end = performance.now() + 350;
while (performance.now() < end) Math.sqrt(Math.random());
await load();
});
document.querySelector('#leak').addEventListener('click', () => {
const root = document.createElement('section');
for (let i = 0; i < 5000; i++) root.append(document.createElement('span'));
retained.push(root);
statusNode.textContent = 'retained batches: ' + retained.length;
});
document.querySelector('#clear').addEventListener('click', () => {
retained.length = 0;
statusNode.textContent = 'references released';
});
</script>`;
const server = http.createServer(async (req, res) => {
if (req.url === '/api') {
const id = randomUUID();
await new Promise(resolve => setTimeout(resolve, 120));
res.writeHead(200, {
'content-type': 'text/plain; charset=utf-8',
'server-timing': 'app;dur=80, db;dur=30',
'x-request-id': id
});
return res.end(`ok request-id=${id}`);
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page);
});
server.listen(0, '127.0.0.1', () => console.log(`PORT=${server.address().port}`));
EOF
node "$LAB_DIR/server.mjs" > "$LAB_DIR/server.log" &
LAB_PID=$!
for _ in $(seq 1 50); do grep -q '^PORT=' "$LAB_DIR/server.log" && break; sleep 0.1; done
PORT=$(sed -n 's/^PORT=//p' "$LAB_DIR/server.log")
test -n "$PORT" || { cat "$LAB_DIR/server.log"; exit 1; }
echo "Open http://127.0.0.1:$PORT"正向实验:从一次交互走到服务端阶段
打开打印出的地址和 DevTools Performance。先保持默认网络与 CPU,不勾选“停用 JavaScript 采样”;JavaScript samples 会影响 trace 体积和开销,但关闭后就无法从火焰图下钻调用栈。开始运行时录制,单击“正常请求”,状态变成带 request ID 的 ok 后立即停止。
按下面的顺序读,不要一开始就盯着最长的彩色块:
在 Interactions 或 Main 轨道定位 click,展开到事件监听器、fetch 和状态文本更新。正常路径不应出现 50ms 以上的 350ms 长任务。在 Timings 轨道找到 api-roundtrip。它包住从发请求到更新页面的客户端等待,不能等同服务处理时间。在 Performance 的 Network 轨道选择 /api,查看排队、连接、request sent、waiting/TTFB 和下载;再到 DevTools Network 面板读取完整响应头。X-Request-ID 是跨层检索键。
Server-Timing 会把示例中的 app;dur=80、db;dur=30 暴露给浏览器。它们是服务声明的阶段,不是浏览器自行测出的 span;生产中必须由可信网关控制字段,避免泄露内部拓扑。
预期证据是:总等待约 120ms,Network 中能看到 request ID 与 Server Timing,Main 轨道没有与等待等长的 JavaScript 执行。由此可以说“这次等待主要不在主线程”,但还不能从单个本地样本推出现场用户分布。
反向实验:接口一样快,交互却被长任务拖慢
新建一段运行时录制,单击“阻塞后请求”。Main 轨道会出现约 350ms 的 Task,火焰图向下可定位 click handler 中的循环;Bottom-up 按 Self time 排序时,这段同步脚本会靠前。Network 中 /api 的服务声明仍约 110ms,但交互的 input delay、processing duration 或 presentation delay 会因录制时机体现出额外阻塞。
这个反例稳定暴露了因果顺序:用户事件进入主线程,事件处理器同步占用线程,浏览器在任务结束前不能处理后续回调和绘制。后端快不等于交互快,Network 总时长也不能替代 INP 三阶段分析。若火焰图只显示压缩后的匿名函数,受控环境可在保存 trace 时包含 resource content 和 source map;导出文件随即升级为源码敏感资产。
内存实验:区分“占得多”和“回不去”
在 Performance 的 Capture settings 中启用 Memory,录制并连续点击“保留脱离 DOM 的对象”10 次,再停止。JS heap、DOM node count 上升只是嫌疑;接着进入 Memory 面板拍 Heap snapshot,按 Detached 过滤并沿 Retainers 查看,应能看到对象被页面的 retained 数组保持可达。
单击“释放引用”,再使用 DevTools 的 Collect garbage,拍第二份快照。存活的脱离节点应显著下降并回到接近初始基线。只看一次峰值不能声称泄漏;重复“分配 -> 释放 -> GC”多轮后存活对象仍单调增长,才形成稳定故障证据。若现场表现是周期性卡顿而对象最终能回收,应继续观察频繁 GC,而不是把所有内存压力都命名为泄漏。
结束时执行 cleanup 或关闭当前 shell。清理函数只终止记录下来的 PID,并在目录前缀匹配后删除临时目录,不会按进程名结束其他 Node 服务。
把本地证据放进负载阶梯
隔离环境的跨层实验再分为空载、低负载和目标负载等阶段。每个阶段稳定后重复同一浏览器场景,记录实际请求率、并发、错误率、后端 p50/p95/p99、CPU/内存、队列或连接池等待,以及浏览器 LCP/INP/CLS、关键 API waiting 和长任务。若服务端 p95 与连接池等待先上升,浏览器 API waiting 同步增加而主线程长任务稳定,证据更支持后端排队;若 API 时序稳定而 Long Task 和 INP 恶化,则先查客户端执行。结论应能被下一轮单变量实验推翻。
证据目录而不是截图目录
建议项目保留实验定义和脱敏后的摘要,不把大体积原始报告永久提交到 Git:
performance/
scenarios/
search-and-open-detail.md
baselines/
baseline-policy.md
lighthouse/
lighthouserc.cjs
schemas/
experiment-result.schema.json
README.md
artifacts/
performance/
<build-id>/artifacts/ 通常由 CI 或受控制品库保存。仓库里保存场景、口径、阈值来源和分析模板,避免把含 URL、截图、源映射或业务数据的 trace 直接提交。
CI 必须先生成静态构建产物,再让 LHCI 启动预览服务。以 Vite 项目为例,package.json 至少提供真实存在的构建和预览脚本:
{
"scripts": {
"build": "vite build",
"preview": "vite preview"
}
}第一版 lighthouserc.cjs 可以只采集回环地址并把结果留在当前 runner。下面的阈值是演示值,项目应从稳定基线和业务风险推导自己的数值:
const buildId = process.env.BUILD_ID || 'local';
module.exports = {
ci: {
collect: {
startServerCommand: 'npm run preview -- --host 127.0.0.1 --port 4173',
startServerReadyPattern: '127.0.0.1:4173',
url: ['http://127.0.0.1:4173/'],
numberOfRuns: 5,
settings: { preset: 'desktop' },
},
assert: {
assertions: {
'largest-contentful-paint': ['warn', { maxNumericValue: 3000 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
},
},
upload: {
target: 'filesystem',
outputDir: `./artifacts/lighthouse/${buildId}`,
},
},
};startServerCommand 只负责启动已经构建好的站点,不会执行 npm ci 或生成 dist/。显式绑定 127.0.0.1 可避免预览服务暴露到所有网卡;startServerReadyPattern 错写会让 CI 在服务已启动时仍超时。numberOfRuns 增加可用于聚合的样本,也线性增加执行时间和制品量。preset 改变设备与节流口径,切换后必须建立新基线。warn 保留报告但不阻断,error 让断言失败;团队应根据波动和风险分级。filesystem 不上传外部存储,若改成 temporary-public-storage,任何持有链接的人都可访问报告,不适合内网或登录态页面。
干净 runner 上的执行顺序应当完整可见。npm ci 按 lockfile 恢复依赖,npm run build 生成预览所需产物,最后才由 LHCI 拉起 preview 并采集:
set -eu
export BUILD_ID="${CI_PIPELINE_ID:-local-$(date +%Y%m%d%H%M%S)}"
npm ci
npm run build
npx lhci autorun
test -f "artifacts/lighthouse/${BUILD_ID}/manifest.json"filesystem.outputDir 中保存上传阶段生成的 Lighthouse 报告和 manifest.json,不等于断言工作目录。采集、断言和上传过程中使用的中间结果位于项目根目录 .lighthouseci/;断言是否阻断以 lhci autorun 的退出码和 CI 日志为准。两处都可能包含内部 URL、页面截图、响应片段或诊断细节,应按敏感制品管理,不能提交到仓库,也不能在公共 CI 中无条件上传。
回滚配置时只移除本次新增配置与依赖。清理时先拒绝空值、路径片段和特殊目录名,再验证本次报告的 manifest.json;随后只删除当前构建的报告目录与项目根下固定的 LHCI 工作目录:
set -eu
: "${BUILD_ID:?BUILD_ID must be set}"
case "$BUILD_ID" in
.|..|*[!A-Za-z0-9._-]*) echo "unsafe BUILD_ID: $BUILD_ID" >&2; exit 2 ;;
esac
REPORT_DIR="artifacts/lighthouse/${BUILD_ID}"
test -f "${REPORT_DIR}/manifest.json"
test -d .lighthouseci
rm -rf -- "$REPORT_DIR" ./.lighthouseci
test ! -e "$REPORT_DIR"
test ! -e ./.lighthouseci不要对共享的 artifacts/lighthouse 使用递归通配清理。并行任务也不应共享同一个工作区;否则一个任务清理 .lighthouseci/ 时可能破坏另一个任务的断言证据。
把性能场景绑定业务路径
不要只测首页。每个性能场景至少声明:
业务路径:搜索 -> 结果列表 -> 详情
用户类型:普通测试用户
数据规模:固定测试数据集
开始状态:已登录、列表缓存已预热
结束状态:详情首屏可交互
关键请求:GET /api/items, GET /api/items/<id>
体验指标:本地 LCP、代表性交互 INP、CLS
服务指标:API p95/p99、错误率、连接池等待
停止条件:错误率或资源使用达到团队红线这样一来,浏览器指标才能和容量目标共享同一个业务语义。
建立三层门禁
| 层级 | 门禁对象 | 推荐用途 |
|---|---|---|
| 代码变更 | Lighthouse 审计、资源预算、稳定实验室指标 | 快速发现明显页面回归 |
| 场景回归 | Performance trace、代表性交互、关键 API | 判断一次用户路径慢在哪里 |
| 容量评审 | 吞吐、错误率、分位延迟、资源、浏览器体验 | 判断系统在目标负载下是否仍满足体验目标 |
三层不能互相替代。Lighthouse CI 通过,不代表高并发下后端没有排队;后端压测达标,也不代表浏览器没有长任务和布局抖动。
从用户体验向下钻取
| 现象 | 浏览器先看 | 后端再看 | 常见判断方向 |
|---|---|---|---|
| LCP 变差 | LCP 元素、资源发现时间、Network waterfall、主线程阻塞 | TTFB、图片或页面接口、CDN / 网关耗时 | 资源晚发现、后端首字节慢、主线程阻塞渲染 |
| INP 变差 | 交互详情、input delay、processing、presentation delay、long task | 交互触发接口及异步回调 | 主线程繁忙、事件处理过重、渲染延迟;网络只在阻塞下一次绘制时相关 |
| CLS 变差 | Layout shifts、受影响节点、字体和动态内容 | 返回内容尺寸、实验配置 | 未预留尺寸、内容异步插入、字体替换 |
| API waiting 增长 | 请求时序、连接复用、优先级 | trace 总耗时、队列、线程池、数据库连接池 | 服务排队、代理限流、依赖变慢 |
| 页面卡顿但 API 正常 | Main flame chart、Task、Bottom-up、Call tree | CPU 可保持正常 | 前端计算、频繁布局、序列化或第三方脚本 |
从容量曲线向上回看
容量实验不要只看服务端吞吐。每升一级负载,都回答:
吞吐是否仍随负载线性增长。p95 / p99、错误率和队列等待是否出现拐点。CPU、内存、GC、线程池、连接池或下游依赖谁先饱和。
同期浏览器关键请求等待和 Web Vitals 是否恶化。停止负载后系统能否恢复,还是已经形成队列积压或雪崩。
典型容量拐点不是“CPU 到 100%”才出现。吞吐不再增长、尾延迟陡升、连接池等待出现、错误率开始爬升,任何一项都可能比 CPU 饱和更早发出信号。
使用差异而不是孤立绝对值
一次优化至少比较:
同一场景 + 同一构建条件 + 同一数据集 + 同一负载阶段
baseline build vs candidate build保留多次运行的分布或代表性运行,不用单次最好成绩。Lighthouse 官方建议在一致环境中重复运行并关注波动;CI 可用中位数或与风险匹配的聚合策略,但聚合方法必须写入门禁说明。
| 现象 | 判断路径 | 常见原因 | 修复与再验证 |
|---|---|---|---|
| Lighthouse 分数无代码变更仍波动 | 比较 Chrome、runner、扩展、网络、页面内容和第三方资源 | 共享 CI 资源争用、A/B 内容、广告、浏览器非确定性 | 固定版本和 runner,隔离第三方,多次运行后比较分布 |
| 本地指标很好,现场数据很差 | 比较设备、网络、地域、页面生命周期和用户路径 | 实验室只覆盖单设备与单次加载 | 用 CrUX / RUM 按设备和页面分组,再复现实例场景 |
| API 在浏览器中很慢,后端 trace 很快 | 拆分 DNS、连接、TLS、代理、TTFB、下载和浏览器调度 | 网关未纳入 trace、连接排队、响应体过大 | 补齐边缘层时序,核对 Server-Timing 或网关日志后重测 |
| 后端 p95 上升,浏览器看不出变化 | 检查页面是否命中缓存、是否测到关键业务路径 | 浏览器样本太少或场景没有走目标接口 | 固定数据和缓存状态,在每个负载阶段重复代表性场景 |
| INP 变差却把责任归给慢接口 | 展开交互的 input、processing、presentation 三阶段 | 混淆交互反馈与异步业务完成时间 | 先确认下一帧为何被阻塞,再单独评价业务完成耗时 |
| 平均延迟正常但用户仍投诉 | 查看 p95 / p99、错误率和慢用户分组 | 平均值掩盖尾部,现场网络和设备分布不同 | 用分位和分群分析,保留最差链路证据 |
| 压测升压后所有层都变慢 | 找最先出现拐点的队列、资源和依赖 | 只看最终饱和状态,失去因果顺序 | 缩小阶梯,延长稳定段,按时间对齐重做实验 |
| 导入 trace 后看不到源码 | 检查导出时是否包含 resource content / source maps | 为减小文件关闭了资源或源映射 | 仅在受控范围重新导出,并把文件按敏感资产管理 |
代理会改变测量路径
企业代理、VPN、安全软件和浏览器扩展可能增加连接时间、注入脚本或修改缓存。实验记录必须注明代理状态,基线和候选版本必须走同一网络路径。不要为了得到更好数字临时绕过企业安全代理;若要隔离代理影响,应设计“经过代理 / 不经过代理”两组获批实验。
测试账号遵循最小权限
使用专用测试账号和合成数据。账号权限既要足够完成业务路径,又不能访问真实敏感信息。认证初始化脚本、Cookie、token 和刷新令牌不得写入仓库、Lighthouse 配置或 CI 日志,应由受控 secret 注入,并在任务结束后撤销或轮换。
报告本身是敏感资产
Performance trace 可选择包含 HTML、JavaScript、CSS、source map 和注释;即使不包含资源正文,性能记录仍可能暴露内部 URL、脚本名、请求时序和页面截图。Lighthouse HTML / JSON 也可能包含 URL、审计细节、截图和运行环境信息。
处理原则:
默认不在 trace 中包含资源正文和 source map,确有调试需要时再开启。内网页面报告进入受控制品库,不上传公共 Viewer、公开 Gist 或公共 CI artifact。Lighthouse CI 的 temporary-public-storage 对持有链接的人可访问,不用于内部或登录态报告。
设定 artifact 访问角色、保留期和删除责任人。分享前扫描 URL query、Cookie、Authorization、业务截图、用户标识和内部源码。
先确定责任分工
| 角色 | 责任 |
|---|---|
| 前端 owner | 定义用户场景,解释主线程、渲染、资源和 Web Vitals |
| 后端 owner | 提供请求关联、trace、队列和依赖耗时证据 |
| 性能测试 owner | 控制负载模型、数据、阶段、停止条件和发生器容量 |
| 平台 / SRE | 保证观测口径、时钟、环境隔离和制品权限 |
| 架构师 | 审核瓶颈证据、容量余量、优化取舍和扩容决策 |
性能问题不能按“浏览器看到慢就归前端”分派。问题单应携带场景、实验条件、跨层时间线和当前最有力的证据,再确定 owner。
建立基线版本和失效规则
基线至少绑定:
代码和配置版本。Chrome、Lighthouse 和采集脚本版本。测试环境规格与拓扑。
数据规模、缓存状态和账号权限。页面场景、负载模型和聚合方法。指标阈值的来源、批准人和复审日期。
浏览器升级、Lighthouse 评分变化、硬件调整、CDN 或网关变化、数据规模显著变化后,旧基线应标记失效并重新采集,不能把两套口径强行画在一条趋势线上。
CI 只做稳定、快速、可解释的门禁
Lighthouse CI 适合对固定页面做重复采集、断言和趋势比较。建议先收集基线,再按页面类型设告警与阻断层级:资源体积和确定性审计可以严格阻断;高波动性能指标先告警或使用多次运行聚合;容量实验放在独立性能流水线,不占用普通提交流水线。
CI 报告上传可选受控 LHCI Server 或内部 artifact。若选择 filesystem 输出,团队要自行完成报告索引、留存、访问控制和差异分析。不要为了快速看到链接就默认上传临时公共存储。
实验室阈值不能直接升级为业务 SLO
Core Web Vitals 官方阈值是通用用户体验参考,Lighthouse 分数是工具评分,业务 SLO 则应由用户类型、关键路径、收入风险和系统能力共同确定。架构评审时应分别列出:现场体验目标、实验室回归门禁、后端服务 SLO 和容量停止线。四者可以关联,但不能共用一个数字。
判断标准:任何阈值必须写明数据源、统计窗口、分位、设备分组、页面范围和违反后的动作。缺少任一项,就只是愿望,不是门禁。
浏览器时间和服务时间不能直接相减
浏览器请求时长包含调度、连接、TLS、代理、网关、服务处理、下载等阶段;后端 span 只覆盖被埋点的服务边界。直接用“浏览器 900ms - 服务 120ms = 网络 780ms”会掩盖未埋点网关、队列和响应传输。
排查路径是先确认时钟和请求标识,再补齐 CDN / 网关日志或 Server-Timing,最后对照 Network timing 与 trace span。无法覆盖的区间应标为“未观测时间”,不能擅自命名原因。
客户端和压力发生器会成为瓶颈
共享 CI runner 同时跑 Chrome、构建和压测,会产生 CPU、内存和网络争用。此时 Lighthouse 波动、压测吞吐停滞和后端资源偏低可能同时出现,形成“系统容量不足”的假象。
判断标准包括发生器 CPU、网络、连接数、事件循环或线程使用率,以及 Chrome 所在机器资源。浏览器采集与大流量发生器宜分机运行;同机时至少错开阶段,并证明采集端未饱和。
容量拐点需要因果顺序,不只需要峰值
压到系统崩溃只能证明它会崩,不能告诉团队何时扩容。架构师需要找到第一个不可接受变化:队列等待出现、尾延迟弯折、错误率上升、吞吐不再线性增长,或 Web Vitals 在目标用户条件下越线。
实验应采用足够细的负载阶梯,并让每级进入稳定状态。输出“负载 -> 服务分位 -> 资源 / 队列 -> 浏览器体验”的曲线与时间线,最终给出安全工作区间和容量余量,而不是只报告极限 QPS。
优化可能把成本转移到另一层
减少浏览器计算可能增加服务端预计算;压缩响应可能增加 CPU;激进缓存可能改善 LCP 却引入一致性风险;提高连接池可能把压力推给数据库。任何优化都要在同一实验中复核用户体验、吞吐、错误率、资源成本和数据正确性。
取舍结论至少说明:改善了哪个指标、恶化了什么成本、在哪个负载范围成立、失败时如何回滚。只展示优化后的 Lighthouse 分数,不足以支持架构决策。
现场数据也不是天然真相
CrUX 是聚合的 Chrome 用户体验数据,自建 RUM 则受采样、埋点版本、同意机制、浏览器支持和用户分群影响。两套现场数据可能不同。团队需要记录数据覆盖率、采样策略、维度和版本变更,不能看到“真实用户数据”标签就停止校验。
排查现场与实验室差异时,按页面、设备、地域、网络、登录状态、缓存和用户路径分组;先解释样本差异,再判断代码回归。
RUM 还会产生持续成本。把每次交互的完整 URL、request ID、资源名和用户属性全部上报,会同时放大网络开销、日志存储、索引基数和隐私风险。客户端采用率、会话采样率、慢样本加权、属性白名单和保留期必须成套设计;采样策略变更要带版本字段,否则趋势变化可能只是采样口径变化。原始细粒度事件短期留存,长期报表优先保存按页面、设备、地域和版本聚合后的分布,并保留从异常聚合回到受控样本的诊断入口。
页面、场景、构建、Chrome 版本、viewport、网络、CPU 和缓存状态已记录。Core Web Vitals 阈值按真实用户第 75 百分位解释,没有当作单次本地硬门禁。Lighthouse 分数、实验室指标、现场指标和业务 SLO 已分开。
浏览器请求可以通过关联标识定位到后端日志或 trace。冷启动、稳态和容量阶梯没有混算。同时采集吞吐、错误率、p95 / p99、资源、队列和浏览器体验。
容量结论指出第一个拐点、安全工作区间和停止条件。客户端与压力发生器资源未饱和,或其影响已被明确记录。报告、trace、截图、URL、source map 和测试账号数据已按敏感资产治理。
CI 使用固定版本、稳定 runner、多次运行和明确聚合策略。优化结论包含收益、代价、适用负载范围、owner 和回滚方式。
