跳到主内容
快讯直播
AI智模界
教程

CI 从瓶颈改成加速器:缓存、分片、增量校验

代码由 AI 生成之后,提交频率和代码量会同时上涨,流水线的压力往往在几周内翻倍。以前十几分钟跑完的 CI,可能变成四十分钟排队加二十分钟执行。这篇讲怎么把这段时间拿回来,按"先定位、再改造"的顺序走一遍。

报错现象

单个 PR 提交后,CI 页面长时间停在 Queued,或者 job 日志尾部出现下面这类信息:

```

The operation was canceled.

The job running on runner xxx has exceeded the maximum execution time of ...

```

GitLab CI 常见的是:

```

This job is stuck because you don't have any active runners online with any of these tags assigned to them

ERROR: Job failed: execution took longer than 1h0m0s

```

依赖安装阶段则有:

```

npm ERR! code ETIMEDOUT

npm ERR! network request to https://registry.npmjs.org/... failed

Read timed out

```

Maven / Gradle 对应的是 Could not transfer artifact ... Connection timed out

影响范围通常长这样:单次流水线从几分钟涨到几十分钟,一个 PR 要等两三小时才能合;主干经常长时间变红;有人开始在提交信息里写跳过 CI 的标记;reviewer 因为 CI 结果不可信而重新人肉看一遍。这些现象看起来是平台问题,实际多数是流水线结构没跟上代码量的增长。

可能原因

按出现概率从高到低:

1. 依赖每次都全量下载、全量安装,缓存没配,或者 key 设计得永远命中不了。

2. 测试全部挤在一个 job 里串行执行,没有分片。

3. 任何一行改动都触发全量 lint、全量构建、全量测试,没有按变更范围收窄。

4. runner 数量或并发度不足,排队时间已经超过执行时间。

5. 多个 job 同时往同一个缓存 key 写,互相覆盖,命中率反而变低。

6. 日志和产物体积过大,上传阶段本身成了耗时大头。

7. 不稳定用例触发重试,重试把总时长成倍放大。

8. 失败定位靠人肉翻日志,一次失败要多花十几分钟。

逐条排查与解决

1. 依赖全量重装

量化,不要凭感觉。在安装命令前后各打一次时间戳,或者直接 time npm ci

```bash

echo "install start $(date +%s)"

npm ci

echo "install end $(date +%s)"

```

判断依据:安装阶段占总时长超过三成,就值得处理。

解决方式是配缓存。以 GitHub Actions 为例(actions/cache 的版本号以官方文档当前版本为准):

```yaml

  • uses: actions/cache@<version>

with:

path: |

~/.npm

node_modules

key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

restore-keys: |

${{ runner.os }}-node-

```

GitLab CI 的写法:

```yaml

cache:

key:

files:

  • package-lock.json

paths:

  • .npm/
  • node_modules/

```

其他生态的缓存目录:Maven 是 ~/.m2/repository,Gradle 是 ~/.gradle/caches~/.gradle/wrapper,pip 是 ~/.cache/pip,Go 是 ~/go/pkg/mod~/.cache/go-build,Cargo 是 ~/.cargo/registry,Docker 镜像层用 buildx 的 --cache-from / --cache-to

判断是否命中:GitHub Actions 日志里会出现 Cache restored from key: ...,未命中则是 Cache not found for input keys。GitLab 里看到 Downloading cache 表示命中,No URL provided, cache will not be downloaded 表示没拿上。

注意 node_modules 缓存有坑:跨操作系统、跨语言大版本会引入难查的问题。key 里带上 runner.os 和运行时大版本,或者干脆只缓存包管理器自己的缓存目录,不缓存 node_modules

2. 测试没有分片

判断方法很直接:单个 test job 的耗时等于全部用例的耗时,日志里能看到测试总数很大而并行度是 1。

思路是把测试文件切成 N 份,N 个并行 job 各跑一份。通用切分脚本:

```bash

#!/usr/bin/env bash

set -euo pipefail

TOTAL="${CI_NODE_TOTAL:-4}"

INDEX="${CI_NODE_INDEX:-0}"

mapfile -t files < <(find tests -type f -name '*test*' | sort)

shard=()

for i in "${!files[@]}"; do

if (( i % TOTAL == INDEX )); then shard+=("${files[$i]}"); fi

done

printf '%s\n' "${shard[@]}" > shard-files.txt

wc -l shard-files.txt

```

把结果喂给测试命令:

```bash

npx jest $(cat shard-files.txt)

pytest $(cat shard-files.txt)

```

不少框架自带分片参数,例如 Jest 的 --shard=1/4、pytest 的分片插件、Go 的 -run 正则分组。能自带就用自带的,语义更稳。

在 CI 里铺开,以 GitHub Actions 的 matrix 为例:

```yaml

jobs:

test:

strategy:

fail-fast: false

matrix:

shard: [0, 1, 2, 3]

env:

CI_NODE_INDEX: ${{ matrix.shard }}

CI_NODE_TOTAL: 4

steps:

  • run: bash scripts/split-tests.sh
  • run: npx jest $(cat shard-files.txt)

```

GitLab 直接用 parallel: 4,环境变量里会带 CI_NODE_INDEXCI_NODE_TOTAL

分片之后有两件事必须做:

均衡。按文件数均分只是起点,少数慢用例会把某个分片拖成尾巴。可以在每轮结束时记录单个文件的耗时,下一轮按累计耗时装箱。判断标准是各分片用例数接近,耗时极差在 20% 以内。

隔离。并行之后共享资源会打架:数据库 schema、Redis 库号、监听端口、临时目录、全局 fixture。给每个分片注入独立资源:

