lazy-trees 与大规模仓库求值:只取所需的文件树

在 nixpkgs 这类四万文件级仓库上,每次改一行都要先下载整棵源码树,求值因此被网络与解包拖垮。惰性树(lazy-trees)把「取整棵树」改成「用到哪个文件才取哪个」,同时保持 narHash 与旧行为一致。本文讲清其机制、开启方式、与 flake 输入的交互、eval-cache 复用,以及大规模仓库的提速实践与常见坑。

引言

「只改了一个字符,为什么 nix flake show 要等四十秒?」在 nixpkgs 或大型 monorepo 上,瓶颈往往不在构建,而在求值前的源码获取:flake 输入被当成一整棵源码树下载并解包进 store,四万多个文件、几百兆的 checkout 每次都要走一遍。惰性树(lazy-trees)把这一步改成按需取文件——只有被求值真正读到的那几个文件会被拉取,而 store 路径与 narHash 仍与「取整棵树」完全一致,因此缓存复用不受影响。

本文回答三个问题:惰性树在求值链路的哪个位置生效、如何确认它真的在生效、以及在 CI 与开发机上怎样把它变成可量化的提速。

前置:求值与构建性能优化 、flake 输入与锁文件 、fetchers 与源码获取 。

目录

1. 为什么大规模仓库求值这么慢

1.1 三段耗时

一次 nix build .#foo 的时间可以粗略拆成三段:

① 获取输入:把 flake 输入(本地路径 / git 仓库 / github:)变成 /nix/store 里的源码树
② 求值    :把 .nix 表达式算成 derivation(.drv)
③ 构建    :按 .drv 执行构建脚本,或从二进制缓存下载

多数人优化 ③,少数人优化 ②,而 ① 常常被忽略——因为它的成本被「第一次」掩盖了。第一次慢可以怪网络,但如果每次改一行都要重来,说明 ① 的成本没有被复用。

1.2 一个具体量级

以 nixpkgs 为例,量级大致如下:

项目量级说明
单文件数4 万+绝大多数是 .nix,但求值只读其中一小部分
解包后体积数百 MB取决于是否含 .git 与历史
单次全量 checkout秒级到十几秒磁盘 IO + 解包 + 哈希计算
实际被求值读到的文件数百到数千与目标属性路径相关

关键矛盾:读的文件数与下载的文件数不成比例。惰性树正是为这个比例差设计的。

1.3 为什么旧行为是「整棵树」

Nix 的 store 是内容寻址的:要得到一个确定的 store 路径,就必须先算出整棵树的哈希(narHash)。在惰性树出现前,唯一可行的做法是把整棵树物化(materialize)到 store 里再算哈希,于是下载与哈希都被绑定成「全量」。

记忆:慢的根源是「输入获取」这一段的整树物化——store 内容寻址要求先有全量哈希,旧实现只能先全量下载再算哈希。

2. 求值前的那一步:输入如何进入 store

2.1 fetchTree 是统一入口

无论 flake 输入写成 github:、git+file: 还是 path:,最终都会被翻译成一次 fetchTree 调用:

builtins.fetchTree {
  type = "github";
  owner = "NixOS";
  repo = "nixpkgs";
  rev = "24.11";
}

返回的属性集包含:

outPath      : store 里的路径(可用于 import / 拼接)
narHash      : 整棵树的哈希(SRI 或 base32)
rev          : 解析后的 commit
lastModified : 提交时间戳(参与 SOURCE_DATE_EPOCH 推导)
shortRev     : 短 hash,便于日志阅读

2.2 求值只接触 outPath 下的一部分

# flake.nix 里绝大多数 import 只碰一两个文件
let
  lib = import (nixpkgs + "/lib");            # 读 lib/default.nix
  pkg = pkgs.callPackage ./pkgs/foo { };      # 读本地路径
in pkg

如果 outPath 是一个「虚拟树」,import 触发的读操作就可以只拉取 lib/default.nix 及其递归 import 链,而不是整棵树。

2.3 store 路径必须提前确定

这里有个看似矛盾的要求:求值开始前就要知道 outPath,但 outPath 又由整棵树的内容决定。惰性树的解法是:先通过底层源(git 仓库)计算出树哈希,再把这个哈希作为 store 路径,文件内容延迟到真正被读时再取。

