一、引言
在《得物新一代可观测性架构:海量数据下的存算分离设计与实践》一文中,我们探讨了存算分离架构如何通过解耦计算与存储资源,显著降低存储成本并提升系统扩展性。然而,仅优化存储成本不足以支撑高效可观测性系统的全局目标。在生产环境中,计算层作为可观测性体系的核心模块,需在处理日益复杂和动态的大流量数据时,保持高性能、强稳定性与优异的资源利用效率。
在得物的可观测性计算层中,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: 85,
};
// 不可变借用
print_student_info(&student);
// student 仍然有效,因为没有可变借用,可以再次访问
println!("{:?}", student);
}
在这个例子中,print_student_info 函数通过不可变借用 &Student 获取学生的引用。由于 Rust 的生命周期和借用机制,student 在整个程序中都有效,直到它超出作用域。这允许多个函数同时读取 student 的数据而不会导致数据被修改。
可变借用示例
#[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: 85,
};
// 不可变借用
print_student_info(&student);
// student 仍然有效,因为没有可变借用,可以再次访问
println!("{:?}", student);
}
在这个例子中,increase_student_grade 函数通过可变借用 &mut Student 接收一个学生对象的引用,并更新学生的成绩。Rust 保证在同一时间只有一个可变借用,确保了数据的一致性。这意味着在 increase_student_grade 函数执行期间,其他任何代码都不能访问 student 的可变引用,从而避免了数据竞争。
并发模型
Rust的并发模型是其系统级编程能力的核心,它通过所有权、借用和生命周期等语言特性,在编译时强制执行线程安全,从而避免了运行时的数据竞争和同步问题。这种模型允许开发者安全地利用多核处理器的能力,而无需牺牲代码的安全性。Rust的并发原语,如互斥锁(Mutex)、读写锁(RwLock)和原子操作,都是基于这种模型构建的,它们帮助开发者在避免数据竞争的同时,实现高效的并发控制。
线程
在Rust中,线程(thread)是并发执行的基本单位。Rust标准库提供了强大的线程支持,允许开发者轻松地创建和管理线程。Rust的线程模型基于操作系统的原生线程,这意味着每个Rust线程都直接映射到一个操作系统线程。
use std::thread;
fn main() {
let handle = thread::spawn(move || {
// 线程执行的代码
println!("Hello from a new thread!");
});
// 等待线程结束
handle.join().unwrap();
}
消息传递
Rust中的消息传递不仅包括基本的通道(channels),还有更高级的并发原语,如crossbeam-channel库提供的无锁通道,这些通道在高并发场景下可以显著提高性能。通道分为单生产者单消费者(如mpsc)和多生产者单消费者(如mpsc)两种类型,它们都是通过所有权和借用规则来保证线程安全的。
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
// 发送端
thread::spawn(move || {
let val = 10;
tx.send(val).unwrap();
});
// 接收端
let received = rx.recv().unwrap();
println!("Received: {}", received);
}
异步编程
Rust的异步编程模型通过async/await语法糖和Future trait,允许开发者在单个线程内以非阻塞的方式执行多个I/O密集型任务。这种模型利用事件循环和非阻塞I/O,使得可以在单个线程内高效地处理大量并发操作,从而提高了程序的吞吐量和响应性。
async fn async_task() {
// 异步代码
println!("Async task");
}
#[tokio::main]
async fn main() {
async_task().await;
}
锁和同步原语
Rust提供了多种锁和同步原语,如互斥锁(Mutex)和读写锁(RwLock),它们都可以通过std::sync模块访问。这些锁内部使用原子操作来保证在多线程环境下的内存一致性。此外,Rust还提供了条件变量(Condvar)来实现线程间的协调,以及Once类型来确保全局初始化的安全执行。
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
无数据竞争保证
Rust的所有权系统保证了在任何给定时间,对同一数据的访问不会同时存在多个可变引用,这从根本上避免了数据竞争。此外,Rust的借用检查器在编译时期就会检查代码,确保所有引用的使用都符合所有权规则,从而在编译时就排除了数据竞争的可能性。
通过这些工具和规则,Rust的并发模型为开发者提供了一个强大而灵活的平台,以安全和高效的方式构建并发系统。这使得Rust成为构建高性能、高可靠性并发系统的不二之选。
三、应用部署
Rust 提供了强大的性能和内存安全保证,但在实际生产环境中,部署和监控同样至关重要。确保应用程序在部署后的高可用性和健康性,需要关注性能瓶颈、错误日志、系统指标以及应用的稳定性。以下内容将帮助您了解如何在生产环境中部署 Rust 应用,并确保其在运行时能被有效监控。
Rust 应用的部署
Rust 的编译结果是一个二进制文件,这意味着部署过程非常简单且高效。Rust 应用不依赖于虚拟机或解释器,这使得它能够直接运行在目标操作系统上。这个过程可以通过持续集成/持续部署(CI/CD)管道自动化,以确保部署的一致性和可靠性。
1.使用 Cargo 编译 Rust 应用
cargo 是 Rust 的包管理器和构建工具,它可以帮助您编译项目并生成二进制文件。在本地机器上构建 Rust 项目,可以使用以下命令:
cargo build --release
这会生成优化过的二进制文件,位于 target/release 目录中。
2.在服务器上运行应用
上传完成后,在服务器上执行二进制文件以启动应用:
./my_rust_app
监控Rust应用
一旦Rust 应用成功部署到生产环境中,接下来就需要考虑如何监控其运行状况,确保其健康,并及时发现问题。监控通常包括以下几个方面:
1.性能监控
Rust的高性能特点是它的一大优势,但在生产环境中,监控性能瓶颈仍然至关重要。为了监控Rust应用的性能,您可以使用Prometheus和Grafana等工具。
创建和注册指标:
在Rust中,您可以创建多种类型的指标,如Counter、Gauge、Histogram和Summary,来记录不同的性能数据。以下是一些基本的示例:
创建 Counter:
use prometheus::{register_counter_vec, CounterVec};
static ref counter: CounterVec = register_counter_vec!(
"my_counter",
"My counter help.",
&["type"]
)
.unwrap();
counter
.with_label_values(&[&"app"])
.inc();
为了实现Rust应用的性能监控,我们通常会集成Prometheus客户端库,该库允许应用暴露一个HTTP端点,用于Prometheus抓取预定义的性能指标。这些指标包括但不限于请求处理时间、吞吐量、错误率等关键性能指标。通过这种方式,我们可以实时监控应用的性能,并根据需要调整资源配置或优化代码。
如图所示,可以实时观察Rust服务的运行情况,并且根据自己的埋点分析系统的瓶颈。可以看到,Rust应用运行非常平稳。相比于有GC的Java应用,Rust明显毛刺很少,非常平滑,而且内存占用相比Java减少了70%。
2.日志管理
及时发现和处理错误对于保证Rust应用的稳定性至关重要。您可以使用flexi_logger作为日志模块,它提供了灵活的日志配置和处理能力。以下是如何在Rust中集成flexi_logger的示例代码:
[dependencies]
flexi_logger = "0.29.6"
log = "0.4.22"
use log::{info, warn};
fn main() {
flexi_logger::Logger::try_with_str("info") // 设置日志级别
.unwrap()
.log_to_file(
flexi_logger::FileSpec::default()
.directory(log_path)
.basename("server") // 设置日志文件基本名称
.suppress_timestamp()
.suffix("log"), // 设置日志文件后缀
)
.rotate(
Criterion::Size(64_000_000), // 单个日志文件大小64MB
Naming::Timestamps,
Cleanup::KeepLogFiles(7), // 保留的滚动日志文件数量
)
.format(flexi_logger::colored_with_thread)
.start()
.unwrap_or_else(|e| panic!("Logger initialization failed with {}", e));
info!("App started");
warn!("This is a warning message");
}
通过 flexi_logger,您可以将日志输出到文件和控制台,并设置日志轮转和压缩,以便于日志的管理。
3.系统健康检查
监控系统健康是确保应用稳定运行的必要步骤。您可以为Rust应用添加健康检查端点,供外部系统定期检查应用状态。这些端点可以返回应用是否正常工作,以及其他相关的诊断信息。
使用Rust的warp或actix-web等框架,您可以轻松实现一个健康检查API:
use warp::Filter;
#[tokio::main]
async fn main() {
let health = warp::path("health")
.map(|| warp::reply::with_status("OK", warp::http::StatusCode::OK));
warp::serve(health).run(([127, 0, 0, 1], 3030)).await;
}
这样,您就可以通过访问 /health 路径来检查应用是否处于正常状态。
小结
Rust的部署和监控非常直观且高效。使用cargo进行编译和部署,结合flexi_logger进行日志管理,以及使用健康检查端点,可以确保您的Rust应用在高流量和复杂系统集成中表现出色。
四、总结
通过迁移到Rust,我们的计算层能够在处理高并发请求时显著提高系统的吞吐量和响应能力,同时减少服务器资源的浪费。这不仅能降低运营成本,还能为我们的用户提供更流畅、更快速的体验。
但是,如果要持续地拥抱Rust生态,目前仍然面临如下挑战:
1.生态不完善:尽管Rust已经有一些非常优秀的库和工具,但某些特定领域仍然缺乏成熟且广泛使用的库。这意味着开发者可能需要花费更多的时间来构建自己的解决方案或者整合不同语言的库。
2.学习曲线陡峭:Rust语言引入了许多独特的概念和特性,对于初学者和来自其他语言的开发者来说,这些特性可能需要一段时间来彻底掌握。
3.开发进度:相比于自动内存管理类型语言的开发任务,Rust严格的编译检查会让开发进度一度阻塞。
尽管开发Rust生产级应用有那么多阻碍,我们目前已经发布的Rust应用已经证明了,相比于付出,迁移Rust带来的收益更大。希望大家都可以探索Rust的可行性,为节能减排和世界和平出一份力,也欢迎各位对Rust有兴趣的同学一起交流。
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。