52.4 流水线设计与耗时优化

一句话

流水线的墙钟时间 = 关键路径 + 排队时间;关键路径靠 DAG 与门禁顺序压缩,排队时间靠 runner 供给与批量控制。只优化构建步骤、不管利用率 ρ,等于在 91% 利用率的队列里省下 1 分钟。

先破除四个误区

误区 现实
“流水线慢,就优化构建” 构建只是关键路径的一段。8 个 runner、ρ=0.91 时,平均等待 5.4 分钟、P95 端到端 40 分钟——等待比大多数构建步骤都长
“每个 PR 都跑全量 e2e 才安全” 200 个 PR/天 × 10 分钟 = 2000 runner 分钟/天;真正需要全量的只有合并后的结果。门禁位置选错,机时和反馈时间一起亏
“runner 越多越好” ρ < 0.5 后等待接近 0,再加 runner 只增加闲置;先看 ρ,再决定加机器还是优化步骤
“失败重跑就行” flaky 让开发者习惯性重跑,门禁信号失真。500 个测试、单个 flake 率 0.1%,每次流水线有 39.4% 概率随机红——重跑成本比修 flaky 高得多

流水线是一张 DAG,不是一串步骤

lint(1.5) ────┐
              ├──→ build(4) ──→ unit(6) ────┐
secrets(0.2) ─┘                              ├──→ e2e(10) ──→ deploy
                        integration(5) ──────┘

两个时间要分开算:

指标 定义 优化手段
墙钟时间(开发者感知) 从 push 到收到结果的最长路径 + 排队 并行、fail-fast、缓存、分片、扩容
机时(成本) 所有节点的执行时间之和 取消过期构建、只跑受影响、批处理、降频

缓存、命中率曲线、分片参数怎么给,见 52.1 构建与缓存;这里只做 52.1 没覆盖的三件事:排队、门禁位置、失败与抖动。

🔑 先画 DAG,再谈优化。画不出来,说明你不知道哪条是最长路径,也不知道哪些步骤可以并行。

数字:排队时间由 ρ 决定

固定 200 个 PR/天、8 小时、单 PR 平均 17.14 分钟,用 FIFO + runner 池模拟一天(到达率与服务时间的建模方法见 34.5 性能测试与负载模型):

package main

import (
	"fmt"
	"math/rand"
	"sort"
)

// 模型假设(改这几个数就能复算):
//   200 PR/天、8 小时;lint 失败 15%、测试失败 10%、e2e 失败 3%
//   阶段耗时:lint 1.5、build 4、unit 6、e2e 10 分钟
//   “失败”只影响是否继续,不影响已消耗的时间。
func pipelineMinutes(r *rand.Rand, lintFirst bool) float64 {
	const lint, build, unit, e2e = 1.5, 4.0, 6.0, 10.0
	if lintFirst {
		if r.Float64() < 0.15 {
			return lint
		}
		if r.Float64() < 0.10 {
			return lint + build
		}
		if r.Float64() < 0.03 {
			return lint + build + unit
		}
		return lint + build + unit + e2e
	}
	// build 先跑:lint 失败时,4 分钟构建已经白做
	if r.Float64() < 0.15 {
		return build + lint
	}
	if r.Float64() < 0.10 {
		return build + lint + unit
	}
	if r.Float64() < 0.03 {
		return build + lint + unit + e2e
	}
	return build + lint + unit + e2e
}

type stats struct {
	rho, meanWait, p95Lead, meanLead float64
}

// simulate 用 FIFO + 固定 runner 池模拟一天:λ 个/小时,hours 小时。
func simulate(runners int, lambda, hours float64, lintFirst bool, seed int64) stats {
	r := rand.New(rand.NewSource(seed))
	n := int(lambda * hours)
	arrivals := make([]float64, n)
	t := 0.0
	for i := range arrivals {
		t += r.ExpFloat64() / lambda * 60 // 指数到达间隔,单位分钟
		arrivals[i] = t
	}
	free := make([]float64, runners) // 每个 runner 的下一个空闲时刻
	leads := make([]float64, n)
	var waits, busy float64
	for i, a := range arrivals {
		best := 0
		for j := 1; j < runners; j++ {
			if free[j] < free[best] {
				best = j
			}
		}
		start := a
		if free[best] > start {
			start = free[best]
		}
		svc := pipelineMinutes(r, lintFirst)
		free[best] = start + svc
		waits += start - a
		leads[i] = free[best] - a
		busy += svc
	}
	sort.Float64s(leads)
	p95 := leads[int(0.95*float64(n))]
	return stats{
		rho:      busy / (float64(runners) * hours * 60),
		meanWait: waits / float64(n),
		meanLead: sum(leads) / float64(n),
		p95Lead:  p95,
	}
}