记忆:fetchTree 是输入进入 store 的统一入口,返回 outPath 与 narHash;惰性树的关键在于「先算出树哈希定 outPath,文件内容延后取」。

3. 惰性树的机制:虚拟文件树与按需取回

3.1 树对象

启用惰性树后,store 里出现的不再是「解包好的目录」,而是一个树对象(tree object):

/nix/store/<hash>-source      ← 逻辑上是一棵树,物理上可能是按需填充的
  ├── flake.nix               ← 被读过 → 已取回
  ├── lib/                    ← 部分取回
  └── pkgs/                   ← 未读 → 尚未取回

3.2 读取路径

① 求值器读 /nix/store/<hash>-source/lib/default.nix
② nix-daemon 查 tree 元数据:该文件在源仓库中的 blob 哈希
③ 若本地已有该 blob → 直接返回
④ 否则从源(本地 git 对象库 / GitHub API / codeload)取回并写入 store
⑤ 返回文件内容

对求值器而言,这一切是透明的:路径仍然可以 import、可以 builtins.readFile,只是第一次读会慢一点。

3.3 narHash 仍然一致

这是惰性树最重要的设计约束:

# 惰性树与全量树产出的 narHash 必须相同
# 否则同一份输入会得到两个 store 路径 → 缓存命中率崩塌
nix flake metadata github:NixOS/nixpkgs --json | jq -r '.locks.nodes.nixpkgs.locked.narHash'

因为 narHash 一致,远端二进制缓存里已有的产物仍然可以直接复用,惰性树不会让缓存失效。

3.4 惰性不等于免费

惰性树的代价:
  ✗ 每个文件的首次读取都有一次元数据查询(本地 git 很快,远端 API 有延迟)
  ✗ 若某操作遍历整棵树,仍然要全量取回,只是取回时机被推迟
  ✗ 远端源(github:)会受 API 速率限制影响,需配置 access-tokens

记忆:惰性树把 store 里的输入变成「树对象」——先由源算出树哈希定 outPath,文件被读时才从 git 对象库或远端取回;narHash 与全量树一致所以缓存不受影响,但遍历整棵树的操作仍会触发全量取回。

4. 开启与确认惰性树

4.1 开启方式

惰性树以实验特性形式引入,需要显式打开:

# 临时开启
nix --extra-experimental-features "nix-command flakes lazy-trees" flake show

# 永久写入配置
cat >> ~/.config/nix/nix.conf <<'EOF'
experimental-features = nix-command flakes lazy-trees
EOF

在 NixOS 上应当写进声明式配置:

{
  nix.settings.experimental-features = [ "nix-command" "flakes" "lazy-trees" ];
}

4.2 确认是否生效

# 方式一:看输入的类型与 narHash 是否已解析
nix flake metadata . --json | jq '.locks.nodes | to_entries[] | {k:.key, t:.value.locked.type}'

# 方式二:观察 store 里输入路径的实际大小
nix path-info -S $(nix flake metadata . --json | jq -r '.path')

# 方式三:在求值统计里看 fetch 次数
NIX_SHOW_STATS=1 nix eval --raw .#foo.name 2>&1 | grep -i fetch

最直观的判据:第一次 nix flake show 明显变快,且第二次几乎瞬时。

4.3 版本差异

早期:lazy-trees 必须显式加入 experimental-features,未开启时退回全量物化
后续:新版 Nix 对 flake 输入默认采用惰性求值,无需手工开启
排查:若行为与文档不符,先确认 nix --version 与 settings 中的 experimental-features

记忆:开启靠 experimental-features 里的 lazy-trees(NixOS 写 nix.settings),确认靠 nix flake metadata --json 看输入类型 + nix path-info -S 看实际体积。

5. 哪些输入形态支持惰性树

5.1 支持矩阵

输入形态是否惰性说明
git+file:///path是本地 git 对象库直接读 blob,最快
git+https://host/repo是走 git 协议或智能 HTTP
github:owner/repo是底层走 GitHub API / codeload,注意速率限制
gitlab: / sourcehut:是同上
path:/local/dir否本地目录直接复制进 store,本来就是全量
tarball:https://...否归档必须先完整解包才能算哈希
https://...zip否同上

5.2 为什么 path 不能惰性

path: 输入没有「源」这个概念——它只是一个本地目录。Nix 必须把目录复制进 store 才能得到确定的哈希,因此天然是全量的。

