深入探讨 tnpm 快速模式——我们如何做到比 pnpm 快 10 秒

2025-06-04

深入探讨 tnpm 快速模式——我们如何做到比 pnpm 快 10 秒

背景

作为一名前端老手,不得不指出的是,现在前端项目的复杂度越来越高,导致依赖的安装越来越慢。

在阿里巴巴和蚂蚁集团,工程生产力是衡量工程师的重要指标,而前端依赖项的安装速度是一个很大的负面影响因素

我们是蚂蚁集团负责前端基础设施的团队,主要负责公司内部 Node.js 社区的建设,以及eggjscnpm等多个开源项目的维护

我们在 2021 年发起了一项计划,目标之一是优化依赖项的安装速度。我们成功将依赖项的安装速度提高了 3 倍

在本文中,我们想与大家分享“tnpm快速模式”的理念和成果。

全面预防医学快速

非常感谢@sodatea@nonamesheep、@Sikang Bian(RichSFO)、@geekdada对本文的翻译原文由@atian25撰写并发布在知乎上。


TL;DR


为什么 npm 这么慢?

npm-so-slow

在现代前端生态系统中,模块总数呈爆炸式增长,依赖图也变得越来越复杂。

  • npm生态系统中模块数量浩如烟海。截至 2021 年底, npm 软件包总数已超过 180 万个,是其他语言模块数量的数倍。
  • 模块关系变得极其复杂。重复的依赖关系和大量的小文件浪费了磁盘空间,并降低了磁盘写入速度。

前端模块系统更倾向于小型且精心设计的模块。这虽然给社区带来了前所未有的繁荣,但也带来了复杂的依赖关系,直接导致了安装速度变慢。这其中需要做出一些权衡。

生态现状是否正确,已经超出了我们今天的讨论范围,所以我们暂时先讨论一下如何提高安装速度

npm install 的工作原理

以上简单介绍一个应用程序的依赖安装流程,关键操作包括:

  1. 查询子依赖项的包信息,进而获取下载地址。
  2. 下载tgz包到本地,解压,然后安装。
  3. 创建'node_modules'目录并将下载的文件写入其下。

依赖项安装

vuepress@1.9.2个例子,它有大约1000 个不同的依赖项,占用 170MB 磁盘空间,包含 18542 个文件。

但如果按照 npm@2 的实现方式嵌套安装依赖包,最终会安装多达 3626 个依赖包,冗余依赖超过 2000 个,实际占用磁盘空间 523MB,包含 60257 个文件。

文件 I/O 操作成本很高,尤其是读取/写入大量小文件时。

npm@3 首先提出了一个优化想法来解决重复依赖 + 不必要的深层层次结构的问题:扁平化依赖功能,其中所有子依赖项都被平放在根目录下的node_modules下。

然而,这种优化最终又引入了新的问题:

  • 幻像依赖
  • NPM doppelgangers。它仍然可能导致同一个包的多个副本(例如,在上面的例子中仍然有 183 个重复的包)
  • 非确定性依赖结构(尽管可以通过依赖图解决)
  • 复杂的扁平化算法带来的性能损失

鉴于“扁平依赖”的诸多副作用,pnpm提出了一种替代解决方案,即通过符号 + 硬链接

下午

这种方法非常有效,因为:

  • 它在兼容 Node.js 解析算法的同时,减少了包重复。该方法不会引入诸如幻影依赖、重复依赖等副作用。
  • 具有全局缓存的硬链接方法减少了文件重复并节省了磁盘占用。

结果数据不言而喻:1109 个模块、18747 个文件、5435 个目录、3150 个符号链接、175M 磁盘占用。

类似地,受 pnpm 的启发,我们cnpm/npminstall在 cnpm 中进行了重构和实现,以利用符号链接。但它并没有使用硬链接,也没有提升传递依赖关系。

但值得注意的是,这种方法存在一些潜在问题:

  • 几年前,我们观察到符号链接在某些 IDE(例如 WebStorm 和 VSCode)中会导致索引问题和死循环。这个问题现在可能尚未完全解决,但应该可以通过 IDE 优化来缓解。
  • 兼容性。相对路径需要适应 EggJS 和 Webpack 等插件加载逻辑,因为它们可能不遵循 Node.js 标准解析策略,该策略在目录结构中查找模块直到磁盘的根目录。
  • 不同应用程序的依赖关系硬链接到同一个文件,因此在调试时修改该文件可能会无意中影响其他项目。
  • 硬链接无法跨文件系统使用。而且不同操作系统对符号链接的实现也存在差异。此外,非 SSD 硬盘由于磁盘 IO 操作,仍然会造成一定的性能损失。

