单仓多包的 CI 策略与受影响分析

单仓(monorepo)把几十上百个包放进一个仓库后,CI 的最大挑战从「怎么构建」变成「该构建什么」。本文讲透受影响分析(affected)的依赖图原理、任务图与流水线编排、并行度与资源分配、制品在包之间的传递、合并队列与主干保护,并给出分阶段落地路径与常见坑。

单仓(monorepo)的好处是共享代码、统一工具链、一次提交可原子地跨越多个包;坏处是这份「原子性」直接砸在 CI 上——如果每次提交都触发全仓构建与测试,一个 80 包的仓库会让每个 PR 都排队 40 分钟,开发者很快学会「先合了再说」。

单仓 CI 要回答的核心问题只有一个:这次改动到底影响了哪些包,哪些任务可以跳过。把这个问题答对,流水线才能从「全量」收缩成「增量」,CI 时间才会与改动规模挂钩,而不是与仓库规模挂钩。本文讲透受影响分析的原理与一整套可落地的单仓 CI 策略。

1. 单仓 CI 的核心矛盾

1.1 规模带来的三重放大

仓库规模放大三重成本:
  1. 任务数量放大:N 个包 × M 个任务(build/test/lint)= N×M 个执行单元
  2. 依赖深度放大:底层工具包一变,所有下游包全部失效
  3. 队列放大:任务多了,runner 不够,等待时间超过执行时间

很多团队的第一反应是「加 runner」。但如果任务集合本身没有收缩,加 runner 只是把「执行时间」换成「排队时间 + 更高的账单」。

1.2 全量 vs 增量

策略触发范围CI 时长特征问题
全量每次提交跑所有包恒定,随仓库规模增长时间税,开发者绕开
路径过滤按目录规则粗筛依赖目录结构漏跑(跨包依赖未覆盖)
受影响分析按依赖图精确计算随改动规模增长需要可靠的依赖图

路径过滤(paths: / only: changes:)是很多团队的过渡方案,但它只认文件路径,不认依赖关系。一旦 libs/utils 被改,所有 import 它的包都该重跑,而路径过滤看不到这层关系——这是漏跑的经典来源。

1.3 依赖图是唯一可靠的依据

正确的做法是:构建一张包级依赖图,把「改动的文件」映射到「受影响的包集合」,再据此生成任务集合。这张图的来源有两种:

  • 显式声明:每个包在清单文件里声明自己的依赖(package.json 的 dependencies、go.mod 的 require、pom.xml 的 <dependency>)。
  • 静态推断:扫描源码 import、模块引用,推断出包与包的边。

生产级方案通常两者结合:以清单声明为主,用静态分析补上隐式依赖。

2. 受影响分析的原理

2.1 从改动到受影响集合

输入:git diff 得到的改动文件列表
输出:需要重跑的任务集合

步骤:
  1. 把改动文件归属到包(文件路径 → 包)
  2. 在依赖图上做「反向可达」:找出所有(直接或间接)依赖这些包的包
  3. 把这些包的对应任务加入待跑集合
  4. 叠加「始终要跑」的任务(如全局 lint、契约测试)

反向可达是关键:依赖是「A 依赖 B」,那么 B 变了,A 就要重跑。如果只在正向方向遍历,会漏掉所有下游。

2.2 基线的选择

受影响分析必须有一个比较基线:相对谁算「改动」。

# 相对目标分支的合并基(merge base)——最常用
BASE=$(git merge-base HEAD origin/main)

# 相对 PR 的目标分支
nx affected -t build --base=origin/main --head=HEAD

# Turborepo 的等价写法
turbo run build --filter='...[origin/main]'
基线选择要点:
  - 用 merge-base 而非分支 tip:分支 tip 会被别人后来的提交污染
  - 合并队列里要用「队列前一个候选」作为基线,而非 main
  - 主分支上的直接提交(hotfix)通常退化为全量,属预期

2.3 传递式失效

依赖图是有向无环图(DAG),改动的影响沿边传递。这带来一个常见现象:改一个底层包,全仓重算。

libs/design-system 被 30 个包依赖
  → 改动它 → 30 个包 + 它们的下游全部失效
  → 一次提交触发几乎全量 CI

缓解手段:
  1. 把高频变动的代码上移为「叶子包」,减少依赖扇出
  2. 对底层包引入版本化发布,下游按版本依赖而非源码依赖
  3. 把底层包拆成更细粒度,避免「一处改动、全体重算」

3. 任务图与流水线编排

3.1 从包图到任务图

包级依赖图只说明「包与包」的关系,任务图还要表达「任务与任务」的依赖。两者叠加才构成可执行的 DAG。

