media - organic
为什么 Rust 成为编写数据库和流处理引擎等高性能系统的最佳选择。
编者注:本文由 P99 CONF 24 发言人 Micah Wylde 撰写。他将讨论“延迟、吞吐量和容错:设计 Arroyo 流引擎”。
Arroyo 是一个用 Rust 编写的分布式、有状态的流处理引擎。它将可预测的毫秒级延迟处理与高性能批量查询引擎的吞吐量相结合,并基于提供容错和精确一次处理的分布式检查点实现。这些设计目标往往相互矛盾:提高吞吐量通常以延迟为代价,而一致的检查点可能会在等待对齐和 IO 时引入周期性的延迟峰值。
在 P99 CONF 上,Micah 将讨论 Arroyo 的分布式架构和实现,包括基于 Arrow 的核心数据流引擎、状态窗口和聚合算法以及受 Chandy-Lamport 启发的分布式检查点系统。
这篇文章最初发表在Arroyo 博客上。
Arroyo 是一款现代流处理引擎,旨在每秒对数百万条记录执行复杂的 SQL 操作。在决定我们需要构建一个新的流引擎之后,下一个问题是:我们使用什么语言?
我们最终用 Rust 编写了 Arroyo,这可能不算什么。事实证明这是一个非常好的决定,让我们的小团队能够在不到一年的时间内构建出最先进的流媒体系统。
那么我们为什么决定使用 Rust,以及它的效果如何?
数据基础设施语言简史
C++
现代数据基础设施的诞生可以追溯到谷歌发表的三篇里程碑式论文:《谷歌文件系统》(2003 年)、《Map Reduce》(2004 年)以及随后的《Bigtable》(2006 年)。这些论文描述了谷歌如何存储、处理和提供构成早期网络索引的 PB 级数据。
但更有趣的是他们是如何做到的。在“大数据”由超级计算机和大型机处理的时代,硅谷最重要的初创公司正在使用数千台廉价 Linux 机器组成的网络。这为未来二十年的数据处理方式奠定了模板。
这些系统是用 C++ 编写的,当时 C++ 是系统软件的标准。C++ 可以精确控制内存,使 Google 能够充分利用其硬件 — — 当您的服务器是 1.4 Ghz Pentium III 并配备 2GB 内存时,这一点至关重要!
Java
受到 Google 论文的启发,一位名叫 Doug Cutting 的工程师决定构建自己的实现来支持新的搜索引擎。后来,他被雅虎聘用,这项工作促成了开源 Hadoop 文件系统 (HDFS) 和 Hadoop Map Reduce 项目。
Cutting 之前曾用 Java 构建过搜索索引 Lucene,并坚持使用 Hadoop 语言。这产生了巨大的影响。随着越来越多的公司发现他们需要分布式数据处理,下一波大数据系统在随后的几年中涌现,包括 HBase、Cassandra、Storm、Spark、Hive、Presto。几乎所有系统都是用 Java 或其他 JVM 语言编写的。
与 C++ 相比,Java 有很多优势:它的垃圾收集器 (GC) 让程序员从繁琐且容易出错的手动内存管理中解放出来,而它的 VM 意味着 Java 可以在许多不同的架构和操作系统上运行,只需进行很少的更改。该语言也比 C++ 简单得多,让新工程师能够快速上手。
Go
与此同时,谷歌继续用 C++ 编写其基础设施,用 Python 和 Java 编写应用程序级软件。2009 年,他们发布了一种专为此应用程序级领域设计的新语言:Go。该语言由该领域的传奇人物 Rob Pike 和 Ken Thompson 参与设计,并具有高级并发原语和 Web 库,因此很快在分布式系统领域得到采用。
2015 年 Kubernetes 开源版本尤其促进了这一进程。Kubernetes 是一个集群调度程序,能够将一组服务器或虚拟机转变为部署应用程序的通用基础。它出现之时,行业正从静态本地或虚拟机部署模式转向更灵活的云容器化部署。
它最初是用 Java 编写的,但在开发的某个阶段迁移到了 Go。它的成功引发了一波 Go 数据系统的浪潮,其中包括 CockroachDB、InfluxDB 和 Pachyderm。
C++ 卷土重来,Rust 登场
科技世界无休止地循环往复,过去几年又经历了一次轮回。ScyllaDB和Redpanda等项目已成功用 C++ 重写 Java 系统(分别为 Cassandra 和Kafka ),以实现更高的性能和更可预测的操作。DuckDB和Clickhouse等新数据库和查询引擎正在用 C++ 从头编写。
Rust 1.0 于 2015 年发布,是一种现代系统语言,目标市场与 C++ 相同。Rust 没有 GC,专注于零成本抽象,并提供对执行的低级控制。与 C++ 不同,它的编译器可以检查安全违规行为(例如使用未初始化的内存或双重释放)并防止多线程代码中的竞争条件。它已成为从 Go 和 Java 重写现有系统组件(TiKV、InfluxDB IoX)以及实现新系统(Materialize、Readyset)的常见选择。
为什么使用 Rust
既然历史已经讲完,那我们为什么决定使用 Rust [1] 呢?简而言之,Rust 非常适合编写数据基础设施,它结合了 C++ 的性能和控制力以及 Java 的安全性和易开发性。
默认情况下具有出色的性能
用 Java 或 Go 编写快速代码是可能的,但用 Rust 编写快速代码是默认的。您必须竭尽全力才能放弃效率,而 Rust 会确保您始终知道自己所做的权衡。
例如,最快的 Java 代码看起来一点也不像惯用的 Java。它严重依赖原语和数组,而不是对象和标准库容器。它被编写为尽量减少对象的创建和销毁,以减轻 GC 的负载。对于以数据为中心的系统,实际的数据存储通常由 RocksDB 等 C++ 库处理,需要通过 Java 本机接口 (JNI) 进行缓慢的通信。
Rust 的设计理念是零成本抽象。这是一个有点令人困惑的概念,但其本质是:按使用量付费。其中一部分是,分配内存或上下文切换等操作被明确调用。但更重要的是,Rust 提供了许多强大的抽象,这些抽象没有任何运行时性能成本,并且通常与手写代码一样高效。
无 GC / 有安全性
有一种流传已久的误解认为,具有垃圾收集功能的语言速度不快或不适合低延迟环境。事实并非如此(请参阅所有用 Java 进行的高频交易),但确实需要对垃圾收集器的行为和性能特征有深入的了解。
流处理系统对于 GC 来说尤其困难。事件处理会非常快速地生成大量对象,同时将其中一些对象存储不可预测的时间。大规模操作像 Apache Flink 这样的 JVM 流处理器意味着要成为 GC 配置和调优方面的专家,但仍然会偶尔出现崩溃 [2]。许多用户为了避免这种情况,会使用比所需更多的内存。
从历史上看,这里存在一个权衡。你可以处理扩展 GC 的挑战,也可以处理手动管理的语言(如 C++)中的错误、崩溃和安全问题。但 Rust 找到了一种解决此问题的方法:其借用检查器和生命周期系统支持编译时内存管理。
这既避免了对 GC(动态内存管理)的需求,又允许语言在编译时保证代码中没有内存安全错误(例如使用后释放或双重释放)[3]。
有些人会声称可以编写内存安全的 C/C++,但内存相关漏洞的长期历史掩盖了这一点。在我写这篇文章的时候,谷歌和苹果正在紧急修补 libwebp 中的一个缓冲区溢出漏洞,该漏洞正在互联网上被积极利用 [4]。
安全性也会带来性能。例如,Rust 的安全性保证使正确使用指针以避免复制变得更加容易。Arroyo 的状态后端(用于存储计算所需的数据,如窗口)包括一个缓存,该缓存将反序列化的结构存储在各种特定用途的数据结构中。当操作员查询其状态时,他们可能会从缓存中接收项目(如果有)。
如果编译通过,那么它可能是正确的
Rust 编译器非常迂腐。它是你遇到过的代码审查者中最执着的 [5]。如果你将一个 32 位整数传递给一个需要 64 位整数的函数,它不会允许你这么做。如果你尝试跨线程共享非线程安全的数据结构,你的编译将失败。忽略文件系统路径可能是任意字节的事实并尝试将它们用作 UTF-8 字符串?直接进入编译器监狱。
有些人会喜欢 Rust 的这一点。而其他人——他们只想让 Rust 正常工作——则会讨厌它。
把我归为第一类。在我的职业生涯中,我已经花了足够多的时间调试生产中难以重现的错误。这需要更多的前期设计工作,以及与编译器斗争的一些挫折。但一旦你满足了它,代码最终在相当高的比例上都是正确的。
有个轶事,在 Arroyo 早期,我花了几天时间编写一个网络堆栈,以便将管道分布在多个节点上。在编写了几千行复杂代码并进行编译后,我第一次运行了它。它就成功了。从那时起,它一直在继续工作,只做了一些小改动。
惯用 Rust 还鼓励促进正确性的模式,例如大量使用代数数据类型 (ADT)。ADT 有两种形式,乘积类型 ( struct) 和和类型 ( enum)。通过结合这些,您可以简洁地定义应用程序的数据,同时防止出现错误。
Cargo 和工具和包管理器生态系统
构建分布式 SQL 流引擎是一项艰巨的工作。仅解析 SQL 就是一个庞大而复杂的项目 [7]。我们作为一个小团队能够取得如此大的进步,是因为我们站在巨人的肩膀上。
Cargo是 Rust 的标准构建工具和包管理器,crates.io是第三方库存储库。与 CMake、Bazel、Buck 等分散的 C/C++ 世界不同,Rust 拥有单一的方式来构建包、共享代码和集成第三方库。
对于 Arroyo 来说,其中最有价值的可能是DataFusion ,它是Apache Arrow项目的一部分。作为一个产品,它是一个非常快的 SQL 查询引擎。但对于我们(以及 Rust 数据领域的许多其他人)来说,它也是一个可以为其他 SQL 引擎提供支持的库。
Arroyo 使用 DataFusion 来解析(将文本 SQL 转换为抽象语法树)、规划(将 AST 转换为计算图)和优化(根据优化规则重写该图)查询。这些都是复杂且具有挑战性的问题,与特定查询引擎的需求有些不相关。毫不夸张地说,它为我们节省了数千小时的开发时间。
困难的部分
1、需要时间来适应
我们已经看到,Rust 的新贡献者需要几个月的时间才能真正上手。对于早期创业公司来说,这几个月的时间尤其会拖慢发展势头。如果我们团队中没有一位已经是经验丰富的 Rust 程序员的成员(我),事情会困难得多。
2、异步仍然很粗糙
关于异步 Rust 的文章很多。Async/await 是一组大型功能,于 2019 年首次发布,使编写非阻塞代码变得更加容易。虽然许多语言都采用了 async/await,但 Rust 的实现却大不相同。Rust 不需要运行时,并且设计为可在从微控制器到超级计算机的各种环境中使用。这限制了 async/await 等高级功能的设计空间。Rust 提出的解决方案对于具有 Go 或 JavaScript 等背景的程序员来说很不寻常,这导致了很多困惑
3、没有稳定的 ABI
Rust 缺少过时的应用程序二进制接口 (ABI)。这意味着什么?如果我使用 Rust 版本 X 编译二进制文件,并尝试将其链接到使用 Rust 版本 Y 编译的共享库,则无法保证它们兼容。
对于希望允许用户动态加载代码(例如用户定义的函数或自定义源/接收器)的任何系统来说,这都是一个问题。
有几种可用的解决方法,例如使用 C ABI 或 WASM,但它们会在性能和表现力上做出妥协。