切分能提速,但结论可能因此改变
一句话总结
把测试拆成多个分片并行运行,可以缩短反馈时间。但代价是:如果拆分不是确定性的,或者分片之间存在隐藏的顺序依赖,总体判定就会改变。 拆分是用速度换来了新的失败模式的一桩交易。
为什么需要它
反馈一慢,就没人去看流水线了。人们不再等结果,而是去做别的事,等回来时已经叠了别的提交,到底是谁弄坏的就变得模糊。Martin Fowler 借用极限编程的准则,提出 10 分钟构建对大多数项目来说是合理的目标,并写道,从构建时间里削减下来的 1 分钟,就是每位开发者每次提交时都节省下来的 1 分钟。测试要跑 40 分钟的话,人们每天只会集成一次,那么 CI 带来的好处本身就消失了。
拆分是缩短这 40 分钟最直接的手段。它不像让服务器更快或删除测试,而是保持测试不变,只缩短挂钟时间。
怎么拆
有两种方式,各有用武之地。
按文件均分。 把测试文件列表排序后,按分片数切开。实现简单,也不需要额外信息。代价是每个文件耗时参差不齐,所以最慢的那个分片决定整体时间。 只要有一个文件要 10 分钟,无论怎么增加分片,都不会降到 10 分钟以下。
按记录的耗时做均衡。 保存上一次运行中每个测试花了多长时间,以这个值为依据,让各分片的总和尽量接近。各分片几乎同时结束,浪费很少。代价是耗时记录本身成了输入,所以必须确定这个文件放在哪里、如何更新。对没有记录的新测试,给一个默认值,在下一次运行中补上。
# 균등 분할: 느린 파일 하나가 전체를 붙잡는다
조각0 [==== ] 2분
조각1 [================== ] 9분 <- 전체 9분
조각2 [===== ] 3분
# 시간 기반 분할: 합이 비슷해진다
조각0 [======== ] 5분
조각1 [======== ] 5분 <- 전체 5분
조각2 [======= ] 4분
拆分必须是确定性的
这是关键。同一个提交,无论什么时候运行,都必须得到同样的分配。 如果分配每次运行都不同,三样东西就会崩塌。
第一,无法复现失败。“分片 2 失败”这条记录,在下一次运行中指向的是另一组测试。第二,重试失去意义。即使只想重跑一个分片,也无法保证那个分片装的是同样的测试。第三,顺序依赖的问题会时隐时现,被误认为是“偶尔失败的测试”。
所以分配只能靠由提交决定的值来计算,而不能靠运行时间或随机数。列表要排序,耗时记录也要从提交进仓库的文件里读取。用哈希决定分片时,也要使用测试名称这样稳定的字符串。
拆分后暴露出的顺序依赖
拆分最常揭出的,是单独运行能通过、一起运行却失败的测试,以及反过来的情形。原因多半是共享状态:前一个测试留下的数据库行、全局变量、临时文件、注册过的全局配置,被后一个测试依赖着。
这样的测试本来就是坏的,只是因为恰好总是按同样的顺序运行,才一直隐藏着。拆分只是打乱了这个顺序,把问题暴露出来。所以,引入拆分之后立刻出现的失败,通常不是拆分的 bug,而是早已存在的缺陷,带着这种判断开始排查才是对的。确认的方法也很简单:把失败的测试单独运行一遍。单独能通过,就说明它依赖着某种状态。
合并之后才有判定
每个分片的结果都只是部分信息。必须等所有分片都结束、把结果合并之后,才会有判定。 所以流水线中必须有一个步骤:等待各分片,收集结果文件并汇总。每个分片把自己的报告作为产物上传,合并步骤下载这些产物并加总。GitLab 文档写道产物“用于在各阶段之间传递中间结果”,指的正是这种用途。
汇总步骤要检查的不只是通过的数量。还要同时看有没有分片缺失,以及总测试数是否是预期的值。如果某个分片整个挂掉了,而其余的全是绿色,汇总得很马虎的流水线就会判定为绿色。一个运行了 0 个测试却成功的分片,也是同样的情形。
还有一条判定的不变式:分片数从 3 改为 5,总体判定必须保持不变。 这是检验拆分是否正确的最好方法。把同一个提交只改变分片数运行两次,对比总测试数和失败列表。
在现场相遇的样子
- 把速度快的测试集中放在前面的分片,几秒钟就能知道失败。全部单元测试往往比一个集成测试结束得更快,所以只要调整放置顺序,体感上的反馈时间就会大幅缩短。
- 增加了分片数,总时间却不变。通常是最慢的那个文件成了瓶颈,或者每个分片每次都要重新下载依赖,准备时间超过了运行时间。
- 听到“只有分片 4 一直失败”的时候,先确认分配是不是确定性的。如果分片编号每次指向的都是不同的测试,这句话本身就不成立。
参考
- 持续集成(快速构建):https://martinfowler.com/articles/continuousIntegration.html
- GitHub Actions 矩阵:https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
- GitLab 作业产物:https://docs.gitlab.com/ci/jobs/job_artifacts/
下一项实验要做什么
用 shell 和 python3 亲手构建测试运行器和拆分器。先按文件均分拆分,测量各分片的耗时相差多少,再读取耗时记录,重新拆分,使总和接近。用同一个提交拆分两次,核对分配是否连一个字符都相同,并与故意混入随机数的拆分器对比,看看什么会崩塌。然后加入依赖共享状态的测试,重现顺序依赖,并把各分片的报告汇总,确认缺了一个分片时汇总步骤能否抓出来。最后一步检验:改变分片数,总体判定是否保持不变。