从一份 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。
四、关键技巧:看结尾的 “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(锁竞争)
注意:完全没有 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 和第五节的文件结构拼起来,因果链就完整了:
- Next 16
next dev默认启用 Turbopack,持久缓存写在.next/dev/cache/turbopack/。 - 这个缓存是 LSM-tree。LSM 的特性就是写入先追加、再靠后台 compaction 周期性地把多个 SST 合并重写成更大的 SST。
- 缓存随着长期开发不断膨胀到 2GB+、出现多个 200MB+ 的 SST 后,compaction 的代价急剧上升:要把这些巨型 SST 读出来 → 归并 → 写成新的巨型 SST →
fchmod设权限 →close旧文件。 - 这一过程由 tokio 的 worker 线程池并行执行,于是 profile 里看到的就是:10 个 tokio worker 全在
open/write/fchmod/close,外加抢锁时的swtch_pri自旋——CPU 被打满。
一句话:不是你的代码在跑,是 Turbopack 的缓存引擎在”搬砖”,而且砖块已经大到搬不动了。
七、解决方案
立即止血(已验证有效)
缓存是可重建的、且在 .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/里对应版本的说明,不要凭记忆套老写法)。
八、复盘:这次排查里可复用的经验
- CPU 高先抓 profile,不要猜。 一条
sample <pid> 10就能拿到全部线程的真实分布。 - 先分清 idle 和 active。 大量
cvwait / kevent / semaphore_wait / workq_kernreturn都是”线程在睡觉”,不是在烧 CPU;主线程卡kevent基本能排除 JS 死循环。 - 善用 “Sort by top of stack” 聚合表。 调用树里全是
???无符号帧时,栈顶聚合直接告诉你 CPU 花在哪类 syscall 上。 - 从 syscall 反推行为。
open/write/fchmod/close密集 = 存储引擎在落盘;swtch_pri多 = 锁竞争自旋;fchmod紧跟write= 原子写文件模式。 - syscall → 磁盘 → 文件类型,层层下钻。 看到大量文件写,就用
du找最大的目录,再看文件后缀(.sst/.meta→ LSM),根因自然浮现。 - 认识你工具链的 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