NodeVerdict | 性能门禁:把追踪数据变成 CI 规则

09 · 性能门禁:让追踪数据变成 CI 里真正会卡人的 "Rules as Code"

阅读时长 :约 35 分钟

前置知识 :知道 CI/CD 构建大致是什么、会看 YAML 配置即可。本篇讲"性能测试如何作为质量关"。

本篇目标 :讲清楚一个优雅的复用------同一个追踪分析引擎,浏览器里当可视化工具,CI 里当性能门禁。 你会理解规则如何用 YAML 描述、如何评估打分、如何退出非零让流水线失败。


目录

  1. 一个引擎,两种用途
  2. 门禁:性能测试的"关键时刻"
  3. 规则如何描述(perf-gate.yaml 逐字段)
  4. RuleGraph:把规则拼成有向无环图
  5. 评估引擎:一次遍历,全部打分
  6. 边界与算术表达式
  7. 裁决与退出码:让流水线"红"
  8. 与 CI 的整合方式
  9. 总结 + 下篇预告

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,怎么找出"一次改动引发的性能回归"。


本篇附赠:动手练习

  1. 写一个"平均延迟必须 <100ms"的规则,跑 perf-gate.yaml 看能否得出分数。
  2. 把 gate 的 --threshold 改成 100,观察同一份数据的通过/失败结果。
  3. 思考:为什么用 DAG 而不用普通数组排规则执行顺序?
相关推荐
小马过河R2 小时前
AI Coding应用上线安全实践指南
人工智能·安全·安全架构·engineering·ai coding·harness
烂蜻蜓2 小时前
Node.js入门教程(五):NVM 管理多版本 Node.js
node.js
杨超越luckly3 小时前
Agent应用指南:巨幕之下 · 中国 IMAX 影院205城的空间布局
人工智能·arcgis·html·agent·数据可视化
城数派3 小时前
1842-2026年全球台风路径数据集
信息可视化
MartinYeung53 小时前
npm爆发大规模供应链攻击 蠕虫污染 2000+ 个包版本: 深度技术剖析
前端·npm·node.js
Flynt4 小时前
Agent跑完就失忆?Cloudflare这个开源项目给它装了块硬盘
typescript·开源
404NotFOund4 小时前
小白本地部署微调耍起
机器学习·开源
鲟迹4 小时前
NVM 管理Node.js版本
node.js
戒了,最后一次5 小时前
WorkBuddy 切换账号后一键恢复对话记录:开源迁移工具 workbuddy-account-migrate 实战
开源