返回文章列表

Rust 服务用 Redis 缓存,最难的不是快,是别把数据搞乱

284·2 分钟阅读
Rust微服务

缓存一加,接口从 80ms 变成 5ms。然后某天用户改了资料,页面还显示旧昵称;商品下架了,缓存里还可以买。

Redis 在 Rust 后端里很好用,redisdeadpool-redisbb8-redis 这些工具都成熟。真正麻烦的不是连接 Redis,而是回答一个问题:

数据库和缓存不一致时,系统允许错多久,错成什么样?

如果这个问题没定义,缓存越快,错误传播也越快。


缓存不是数据库的副本,是读取路径的优化

很多缓存 bug 都来自一个误解:把 Redis 当成数据库的同步副本。

我更愿意这样定义缓存:

缓存是为了让读请求少打数据库,它不是事实来源。

事实来源仍然是数据库。Redis 里的值可以过期,可以被淘汰,可以短暂不一致。业务必须接受这个前提。

先看一个简单的缓存值:

use serde::{Deserialize, Serialize};
 
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UserProfileCache {
    user_id: u64,
    nickname: String,
    version: u64,
}

这里我会放一个 version。不是每个场景都必须,但它能提醒你:缓存里的数据是某个时间点的快照。

没有版本意识的缓存,排查时很难判断“这是旧值,还是写错了”。


Cache Aside 是最常用,也最容易写错的模式

读路径通常是 Cache Aside:

读 Redis
命中则返回
没命中读数据库
写回 Redis
返回

用 Rust 表达就是这个形状:

#[derive(Debug)]
enum LoadSource<T> {
    Cache(T),
    Database(T),
}
 
fn choose_profile(cached: Option<UserProfileCache>, db: UserProfileCache) -> LoadSource<UserProfileCache> {
    match cached {
        Some(value) => LoadSource::Cache(value),
        None => LoadSource::Database(db),
    }
}

这段代码刻意没写 Redis 调用,因为重点不是 API。重点是读路径的语义:

  • 命中缓存时,你接受它可能不是最新
  • 未命中时,数据库是事实来源
  • 写回缓存失败时,请求不一定要失败

这一点很重要。读数据库成功、写缓存失败,通常不应该让用户请求失败。缓存是优化,不是主流程。


更新时先删缓存,还是先写数据库

最常见的问题:用户改昵称,缓存怎么办?

很多人会想“更新数据库后更新缓存”。这看起来合理,但并发下很容易被旧值覆盖。

更常用的策略是:

更新数据库
删除缓存
下一次读取重新加载

也就是让缓存失效,而不是努力维护它永远最新。

#[derive(Debug, PartialEq, Eq)]
enum CacheInvalidation {
    DeleteKey(String),
    Skip,
}
 
fn invalidate_user_profile(user_id: u64) -> CacheInvalidation {
    CacheInvalidation::DeleteKey(format!("user:profile:{user_id}"))
}

这套策略的边界也要说清楚:数据库更新成功,删除缓存失败怎么办?

如果是用户昵称这种弱一致数据,可以靠 TTL 兜底。如果是价格、库存、权限这种强敏感数据,单靠“删缓存失败也没关系”就不够了。


TTL 不是随便填一个数字

所有缓存都应该有 TTL。没有 TTL 的缓存,等于把错误永久化。

不同数据的 TTL 应该不同:

数据 推荐策略
用户昵称 分钟级 TTL,可接受短暂旧值
商品详情 秒到分钟级 TTL,配合主动失效
权限 短 TTL 或不缓存关键判断
库存 谨慎缓存,写路径必须查数据库
配置 短 TTL + 版本号

用一个小函数表达 TTL 策略,比到处散落数字更好:

use std::time::Duration;
 
#[derive(Debug, Clone, Copy)]
enum CacheKind {
    UserProfile,
    ProductDetail,
    Permission,
}
 
fn ttl(kind: CacheKind) -> Duration {
    match kind {
        CacheKind::UserProfile => Duration::from_secs(300),
        CacheKind::ProductDetail => Duration::from_secs(60),
        CacheKind::Permission => Duration::from_secs(15),
    }
}

TTL 要表达业务容忍度,不要表达程序员的手感。

“缓存五分钟”这句话背后应该是“这类数据错五分钟业务能接受”。


缓存击穿不是 Redis 问题,是并发读问题

热门 key 过期时,大量请求同时打到数据库,这就是缓存击穿。

Rust 服务里可以用进程内 singleflight 思路缓解:同一个 key 同一时间只让一个请求回源,其他请求等结果。

文章里不展开完整实现,只看接口形状:

use std::future::Future;
 
async fn load_with_singleflight<T, F>(
    key: String,
    loader: F,
) -> T
where
    F: Future<Output = T>,
{
    let _ = key;
    loader.await
}

真实实现可以用 mokadashmaptokio::sync::Mutex 组合,或者直接选成熟库。关键不在代码技巧,而是策略:

  • 热点 key 不要同时回源
  • 回源失败不要把错误无限缓存
  • 允许短时间返回旧值的场景,可以用 stale-while-revalidate

缓存系统真正难的地方,往往是“失败时怎么退化”。


权限和价格不要迷信缓存

缓存最容易滥用在两个地方:权限和价格。

权限错了,可能导致越权。价格错了,可能导致资损。

这类数据不是完全不能缓存,而是不能把缓存结果当最终裁决。比如权限可以缓存用户角色,但关键操作仍然要在服务端按当前策略校验。

#[derive(Debug)]
struct PermissionSnapshot {
    user_id: u64,
    roles: Vec<String>,
    version: u64,
}
 
fn has_role(snapshot: &PermissionSnapshot, role: &str) -> bool {
    snapshot.roles.iter().any(|item| item == role)
}

这段代码适合做快速判断,但不适合独自承担高风险操作的授权边界。

缓存可以加速判断,但不要让缓存独占裁判权。

尤其是管理后台、支付、库存、风控这类场景,慢一点通常比错一点便宜。


缓存 key 要像 API 一样设计

缓存 key 不是随手拼字符串。它是内部 API。

fn user_profile_key(user_id: u64) -> String {
    format!("v1:user:profile:{user_id}")
}
 
fn product_detail_key(product_id: u64) -> String {
    format!("v1:product:detail:{product_id}")
}

我习惯带上版本前缀,比如 v1。当缓存结构变化时,可以直接切到 v2,避免新代码读到旧结构。

key 设计至少考虑:

  • 业务域:user、product、order
  • 数据类型:profile、detail、summary
  • 版本:v1、v2
  • 租户或环境隔离:tenant、env

缓存 key 混乱以后,清理和排查都会变成体力活。


结论

Redis 缓存不是“加一层就更快”的魔法。它引入的是一套新的数据一致性边界。

数据库是事实来源,缓存是读取优化;更新时优先让缓存失效,而不是执着同步更新;TTL 要表达业务容忍度;权限、价格、库存这类高风险数据不要让缓存独自裁决。

Rust 能让缓存代码写得很清楚,但真正决定系统质量的,是你有没有说清楚“不一致可以持续多久”。