CI 流水线并行策略合理拆分 Stage 让构建时间减半一、CI 跑 25 分钟其中 15 分钟在排队等很多团队的 CI 瓶颈不在单个 job 慢而在 job 之间的串行等待。典型的流水线Lint → Test → Build → Deploy每个阶段必须等上一个完成。但实际上Lint 和 Test 完全可以并行——它们不共享状态不依赖对方的产物。Build 和 Deploy 之间也可以有重叠——Build 的多平台镜像可以并行构建。实际数据一个 25 分钟的串行流水线Lint 占 3 分钟、Test 占 8 分钟、Build 占 10 分钟、Deploy 占 4 分钟。如果把 Lint 和 Test 并行时间从 11 分钟降到 8 分钟多平台 Build 并行x86 ARM时间从 10 分钟降到 5 分钟。总时间从 25 分钟降到 17 分钟——减少 32%。并行化的核心是正确识别 Job 之间的依赖关系把 DAG 中的独立节点并行执行。这不是简单的把所有 job 并行跑——有些 job 之间确实有依赖Test 必须在 Build 之后不——Test 不需要 Build 的产物它需要源代码。flowchart TD subgraph Stage1[阶段1: 并行 0-5min] A1[Lint] A2[Type Check] A3[安全扫描] end subgraph Stage2[阶段2: 并行 5-12min] B1[单元测试] B2[集成测试 - 数据库] B3[集成测试 - 缓存] end subgraph Stage3[阶段3: 并行 12-20min] C1[Build Docker x86] C2[Build Docker ARM] end subgraph Stage4[阶段4: 串行 20-25min] D1[Push to Registry] D2[Deploy to Staging] end A1 A2 A3 -- B1 B2 B3 B1 B2 B3 -- C1 C2 C1 C2 -- D1 D1 -- D2二、并行化的三个层次层次一Job 级并行同一个 Stage 内的多个 Job 并行执行。GitHub Actions 的jobs.job_id.needs字段就是用来描述 DAG 的。关键理解needs定义的是前置依赖而非执行顺序。没有needs的 job 自然并行——不需要额外配置。常见错误把 Test 设置为needs: lint认为Lint 先跑完再跑 Test 更合理。但实际上 Lint 的结果对 Test 没有任何影响——Test 不依赖 Lint 的输出。这种伪依赖白白浪费了并行时间。层次二Matrix 并行同一个 Job 的不同参数组合并行执行。比如同一个测试集在 Node 18/20/22 三个版本上跑——用strategy.matrix解决。Matrix 并行的收益兼容性验证从串行变成并行3 个版本从 24 分钟降到 8 分钟。但 Matrix 有一个隐性成本每个组合都是一个独立的 Job消耗独立 Runner。3 个 Node 版本 × 4 个 shard 12 个并行 Job需要 12 个 Runner。私有仓库的免费额度是 2000 分钟/月12 个 Job 每个 8 分钟 96 分钟/次一个月跑 20 次 1920 分钟——接近额度上限。层次三Shard 并行将大的测试集切分到多个 Runner 上并行执行。Jest 的--shard参数、Playwright 的--shard都支持。Shard 并行解决的是单次测试运行时间太长的问题——1000 个 E2E 测试跑 60 分钟切 8 个 shard 就降到约 8 分钟。Shard 的关键细节分 shard 后覆盖率报告需要合并。每个 shard 生成独立的覆盖率数据最终需要一个合并 job 把所有 shard 的数据汇总。这个合并 job 依赖所有 shard 完成——是 DAG 中的一个汇聚节点。三、生产级 CI 并行配置# .github/workflows/ci-parallel.yml name: CI Pipeline (Parallel) on: pull_request: branches: [main] push: branches: [main] # concurrency: 防止同一 PR 的多个 push 重复排队 # cancel-in-progress 取消旧的运行节省 Runner 资源 concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: NODE_VERSION: 20 jobs: # # 阶段一所有不依赖构建产物的检查全部并行 # lint: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci - run: npx eslint . --max-warnings 0 typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci - run: npx tsc --noEmit # # 阶段二测试——按 Node 版本矩阵 Test Shard 并行 # test: needs: [lint, typecheck] runs-on: ubuntu-latest strategy: fail-fast: false # 一个 shard 失败不取消其他 shard matrix: node-version: [18, 20, 22] shard: [1, 2, 3, 4] # 分 4 个 shard # 总 Job 数 3 × 4 12 个并行 steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - name: Run tests (shard ${{ matrix.shard }}) run: | npx jest \ --shard${{ matrix.shard }}/${{ strategy.job-total }} \ --maxWorkers2 \ --coverage # 上传覆盖率——各 shard 独立上传后续合并 - name: Upload coverage uses: actions/upload-artifactv4 with: name: coverage-node-${{ matrix.node-version }}-shard-${{ matrix.shard }} path: coverage/ # # 阶段三E2E 测试——最大瓶颈需要分 shard # e2e: needs: [lint, typecheck] runs-on: ubuntu-latest strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] # 8 个 shard 并行 services: postgres: image: postgres:16-alpine env: POSTGRES_PASSWORD: test options: - --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci - name: Run E2E tests run: | npx playwright test \ --shard${{ matrix.shard }}/${{ strategy.job-total }} \ --reporterblob - name: Upload E2E report if: always() uses: actions/upload-artifactv4 with: name: e2e-report-shard-${{ matrix.shard }} path: blob-report/ # # 阶段四构建——多平台并行 # build: needs: [test, e2e] runs-on: ubuntu-latest strategy: matrix: platform: - linux/amd64 - linux/arm64 steps: - uses: actions/checkoutv4 - uses: docker/setup-buildx-actionv3 - name: Build and push uses: docker/build-push-actionv5 with: context: . platforms: ${{ matrix.platform }} push: false cache-from: typegha cache-to: typegha,modemax # # 阶段五合并报告——等待所有 shard 完成后执行 # merge-reports: needs: [test, e2e] if: always() runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Download coverage artifacts uses: actions/download-artifactv4 with: pattern: coverage-* merge-multiple: true path: coverage/ - name: Merge coverage reports run: | npx nyc merge coverage/ coverage/merged.json npx nyc report --reporterlcov --reportertext # # 阶段六部署——仅 main 分支执行 # deploy: needs: [build, merge-reports] if: github.ref refs/heads/main runs-on: ubuntu-latest environment: staging steps: - name: Deploy run: echo Deploying to staging...四、并行化的隐性成本Runner 成本12 个 Job 并行需要 12 个 Runner。GitHub Actions 的免费额度公开仓库无限私有仓库每月 2000 分钟。如果私有仓库且 Job 数量大要注意额度消耗。计算方法每次 CI 运行的分钟消耗 Σ(每个 Job 的运行时间 × Runner 数)。3 版本 × 4 shard × 8 分钟 96 分钟/次。自建 Runner 是控制成本的有效手段。用 K8s 部署 self-hosted Runner成本从 GitHub 的 $0.008/分钟降到云实例的实际成本约 $0.001/分钟5 倍差距。缓存竞争多个 Job 同时写缓存如actions/cache可能导致竞争条件。具体表现两个 Job 同时写同一个 cache key后写的覆盖前写的数据导致后续 Job 读到不完整的缓存。GitHub 的actions/cache有内置并发控制写者胜但 Docker BuildKit 的cache-to: typegha更安全——它使用 content-addressable 存储天然避免竞争。测试片间依赖E2E 测试分 shard 后如果一个 shard 用了测试数据被另一个 shard 修改结果不可靠。例如 shard 1 创建了一个用户shard 3 也在测试同一条用户数据——两者互相干扰。解决方案每个 shard 独立数据库实例service containers或数据库 template 克隆。更根本的方案是让每个 shard 的测试数据完全独立——不同用户 ID、不同订单号避免任何交叉。Trade-off 讨论并行度 vs 成本更多 shard 更快完成 更多 Runner 消耗。找到一个拐点4-8 shard 通常是最优范围。超过 8 shard 后边际收益递减启动开销、数据准备时间开始占主导。fail-fast vs 完整性fail-fast: true在第一个失败时取消所有并行 Job节省 Runner 时间但丢失完整结果。fail-fast: false跑完所有 shard消耗更多时间但能看到所有失败的 shard。推荐开发阶段 fail-fast: true正式 CI fail-fast: false。覆盖率合并 vs 精度合并多个 shard 的覆盖率报告可能产生误差路径解析、文件映射差异。如果精度要求高合并后需要做一致性校验。五、总结CI 并行的核心不是拆分方便而是正确建模 Job 之间的依赖 DAG。依赖图对并行自然对。矩阵并行解决多版本兼容性问题Shard 并行解决单测试运行时间长的问题两者叠加使用效果最好。最终的收益构建时间从 25 分钟压缩到 8-10 分钟PR 反馈循环缩短 60%。但要注意隐性成本——Runner 消耗、缓存竞争、测试数据依赖——并行不是免费的午餐。