func sum(xs []float64) float64 {
	var s float64
	for _, x := range xs {
		s += x
	}
	return s
}

func expectedMinutes(lintFirst bool) float64 {
	const lint, build, unit, e2e = 1.5, 4.0, 6.0, 10.0
	const pLint, pTest = 0.15, 0.10
	pass := 1 - pLint
	if lintFirst {
		return lint + pass*build + pass*(1-pTest)*(unit+e2e)
	}
	return build + lint + pass*unit + pass*(1-pTest)*e2e
}

func main() {
	const lambda, hours = 25.0, 8.0 // 200 PR / 8 小时

	fmt.Println("== 门禁顺序:解析值(200 PR/天)==")
	for _, lintFirst := range []bool{true, false} {
		name := "build 先跑"
		if lintFirst {
			name = "lint 先跑"
		}
		m := expectedMinutes(lintFirst)
		fmt.Printf("%-10s 平均 %.2f 分钟/PR  runner 机时 %4.0f 分钟/天\n", name, m, m*200)
	}

	fmt.Println("\n== 队列:不同 runner 数(lint 先跑,单日 200 PR 样本)==")
	fmt.Printf("%-6s %5s %8s %10s %12s\n", "runner", "ρ", "平均等待", "平均端到端", "P95 端到端")
	for _, runners := range []int{8, 10, 12, 14, 16, 20} {
		st := simulate(runners, lambda, hours, true, 42)
		fmt.Printf("%-6d %5.2f %6.1f 分 %8.1f 分 %10.1f 分\n",
			runners, st.rho, st.meanWait, st.meanLead, st.p95Lead)
	}
}

输出:

== 门禁顺序:解析值(200 PR/天)==
lint 先跑    平均 17.14 分钟/PR  runner 机时 3428 分钟/天
build 先跑   平均 18.25 分钟/PR  runner 机时 3650 分钟/天

== 队列:不同 runner 数(lint 先跑,单日 200 PR 样本)==
runner     ρ     平均等待   平均端到端     P95 端到端
8       0.91    5.4 分     22.8 分       40.0 分
10      0.72    0.8 分     18.1 分       26.1 分
12      0.60    0.2 分     17.6 分       21.5 分
14      0.52    0.0 分     17.4 分       21.5 分
16      0.45    0.0 分     17.4 分       21.5 分
20      0.36    0.0 分     17.4 分       21.5 分

读这张表要抓四点:

  1. ρ=0.91 时,平均等待 5.4 分钟,P95 端到端 40 分钟:服务时间没变,尾部被排队放大了一倍。
  2. 8 → 12 个 runner,P95 从 40.0 降到 21.5 分钟,ρ 从 0.91 降到 0.60。多 4 台机器买到的是尾部,不是均值。
  3. 14 个以后等待为 0:再扩只是闲置。ρ < 0.5 是“容量安全区”,不是“越安全越好”。
  4. 均值问题靠优化步骤,尾部问题靠加容量:把 e2e 从 10 分钟压到 5 分钟,均值降约 3.8 分钟,但 P95 仍会被 ρ=0.91 的排队拉到 30 分钟以上。

用 Little 定律校验:λ=25 PR/小时,平均端到端 17.14 分钟 = 0.286 小时,平均在制 L = λW ≈ 7.1 个;P95 端到端 40 分钟时,瞬时在制约 16.7 个。在制数就是 runner 需求的下界,也是 WIP 限制的依据。

门禁顺序:便宜的先跑

解析值(含失败率假设):

顺序 平均分钟/PR runner 机时/天 差额
lint(1.5) → build(4) → unit(6) → e2e(10) 17.14 3428 —
build(4) → lint(1.5) → unit(6) → e2e(10) 18.25 3650 +1.11 分钟/PR,+222 分钟/天

222 分钟/天 ≈ $1.78/天 ≈ $53/月($0.008/分钟),钱不多;真正贵的是反馈时间:每个 PR 多等 1.11 分钟,200 个 PR 就是 222 分钟/天的人等。

节省的公式很简单:Δ = p_lint × build时间 + p_lint × p_test × ...。先测失败率,再排顺序:

lint 失败率 先跑 lint 省下的分钟/PR
1% 0.04
5% 0.20
15% 1.11
40% 4.00

失败率低于 2% 时,调顺序基本没用——这时该优化的是失败率本身,而不是顺序。

门禁位置:全量 e2e 该放在哪

200 个 PR/天,能走到 e2e 的约 200 × 0.85 × 0.9 = 153 个。四种放法:

