跳到正文
Linear:Now·· 2026-09-21精选AI 评分61

Linear 分享 CI 提速实践:PR 等待时间从 6 分多降至 5 分出头,单测机器时间约减半

AI coding has made CI a bottleneck, so we reworked ours to keep up

AI 导读

Linear 工程团队详细介绍其 CI 优化实践:在测试套件今年年初以来近乎翻四倍的情况下,PR 等待时间从超过 6 分钟降到 5 分出头,每项测试的 runner 时间约减半。

推荐理由

Linear 自述在测试套件近乎翻四倍的情况下把 CI 等待时间和 runner 成本降下来,四类优化手法可迁移到其他工程团队。

正文 · AI 翻译

今年早些时候,我打开 Linear,发现 CTO Tuomas 给我指派了一个 issue,标题是“CI 成本太高”。顺便,他还希望我把 CI 做得更快。

智能体让代码交付速度成倍提升,但验证这些改动却没能跟上同样的节奏。每个 PR 仍然必须通过 CI,所以随着开发提速,CI 就成了瓶颈,推高基础设施成本,也让开发者和智能体等反馈等得更久。

为了让 Linear 的 CI 性能更好,我们主要优化两个指标:PR 在 CI 上等待的时间,以及消耗的 runner 时间。尽管从年初到现在,我们的测试套件规模几乎翻了两番,但 PR 等待时间从 6 分钟以上降到了 5 分钟出头,同时每个测试的 runner 时间大约减半。

Performance metrics chart showing test coverage increase (blue line) and machine time reduction (white line) from January through September.
这是以 1 月第一周为基准的测试套件性能。白线追踪的是每个测试的机器时间,在我们加入测试分片时出现尖峰——分片能缩短等待时间,但会消耗更多机器时间——之后在 checkout 卡顿问题期间又出现了一次

总体来看,我们从四个方面改进了 CI:

  • 升级基础设施和工具链
  • 优化那些会阻塞其他工作的任务
  • 减少重复的初始化
  • 让测试执行更高效

Linear 的代码库主要是 TypeScript,但其中许多优化适用于各种语言和工具链。

升级基础设施和工具链

早期的一些收益几乎不需要优化 CI 本身。我们把工作负载从 GitHub Actions 迁到第三方 runner,这些 runner 有更快的 CPU、更高性能的存储和更好的缓存基础设施,等于用更快的机器跑同样的流水线。对比切换前后各两天的同类任务,任务平均快了 34%,其中一些工作负载如 tsc 下降了 52%。

另外,工具链现代化也带来了回报。切换到原生 TypeScript 编译器 tsgo 后,tsc 检查的每周中位数耗时下降了 73%,降幅大到足以让瓶颈完全从类型检查上移开。

去掉类型检查器的 lint

Lint 是另一个早期目标。我们有少量自定义 lint 规则依赖 TypeScript 的类型信息,用来实施限制或应用自动修复。这意味着每次 lint 运行都必须先构建完整的类型图,再评估这些规则,使 lint 成为我们最耗内存的 CI 任务之一。

我们重写了这些规则,改为对抽象语法树做静态分析,在没有类型信息的情况下识别类函数结构和守卫模式。这让 ESLint 彻底摆脱了 TypeScript,API lint 时间减少了 68%,全仓库 lint 时间减少了 55%,内存占用也大幅下降。

去掉对类型信息的依赖,也让我们后来迁移到 Oxlint 容易得多,因为纯基于语法的规则移植起来很直接。Oxlint 本身也减少了 lint 消耗的 CI runner 分钟数。

优化那些会阻塞其他工作的任务

底层基础设施和各项检查跑得更快之后,我们把 CI 当作一个整体系统来看。这一视角让我们注意到排在所有环节最前面的那些小任务。每次运行开始时,都要先检查 PR 改动了哪些路径,以及这些测试是否已经针对相同输入通过过。我们在任务层面用这些检查做门禁,这样被跳过的工作就不会占用 runner,但这也把它们直接放到了关键路径上。八个 API 测试分片必须等它们完成才能启动,因此哪怕很小的延迟也会被不成比例地放大。

只拉取每个任务真正需要的内容

