09 · 性能门禁:让追踪数据变成 CI 里真正会卡人的 "Rules as Code"
阅读时长 :约 35 分钟
前置知识 :知道 CI/CD 构建大致是什么、会看 YAML 配置即可。本篇讲"性能测试如何作为质量关"。
本篇目标 :讲清楚一个优雅的复用------同一个追踪分析引擎,浏览器里当可视化工具,CI 里当性能门禁。 你会理解规则如何用 YAML 描述、如何评估打分、如何退出非零让流水线失败。
目录
- 一个引擎,两种用途
- 门禁:性能测试的"关键时刻"
- 规则如何描述(
perf-gate.yaml逐字段) - RuleGraph:把规则拼成有向无环图
- 评估引擎:一次遍历,全部打分
- 边界与算术表达式
- 裁决与退出码:让流水线"红"
- 与 CI 的整合方式
- 总结 + 下篇预告
1. 一个引擎,两种用途
打个比方:一辆消防车,白天当消防车救火,晚上改装成洒水车给马路降温。车还是那辆车,只是用在不同场景。
NodeVerdict 的核心引擎(src/shared/engine/)就是这样:
- 浏览器里:分析追踪数据 → 画瀑布图、火焰图、报告
- CI 门禁 (
src/shared/gate/):分析追踪数据 → 判定性能分数,决定流水线过不过
两份工作共用同一套分析逻辑,只是输出用途不同。 这就是架构复用的优雅之处。
2. 门禁:性能测试的"关键时刻"
2.1 为什么需要门禁
没有门禁时,性能问题是"事后发现":
text
写代码 → 提交 → 上线 → 用户反馈卡顿 → 才发现问题
有门禁后:
text
写代码 → CI 跑追踪 → 性能分 < 80 → 流水线失败 → 发布前就把问题挡住
打个比方:没有质检的工厂,次品直接出厂到客户手里;有质检的工厂,次品在出厂线就被挑出来。
2.2 核心口号:Rules as Code
不是人工截图看报告,而是把性能规则写成代码/配置,机器的判断。
3. 规则如何描述(perf-gate.yaml 逐字段)
3.1 完整示例
examples/perf-gate.yaml:
yaml
name: demo
rules:
- name: P95 latency under 200ms
metric: p95
op: <=
threshold: 200
points: 20
- name: No runaway GC
valueFrom: gcStats
field: totalMajorPauseMs
op: <
threshold: 2000
- name: Heap growth stability
metric: heapGrowthRate
op: <=
threshold: 1.0
3.2 每个字段的含义
| 字段 | 含义 | 打个比方 |
|---|---|---|
name |
规则名字 | 质检项标题 |
metric |
取哪个指标(p95/avg/...) | 量的是腰围还是体重 |
valueFrom + field |
从哪个统计块取字段 | 从"GC 报告"里取"总大暂停" |
op |
比较符(<= / < / >=) | 判断标准 |
threshold |
阈值 | 及格线 |
points |
分值(达标/不达标贡献的分数) | 这项值多少分 |
metric vs valueFrom+field 的区别 :metric 是从事件统计里直接算的指标(P95 等);valueFrom: gcStats.field 是从预计算的统计块里取特定字段(更灵活,可访问任意统计值)。
4. RuleGraph:把规则拼成有向无环图
4.1 什么是 DAG
有向无环图(DAG) = 一个有方向、且没有环的图。没有环意味着:顺着箭头走,永远不会回到起点(不存在死循环)。
打个比方:一个工程的依赖顺序------先打地基、再砌墙、再盖顶。你不可能"先盖顶再打地基"。这就是没有环。
4.2 为什么要 DAG
规则之间可以有依赖(比如 A 规则要 B 规则的数据)。用 DAG:
- 能推导出正确的执行顺序(拓扑排序)
- 并行执行不冲突(无环 = 无死锁)
- 保证不会无限循环
4.3 代码如何构建
src/shared/gate/performance-gate.ts 里把规则节点按依赖连起来,做拓扑排序后依次求值:
typescript
function evaluateRules(rules, stats) {
// 拓扑排序:按依赖关系确定执行顺序
const sorted = topoSort(rules);
const results = [];
for (const rule of sorted) {
const value = computeRuleValue(rule, stats); // 取指标
const passed = compare(value, rule.op, rule.threshold); // 比较
results.push({ rule, value, passed });
}
return results;
}
5. 评估引擎:一次遍历,全部打分
5.1 环境准备
评估前,把各项统计数据算好塞进一个"统计包"里:
typescript
const env = {
p95: stats.latency.p95, // 延迟 P95
avg: stats.latency.avg, // 平均延迟
gcStats: stats.gc, // GC 统计(供 valueFrom 用)
heapGrowthRate: stats.heapGrowthReco.rate, // 堆增长率
// ... 更多
};
5.2 求值闭包
每个 valueFrom 指标对应一个"取值的函数",Mermaid 里画成一条"数据 → 取值 → 比较 → 分数"的流水线:
#mermaid-svg-Ccmsk1eTuDHstfqx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ccmsk1eTuDHstfqx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ccmsk1eTuDHstfqx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ccmsk1eTuDHstfqx .error-icon{fill:#552222;}#mermaid-svg-Ccmsk1eTuDHstfqx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ccmsk1eTuDHstfqx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ccmsk1eTuDHstfqx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ccmsk1eTuDHstfqx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ccmsk1eTuDHstfqx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ccmsk1eTuDHstfqx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ccmsk1eTuDHstfqx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ccmsk1eTuDHstfqx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ccmsk1eTuDHstfqx .marker.cross{stroke:#333333;}#mermaid-svg-Ccmsk1eTuDHstfqx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ccmsk1eTuDHstfqx p{margin:0;}#mermaid-svg-Ccmsk1eTuDHstfqx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ccmsk1eTuDHstfqx .cluster-label text{fill:#333;}#mermaid-svg-Ccmsk1eTuDHstfqx .cluster-label span{color:#333;}#mermaid-svg-Ccmsk1eTuDHstfqx .cluster-label span p{background-color:transparent;}#mermaid-svg-Ccmsk1eTuDHstfqx .label text,#mermaid-svg-Ccmsk1eTuDHstfqx span{fill:#333;color:#333;}#mermaid-svg-Ccmsk1eTuDHstfqx .node rect,#mermaid-svg-Ccmsk1eTuDHstfqx .node circle,#mermaid-svg-Ccmsk1eTuDHstfqx .node ellipse,#mermaid-svg-Ccmsk1eTuDHstfqx .node polygon,#mermaid-svg-Ccmsk1eTuDHstfqx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ccmsk1eTuDHstfqx .rough-node .label text,#mermaid-svg-Ccmsk1eTuDHstfqx .node .label text,#mermaid-svg-Ccmsk1eTuDHstfqx .image-shape .label,#mermaid-svg-Ccmsk1eTuDHstfqx .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ccmsk1eTuDHstfqx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ccmsk1eTuDHstfqx .rough-node .label,#mermaid-svg-Ccmsk1eTuDHstfqx .node .label,#mermaid-svg-Ccmsk1eTuDHstfqx .image-shape .label,#mermaid-svg-Ccmsk1eTuDHstfqx .icon-shape .label{text-align:center;}#mermaid-svg-Ccmsk1eTuDHstfqx .node.clickable{cursor:pointer;}#mermaid-svg-Ccmsk1eTuDHstfqx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ccmsk1eTuDHstfqx .arrowheadPath{fill:#333333;}#mermaid-svg-Ccmsk1eTuDHstfqx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ccmsk1eTuDHstfqx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ccmsk1eTuDHstfqx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ccmsk1eTuDHstfqx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ccmsk1eTuDHstfqx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ccmsk1eTuDHstfqx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ccmsk1eTuDHstfqx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ccmsk1eTuDHstfqx .cluster text{fill:#333;}#mermaid-svg-Ccmsk1eTuDHstfqx .cluster span{color:#333;}#mermaid-svg-Ccmsk1eTuDHstfqx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ccmsk1eTuDHstfqx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ccmsk1eTuDHstfqx rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ccmsk1eTuDHstfqx .icon-shape,#mermaid-svg-Ccmsk1eTuDHstfqx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ccmsk1eTuDHstfqx .icon-shape p,#mermaid-svg-Ccmsk1eTuDHstfqx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ccmsk1eTuDHstfqx .icon-shape .label rect,#mermaid-svg-Ccmsk1eTuDHstfqx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ccmsk1eTuDHstfqx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ccmsk1eTuDHstfqx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ccmsk1eTuDHstfqx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 统计
metric取值
op 比较 threshold
是
否
追踪数据
统计包 env
规则值
达标?
+points
0分 + 标记失败
总分
5.3 可能的失败原因
代码里会抛出清晰的失败原因:
| 常见失败 | 含义 |
|---|---|
No metrics computed for P95. Empty input? |
没有足够的样本产生 P95 |
Unknown metric: foo |
指标名拼写或引擎不支持 |
打个比方:质检清单里写了个不存在的检查项(如"检查螺纹"但对塑料件),质检员直接报"没有这个指标"------而不是瞎蒙一个数。
6. 边界与算术表达式
6.1 边界处理(防止除零/空数据)
typescript
function safeDivide(num, den, fallback = 0) {
return den !== 0 ? num / den : fallback; // 分母 0 → 回退值
}
打个比方:你问"平均每桌营业额 = 总营业额 ÷ 桌数",但今天一个客人没有(桌数 0)------不能除零崩掉,而是给出一个合理的"回退值 0"。
6.2 支持简单算术表达式
规则里允许 (a + b) / 2 这类表达式,用一个小型求值器解析:
typescript
// 简化示意(真实是对表达式求值,支持 + - * / 括号)
function evaluateExpression(expr, env) {
return parser.parse(expr).eval(env); // 把 env 里的变量代进去算
}
这让你能写"(p95 + avg) / 2 < 150"这种组合条件,而不只是单个指标。
7. 裁决与退出码:让流水线"红"
7.1 总分计算
typescript
const total = results.reduce((s, r) => s + r.passed ? r.rule.points : 0, 0);
const perfScore = results.length ? total : 0; // 全部达标 → 满分
7.2 判定与退出
typescript
const threshold = 80; // 及格线 80 分
const perfScore = ...; // 算了总分(0-100)
if (perfScore < threshold) {
const msg = `PERFORMANCE GATE FAILED: score ${perfScore} < 80`;
process.exitCode = 1; // ← 关键!让 CI 流水线失败
} else {
process.exitCode = 0;
}
打个比方 :这是质检的最后一步------不合格就把红灯亮起来(exit code 1),CI 看到红灯就拒绝合并代码。
为什么退出非零必然让流水线失败? 几乎所有 CI(GitHub Actions / GitLab CI / Jenkins)都以"命令是否返回 0"判断该步骤成败。返回 1 代表失败,流水段标红,阻止发布。
8. 与 CI 的整合方式
实际用法:CI 跑基准测试时开启追踪,导出 .ndv 或 JSON,然后运行门禁命令:
yaml
# GitHub Actions(示例)
- name: Run performance gate
run: node dist/gate.js trace.ndv --config perf-gate.yaml --threshold 80
| 平台 | 整合点 | 效果 |
|---|---|---|
| GitHub Actions | workflow step + exit code |
步骤失败 → Job 标红 |
| GitLab CI | job script | job 失败 → pipeline 失败 |
| CLI 手工 | npx nodeverdict gate ... |
直接看分数和报告 |
9. 总结 + 下篇预告
9.1 本篇干货清单
| 概念 | 要点 | 打个比方 |
|---|---|---|
| 引擎复用 | 同引擎浏览器当图、CI 当门禁 | 消防车/洒水车 |
| Rules as Code | YAML 描述规则 | 质检清单 |
| DAG | 有向无环图排序 | 工程依赖顺序 |
| exit code | 返回 1 → 流水线红 | 亮红灯 |
| 边界处理 | safeDivide 防除零 | 空桌不除零 |
9.2 配餐数据
examples/perf-gate.yaml--- 现成规则配置,可直接跑- 用
examples/differential-*.json或任意追踪文件验证规则打分
9.3 下篇预告
微分调试(Differential Debugging)的最高光部分:两张图的 diff。 算法的深潜------align、fingerprint、divergence、report,怎么找出"一次改动引发的性能回归"。
本篇附赠:动手练习
- 写一个"平均延迟必须 <100ms"的规则,跑
perf-gate.yaml看能否得出分数。 - 把 gate 的
--threshold改成 100,观察同一份数据的通过/失败结果。 - 思考:为什么用 DAG 而不用普通数组排规则执行顺序?