返回文章列表

Rust 内存管理不是背所有权,服务端更该关心分配和生命周期

279·2 分钟阅读
Rust微服务

Rust 没有 GC,不代表内存问题自动消失。高 QPS 服务里,频繁分配、大对象 clone、缓存无界增长、任务持有数据太久,一样会把延迟和内存打上去。

Rust 的所有权系统解决了很多安全问题:悬垂指针、数据竞争、重复释放。

但服务端开发更常遇到的不是“内存不安全”,而是:

  • 请求路径上分配太多
  • 大字符串被反复 clone
  • buffer 每次重新申请
  • 缓存没有容量上限
  • Arc 让对象活得比预期更久
  • 后台任务抓住大对象不放

Rust 保证你安全地使用内存,不保证你节省地使用内存。

本文代码环境:

# Cargo.toml
[dependencies]
bytes = "1"
tokio = { version = "1", features = ["rt"] }

clone 不一定贵,但要知道它 clone 的是什么

Rust 代码里经常看到 clone(),新手会紧张。

先看两个完全不同的 clone:

use std::sync::Arc;
 
#[derive(Clone)]
struct AppState {
    config: Arc<Config>,
}
 
struct Config {
    service_name: String,
}

AppState::clone() 很便宜,它只是 clone 一个 Arc,增加引用计数。

再看这个:

fn duplicate_body(body: String) -> (String, String) {
    (body.clone(), body)
}

这里的 clone 会复制字符串内容。如果 body 很大,又在高频请求路径上,成本就明显。

不要把 clone 当敌人,要把不清楚成本的 clone 当信号。

看到 clone,先问它复制的是句柄、引用计数,还是一整块数据。


请求体和响应体尽量少来回变形

服务端常见内存浪费来自数据来回转换:

Bytes -> String -> serde_json::Value -> Domain -> String -> Bytes

每次转换都可能带来分配。

如果只是转发或做少量检查,bytes::Bytes 很适合:

use bytes::Bytes;
 
#[derive(Clone)]
struct RawPayload {
    body: Bytes,
}
 
fn payload_len(payload: &RawPayload) -> usize {
    payload.body.len()
}

Bytes clone 通常很便宜,因为它是引用计数的字节缓冲区。

当然,不是所有地方都要用 Bytes。如果你需要业务字段,就应该反序列化成结构体。重点是不要在没必要的时候把数据转成 String,又转回字节。


buffer 复用适合热路径

一些高频路径会反复创建临时 Vec<u8>

fn encode_line(fields: &[&str]) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend_from_slice(fields.join(",").as_bytes());
    out
}

这段简单,但 joinVec::new 都会分配。

如果它在热路径上,可以改成调用方传 buffer:

fn encode_line_into(fields: &[&str], out: &mut Vec<u8>) {
    out.clear();
    for (index, field) in fields.iter().enumerate() {
        if index > 0 {
            out.push(b',');
        }
        out.extend_from_slice(field.as_bytes());
    }
}

这个写法牺牲了一点函数纯度,换来可复用的内存。

我不会在所有地方都这么写。只有当 profiling 证明这条路径分配明显时,再引入这种接口。否则会让普通业务代码变得啰嗦。


Arc 解决共享,不解决生命周期膨胀

Arc 很好用,但它也会让对象活得更久。

use std::sync::Arc;
 
#[derive(Debug)]
struct LargeReport {
    rows: Vec<String>,
}
 
fn share_report(report: LargeReport) -> Arc<LargeReport> {
    Arc::new(report)
}

一旦多个任务持有 Arc<LargeReport>,这份大数据会一直活到剩下的引用全部释放。

这不是内存泄漏,但效果可能很像:请求已经结束,后台任务、缓存、channel 里还有引用,内存迟迟不降。

使用 Arc 时要问:

  • 共享的是配置,还是请求级大对象
  • 持有者有哪些
  • 有没有跨任务保存
  • 是否需要弱引用或复制小摘要

我的偏好是:配置、连接池、只读字典适合 Arc;请求级大对象要谨慎 Arc。


Channel 也会吃内存

异步系统里,channel 很容易变成隐形缓存。

无界 channel 最危险:

#[derive(Debug)]
struct WorkItem {
    payload: Vec<u8>,
}
 
fn estimate_queue_bytes(items: usize, payload_size: usize) -> usize {
    items * payload_size
}

如果每个任务 256KB,队列里积压 10,000 个,就是 2.5GB 级别的内存压力。

所以后台任务、日志、事件发送、批处理队列都应该有容量上限。前面 背压 已经讲过:队列满不是异常,它是系统在告诉你“下游跟不上”。

内存问题很多时候不是单个对象太大,而是队列没有上限。


缓存必须有容量边界

缓存不设上限,就是把内存交给流量决定。

哪怕每个值很小,key 空间一大也会把内存吃满。

#[derive(Debug)]
struct CachePolicy {
    max_entries: u64,
    ttl_seconds: u64,
}
 
fn user_profile_cache_policy() -> CachePolicy {
    CachePolicy {
        max_entries: 100_000,
        ttl_seconds: 300,
    }
}

缓存策略至少要有:

  • 最大条目数或最大内存
  • TTL
  • 淘汰策略
  • 指标:命中率、淘汰数、当前大小

没有这些指标,缓存只是一个看起来很快的内存增长器。


async 任务会持有它捕获的东西

async move 会把用到的数据搬进 future。只要 future 没完成,这些数据就不会释放。

async fn process_big_payload(payload: Vec<u8>) -> usize {
    tokio::task::yield_now().await;
    payload.len()
}

这里 payload 会活过 .await。如果任务被排队、等待锁、等待下游,它就一直占着内存。

优化方向不是机械地避免 async move,而是缩短大对象生命周期:

async fn process_then_wait(payload: Vec<u8>) -> usize {
    let len = payload.len();
    drop(payload);
 
    tokio::task::yield_now().await;
    len
}

这段代码刻意 drop,表达的是“后面不需要大对象了”。在普通代码里不必到处写,但处理大 buffer、大 JSON、大报表时,这个意识很有用。


结论

Rust 的内存安全很强,但服务端内存效率仍然要靠设计。所有权系统防止你用错内存,不会自动帮你减少分配、限制缓存、缩短对象生命周期。

弄清 clone 的真实成本;热路径再考虑 buffer 复用;Arc 适合共享长期小状态,不适合随手包大对象;channel 和缓存必须有容量上限;async 任务不要无意持有大数据太久。

Rust 给的是控制权,控制权用不好,也会变成成本。