我们有多个工作流都以一个变更检测任务开头,由它决定接下来跑什么;比如它会检查 diff 中是否包含数据库迁移,并输出一个信号,用来调度相关的数据库 CI 检查。这些任务原本会检出完整的工作树,尽管它们只需要其中很小一部分。我们限制了拉取深度,把这些门禁中最慢的一个从 94 秒降到 20 秒;对于根本不需要工作树的任务,则完全去掉检出步骤,把耗时从 27 秒降到 7 秒。对于 commit push 和 merge-queue 事件,确实需要对比路径,我们发现采用稀疏、无 blob、限制历史的检出方式就足够了,又省下约 11 秒。

Performance distribution histogram: blue bars (After 8s median) and gray bars (Before 26s median), showing reduced load times after optimization.
变更检测任务的中位耗时从 26 秒降到 8 秒,p90 从 31 秒降到 12 秒,最慢的一次从 138 秒降到 37 秒。

让检出更稳健

更换底层 runner 基础设施之后,我们发现任务中的检出耗时(使用 actions/checkout)变长了,有时还会挂起。由于第三方 runner 位于 GitHub 网络之外,它们依赖直连 IP 链路访问 GitHub。服务商排查后认为,挂起是该链路间歇性劣化导致的。我们有多个工作流以检出开头,所以一次拉取卡住就可能拖慢整个 CI 运行。

为了应对网络不稳定,我们用自己的复合 action 替换了 actions/checkout,它会带退避地重试,并设置 GIT_HTTP_LOW_SPEED_LIMIT 和 GIT_HTTP_LOW_SPEED_TIME,让卡住的连接在约 30 秒后中止而不是一直挂着,同时还使用检出缓存,在粘性磁盘上保留一个持久的 git 镜像。结果是关键路径任务空等检出完成的情况大幅减少。

尽量减少关键路径上的环节

关键路径上的任务并非都必须留在那里。我们原本在合并前的最后一项检查中写入缓存标记,这意味着一个 PR 即使测试已经通过,仍可能滞留在合并队列里。我们把这步写入移到一个在测试分片完成后运行、但不作为任何门禁的任务中,从而为每个 API PR 和合并队列条目在合并路径上省下 42 秒。

这些改动合在一起,让 API PR 在缓存未命中时的必需检查缩短了大约一分钟,同时也减少了 runner 的启动次数。

减少重复的环境准备

接着,我们转向每个任务都会重复付出的准备成本,比如启动 runner、安装软件包、准备构建依赖。这些开销意味着一个只做几秒钟有效工作的任务,可能消耗掉好几分钟的基础设施时间。以下是我们为绕开这个问题采取的几项措施:

在 CI 镜像中预装共享依赖

我们的 API 测试分片每次运行都要用 apt 安装同一个 Postgres 客户端,各花 7 到 8 秒。我们把它移进一个包含 Node 和该客户端的小型 CI 基础镜像,这样每个分片都能从一个开箱即用的环境启动。后来我们发现,在安装过程中下载所需的原生构建头文件偶尔会卡住,于是把这些头文件也加进了镜像,缩短了长尾耗时。

只安装每个任务真正需要的依赖

Linear 的代码库是一个用 pnpm workspace 管理的 monorepo。我们的 API 测试工作流原本会安装整个 workspace,尽管它只需要 API 包及其依赖。把安装范围限制在 API 包后,pnpm install 从 44-73 秒降到 16-18 秒。我们把同样的做法套用到与 API 相邻的任务上——这些任务原本各自安装整个仓库,还上传一份后续运行几乎从不命中的依赖缓存。

重建更快时就不要缓存

我们还测试了缓存 node_modules,结果发现重建更快。缓存键依赖一个频繁变动的 lockfile,而且即便命中缓存,恢复也要约 28 秒,而过滤安装只需约 7.5 秒。缓存只增加了保存时间和波动性,没带来任何明显好处。

这三项改动合起来,把每个分片的准备时间缩短了约 44%,从 110-140 秒降到 67-73 秒。

GitHub Actions workflow comparison showing test-api runs with step-by-step timing breakdown; left run (4m 57s) versus right run (3m 14s), demonstrating performance improvements across pipeline stages
测试分片的 p95 耗时

除此之外,还有一些重复的准备步骤可以彻底省掉。

避免重放未变化的准备步骤

