去年底我接手了公司一个 Java 后端仓库的构建,不是我想接,是没人愿意管。CI 排队排到晚上十点还跑不完,谁改谁挨骂。
仓库情况先摆出来:42 个 Maven 模块,Java 17,Spring Boot 3.1.4,单元测试 3862 个,集成测试 417 个(大部分用 Testcontainers 起 MySQL 和 Redis),另外还有一个 Vite 打包的管理后台。CI 是 GitHub Actions,标准 runner,2 核 7G,ubuntu-22.04。
那会儿一次完整流水线的耗时,我用 gh run view 拉了两周的数据算 p50,是 23 分 07 秒,最慢一次 41 分钟。
我先做的不是优化,是统计。把每一步的耗时打出来之后,结果跟我原本猜的完全不一样:编译只占 2 分 50 秒,剩下 20 分钟全在下载依赖、起数据库容器、串行跑测试、以及构建 Docker 镜像。
然后老板说,加机器吧。
我算了一笔账。GitHub 的 larger runner 报价我记不太准了,大概比标准 runner 贵一个量级,四台 8 核的话一个月多出两千多美金。我说先试一台吧。跑下来,时间从 23 分降到 21 分 30 秒左右。90 秒。两千美金换 90 秒。
为什么?因为那 71% 的时间在等 IO、等容器起来、等串行的测试跑完,跟你有几个核基本没关系。这件事我后来跟好几个团队聊过,他们的第一反应也都是「加机器」,好像慢就等于算力不够。不是的。
下面说 7 个具体的坑,都是我们自己踩过的,数字是那个仓库的,你那边不一定一样。
坑 1:Maven 缓存 key 写错了。 原来的 cache key 是 ${{ github.sha }},等于每次 commit 都是新 key,永远 miss。改成 ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 之后,命中率从 0 涨到 87%,光这一步省了 4 分 40 秒。顺便说一句,actions/cache 命中之后也要看恢复时间,我们的 .m2 缓存 1.8G,恢复大概 25 秒,这个代价是划算的。
坑 2:git clone 拉了全量历史。 actions/checkout 默认 fetch-depth 是 0。那个仓库有 6 年历史,.git 目录 1.2G。改成 fetch-depth: 1,省 38 秒。如果你们仓库更大,这一项能省更多。
坑 3:Docker 没开 BuildKit。 加上 DOCKER_BUILDKIT=1 和 --cache-from,镜像构建从 3 分 20 秒到 47 秒。这个改动我印象里只花了十分钟,收益却是全场最高的几个之一。
坑 4:测试串行跑。 3862 个单测串行跑 9 分钟。我们用 matrix 按模块分了 4 组,配合 surefire 的 -DforkCount=2C,降到 2 分 30 秒左右。这里有个反直觉的地方:我试过分成 8 组,结果比 4 组慢 40 秒。因为每个 job 都有固定开销——拉代码、装 JDK、恢复缓存,加起来一份大概 22 秒,组数太多固定开销就吃掉收益了。4 到 6 组一般是甜点区。
坑 5:Testcontainers 每个测试类都重建 schema。 417 个集成测试,每个类都 @Testcontainers 起一套新容器,光 MySQL 初始化就 12 秒一次。改成静态单例容器 + @DynamicPropertySource 复用,省了 3 分多。这个改动风险也最高,因为测试之间可能互相污染,我们是花了大概一周才把残留的状态依赖清干净的。
坑 6:npm ci 每次都重装。 npm ci --prefer-offline --no-audit 加上 node_modules 缓存,前端那步省 55 秒。
坑 7:缓存反而拖慢了。 这个我觉得最有意思。有一阵我们缓存了 target 目录,缓存包 2.4G,恢复要 1 分 10 秒,但省下来的增量编译时间只有 40 秒。净亏 30 秒。删掉之后反而快了。所以别看到「加缓存」三个字就无脑加,缓存本身有成本,恢复也是 IO。
全部做完,最终是 4 分 12 秒。
如果让我排个顺序,是这样的:先减少工作量(该跳过的 job 跳过,该合并的合并),再并行化,再缓存,最后才考虑加机器。大部分团队是反过来的——先加机器,然后发现没用,然后开始乱加缓存,最后把流水线搞得又复杂又慢。
还有一件事得说清楚:这套东西不是所有情况都值得做。如果你一天只跑 5 次 CI,省 19 分钟一天也就省 95 分钟,可能还不如把本地的增量编译搞快一点,那个收益是每个开发者每天都能拿到的。我们那边一天 60 多次 push,排队才是真正的痛点,所以才值得花两周去抠。
最后给个方法论,比结论有用:先量,别猜。用 gh run view 的 job 耗时,或者 Jenkins 的 Stage View,把 p50 和 p95 都算出来。你会发现自己以为的瓶颈,八成不是真正的瓶颈。我们那次统计之前,全组人都以为是编译慢,因为本地编译确实慢——但本地慢是因为你机器上跑着 IDE、Chrome 和几十个别的进程。
对了,还有一个我到现在也没完全解决的问题:flaky test。我们可以把流水线做到 4 分钟,但只要有一个 flaky 的集成测试,重跑一次就是 4 分钟,一天重跑十几次,前面省的又还回去了。这个只能靠 quarantine,把不稳定的测试先摘出去单独跑,别让它卡主干。这个我们搞了三个月,从 11 个 flaky 降到 2 个,还没清干净。