此外,Yarn 还提出了 Plug'n'Play 等其他优化,由于这些优化过于激进,无法兼容现有的 Node.js 生态,这里就不再赘述了。

元数据请求

我们来看看依赖项的安装过程:

  • 每个依赖项需要一次元数据查询和一次 tgz 下载,总共需要 2 个 HTTP 请求;
  • 如果同一个包有不同的版本,则只查询一次元数据,然后分别下载每个版本的 tgz。

由于依赖项数量通常非常大,HTTP 请求的总数也会随之放大,从而导致时间消耗显著增加。在上面的例子中,npm@2 会发出超过 2500 个 HTTP 请求。

一种常见的优化策略是提前计算依赖关系图,这样包管理器就可以直接下载 tgz 文件,而无需查询包元数据。这样一来,就可以避免大量的网络请求。

NPM 率先提出了shrinkwrap的概念。它很快就被Yarn 的lockfile所取代。pnpm 中也有类似的概念,但格式不同。
虽然 lockfile 的目的是锁定依赖版本,但人们发现 lockfile 也可以用作依赖关系图来加快安装速度。

然而,还存在一些尚未解决的问题,例如:

  • 除非将锁文件预先存储在源代码管理中,否则第一次安装不会加速。
  • 在实践中,锁定版本会导致大型项目出现一些治理问题。

简要概述

安装过程摘要

总而言之,为了加快安装过程,我们需要考虑:

  • 如何更快地获取依赖关系图?(解析策略)
  • 如何加快 tgz 下载速度?(网络 I/O)
  • 如何加快写入磁盘的速度?如何处理重复的依赖项?(文件 I/O)

社区达成了一些共识:

  • 由于请求得到了更好的调度,依赖关系图的利用可以实现更高效的并发下载。
  • 简化的node_modules目录由于重复依赖项减少,可以减少文件 I/O 操作的时间。
  • 全局缓存可以减少下载请求的数量。

仍存在的问题:

  • 锁定文件会增加维护成本。无论锁定还是解锁版本都不是灵丹妙药。
  • 平面依赖和符号链接(简称 symlinks)有各自的兼容性问题。
  • 目前,关于全局缓存的最佳实现方案尚无定论。“未压缩复制”方法会产生大量文件 IO,而硬链接方法则可能引发潜在的冲突问题。因此,需要做出权衡。

什么是 tnpm 和 cnpm?

tnpm-cnpm

如上图所示,简单来说:

  • cnpm是我们对 npm 的开源实现,支持与官方 npm 注册中心的镜像同步以及私有包功能。
  • npmmirror是一个基于 cnpm 的社区部署项目,为中国前端开发者提供镜像服务。
  • tnpm是我们为阿里巴巴和蚂蚁集团提供的企业服务,也是基于cnpm,并附加了企业级定制。

tnpm不仅是一个本地命令行界面,而且还是一个远程注册服务,与其他包管理器相比,它允许更深层次的优化。


优化结果

测试场景

如果你无法衡量它,你就无法改进它。——彼得·德鲁克

测试场景

附言:我们可能是业内第一家在 Mac mini m1 上重新安装 Linux 操作系统,从而组建前端构建集群的公司。除了其他所有优化之外,这次重新安装本身就使我们的整体构建速度翻了一番。

测试结果

测试结果

我们暂时不对结果进行解读,等我们系统地讲解一下 tnpm 快速模式的优化思路之后,大家会有更深入的感受和理解。

支持数据

回想一下我们在分析整体经济放缓原因之初给出的数据。完整的数据集如下所示。

支持数据

我们使用stracecharles收集了相关数据,没有使用 lock 或 cache,并统计了相应的文件数量和大小。

以下是简要解释:

  • 文件数量:“平面依赖”和“符号链接和硬链接”的数量基本相同。它们都能显著减少磁盘占用。
  • 磁盘IO:重要指标,文件写入次数直接关系到安装速度。
  • 网络速度:反映安装过程能否尽可能充分利用带宽,越大越好。
  • 请求次数:包含tgz下载次数和查询包信息次数,可以近似理解为整体模块数量。

从数据中我们可以看出,tnpm 无论是对于磁盘 IO 还是网络 IO 都进行了更加优化。


这些优化是如何实现的?