方案 PR 反馈(均值) e2e 机时/天 主要代价
每个 PR 全量 e2e 17.1 分钟 153 × 10 = 1530 分钟 反馈慢、机时贵
每 PR 冒烟 2 分钟 + 主分支每 30 分钟全量 ≈ 11.0 分钟 153 × 2 + 16 × 10 = 466 分钟 主分支最多坏 30 分钟;冒烟覆盖有限
合并队列里每个 PR 全量(合并树上跑) 17.1 分钟 + 排队 153 × 10 = 1530 分钟 防“各自绿、合起来红”,但不省机时
只在夜间全量 ≈ 11.0 分钟 约 20 分钟 发现延迟最长 24 小时

三点判断:

  • 默认不要每个 PR 跑全量 e2e。全量 e2e 本质是集成测试,分层理由见 53.3 集成测试。把全量移到主分支定时跑,机时降 70%,PR 反馈从 17 分钟降到 11 分钟;代价是主分支可能短时间带病,需要 55.1 发布策略 里的回滚能力兜底。反馈时间也该写进 SLO,错误预算见 50.7 SLO 与错误预算。
  • 合并队列解决的是另一个问题:两个 PR 各自绿、合起来红。它在合并树上重跑检查,能挡住语义冲突,但机时不变、延迟增加。合并队列与批量测试的机制见 51.1 分支策略。
  • 冒烟覆盖要选“坏了就一定红”的路径:登录、下单、支付各一条,比 50 条随机 e2e 更有用。

取消过期构建:最便宜的一笔优化

开发者连续 push 三次,前两次的流水线结果已经没人要。如果 CI 不取消,这三次都跑完,机时白花。

# GitHub Actions 官方语法,本机未跑过 CI
concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

按 25% 的 run 被新 push 取代估算:3428 × 25% = 857 分钟/天,约 $206/月,而且释放的 runner 直接降低 ρ。这是投入最小、收益最确定的一项,优先级高于任何构建优化。

注意两个边界:

  • 发布/部署流水线不要用 cancel-in-progress,否则可能取消正在变更线上的那一次。
  • 取消要能清理中间资源(锁、临时环境),否则只是把泄漏留给下一次。

抖动:39.4% 的随机红

500 个测试,每个 flake 率 0.1%,一次流水线至少一个失败的概率:

1 − (1 − 0.001)^500 ≈ 39.4%

若每个测试 flake 率降到 0.01%,概率是 4.9%。这不是线性下降,是指数级的——flaky 是乘法,不是加法。

代价按“整条流水线重跑”算:39.4% × 17.14 ≈ 6.75 分钟/PR,200 × 6.75 ≈ 1350 分钟/天,约 $324/月,还没算开发者被打断的成本。

处理顺序:

  1. 隔离:命中 flaky 的测试移出阻断集,进 quarantine job(仍跑、不阻断),标注 owner 和修复期限(建议 7 天)。
  2. 限次重试:最多 1 次,且只重试失败的分片/用例,不重跑整条流水线。
  3. 度量:把“重试后通过率”做成看板指标,持续上涨说明隔离没做到位。
  4. 修根因:时间依赖、共享状态、端口冲突、异步等待——这四类占大多数。

⚠️ 用“重跑到绿”代替修 flaky,等于把门禁变成一个概率过滤器;半年后没人相信红色,门禁就失效了。

生产落地:指名 → 可抄 → 可验 → 可退

指名:GitHub Actions 或 GitLab CI(托管 runner),自建场景用 Jenkins + Kubernetes Agent;合并队列用 GitHub merge queue / GitLab merge trains。runner 池按“ρ ≤ 0.7”配置,托管 runner 按分钟计费,自建 runner 按容量计费。流水线到环境的最后一公里(部署编排、配置注入)见 54.3 部署编排。

可抄(GitHub Actions 骨架,官方语法,未在本机跑过 CI):

name: ci
on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.27.1', cache: true }
      - run: go vet ./...
      - run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0
      - run: golangci-lint run --timeout=5m ./...

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.27.1', cache: true }
      - run: go test -race ./...

  smoke:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/smoke.sh   # 登录 + 下单 + 支付三条主链路

可验(本机验证的是模型与门禁逻辑,不是 CI 平台):

$ go run .        # 52.4 的模拟器
lint 先跑    平均 17.14 分钟/PR  runner 机时 3428 分钟/天
8       0.91    5.4 分     22.8 分       40.0 分
12      0.60    0.2 分     17.6 分       21.5 分

上线后验三件事:① ρ = 已用 runner 分钟 / 可用 runner 分钟 是否 ≤ 0.7;② PR 端到端 P95 是否接近关键路径(差距大 = 排队);③ 重试率是否 < 5%。

可退:

  • 合并队列/批量测试导致积压:先关批量,回到“每 PR 冒烟”,不要直接关掉 e2e。
  • 扩容成本超预算:保留 ρ ≤ 0.8 的最小池,把长尾任务(夜间全量、镜像扫描)挪到低峰。
  • 新门禁误杀:降级为 report-only 一个迭代,保留 canary 用例,修好再恢复阻断。