包图:  web → ui → tokens
任务图(每个包展开为 build/test/lint):
  tokens#build → ui#build → web#build
  web#build → web#test → web#e2e
  ^build 语义:先跑所有依赖包的 build,再跑本包 build

3.2 编排引擎的两种形态

形态代表特点
CI 原生 DAGGitHub Actions needs、GitLab needs表达力弱,任务多时 YAML 爆炸
外部编排器Nx、Turborepo、Bazel任务图由工具计算,CI 只负责触发

单仓规模一旦超过 ~15 个包,用 CI 原生 YAML 手写 DAG 会迅速失控:每加一个包就要改 YAML,任务间的依赖要手工维护。这时应该把任务图的职责交给编排器,CI 只做三件事:拉代码、跑编排器命令、上传结果。

# .github/workflows/ci.yml — CI 只做触发与汇总
name: ci
on:
  pull_request:
jobs:
  affected:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # 受影响分析需要完整历史
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - name: Run affected tasks
        run: npx nx affected -t lint test build --base=origin/main --head=HEAD

fetch-depth: 0 是必须的:受影响分析要计算 merge-base,浅克隆(默认 depth=1)会导致基线缺失,退化成全量或直接报错。这一点与 GitHub Actions 单仓实践 的结论一致。

3.3 分级流水线

并非所有任务都值得在 PR 上跑。按「反馈速度」分级能显著降低平均等待。

L1(秒级,PR 必跑):lint、格式检查、类型检查、受影响包的单元测试
L2(分钟级,PR 必跑):受影响包的集成测试、构建产物
L3(十分钟级,合并后或 nightly):全量 E2E、跨包契约测试、性能基准
L4(小时级,定时):全量回归、安全扫描、依赖审计

把 L3/L4 从 PR 路径移走,PR 的 CI 时间会立刻下降一大截,而回归覆盖由主分支与定时任务兜底。

4. 并行度与资源分配

4.1 任务图的并行展开

编排器会把 DAG 中「所有前置已完成」的任务同时启动,受限于 --parallel 或 runner 并发数。

# Nx:限制并行任务数,避免打爆 runner
nx affected -t build --parallel=4

# Turborepo:设置并发度
turbo run build --concurrency=50%
并行的两个天花板:
  1. 图结构:依赖链越长,可并行的宽度越小(关键路径决定下限)
  2. 资源:runner 数量、CPU/内存、外部服务(如数据库)连接数

关键路径(critical path)是并行的下限:无论加多少 runner,一条 tokens → ui → web 的构建链都得串行跑完。压缩关键路径的办法是拆包、减少层数,而不是加机器。

4.2 大仓库的分片

当 affected 集合本身就很大(如发布分支的全量构建),可以把任务集切成多份分片,分发到多台机器。

# GitHub Actions 矩阵分片
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx nx affected -t test --base=origin/main --shard=${{ matrix.shard }}/4
分片粒度选择:
  按包分片   → 天然均衡,但包大小不均时会倾斜
  按任务分片 → 更均衡,但同一包的任务可能跨机器,制品传递变复杂
  动态分片   → 由工具根据历史耗时分配(Nx DTE、Bazel 远程执行)

4.3 资源争用与隔离

单仓里多个任务并行时,最容易踩的是共享资源冲突:

共享资源冲突表现对策
本地端口两个 E2E 抢 3000 端口动态分配端口
测试数据库互相污染数据每任务独立 schema 或容器
构建缓存目录并发写损坏每任务独立 cache 目录
外部 API 配额触发限流用 mock 或共享 fixture

5. 制品在包之间的传递

5.1 为什么不能重复构建

单仓的天然优势是「一次构建、多处复用」:libs/ui 构建一次,所有依赖它的包直接用产物,而不是各自重编译一遍源码。

反模式:每个下游任务都重新编译依赖包的源码
  → 同一个包被编译 N 次,缓存命中率再高也救不回

正模式:依赖包的构建产物作为「输入」传递给下游任务
  → 依赖包命中缓存 → 下游拿到确定性产物 → 下游也可命中缓存

5.2 制品的存放与寻址

# 方式一:工作区内直接共享(同机、同工作区)
# 编排器约定产物目录(dist/),下游从相对路径读取

# 方式二:CI 制品上传下载(跨 job/跨机器)
- uses: actions/upload-artifact@v4
  with:
    name: libs-ui-dist
    path: libs/ui/dist
    retention-days: 1
- uses: actions/download-artifact@v4
  with: { name: libs-ui-dist, path: libs/ui/dist }

# 方式三:包管理器本地链接(pnpm workspace / npm workspaces)
# 下游通过 workspace: 协议引用,无需发布

