Chapter 04

hash = 缓存的灵魂

Turbo 给每个任务算一个 hash:输入相同 → hash 相同 → 不用跑。看懂 hash 怎么算,就能让缓存命中率从 30% 提到 95%——那才是真 FULL TURBO。

hash 的输入集合

任务 @myorg/ui#build 的 hash = SHA-256 of:
  ├── 包源码文件内容(根据 inputs 过滤)
  ├── package.json
  ├── lockfile 里该包相关的版本信息
  ├── 依赖包的 hash(recursive)
  ├── 声明的 env 变量的值
  ├── globalDependencies 里的文件内容
  ├── globalEnv 的值
  ├── turbo.json 里该任务的配置
  └── 执行的 script 命令

任一输入变化 → hash 变 → 缓存 miss。

FULL TURBO 的含义

 Tasks:    10 successful, 10 total
Cached:    10 cached, 10 total
  Time:    422ms >>> FULL TURBO

10 个任务全部命中缓存,真正执行的只是"从 .turbo 拷贝 dist 产物 + 回放 stdout 日志"——几乎是文件复制的速度。

部分命中

@myorg/utils:build: cache hit, replaying logs
@myorg/ui:build: cache miss, executing          ← utils 改了,ui 跟着失效
@myorg/web:build: cache miss, executing
@myorg/docs:build: cache hit, replaying logs    ← docs 不依赖 ui 的某些文件,可能仍命中

改 utils 会让依赖 utils 的包一起 miss——DAG 的传染。

精细 inputs 提高命中率

// 默认
{ "build": { "inputs": ["$TURBO_DEFAULT$"] } }

// 改 README 也失效缓存 ← 浪费

// 更好
{
  "build": {
    "inputs": [
      "src/**",
      "tsconfig.json",
      "package.json"
    ]
  }
}

// 只在这些目录变化时才重新 build
精细 inputs 是命中率优化的最大杠杆
默认包含整个包的所有文件——意味着 README/测试/文档改动也会失效缓存。显式列 inputs 把范围收窄到真正影响产物的文件,提高命中率立竿见影。

outputs 要写全

{
  "build": {
    "outputs": [
      "dist/**",
      ".next/**",
      "!.next/cache/**",          // 不要缓存 Next 自己的缓存
      "public/generated/**"
    ]
  }
}

outputs 是"要恢复的产物路径"。漏了会怎样?

缓存 key 的稳定性

# 看某任务的 hash
pnpm turbo run build --dry-run=json | jq '.tasks[] | select(.taskId=="@myorg/ui#build") | .hash'
# "5a2f1e8c3d7f..."

连跑两次没改代码,hash 应该完全一致。如果变了——说明某个"不该影响"的输入被算进去了,往下查。

常见 hash 抖动原因

时间戳进了产物
webpack banner 含 build time,outputs 的文件内容每次不同——导致下游任务 hash 变(因为这个包的 hash 依赖它的 outputs)。修复:去掉时间戳,或把含时间戳的文件从 outputs 排除。
没锁 Node 版本
Node 不同小版本 build 出的二进制可能不同。用 Corepack + packageManager 锁。
.env 变量泄漏
任务意外读取了 process.env.SOMETHING 但没声明在 env 里——这是错误的,Turbo 无法感知。声明进 env,或改代码不读它。
lockfile 频繁变
每次升级依赖都会改 lockfile → 所有任务 hash 变。这是对的——只是改依赖就该重跑。

本地缓存位置

# 默认
apps/web/.turbo/cache/
├── 5a2f1e8c3d7f9a2b.tar.zst    ← 产物压缩包
├── 5a2f1e8c3d7f9a2b-meta.json  ← 元数据
└── 5a2f1e8c3d7f9a2b.log        ← stdout/stderr

# 清本地缓存
rm -rf apps/web/.turbo/cache
# 下次 build 会重新生成

替代缓存目录

pnpm turbo run build --cache-dir=.cache/turbo
# 自定义缓存位置

pnpm turbo run build --no-cache
# 本轮不用缓存也不写缓存

pnpm turbo run build --force
# 忽略缓存,强制重跑,但会写新缓存

缓存签名(防篡改)

{
  "remoteCache": {
    "signature": true
  }
}
.env
TURBO_REMOTE_CACHE_SIGNATURE_KEY=a-secret-random-key

开启后,每个缓存 tar 包附带 HMAC 签名——下载时不对就拒绝。防止远程缓存服务器被攻击后投毒。

cache-miss 分析工作流

# 1. 保存第一次 run 的 hash
pnpm turbo run build --dry-run=json > run1.json

# 2. 改个不应该影响缓存的文件(比如 README)
echo "x" >> README.md

# 3. 再跑一次
pnpm turbo run build --dry-run=json > run2.json

# 4. diff 两次 hash
diff <(jq -r '.tasks[] | "\(.taskId): \(.hash)"' run1.json) \
     <(jq -r '.tasks[] | "\(.taskId): \(.hash)"' run2.json)

如果 hash 变了 → 说明 README 被 inputs 包含——收窄 inputs 排除之。

任务级别的 env

{
  "build": {
    "env": [
      "NEXT_PUBLIC_API_URL",
      "NEXT_PUBLIC_*",          // 通配
      "!NEXT_PUBLIC_DEBUG"        // 通配后排除
    ]
  }
}

Turbo 会读声明过的 env 值,算进 hash。未声明的 env 不影响缓存——这就是为什么 env 必须显式声明。

replayLog 的原理

cache hit 时,Turbo 做两件事:

  1. 从 .turbo/cache 解压 tar.zst 到 outputs 指定的目录
  2. 把上次的 stdout/stderr 日志原样打印出来(用色彩做"replay"标识)

结果:看起来像重跑了,实际是纯文件复制 + echo,快到毫秒级。

远程缓存和本地缓存

任务请求:
  1. 本地 .turbo/cache 有 hash xxx ? → 用
  2. 没有 → 查远程缓存(Vercel/S3)
  3. 也没有 → 执行任务
  4. 执行完 → 写本地 + 上传远程(如果启用)

下一章详细讲远程缓存。

summarize 模式

pnpm turbo run build --summarize
{
  "id": "xxx",
  "version": "1",
  "turboVersion": "2.2.3",
  "executionSummary": { "success": 10, "cached": 8, "failed": 0, "attempted": 10 },
  "tasks": [
    {
      "taskId": "@myorg/ui#build",
      "hash": "5a2f1e8c...",
      "cacheState": { "local": true, "remote": false },
      "executionTime": 12,
      "expandedInputs": { "src/index.ts": "...", ... }
    }
  ]
}

生成的 JSON 可以投喂到监控 dashboard——跟踪每次 CI 的缓存命中率,量化优化效果。

命中率调优 checklist

显式 inputs 收窄范围
不要默认 $TURBO_DEFAULT$,列具体目录。
outputs 写全
漏了会运行时出 bug,缓存却命中。
env 显式声明
不声明 = 不进 hash = 可能错误命中。
消除非确定性
产物里不要有时间戳、随机 ID、node_modules 的绝对路径。
Node 版本锁定
Corepack + packageManager,避免 Node 差异造成产物差异。
监控 --summarize
定期看哪些任务命中率低,针对性优化。

本章小结