把高性能缓存服务从 Kotlin 迁移到 Rust,很容易被理解成一次单纯的性能优化。但 Ruth Linehan 分享的经验打破了另一个更顽固的预设:Rust 未必会拖慢交付,借用检查器和编译期安全反而能缩短反馈循环,让并发错误更早暴露,并让性能优化建立在可重复测量之上。
Rust 的成本发生在提交代码之前
Kotlin/JVM 与 Rust 的运行模型不同,迁移不能只做语法替换。缓存服务通常包含共享状态、并发读写、过期策略和大量对象访问,这些部分正好会触碰 Rust 的所有权、生命周期以及线程安全约束。
借用检查器确实增加了编码阶段的约束,但它也改变了问题出现的时间:许多悬垂引用、跨线程共享和可变别名问题会在编译时失败,而不是进入压力测试或生产环境后才表现为偶发故障。
例如,下面这种跨线程共享缓存使用 Arc<RwLock<_>> 明确表达了三件事:
Arc负责多个线程共同持有缓存。RwLock允许多个读操作并行,但写操作需要独占访问。- 锁保护的数据不能在锁的生命周期之外被不安全地引用。
这并不意味着代码自动具备高性能。RwLock 仍可能发生锁竞争,值的 clone 也会产生开销。Rust 提供的是可验证的并发边界,而具体结构是否高效,仍然需要基准测试和剖析工具回答。
可以这样实践:构建一个可测量的并发缓存
下面是一个最小实验项目。它不是来源中缓存服务的实现,而是根据演讲涉及的借用检查、Criterion 和 flamegraph 工具链设计的可运行示例。
创建目录:
mkdir -p rust-cache-lab/src rust-cache-lab/benches
cd rust-cache-lab
写入 Cargo.toml:
[package]
name = "rust-cache-lab"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "cache"
harness = false
写入 src/lib.rs:
use std::collections::HashMap;
use std::sync::RwLock;
#[derive(Default)]
pub struct Cache {
entries: RwLock<HashMap<String, String>>,
}
impl Cache {
pub fn insert(&self, key: String, value: String) {
self.entries
.write()
.expect("cache write lock poisoned")
.insert(key, value);
}
pub fn get(&self, key: &str) -> Option<String> {
self.entries
.read()
.expect("cache read lock poisoned")
.get(key)
.cloned()
}
}
#[cfg(test)]
mod tests {
use super::Cache;
#[test]
fn returns_inserted_value() {
let cache = Cache::default();
cache.insert("region".into(), "eu-west".into());
assert_eq!(cache.get("region").as_deref(), Some("eu-west"));
}
}
写入 src/main.rs,生成一个可供 flamegraph 分析的并发负载:
use rust_cache_lab::Cache;
use std::sync::Arc;
use std::thread;
fn main() {
let cache = Arc::new(Cache::default());
for i in 0..1_000 {
cache.insert(format!("key-{i}"), format!("value-{i}"));
}
let mut workers = Vec::new();
for worker_id in 0..8 {
let cache = Arc::clone(&cache);
workers.push(thread::spawn(move || {
for i in 0..500_000 {
let key = format!("key-{}", (i + worker_id) % 1_000);
std::hint::black_box(cache.get(&key));
}
}));
}
for worker in workers {
worker.join().expect("worker thread failed");
}
}
再写入 benches/cache.rs:
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use rust_cache_lab::Cache;
fn benchmark_cache_hit(c: &mut Criterion) {
let cache = Cache::default();
cache.insert("session:42".into(), "active".into());
c.bench_function("cache hit", |b| {
b.iter(|| black_box(cache.get(black_box("session:42"))))
});
}
criterion_group!(benches, benchmark_cache_hit);
criterion_main!(benches);
运行测试和基准:
cargo test
cargo bench
Criterion 会执行多轮采样并报告时间分布。不要只比较一次运行的数字;应该在相同机器、相同编译配置和接近真实的数据规模下比较修改前后的结果。
从基准数字追到具体代码路径
Criterion 能告诉你性能是否变化,flamegraph 则帮助定位 CPU 时间消耗在哪里。Linux 环境通常需要 perf,不同发行版的安装命令可能不同。工具准备完成后可以运行:
cargo install flamegraph
cargo build --release
cargo flamegraph --release --bin rust-cache-lab
命令会生成 flamegraph.svg。分析时应优先查看宽度明显的调用栈,并提出具体问题:
- 时间是否大量消耗在
RwLock的读锁或写锁上? String::clone和内存分配是否占据主要路径?- 热点来自缓存查询本身,还是示例中的
format!等负载生成代码? - 增加线程后,吞吐量是否增长,还是锁竞争让性能提前见顶?
最后一点尤其重要。并发代码在单线程微基准中可能表现很好,却在多线程压力下退化。可以保留 Criterion 测量稳定的局部操作,再用接近生产读写比例的负载测试整体吞吐量和尾延迟。
迁移时不要只比较语言
从 Kotlin 迁移到 Rust时,应同时比较运行成本和工程反馈周期。一个可执行的评估清单包括:
- 选择 CPU、内存或尾延迟确实构成瓶颈的服务,而不是全面重写。
- 记录迁移前的吞吐量、P95/P99 延迟、内存和 GC 行为。
- 把编译错误、单元测试、Criterion 基准和 flamegraph 纳入日常反馈循环。
- 审查锁粒度、复制次数和分配热点,不把“通过借用检查器”等同于“性能已经最优”。
- 评估团队学习成本、构建时间、可观测性和线上排障能力。
这场分享最值得关注的结论并非“Rust 一定比 Kotlin 快”,而是性能、安全性和交付速度并不必然互相排斥。Rust 把一部分调试成本前移到编译阶段,再通过可重复基准和剖析工具,让工程师更快判断一项优化究竟有效,还是只改变了代码外观。