缓存穿透模式(Cache-Aside):缓存命中、缓存未命中、数据加载,以及Redis缓存策略
说实话,缓存穿透这个问题,我早年刚带团队的时候踩过一个大坑。当时线上一个接口突然变慢,数据库连接池被打满,查了半天才发现是缓存穿透导致的。从那以后,我对这个模式就格外上心。
今天咱们聊聊 Cache-Aside 模式。这是最常用的一种缓存策略,说白了就是:应用程序自己管理缓存,读写都走应用代码。
什么是 Cache-Aside 模式?
Cache-Aside 也叫"旁路缓存"。它的核心思想是:应用程序同时持有缓存和数据库两个数据源,由应用代码决定什么时候读缓存、什么时候读数据库。
我习惯把它理解成"懒加载"——数据只有在被请求时才会加载到缓存里。你想想看,这比那些启动时就把所有数据塞进缓存的方案,要灵活得多。
三种核心场景
1. 缓存命中(Cache Hit)
这是最理想的情况。流程很简单:
- 应用先查 Redis
- Redis 里有数据,直接返回
- 完事,数据库都不用碰
我在项目中遇到过,有些团队把命中率目标定在 95% 以上。其实这个要看业务场景,读多写少的场景,命中率做到 99% 都不难。
2. 缓存未命中(Cache Miss)
缓存里没有,就得走数据库了。流程是这样的:
- 查 Redis,没找到
- 查数据库,拿到数据
- 把数据写回 Redis
- 返回给调用方
这里有个坑——缓存击穿。如果某个热点 key 刚好过期,瞬间大量请求同时打到数据库,数据库扛不住。我曾经就因为一个秒杀商品的缓存过期,导致数据库 CPU 直接飙到 100%。
3. 数据加载策略
数据加载分两种:
- 懒加载:请求来了才加载,适合大多数场景
- 预加载:提前把数据塞进缓存,适合热点数据
我个人习惯用懒加载作为默认策略。预加载虽然能避免缓存未命中,但维护成本高——你得知道哪些数据是热点,还得处理数据更新后的缓存同步。
Redis 缓存策略
Redis 作为缓存层,有几个关键策略需要关注:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| LRU(最近最少使用) | 淘汰最久没被访问的数据 | 通用场景,我用的最多 |
| TTL(过期时间) | 数据到期自动删除 | 需要保证数据新鲜度的场景 |
| LFU(最不经常使用) | 淘汰访问频率最低的数据 | 访问频率差异大的场景 |
| 随机淘汰 | 随机删除数据 | 简单场景,不推荐 |
嗯,这里要注意:TTL 不是万能的。我见过有人把所有 key 都设成 5 分钟过期,结果大量 key 同时过期,导致缓存雪崩。解决办法是给过期时间加一个随机偏移量,比如 5 分钟 ± 30 秒。
代码示例:Java 实现 Cache-Aside
public class CacheAsideService {
private RedisTemplate<String, Object> redisTemplate;
private JdbcTemplate jdbcTemplate;
public Object getData(String key) {
// 1. 先查缓存
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value; // 缓存命中
}
// 2. 缓存未命中,查数据库
// 这里加锁防止缓存击穿
synchronized (key.intern()) {
// 双重检查
value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 3. 从数据库加载
value = jdbcTemplate.queryForObject(
"SELECT data FROM my_table WHERE id = ?",
new Object[]{key},
String.class
);
// 4. 写入缓存,设置过期时间
redisTemplate.opsForValue().set(key, value, 300, TimeUnit.SECONDS);
}
return value;
}
public void updateData(String key, Object newValue) {
// 更新数据库
jdbcTemplate.update("UPDATE my_table SET data = ? WHERE id = ?",
newValue, key);
// 删除缓存,而不是更新
redisTemplate.delete(key);
}
}
为什么更新数据时要删除缓存而不是更新?这是 Cache-Aside 模式的一个经典设计。我刚开始也不理解,后来发现:删除缓存比更新缓存更安全。因为并发场景下,更新缓存可能导致数据不一致。
代码示例:C++ 实现 Cache-Aside
class CacheAsideService {
private:
std::shared_ptr<RedisClient> redis_;
std::shared_ptr<DatabaseClient> db_;
std::mutex mtx_;
public:
std::string GetData(const std::string& key) {
// 1. 查缓存
auto value = redis_->Get(key);
if (value.has_value()) {
return value.value();
}
// 2. 加锁防止击穿
std::lock_guard<std::mutex> lock(mtx_);
// 双重检查
value = redis_->Get(key);
if (value.has_value()) {
return value.value();
}
// 3. 查数据库
auto dbValue = db_->Query("SELECT data FROM my_table WHERE id = ?", key);
// 4. 写入缓存
redis_->Set(key, dbValue, 300);
return dbValue;
}
void UpdateData(const std::string& key, const std::string& newValue) {
// 先更新数据库
db_->Execute("UPDATE my_table SET data = ? WHERE id = ?", newValue, key);
// 再删除缓存
redis_->Delete(key);
}
};
Cache-Aside 的核心流程图
常见问题与避坑
解决方案:缓存空值(即使数据库返回 null,也缓存一个空标记),或者用布隆过滤器。
还有一个容易被忽略的问题——缓存与数据库的双写一致性。Cache-Aside 模式里,我们选择"先更新数据库,再删除缓存"。为什么不是先删缓存再更新数据库?因为并发场景下,先删缓存可能导致另一个线程读到旧数据并写入缓存,造成数据不一致。
说白了,没有完美的方案,只有最适合当前业务的方案。Cache-Aside 模式胜在简单、可控,适合绝大多数业务场景。如果你追求更高的一致性,可以考虑引入消息队列或者订阅 binlog 来同步缓存。