从一份 CPU profile 定位到 Turbopack 持久缓存把 CPU 打满

现象:本地 pnpm dev 启动后,风扇狂转、CPU 长时间高占用,但页面其实是能正常访问的。 结论:烧 CPU 的不是业务 JS 代码,而是 Next.js 16 默认开启的 Turbopack 持久缓存——它的磁盘存储(LSM-tree)膨胀到 GB 级后,后台一直在做 compaction(合并重写缓存文件),把 native 线程全部打满。 解法:停 dev server → rm -rf .next → 重启。

这篇文章记录的不只是结论,更重要的是排查方法:怎么从一份看似天书的 CPU profile,一步步推到磁盘上那个 2GB 的缓存目录。


一、背景

技术栈:

  • Next.js 16.2.6(next dev 在 16 里默认就是 Turbopack)
  • React 19、Drizzle、Inngest
  • macOS (Apple Silicon / arm64)

某天本地起服务,CPU 占用居高不下。第一反应可能是”哪段 server code 写了死循环 / 哪个 effect 疯狂重渲染”——但别猜,先抓数据


二、第一步:抓一份 CPU profile

macOS 自带采样分析器 sample,不用装任何东西。先找到 dev server 的进程号:

# 找到吃 CPU 的 node 进程
ps aux | grep "next" | grep -v grep
# 或者直接看 top 里 CPU 最高的 node pid

然后对它采样(下面对 pid 50870 采 10 秒,每 1ms 一个样本):

sample 50870 10 -mayDie

sample 会把这 10 秒里所有线程的调用栈聚合成一棵”调用树”,并在结尾给出几个非常有用的汇总表。


三、读 profile:先分清”在干活的线程”和”在睡觉的线程”

profile 很长很吓人,但不需要逐行读。先看主线程:

1026 Thread_..., DispatchQueue_1: com.apple.main-thread
  + 1023 uv_run
  +   824 uv__io_poll → kevent          ← 卡在 kevent,等事件,IDLE
  +   197 uv__io_poll → ...async callbacks

主线程 1026 个样本里 824 个卡在 kevent(libuv 事件循环在等 I/O),也就是说 Node 的 JS 主线程基本是空闲的。这一条就排除了”我的 JS 代码死循环”。

那 CPU 到底谁在烧?往下翻,会看到一堆这样的线程:

1026 Thread_..., tokio-runtime-worker
  + ... ???  (in next-swc.darwin-arm64.node)
  • next-swc.darwin-arm64.node 是 Next.js 的 Rust 原生模块(SWC 编译器 + Turbopack 引擎)。
  • tokio-runtime-worker 是 Rust 异步运行时 tokio 的工作线程。

这种线程有 10 个,每个都在 next-swc 内部跑得很满。所以问题在 native 层,不在 JS 层——这也解释了为什么 Node 自带的 --prof / Chrome inspector 看不出名堂:它们只采 JS 层,采不到 Rust 写的 native 模块,只能靠系统级的 sample

Node 进程里,JS 主线程卡在 kevent、libuv 线程池在 __workq_kernreturn 上挂起,全都空闲;真正烧 CPU 的是 next-swc.darwin-arm64.node 这个 Rust 原生模块里的 10 个 tokio-runtime-worker 线程,全程在 open/write/fchmod/close。Node 的 JS profiler 看不到这一层


四、关键技巧:看结尾的 “Sort by top of stack” 聚合表

调用树太深、符号大多是 ???(native 模块没符号),硬看没意义。sample 在最后会按栈顶函数把所有样本聚合一次,这才是金矿:

Sort by top of stack, same collapsed (when >= 5):
    __psynch_cvwait        11194    ← 条件变量等待(线程在等活干)→ IDLE
    kevent                  3871    ← 事件循环 poll → IDLE
    mach_msg2_trap          1720    ← CFRunLoop → IDLE
    swtch_pri               1327    ← 线程让步/自旋  ★ 在烧 CPU
    semaphore_wait_trap     1264    ← IDLE
    __open                  1201    ← 打开文件      ★ 在烧 CPU
    __workq_kernreturn      1026    ← 空闲 worker   → IDLE
    close                    945    ← 关闭文件      ★ 在烧 CPU
    semaphore_timedwait      728    ← IDLE
    write                    427    ← 写文件        ★ 在烧 CPU
    __fchmod                 411    ← 改文件权限    ★ 在烧 CPU
    __psynch_mutexwait       133    ← 互斥锁竞争    ★

把”睡觉的”(各种 wait / poll / kernreturn)划掉,剩下真正在动的就是:

swtch_pri(线程自旋让步)+ open / write / fchmod / close(密集文件读写)+ mutexwait(锁竞争)

sample 结尾的 Sort by top of stack 聚合表分成两列:左边 IDLE,__psynch_cvwait、kevent、mach_msg2_trap、semaphore_wait_trap、__workq_kernreturn、semaphore_timedwait 这些 wait/poll 都是线程在睡觉,可以划掉;右边 ACTIVE,swtch_pri、__open、close、write、__fchmod、__psynch_mutexwait 才是真正在烧 CPU 的文件 I/O 与锁竞争。没有一行 JS 计算或 GC

注意:完全没有 JS 计算、没有正则、没有 JSON 解析、没有 GC 占大头。这是一个纯粹的**「文件 I/O + 多线程锁竞争」**负载。

fchmod 出现这么多次是个很强的信号:一个程序在”写文件 → 立刻改这个文件的权限”,这是存储引擎原子落盘的典型模式(写临时文件 → set 权限 → rename)。swtch_pri(由 cthread_yield 触发)则是多个线程在抢同一把锁时反复自旋让步。

到这里已经能下半个结论了:next-swc 在用 10 个线程疯狂地往磁盘写一大堆文件,并伴随激烈的锁竞争。


五、对应到磁盘:这些文件到底是什么

next-swc 在 dev 模式下大量写文件,第一个怀疑对象就是 .next:

du -sh .next
# 2.3G  .next        ← dev 缓存 2.3GB,异常

继续下钻:

