首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >把「构建又慢了」变成可定位的数据:CI 阶段耗时埋点与归因实践

把「构建又慢了」变成可定位的数据:CI 阶段耗时埋点与归因实践

原创
作者头像
老王说技术
发布于 2026-11-13 15:51:33
发布于 2026-11-13 15:51:33
140
举报

一、问题不是慢,是说不清慢在哪

团队里最常见的对话是:「最近流水线明显变慢了。」然后有人说是依赖装得久,有人说是runner 排队,有人说是测试用例变多了,最后靠嗓门决定优化哪一项。

这背后不是性能问题,是度量问题:流水线只给出了一个总时长数字,没有任何可归因的结构。总时长是一个和值,和值不能告诉你该改哪一刀。

这篇记录一套不需要买平台、当周就能落地的做法:在 CI 脚本里埋结构化阶段耗时,落成一张可查询的表,再用分层指标把「感觉慢」拆成具体项。文中的数值全部是示例值,用来说明计算方式,不是任何生产环境的测量结果。

二、先把度量对象定义清楚

一次流水线运行不是一个数字,至少拆成四层:

  • run:一次触发,标识为 run_id,带上仓库、分支、提交 SHA、触发方式、runner 机型
  • stage:编排层的阶段,如 checkout / install / build / test / package / upload
  • step:阶段内的可归因步骤,比如 test 里再拆 unit / integration / e2e
  • event:四个时间点——排队开始、实际开始、实际结束、上传结束

每个 step 至少记录这些字段:

代码语言:json
复制
{
  "run_id": "20261113-042",
  "repo": "svc-order",
  "sha": "a1b2c3d",
  "branch": "main",
  "stage": "test",
  "step": "integration",
  "queued_at": "2026-11-13T09:02:11Z",
  "started_at": "2026-11-13T09:03:05Z",
  "ended_at": "2026-11-13T09:09:41Z",
  "exit_code": 0,
  "cache_hit": false,
  "runner": "8c16g",
  "attempt": 1
}

关键设计有三条:

  1. 排队和执行分开记。queued_at → started_at 是资源供给问题,started_at → ended_at 才是脚本效率问题。混在一起,扩容和改脚本会互相甩锅。
  2. attempt 必填。重试会污染所有分布指标,必须能单独过滤。
  3. cache_hit 是布尔,不是推测。由缓存目录是否命中直接决定,不要事后用耗时反推「大概命中了」。

三、最小埋点实现:一个 shell 函数 + JSONL

不需要接 SDK。在 CI 脚本里包一层函数,每个步骤调用一次,追加写一行 JSON:

代码语言:bash
复制
#!/usr/bin/env bash
# ci-trace.sh —— 追加写 JSONL,不依赖 jq 以外的工具
set -u
TRACE_FILE="${TRACE_FILE:-ci-trace.jsonl}"
RUN_ID="${CI_RUN_ID:-local-$(date +%Y%m%d%H%M%S)}"
REPO="${CI_REPO:-$(basename "$PWD")}"
SHA="${CI_SHA:-$(git rev-parse --short HEAD 2>/dev/null || echo unknown)}"

emit() {
  printf '{"run_id":"%s","repo":"%s","sha":"%s","stage":"%s","step":"%s","ts":"%s","event":"%s","exit_code":%s,"cache_hit":%s,"attempt":%s}\n' \
    "$RUN_ID" "$REPO" "$SHA" "$1" "$2" "$3" "$4" "${5:-0}" "${6:-false}" "${7:-1}" \
    >> "$TRACE_FILE"
}

run_step() {
  local stage="$1" step="$2"; shift 2
  local t0 t1 rc
  t0="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  emit "$stage" "$step" "$t0" "start" 0 false 1
  set +e
  "$@"; rc=$?
  set -e
  t1="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  emit "$stage" "$step" "$t1" "end" "$rc" "${CACHE_HIT:-false}" "${ATTEMPT:-1}"
  return "$rc"
}

run_step install deps      npm ci
run_step build   compile   npm run build
run_step test    unit      npm test -- --runInBand

这个实现刻意保持朴素:JSONL 追加写不会因为流水线中途失败丢数据,也不要求额外服务。若编排层能给出排队开始时间,再补一条 event: "queued" 即可。

字段生成时最容易踩的坑是时间戳用本地时区。统一 date -u 输出 UTC,否则跨时区 runner 的数据合到一起会出错。

四、落成一张能查询的表

用 SQLite 就够,把 start/end 配成一行:

代码语言:sql
复制
CREATE TABLE ci_step (
  run_id TEXT, repo TEXT, sha TEXT, stage TEXT, step TEXT,
  queued_at TEXT, started_at TEXT, ended_at TEXT,
  queue_sec REAL, exec_sec REAL,
  exit_code INT, cache_hit INT, attempt INT, runner TEXT
);

