
把 pnpm 从 11 升到 12 的那天我其实没抱什么期待。包管理器这种工具能装上依赖、别天天报错就谢天谢地了性能升级对我来说无非是 release note 里多几行字。直到我清空.pnpm-store和node_modules用 pnpm 12 实测了一遍冷安装原本要 58 秒的依赖安装流程缩到了 21 秒出头我才认真去翻了 changelog——原来这次的核心变化是把大量以前由 Node 进程干的活沉进了一个基于 Rust 实现的内核里。这篇就围绕 pnpm 12 的 Rust 内核改动拿我手头一个真实的中大型项目从构建速度、资源占用、迁移坑和排错思路几个角度完整做一轮实测和复盘。如果你也在纠结要不要升级、升级后到底能省多少时间这篇应该能给你一个比较踏实的参考。1. 为什么 pnpm 会在这个版本动刀Rust 内核到底改了什么1.1 从 9.x 开始的长线迁移这不是一次重写先说一个容易误解的地方pnpm 并没有在 12 这个版本里把整个代码库用 Rust 重写一遍。如果你去翻它的历史会发现 Rust 化是一条长线。大概从 pnpm 9.x 开始官方就把 tarball 解压、文件复制这类纯 IO 密集的环节抽到一个用 Rust 写的独立 worker 进程里。当时的收益已经很明显解压提速就是从那时候开始被人频繁讨论的。到 10.x、11.x这个 worker 的覆盖范围在逐步扩大比如完整性校验、store 的写入路径都往里迁。到了 12我的理解是Rust 的边界被进一步扩大install 的核心流水线开始整体走新内核的通道。用一句话概括我的观察以前是 Node 主线程当包工头Rust 只负责搬砖现在相当于现场调度这一层也交到了 Rust 手里。官方没有把 pnpm 变成另一个项目但把最重的热路径一条条搬进了 Rust 侧。所以你在使用层面不会有换了个工具的感觉命令还是那些命令但 install 的时候体感差了一个档次。1.2 为什么偏偏是 Rust性能瓶颈到底堵在哪做包管理器这类工具性能瓶颈往往不在逻辑本身而在大量小文件操作、字符串处理、压缩包解压和校验。这几个场景恰好都踩在 JavaScript 的短板上。Node 在 IO 上其实不弱底层有 libuv 线程池文件读写不会阻塞主线程。但问题在于当你要对一个压缩包做解压 校验 写 store这一串操作时数据要在 Node 的 Buffer、字符串、流之间不断转换每转换一次就是一次拷贝和 GC 压力。依赖一多这种开销会被放大得非常可观。Rust 解决的是这几件事没有 GC不会在 install 中途出现明显的停顿尖峰所有权模型让并发控制更安全可以放心地开多线程去处理不同 tarball原生机器码没有 JIT 预热成本进程启动就是满状态流式处理能力强校验和、解压、写文件可以在同一趟数据流里完成避免反复拷贝。所以与其说Rust 比 JS 快所以 pnpm 12 快不如说pnpm 在 12 里把以前不适合 Node 干的活用更适合的工具重新实现了。JS/TS 仍然负责业务逻辑、插件系统、命令行交互这些地方 Rust 化的收益不高维持现状反而利于迭代。2. 实测设计怎么测才能看出真实差距2.1 被测项目画像这次实测选的项目是一个中后台方向的 monorepo四个业务应用十几个共享组件/工具包。依赖规模属于中等偏上直接依赖和间接依赖加起来大概 2300 个左右lockfile 文本量约 1.8MB行数超过两万行。这个规模不算夸张但在日常业务里已经足够有代表性——你平时抱怨 install 慢的项目大概率就是这个体量。项目里用到了不少带二进制依赖的包比如esbuild、sharp这类它们的安装脚本对并发和缓存策略很敏感恰好能暴露新旧版本在 install 调度上的差异。另外我们 team 的 CI 每次都是全新 runner没有持久化的增量缓存所以冷安装场景是日常最真实的痛点。2.2 测试环境与版本控制项目配置操作系统Ubuntu 24.04 LTSLinux 内核 6.8CPU4 核CI 同规格内存16GB磁盘NVMe SSDNode22 LTS测试版本pnpm 11.10.8 vs pnpm 12.0.1registry公司内网 npm 镜像这里有个值得注意的细节对比时必须保证 Node 版本一致。pnpm 自身的 JS 部分是在 Node 上跑的Node 升级带来的 V8 优化也会影响 install 速度如果连 Node 版本都换了测出来的差距就不全是 pnpm 的。所以我用nvm把 Node 固定在 22只切换 pnpm 版本用corepack做版本切换保证变量可控。2.3 三档场景与计时脚本我没有只测一个pnpm install完事因为不同场景下的收益逻辑完全不同。我分了三个档冷安装删除node_modules、删除 pnpm store、不保留任何缓存模拟全新环境第一次装依赖。热 store 安装保留 store只删node_modules模拟日常切换分支后重新生成依赖树。幂等校验什么都不删直接跑pnpm install验证依赖树和磁盘文件是否一致模拟 CI 里常规的 install 步骤。每个场景连续跑三次取中位数避免文件系统缓存和网络抖动造成干扰。计时我用的是外部date %s%N包一层没有依赖 pnpm 自己的 reporter 输出的总时长因为它的计时口径有时候和真实墙钟时间有偏差。#!/usr/bin/env bash # pnpm 11 / 12 冷安装对比脚本示例 export COREPACK_ENABLE_DOWNLOAD_PROMPT0 for ver in 11.10.8 12.0.1; do corepack prepare pnpm$ver --activate echo pnpm $ver for i in 1 2 3; do rm -rf node_modules rm -rf ~/.pnpm-store # 冷安装才需要热 store 场景保留该目录 start$(date %s%N) pnpm install --frozen-lockfile end$(date %s%N) echo round $i: $(( (end - start) / 1000000 )) ms done done脚本比较朴素但足够解决问题。如果你想看更详细的耗时分布可以用--reporterndjson把结构化日志导出来按阶段做聚合。下面分享的数据就是我用这种办法统计的。3. 实测数据冷安装、热存储、幂等校验分别差多少3.1 三个场景的原始数据我先给结论后面再拆原因。场景pnpm 11 耗时pnpm 12 耗时提升比例冷安装清 store 清 node_modules58.4s21.7s约 2.7 倍热 store 安装保留 store清 node_modules26.8s13.4s约 2 倍幂等校验无变更 install6.9s1.5s约 4.6 倍这个结果其实比我预想的要猛。尤其是幂等校验从 6.9 秒降到 1.5 秒体感上几乎变成了瞬间完成。CI 里每个 job 都要先跑一次 install如果依赖没变化旧版也要花 6 秒多去确认一切正常现在这 6 秒基本可以忽略不计。要注意的是这个数字受项目环境、磁盘性能、registry 镜像延迟影响很大。你的项目如果很小可能只有几十个依赖差距不会这么夸张如果比我这个项目还大收益可能更明显。所以比起绝对数字我更关注的是相对提升以及时间到底省在哪一段。3.2 时间到底省在哪一段按 install 阶段拆分我用--reporterndjson导出日志后按阶段做了聚合。冷安装场景下p时间的分布大概是这样阶段pnpm 11pnpm 12说明解析 lockfile / 构建依赖图10.8s1.9s依赖图构建的瓶颈在字符串解析和对象管理下载 tarball14.2s8.3s网络占用大头但并发调度优化后仍有明显收益解压 完整性校验18.1s6.2s这是 Rust worker 最受益的一段写入 store 创建硬链接11.7s4.1s大量文件系统 syscall并发模型差异很大其他生命周期脚本等3.6s1.2s调度开销减少可以看出提升最大的并不是下载环节而是解压、校验和文件链接这种纯本机操作。这也解释了为什么在热 store 场景下收益依然明显——下载量减少了但剩下的文件处理和依赖图解析恰恰是 Rust 内核加速最狠的部分。3.3 为什么能快这么多三个核心原因很多人误以为Rust 就是比 JS 快是唯一原因其实不完全是。我在实测后重新翻了一遍实现思路觉得真正起作用的是这三点。第一校验和解压合并成了一趟。旧版流程里tarball 下载后要先在 Node 侧做完整性校验确认哈希没问题再解压解压完再写 store。数据在内存和临时文件之间反复横跳。Rust 内核可以边流式解压边算哈希校验通过的数据直接写本地省掉了中间环节这在大 tarball 上的收益非常大。第二多线程并发的粒度更细。Node 的文件操作虽然不阻塞主线程但 JS 层要管理 Promise、处理回调大量文件操作时 event loop 会频繁切换上下文。Rust 侧可以直接用线程池并行处理一批 tarball并且不用考虑 JS 对象生命周期所以文件系统的吞吐量更高。第三减少 GC 尖峰。依赖图解析会产生海量临时字符串和对象旧版在 Node 堆里频繁分配、释放install 过程中你经常能看到 CPU 曲线像锯齿一样。pnpm 12 把这块逻辑移到了 Rust 侧运行时内存管理更紧凑不再有周期性的卡顿。4. 资源占用和并发行为的变化4.1 内存峰值与 CPU 曲线我顺手用/usr/bin/time -v记录了峰值驻留内存。pnpm 11 冷安装时峰值大约在 860MB 左右pnpm 12 大概是 640MB。这个差异来自于两边处理数据的方式不同旧版在高并发下载和解压时Node 进程内存里要堆积大量 Buffer、Promise 状态和临时对象而 Rust worker 在一块独立的内存区域里处理数据很多中间结果用完即弃不需要长期留在堆里。CPU 曲线差异更直观。旧版在解析依赖图和执行脚本阶段会出现明显的尖峰和凹谷——尖峰是发生了大量计算凹谷是 GC 在做清理。pnpm 12 的曲线整体平稳占用率更像是一条相对平滑的上升和回落。对我们跑 CI 的机器来说平稳意味着邻居任务被干扰的概率更低多人共享 runner 时这个优势很实用。4.2 并发参数在新版里的正确姿势升级之后有个坑容易被人忽略你以前调过的并发参数可能语义变了。pnpm 11 里我习惯用--child-concurrency和--network-concurrency来控制安装脚本和网络请求的并发数。到了 pnpm 12网络相关的参数仍然有效但 Rust worker 内部的线程调度不完全受这两个参数控制。在文档里我看到新版本提供了单独控制 worker 线程数的配置项不过我在实测中更推荐的做法是先不要动参数用默认值完整跑一遍再根据自己的 CI 环境调整。我试过在一个共享的 4 核 runner 上同时跑两个pnpm installpnpm 12 的默认并发策略明显比旧版更激进两个任务抢资源的时候反而比串行慢。如果你的 CI 上经常有多个构建任务共用一台机器建议把 worker 线程数上限调低一些具体值根据 CPU 核心数按比例来不要盲目拉满。5. 日常操作里能感知到的变化5.1 pnpm add 的速度变化install 是最明显的风向标但日常开发中你还会频繁用到pnpm add。我试了一下给这个 monorepo 添加一个新依赖比如lodash-espnpm 11敲完命令到控制台可交互大约 6 秒左右pnpm 12大约 2 秒出头。这个场景的慢主要在于添加依赖后要重新解析整棵依赖树、生成新的 lockfile再把新增的依赖链接到所有需要的位置。旧版里依赖树重建是纯 JS 逻辑节点越多越慢新版把图和 lockfile 的重建路径也 Rust 化了所以体感提升很明显。5.2 离线安装和 CI 缓存场景还有一个容易被忽略的点pnpm install --offline。我们 CI 里会把 store 打包成缓存传到云存储下次构建时先恢复 store再用--offline避免访问 registry。在 store 已存在的情况下旧版跑一次 install 大约要 2.8 秒新版只需要 1.3 秒左右。虽然绝对时间不长但每个 job 都要跑一整天下来省下的分钟数还是很可观的。如果你用了类似store 缓存 离线安装的方案升级 pnpm 12 后记得重新测量一下缓存恢复后的 install 耗时这个场景下的收益可能比你想象中还大。6. 升级 pnpm 12 之前我建议你先扫一遍这些坑6.1 lockfile 版本升级不是小事pnpm 12 对 lockfile 的版本号做了更新。升级后第一次pnpm install会重写 lockfile如果团队里有人还在用旧版本 pnpm可能会遇到 lockfile 被来回改的问题。我的建议是不要直接在业务分支上升级然后顺手提交所有改动。先开一个单独的分支跑一遍pnpm install --lockfile-only --frozen-lockfilefalse把 lockfile 的 diff 单独提出来和团队说清楚升级后 lockfile 会一次性变更之后大家需要统一 pnpm 版本。否则混在功能提交里出问题不好回溯。6.2 lifecycle scripts 的管理方式变了pnpm 从 10 开始对依赖包的生命周期脚本执行权限收紧了pnpm 12 延续了这个策略而且管理方式上有变化。默认情况下只有少数被信任的包能执行postinstall这类脚本其他包的构建脚本会被忽略。这意味着如果你项目里有esbuild、sharp、node-sass这类需要下载二进制或编译的依赖升级后可能出现包装上了但二进制没下载的情况。解决办法是借助pnpm approve-builds手动放行可信的包或者在 package.json 里维护一个允许执行安装脚本的白名单。我建议升级前先把项目里所有依赖的 build 脚本跑一遍看哪些是真正需要放行的提前配好白名单别等 CI 挂了再加。6.3 老 Linux 镜像和 Node 版本的下限问题Rust 编译出来的二进制对系统环境有要求尤其是 glibc。我在测试里发现pnpm 12 的新 worker 在比较新的 Ubuntu/Debian 镜像上没问题但如果你 CI 用的还是基于 CentOS 7 或更老的容器镜像启动时可能会直接报GLIBC_xx not found之类的错误。这个问题的排查成本不高但很容易让人懵。升级前建议先看一眼 CI 基础镜像的年代如果太老要么升级镜像要么暂缓升级 pnpm。另外 Node 版本也别太旧pnpm 12 对 Node 的版本要求比 11 明显提高了旧版本 Node 下可能连安装命令都跑不起来。6.4 网盘/NFS 上的 store 要多留个心眼如果你为了省磁盘空间把 pnpm store 放到了 NFS 或者网络挂载盘上升级后要特别留意。Rust worker 的并发写入模型和旧版不太一样在本地磁盘上它效率很高但 NFS 的锁语义和缓存一致性和本地盘不同并发高的时候容易出现奇怪的文件锁冲突。我们没在 NFS 上放 store但我听说过其他团队遇到过类似问题。如果你们确实有这种用法升级后先在测试环境完整跑几轮 install观察有没有偶发的 EBUSY 或文件不存在 报错。如果中招暂时还是把 store 放回本地磁盘或者用 CI 缓存机制打包 store。7. 升级后遇到 install 偶发失败我是怎么排查的7.1 现场信息和第一轮猜测升级到一个大版本最怕的不是变慢而是偶尔挂一次。我们就在升级后的第三天遇到了CI 上某个 job 的 install 偶尔失败报错信息里有 tarball 完整性校验相关的字样重跑一次又好了。这种偶发性最让人抓狂。第一轮我猜是老生常谈的网络问题。我们用的是内网 npm 镜像偶尔超时很正常。但连续几次出现后我意识到不是偶发网络抖动因为它总在同一个依赖附近失败而且失败时机非常固定——都是在我看了好几遍的那几个包解压阶段。7.2 顺着日志找到根因我开始认真收集完整日志。具体做法是加上了--reporterappend-only和--verbose把完整输出落盘同时让 CI 保留失败现场不要自动重试。日志显示校验失败时store 里对应包的内容掐头去尾地缺了一部分。这让我开始怀疑 store 的缓存被污染了。进一步检查发现这个项目在此前很长一段时间里都是用旧版 pnpm 在跑store 里存在旧版本写入的 metadata 缓存文件。pnpm 12 升级后Rust 内核在读取这些旧缓存时按新格式解析失败在并发场景下某些文件被错误地跳过或写半截。根因基本确定后处理方案就很简单清掉旧的 store 缓存删掉node_modules重新完整安装一遍。之后连续跑了很多轮没有再出现过同样的问题。7.3 解决办法和后续预防最终我们做的三件事在 CI 缓存 key 里加入了 pnpm 版本号避免旧版 store 缓存被新版复用升级后的第一次全量构建主动清空一次 store 缓存让 Rust 内核重新建立完整的 metadata在团队文档里记录了一条规则跨大版本升级 pnpm 后先手动执行一次pnpm store prune和全量重建再恢复正常流程。这个坑给我们的教训是新版本切换后不要理所当然认为旧缓存一定还能用。尤其是像 pnpm 这种对 store 数据结构有强依赖的工具跨版本升级时缓存兼容性一定要主动验证而不是等 CI 挂了再去查。8. 根据这段实测我给项目组的升级建议如果你问我现在该不该把 pnpm 升到 12我的回答是分情况。如果你们项目和我这个类似属于中大型 monorepoCI 里的冷安装耗时经常超过半分钟那升级收益非常大值得尽快提上日程。光是冷安装 2.7 倍、幂等校验 4.6 倍的提升就能让每个 CI job 的耗时肉眼可见地降下来。如果项目比较小只有几十个依赖那升级的收益感知没那么强但也没有明显坏处顺手升了就行。真正应该谨慎的是那种历史包袱很重的项目——大量依赖的安装脚本没配白名单、CI 基础镜像非常老、又用了网络盘放 store。这种情况下建议先在分支上完整验证一轮把前面提到的几个坑都排掉再切主版本。最后说一句我在实际测试里的体会pnpm 12 的 Rust 内核不是营销噱头它解决的是 install 链路里真正拖后腿的 IO 密集环节。但也别指望换一个包管理器就能解决项目构建的所有性能问题——它管的是从 lockfile 解析到 node_modules 可用的这一整段你项目的 Vite/Webpack 编译时间它管不着。先把依赖安装这关过了再去优化真正的业务构建顺序不能反。