du -sh .next/*/ | sort -rh
# 2.3G  .next/dev/
du -sh .next/dev/*/ | sort -rh
# 2.1G  .next/dev/cache/
du -sh .next/dev/cache/*/ | sort -rh
# 2.1G  .next/dev/cache/turbopack/

锁定 .next/dev/cache/turbopack/。看看里面是什么文件:

find .next/dev/cache/turbopack -type f -exec ls -la {} \; \
  | sort -k5 -rn | head | awk '{print $5/1024/1024 "MB", $NF}'
# 247MB  .../ee6e79b1/00001353.sst
# 246MB  .../ee6e79b1/00001185.sst
# 246MB  .../ee6e79b1/00002141.sst
# 245MB  .../ee6e79b1/00002791.sst
# ...(7 个 230–247MB 的 .sst)
# 7.6MB  .../ee6e79b1/00001351.meta

.sst(Sorted String Table)+ .meta —— 这是 LSM-tree(Log-Structured Merge-tree) 存储引擎的标志性文件结构(RocksDB / LevelDB 同款)。Turbopack 的持久缓存底层(turbo-persistence)就是一个 LSM 存储。

异常点:正常 LSM 的单个 SST 文件是几十 MB 量级,这里却有 7 个 230–247MB 的巨型 SST,总量 2.1GB。


六、根因:LSM compaction 在后台空转

把第四节的 syscall 和第五节的文件结构拼起来,因果链就完整了:

  1. Next 16 next dev 默认启用 Turbopack,持久缓存写在 .next/dev/cache/turbopack/
  2. 这个缓存是 LSM-tree。LSM 的特性就是写入先追加、再靠后台 compaction 周期性地把多个 SST 合并重写成更大的 SST
  3. 缓存随着长期开发不断膨胀到 2GB+、出现多个 200MB+ 的 SST 后,compaction 的代价急剧上升:要把这些巨型 SST 读出来 → 归并 → 写成新的巨型 SST → fchmod 设权限 → close 旧文件。
  4. 这一过程由 tokio 的 worker 线程池并行执行,于是 profile 里看到的就是:10 个 tokio worker 全在 open/write/fchmod/close,外加抢锁时的 swtch_pri 自旋——CPU 被打满。

一句话:不是你的代码在跑,是 Turbopack 的缓存引擎在”搬砖”,而且砖块已经大到搬不动了。

LSM compaction 的因果链:① 长期开发缓存只增不减,新数据先 append 成很多小 SST;② 后台 compaction 把多个 SST 读出、归并排序、写成新 SST、fchmod 设权限、close 旧文件,由 10 个 tokio worker 并行执行;③ SST 越滚越大,最终出现 7 个 230–247MB 的巨型 SST、总量约 2.1GB。结果是 10 个 worker 全卡在文件读写加抢锁,CPU 被打满;止血办法是停掉 server 后 rm -rf .next


七、解决方案

立即止血(已验证有效)

缓存是可重建的、且在 .gitignore 里,删掉无风险。注意要先停 server 再删,否则边写边删 .sst 会让 Turbopack 报错:

# 1. Ctrl-C 停掉 dev server
# 2. 删除缓存
rm -rf .next
# 3. 重启
pnpm dev

重建后缓存回到正常体积,CPU 立刻回落。代价只是首次编译稍慢(冷缓存),属正常。

复发处理

Next 16 的 Turbopack 持久缓存长期开发下确实会持续增长。如果发现它频繁涨回 GB 级,可以:

  • rm -rf .next 当作周期性的清理动作(最省事);
  • 或进一步查 Next 当前版本下限制 / 关闭 Turbopack 持久缓存的配置项(版本相关,改前建议先读 node_modules/next/dist/docs/ 里对应版本的说明,不要凭记忆套老写法)。

八、复盘:这次排查里可复用的经验

  1. CPU 高先抓 profile,不要猜。 一条 sample <pid> 10 就能拿到全部线程的真实分布。
  2. 先分清 idle 和 active。 大量 cvwait / kevent / semaphore_wait / workq_kernreturn 都是”线程在睡觉”,不是在烧 CPU;主线程卡 kevent 基本能排除 JS 死循环。
  3. 善用 “Sort by top of stack” 聚合表。 调用树里全是 ??? 无符号帧时,栈顶聚合直接告诉你 CPU 花在哪类 syscall 上。
  4. 从 syscall 反推行为。 open/write/fchmod/close 密集 = 存储引擎在落盘;swtch_pri 多 = 锁竞争自旋;fchmod 紧跟 write = 原子写文件模式。
  5. syscall → 磁盘 → 文件类型,层层下钻。 看到大量文件写,就用 du 找最大的目录,再看文件后缀(.sst/.meta → LSM),根因自然浮现。
  6. 认识你工具链的 native 部分。 next-swc.*.node = Rust 写的 SWC/Turbopack;它的 CPU 不会出现在 Node 的 JS profiler 里,只能靠系统级的 sample 看到。

小结

整条排查链路其实很短:sample 抓一份 profile → 把睡觉的线程划掉 → 从剩下的 syscall(open/write/fchmod/close + swtch_pri)认出”存储引擎在落盘 + 锁竞争” → du 下钻到 .next/dev/cache/turbopack/ → 看到 .sst 认出 LSM → 对上 Turbopack 持久缓存的 compaction。关键不是记住”Turbopack 会把 CPU 打满”这个结论,而是这套”抓数据 → 分 idle/active → 从 syscall 反推 → 落到磁盘”的通用手法——换一个把 CPU 莫名打满的 native 模块,同样能用。

下次再遇到风扇狂转,先别怀疑自己的代码:

# dev server CPU 高时,先看 Turbopack 缓存有多大
du -sh .next/dev/cache/turbopack 2>/dev/null
# GB 级 + 多个 200MB+ 的 .sst,基本就是它了 → rm -rf .next