一、技术难点
Day37 讲的是装配边界 ------六个应用各自发布,怎么保证它们拼在一起不崩。这一篇往前走一步,讲从 git push 到产物进 CDN 这一段路本身。
大多数人把流水线理解成一份 YAML:几个 job、几个 run:、串成一条竖线。但流水线真正的职责只有一个:
把一组输入(源码 + 依赖 + 工具链 + 环境)映射成一组产物,并让这个映射可重复、可验证、可解释。
一旦你接受这个定义,绝大部分流水线问题都会变成同一个问题:这个映射依赖了哪些输入?我有没有把它们全部写进键里、写进校验里? 缓存键漏了输入 → 静默错产物;验证漏了 base → 结论作废;产物漏了确定性 → 回滚变成赌博。
所以我不用任何第三方依赖,从零写了一条最小流水线内核(都在 day38-assets/):pipeline.mjs(缓存键 / 任务图 / 分片 / 门禁 / 合并队列 / flaky / 可复现构建,350 行)、bench.mjs(6 组实验)、test-day38.mjs(46 条断言全绿 ),配 bench-log.txt。环境:Node v22.23.3 / darwin-arm64。
先说清口径:计数类结论是确定性的 ------种子化 PRNG,重跑逐字节一致,我用 diff 验过两轮输出完全相同;所有"期望成本/墙上时间"都是模型计算值 ,其中门禁的失败率 p、推测验证的命中率 λ=0.85 是显式假设 ,不是实测,结论针对的是公式与排序规则,不是这几个具体毫秒数。
| 流传的说法 | 实测结果 | 差在哪 |
|---|---|---|
| 「缓存命中率 97%,缓存配好了」 | 自身内容键命中率 97.13%,其中 796 次是假命中(产物与真值不符) | 命中率把"命中了错的"和"命中了对的"算成同一个数 |
| 「整仓哈希最保险」 | 命中率 0.00%(7320 次任务全部重建) | 正确但无用:一次提交让所有任务失效 |
| 「闭包键太复杂,不值得」 | 命中率 96.41%、假命中 0 、平均每轮只重建 2.38 个任务(最多 12 个) | 复杂一次,之后每条提交都省 |
| 「分片按文件数均分就行」 | 400 文件 / 4 片:轮转最慢片 216,361ms vs LPT 178,553ms(1.21x) | 测试耗时是幂律分布,按个数分 ≠ 按时间分 |
| 「分片加机器就快了」 | 单个用例占 55% 时 4 片最慢片 = 总时长 0.36x ,加到 8 片还是 0.36x | 分片有下界 max(理想, 最慢用例),天花板由最慢用例决定 |
| 「门禁按便宜到贵排」 | 便宜优先期望 165,675ms,昂贵优先 441,129ms(2.66x);但真正的判据是 c/p 升序 | 「便宜」≠「便宜且拦得住」,要除以失败率 |
| 「c/p 排序是拍脑袋的经验」 | 暴力枚举全部 120 种 排列:c/p 升序的期望成本与最优完全相等 | 它是精确最优解,不是启发式 |
| 「全并行最快」 | 墙上 300,000ms,但每轮烧 483,000ms 算力;串行 165,675ms 更省 | 并行买的是墙上时间,付的是算力和"失去提前失败" |
| 「并行验证 12 个 PR 快多了」 | 墙上 60min (和串行一模一样),验证次数 78(+550% 浪费) | 合并会改变 base,并行验证的结论全部作废 |
| 「批量合并会让发布变慢」 | 批量 4 个:15min / 3 次验证(4x );叠加推测:6.5min(9.2x) | 关键是让 base 只变一次,而不是让任务并行 |
| 「flaky 就加重试」 | 重试 ≤3:假红 6.00%→0.00%,但间歇性 bug 检出 40.45%→6.90% | 重试把误报换成了漏报,而漏报不会报警 |
| 「隔离就等于放弃这些用例」 | 隔离后假红 0.00% ,检出率仍是 40.45% | 隔离不是丢弃,是把"阻断"和"归因"拆成两条路 |
| 「产物不需要逐字节一致吧」 | 8 次重复构建:并行完成顺序波动 → 产物哈希 8/8 全不同;时间戳同理 | 回滚时你无法确认"这个产物就是上次那个" |
1.1 难点一:缓存键不封闭,命中率就是个假指标
先定义一个不可再简化的真值。一个任务的产物只由它的输入闭包决定:
js
// 真产物:只由"源码闭包 + 工具链"决定。这是唯一可信的定义。
const ARTIFACT_TOKEN = '9f2a-toolchain';
export const sha = (s, n = 12) =>
createHash('sha1').update(String(s)).digest('hex').slice(0, n);
export function closure(files, i) { // 传递依赖闭包
const seen = new Set();
const stack = [i];
while (stack.length) {
const c = stack.pop();
if (seen.has(c)) continue;
seen.add(c);
for (const d of files[c].imports) if (!seen.has(d)) stack.push(d);
}
return seen;
}
export function trueArtifact(files, i) {
const cl = [...closure(files, i)].sort((a, b) => a - b);
return sha(ARTIFACT_TOKEN + '|' + cl.map((c) => files[c].content).join('|'), 16);
}
现在换三种缓存键去"猜"这个真值:
js
const keyOwn = (files, i, lock) => sha('own|' + files[i].content + '|' + lock); // 只看自己
const keyRepo = (files, i, lock) => sha('repo|' + i + '|' + files.map(f => f.content).join('') + '|' + lock); // 整仓
const keyClosure = (files, i) => sha('closure|' + trueArtifact(files, i)); // 内容寻址
跑 120 个任务 × 61 轮(1 轮初始 + 60 次提交,每次改 1~2 个文件),共 7320 次任务:
yaml
E1 缓存键封闭性(120 任务 × 61 轮 = 7320 次任务)
P1: 命中 7110 未命中 210 命中率 97.13% **假命中 796**
P2: 命中 0 未命中 7320 命中率 0.00% **假命中 0**
P3: 命中 7057 未命中 263 命中率 96.41% **假命中 0**
P3 每轮重建:平均 2.38 / 最多 12(共 120 个任务)
最高枢纽文件被 12 个任务的闭包依赖
三个结论,每一个都反直觉:
第一,命中率最高的那个是错的。 P1 命中率 97.13%,看着完美;但它 7110 次命中里有 796 次产物是错的 ------因为 a.ts 改了,b.ts 引入了 a.ts,可 P1 的键只看 b.ts 自己的内容,于是 b.ts 的产物原样复用了一份基于旧 a.ts 的结果。
这是流水线上最坏的失败模式:它不报错。CI 绿了、产物传上去了、监控没异常------错误被编码进 CDN 的某个 chunk 里,等到有人在生产环境打开那个页面才发作,而那时候你已经改了几十行代码。
第二,命中率最低的那个是对的,但毫无用处。 P2 把整仓内容写进键,理论上绝对安全------一次提交改一个标点,这 120 个任务的键全变,命中率 0.00%(7320 次全部重建)。它的正确性来自"什么都不复用"。
第三,"正确命中数"才是真指标,而它恰好把两个错答案都排除了。 算一下:P1 的正确命中 = 7110 − 796 = 6314 ;P3 的正确命中 = 7057 。P3 多出了 743 次正确命中(+11.8%) ,同时把假命中压到 0。
注意这里的口径差:P1 只跑了 210 次构建,P3 跑了 263 次------P1 少跑的那 53 次构建,正是它复用错产物的次数来源。所以"我的缓存省了更多构建"这句话本身没有意义,必须和"错了多少次"一起看。
1.2 难点二:流水线是 DAG,不是脚本清单
上面 P3 的数字里藏着一个更重要的结论:平均每轮只重建 2.38 个任务,最多 12 个(共 120 个) 。一次提交改 1~2 个文件,只有它们的下游闭包需要重跑,其它 117 个左右全部命中。
这就是"任务图"和"脚本清单"的区别。绝大多数人的流水线是这样的:
yaml
# 脚本清单:没有依赖概念,只是"依次执行"
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
- run: npm run e2e
npm test 不知道 build 依赖 typecheck 的产物,build 也不知道哪些输入变了。它唯一能做的判断是"整条路重跑一遍",或者用最多一层的文件指纹糊一下。要拿到 2.38 这个数量级,你必须显式持有三样东西:
- 节点 :每个任务是一个
(命令, 输入集, 输出集); - 边 :任务之间有依赖,且依赖要能推导(import 图 / workspace 依赖 / 声明的
dependsOn); - 脏集传播 :改动集合沿反向边扩散,得到本轮要跑的最小闭包。
而这三样东西的前提,又是每个节点的输入集必须写全------也就是 1.1 那个结论。所以顺序不能反:先证明键封闭,任务图才有意义;键不封闭的增量构建,只是"更快地产出错误的产物"。
1.3 难点三:分片的目标是摊平,而且分片有天花板
测试分片最常见的实现是按文件取模:
js
durations.forEach((_, i) => shards[i % n].push(i)); // 轮转
400 个测试文件、4 个分片,用幂律分布生成耗时(少数慢文件吃掉大部分时间,这是真实测试集的形态):
ini
E2a 分片:均匀幂律(400 文件 / 4 片);理想每片 178,553ms,最慢单文件 13,856ms
roundrobin : 每片 [152,378ms, 185,421ms, 160,051ms, 216,361ms] 最慢 216,361ms 偏离理想 1.21x
contiguous : 每片 [158,535ms, 182,444ms, 219,429ms, 153,803ms] 最慢 219,429ms 偏离理想 1.23x
lpt : 每片 [178,553ms, 178,553ms, 178,553ms, 178,552ms] 最慢 178,553ms 偏离理想 1.00x
关键不是"均值",是"最慢的那个分片" ------流水线的墙上时间永远是 max(shard),不是 avg(shard)。轮转分片的均值和 LPT 一样,但最慢片多出 21%(216,361 vs 178,553),而这 21% 就是每个 PR 白等的 38 秒。
LPT(Longest Processing Time first)的正确写法是:按耗时降序 排序,每个任务丢给当前负载最小的分片。
js
export function lptShard(durations, shards) {
const bins = Array.from({ length: shards }, () => []);
const loads = new Array(shards).fill(0);
[...durations.keys()]
.sort((a, b) => durations[b] - durations[a]) // 降序:慢的先安排
.forEach((i) => {
let m = 0;
for (let s = 1; s < shards; s++) if (loads[s] < loads[m]) m = s; // 最空的那片
bins[m].push(i);
loads[m] += durations[i];
});
return { bins, loads };
}
在均匀幂律下 LPT 直接把偏离打到 1.00x ([178,553, 178,553, 178,553, 178,552],四个片几乎逐毫秒对齐)。
但真正的教训在下半张表。给 60 个用例里塞一个跑 55% 总时长的巨慢用例(e2e 里太常见了:一个用例串起完整的支付流程):
matlab
E2b 分片:单个用例占 55% 总耗时(60 文件 / 4 片);理想每片 34,794ms,最慢单文件 49,495ms
roundrobin : 每片 [69,417ms, 22,294ms, 27,119ms, 20,345ms] 最慢 69,417ms 偏离理想 2.00x
lpt : 每片 [49,495ms, 29,893ms, 29,895ms, 29,892ms] 最慢 49,495ms 偏离理想 1.42x
LPT 下界 max(理想, 最慢单文件) = 49,495ms
慢用例占 55% 时:4 片最优 0.36x 总时长,8 片最优 0.36x
→ 分片数翻倍,墙上时间几乎不变:瓶颈是那一个用例,不是分片
LPT 依然是最优的(距下界 1.000x ,它已经顶到下界了),但偏离理想仍有 1.42x ------因为下界本身变了:
任何分片方案的下界都是
max(总时长/片数, 最慢单个用例)。
当最慢用例占总时长 55%,理想 = 25% 已经低于 最慢用例 = 55%,于是加机器完全无效 :4 片的墙上时间是总时长的 0.36x,8 片还是 0.36x 。很多人在这里的第一反应是"再申请 8 个 runner",而正确动作是拆那个用例(把一条 10 分钟的端到端流程拆成 3 条并行的子流程),或者把它挪出阻塞级门禁。
分片只能把"理想"逼近到"下界",永远不能突破下界。这是分片的天花板,也是"加机器为什么没效果"的唯一解释。
1.4 难点四:门禁顺序不是经验,是 c/p 升序的精确解
五道门禁,成本与失败率如下(costMs 是观测值,failP 是假设,第 2.4 节讲怎么把它们测出来):
| 门禁 | costMs | failP | c/p |
|---|---|---|---|
| lint | 8,000 | 0.30 | 27s |
| typecheck | 25,000 | 0.25 | 100s |
| unit | 60,000 | 0.40 | 150s |
| build | 90,000 | 0.15 | 600s |
| e2e | 300,000 | 0.10 | 3000s |
串行执行且失败即停的期望成本是:
text
E = Σ_k c_k · Π_{j<k} (1 - p_j)
直觉:第 k 道门禁只有在"前面全部通过"的概率下才会被执行,
所以它的成本要被 Π_{j<k}(1-p_j) 打折。越靠后,打折越狠。
展开成代码就三行:
js
export function expectedSequential(order) {
let acc = 0, survive = 1;
for (const g of order) {
acc += survive * g.costMs; // 只有活着走到这里的概率才付这笔钱
survive *= 1 - g.failP;
}
return { expectMs: acc, fullRunP: survive };
}
结果:
less
E3 门禁顺序的期望成本(5 道门禁,失败即停)
便宜优先: 期望 165,675ms,全绿跑到底概率 24.10%
昂贵优先: 期望 441,129ms
全并行 : 单轮墙上时间 300,000ms,但每轮烧算力 483,000ms
便宜优先 vs 昂贵优先 = 2.66x 差
c/p 升序 : 期望 165,675ms,顺序 [lint → typecheck → unit → build → e2e]
暴力枚举 120 种排列,最优 165,675ms [lint → typecheck → unit → build → e2e]
→ c/p 排序 命中暴力最优
先说一个容易被忽略的量:全绿跑到底的概率只有 24.10% 。也就是说 3/4 的 CI 轮次根本跑不到 e2e 就失败了。这意味着 e2e 那 300 秒在大多数轮次里是白花的------前提是它被排在最后。要是排在前面,那 300 秒就变成了每一轮都要付的过路费。
现在做那个真正重要的推导。两道门禁 i、j,比较"i 在前"与"j 在前":
css
i 在前:c_i + (1-p_i)·c_j
j 在前:c_j + (1-p_j)·c_i
i 在前更好 ⟺ c_i + c_j - p_i·c_j < c_j + c_i - p_j·c_i
⟺ p_i·c_j > p_j·c_i
⟺ c_i / p_i < c_j / p_j
所以最优顺序就是按 c/p 升序排 。这不是经验法则,可以暴力验证------5 道门禁共 5! = 120 种排列:
js
export function* permutations(arr) {
if (arr.length <= 1) { yield arr; return; }
for (let i = 0; i < arr.length; i++) {
const rest = [...arr.slice(0, i), ...arr.slice(i + 1)];
for (const p of permutations(rest)) yield [arr[i], ...p];
}
}
css
暴力枚举 120 种排列,最优 165,675ms [lint → typecheck → unit → build → e2e]
→ c/p 排序命中暴力最优
c/p 升序的期望成本与暴力最优数值完全相等、顺序完全一致 。这条规则带来一个很锋利的推论:p ≈ 0 的门禁会被推到队尾 ------因为 c/p → ∞。而这是对的:一道几乎从不失败的门禁,要么是它拦住了本该挡住的东西(那就把 failP 测准),要么是它在浪费所有人的时间(那就把它从阻塞路径上摘掉)。
再看"全并行"这个选项:
makefile
全并行: 单轮墙上时间 300,000ms,但每轮烧算力 483,000ms
它墙上时间 5 分钟,确实比串行最坏情况(15 分钟)快。但注意两个数:它每轮烧掉 483,000ms 的算力 (Σ cost,因为没有任何一道门禁能提前中止别人),而串行的期望成本只有 165,675ms。全并行用 2.9 倍的算力,买了一个在 75% 轮次里都不会兑现的墙上时间优势------因为那些轮次里 lint 就已经红了。
正确的做法不是二选一,而是分层:见 2.4。
1.5 难点五:合并队列------并发与"基于正确 base 验证"互斥
这是流水线上最贵的一个错误,而且它以"优化"的面目出现。
12 个 PR 同时待合,单次验证 300,000ms(5 分钟)。你想加速,于是 12 个并行验证:
yaml
E4 合并队列(12 个 PR,单次验证 300,000ms,批量 4)
serial : 墙上时间 3,600,000ms,验证次数 12.00
cascade : 墙上时间 3,600,000ms,验证次数 78.00
batch : 墙上时间 900,000ms,验证次数 3.00
speculative: 墙上时间 390,000ms,验证次数 3.45
级联重验浪费 = 550% 的验证额度
看 cascade(朴素并行)这两列:墙上时间 60 分钟,和完全串行一模一样 ;验证次数 78 次,是串行的 6.5 倍。
为什么?因为每个 PR 的"验证通过"都是在某个特定 base 上成立的事实。合并第 1 个 PR 之后,base 变了,其余 11 个的验证结论在逻辑上全部作废 ;依此类推,12 + 11 + ... + 1 = 78。并行赚到的等待时间,被级联重验全部吐了回去,还倒贴 550% 的算力。
所以合并队列的核心不变量只有一句:
一次合并的产物,必须在它真正合入的那个 base 上被验证过。
满足这个不变量有两条路,都不是"并行":
路一:让 base 只变一次(批量原子合并)。 把 K 个 PR 一起验证、一起合入,PR 数 / K 次 base 变更:
batch(12 个 PR,批量 4): 15 分钟,3 次验证
从 60 分钟降到 15 分钟(4x ),验证次数从 78 降到 3。
路二:预测下一个 base(推测验证)。 在第 1 批验证的同时,直接在"第 1 批一定成功"的假设上把第 2 批跑起来。命中则零等待,未命中就浪费一次并补跑:
js
export function mergeQueue({ prs = 12, validateMs = 300_000, batchSize = 4, specHitP = 0.85 }) {
const batches = Math.ceil(prs / batchSize);
const specWall = validateMs + (batches - 1) * validateMs * (1 - specHitP);
const specValidations = batches * (1 + (1 - specHitP));
return { specWall, specValidations };
}
λ = 0.85 是假设 (实测命中率取决于主干变更频率)。在 λ=0.85、3 批的情况下:墙上 6.5 分钟 (对比串行 60 分钟,9.2x ),验证 3.45 次。
1.6 难点六:flaky 重试把误报换成了漏报
flaky 用例(同一份代码时而过时而挂)的默认解法是重试到通过。它确实能压掉假红------但它同时在偷走另一样东西。
建模:3 个 flaky 用例,每次尝试有 2% 概率假失败;另有 1 个间歇性真 bug,每次尝试有 40% 概率被"这一轮"抓到(竞态、超时、内存泄漏这类 bug 天然是间歇性的)。策略"任一次通过就算通过":
erlang
E5 flaky 策略(2000 轮,3 个 flaky,假失败 2.00%,单轮检出率 40.00%)
不重试(n=1) : flaky 假红 6.00%,间歇性真 bug 检出 40.45%
重试到通过(≤2) : flaky 假红 0.10%,间歇性真 bug 检出 16.05%
重试到通过(≤3) : flaky 假红 0.00%,间歇性真 bug 检出 6.90%
隔离出主流水线(n=1) : flaky 假红 0.00%,间歇性真 bug 检出 40.45%
假红从 6.00% 干净地降到 0.00%------这就是为什么所有人都觉得重试很香。但看第二列:检出率从 40.45% 一路掉到 6.90% ,掉了 5.9 倍。
原因很朴素:真 bug 要被抓住,现在必须每一次 尝试都抓住它。0.4³ = 6.4%,实测 6.90%,对得上。而假红只需要有一次尝试没挂就行,所以是 1 - (0.02)³。「任一次通过即通过」这个开关,方向天然偏向"放过"。
所以重试不是"降低噪声",是把误报换成了漏报,而漏报不会报警。假红至少有人骂;漏掉的竞态 bug 会在生产环境里以"偶发白屏"的形式回来,那时候排查成本是当初的百倍。
最后一行是正解:隔离 。把 flaky 用例挪出阻塞级流水线(进一条非阻塞的、带趋势图的通道),假红 0.00% 、检出率保持 40.45%------因为它把"阻断"和"归因"拆成了两条路:主流水线只负责"能不能合",被隔离的用例继续跑、继续记录、继续有人认领。
1.7 难点七:产物不可复现,回滚就没有锚点
同一份源码,构建 8 次,比较产物哈希:
E6 可复现构建(同一份源码,重复 8 次)
顺序固定 + 无时间戳(基线) : 产物哈希种类 1/8 → 可复现
并行完成顺序波动 + 无时间戳 : 产物哈希种类 8/8 → 不可复现
顺序固定 + 时间戳写入产物 : 产物哈希种类 8/8 → 不可复现
两个经典的确定性杀手,单独出现任何一个都足以破坏可复现:
js
// 杀手一:并行完成顺序。哪个 promise 先 resolve 全靠调度器心情。
const order = await Promise.all(tasks.map(t => t.run())); // 顺序随运行时波动
// 杀手二:构建时间戳写进产物
banner: `/* built at ${new Date().toISOString()} */`
看起来都是小事------反正内容一样、用户看不出来。但流水线的产物不只是"给用户看的字节",它还是运维的锚点:
- 产物不可复现 → 两个哈希不同的文件,你无法证明"它们功能相同";
- 回滚时你只能"把上次的 git commit 重新构建一遍",而新构建出来的东西可能和上次线上那份不是同一个东西;
- 二进制 diff、增量发布、产物完整性校验(SRI)全部失效。
这就是为什么"回滚"在很多团队里是个心理安慰动作,而不是工程动作------它没有锚点。
好消息是修复成本极低,见 2.7:排序 + 去时间戳 + 工具链进键,8/8 立刻变 1/8。
二、完整解法
2.1 第一件事:让缓存键成为输入闭包
规则只有一条:任何能改变产物的东西,都必须在键里;任何不能改变产物的东西,都不许在键里。
js
// 完整的键 = 内容闭包 + 工具链 + 锁文件 + 影响产物的环境
export function taskCacheKey({ files, task, toolchain, lockHash, env }) {
const cl = [...closure(files, task.id)].sort((a, b) => a - b); // 关闭顺序影响
return sha([
'v1', // 键格式版本:改格式必须让旧缓存失效
toolchain, // 编译器/打包器版本
lockHash, // 依赖树
stableStringify(env), // 只挑真正进产物的(NODE_ENV、define)
...cl.map((c) => sha(files[c].content)), // 闭包内容
].join('|'));
}
function stableStringify(o) {
return JSON.stringify(o, Object.keys(o).sort()); // 键序稳定
}
三个容易漏、且漏了就是 796 次假命中的输入:
- 工具链版本 。
tsc从 5.5 升到 5.6,产物变了而源码没变------不写进键,全量缓存全部失灵或全部错误命中。 - 锁文件 。
node_modules里某个依赖的 patch 版本变了,产物变了。 - 影响产物的环境变量 。
NODE_ENV=production与development产出完全不同的代码;define里注入的构建号进产物。反过来,CI=true、HOME、主机名绝不能进键,否则远程缓存跨机器必不命中。
配套两条工程纪律:
- 键必须是可移植的 。永远不要拼绝对路径、
cwd、hostname、Date.now()。远程缓存的价值就在跨机器共享。 - 命中后要校验产物完整性 。存
key → { 输出文件哈希清单 },还原时逐个校验;对不上就当未命中。缓存被污染(半截写入、磁盘损坏)时的唯一防线就是这个。
2.2 第二件事:把流水线建成任务图,并按脏集调度
有了封闭的键,任务图才有意义。核心是"只跑脏闭包":
js
export function dirtyTasks(graph, changedFiles, cacheKeyOf, cache) {
// 1) 反查:哪些任务直接吃了这些文件
const seeds = graph.tasks.filter((t) => t.inputs.some((f) => changedFiles.has(f)));
// 2) 沿反向边扩散,得到需要重新考虑的任务集合
const dirty = new Set();
const queue = seeds.map((t) => t.id);
while (queue.length) {
const id = queue.pop();
if (dirty.has(id)) continue;
dirty.add(id);
for (const next of graph.dependentsOf(id)) queue.push(next.name);
}
// 3) 逐个用封闭键问缓存:命中就剪掉整棵子树
return graph.tasks
.filter((t) => dirty.has(t.id))
.filter((t) => !cache.has(cacheKeyOf(t)));
}
第 3 步那个 !cache.has(...) 是关键,也是 1.2 里"平均只重建 2.38 个任务"的来源:改动扩散到 40 个任务,但其中 38 个的键没变(它们的闭包里被改的那份内容恰好等价,或者它们只是传递依赖),于是真正执行的可能只有 2 个。
要盯的两个数:每次提交的重建任务数 和它的最大值 。前者的健康值是个位数百分比,后者暴露"枢纽文件"(我们那张图里最高枢纽被 12 个任务依赖,改它就重建 12 个)。枢纽文件重是不可消除的,但可以减少:把它拆小、或者让它变成纯类型/常量。
2.3 第三件事:分片用 LPT,并且先算天花板
分片之前先算一算下界,再决定值不值得加机器:
js
export function shardPlan(durations, shards) {
const total = durations.reduce((a, b) => a + b, 0);
const ideal = total / shards;
const slowest = Math.max(...durations);
const lowerBound = Math.max(ideal, slowest); // 任何方案都不可能快过它
// 若下界被"最慢用例"顶住,加机器无效 → 先去拆用例
const plateaued = slowest > ideal;
return { ideal, slowest, lowerBound, plateaued, ...lptShard(durations, shards) };
}
plateaued === true 就是那个信号:slowest > total/shards。这时候分片数从 4 加到 8,墙上时间一动不动(实测两边都是总时长的 0.36x)。正确的动作按性价比排序:
- 拆用例 :把"一条跑完整个下单流程"的 e2e 拆成 3 条独立子流程,每条自己起状态。收益最大,因为它直接把
slowest打下来。 - 降级:把这个用例从阻塞级移到 nightly。主流水线不再等它。
- 在用例内部并行 :Playwright 的
fullyParallel对单个test()无效,只能拆test.step之外的粒度------收益有限,别指望它。 - 加机器 :仅当
slowest < ideal时有效。
另外一条实践要点:LPT 需要历史耗时 。冷启动时没有数据,就用文件行数/历史均值做估计,并在每轮结束后把真实耗时回写成一个按 commit 键控 的产物。缓存 duration map 本身就是一次内容寻址的应用。
2.4 第四件事:门禁分层,而不是排队
1.4 已经证明:单层里按 c/p 升序是最优的。但更优的结构是分层------不同层有不同的失败率、不同的时限、不同的"要不要阻塞"。
| 层 | 触发 | 门禁 | 目标 | 策略 |
|---|---|---|---|---|
| L0 提交级 | 每次 push | lint、typecheck、unit(分片) | < 3 min | 全部阻塞,c/p 升序 |
| L1 合并级 | 进入合并队列 | build、integration | < 15 min | 批量原子合并 + 推测 |
| L2 发布级 | 打 tag / 每日 | e2e 全量、性能、a11y | 无硬时限 | 非阻塞,结果进趋势图 |
| L3 隔离池 | 自动 | 被判定 flaky 的用例 | --- | 永不阻塞,必须有人认领 |
分层的收益不是"总时间变短",而是把 24.10% 那个全绿概率用在正确的层上:L0 里 lint/typecheck 便宜且失败率高,让 76% 的有问题提交在 3 分钟内死在最便宜的地方,L1、L2 的资源只花在真正要合的代码上。
而 failP 必须测出来,不能拍 。做法很简单:流水线每轮把 (gate, 结果) 记一条,滚动 30 天算每个门禁的失败率,c/p 顺序自动重算。一旦某道门禁的 p 掉到 1% 以下,它就会被规则挤到队尾------这是规则在提醒你"这道门禁已经抓不到东西了"。
2.5 第五件事:合并队列的两条路
js
// 批量原子合并:一批一起验、一起进,base 只变一次
async function drainQueue(queue, batchSize) {
while (queue.length) {
const batch = queue.splice(0, batchSize);
const merged = await validateTogether(batch); // 在同一个 base 上验整批
if (!merged.ok) {
// 批内二分定位:批验证失败时逐半拆分,把责任人缩小到 1 个 PR
await bisectBatch(batch);
continue;
}
await mergeAtomically(batch); // 关键:一次 commit,不是 K 次
}
}
两个必须一起做的细节:
mergeAtomically真的是一次原子提交。如果 4 个 PR 合成 4 个 commit 依次推上去,第 2 个 push 之后 base 又变了,前面验的东西再次作废------你会白拿 4x 的收益然后吐回去。- 批验证失败必须二分 。一批 4 个 PR 红了,如果只是"整批重来",就退化成了 cascade。二分
O(log K)次定位,才能保住批量的收益。
推测验证则是在 validateTogether 之外再挂一层预测:批 N 验证时,直接把批 N+1 的 base 设成"批 N 已合并"的预测结果并链式跑下去。命中 λ 越高,收益越大;未命中时代价只是重验一次,不会污染任何已合并的结论。
2.6 第六件事:flaky 用隔离,不用重试
判断标准要分成两类,很多团队把它们混成了一种:
| 失败类型 | 例子 | 正确对策 |
|---|---|---|
| 基础设施失败 | runner 掉了、网络超时、磁盘满、拉镜像 429 | 重试(这种情况下重试是对的,因为失败与代码无关) |
| 断言失败 | 元素没找到、快照不一致、比值不等 | 不准重试,必须归因:要么是 bug,要么是 flaky 用例 |
Playwright 的 retries: 2、Jest 的 jest.retryTimes(2) 默认对这两类一视同仁,这是它们最危险的地方。做法:
- 只对基础设施失败重试。把"任务级重试"和"用例级重试"分开,用例级默认 0。
- 按历史计算 flaky 率。同一个用例最近 N 次里"最后失败/通过 交替"的比例超过阈值 → 自动进隔离池,并开 issue、指定 owner。
- 隔离池必须有人看 。带趋势图的非阻塞通道,
p值、首次进入时间、owner 三个字段常驻看板。无人认领超过 N 天的隔离用例,按删除处理并记录------留着无人看的隔离池比删掉更糟,因为它假装这些覆盖还在。 - 绝不用重试掩盖断言失败 。1.6 的实测很明确:重试 ≤3 把检出率从 40.45% 打到 6.90%,而这条损失在图表上完全不可见。
2.7 第七件事:把可复现做成一道门禁
规范化清单(每一行对应一个确定性杀手):
js
export function canonicalManifest(files, toolchain) {
const order = [...files.keys()].sort((a, b) => a - b); // ① 排序:消灭并行顺序
return {
toolchain, // ② 工具链进清单
files: order.map((i) => ({ // ③ 路径相对化,不带绝对路径
path: posixRelative(files[i].path),
hash: sha(files[i].content),
})),
// ④ 没有 timestamp / buildId / hostname
hash: sha(toolchain + '|' + order.map((i) => files[i].content).join('|')),
};
}
然后加一道门禁,成本极低、收益极高:
js
// 可复现性门禁:连构两次,哈希必须相等
const a = await build(); const b = await build();
assert.equal(a.artifactHash, b.artifactHash, '产物不可复现:检查顺序依赖与时间戳');
拿到可复现之后,三件事立刻变简单:回滚 = 换指针 (哈希对得上,就是同一个东西);增量发布 (只传哈希变了的 chunk);SRI/完整性校验(哈希就是完整性证明)。
2.8 第八件事:把流水线的判据也做成门禁
前面七条全是判据,判据会腐烂------有人加了个新任务忘了写全键,有人把 e2e 挪到了 L0。所以最后一层是给流水线自己做静态检查 (这正是 Day37 里 gate.mjs 的思路搬过来):
js
export const PIPELINE_RULES = [
{ id: 'key-closed', check: (cfg) => cfg.tasks.every((t) => t.cacheKey.includes('toolchain')) },
{ id: 'no-wallclock', check: (cfg) => !JSON.stringify(cfg).match(/Date\.now|new Date\(\)/) },
{ id: 'gate-ordered', check: (cfg) => isSortedByCostOverP(cfg.gates) },
{ id: 'repro-gate', check: (cfg) => cfg.jobs.some((j) => j.name === 'reproducibility') },
{ id: 'no-assert-retry', check: (cfg) => cfg.tasks.every((t) => (t.retries ?? 0) === 0 || t.infraOnlyRetry) },
];
export function auditPipeline(cfg) {
return PIPELINE_RULES.map((r) => ({ id: r.id, pass: !!r.check(cfg) }));
}
再叠加四个运行期 SLO,进看板而不是进告警(先看趋势,别急着叫人):p50/p95 流水线时长 、正确缓存命中率 (扣掉假命中)、flaky 率 、队列等待时间(合并队列里最容易被忽略的成本------它经常比验证本身还长)。
2.9 别在这些地方花时间
- 抠 YAML、拆 job。把 5 个 job 拆成 12 个,不会让任何东西变快;只是让失败信息更难读。
- 一味加 runner 。1.3 已经说明:
slowest > ideal时加机器零收益。先算下界。 - 追求 100% 缓存命中 。有些任务(
git version、时间戳注入、发布上传)天然不可缓存,把它们标成cache: false比硬凑键更诚实。 - 把 e2e 挪到提交级。300 秒 × 每个 push,换来的是全绿概率从 24.10% 继续往下掉。
- 上"流水线平台"来解决流程问题。78 次级联验证(1.5)不是工具问题,是"base 会变"这个事实没被建模。换成任何平台,只要还是 12 个并行各自验、各自合,还是 78 次。
三、应用场景
3.1 什么时候该建这条流水线
不用一上来就全建。按触发条件逐级加:
| 症状 | 该做什么 | 对应章节 |
|---|---|---|
| CI 常绿但线上出现过"只有部分用户"的错 | 先查缓存键封闭性,看假命中 | 2.1 |
| 每次提交都全量构建/全量测试 | 上任务图 + 封闭键,做增量 | 2.2 |
| 测试分片最慢片显著慢于均值 | LPT 分片;若 plateaued 先拆用例 |
2.3 |
| CI 平均时长 20 分钟以上且没人看 | 门禁分层 + c/p 排序 |
2.4 |
| PR 排队合并要等一小时 | 批量原子合并 + 推测 | 2.5 |
| 同一条用例反复"重跑就过" | 隔离池 + 归因,停掉用例级重试 | 2.6 |
| 回滚过但不敢确定回滚到了什么 | 可复现门禁 | 2.7 |
反过来的判据也一样重要:如果团队不到 3 个人、一天不到 5 次提交 ,把 L2/L3 建起来是负收益------维护成本超过它省下的时间。这时候只有两条值得做:键封闭 (错了会静默产错产物)和可复现(错了回滚无效)。这两条与规模无关。
3.2 与现成工具的关系
这套东西不需要自己全写,关键是分清每层谁负责:
| 层 | 现成方案 | 你要自己保证的 |
|---|---|---|
| 执行层 | GitHub Actions / GitLab CI / Buildkite | 别把业务判据写进 YAML |
| 任务图 + 缓存 | Nx、Turborepo | 键是否封闭(它们的默认键不一定含你的工具链) |
| 内容寻址 + 远程执行 | Bazel、Turbo Remote Cache | 键的可移植性、产物完整性校验 |
| 分片 | Playwright --shard、Jest --shard |
分片依据是耗时还是个数 |
| 合并队列 | GitHub merge queue、Bors | 是否真的原子提交、失败是否二分 |
最容易踩的坑是:以为用了 Turborepo 就有增量构建了。它给的是任务图与缓存机制,输入声明仍然是你的责任 ------inputs 漏一个 tsconfig.json,或者 globalDependencies 漏掉工具链版本,就是 1.1 那 796 次假命中,而且它不会报错。
3.3 最容易翻车的三处
- 环境变量进了缓存键,导致跨环境污染 。
HOME=/Users/a和/home/runner拼进键 → 远程缓存 0 命中(看起来"缓存就是不好使");反过来,NODE_ENV没进键 → 用 development 产物发布了生产。规则:白名单,只列真正进产物的环境变量,其余一律不参与。 - 缓存了但没有校验产物 。命中即信任。磁盘半截写入、并发写同一个键、镜像里带了旧缓存------全都会让错误产物被当成正确产物使用,且完全静默。规则:存清单,还原时逐文件校验哈希。
- 重试掩盖真失败 。这是最难发现的,因为它在监控上表现为"变好了"。规则:用例级重试默认 0;只有基础设施失败可重试;被重试救回来的用例自动记一条 flaky 记录。
3.4 与前后篇咬合
- Day36-Monorepo :那里讲的"单实例判据"在这里变成"工作区级任务图"------包之间的依赖边既是构建顺序,也是脏集扩散的边。
- Day37-微前端架构 :那篇末尾提到的
gate.mjs(把架构判据做成 CI 门禁)在这里继续长大,变成 2.8 的auditPipeline:判据进 CI,才不会腐烂。 - Day34-性能监控 / Day56-性能案例 :性能门禁(体积预算、LCP 预算)挂在 L2 层,用同一套
c/p逻辑排序。 - Day39-安全 :安全扫描进 L0(依赖漏洞、密钥扫描),但它
p值低、c值高,按规则会自然落到队尾------2.4 那条推论正好解释了"为什么安全扫描总是被加班跳过"。 - Day55-发布管理:可复现产物(2.7)是"回滚 = 换指针"的前置条件;没有它,发布管理只能靠流程约束。
四、总结
流水线只做一件事:把输入映射成产物。所有难点都是"这个映射依赖了哪些输入、我有没有把它们全部写进键里和校验里"的不同侧面。
三条不变量,缺一条整条流水线就失去意义:
- 键封闭 ------ 任何能改变产物的输入都必须在缓存键里。漏一个,你得到的是 796 次静默的错产物,而不是一个报错。命中率不是指标,正确命中数才是:97.13% 的命中率可以是错的,96.41% 的命中率可以比它多出 743 次正确命中。
- base 正确 ------ 一次合并的产物,必须在它真正合入的那个 base 上被验证过。12 个 PR 并行验证不会更快(60 分钟 vs 60 分钟),只会多烧 550% 的算力;真正的加速来自让 base 只变一次(4x)或提前预测它(9.2x)。
- 产物可复现 ------ 不可复现的产物不是资产,是猜测。修复成本极低(排序 + 去时间戳 + 工具链进键),8/8 立刻变 1/8。
四条结构性结论:
- 顺序可以用公式求解,不必用经验 :门禁按
c/p升序排,与暴力枚举 120 种排列的最优解完全相等 。由此得到一条锋利的推论------p ≈ 0的门禁会被推到队尾,这是规则在提醒你它已经抓不到东西了。 - 并行不是免费的 :分片、门禁、合并队列三处的并行都要付「失去提前失败」或「结论作废」的代价。先算下界(
max(理想, 最慢用例)),再决定加不加机器。 - 重试是借新债还旧债:它把 6.00% 的误报换成 5.9 倍的漏报,而漏报不出现在任何图表上。flaky 的正解是隔离 + 归因,不是重试------实测隔离同时拿到 0.00% 假红和 40.45% 检出率。
- 判据会腐烂,所以要进 CI:键封闭、门禁顺序、可复现、用例级重试为 0------这四条做成静态检查(2.8),才可能在半年后还成立。
最后一句:一条好的流水线,会让你不怕回滚、不怕增量、不怕加人。不是因为它快,而是因为它输出的每一个产物都能被证明"就是它该是的样子"。速度是这套确定性之后的副产品,从来不是目标本身。
配套代码:
day38-assets/pipeline.mjs(350 行,零依赖)、bench.mjs(6 组实验)、test-day38.mjs(46 条断言)、bench-log.txt。 全部可复现:node test-day38.mjs→ 46 条断言全绿;node bench.mjs→ 两轮输出逐字节一致。
附录:完整代码
以下脚本均来自
day38-assets/(完整源码)。 运行:node test-day38.mjs验证流水线确定性;node bench.mjs跑各阶段耗时。
pipeline.mjs ------ 流水线内核(提交 -> 构建 -> 产物 -> CDN 的确定性映射,350 行)
javascript
// Day38 配套:零依赖前端流水线内核
// 缓存键 / 任务图 / 分片 / 门禁 / 合并队列 / flaky / 可复现构建
// 全部确定性计算(seeded PRNG),计数类结论可逐字节复现
import { createHash } from 'node:crypto';
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const sha = (s, n = 12) => createHash('sha1').update(String(s)).digest('hex').slice(0, n);
/* ------------------------------------------------------------------ */
/* E1:缓存键是否"封闭" ------ 假命中产静默错产物 */
/* ------------------------------------------------------------------ */
export function buildGraph(n, seed) {
const rnd = mulberry32(seed);
const files = [];
for (let i = 0; i < n; i++) files.push({ id: i, content: `v0:${i}`, imports: [] });
for (let i = 1; i < n; i++) {
const k = rnd() < 0.4 ? 1 + Math.floor(rnd() * 2) : 0;
for (let e = 0; e < k; e++) {
// rnd()*rnd() 偏向低 id → 少数"枢纽文件"被大量依赖,接近真实代码库
const j = Math.floor(rnd() * rnd() * i);
if (j !== i && !files[i].imports.includes(j)) files[i].imports.push(j);
}
}
return files;
}
export function closure(files, i) {
const seen = new Set();
const stack = [i];
while (stack.length) {
const c = stack.pop();
if (seen.has(c)) continue;
seen.add(c);
for (const d of files[c].imports) if (!seen.has(d)) stack.push(d);
}
return seen;
}
const ARTIFACT_TOKEN = '9f2a-toolchain';
// 真产物:只由"源码闭包 + 工具链"决定,这是唯一可信的定义
export function trueArtifact(files, i) {
const cl = [...closure(files, i)].sort((a, b) => a - b);
return sha(ARTIFACT_TOKEN + '|' + cl.map((c) => files[c].content).join('|'), 16);
}
// P1 朴素键:只看文件自己的内容
const keyOwn = (files, i, lock) => sha('own|' + files[i].content + '|' + lock);
// P2 整仓键:正确但无命中(每个任务一个槽位,键含全仓内容)
const keyRepo = (files, i, lock) => sha('repo|' + i + '|' + files.map((f) => f.content).join('') + '|' + lock);
// P3 闭包键:内容寻址,覆盖全部输入
const keyClosure = (files, i) => sha('closure|' + trueArtifact(files, i));
export function cacheExperiment({ n = 120, commits = 60, seed = 7, lock = 'lock-v1' } = {}) {
const rnd = mulberry32(seed);
const files = buildGraph(n, seed);
const caches = { P1: new Map(), P2: new Map(), P3: new Map() };
const stat = {
P1: { hit: 0, miss: 0, stale: 0 },
P2: { hit: 0, miss: 0, stale: 0 },
P3: { hit: 0, miss: 0, stale: 0 },
};
const perCommitP3 = [];
let tasks = 0;
const runRound = () => {
for (let i = 0; i < n; i++) {
tasks++;
const truth = trueArtifact(files, i);
const keys = { P1: keyOwn(files, i, lock), P2: keyRepo(files, i, lock), P3: keyClosure(files, i) };
for (const p of ['P1', 'P2', 'P3']) {
const c = caches[p];
if (c.has(keys[p])) {
stat[p].hit++;
if (c.get(keys[p]) !== truth) stat[p].stale++; // 命中但产物是错的
} else {
stat[p].miss++;
c.set(keys[p], truth);
}
}
}
};
runRound();
for (let t = 1; t <= commits; t++) {
const touched = 1 + Math.floor(rnd() * 2); // 每次提交改 1~2 个文件
const before = stat.P3.miss;
for (let k = 0; k < touched; k++) {
const f = files[Math.floor(rnd() * n)];
f.content = `v${t}:${f.id}`;
}
runRound();
perCommitP3.push(stat.P3.miss - before);
}
const rate = (p) => (stat[p].hit / tasks);
const maxRebuild = Math.max(...perCommitP3);
const avgRebuild = perCommitP3.reduce((a, b) => a + b, 0) / perCommitP3.length;
return { n, commits, tasks, stat, hitRate: { P1: rate('P1'), P2: rate('P2'), P3: rate('P3') }, perCommitP3, maxRebuild, avgRebuild, hubDegree: hubDegree(files) };
}
// 最大的枢纽文件被多少个任务的闭包依赖 ------ 解释"为什么整仓键必然 0 命中"
export function hubDegree(files) {
const counts = new Array(files.length).fill(0);
for (const f of files) for (const d of closure(files, f.id)) counts[d]++;
return Math.max(...counts);
}
/* ------------------------------------------------------------------ */
/* E2:测试分片 ------ 目标不是分得开,是摊得平 */
/* ------------------------------------------------------------------ */
export function shardExperiment({ files = 400, shards = 4, seed = 11, dominant = 0 } = {}) {
const rnd = mulberry32(seed);
// 幂律分布:少数慢文件吃掉大部分时间(真实测试集形态)
const durations = [];
for (let i = 0; i < files; i++) durations.push(Math.round(60 + 14000 * Math.pow(rnd(), 6)));
// dominant > 0 时,注入一个"巨慢"用例(e2e 里很常见:一个用例跑 10 分钟)
if (dominant > 0) {
const total0 = durations.reduce((a, b) => a + b, 0);
durations[0] = Math.round(total0 * dominant);
}
const total = durations.reduce((a, b) => a + b, 0);
const ideal = total / shards;
const mk = () => Array.from({ length: shards }, () => []);
const summarize = (bins) => {
const sums = bins.map((b) => b.reduce((s, i) => s + durations[i], 0));
const max = Math.max(...sums);
return { sums, max, ideal, skew: max / ideal, counts: bins.map((b) => b.length) };
};
const roundrobin = mk();
durations.forEach((_, i) => roundrobin[i % shards].push(i));
const contiguous = mk();
const chunk = Math.ceil(files / shards);
durations.forEach((_, i) => contiguous[Math.min(shards - 1, Math.floor(i / chunk))].push(i));
// LPT:按耗时降序,谁先空谁先拿(Longest Processing Time first)
const lpt = mk();
const loads = new Array(shards).fill(0);
[...durations.keys()]
.sort((a, b) => durations[b] - durations[a])
.forEach((i) => {
let m = 0;
for (let s = 1; s < shards; s++) if (loads[s] < loads[m]) m = s;
lpt[m].push(i);
loads[m] += durations[i];
});
const slowest = Math.max(...durations);
return {
files, shards, total, ideal,
roundrobin: summarize(roundrobin),
contiguous: summarize(contiguous),
lpt: summarize(lpt),
slowest,
lptLowerBound: Math.max(ideal, slowest), // LPT 的理论下界
};
}
/* ------------------------------------------------------------------ */
/* E3:门禁顺序 = 期望成本的函数 */
/* ------------------------------------------------------------------ */
export const GATES = [
{ name: 'lint', costMs: 8_000, failP: 0.30 },
{ name: 'typecheck', costMs: 25_000, failP: 0.25 },
{ name: 'unit', costMs: 60_000, failP: 0.40 },
{ name: 'build', costMs: 90_000, failP: 0.15 },
{ name: 'e2e', costMs: 300_000, failP: 0.10 },
];
// 串行 + 失败即停:E = Σ c_k * Π_{j<k}(1-p_j)
export function expectedSequential(order, gates = GATES) {
let acc = 0;
let survive = 1;
const rows = [];
for (const g of order) {
const expect = survive * g.costMs;
acc += expect;
rows.push({ name: g.name, costMs: g.costMs, reachP: survive, expectMs: expect });
survive *= 1 - g.failP;
}
return { expectMs: acc, rows, fullRunP: survive };
}
// 全并行:没有提前失败,代价 = max(c),但算力消耗 = Σ c
export function expectedParallel(gates = GATES) {
return {
expectMs: Math.max(...gates.map((g) => g.costMs)),
computeMs: gates.reduce((a, g) => a + g.costMs, 0),
};
}
// 两道门禁 i 放在 j 前面更优 ⟺ c_i + (1-p_i)c_j < c_j + (1-p_j)c_i ⟺ c_i/p_i < c_j/p_j
// 所以「按 c/p 升序」就是精确最优排序,不需要试
export function ratioOrder(gates = GATES) {
return [...gates].sort((a, b) => a.costMs / a.failP - b.costMs / b.failP);
}
export function* permutations(arr) {
if (arr.length <= 1) { yield arr; return; }
for (let i = 0; i < arr.length; i++) {
const rest = [...arr.slice(0, i), ...arr.slice(i + 1)];
for (const p of permutations(rest)) yield [arr[i], ...p];
}
}
// 暴力枚举全部排列,找真正的最小期望成本 ------ 用来验证 c/p 排序规则
export function bruteForceOptimal(gates = GATES) {
let best = null;
let count = 0;
for (const perm of permutations(gates)) {
count++;
const { expectMs } = expectedSequential(perm);
if (!best || expectMs < best.expectMs) best = { expectMs, order: perm.map((g) => g.name) };
}
return { ...best, permutations: count };
}
/* ------------------------------------------------------------------ */
/* E4:合并队列 ------ 并发与"基于正确 base 的验证"互斥 */
/* ------------------------------------------------------------------ */
export function mergeQueue({ prs = 12, validateMs = 300_000, batchSize = 4, specHitP = 0.85 } = {}) {
// a) 完全串行:一次验证一个,合并后下一个再来(base 永远正确)
const serial = { wallMs: prs * validateMs, validations: prs };
// b) 朴素并行:12 个一起验证,合并第 1 个后其余 11 个的 base 全失效 → 级联重验
const cascadeValidations = (prs * (prs + 1)) / 2;
const cascade = { wallMs: prs * validateMs, validations: cascadeValidations };
// c) 批量原子合并:K 个一起验、一起进,base 变更次数从 prs 降到 ceil(prs/K)
const batches = Math.ceil(prs / batchSize);
const batch = { wallMs: batches * validateMs, validations: batches };
// d) 批量 + 推测验证:第 i+1 批在"第 i 批一定成功"的假设上提前开跑
// 命中则零等待;未命中(概率 1-specHitP)则浪费一次并补一次
const specWall = validateMs + (batches - 1) * validateMs * (1 - specHitP);
const specValidations = batches * (1 + (1 - specHitP));
const speculative = { wallMs: specWall, validations: specValidations };
return { prs, validateMs, batchSize, specHitP, serial, cascade, batch, speculative };
}
/* ------------------------------------------------------------------ */
/* E5:flaky 重试 ------ 把"误报"换成"漏报" */
/* ------------------------------------------------------------------ */
export function flakyExperiment({ runs = 2000, seed = 23, flakyCount = 3, falseFailP = 0.02, detectP = 0.4 } = {}) {
const policies = [
{ name: '不重试(n=1)', maxAttempts: 1, quarantine: false },
{ name: '重试到通过(≤2)', maxAttempts: 2, quarantine: false },
{ name: '重试到通过(≤3)', maxAttempts: 3, quarantine: false },
{ name: '隔离出主流水线(n=1)', maxAttempts: 1, quarantine: true },
];
const out = [];
for (const pol of policies) {
const rnd = mulberry32(seed);
let flakyRed = 0; // flaky 造成的假红 = 全员排查
let detected = 0; // 间歇性真 bug 被这轮抓到
for (let r = 0; r < runs; r++) {
// 3 个 flaky 用例:每次尝试有 falseFailP 概率假失败;任一次通过即视为通过。
// 无论策略如何都**固定消耗** maxAttempts 个随机数,保证四条策略跑的是同一批随机样本。
let anyFlakyRed = false;
for (let t = 0; t < flakyCount; t++) {
let allFailed = true;
for (let a = 0; a < pol.maxAttempts; a++) {
if (rnd() >= falseFailP) allFailed = false;
}
if (allFailed) anyFlakyRed = true;
}
if (anyFlakyRed && !pol.quarantine) flakyRed++;
// 1 个间歇性真 bug:每次尝试以 detectP 概率抓到。
// 策略是"任一次通过就算通过" → 只有在**每一次**尝试都抓到,bug 才不会被放过去。
let allDetected = true;
for (let a = 0; a < pol.maxAttempts; a++) {
if (rnd() >= detectP) allDetected = false;
}
if (allDetected) detected++;
}
out.push({
...pol,
runs,
flakyRedRate: flakyRed / runs,
detectionRate: detected / runs,
});
}
return { runs, flakyCount, falseFailP, detectP, policies: out };
}
/* ------------------------------------------------------------------ */
/* E6:可复现构建 ------ 回滚没有锚点就只是"再赌一次" */
/* ------------------------------------------------------------------ */
// 变体矩阵:单独隔离"并行完成顺序"与"时间戳"这两个不确定性来源
export function buildVariant(files, { shuffle = false, stamp = false, seed = 0, sorted = false } = {}) {
let order = [...files.keys()];
if (shuffle) {
const rnd = mulberry32(seed);
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
}
if (sorted) order = order.sort((a, b) => a - b);
const body = ARTIFACT_TOKEN + '|' + order.map((i) => files[i].content).join('|');
return sha(body + (stamp ? '|' + Date.now() : ''), 16);
}
export function reproducibilityExperiment({ samples = 8 } = {}) {
const files = buildGraph(40, 31);
const variants = [
{ name: '顺序固定 + 无时间戳(基线)', opt: { sleepMs: 0 } },
{ name: '并行完成顺序波动 + 无时间戳', opt: { shuffle: true } },
{ name: '顺序固定 + 时间戳写入产物', opt: { stamp: true } },
{ name: '规范化(排序 + 去时间戳)', opt: { sorted: true } },
];
const rows = [];
for (const v of variants) {
const seen = new Set();
for (let s = 0; s < samples; s++) {
if (v.opt.stamp) {
// 用不同毫秒触发时间戳差异,避免同一 ms 内塌缩
const end = Date.now() + s;
while (Date.now() < end) { /* 等 1ms,模拟真实构建间隔 */ }
}
seen.add(buildVariant(files, { ...v.opt, seed: s }));
}
rows.push({ name: v.name, distinct: seen.size, samples });
}
return { samples, rows };
}
test-day38.mjs ------ 流水线全套断言(可复现性、缓存键、门禁)
javascript
// Day38 断言:把文章里的每个结论钉成可执行检查
import assert from 'node:assert/strict';
import {
cacheExperiment, shardExperiment, GATES, expectedSequential, expectedParallel,
mergeQueue, flakyExperiment, reproducibilityExperiment, mulberry32, sha,
ratioOrder, bruteForceOptimal,
} from './pipeline.mjs';
let pass = 0;
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; };
/* ---------- 基础 ---------- */
{
const a = mulberry32(1), b = mulberry32(1);
ok(a() === b(), '同种子 PRNG 必须同序列');
ok(sha('x') === sha('x') && sha('x') !== sha('y'), 'sha 稳定且可区分');
}
/* ---------- E1:缓存键封闭性 ---------- */
const e1 = cacheExperiment();
{
ok(e1.tasks === 7320, 'E1 任务数 120×61=7320');
// 核心结论 1:只看自身内容的键会产生假命中
ok(e1.stat.P1.stale > 0, 'P1(自身内容键)必须出现假命中');
ok(e1.stat.P1.stale === 796, `P1 假命中应为 796,实际 ${e1.stat.P1.stale}`);
// 核心结论 2:整仓键永远正确,但命中率为 0
ok(e1.stat.P2.stale === 0, 'P2 整仓键不应产生错产物');
ok(e1.hitRate.P2 === 0, `P2 命中率必须是 0,实际 ${e1.hitRate.P2}`);
// 核心结论 3:闭包键既正确又有命中
ok(e1.stat.P3.stale === 0, 'P3 闭包键不得产生任何假命中');
ok(e1.hitRate.P3 > 0.9, `P3 命中率应 >90%,实际 ${e1.hitRate.P3}`);
// 核心结论 4:命中率不是指标,正确命中数才是;P3 的正确命中数必须高于 P1
const p1Correct = e1.stat.P1.hit - e1.stat.P1.stale;
ok(e1.stat.P3.hit > p1Correct, `P3 正确命中 ${e1.stat.P3.hit} 应多于 P1 正确命中 ${p1Correct}`);
// 核心结论 5:增量重建立即收敛,不会退化成全量
ok(e1.avgRebuild < e1.n / 4, `P3 平均每轮重建 ${e1.avgRebuild} 应远小于 ${e1.n}`);
ok(e1.maxRebuild <= e1.n, 'P3 单轮重建不得超过任务总数');
}
/* ---------- E2:分片 ---------- */
{
const e2 = shardExperiment();
ok(e2.lpt.skew < e2.roundrobin.skew, 'LPT 偏离必须小于轮转');
ok(e2.lpt.skew < e2.contiguous.skew, 'LPT 偏离必须小于连续切块');
ok(e2.lpt.max <= e2.roundrobin.max, 'LPT 最慢分片不得劣于轮转');
ok(e2.lpt.skew < 1.02, `LPT 在均匀幂律下应接近理想,实际 ${e2.lpt.skew}`);
// 边界:出现单点巨慢用例时,LPT 也会顶到下界,多分片不再有帮助
const dom = shardExperiment({ files: 60, shards: 4, seed: 11, dominant: 0.55 });
ok(dom.lpt.max === dom.slowest, '有巨慢用例时 LPT 最慢分片 == 该用例本身');
ok(Math.abs(dom.lpt.max - dom.lptLowerBound) < 1e-9, 'LPT 应恰好顶到下界');
const dom8 = shardExperiment({ files: 60, shards: 8, seed: 11, dominant: 0.55 });
ok(dom8.lpt.max === dom.lpt.max, '分片数从 4 加到 8,最慢分片不变(瓶颈是那个用例)');
}
/* ---------- E3:门禁顺序 ---------- */
{
const cheap = expectedSequential(GATES);
const pricey = expectedSequential([...GATES].reverse());
ok(cheap.expectMs < pricey.expectMs, '便宜优先的期望成本必须更低');
ok(pricey.expectMs / cheap.expectMs > 2, `昂贵优先应显著更差,实际 ${pricey.expectMs / cheap.expectMs}`);
ok(cheap.fullRunP < 0.3, '五道门禁全绿概率应低于 30%');
// 全并行的算力浪费一定大于串行最坏情况之外的部分
const par = expectedParallel();
ok(par.computeMs > par.expectMs, '全并行每轮烧的算力大于其墙上时间');
ok(par.computeMs > cheap.expectMs, '全并行算力消耗高于便宜优先的期望成本');
// 关键:c/p 升序不是经验法则,是精确最优排序
const ratio = expectedSequential(ratioOrder());
const brute = bruteForceOptimal();
ok(brute.permutations === 120, '5 道门禁应有 120 种排列');
ok(ratio.expectMs === brute.expectMs, 'c/p 排序必须命中暴力最优期望成本');
ok(ratioOrder().map((g) => g.name).join() === brute.order.join(), 'c/p 排序与暴力最优的顺序必须一致');
// 顺序无关的量:全绿概率与排列无关
const anyPerm = expectedSequential([GATES[2], GATES[0], GATES[4], GATES[1], GATES[3]]);
ok(Math.abs(anyPerm.fullRunP - cheap.fullRunP) < 1e-12, '全绿概率与门禁顺序无关');
ok(anyPerm.expectMs !== cheap.expectMs, '但期望成本与顺序强相关');
}
/* ---------- E4:合并队列 ---------- */
{
const q = mergeQueue();
ok(q.batch.validations < q.cascade.validations, '批量合并的验证次数必须少于级联');
ok(q.cascade.validations === 78, `12 个 PR 级联重验应为 78 次,实际 ${q.cascade.validations}`);
ok(q.batch.wallMs < q.serial.wallMs, '批量合并必须比串行快');
ok(q.speculative.wallMs < q.batch.wallMs, '推测验证必须比纯批量更快');
ok(q.cascade.wallMs === q.serial.wallMs, '朴素并行并没有更快,只是多烧了算力');
ok(q.speculative.validations >= q.batch.validations, '推测验证的验证次数不少于纯批量');
}
/* ---------- E5:flaky ---------- */
{
const e5 = flakyExperiment();
const [noRetry, retry2, retry3, quarantine] = e5.policies;
ok(noRetry.detectionRate > retry2.detectionRate, '重试会降低间歇性 bug 检出率(1→2)');
ok(retry2.detectionRate > retry3.detectionRate, '重试越多检出率越低(2→3)');
ok(noRetry.flakyRedRate > retry2.flakyRedRate, '重试能压低假红');
ok(retry3.flakyRedRate < 0.001, '三次重试后假红接近 0');
// 关键:隔离同时拿到「零假红」和「不损失检出」
ok(quarantine.flakyRedRate === 0, '隔离后 flaky 不得让流水线变红');
ok(quarantine.detectionRate === noRetry.detectionRate, '隔离不应降低真 bug 检出率');
ok(quarantine.detectionRate > retry3.detectionRate * 4, '隔离的检出率应远高于激进重试');
}
/* ---------- E6:可复现 ---------- */
{
const r = reproducibilityExperiment();
const byName = Object.fromEntries(r.rows.map((x) => [x.name, x.distinct]));
ok(byName['顺序固定 + 无时间戳(基线)'] === 1, '基线必须可复现');
ok(byName['并行完成顺序波动 + 无时间戳'] === r.samples, '完成顺序波动必须破坏可复现');
ok(byName['顺序固定 + 时间戳写入产物'] === r.samples, '时间戳必须破坏可复现');
ok(byName['规范化(排序 + 去时间戳)'] === 1, '规范化后必须可复现');
}
console.log(`✅ test-day38:${pass} 条断言全绿`);
bench.mjs ------ 基准主程序(流水线各阶段耗时)
javascript
// Day38 bench:跑全部实验,输出可直接引用的结果
import {
cacheExperiment, shardExperiment, GATES, expectedSequential, expectedParallel,
mergeQueue, flakyExperiment, reproducibilityExperiment,
ratioOrder, bruteForceOptimal,
} from './pipeline.mjs';
const ms = (n) => `${Math.round(n).toLocaleString('en-US')}ms`;
const pct = (n) => (n * 100).toFixed(2) + '%';
console.log(`# Day38 bench node ${process.version} / ${process.platform}-${process.arch}\n`);
/* E1 */
const e1 = cacheExperiment();
console.log('E1 缓存键封闭性(120 任务 × 61 轮 = ' + e1.tasks + ' 次任务)');
for (const p of ['P1', 'P2', 'P3']) {
const s = e1.stat[p];
console.log(` ${p}: 命中 ${s.hit} 未命中 ${s.miss} 命中率 ${pct(e1.hitRate[p])} **假命中 ${s.stale}**`);
}
console.log(` P3 每轮重建:平均 ${e1.avgRebuild.toFixed(2)} / 最多 ${e1.maxRebuild}(共 ${e1.n} 个任务)`);
console.log(` 最高枢纽文件被 ${e1.hubDegree} 个任务的闭包依赖\n`);
/* E2 */
const printShard = (e2, tag) => {
console.log(`${tag}(${e2.files} 文件 / ${e2.shards} 片);理想每片 ${ms(e2.ideal)},最慢单文件 ${ms(e2.slowest)}`);
for (const k of ['roundrobin', 'contiguous', 'lpt']) {
const r = e2[k];
console.log(` ${k.padEnd(11)}: 每片 [${r.sums.map(ms).join(', ')}] 最慢 ${ms(r.max)} 偏离理想 ${r.skew.toFixed(2)}x`);
}
console.log(` LPT 下界 max(理想, 最慢单文件) = ${ms(e2.lptLowerBound)},LPT 距下界 ${(e2.lpt.max / e2.lptLowerBound).toFixed(3)}x\n`);
};
printShard(shardExperiment(), 'E2a 分片:均匀幂律');
printShard(shardExperiment({ files: 60, shards: 4, seed: 11, dominant: 0.55 }), 'E2b 分片:单个用例占 55% 总耗时');
const e2d = shardExperiment({ files: 60, shards: 4, seed: 11, dominant: 0.55 });
const e2d8 = shardExperiment({ files: 60, shards: 8, seed: 11, dominant: 0.55 });
console.log(` 慢用例占 55% 时:4 片最优 ${(e2d.lpt.max / e2d.total).toFixed(2)}x 总时长,8 片最优 ${(e2d8.lpt.max / e2d8.total).toFixed(2)}x`);
console.log(` → 分片数翻倍,墙上时间几乎不变:瓶颈是那一个用例,不是分片\n`);
/* E3 */
const cheapFirst = expectedSequential(GATES);
const expensiveFirst = expectedSequential([...GATES].reverse());
const par = expectedParallel();
console.log('E3 门禁顺序的期望成本(5 道门禁,失败即停)');
console.log(` 便宜优先: 期望 ${ms(cheapFirst.expectMs)},全绿跑到底概率 ${pct(cheapFirst.fullRunP)}`);
console.log(` 昂贵优先: 期望 ${ms(expensiveFirst.expectMs)}`);
console.log(` 全并行 : 单轮墙上时间 ${ms(par.expectMs)},但每轮烧算力 ${ms(par.computeMs)}`);
console.log(` 便宜优先 vs 昂贵优先 = ${(expensiveFirst.expectMs / cheapFirst.expectMs).toFixed(2)}x 差`);
const ratio = expectedSequential(ratioOrder());
const brute = bruteForceOptimal();
console.log(` c/p 升序 : 期望 ${ms(ratio.expectMs)},顺序 [${ratioOrder().map(g => g.name).join(' → ')}]`);
console.log(` 暴力枚举 ${brute.permutations} 种排列,最优 ${ms(brute.expectMs)} [${brute.order.join(' → ')}]`);
console.log(` → c/p 排序 ${ratio.expectMs === brute.expectMs ? '命中' : '未命中'}暴力最优`);
console.log(` c/p 值: ${GATES.map(g => `${g.name}=${Math.round(g.costMs / g.failP / 1000)}s`).join(', ')}\n`);
/* E4 */
const e4 = mergeQueue();
console.log(`E4 合并队列(${e4.prs} 个 PR,单次验证 ${ms(e4.validateMs)},批量 ${e4.batchSize})`);
for (const k of ['serial', 'cascade', 'batch', 'speculative']) {
const r = e4[k];
console.log(` ${k.padEnd(11)}: 墙上时间 ${ms(r.wallMs)},验证次数 ${r.validations.toFixed(2)}`);
}
console.log(` 级联重验浪费 = ${((e4.cascade.validations / e4.prs - 1) * 100).toFixed(0)}% 的验证额度\n`);
/* E5 */
const e5 = flakyExperiment();
console.log(`E5 flaky 策略(${e5.runs} 轮,${e5.flakyCount} 个 flaky,假失败 ${pct(e5.falseFailP)},单轮检出率 ${pct(e5.detectP)})`);
for (const p of e5.policies) {
console.log(` ${p.name.padEnd(22)}: flaky 假红 ${pct(p.flakyRedRate)},间歇性真 bug 检出 ${pct(p.detectionRate)}`);
}
console.log('');
/* E6 */
const e6 = reproducibilityExperiment();
console.log(`E6 可复现构建(同一份源码,重复 ${e6.samples} 次)`);
for (const r of e6.rows) {
console.log(` ${r.name.padEnd(24)}: 产物哈希种类 ${r.distinct}/${r.samples} ${r.distinct === 1 ? '→ 可复现' : '→ 不可复现'}`);
}