Monorepo 构建缓存治理:pnpm 与 Turbo 的增量管线实践
一、规模膨胀下的构建债务:从分钟级到秒级的 CI 攻坚
当一个前端仓库从 5 个包增长到 80 个包,CI 构建时间会从 40 秒膨胀到 12 分钟。这不是线性增长,而是指数级恶化。根因在于重复安装、重复编译、重复类型检查。
真实场景里,构建债务通常表现为三类。第一类是依赖重复安装,扁平 node_modules 导致同一个包被多份拷贝。第二类是任务重复执行,改了一个工具函数,却触发 30 个不相关包的测试。第三类是缓存无法跨机器复用,本地开发者每次拉代码都要重新构建。
pnpm 与 Turbo 的组合,正是针对这三类债务的工程化解法。前者用内容寻址存储消除依赖重复,后者用任务图谱与内容哈希实现增量构建。两者协同,能把 12 分钟的 CI 压到 90 秒以内。但前提是,缓存治理必须做到位,否则只是把瓶颈转移。
二、内容寻址与任务图谱:pnpm store 与 Turbo daemon 的缓存机理
pnpm 的核心是全局 store。所有依赖包只在全局目录里存一份,项目内的 node_modules 通过硬链接指向 store。
text
┌─────────────────────────────────────────────────────────┐
│ 全局 store (~/.pnpm-store) │
│ ├── vue@3.4.0 ← 唯一物理副本 │
│ ├── vue@3.4.0_peervue@2 ← 不同 peer 得到不同条目 │
│ └── lodash@4.17.21 │
└─────────────────────────────────────────────────────────┘
▲ hard link ▲ hard link
│ │
┌───────┴────────┐ ┌───────┴──────────┐
│ packages/app │ │ packages/admin │
│ node_modules/ │ │ node_modules/ │
│ vue → store │ │ vue → store │
└────────────────┘ └──────────────────┘
关键在于硬链接不占额外磁盘空间,且 store 内的包按内容哈希寻址。这意味着 80 个包共享一个 lodash,磁盘开销只有一份。
Turbo 的增量管线建立在任务图谱上。它把每个包的 build/test/lint 声明为节点,依赖关系作为边,构建时做拓扑排序。
text
┌──────────┐
│ @repo/ui │ ← 改动了 ui 包
└────┬─────┘
│ dependsOn
┌─────────┴─────────┐
▼ ▼
┌─────────┐ ┌──────────┐
│ app │ │ admin │ ← 只有 app/admin 受影响
└─────────┘ └──────────┘
(build) (build)
│ skip │ skip
┌─────────┐ ┌──────────┐
│ utils │ │ docs │ ← 不在依赖链上,跳过
└─────────┘ └──────────┘
Turbo 对每个任务计算哈希,输入包括源文件内容、依赖包的输出哈希、环境变量、任务配置。哈希命中缓存则直接复用 .turbo/cache 下的产物,跳过实际执行。
| 缓存维度 | pnpm store | Turbo cache |
|---|---|---|
| 寻址依据 | 包内容 + peer 依赖树 | 任务输入哈希 |
| 存储层级 | 全局(跨项目共享) | 仓库级 + 远程 |
| 失效粒度 | 单个包版本 | 单个任务输出 |
| 跨机器复用 | 否(需 setup-node 缓存) | 是(远程缓存) |
理解这两层缓存的边界,是后续做远程缓存治理的前提。
三、生产级缓存管线落地:远程缓存、哈希输入与失败回退
生产级 Monorepo 必须解决"本地能跑、CI 慢"的问题。核心是启用 Turbo 的远程缓存,并严格控制哈希输入。
先看 turbo.json 的生产配置:
json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [".env", "tsconfig.base.json"],
"globalEnv": ["NODE_ENV", "CI"],
"remoteCache": {
"enabled": true,
"signature": true
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": [
"src/**",
"public/**",
"package.json",
"vite.config.ts",
"!src/**/*.spec.ts"
],
"outputs": ["dist/**", ".vite/**"],
"env": ["VITE_API_BASE"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "test/**", "jest.config.ts"],
"outputs": ["coverage/**"]
}
}
}
注释要点:inputs 必须显式声明,避免把无关文件(如 spec 文件)纳入哈希。env 列出影响产物的环境变量,防止 staging 与 dev 构建串味。signature 启用签名校验,防止远程缓存被篡改。
远程缓存服务自建时,需要考虑鉴权与回退:
typescript
// scripts/turbo-remote-cache.ts
// 自建远程缓存服务:对象存储 + 签名校验 + 超时降级
import { createHmac } from 'node:crypto';
import { Readable } from 'node:stream';
interface CacheArtifact {
hash: string;
teamId: string;
body: Buffer;
}
const CACHE_TIMEOUT_MS = 5000; // 远程缓存超时阈值,超时降级本地
export async function fetchArtifact(
hash: string,
teamId: string
): Promise<Buffer | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CACHE_TIMEOUT_MS);
try {
const signature = signPayload(
`${teamId}/${hash}`,
process.env.CACHE_SECRET!
);
const resp = await fetch(
`${process.env.CACHE_ENDPOINT}/${teamId}/${hash}`,
{
headers: { 'X-Turbo-Signature': signature },
signal: controller.signal,
}
);
// 404 视为未命中,走正常构建,不报错
if (resp.status === 404) return null;
if (!resp.ok) throw new Error(`cache fetch failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
} catch (err) {
// 网络抖动或超时,降级为本地缓存,绝不阻塞构建主流程
console.warn(
'[turbo-cache] remote miss, fallback to local:',
(err as Error).message
);
return null;
} finally {
clearTimeout(timer);
}
}
export async function storeArtifact(art: CacheArtifact): Promise<void> {
const signature = signPayload(
`${art.teamId}/${art.hash}`,
process.env.CACHE_SECRET!
);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CACHE_TIMEOUT_MS);
try {
const resp = await fetch(
`${process.env.CACHE_ENDPOINT}/${art.teamId}/${art.hash}`,
{
method: 'PUT',
headers: {
'X-Turbo-Signature': signature,
'Content-Type': 'application/octet-stream',
},
body: Readable.from(art.body),
signal: controller.signal,
// Node 18+ 要求流式请求显式声明 duplex
// @ts-expect-error: duplex 是运行时字段,类型未声明
duplex: 'half',
}
);
if (!resp.ok) {
throw new Error(`cache store failed: ${resp.status}`);
}
} catch (err) {
// 存储失败不阻断构建,仅记录指标便于排查
console.warn('[turbo-cache] store failed:', (err as Error).message);
} finally {
clearTimeout(timer);
}
}
function signPayload(payload: string, secret: string): string {
// 用 HMAC-SHA256 对资源路径签名,防止缓存被伪造或越权访问
return createHmac('sha256', secret).update(payload).digest('hex');
}
关键设计:超时 5 秒降级本地、404 视为未命中、签名防篡改。生产环境里远程缓存可用性必须低于构建本身,不能让缓存服务挂掉就拖垮 CI。
CI 侧(以 GitHub Actions 为例)的缓存预热配置:
yaml
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Turbo 分析 git 改动需要完整历史
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Configure Turbo remote cache
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_API: ${{ vars.TURBO_API }}
run: |
pnpm turbo run build test lint \
--filter=...[origin/main] \
--concurrency=4
--filter=...[origin/main] 只构建相对主分支有改动的包,--concurrency=4 限制并发避免内存打爆。fetch-depth: 0 让 Turbo 能拿到完整 git 历史做变更分析。
四、缓存失效与远程存储成本:增量管线的代价与边界
缓存治理不是免费午餐,它引入了三类新成本。
第一是远程存储成本。Turbo 远程缓存按产物体积计费,80 个包的仓库每天可能产生 5 到 10GB 缓存。如果用 S3 自建,月成本可能在数百美元。更隐蔽的是缓存膨胀,历史哈希永不清理,存储只增不减。必须设置 TTL 或基于 LRU 的淘汰策略,否则账单会失控。
第二是哈希输入漂移。一旦 inputs 配置不严,比如漏掉 vite.config.ts,会导致改了配置但缓存仍命中,线上构建出旧产物。这类 bug 极难排查,因为本地完全无法复现。治理手段是定期 turbo run build --force,并在 CI 里加入无缓存全量构建的 nightly 任务做对账。
第三是缓存中毒风险。多个开发者共享远程缓存时,如果某个开发者的环境变量未声明进 env,他的产物会被其他机器复用,导致环境相关 bug 跨机器传播。签名机制能防外部篡改,但防不了"合法但错误"的缓存写入。
适用边界要明确。以下场景不适合这套方案:
- 仓库包数少于 10 个,构建时间小于 30 秒,引入 Turbo 的维护成本高于收益。
- 产物强依赖运行时环境,如 SSR 渲染依赖容器内文件系统,缓存命中率极低。
- 团队无运维能力,无法保障远程缓存服务的可用性,降级频繁反而拖慢 CI。
五、总结
Monorepo 构建缓存治理的本质,是把"重复劳动"转化为"内容寻址复用"。pnpm 在依赖层消除物理重复,Turbo 在任务层消除逻辑重复,两者协同形成增量管线。
落地步骤建议如下。第一步,先迁移到 pnpm 加 workspace,验证依赖安装提速。第二步,引入 Turbo 做任务编排,先只配置 build 任务,观察缓存命中率。第三步,启用远程缓存,自建或用官方服务,配置签名与超时降级。第四步,建立缓存治理面板,监控命中率、存储体积、降级次数三项核心指标。第五步,加入 nightly 全量构建任务,对账缓存正确性。
缓存治理不是一次性工程,而是持续运营。只要仓库在增长,哈希输入就需要持续校准,远程存储就需要持续清理。把缓存当作系统的一等公民来运维,增量管线的价值才能稳定兑现。