通过将一个内部的Node.js模块替换为Rust原生模块,性能惊人地提高了25倍。下面,我们将探讨其背后的原因。
在2020年,我所在的Wix团队推出了一个名为CDN Stats的内部新产品。正如其名,CDN Stats是一个用于展示和汇总Wix CDN的数据和统计信息的平台。
在那一年,我为一个我所进行的实验进行了演讲。在这次实验中,我尝试将一个模块重构为Rust的原生Node.js扩展,结果性能显著提高了25倍。
CDN统计截图
这个平台为Wix的前端开发者提供了有关客户如何使用其编译资产的实时数据,从而提高了他们的工作效能。这些数据包括:
- 使用的平台
- 按平台的下载次数
- 传输时的响应大小
通过这些数据,前端开发者可以明确知道哪些资源需要进行优化,以及这种优化的重要性。这个平台帮助我们发现并修正了一个可能被我们忽视的重要问题:由于一个人为的失误,我们的一份主要的JavaScript文件大小激增至33MB(!),原因是它直接内置了一个未经压缩的SVG,而没有将其提取出来或进行动态加载。
虽然听起来这个项目似乎很简单,我们只是在做一些基本的数据统计和汇总。在数据库上设置一些索引,然后通过一个数据表展示出来。但真的就这么简单吗?
那它是如何运作的呢?
为了填充这个数据表,我们必须仔细检查CDN的日志。这些日志是存储在Amazon S3上的制表符分隔的值文件(TSV)。每天大约有290k个这样的文件,总大小可能达到200GB。
因此,我们每天会下载前一天的TSV文件,将其解析成有意义的信息,然后将这些数据保存在我们的数据库中。我们的做法是将每个日志文件放入一个任务队列中。这种策略使我们可以并行处理任务,利用25个实例每天在大约3小时内解析所有的日志文件。
接着,我们每隔几小时为过去的一周生成一个汇总数据。这个过程通常需要大约1小时,主要是在数据库中使用MongoDB的聚合功能来完成。
而MongoDB的查询过程是非常繁琐的。
🤔你可能会想:“难道这不是AWS Athena能够解决的问题吗?”你想得没错。但是,当时我们并没有考虑使用它。简而言之,当时它不是我们可行的选择。
那问题到底是什么?
解析几十GB的TSV文件无疑是一项耗时的工作。在这上面花费大量的时间和资源并不是一个让人愉快的事。坦率地说:运行25个Node.js实例,每个实例工作三个小时来解析200GB的数据,显然是有些问题的。
我们最终找到的有效方法并不是一开始就采用的。最初,我们尝试利用Wix的内部无服务器框架。我们并行地处理所有日志文件(还用Promise.all来聚合,很高效吧?)。但出乎我们意料的是,我们很快遭遇了内存溢出问题,这甚至发生在我们使用像p-limit这样的工具来限制同时只进行两个任务的时候。所以,我们不得不重新考虑解决方案。
接下来,我们试图直接迁移相同的代码到Wix的Node.js平台,这个平台在Kubernetes集群的Docker容器中运行。但我们还是碰到了内存溢出的问题,这迫使我们减少并行处理的文件数量。虽然最后我们成功地使其运行,但速度却令人失望。处理一天的数据竟然需要超过一天的时间。显然,这种方式并不具备可扩展性!
因此,我们转向任务队列模式。通过将服务器扩展到25个容器,我们终于缩短了处理时间。但是,是否真的可以认为开启25个实例是一种合理的做法呢?
也许是时候重新审视JavaScript了。可能有一些问题是JavaScript本身难以高效解决的,尤其是在主要受到CPU和内存限制的工作中——这些恰恰是Node.js的短板所在。
那为什么会是这样的呢?
Node.js 是一个具备垃圾回收机制的虚拟机
Node.js 无疑是一项杰出的技术,它不仅推动了技术创新,还助力了众多开发者顺利完成任务和交付成品。相较于 C 和 C++ 这类语言,JavaScript 不必进行显式的内存管理。所有这些都由其运行时环境负责,也就是众所周知的 V8 虚拟机。
是 V8 决定何时释放内存的。许多语言的设计初衷都是为了提供更佳的开发体验。但对于一些特定应用,显式的内存管理是不可或缺的。
考虑以下简化的 TSV 解析代码:
for await (const line of readlineStream) {
const fields = line.split('\t');
const httpStatus = Number(fields[5]);
if (httpStatus < 200 || httpStatus > 299) continue;
records.push({
pathname: fields[7],
referrer: fields[8],
// ...
});
}
这段代码的逻辑很清晰。利用 readline,我们可以在数据流中按行读取文件,避免因一次性读取整个文件造成的内存压力。每读取一行,我们都按照制表符进行分割,并把相关数据存入结果数组。但在内存层面,这中间发生了什么呢?
每次调用 line.split('\t') 时,系统都会为我们分配一个包含多个元素的全新数组。其中的每个元素都是新分配的字符串,这自然会占据一定内存。而 Array#split 就是这么工作的,每次调用都会生成新的数组和字符串。
更为关键的是,直到垃圾回收机制运行,line 和 fields 都不会被从内存中清除。这导致 RAM 中充满了不再使用的字符串和数组,它们等待被清理。我们的计算机对这种情况显然不满,这一点并不出乎意料。
寻找 Rust 的舒适区
作为一位有一定经验的 Rust 开发者(我曾用 Rust 创建了一个相当成功的开源项目,名为 fnm),我深知 Rust 在内存管理方面的卓越性能。Rust 不依赖于运行时垃圾回收,这使得它非常适合嵌入其他语言或虚拟机,成为解决此类问题的理想选择。
我之前也曾分享过一些相关的观点。虽然使用 Rust 构建应用是一种强大的选择,但能够将 Rust 嵌入到经过严格测试的 VM 中,同时不影响应用的其他部分,这种方式无疑更具革命性。通过在 Node.js 中创建服务,并仅在需要时采用 Rust,这不仅可以提高开发效率,还可以在必要时将其替换为更为高效的模块。
采用 Node.js 作为应用的主入口,我可以充分利用 Wix 提供的标准存储/数据库连接、错误报告、日志记录以及配置管理功能,而无需在 Rust 中重新开发。这让我可以将精力集中在最关键的部分:优化性能。
幸运的是,利用 napi.rs 这一出色的 Rust 库,将 Rust 嵌入到 Node.js 成为可能,并且操作十分简单。你只需要编写一个 Rust 库,适当使用一些宏,就能轻易地获得一个用 Rust 编写的原生 Node.js 模块。
The Rust code
在 Rust 中,我会先声明结构体,按照预期的格式来解析每一行数据,这可以被看作是一种"基于类型的开发"方式。首先我创建了一个名为 Record 的结构体,这代表了我希望从 TSV 记录集中解析出的基础行内容。与其将 Record 的每个部分都设置为 String(一个完全拥有的字符串),不如将其设为引用,&'a str,这意味着它有一个为 'a 的生命周期:
struct Record<'a> {
pathname: &'a str,
referrer: &'a str,
// ...
}
📝 提示:当我们让 Record 包含字符串的切片(并为其指定 'a 生命周期)时,我们实际上是在说 Record 是从原始数据派生出来的,并不会改变原始数据。这确保了 Record 的生命周期不会超过它所引用的数据——这是一个十分有益的特性。这样我们在开发时就可以更加关注于内存使用的优化。
所以,Record 究竟是什么?它是一个结构体,用于以结构化的方式呈现原始数据。但只是简单地从原始数据派生结构体,还不能满足我们的所有需求。为了利用浏览器缓存的最大优势,当静态资源部署时,它们的文件名中会加入一个内容哈希。这意味着我们的文件不仅仅是像 /artifact/file.js 这样的形式,更常会是 /artifact/file.abcdef0123456.js 这样的形式,允许浏览器长时间地缓存它们。
为了在数据汇总中移除内容哈希,并从路径名推断出构件名,我们引入了 EnhancedRow 结构体:
struct EnhancedRow<'a> {
pathname: &'a str,
referrer: &'a str,
// ...
artifact: &'a str, // 这部分取自 pathname 的切片
// 也就是说,这是一个切片的切片
filename: Cow<'a, str>,
// ...
}
从上面的定义可以看出,EnhancedRow<'a> 在 Record<'a> 的基础上增加了一些字段:
artifact是一个 &'a str,它直接引用了pathname的某个部分,因此无需克隆即可获取此信息。filename是 "写时复制" 类型,即 Cow<'a, str>。我们的目的是尝试从文件名中移除内容哈希。如果没有找到哈希,就无需复制整个字符串。但如果找到了哈希,我们会进行复制。这种类型设计使得我们的处理意图非常明确。
定义完这些结构体后,我们为 Record<'a> 实现了从 &'a str 的转换,同时也为 EnhancedRecord<'a> 从 Record<'a> 的转换提供了实现。这样,我们可以安全地把一个字符串引用解析成 Record,再进一步解析成 EnhancedRecord,整个过程都非常节省克隆操作。
然后,我们定义了一个名为 ResourceCounter 的结构体,用于对给定的文件进行数据聚合。本质上,它是围绕 HashMap 的简单封装,其键是由 (artifact, file_name) 组成,值则是基于各种平台的请求计数。
使用 ResourceCounter 时,我们只需输入一个 EnhancedRecord<'a> 。仅当必要时,它才会进行数据的克隆。比如当遇到之前从未记录过的 artifact/filename 组合时,它会进行克隆。
完成这些代码后,由于这是一个性能优化项目,我们必须确保与现有的 JavaScript 实现进行性能基准测试。而这个测试要求我们使用 Node.js 模块,这样可以确保考虑到 JS 和 Rust 间的通信开销。
最后,我使用我们的内部 JavaScript 性能测试工具——Perfer,编写了一个简单的基准测试。
import { benchmark } from '@wix/perfer';
import * as jsParser from '@wix/cdn-stats-js-parser';
import * as nativeParser from '@wix/cdn-stats-native-parser';
import fs from 'fs';
import assert from 'assert';
benchmark.node('native parser', async () => {
const values = await nativeParser.runAsync(['../SOME_BIG_FIXTURE']);
assert.ok(values.length > 0)
});
benchmark.node('js parser', async () => {
const stream = fs.createReadStream('../SOME_BIG_FIXTURE');
const values = await jsParser.runAsync([stream]);
assert.ok(values.length > 0)
});
然后运行它,结果令人一印象深刻。
CLI 输出的基准测试工具截图。
基准测试工具的网页界面截图。
结果如下:
-
JavaScript 的解析速度:
- 总运行时间:5878ms
- CPU 使用时间:6.49秒
- 内存占用:381mb
-
而 Rust 的解析速度:
- 总运行时间:1145ms
- CPU 使用时间:3.25秒
- 内存占用:1.75mb
尽管两者在运行时间上存在显著差异,但最显著的区别是内存占用。由于 Rust 解析器的内存使用效率更高,我们可以同时处理更多的文件,这无疑为我们带来了极大的优势。
部署预览
Wix 的部署预览策略是基于 ephemeral Kubernetes pods 来设计的,为特定的代码提交手动请求这些 pods。随后,入口代理会根据特定的头部信息将流量定向至这些专门的 pods。这种设计对于测试这个项目这样的创新特别有效,因此我们决定试验使用 Rust 解析器,而不是原先的 JavaScript 解析器,来进行部署预览。
我们多次进行了基准测试,每次都处理了190,000个相同的日志文件,并采用以下配置:
- 使用 25 个实例的 JavaScript 解析器,大约花费了3小时。
- 使用一个 Rust 部署预览实例,大约花费了2.5小时。
值得注意的是,这并不包括与数据库的任何输入/输出操作,因为部署预览实例是只读的。但有几点要强调:
- 由于我们可以同时处理更多的文件,Rust 解析器需要插入的记录数量明显减少。这提高了我们的汇总效率,并减少了推送到数据库的记录数量。
- 我们还可以调整我们的策略,首先将数据存储在简单的储存解决方案如 S3 中,然后通过一个独立的任务将数据直接转移到数据库。这样可以把计算和网络传输两个工作分开进行。
如果假设处理时间相同,那么 Rust 解析器使用的资源只相当于 JavaScript 解析器的1/25。这意味着性能提高了2500%!而且,即使加上对 MongoDB 进行数据插入所需的额外时间,我们仍然可以预期性能提高在2000%或1900%左右。无论如何看,这都是一个显著的进步。
那么,我们能从中学到什么呢?
选择对的工具来完成任务显得尤为关键。有些任务JavaScript处理得很好,而有些则不然。虽然Rust是一门学习曲线相对陡峭的语言,但我们可以巧妙地封装其逻辑,并以一种方式发布它,使得用户也愿意接受。在JavaScript的生态系统中,我们观察到了这样的变化趋势:Next.js已经用SWC取代了Babel,并可能不久的将来默认使用Turbopack(期待中)。而Esbuild,一个流行的用于将TypeScript或现代JS转换成JavaScript的工具,实际上是用Golang写的。它更为简洁高效,这种优势总是受到欢迎的。
因此,我的建议是...选用那些让你工作愉快的工具,如果某一工具响应缓慢,那么首先要进行性能测试,接着考虑优化或封装,然后——如果真的需要——考虑用Rust来重写它。😈