网络I/O

我们优化网络I/O只有一个目标:如何最大化网络利用率

网络I/O

第一个优化来自“依赖图”

  • 常见的做法是使用依赖图来避免在客户端请求每个包的元数据,从而大大减少 HTTP 请求的数量;
  • 我们的方法的特别之处在于:我们在服务器端生成依赖关系图,并采用多级缓存策略;
  • 它基于@npmcli/arborist,因此与 npm 兼容。

我们在企业级项目中的经验和理念是,我们不主张在本地锁定版本,而只在迭代流程中复用上一阶段的依赖关系图,例如从开发环境到测试环境(或紧急迭代)。(锁定版本与不锁定版本是一个常见的争论话题,目前尚无统一意见。通常建议根据企业团队的具体情况找到相应的平衡点。我们在此不再赘述。)

第二个优化是 HTTP 请求预热

  • tgz下载过程会先访问注册中心,然后被302重定向到OSS(阿里云对象存储服务)下载地址。
  • 我们可以通过提前预热来提高并发性,从而减少整体的HTTP时间消耗。
  • 值得一提的是,我们遇到了间歇性 DNS 5 秒延迟的问题。

npm 官方注册中心没有这样的 302 重定向,我们将下载流量从注册中心分离出来,重定向到 CDN 缓存的 OSS 地址,提升了注册中心服务的稳定性。

第三个优化是合并文件:

  • 我们在测试过程中发现带宽利用不充分,分析发现:依赖包数量巨大,频繁写入小文件容易造成文件IO瓶颈。
  • 由于 tar 是一种存档文件格式,因此只需将 tgz 文件提取为 tar 文件,就可以在写入磁盘时轻松地正确合并文件。
  • 经过反复测试,将1000个tgz文件合并成40个tarball文件是理想的。

第四个优化是使用Rust重新实现下载和解压的过程:

  • 使用四十个并发线程以流式方式下载、解压并将原始包合并为四十个 tarball 文件。(价值来自反复测试)
  • 我们曾尝试使用 Rust 来实现此功能。它在解压文件方面展现出了一些潜力,但还不足以让我们相信它是解决所有性能问题的灵丹妙药。我们使用 neon 来连接 Rust 和 Node.js,并计划将其重写为napi-rs模块。

cnpm 不是基于 Rust 的实现。


FUSE技术

我们认为原始的嵌套目录方法比扁平化的node_modules 方法更好。但我们不希望出现符号链接引起的兼容性问题。如何才能一举两得?

首先我们来介绍一下一项“黑科技”:FUSE(FileSystem in Userspace)。

听起来很抽象?我们来想一个前端开发者熟悉的类比:使用 ServiceWorker 来细化和定制 HTTP Cache-Control 逻辑。

同理,从前端开发者的角度来看,我们可以将 FUSE 理解为 ServiceWorker 的文件系统版本。我们可以通过 FUSE 接管目录的文件系统操作逻辑。

保险丝

如上图:

  • 我们在nydusnpmfs之上实现了一个 FUSE 守护进程,它将为一个项目挂载一个目录。
  • 当操作系统需要读取该目录中的文件时,我们的守护进程会处理该任务。
  • 守护进程将查找依赖关系图以从全局缓存中检索相应的文件内容。

通过这种方式,我们能够实现:

  • 所有针对文件和目录的系统调用都会将该目录视为真实目录。
  • 文件彼此独立。对一个文件的修改不会导致其他项目的更改(与硬链接方法不同)。

nydus 目前不支持 macOS,因此我们实现了 nydus 到 macfuse 的适配器。待其准备就绪后,我们将开源。

冷知识:Nydus是《星际争霸》中的一种虫族建筑,用于在地图上快速移动单位。


OverlayFS

在日常开发调试过程中,我们可能需要临时修改 node_modules 中的代码。由于符号链接和硬链接的工作原理,编辑模块内的文件可能会无意中导致另一个模块的更改。

FUSE 支持自定义写入操作,但实现起来比较繁琐。因此我们直接使用联合挂载文件系统OverlayFS

  • OverlayFS 可以将多个不同的挂载点聚合到一个目录中。
  • 一种常见的情况是将读写层覆盖在只读层之上,以启用读写层。
  • Docker 镜像就是这样实现的,镜像中的层可以在不同的容器中重复使用,而不会互相影响。

OverlayFS