参数怎么给

你的情况 优先做
流水线 > 20 分钟 画 DAG,找最长路径;先并行、再缓存(52.1 构建与缓存)、最后扩容
P95 远大于均值 看 ρ;> 0.8 先加 runner,不要先优化步骤
均值高、P95 正常 优化关键路径上的步骤(构建、测试分片)
每个 PR 跑全量 e2e 改冒烟 + 主分支定时全量,机时通常降 60–70%
每天都在重跑 先隔离 flaky,再谈扩容;重试率是门禁健康度指标
合并冲突频繁 上合并队列(51.1 分支策略),它解决语义冲突,不解决速度
流水线 < 10 分钟 不要优化,收益小于维护成本
发布 < 1 次/周 优先缩短发布周期,流水线不是瓶颈
要证明改进有效 用 59.1 DORA 四指标的变更前置时间与部署频率,不要只看流水线时长

反模式

反模式 后果
只盯构建步骤,不看排队 优化 1 分钟,ρ=0.9 的队列再放大 10 倍还回去
每个 PR 跑全量 e2e 机时和反馈时间双输,开发者开始绕过 CI
无脑加 runner ρ < 0.5 后只增加闲置成本
不取消过期构建 可达 25% 的机时花在没人看的结果上
flaky 靠重试 门禁变成概率过滤器,红色失去意义
所有检查串行 关键路径 = 总耗时,白扔并行能力
门禁放在长构建之后 15% 的失败要等 10 分钟才知道
一个巨型 job 干所有事 无法并行、无法重试单步、日志不可读
合并队列当加速器 它增加延迟,只买“合并后仍然绿”的确定性
没有 canary 的门禁 路径错/规则没加载时静默全绿(见 52.2 静态检查)

自检

Q1 流水线均值 17 分钟、P95 40 分钟,团队把构建从 4 分钟优化到 2 分钟,P95 只降到 38 分钟。为什么?

▸ 答案:因为 P95 的主要成分不是服务时间,是排队。8 个 runner、ρ=0.91 时,平均等待 5.4 分钟、尾部等待接近 19 分钟;构建省 2 分钟只作用于 85% 的 run,均值降约 1.7 分钟,尾部几乎没有变化。要压 P95,先降 ρ(加 runner 或减少并发提交),或把长尾任务(全量 e2e)移出 PR 路径;只优化关键路径上的短步骤,收益会被队列放大后仍显不足。

Q2 两个 PR 各自 CI 全绿,合并后主干红了。该加什么?

▸ 答案:加合并队列(GitHub merge queue / GitLab merge trains),不是加 e2e。合并队列把 PR 放到合并树上重跑检查,能发现“各自绿、合起来红”的语义冲突。代价是延迟增加、机时增加(多跑一轮检查)。注意它解决的是正确性问题,不是速度问题;如果 PR 反馈本来就慢,先按 Q1 处理排队,再上合并队列。

Q3 500 个测试、单个 flake 率 0.1%,为什么说“重跑一下”不是解法?

▸ 答案:因为概率是乘法:1 − 0.999^500 ≈ 39.4%,也就是说近四成的流水线会随机红。重跑能让这次变绿,但下一次仍有 39.4% 概率红;团队会逐渐把红色当噪声,真正的问题被忽略。按整条流水线重跑算,成本约 1350 分钟/天。正确顺序是隔离(不阻断)+ 限次重试(只重试失败分片)+ 度量重试率 + 修根因;把 flake 率降到 0.01%,随机红概率降到 4.9%。

Q4 为什么“取消过期构建”的优先级高于构建缓存优化?

▸ 答案:因为它的收益确定、实现成本几乎为零,而且同时降低机时和 ρ。25% 的 run 被新 push 取代时,直接省 857 分钟/天,并释放 runner 给仍在排队的任务;缓存优化则依赖命中率、key 设计和正确性维护(见 52.1 构建与缓存),投入大且有缓存投毒/失效风险。顺序应该是:取消过期 → 并行/fail-fast → 缓存 → 扩容 → 深度优化。发布流水线例外,不能取消进行中的部署。

Q5 团队流水线 8 分钟,想投入两周做“极致优化”。你的判断?

▸ 答案:先算账再拒绝。8 分钟的流水线,即使优化到 4 分钟,每个 PR 省 4 分钟;如果每天 20 个 PR,就是 80 分钟/天,约 1.3 人时/天。两周工程投入换每天 1.3 人时,回收期以月计,而且 8 分钟通常不是瓶颈——更可能卡在评审(51.2 代码评审)或发布频率。流水线 < 10 分钟时,优化收益小于维护成本;把这两周用在缩短发布周期或降低 flaky 上,回报更高。

关联