Rust 性能优化别靠猜,先把慢在哪里量出来
Rust 很快,但 Rust 服务照样会慢。慢在数据库、慢在锁、慢在分配、慢在序列化、慢在日志、慢在下游等待,表现出来都可能只是一个 P99 飙升。
很多性能优化从一句话开始:
感觉这里 clone 太多了。也可能是:
是不是 async 太慢?这种猜法很浪费时间。Rust 的确给了你很强的性能上限,但服务慢的时候,瓶颈经常不在你以为的地方。
性能优化的顺序应该是:先定位,再解释,再改代码,再验证。
先看延迟分布,不要只看平均值
平均延迟很会骗人。
一个接口平均 20ms,P99 可能 800ms。用户感受到的往往是尾部延迟。
先定义一个最小快照:
#[derive(Debug)]
struct LatencySnapshot {
p50_ms: u64,
p95_ms: u64,
p99_ms: u64,
}
fn tail_is_bad(snapshot: &LatencySnapshot) -> bool {
snapshot.p99_ms > snapshot.p50_ms * 10
}这段代码不是监控系统,但它表达了一个判断:尾部延迟和中位数差太多,通常说明系统里有排队、锁等待、下游抖动或资源池耗尽。
优化前先回答:
- 慢的是所有请求,还是少数请求
- 慢的是入口,还是某个下游
- 慢的是 CPU,还是 I/O 等待
- 慢的时候连接池、队列、线程池是什么状态
没有这些问题的答案,优化很容易变成改代码求心安。
tracing span 可以先把时间切开
服务端性能定位,优先用 span 把耗时切开,而不是直接上火焰图。
use tracing::instrument;
#[instrument(skip(payload))]
async fn create_order(payload: String) -> Result<(), &'static str> {
validate_order(&payload)?;
save_order().await?;
publish_event().await?;
Ok(())
}
fn validate_order(payload: &str) -> Result<(), &'static str> {
if payload.is_empty() { Err("empty payload") } else { Ok(()) }
}这能帮你把一次请求拆成几个阶段:
validate_order
save_order
publish_event如果 save_order 占 700ms,就不要先优化字符串 clone。如果 publish_event 偶发 2s,就要看 MQ 或网络。如果 validate 阶段 CPU 高,才值得继续看算法和分配。
先用 tracing 把时间切成业务阶段,再用 profiler 看阶段内部。
一上来就看火焰图,容易陷在细节里。
CPU 满了再看火焰图
火焰图适合回答“CPU 时间花在哪里”。
如果服务大部分时间在等数据库或下游 HTTP,火焰图不一定给你答案。
CPU 真的高时,常见热点包括:
- JSON 序列化/反序列化
- 正则表达式
- 压缩和加密
- 大量 clone 和分配
- 日志格式化
- 锁竞争导致自旋或调度开销
先把热点归类:
#[derive(Debug, PartialEq, Eq)]
enum HotspotKind {
Serialization,
Allocation,
Locking,
Crypto,
Unknown,
}
fn likely_hotspot(function_name: &str) -> HotspotKind {
if function_name.contains("serde_json") {
HotspotKind::Serialization
} else {
HotspotKind::Unknown
}
}真实定位当然不会靠字符串判断,这里只是表达一个习惯:看到热点以后先分类,再决定改法。
序列化慢,可能换格式;分配多,可能复用 buffer;锁竞争,可能拆锁或换结构。不同热点不能用同一种优化手段。
分配问题先看数据所有权
Rust 性能问题里,clone 最容易被过度怀疑。
有些 clone 很便宜,比如 clone 一个 Arc。有些 clone 很贵,比如复制一个大 String 或 Vec。
先分清:
use std::sync::Arc;
#[derive(Clone)]
struct SharedConfig {
inner: Arc<AppSettings>,
}
struct AppSettings {
service_name: String,
}SharedConfig 的 clone 只是引用计数加一,不是复制整个配置。
真正要警惕的是这种路径:
fn normalize_tags(tags: &[String]) -> Vec<String> {
tags.iter().map(|tag| tag.to_lowercase()).collect()
}这里每个 tag 都会分配新字符串。如果它在高频路径上,就值得重新设计:能不能提前规范化,能不能用更小的表示,能不能避免每次请求重复做。
不要反射性删除 clone,要先知道它复制的是什么。
锁慢通常不是锁本身慢,而是锁里做了太多事
Mutex 经常背锅。
锁真正危险的地方,是持锁期间做慢操作:
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct Counters {
requests: u64,
}
fn increment(counters: &Arc<Mutex<Counters>>) {
let mut guard = counters.lock().unwrap();
guard.requests += 1;
}这段没问题,持锁时间非常短。
有问题的是持锁期间做 I/O、序列化大对象、await 下游。尤其在 async 代码里,不要持有同步锁跨 .await。
优化锁竞争的方向通常是:
- 缩短临界区
- 拆分热点锁
- 用 channel 交给单 owner 处理
- 用原子类型处理简单计数
- 用并发 map,但仍然关注热点 key
不要看到 Mutex 就换成更复杂的数据结构。先看锁等待时间和临界区内容。
数据库慢不要在 Rust 里硬优化
很多接口慢,根因是 SQL:
- 缺索引
- N+1 查询
- 查询返回太多列
- 事务持有太久
- 连接池太小或太大
Rust 代码可以把数据库访问封装得很漂亮,但 SQL 计划不会因此变好。
一个粗粒度分类就很有用:
#[derive(Debug, PartialEq, Eq)]
enum DbSlowReason {
QueryPlan,
PoolWait,
LockWait,
TooManyRows,
}定位数据库问题时,至少要看:
- 查询耗时
- 连接池等待耗时
- 返回行数
- 是否命中索引
- 是否有锁等待
连接池等待慢和 SQL 执行慢是两回事。
前者说明应用侧容量或下游慢导致连接被占;后者说明数据库执行计划或数据量有问题。
优化后一定要验证,不要只看代码更漂亮
性能优化必须有前后对比。
#[derive(Debug)]
struct BenchmarkResult {
before_p99_ms: u64,
after_p99_ms: u64,
}
fn improved(result: &BenchmarkResult) -> bool {
result.after_p99_ms < result.before_p99_ms
}真实验证可以是压测、基准测试、线上灰度指标。重点是不要把“看起来更少分配”当成功。
有些优化会牺牲可读性,有些会增加缓存一致性风险,有些会让错误路径更复杂。没有量化收益,就不要轻易换复杂度。
结论
Rust 性能优化最忌讳靠猜。语言很快不代表服务不会慢,所有权清晰不代表没有分配,async 高效不代表没有排队。
先看延迟分布,再用 tracing 切开阶段;CPU 高再看火焰图;I/O 慢就看下游和连接池;clone、锁、分配都要基于证据优化。
优化不是把代码写得更“像高手”,而是让瓶颈被定位、被解释、被验证。