事情的起点很普通。前两天打开 DaisyDisk,我习惯用它看磁盘占用,扇形图一眼能看出谁占得多。本来只想顺手清一下下载文件夹,结果一打开就愣住了:上次扫描硬盘用了 40% 多,这次直接到 60%。
DaisyDisk 那种螺旋扇形图,outlier 很好认。最外圈最厚的那一片是 swil-fitneheal/apps/web/.next/。点进去,单独一个 dev/cache/turbopack/<hash>/ 目录就占了 170 GB。仓库根目录还有一份没人管的 .next/,叠在一起接近 178 GB。
这个 cache 到底是什么
打开目录看一眼就明白了:上千个 .sst、几百个 .meta、一份 LOG、一份 CURRENT。这是 RocksDB 的 on-disk 格式,Turbopack 拿它做 persistent cache。
LSM-tree 的特点是只往尾部追加,靠后台 compaction 回收旧版本。我这个目录里 SST 一共 2145 个,其中 654 个已经合并到顶层(每个 258 MB,是默认的 level-size cap),还有 1500 多个散落在低层等着合并。删除标记 .del 只有 13 个。cache 几乎没在有效回收:HMR 每次重编译都往里写一批新 SST,旧的不会立刻物理删掉,要等 compaction;而 Turbopack 现在的 compaction 触发,对「持续小步改」的开发模式偏保守。
跟 Webpack 的 cache.type = 'filesystem' 对照一下。Webpack 用 single-pack 的 pack 文件,再加一份滚动覆盖的 .pack.old,每次构建结束写一份新的盖上去。容量大致跟项目模块图的大小成正比,中型项目几百 MB,撑死 1~2 GB。Turbopack 这套 LSM 增量写入更快、查询带索引,但磁盘占用会随写入次数涨,而不是随项目规模封顶。
两种 cache 的 size 函数不一样:Webpack 是 f(项目规模);Turbopack persistent cache 是 f(项目规模 × HMR 次数 − compaction),第二项才是那 170 GB 的来源。
monorepo 里那个 barrel 把问题放大了
之前用 Turbopack 跑 next dev 时没遇到过这种事。当时 fitnuhealth 还是单 app 的 Next 仓库,.next/dev/cache/turbopack/ 一般 2~4 GB,偶尔到 8 GB,重启 dev server 就归零,不用专门盯。
改成 monorepo 之后完全不同。repo 里有 apps/web(Next.js)、apps/mp(Taro 微信小程序)、packages/content(共享内容包)。160 多个 story、citations / edges 都在 packages/content/src/,apps/web 通过 workspace 直接 import。
我一开始怀疑是 mp 构建生成的 *.generated.json:每次 pnpm build:weapp,几个被大量模块依赖的文件 hash 全变,看起来很合理。跑了一遍 grep 才发现,apps/web 根本没 import 那些 generated.json,mp 的构建和 web 的 Turbopack cache 没有依赖关系。所以「monorepo 让 cache 涨」得把原因说细一点。
实际链条是这样:
packages/content/src/<story>.ts (160+ 个独立 story 文件)
↓ import
packages/content/src/stories/by-region/<region>.ts (7 个大陆 barrel)
↓ import
packages/content/src/stories/index.ts (STORIES 总 barrel)
↓ import
apps/web/src/app/atlas/[region]/[slug]/page.tsx
apps/web/src/app/atlas/[region]/[slug]/[scene]/page.tsx
apps/web/src/lib/search.ts
apps/web/src/lib/tracking/story-meta.ts
apps/web/src/app/sitemap.ts
任意一个 story 改一个字,所在 region barrel 的导出 hash 会变,连带 stories/index.ts 也变,然后所有从总 barrel 拿数据的页面整片失效。Turbopack 把每个失效模块的新版本写进 LSM 顶部,旧 SST 等 compaction,而 compaction 跟不上写入。
写一段内容、改一段引言、保存。一下就有几十个模块 entry 进 cache。一天几百次保存,一周几十 GB,170 GB 就这么堆出来的。
回头看,不是 Turbopack 不行,也不是 monorepo 本身有问题,而是一个被广泛 import 的 barrel,叠上 LSM 没有 size cap。单 app 时代没事,是因为当时没有「一个 barrel 聚合 160 个文件」这种结构。
怎么改
最直接的做法是把热路径从总 barrel 上摘下来。两个 region 路由(atlas/[region]/[slug]/...)在 URL 里已经知道是哪个大陆,不必拉全量 STORIES,改成按 region 查:
// packages/content/src/stories/lookup.ts
const REGION_LOADERS = {
conditions: () => import("./by-region/conditions").then(m => m.CONDITIONS_STORIES),
vitamins: () => import("./by-region/vitamins").then(m => m.VITAMINS_STORIES),
// ... 7 个大陆
};
export async function storyBySlugInRegion(region, slug) {
const list = await REGION_LOADERS[region]();
return list.find(s => s.slug === slug);
}
import() 要用动态形式,这样 lookup.ts 本身不静态依赖任何 story 文件。改一个 condition story 只会让 by-region/conditions.ts 标脏,不再连累 lookup.ts 和挂在上面的页面。以前改一个字、十个模块进 cache,现在大概是两个。
sitemap.ts 和 search.ts 这种确实要全量 stories 的,barrel 可以留着。generateStaticParams 只在 build 时跑,可以把 STORIES 的 import 挪进函数体里做动态 import,模块顶层就别静态依赖了。
改完跑 tsc --noEmit 和 vitest --run:typecheck 过,215 个测试全过。dev cache 的 LSM 写入压力大概降到原来的 1/7,一两周应该能从体积上看出来。
收尾
dev cache 平时跟 node_modules 一样,占地方但很少主动去管。LSM 持久化 cache 加上一个聚合大量数据的 barrel,会把背景资源变成前台麻烦。
Turbopack 比 Webpack 快,这点没问题;代价是磁盘预算要重新估。更值得查的是 import 拓扑:任何被几十、上百个文件 fan-in 的 barrel,在 incremental cache 里都是放大器。
硬盘莫名其妙涨了几十 GB,可以先开 DaisyDisk,再看 .next/dev/cache/,十有八九是它。然后翻翻自己的 barrel,有没有一个文件塞得太满。
环境:Next.js 16 + Turbopack persistent cache,pnpm workspace monorepo(apps/web + apps/mp + packages/content),macOS Sequoia,磁盘可视化用 DaisyDisk。