```bash

export TEST_DB_SCHEMA="test_${CI_NODE_INDEX}"

export PORT=$(( 30000 + CI_NODE_INDEX ))

export TMPDIR="$(mktemp -d)"

```

3. 全量校验

判断方法:只改了一行文档,CI 却把后端服务全量构建、全量测试了一遍。

浅层做法是在平台层过滤路径:

```yaml

on:

pull_request:

paths:

  • 'services/api/**'
  • 'packages/shared/**'

```

更灵活的是在脚本层判断变更范围,适合 monorepo:

```bash

BASE_SHA="${BASE_SHA:-origin/main}"

changed=$(git diff --name-only "$BASE_SHA...HEAD")

full_required=false

echo "$changed" | grep -qE '^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Dockerfile|\.github/workflows/)' && full_required=true

echo "$changed" | grep -q '^services/api/' && echo "api_changed=true" >> "$GITHUB_OUTPUT"

echo "$changed" | grep -q '^apps/web/' && echo "web_changed=true" >> "$GITHUB_OUTPUT"

echo "full_required=$full_required" >> "$GITHUB_OUTPUT"

```

后面的步骤按输出决定跑不跑。monorepo 工具链通常自带"受影响项目"能力,比如 Turbo 的 --filter='...[origin/main]'、Nx 的 affected、Bazel 的依赖图、Gradle 的增量构建任务,能复用就复用,别自己造依赖图。

两个必须守住的点:改了公共库、构建脚本、锁文件时强制全量;增量规则集中在一个文件里维护,不要散落在各个 job 的 if 里。

判断是否生效:故意改一个只属于前端的文件,看后端 job 是否被跳过或提前退出。

4. 先分清排队慢还是执行慢

这一条很容易被忽略。很多人一看到 CI 慢就去优化构建,结果时间其实花在排队上。

GitLab 的 job 页面能看到 Queued 时长,也可以用 API 批量拉:

```bash

curl -s --header "PRIVATE-TOKEN: $TOKEN" \

"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/pipelines/$PIPELINE_ID/jobs" \

| jq '.[] | {name, queued: .queued_duration, duration}'

```

GitHub Actions 的 job 列表里有 created 和 started 两个时间,相减就是排队时间。

如果排队时间大于执行时间,处理方向和优化构建完全不同:加 runner、把大 job 拆小、给关键分支配置专属 runner 标签、限制同时触发的流水线数量。

5. 缓存互相覆盖

判断依据:缓存日志里频繁出现 Cache not found,或者同一个 key 被不同分支反复写入。

处理办法是给 key 加维度,PR 只读、主干只写;指定一个 job 负责写缓存,其余 job 只读。

6. 日志和产物过大

判断依据:job 日志几百 MB,或者上传 artifact 的阶段耗时超过测试本身。

```bash

npx jest --silent --ci

npx jest --reporters=default --reporters=jest-junit

```

reporter 换成精简输出,只在失败时打印详情;只上传失败时的截图、快照和报告;关掉调试级日志。

7. 不稳定用例

判断依据:同一个 commit 重跑两次,结果不一样。

先量化再动手。统计近若干次流水线里"重跑后通过"的用例名,按出现次数排序,取前几个集中处理。常见根因是时间依赖(改用可注入的假时钟)、测试之间共享状态、异步等待写成固定 sleep。

已知不稳定的用例先隔离到非门禁 job 里,限期修复;不要用无上限重试掩盖,重试会把总时长成倍放大。

8. 失败后定位慢

把失败信息放到一眼能看到的位置:解析测试报告,把 文件:行号:用例名 写进 job 摘要(GitHub Actions 的 $GITHUB_STEP_SUMMARY,或 GitLab 的测试报告产物格式),点一下就能跳到代码;失败时自动保存最小复现命令。

同时开启 fail-fast,--bail--fail-fast 这类参数能让坏用例立刻中断,避免跑完四十分钟才报错。

都不管用时的兜底方案

拆成两层门禁。PR 阶段只跑"受影响模块的测试 + 增量 lint + 类型检查",控制在十分钟内;合并进主干后再跑全量。主干全量至少每天一次。

把依赖烘进镜像。预构建一个包含依赖缓存和常用工具的基础镜像,runner 直接拉取,跳过安装阶段。这是对"安装阶段怎么优化都降不下来"的直接解法。

自托管 runner 或更大规格的机器,先把并行度不足这个硬约束解决掉。

启用 merge queue 或 merge trains 之类的合并队列机制,把"每个 PR 各跑一遍全量"变成"合并队列里批量验证",减少重复执行。

临时降级:把性能测试、安全扫描、端到端测试改成定时或手动触发。但要在看板上记账,别让临时状态变成永久状态。

如何预防再次发生

1. 给 CI 设预算:PR 门禁的 P95 时长、排队时长各定一条线,超线告警。指标比感觉可靠。

2. 采集每个阶段的耗时——安装、构建、测试、上传——做成趋势图。哪一段在涨,一眼可见。

3. 把缓存命中率放进看板,缓存 key 规则变更走 review。

4. 定期检查分片均衡度,把慢用例当技术债治理,而不是靠加机器硬扛。

5. 增量校验规则集中维护,保留强制全量的开关,例如给 PR 打一个 full-ci 标签就全量跑一遍。

6. 每周固定一次夜间全量流水线,用来兜住增量规则的漏检。

7. 代码量增长时同步评估 runner 容量,别等到排队时间超过执行时间才想起来加机器。

最后收一句:CI 变慢通常不是某个工具的问题,而是"每次改动都重做全部工作"这个默认假设失效了。缓存解决重复下载,分片解决串行等待,增量校验解决做无关的功,失败快速定位解决人等人的时间。四件事各做一件,流水线时长往往就能从以小时计回到以分钟计。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。