Rust 服务用 Redis 缓存,最难的不是快,是别把数据搞乱
缓存一加,接口从 80ms 变成 5ms。然后某天用户改了资料,页面还显示旧昵称;商品下架了,缓存里还可以买。
Redis 在 Rust 后端里很好用,redis、deadpool-redis、bb8-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
}真实实现可以用 moka、dashmap、tokio::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 能让缓存代码写得很清楚,但真正决定系统质量的,是你有没有说清楚“不一致可以持续多久”。