返回文章列表

Rust 性能优化别靠猜,先把慢在哪里量出来

309·3 分钟阅读
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 很贵,比如复制一个大 StringVec

先分清:

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、锁、分配都要基于证据优化。

优化不是把代码写得更“像高手”,而是让瓶颈被定位、被解释、被验证。