-- 从 JSONL 导入后,按 run_id + stage + step 配对生成指标
INSERT INTO ci_step
SELECT s.run_id, s.repo, s.sha, s.stage, s.step,
       NULL, s.ts, e.ts,
       NULL,
       (julianday(e.ts) - julianday(s.ts)) * 86400,
       e.exit_code,
       CASE e.cache_hit WHEN 'true' THEN 1 ELSE 0 END,
       CAST(e.attempt AS INT), s.runner
FROM events s JOIN events e
  ON s.run_id = e.run_id AND s.stage = e.stage AND s.step = e.step
WHERE s.event = 'start' AND e.event = 'end';

有了这张表,下面这些查询才问得出来。

五、五个真正有用的指标

1. 阶段 p50 / p95 耗时,而不是平均值

代码语言:sql
复制
SELECT stage, step,
       ROUND(AVG(exec_sec),1) AS avg_s,
       ROUND(MAX(exec_sec),1) AS max_s
FROM ci_step
WHERE exit_code = 0 AND attempt = 1 AND ended_at > '2026-11-06'
GROUP BY stage, step
ORDER BY avg_s DESC;

SQLite 没有内建百分位,用窗口函数或把结果导出到 Python 算 p95 都可以。看 p95 而不是平均值,是因为流水线体感由长尾决定:一条偶发的 20 分钟构建会毁掉一上午的节奏,平均值却几乎不动。

2. 队列等待 vs 实际执行

排队占比高,说明该谈的是 runner 容量和并发策略,改 npm ci 没用;反过来才该优化脚本。这一项能把「扩容」和「优化」两类方案彻底分开。

3. 缓存命中率,按步骤分组

全局命中率没有意义,要按 install 这类步骤单独看。同时把命中与未命中两组的 exec_sec 放在一起对比,才能算出优化的真实收益上限。

4. 重试率与 flaky 测试

attempt > 1 的运行单独统计,再按 step 聚合。重试率高但每次都"最终通过",是最容易被忽略的效能黑洞——它不体现在总时长里,却让开发者反复等待。

5. 变更前置时间的分解

把 merged_at - first_commit_at 拆成「编码等待」「排队」「CI 执行」「评审」「发布」几段。只有 CI 执行这一段归流水线团队负责,其余各段对应不同改进动作。不做分解就会出现「让 CI 背所有交付慢的锅」。

六、怎么归因,而不是拍脑袋

有了分层数据,做一次归因只需要三步:

  • 算贡献度:按 stage 求 p95 耗时之和,看哪一项对总长尾的贡献最大。示例分布若为 install 占 45%、test 占 35%、build 占 20%,那么优化 build 的收益天花板就是 20%,不值得先动。
  • 做前后对比:改动只保留一个变量(比如只换缓存 key 策略),取改动前后各不少于 30 次成功运行的 p95 对比,同时记录失败/重试是否变化。样本太少就别下结论。
  • 固化成看板:按周出「阶段 p95 趋势 + 队列占比 + 重试率」三张图。看板的价值不在好看,在于下次有人说"变慢了"时,能当场指到某一层。

七、几个反模式

  • 只看总时长:和值无法归因,也无法验证优化是否生效。
  • 用平均值汇报:长尾被抹平,体感和数据长期对不上。
  • 把缓存命中率当 KPI:命中率可以靠「只跑空构建」刷到 100%,要同时看步骤耗时和产物完整性。
  • 度量到个人:这类数据一旦用于考核,最先出现的是拆小提交、跳过慢测试等行为,数据立刻失真。只用于团队级趋势与方案取舍。
  • 跨仓库口径不统一:stage 命名各写各的,合表时 test 和 ut 被当成两件事,趋势全乱。统一枚举值比统一工具更重要。

八、落地节奏

第一周只埋点、不看板、不定目标,先拿到两周基线;第二周选贡献度最大的一项做单点改动并做前后对比;第三周把通过验证的指标固化成看板与告警阈值。跳过基线直接定目标,几乎必然会把优化做成数字游戏。

九、边界

本文的字段和 SQL 是可直接改造运行的最小骨架,没有引入任何特定 CI 平台的能力;文中的比例、耗时、占比均为说明计算方式的示例值,不是生产测量结果。真实环境里还要处理并发 stage、矩阵构建、部分步骤并行造成的时长不可加等问题——这些正是分层埋点比总时长更值得投入的原因。

结论

「构建慢」不是一个性能结论,是一个缺少结构的观察。把一次运行拆成带时间戳的步骤,落成一张表,再用 p95、队列占比、重试率去分层归因,优化才有对象、有上限、有验证。工具可以很轻,口径必须很硬。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、问题不是慢,是说不清慢在哪
  • 二、先把度量对象定义清楚
  • 三、最小埋点实现:一个 shell 函数 + JSONL
  • 四、落成一张能查询的表
  • 五、五个真正有用的指标
  • 六、怎么归因,而不是拍脑袋
  • 七、几个反模式
  • 八、落地节奏
  • 九、边界
  • 结论
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档