有些准备工作只有在输入变化时才需要重做。比如我们的 API 容器,每次运行都会重放完整的数据库迁移历史,哪怕这个 PR 并没有改动 schema。对这类情况,我们改为加载生成的 schema 快照和 bootstrap 文件,把每个容器的数据库准备时间从约 12 秒降到 1-2 秒。

把零散的短检查合并成更少的任务

有七项独立检查各自都要启动 runner、检出仓库、安装依赖,然后只做几秒钟的实际工作。我们把它们合并成两个任务,再在这两个任务里并发执行这七项检查。这样,重复支付同一笔准备开销的次数从七次降到两次。按六月的用量计算,这项改动每月节省约 87,000 runner 分钟,相当于我们 CI 总用量的 11.8%。

Before and after comparison of CI/CD pipeline batching optimization, showing test workflow steps with execution times and dependencies, with all tasks marked as successfully completed

让测试执行更高效

每个测试分片的固定成本降下来后,我们就有余力把 API 测试套件并行得更激进。它是我们工作流中规模最大、执行最频繁的部分之一,因此这里的改进对合并时间的影响格外大。

按测试运行器的视角来平衡工作

Vitest 是我们用于 TypeScript 测试套件的测试运行器,它按文件而非单个测试的时长来分配工作。这意味着少数异常大的测试文件可能主导一个分片,实际上拖住整个套件的完成,哪怕其他分片早就跑完了。

我们在保持测试结构不变的前提下,把这些大文件拆成更小、更聚焦的文件,然后评估了不同的分片和 runner 配置。今年早些时候我们已经从三个分片增加到四个;在初步基准测试中,增加到八个让关键任务快了约 19%,成本也低了约 19%。改动一周后,最慢的分片从 5.25 分钟降到 4.33 分钟。

只在严格的隔离规则下共享模块状态

Vitest 默认会隔离每个测试文件,对我们来说,这意味着每个测试分片都要重新构建实体、GraphQL 和装饰器图。我们引入了一个可选的 vitest 项目,配置了 isolate: false,让安全的文件可以在每个 worker 内共享模块注册表。

Module state caching optimization: Before (individual state per file) versus After (single shared state), reducing redundant rebuilds across worker processes

这是我们单项性能提升最大的一次,按我们的体量计算,每月大约节省 17%。最慢的分片从约 300-379 秒降到约 195 秒,API 分片的 runner 总耗时从每次运行约 32.8 分钟降到 22 分钟。

这也是正确性风险最高的一项优化。我们要求每个文件都显式加上 opt-in 注释来声明资格,并为共享状态补上了必要的清理逻辑。有少数文件使用了假定时器或共享状态,我们无法安全地拆解,就把它们留在了隔离项目里。另外,现在大部分测试由智能体编写,我们同步更新了各自的智能体技能,把这项性能 opt-in 也纳入其中,让生成的测试默认遵循同样的约束。

分片受限于初始化开销

只有当每个分片的固定成本足够低时,继续增加分片才有收益,因为分片数翻倍,工作流花在初始化上的时间也会翻倍。正是前面提到的初始化优化,才让 8 个分片变得可行。按每个分片 110-140 秒计算,8 个分片光初始化就要花掉 15-19 分钟的 runner 时间,比测试本身还多。现在初始化约 40 秒,8 个分片的总初始化时间比之前的 4 个分片还少,同时测试的并行度翻了一倍。

Test sharding optimization: increasing from 4 shards (8.3 min setup) to 8 shards (7.5 min setup) with better parallelization of test workloads
初始化优化之前,测试任务用 4 个分片,花 8.3 分钟做初始化。优化之后,我们可以跑 8 个分片,初始化只用 7.5 分钟。

能在整个系统中叠加的改进

如果今年早些时候我们没有刻意去改进 CI,今天的测试套件大约要跑 11 分钟,接近开发者现在等待时间的两倍。这件事也还没结束。我们的代码库显然会继续增长,目前每周大约新增 2,000 个测试。在增长的同时保持 CI 快速将是一项持续的工作,其中很大一部分是把这次过程中学到的东西用到新的瓶颈上。

Breakdown of CI/CD performance improvements across pipeline stages, showing percentage time reductions ranging from -12% to -89% across toolchain, gating jobs, setup, and overall metrics

来源:Linear:Now · linear.app