因此,我们进一步实施:

  • 使用 FUSE 目录作为 OverlayFS 的 Lower Dir,我们构建一个读写文件系统,并将其挂载为应用程序的node_modules目录。
  • 利用其COW(写时复制)特性,我们可以重用底层文件以节省空间并支持独立的文件修改,隔离不同的应用程序以避免干扰,并独立重用全局缓存的一个副本。

文件输入/输出

接下来我们来说说全局缓存,目前业界主要有两种方案:

  • npm:将tgz解压成tar作为全局缓存,再次安装依赖项时解压到node_modules中。
  • pnpm:将tgz解压成文件,并以hash形式全局缓存,这样同一个包的不同版本可以共用同一个文件,再次安装时直接硬链接即可。

它们的共同点是,在某个时刻,TGZ 文件会被解压为独立文件并写入磁盘。正如我们上面提到的,解压产生的大量小文件会导致大量的 I/O 操作。

有一天,我们突然想到,也许我们可以跳过减压的步骤
🤔🤔🤔

文件输入/输出

因此,我们更进一步:

  • node_modules通过 FUSE + 依赖关系图直接映射到 tar 档案,从而无需在解压缩时进行文件 I/O 操作
  • 同时,FUSE 的高度可控性使我们能够轻松支持嵌套目录和扁平结构,并根据需要在它们之间切换。
  • 甚至更好:我们将来如何进一步提高云存储访问的性能,以至于我们甚至不必下载 tgz?

其他一些尝试:我们尝试使用 stargz + lz4 代替 tar + gzip,但好处并不显著:

  • stargz 比 tar 拥有更强大的索引功能。但实际上,单独的依赖关系图也能达到类似的目的,无需将它们打包在一起。
  • lz4 相对于 gzip 具有巨大的性能提升,但我们发现在当前的实践中投资回报率并不高。

额外费用

没有任何解决方案是完美的,而且我们的解决方案会产生一些额外的成本。

第一个是FUSE的成本

  • 我们需要注意跨系统的兼容性问题。虽然每个操作系统都有支持库,但测试它们的兼容性需要时间。
  • 我们需要支持企业内部使用的场景的特权容器。
  • CI/CD 等社区场景依赖于 GitHub Actions 和 Travis 是否支持 FUSE。

第二个是注册服务器的维护负担

  • 由于服务器端资源限制,生成依赖关系图分析的功能只能在私有企业注册表中启用。
  • 公共镜像服务将回退到 CLI 端来生成依赖关系图。

PS:社区的解决方案,包括我们的方案,都无法解决同一个依赖多次 require cache 的问题。或许可以通过 ESM Loader 来解决,但这不在我们今天的讨论范围内。


概括

关键思想

TNPM 摘要

总结一下,我们的解决方案的核心优势是:

  • 网络I/O
    • 使用服务器生成的依赖关系图跳过元数据请求。这样可以节省Number of packages * Metadata request duration
    • 使用Rust语言带来的性能提升,以及下载过程优化带来的并发性提升。
  • 文件输入/输出
    • 通过存储合并后的 tar 文件来减少磁盘写入。这样可以节省(Number of packages - 40) * Disk operation duration
    • 在项目中,通过不解压文件而是使用 FUSE 挂载来减少磁盘写入。这可以节省(Number of files + Number of directories + Number of symlinks and hard links) * Disk operation duration
  • 兼容性
    • 标准的 Node.js 目录结构。无符号链接,不会因扁平化node_modules 而引发任何问题。

