Linear 的 Mufeez Amjad 在 linear.app 的 Now 栏目写了篇 CI 优化。我一开始以为是那种「12 个技巧让你的 CI 更快」的清单,读到第三部分才确认不是。
这篇我没跑,纯读的——CI 优化这种没法顺手验证,我只能照着他的账本过一遍。结果读下来,账对得上。他们说的其实就一句:AI 代理把代码提交速度提上去了,变更验证没跟上,每个 PR 还是得排队过 CI,CI 在开发提速之后变成了瓶颈——一边推高基础设施成本,一边让开发者和代理干等反馈。这个「代理也一起等」我读过很多工具更新说明都没人提,其实这才是代码写完到合码的真实时间线里最无聊的一段。
他们的优化目标只有两个:PR 等 CI 的时间、CI 消耗的 runner 时间。注意,不是把「CI 跑得慢」当单一问题修。我读到后面才回过味来,整篇的优化对象不是「慢」,是「每次跑之前先干一堆跟本次改动无关的活」。
先丢几个数。今年年初到现在,Linear 的测试套件规模快翻四倍,PR 等待时间从超过 6 分钟压到略高于 5 分钟,同时每个测试消耗的 runner 时间大约减半。这组合靠的不是把套件跑快,是把验证路径上一堆每次都要重复的固定成本拆了下来。
我读到 lint 那段才确认这篇能帮我把脑子里几个模糊判断说清楚。他们有一部分自定义 lint 规则依赖 TypeScript 类型信息,每次要先构建完整类型图,lint 因此变成内存消耗很高的 CI 任务之一。后来把这些规则重写成基于 AST 的静态分析,不再需要类型信息,ESLint 完全移除 TypeScript 依赖。结果 API lint 时间降 68%,全仓库 lint 降 55%,内存占用也显著下降。这个改法我之前在一个 pnpm workspace 项目里也用过类似思路,但处理得没他们彻底——当时只是把类型检查踢到独立 stage 就完事,lint 还是带着 TS 的尾巴。
还有个附带效果:lint 不再抓类型信息之后,迁移到 Oxlint 也更容易了,很多东西可以更自由地拆。
有一块是纯低效的删除,读起来特别像对账。他们 API 测试分片每次跑之前都要用 apt 装同一个 Postgres 客户端,7 到 8 秒。后来把 Node 和那个客户端一起放进小型 CI 基础镜像,分片直接从就绪环境启动。再后来把原生构建头文件也加进镜像,因为安装过程中下载这些头文件偶尔会挂起。这个「偶尔挂起」就是 CI 最磨人的地方,稳定慢反而是最好查的。
缓存那事我一开始也想错了。他们试着缓存 node_modules,发现重建更快——缓存键依赖频繁变化的 lockfile,就算命中也要约 28 秒才能恢复,而过滤安装只要 7.5 秒。缓存只多了保存时间和波动,没带来收益。这些改动加起来,每个 API 分片的初始化时间从 110 到 140 秒降到 67 到 73 秒。
为什么初始化这个数重要,他们还算了一笔账:分片受初始化开销限制,分片数翻倍,初始化时间也翻倍。如果在每片初始化 110-140 秒的状态下上八个分片,光初始化就要吃掉 15 到 19 分钟 runner 时间,超过测试本身。所以八个分片不是随便上的,得先砍初始化。砍完之后再上八个分片,总初始化时间反而少于之前四个分片。
后面还有一段我特别注意的,跟「AI 写完的测试自己变成 CI 负担」这条缠绕上了。Linear 用的 Vitest 默认隔离每个测试文件,而他们的 API 测试需要在每个分片重建实体、GraphQL 和装饰器图。他们引入可选的 vitest 项目,设置 isolate: false,让安全文件在同一个 worker 内共享模块注册表。这是全文最大的单项改进——最慢分片从 300 到 379 秒降到约 195 秒,API 分片每次运行总 runner 时间从约 32.8 分钟降到 22 分钟。
但这也是正确性风险最高的一项。每个文件必须显式注释选择加入,共享状态要做必要清理,少数用假计时器或者难安全解耦状态的文件仍留在隔离项目里。我读到这里想到的是,agent 现在写的测试如果按默认模板来,几乎都会把隔离成本叠满。Linear 的做法是把性能约束写进代理技能,让生成的测试默认走共享状态的选择加入路径。不这么干,测试越多,CI 越胖。
还有一条纯流程的浪费,跟性能无关,却比很多「提速」都值钱。他们原先在合并前最后一次检查中写缓存标记,导致 PR 即使测试通过也可能滞留在合并队列。把这个写入挪到测试分片完成后、不阻塞任何任务的工作里,每个 API PR 和合并队列条目在合并路径上省 42 秒。
对了,有个数我记得很牢。他们七个各自启动 runner、检出仓库、安装依赖但只做几秒有用工作的独立检查,合并成两个任务、任务内并发跑这七项。按 6 月使用量估算,每月省约 87,000 runner 分钟,相当于总 CI 使用量的 11.8%。这个「只做几秒有用工作」是很多流水线里的隐形税,真的跑出来才对得上账。
有一段我没料到的。换到第三方 runner 后,actions/checkout 的检出时间反而变长,有时候挂起。原因不复杂:第三方 runner 在 GitHub 网络之外,依赖直连 IP 链路访问 GitHub,而提供商发现那条链路有间歇性降级。他们用自研复合 action 替换 actions/checkout,加退避重试,设置 GIT_HTTP_LOW_SPEED_LIMIT 和 GIT_HTTP_LOW_SPEED_TIME 让停滞连接约 30 秒后中止,再用带持久 git 镜像的 checkout 缓存。这篇的教训之一:CI 慢有时候不是你的测试慢,是外面有链路在抖动。
写到这,那个数我也顺手记下了:如果没有这些刻意改进,今天的测试套件大概要 11 分钟,接近现在开发者实际等待的两倍。代码库还在每周新增约 2,000 个测试,CI 不会自己变快。
不点链接也能带走的一句:动测试本身之前,先把每个 job 起来之前那几十秒里跟本次改动无关的活找出来删掉。
