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 分
读这张表要抓四点:
- ρ=0.91 时,平均等待 5.4 分钟,P95 端到端 40 分钟:服务时间没变,尾部被排队放大了一倍。
- 8 → 12 个 runner,P95 从 40.0 降到 21.5 分钟,ρ 从 0.91 降到 0.60。多 4 台机器买到的是尾部,不是均值。
- 14 个以后等待为 0:再扩只是闲置。ρ < 0.5 是“容量安全区”,不是“越安全越好”。
- 均值问题靠优化步骤,尾部问题靠加容量:把 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/月,还没算开发者被打断的成本。
处理顺序:
- 隔离:命中 flaky 的测试移出阻断集,进 quarantine job(仍跑、不阻断),标注 owner 和修复期限(建议 7 天)。
- 限次重试:最多 1 次,且只重试失败的分片/用例,不重跑整条流水线。
- 度量:把“重试后通过率”做成看板指标,持续上涨说明隔离没做到位。
- 修根因:时间依赖、共享状态、端口冲突、异步等待——这四类占大多数。
⚠️ 用“重跑到绿”代替修 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 上,回报更高。