构建机核数翻了一倍,全量构建只快了一点点;接上远程缓存后同一台机器的增量构建从十几分钟掉到几十秒,两周后又悄悄回到十几分钟——问题出在缓存键上,不在 CPU 上。这两句话描述的是两次不同的失望,而很多团队把它们当成同一件事在处理,于是钱花在加核上,时间却一分没省下来。
先看第一句。一个全量构建的时间从来不是纯粹的"编译核·分钟"。以 Bazel 这类以构建动作(action)为最小单位的工具为例,一次构建大致分成加载、分析、执行三个阶段。执行阶段的动作可以并行,加载与分析阶段的并行度远低于执行阶段,具体并行能力以实际版本文档为准。再往下看执行阶段内部,也不是所有动作都能摊到核上:链接、聚合打包、全局性的代码生成与一致性检查,以及一部分必须串行执行的测试,天然只能一个一个来。
用 Amdahl 的口径估算,若一个全量构建里不可并行的部分占比为 s,那么核数翻倍带来的加速比上限是 1/(s + (1-s)/2),永远够不到 2。若串行段占总时长两成,翻倍的收益上限就在 1.6 倍附近;若串行段占三成,上限掉到 1.5 倍附近。也就是说,"核数翻倍、时间减半"在数学上从来就不成立,能拿到三到四成的改善已经接近这条曲线的上限。这个估算是口径,不是承诺,具体占比要按自家构建图拆解后才知道。
真正容易被忽略的是:全量构建的账和增量构建的账不是同一笔。全量构建受限于核数与串行段,增量构建受限于"有多少动作必须真的重跑"。当团队抱怨构建慢时,得先问清楚抱怨的是哪一种。多数日常等待发生在增量构建上,而增量构建的时间几乎与核数无关。
接上远程缓存的第一天,效果往往非常夸张:改一行代码,大部分动作的缓存键没变,结果直接从缓存取回,十几分钟的增量构建掉到几十秒。这时候所有人都觉得问题解决了。
两周后,同一台机器、同样的改动量,时间又回到十几分钟。缓存服务没宕机,网络也正常,变的是缓存键。常见的触发点有四类:CI 机器上的 SDK 或编译器在某个夜里自动升了补丁版本;某次提交为了让版本号显示得更"有用",把构建号注入进了动作的环境声明;有人把检出目录从 build 改成了 build2;某个底层库开始把构建时间写进产物。这四类改动在代码评审里通常都"看着没问题",因为它们不改动任何一行业务逻辑,但它们改的是缓存键。
这里有个判断值得单独拎出来:一旦上了远程缓存,构建就从本地 CPU 型负载变成了"命中率 × 网络往返"型负载。决定构建时长的不再是核数,而是缓存命中率、单个缓存条目的体积,以及到缓存服务的往返时延。命中率每下降几个百分点,构建时间可能成倍上升——因为从缓存取一个结果和真编译一次的耗时差距,量级上通常在几十倍到几百倍之间(这个倍数是估算,需按自家构建样本实测确认)。而拉低命中率的往往不是算力,是环境里那些被当作输入哈希进去的东西:工具链版本、环境变量、绝对路径与时间戳。
要理解命中率,得先看清键是怎么生成的。Bazel 是 Google 开源、后由社区维护的构建与测试工具,它以构建动作为最小单位,每个动作由输入文件内容、命令行、工具链与声明的环境共同决定。远程缓存与远程执行都基于 Remote Execution API(一套 gRPC 接口规范),其中内容可寻址存储(CAS)存放输入与输出文件,动作缓存(Action Cache)存放动作到结果的映射。
于是"缓存键"在本质上就是那个动作的内容哈希:把输入文件的内容摘要、命令行字符串、工具链相关输入、声明的环境变量、执行平台属性拼成一个动作描述,再对这个描述做摘要。输入集合里任何一项变化都会产生不同的键,从而未命中。反过来说,只要键相同,缓存就认为这是同一次动作,可以复用上一次的结果——它不会去验证"这两个键相同的前提是否真的成立"。
这就带出环境变量这条最容易踩的线。Bazel 默认只把显式声明的环境变量纳入动作哈希,未声明的环境变量不会自动失效缓存。这意味着环境变量的影响是双向的,且两个方向都会出问题。
第一个方向是声明不足导致的脏命中。动作在运行时读了 PATH 里的编译器、读了 LD_LIBRARY_PATH 里的库、读了 TMPDIR,但这些变量没有被声明,于是它们不进键。换一台机器,PATH 指向的编译器小版本不同,动作键却完全相同,缓存照样命中,取回的却是另一套工具链产出的二进制。这类问题排查起来极其痛苦,因为现象是"偶发的诡异崩溃",而不是"构建失败"。
第二个方向是声明过度导致的过度失效。为了让行为"看起来确定",有人把 CI 提供的 BUILD_NUMBER、GIT_COMMIT、构建启动时间统统声明成动作环境。结果每次构建的键都不同,全图动作集体未命中,缓存等于关掉了。这类问题的现象是"命中率长期在低位",而不是报错。
所以缓存键设计的核心不是"多声明一些"或"少声明一些",而是把真正影响产物内容的变量找全,同时把每次都变的、与产物无关的变量挡在动作之外。Bazel 用 --incompatible_strict_action_env 一类选项把 PATH 固定成常量值,就是为了让本地开发机的环境差异不再渗进键里,具体行为以实际版本文档为准。
把账写成式子会更清楚。上缓存之后,一次构建的墙钟时间大致可以拆成四项:
核数只出现在第二项的分母里。当命中率很高时,第二项已经不是主导项,第三项和第四项成为主角,此时再加核对总时长几乎没有贡献——这就是"接上缓存后加核失效"的完整解释。而当命中率崩掉时,第二项重新变成主导项,核数又变得有价值了。同一套配置在不同时期的结论相反,原因就在这里。
还有一个不那么直观的点:命中率对墙钟时间的影响是非线性的。未命中动作要走完整编译流程,命中动作只走一次下载,两者耗时差一个到两个数量级。因此在高命中率区间,命中率从 95% 掉到 90%,未命中动作数翻了一倍,而由于剩余动作又大多落在依赖图的下游、会连带触发级联重算,最终墙钟时间的涨幅往往远超"翻一倍"这个直觉。具体斜率取决于构建图的形状,需按自家样本实测确认。
下面四类输入有一个共同特征:改它们的人通常不认为自己在改构建配置。这也是它们比"忘了配缓存"更难防的原因。
工具链版本漂移。编译器二进制本身是动作的输入文件,它的摘要进了键。GCC 或 Clang 的补丁版本、JDK 的 build 号、Android NDK 与 build-tools 的版本、Rust 工具链的 nightly 日期,任何一项变动都会让使用该工具链的全部动作集体失效。一次静默的补丁升级,等价于把整个缓存推倒重来。更麻烦的是,工具链变化带来的未命中是全局性的,会同时拉满执行集群的排队时间。
环境变量泄漏。这一条上一节已经展开,这里补充一个具体形态:本地化与区域设置类变量(locale、时区)如果被声明进动作,不同开发机上的取值不同,命中率会被切成几份。另一类形态是 CI 平台注入的构建上下文变量,每一次流水线运行都不同,属于"每次必失效"的声明。
绝对路径与工作区名。很多编译器会把源文件的路径写进产物:调试信息里的编译目录字段、__FILE__ 宏展开、代码里拼接的绝对路径字符串常量。于是同一份代码在 /home/a/build 和 /home/b/build2 下编译出的产物字节不同,缓存键自然不同。CI 与本地目录不一致、多分支并行检出到不同目录、构建容器的挂载点不同,都会触发。GCC 与 Clang 提供 -ffile-prefix-map 与 -fdebug-prefix-map 用于把这些前缀映射成固定值,具体选项名与支持版本以编译器文档为准。
时间戳与随机数。把 __DATE__、__TIME__ 宏展开进代码,把构建时间写进版本字符串,用随机临时文件名做中间产物,或者让归档步骤保留文件的 mtime——这些做法让每次构建的输入或输出都带有当次构建的唯一性,命中率会趋近于零。可复现构建社区(reproducible-builds.org)定义的 SOURCE_DATE_EPOCH 环境变量就是为了解决这一类问题:把归档时间戳固定成一个由源码决定的值,而不是"现在"。这是通用工程常识,并非某次实测的结果。
Nix 在这件事上走的是另一条路,值得对照着看:它采用内容寻址的存储(/nix/store),每个构建结果按 derivation 哈希寻址,路径本身由内容派生,因此不存在"工作区名不同导致路径不同"的问题;二进制缓存(substituter)允许直接从缓存下载已构建好的结果而不必本地重建。代价是它对构建过程的封闭性要求更严,不封闭的 derivation 会从内容寻址退化为路径寻址。
| 影响因素 | 它怎么进入缓存键 | 命中率下降的典型幅度(估算口径) | 稳定它的做法 | 怎么验证它真的稳定了 |
|---|---|---|---|---|
| 工具链版本 | 编译器与 SDK 二进制作为动作输入文件,其摘要直接进入动作哈希 | 一次补丁级升级可让使用该工具链的动作整体失效,命中率可能出现数十个百分点的断崖,量级需按自家构建样本实测确认 | 把工具链版本锁到补丁级并纳入仓库声明,关闭 CI 与开发机的自动更新,工具链变更走独立评审并安排缓存预热 | 在 CI 启动时打印工具链摘要并与基线比对,动作级命中率曲线在升级窗口外保持平坦 |
| 环境变量 | 仅显式声明的变量进入哈希;未声明的变量不进键,会导致结果依赖了它却仍命中 | 声明了每次变化的变量(构建号、提交号)会让全图动作每次失效;声明不足则不降命中率但会引入脏命中 | 用白名单方式声明动作环境,把每次变化的变量交给 stamp 通道单独处理,固定 PATH 等易漂移变量 | 在两台环境不同的机器上构建同一提交,比对产物摘要是否一致且命中率是否一致 |
| 绝对路径与工作区名 | 路径被写进调试信息、宏展开或字符串常量,成为编译命令或产物内容的一部分 | 本地与 CI 目录不一致时,同一提交在两套路径下各存一份缓存,有效命中率按路径种类被摊薄 | 用前缀映射选项把路径改写成固定值,统一本地与 CI 的检出目录名,避免在代码里拼接绝对路径 | 把同一提交检出到两个不同目录分别构建,比较产物字节与动作摘要 |
| 时间戳与随机数 | 构建时间、随机文件名、归档 mtime 进入输入或输出,使每次构建的输入集合都不同 | 影响范围若落在图的上游,命中率可能趋近于零,构建时间退回未命中主导区间 | 禁用日期时间宏,设置 SOURCE_DATE_EPOCH,中间产物按内容哈希命名,归档固定 mtime | 同一提交连续构建两次,比较最终产物摘要是否完全相同 |
| action 粒度划分 | 粒度决定每个动作的输入集合大小:粒度越粗,单个输入变动影响的输出越多 | 过粗会让一个小改动连带整个聚合动作失效;过细则元数据往返次数与动作数量同步膨胀 | 按模块边界切分动作,避免把整个大目录作为单个动作输入,同时避免逐文件建动作 | 统计动作总数与输入文件数的比值,以及单个动作输入集合的 P95 规模是否落在预期区间 |
| 缓存容量与淘汰 | 不进入键,但决定既有条目是否还在:条目被淘汰后等价于未命中 | 容量接近工作集阈值时淘汰加剧,命中率呈断崖式下降而非线性下降,斜率需按自家样本实测确认 | 容量按一次全量构建的输出总体积乘以需要并存的分支与工具链版本数估算,保留期与淘汰策略分开配置 | 持续记录 CAS 占用与淘汰字节速率,观察命中率是否随容量增长出现拐点并趋于平坦 |
最常见的错误度量是"整体构建是否变快了"。这个指标受改动量、机器负载、分支差异影响太大,看不出原因。要统计的是动作级命中率:命中动作数除以总动作数。这个数字能直接回答"有多少工作是真的重跑的"。
拿到动作级命中率之后,还要把它和下载命中率分开看。两者组合出四种状态,指向完全不同的问题:
第三种状态是这套度量最有价值的地方。很多团队花了两周查"为什么缓存不生效",其实只要把这两条曲线摆在一起,十分钟就能定位到是某次提交改了编译参数。
数据从哪来。Bazel 的 Build Event Protocol(BEP)会以事件流的形式输出构建过程,可以落成本地 JSON 文件,也可以推到后端服务,解析后就能统计动作数与执行结果;--profile 产出的跟踪文件配合分析子命令可以看动作在时间轴上的分布。缓存服务端自身的指标(CAS 读写字节数、动作缓存查询数、缺失 blob 查询数)同样必须采集,因为客户端看不到淘汰流量。具体选项名以实际版本文档为准。
还有一个很实用的手法:先开影子模式,也就是只写不读。让构建照常本地执行,把结果写进远程缓存但不读取,同时统计"如果读了会有多少命中"。这样能在不影响任何人的前提下拿到潜在命中率基线,也能在切换开关前预估收益。这是典型部署思路,并非特指某一真实客户。
容量这一层常被拍脑袋决定,而它恰恰是命中率最不可控的一环。先看清体积分布:大部分编译动作的输出在几 KB 到几百 KB 量级,而链接产物、聚合打包产物、测试日志与覆盖率文件会落在几十 MB 到 GB 量级。也就是说,条目数的大头是编译动作,字节数的大头是少数几个聚合动作。这两条长尾要分开统计,因为淘汰策略对它们的效果完全不同。
容量估算的口径建议这样定:一次全量构建产生的输出总体积,乘以需要同时存活的版本数。这个"版本数"不是分支数量那么简单,它是并行活跃的提交数 × 活跃的工具链版本数 × 需要保留的历史天数三者的乘积量级。分支越多、工具链切换越频繁,工作集膨胀得越快,且是乘法关系。
为什么强调非线性。当缓存容量远大于工作集时,加容量几乎不提升命中率,曲线是平的;当容量逼近工作集时,淘汰开始频繁发生,而且被淘汰的往往是刚被换出又被换回的热条目,命中率随容量下降的斜率急剧变陡,出现断崖。这是通用工程常识,不是某次实测的结果,具体拐点位置必须按自家构建的条目访问分布实测确认。
淘汰策略上,纯 LRU 对构建负载并不总是最优,因为构建负载有明显的周期性:每天上午的第一次全量构建会集中访问一批老条目。把保留期(按天数的硬性下限)与淘汰策略(LRU 或按访问频次)分开配置,通常比单一策略更稳。Nix 的闭包概念在这里也有参照价值:一个构建结果的完整依赖闭包决定了需要传输或复制多少内容,淘汰时如果只删结果不删闭包依赖,缓存里会积累大量无人引用的孤儿条目,占用容量却不产生命中。
单个条目的下载耗时可以拆成两段:一次或若干次控制面往返的固定时延,加上传输时间(条目体积除以可用吞吐)。对于体积在 KB 量级的编译输出,传输时间几乎可以忽略,耗时几乎全部由往返时延决定。而大型代码库的动作数量是万级的,哪怕用批量接口把往返次数摊薄一两个数量级,剩余的往返次数乘以几十毫秒的往返时延,依然会在账上留下一个可观的固定成本。
这就是为什么"带宽看起来很充裕"和"构建很快"是两件事。一个跨城访问的开发者,带宽可能有几百 Mbps,但往返时延高一个数量级,且连接建立、TLS 握手这类固定成本按连接摊销而非按字节摊销,于是小条目密集的负载会明显变慢。极端情况下,从远端缓存拉取一万个小条目的总耗时,会超过本地全量编译其中一部分动作的耗时,此时接缓存反而是负收益。
缓解手法有几条,都属于典型部署思路:把缓存服务放在与主要 CI 流量同一个网络区域内;为地理分散的团队在每个主要办公地放一份只读副本或本地二级磁盘缓存,写仍然回中心;确保客户端使用长连接与多路复用,避免每个条目新建连接;用只下载顶层输出或按需下载的策略减少回传字节与本地磁盘占用。Bazel 提供远程下载输出的策略选项(例如只下载顶层输出、按需下载),用于控制回传带宽与本地磁盘占用。
这里还有一条容易被忽略的判断:远程缓存的网络成本是按"动作数"而非"代码量"增长的。一个把十万个小文件各编译成一个动作的构建图,和一个把同样代码聚合成五千个动作的构建图,即使总字节数相同,前者的网络开销会高得多。这也是 action 粒度会同时影响命中率和网络两项的原因。
远程缓存解决的是"不用重跑",远程执行解决的是"重跑的那部分别在我这台机器上跑"。两者共用一套 gRPC 接口,但收益模型完全不同,不应该一起上。
一次动作在远端执行的耗时大致是:上传输入 + 排队等待 + 远端执行 + 回传输出。在本地执行的耗时是:本地执行时间除以本地可用并行度。只有当前者小于后者时,远程执行才划算。把这个不等式拆开看,能推出几条判断:
还有一个前置条件经常被跳过:远程执行能正确工作的前提是沙箱正确。Bazel 在本地执行时使用沙箱(Linux 上常见的实现为 linux-sandbox),用于限制动作只能访问声明过的输入。如果动作在本地非沙箱模式下能跑、在沙箱模式下缺文件失败,说明它声明的输入不完整,这类动作放到远端必然失败。因此正确的顺序是:先在本地强制沙箱跑通全量,再谈远程执行。这一步同时也是查"环境变量泄漏"的最好工具,因为沙箱会暴露那些没声明却被读取的依赖。
远程执行还有一个隐性的收益值得算:它让本地机器配置不再决定构建速度,团队里配置较弱的机器收益最大。但同一份账也要算上执行集群本身的成本,包括它需要的核数峰值、内网带宽与存储。这笔账和"给每个人换更好的开发机"是同一个数量级的比较,应该在决定前放在一起算。
下面这份清单按"从上游到下游"排列,每一条都能单独落地,也能单独验证。
这份清单里,"同一提交构建两次产物一致"这一条成本最低、收益最高。它不需要任何额外基础设施,只要在 CI 里多跑一次,比对摘要。它不能告诉你问题出在哪,但能第一时间告诉你问题存不存在。
排查顺序建议固定下来,避免每次都从头猜:
如果以上都稳定了,命中率仍然上不去,那问题多半不在配置,而在构建图本身。几个典型信号:头部动作的输入集合过大,任意文件改动都会连带大量动作失效;存在把多个模块聚合进单个动作的规则,导致缓存粒度粗到无法复用;依赖图里存在不必要的跨模块依赖,使改动的影响半径远大于改动本身。这些情况下,继续调缓存参数、继续加容量的边际收益会很低,应该回头做依赖裁剪与动作拆分。
还有一种情况应该直接承认缓存帮不上忙:如果团队的负载以全量构建为主(例如每次发布都从空缓存起,且工具链每周都变),那么缓存的价值主要落在日常开发循环上,对发布流程的加速有限。这时候应该把资源投向串行段的优化与执行集群的并发,而不是继续扩容缓存。
排查到最后,如果瓶颈既不在命中率也不在构建图,而在"人和缓存服务离得太远",那就进入了基础设施账的范畴:缓存服务与执行集群放在哪个网络位置、跨地团队怎么就近读取、回传带宽按什么峰值预留、存储按什么增长曲线扩容。一万网络深耕 IDC 行业 19 年(成立于 2007 年),在华南、华东、华北及中国香港、海外多地整合自营节点与 BGP 多线资源,提供服务器租用、机柜托管与带宽接入的一体化交付;具体机型、带宽与报价以官网实时信息或咨询确认为准。
这篇文章给的全是估算口径,不是可以照抄的参数。原因很简单,下面这些输入在不同团队之间差异太大,任何"典型值"都会误导:
压测的方法本身不复杂:用 BEP 落盘的事件流统计动作数与执行结果,用 profile 文件看时间轴,用影子模式拿潜在命中率基线,用服务端的读写与淘汰指标看容量健康度。真正难的是坚持采集足够长的时间——跨越至少一轮工具链升级和一轮分支合并高峰,才能看到命中率曲线的全貌。只看一天的数据,很容易把"刚预热完的最好状态"当成常态。
本文涉及的工具行为,基于 Bazel 与 Nix 的公开文档、Remote Execution API 的接口规范,以及可复现构建社区对 SOURCE_DATE_EPOCH 的公开定义;具体选项名称、默认值与支持情况以实际版本的官方文档为准。
需要明确的划界:本文只讨论远程缓存与远程执行的命中率口径与配套资源估算方法,不给出具体机型与报价,不提供性能承诺,不对具体构建工具做选型结论;所有命中率与耗时数字均为估算方法,必须按自家代码库实测确认。文中出现的部署安排都属于典型部署思路,并非特指某一真实客户,也不构成任何形式的交付承诺。
最后把这笔账的核心再压缩成一句:上缓存之前,构建时间的自变量是核数;上缓存之后,自变量是命中率、条目体积和往返时延。如果团队还在用加核的方式解决上缓存之后才出现的问题,那钱花在了已经不是瓶颈的那一项上。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品