黑魔法黑科技的区别在于,前者是一堆“这没问题”的肮脏小伎俩,用来达到目的;而后者则是一劳永逸地解决挑战的跨学科巨无霸。FUSE 就是我们的双向量箔。(《三体》

数据解释

通过上面的分析,大家可能已经完全理解了 tnpm 快速模式的优化思路。现在我们回过头来解读一下之前的测试结果数据。

注意:“tnpm 快速模式”仍在小规模测试中,预计在未来的迭代中会有所改进。因此测试数据仅供参考。
此外,表格中的 yarn 比npm@8慢。我们目前尚不清楚原因,但我们已经使用 pnpm 基准测试了多次,结果仍然相同。

测试数据

以下是简要解读:

(1)生成依赖关系图所需的时间。

  • 测试 1 和测试 5 之间的差异在于相应的包管理器所花费的时间。
  • pnpm通过客户端HTTP请求来分析图表,大概需要4秒左右(查询包信息和下载是并行的)。
  • tnpm 通过服务器端计算来分析图表,目前需要 5 秒。(当访问远程缓存时,这应该花费少于 1 秒的时间)。
  • 现在速度是一样的,但是由于tnpm的网络延迟比pnpm要小,所以我们以后还需要对此进行优化。

在企业场景下,依赖模块比较收敛,所以大多数情况下,tnpm的第一次测试在命中缓存的情况下应该需要5秒(tnpm的依赖图生成有缓存机制)。

(2)文件I/O开销

  • 测试 5 更接近具有依赖关系图 + 无全局缓存的 CI/CD 场景。
  • 观察到的主要时间消耗是 TGZ 下载时间 + 文件 IO 时间。由于 TGZ 下载时间相似,因此时间差距主要来自文件 IO。
  • 我们从数据中得出的结论是,tnpm 比 pnpm 快 4 秒。FUSE 帮助我们节省了解压缩 + 文件写入的时间,以及 TAR 合并的时间。

(3)地方发展

  • 依赖关系图和全局缓存均可用于本地开发。
  • 这对应于测试 2(依赖项不是新的,二次开发)、测试 3(二次开发,重新安装依赖项)和测试 4(新应用程序的第一次开发)。
  • 原则上,所用时间 = 依赖图更新 + 写入 node_modules 文件 + 少量包下载和更新。
  • 由于tnpm还在开发中,本次未能测试,但从上面的公式分析来看,tnpm相比pnpm有IO优势。

总结一下:tnpm 相对于 pnpm 的速度优势是依赖关系图快 5 秒 + FUSE 免费解压快 4 秒。


未来规划

前端包管理已经发展了近十年。npm 曾是该领域的先驱,不断创新和发展。然而,在 npm 击败 Bower 等所有其他替代方案后,其发展略显停滞。不久之后,Yarn 成为挑战者,重振了整个竞争格局,推动了 npm 的进一步创新。npm 成功应对新的挑战,再次引领创新。

我们认为,对于前端依赖项的优化和治理,还有很长的路要走。我们希望继续加强与国内外同行的合作,共同推动包管理器的进步。

cnpm 并非旨在取代现有的包管理器。我们始终致力于提供企业级解决方案,用于构建本地私有仓库。如果没有特定需求,我们不建议开发者使用 cnpm cli。pnpm 和 yarn 已经足够好了。

npmfs 的设计初衷是与包管理器无关。我们希望它不仅能惠及 cnpm/tnpm,还能惠及社区中所有备受喜爱的包管理器。

如果社区认可我们提出的解决方案,我们很乐意为其他广受欢迎的包管理器做出贡献。敬请期待 npmfs 的开源!

因此,我们后续的计划是尽可能的将我们在企业级私有部署和治理方面积累的经验回馈给社区。

  • 等 tnpm rapid 模型完善之后,我们会开源相应的能力,以及 npmfs 套件。可惜的是,目前社区还没有办法体验。
  • 目前,cnpm/npmcore正在重构,以更好地支持私有部署。(我们诚挚欢迎开源社区的贡献,以进一步加快这项工作。)

与此同时,如果我们能够共同努力,使前端包管理标准化,那么对社区来说将是非常有益的:

  • 我们需要一个像 ECMAScript 这样的标准来规范每个包管理器的行为。
  • 我们需要一个像“Test262”这样的一致性测试套件。
  • 我们应该加速从 CommonJS 到 ES 模块的过渡。
  • 我们应该找到一种方法来彻底解决前端与 Node.js 不同依赖场景之间差异导致的混乱局面。

关于我

我是 TZ ( atian25 ),目前就职于蚂蚁集团,主要负责 Node.js 前端基础设施的搭建和优化。热爱开源,是eggjscnpm
的主要维护者 。

Node.js 是前端领域不可或缺的基础设施。或许未来前端的变革会让所有现有的工程难题都变得无关紧要。但无论发生什么,我只希望能够认真记录我在这个领域的所见所想,并与正在经历当下“前端工业化”演变,并同样为此苦恼的同行们交流

在企业应用场景中,优化前端构建执行速度是一项系统工程挑战。依赖项解析和安装只是我们面临的众多挑战之一。机遇无限。我们持续寻找优秀的工程师加入我们,共同推动创新。期待您的加入。

文章来源:https://dev.to/atian25/in-deep-of-tnpm-rapid-mode-how-could-we-fast-10s-than-pnpm-3bpp
PREV
JavaScript 中的集合
NEXT
JavaScript 中的纯函数和副作用是什么?