跨 job 传递制品时,注意 upload-artifact 的默认压缩与保留策略,大产物会拖慢流水线。更细的制品管理与溯源可参考 制品管理与供应链溯源 中的做法。

5.3 缓存与制品的关系

缓存(cache):按输入哈希复用「任务输出」,是优化手段,命中与否不影响正确性
制品(artifact):构建的「正式产物」,要被打包、发布、部署,是交付物

区别:缓存可以随时清空重建,制品必须可追溯、可复现、有版本

6. 合并队列与主干保护

6.1 单仓为什么更需要合并队列

单仓的提交密度高,两个 PR 各自「基于旧 main 试跑绿」,先后合入后极易冲突或语义冲突(比如都改了同一个公共类型)。合并队列(Merge Queue)把待合并的 PR 串成一个序列,每个候选都在「最新 main + 前面已合入的改动」之上重新验证。

合并队列的行为:
  1. PR 进入队列后,用「队列中前一个候选的结果」作为新基线
  2. 在最新基线上重跑受影响分析 + 受影响任务
  3. 通过则合入,失败则弹出并通知作者

单仓收益:主干始终保持「可发布」状态,避免「合完就红」

6.2 受影响分析在队列中的基线

这是最容易配错的地方:

错误做法:队列中的候选仍用 origin/main 作为基线
  → 看不到「前面已合入候选」带来的影响 → 漏跑
正确做法:用「队列中前一个候选的合并结果」作为基线
  → 每个候选都相对真实的上游状态计算影响

GitHub Merge Queue 会为每个候选构造一个临时的合并分支(gh-readonly-queue/...),CI 应基于该分支计算 diff,而不是基于 main。

6.3 主干保护规则

# 分支保护要点
- 要求 PR 通过「必需检查」(required checks)
- 必需检查应覆盖:受影响任务的聚合结果 + 全局门禁
- 禁止直接推送 main(含管理员)
- 要求分支最新(require branches up to date)或启用合并队列

7. 分阶段落地与常见坑

7.1 落地路径

阶段一:建立依赖图
  - 统一包清单格式,确保依赖声明完整
  - 用静态分析补齐隐式依赖,输出可视化图

阶段二:引入受影响分析
  - CI 中改为 nx affected / turbo --filter
  - fetch-depth: 0,正确计算 merge-base

阶段三:分级与编排
  - L1/L2 进 PR,L3/L4 移到主分支与定时
  - 用编排器替代手写 YAML DAG

阶段四:合并队列
  - 启用合并队列,修正基线为「队列前一个候选」

阶段五:度量与收敛
  - 上报命中率、CI 时长、漏跑率

7.2 度量指标

必看指标:
  - CI 平均时长(P50/P90)
  - 受影响任务命中率(affected 集合 / 全量集合)
  - 漏跑率(合并后主分支失败的 PR 占比)
  - 缓存命中率
  - 排队等待时长 vs 执行时长

漏跑率是最关键的质量指标:如果为了提速把该跑的任务跳过了,省下的时间会以「主分支红灯 + 回滚」的形式加倍还回来。

7.3 常见坑

坑现象对策
浅克隆基线缺失,退化为全量fetch-depth: 0
依赖声明不全漏跑下游静态分析补边 + 定期全量兜底
基线用分支 tip受影响集合虚高用 merge-base
队列基线错误队列里漏跑用队列前一个候选作基线
无漏跑监控提速但质量崩监控主分支失败率
并行无上限runner 被打爆--parallel / --concurrency 限流
制品跨机丢失下游找不到产物显式上传下载或统一工作区

7.4 与构建缓存的分工

受影响分析负责「决定跑不跑」,构建缓存负责「跑了也能秒回」。两者是正交的优化:即便受影响集合算得准,如果缓存没配好,跑起来依然慢。缓存键与远程缓存的设计可参考 CI/CD 性能优化与构建缓存 的完整拆解。

小结

单仓 CI 的本质是用依赖图把「全量」收缩成「增量」。做到这一点需要四件事配合:一张可靠的包级依赖图、正确的基线(merge-base 或队列前一个候选)、一个把任务图交给编排器而非手写 YAML 的流水线,以及把长尾任务(E2E、回归、安全扫描)从 PR 路径移走的分级策略。

落地时先建图、再上受影响分析、最后接合并队列,每一步都要盯住「漏跑率」这个质量指标——单仓 CI 的目标不是「最快」,而是「既快又不漏」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「devops」更多文章

  1. 值班工程与告警疲劳治理
  2. 基础设施代码测试:Terratest、Kitchen 与 InSpec
  3. 发布列车与版本节奏治理