# 反例:把 nixpkgs 用 path: 引进来,惰性树完全失效
inputs.nixpkgs.url = "path:/home/me/nixpkgs";

# 正例:用 git+file 指向本地 checkout
inputs.nixpkgs.url = "git+file:///home/me/nixpkgs";

5.3 私有仓库与速率限制

# github: 输入在惰性取回时会调用 GitHub API,未认证时速率很低
cat >> ~/.config/nix/nix.conf <<'EOF'
access-tokens = github.com=ghp_xxxxxxxxxxxxxxxx
EOF

私有 git 仓库则依赖 ssh-agent 或凭据助手;在 CI 中应显式配置,否则会出现「求值到一半卡住」的现象。

记忆:git 类输入(git+file / git+https / github:)支持惰性树,path: 与 tarball: 天然全量;github: 走 API 取回,务必配 access-tokens 避免速率限制。

6. eval-cache:求值结果本身的复用

6.1 缓存的是什么

惰性树解决「输入取回」,eval-cache 解决「求值结果重算」:

eval-cache 键 = flake 指纹(self 的 rev/narHash + 相关输入指纹) + 属性路径
eval-cache 值 = 该属性路径求值出的值(如 drvPath、outPath、字符串)
存储位置      = ~/.cache/nix/eval-cache-v5/*.sqlite

6.2 常用开关

# 关闭缓存,用于排查「缓存导致结果不更新」
nix eval --no-eval-cache --raw .#foo.name

# 打开求值统计,确认命中情况
NIX_SHOW_STATS=1 nix eval --raw .#foo.drvPath

# 查看缓存目录大小
du -sh ~/.cache/nix/eval-cache-v5

6.3 缓存失效的条件

失效触发:
  - self 的源码树指纹变化(改了任何被追踪的文件)
  - 上游输入被更新(flake.lock 变化)
  - 显式 --no-eval-cache 或缓存文件被删除
不失效:
  - 仅修改了未被 git 追踪的文件(脏文件不在指纹内,注意这会造成困惑)

6.4 与惰性树的协同

两者叠加后的收益是乘法关系:惰性树让「输入取回」从全量变成增量,eval-cache 让「求值」从全量变成命中。在 nixpkgs 这种仓库上,nix flake show 的体感可以从数十秒降到一两秒。

记忆:eval-cache 缓存「属性路径 → 求值结果」,键是 flake 指纹 + 属性路径;用 --no-eval-cache 排查陈旧结果,用 NIX_SHOW_STATS=1 观察命中;与惰性树叠加是乘法收益。

7. 什么操作会强制拉取整棵树

7.1 触发全量取回的操作

✗ nix flake archive              # 显式归档整棵树,必然全量
✗ nix flake check(在 nixpkgs 上)# 遍历所有输出
✗ builtins.readDir 递归遍历整棵树
✗ lib.fileset 对整棵树做 toSource / 过滤
✗ nix store prefetch-file 之类对目录整体的操作
✗ 把 outPath 直接当 path: 传下去(会退化为物化整树)

7.2 典型反模式

# 反模式:为了取一个版本号,先遍历整棵树
version = lib.strings.fileContents (nixpkgs + "/.version");
# 这条本身只读一个文件,是 OK 的

# 真正的反模式:对整棵树做 readDir 统计
allFiles = builtins.attrNames (builtins.readDir nixpkgs);   # 触发根目录枚举
recursive = builtins.readDir (nixpkgs + "/pkgs");           # 触发子树枚举

builtins.readDir 只需要目录项元数据,惰性实现通常能只取「目录表」而不取全部文件内容,但一旦后续对每个条目 readFile,就退化为全量。

7.3 判断是否退化的方法

# 观察 store 中输入路径的实际磁盘占用随时间变化
watch -n1 "du -sh /nix/store/*-source 2>/dev/null | tail -5"

# 或者用 path-info 看闭包大小
nix path-info -S ./result

记忆:flake archive / flake check 全量、readDir 递归与 fileset 整树过滤会退化、把 outPath 当 path: 传下去也会物化整树;判断退化用 du -sh /nix/store/*-source 观察增长。

8. 大规模仓库的工程实践

8.1 输入形态选择

# monorepo 内部互引:优先 git+file,避免 path: 导致全量复制
inputs.shared.url = "git+file:///srv/repos/shared?ref=main";

# 上游依赖:用 github: 并配 access-tokens
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";

# 只需要一个文件而不需要 flake 输出时,用 flake = false
inputs.scripts.url = "github:me/scripts";
inputs.scripts.flake = false;

8.2 切分求值面

思路:让「常用属性」只依赖少量文件
  - 把版本号、常量抽到独立的 version.nix,避免 import 整个 pkgs 目录
  - 用 flake-parts / perSystem 把输出按系统切分,`nix flake show` 只求值一层
  - 避免在 flake.nix 顶层做昂贵计算(顶层表达式每次求值都会跑)

8.3 CI 上的落地顺序

# 伪代码:先暖输入,再离线求值,最后构建
- run: nix flake metadata --json > /tmp/meta.json      # 解析输入
- run: nix flake archive --to /tmp/store-cache          # 可选:显式归档输入
- run: nix eval --raw .#packages.x86_64-linux.foo.drvPath
- run: nix build --print-build-logs .#packages.x86_64-linux.foo

CI 中真正省时间的是缓存输入本身:把 /nix/store 中 *-source 与 *.drv 一起缓存,下一次运行即可跳过取回阶段。这与构建缓存是两回事,可参考 CI 缓存与构建加速 中的分层思路。

8.4 开发机上的循环优化

# 1) 本地 git 源:改文件后无需重新下载,只重新求值
# 2) 常驻 daemon:nix-daemon 会缓存 tree 元数据
# 3) 关闭无谓的 flake show:用 nix eval 精确取需要的属性
nix eval --raw .#packages.x86_64-linux.myapp.version

记忆:工程实践三招——输入尽量用 git 类而非 path:、把昂贵计算从 flake.nix 顶层挪走、CI 里把输入与 .drv 一起缓存。

9. 常见坑与排错

9.1 求值变快但结果不对

症状:改了文件,求值结果没变
原因:文件未被 git 追踪 → 不在源码树指纹内 → eval-cache 未失效
处理:git add 该文件;或临时 nix eval --no-eval-cache 验证

9.2 github: 输入间歇性卡顿

# 现象:nix flake show 偶发几十秒延迟
# 原因:GitHub API 未认证时的速率限制
curl -sI https://api.github.com/rate_limit | head -1
# 处理:配置 access-tokens

9.3 –offline 下的失败

nix build --offline .#foo
# error: file 'lib/default.nix' does not exist in the store

惰性树的「按需取回」与 --offline 天然冲突:必须先完整取回(nix flake archive)再离线操作。

9.4 构建产物哈希变化

症状:同一 flake.lock,两次构建产物哈希不同
排查:确认输入 narHash 是否一致(nix flake metadata --json)
     若 narHash 一致而产物不同,问题不在惰性树,而在构建脚本的不确定性

9.5 团队协作中的渐进启用

风险:A 开启惰性树、B 未开启 → 差异只体现在耗时与网络请求上,store 路径一致
结论:惰性树不改变可复现性,可以放心让团队渐进启用

记忆:四类坑——未追踪文件导致 eval-cache 不失效、github API 速率限制、--offline 与按需取回冲突、以及误把构建不确定性归因于惰性树。

10. 速查表与一句话记忆

概念一句话
惰性树用到哪个文件才取哪个,narHash 与全量一致
fetchTree输入进入 store 的统一入口,返回 outPath/narHash
树对象store 里逻辑为整树、物理为按需填充
eval-cache缓存「属性路径 → 求值结果」,键为 flake 指纹
退化触发flake archive / flake check / 递归 readDir
提速组合惰性树 × eval-cache = 乘法收益

一句话记忆:惰性树把 flake 输入的物化从「先全量下载再算哈希」改成「先由源算出树哈希定 outPath、文件被读时才取回」,narHash 与全量树严格一致所以缓存与可复现性都不受影响;真正提速要三件事一起做——输入用 git 类(git+file: / github:)而不是 path:,配置 access-tokens 避开 API 速率限制,再用 eval-cache 复用求值结果;凡是遍历整棵树的操作(nix flake archive、nix flake check、递归 readDir、lib.fileset 整树过滤)都会把它打回全量,排查退化就盯 du -sh /nix/store/*-source 的增长。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nix」更多文章

  1. Nix 语言服务器与编辑器工具链:补全、格式化与静态检查
  2. Store 垃圾回收与存储优化:gc root、去重与瘦身
  3. 构建沙箱与可复现性:Nix 如何隔离构建过程