一、引言
在《得物新一代可观测性架构:海量数据下的存算分离设计与实践》一文中,我们探讨了存算分离架构如何通过解耦计算与存储资源,显著降低存储成本并提升系统扩展性。然而,仅优化存储成本不足以支撑高效可观测性系统的全局目标。在生产环境中,计算层作为可观测性体系的核心模块,需在处理日益复杂和动态的大流量数据时,保持高性能、强稳定性与优异的资源利用效率。
在得物的可观测性计算层中,Java凭借其成熟的生态系统和强大的工具链,在系统建设初期帮助团队快速迭代和稳定交付。然而,随着业务流量规模的指数级增长,计算层对低延迟处理和高效资源利用的要求不断提高,Java开始显现局限性:
- GC延迟问题:垃圾回收机制在高吞吐场景下可能引发不可控的性能波动,影响实时性。
- 内存资源浪费:频繁的对象分配导致内存占用增大,进一步推高硬件成本。
- 异步处理瓶颈:虽然Java近年来强化了异步编程支持,但在极限性能优化方面,仍存在不可忽视的不足。
为了缓解Java在高吞吐场景下的GC延迟问题,我们尝试引入了ZGC(Z Garbage Collector),这是Java的一种低延迟垃圾回收器,它通过并发执行大部分垃圾回收任务来减少应用程序的停顿时间。然而,我们观察到ZGC在处理大量数据时可能会占用较多的CPU资源,并且在需要大堆内存时可能导致内存使用率上升,这在资源受限的环境中可能成为性能瓶颈
在此背景下,Rust成为计算层技术升级的关键选项。Rust以其内存安全、零成本抽象和高性能异步编程模型(如 Tokio)闻名,不仅可以规避GC相关性能波动,还能在资源利用效率上带来显著优势。Rust 的优势促使其在许多高性能场景中逐渐取代传统的编程语言。作为一种新兴的底层系统语言,Rust 以其独特的内存安全机制、与 C/C++ 相媲美的高性能、活跃的开发者社区以及出色的文档、工具链和 IDE 等优点而闻名。
因此,可观测性计算层迁移到Rust后获得了以下显著好处:
- 更少的Bug:源于Rust强大的编译检查和错误处理方式。
- 运行时开销降低:在我们的性能测试中,迁移到Rust后,内存资源使用率平均下降了68%,CPU资源使用率平均下降了40%。
本篇文章将结合得物可观测性的技术实践,深入探讨计算层从Java迁移至Rust的全流程。我们将重点分析迁移过程中的技术挑战及其解决方案,展示Rust如何在万亿流量场景下实现性能与资源优化,并为其他面临类似挑战的团队提供实践参考与技术启发。
遇到的问题
如图所示,得物可观测性架构采用经典的数据处理流程(Pipeline)。在该架构中,OTel-Exporter订阅上游Kafka的数据,经过清洗后批量写入ClickHouse。然而,随着数据量的增加,OTel-Exporter和ClickHouse之间的写入性能逐渐成为瓶颈,特别是在海量数据的处理过程中。
- 性能瓶颈:OTel-Exporter与ClickHouse的写入瓶颈。
- 成本上升与资源瓶颈:尽管配置调整后性能符合预期,但由于POD的CPU和内存配置由1:4调整为1:6,宿主机的规格也需要升级至1:8机型。这导致了整个资源池成本的上升,特别是内存消耗的增加,使得云账单上涨了约10%。此外,JVM的ZGC(垃圾回收)过程频繁发生,导致CPU占用过高
Rust的引入:在尝试了多种优化策略后,我们依然未能找到有效的解决方案,直到我们发现Rust的极低资源消耗特性。Rust在内存管理和性能方面的优势使得它成为解决这一瓶颈问题的关键。通过引入Rust,我们显著降低了内存占用,减少了CPU负担,进而提升了OTel-Exporter的吞吐量,同时避免了Java在内存管理上的开销。
二、Rust: 系统级编程的破冰之旅
Rust作为一种系统级编程语言,在现代编程中发挥着越来越重要的作用。其独特的内存安全机制和高效并发能力使其在处理复杂应用时克服了传统编程语言的性能瓶颈。通过全面控制内存使用,Rust能有效减少运行时错误,并确保代码的安全性。
在面临高流量数据处理和系统性能优化的挑战时,Rust提供了一种可靠的解决方案。接下来,我们将聚焦于Rust的并发模型、所有权和生命周期管理,探讨这些特性如何在实际应用中提升代码的安全性和性能。
所有权
在Rust中,所有权是一个核心概念,它决定了内存如何管理以及数据如何在程序中传递。Rust的所有权机制确保了内存安全,避免了内存泄漏和数据竞争,这是通过编译时的规则来实现的,而不是依赖运行时的垃圾回收机制。
所有权规则
Rust 中的所有权有三个主要规则:
- 单一所有权:每个值在任意时刻只能有一个所有者。
- 作用域结束时自动清理:当所有者离开作用域时,值会被自动销毁,Rust 自动释放内存,不需要垃圾回收。
- 所有权转移和借用:值的所有权可以通过移动(Transfer)或借用(Borrow)传递,避免了传统的引用计数和垃圾回收带来的性能开销。
为了更直观地理解所有权的运行机制,我们可以比较 Rust、C++ 和 Java 中对象赋值的不同:
- Java:在 Java 中,将对象 a 赋值给 b 时,实际上是将 a 的引用传递给 b,a 和 b 都指向同一个对象,增加了引用计数。
- C++:在 C++ 中,赋值操作会创建 a 的一个新副本,并将其赋值给 b,这意味着内存中存在两个相同的对象副本。
- Rust:不同于 Java 和 C++,Rust 采用移动所有权的方式。当 a 被赋值给 b 时,a 的所有权被移动到 b 上,a 变为未初始化状态,无法再被使用,而 b 现在拥有 a 原来的所有权。
Rust 的所有权概念内置于语言本身,在编译期间对所有权和借用规则进行检查。这样,程序员可以在运行之前解决错误,提高代码的可靠性。
共享所有权
尽管Rust规定大多数值会有唯一的拥有者,但在某些情况下,我们希望某个值在每个拥有者使用完后就自动释放。简单来说,就是可以在代码的不同地方拥有某个值的所有权,所有地方都使用完这个值后,会自动释放内存。对于这种情况,Rust提供了引用计数智能指针:Rc和Arc。
- Rc(非线程安全)和Arc(线程安全)非常相似,唯一的区别是Arc可以在多线程环境进行共享,代价是引入原子操作后带来的性能损耗。
- Rc和Arc实现共享所有权的原理是,Rc和Arc内部包含实际存储的数据T和引用计数,当使用clone时不会复制存储的数据,而是创建另一个指向它的引用并增加引用计数。当一个Rc或Arc离开作用域,引用计数会减一,如果引用计数归零,则数据T会被释放。
Rust的开发者确保了即使在多个地方共享所有权,也不会引入数据竞争的问题。引用计数智能指针是内部不可变的,即无法对共享的值进行修改。如果要对共享的值进行修改,可以使用Mutex等同步原语来避免数据竞争和未定义行为。
生命的周期与借用
生命周期(Lifetimes)和借用(Borrowing)是Rust保证内存安全和线程安全的两个重要机制。生命周期帮助Rust跟踪引用的有效性,而借用允许你在不拥有数据所有权的情况下,访问数据。
不可变借用:允许多个地方同时读取数据,但不允许修改数据。Rust保证,所有不可变引用在数据被销毁之前都有效,避免了悬垂引用。
可变借用:允许我们修改数据,但在同一时刻只能有一个可变借用。Rust会确保没有其他引用可以同时访问该数据,从而避免并发修改引发的问题。
不可变借用示例
#[derive(Debug)]
struct Student {
name: String,
grade: u32,
}
fn print_student_info(student: &Student) {
println!("Student: {:?}, Grade: {}", student.name, student.grade);
}
fn main() {
let student = Student {
name: "Alice".to_string(),
grade