缓存穿透模式(Cache-Aside):缓存命中、缓存未命中、数据加载,以及Redis缓存策略

说实话,缓存穿透这个问题,我早年刚带团队的时候踩过一个大坑。当时线上一个接口突然变慢,数据库连接池被打满,查了半天才发现是缓存穿透导致的。从那以后,我对这个模式就格外上心。

今天咱们聊聊 Cache-Aside 模式。这是最常用的一种缓存策略,说白了就是:应用程序自己管理缓存,读写都走应用代码。

什么是 Cache-Aside 模式?

Cache-Aside 也叫"旁路缓存"。它的核心思想是:应用程序同时持有缓存和数据库两个数据源,由应用代码决定什么时候读缓存、什么时候读数据库。

我习惯把它理解成"懒加载"——数据只有在被请求时才会加载到缓存里。你想想看,这比那些启动时就把所有数据塞进缓存的方案,要灵活得多。

三种核心场景

1. 缓存命中(Cache Hit)

这是最理想的情况。流程很简单:

  1. 应用先查 Redis
  2. Redis 里有数据,直接返回
  3. 完事,数据库都不用碰

我在项目中遇到过,有些团队把命中率目标定在 95% 以上。其实这个要看业务场景,读多写少的场景,命中率做到 99% 都不难。

2. 缓存未命中(Cache Miss)

缓存里没有,就得走数据库了。流程是这样的:

  1. 查 Redis,没找到
  2. 查数据库,拿到数据
  3. 把数据写回 Redis
  4. 返回给调用方

这里有个坑——缓存击穿。如果某个热点 key 刚好过期,瞬间大量请求同时打到数据库,数据库扛不住。我曾经就因为一个秒杀商品的缓存过期,导致数据库 CPU 直接飙到 100%。

避坑指南: 对于热点 key,我建议用互斥锁或者逻辑过期时间来解决。互斥锁就是让第一个请求去加载数据,其他请求等待;逻辑过期时间则是缓存永不过期,但数据里带一个过期标记,异步去刷新。

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 的核心流程图

Cache-Aside 模式核心流程 应用程序 Redis 缓存 数据库 ① 查缓存 命中 ✓ ② 未命中,查 DB ③ 返回数据 ④ 写入缓存 关键说明: • 缓存命中:直接返回,不碰数据库 • 缓存未命中:查 DB → 写缓存 → 返回 • 更新数据:先更新 DB,再删除缓存(不是更新缓存) • 防止击穿:加锁 + 双重检查

常见问题与避坑

缓存穿透:查询一个不存在的数据。缓存里没有,数据库里也没有,每次请求都穿透到数据库。

解决方案:缓存空值(即使数据库返回 null,也缓存一个空标记),或者用布隆过滤器。
我的经验:布隆过滤器虽然能解决缓存穿透,但实现起来稍微复杂。如果业务简单,我建议先缓存空值,设置一个较短的 TTL(比如 60 秒),够用了。

还有一个容易被忽略的问题——缓存与数据库的双写一致性。Cache-Aside 模式里,我们选择"先更新数据库,再删除缓存"。为什么不是先删缓存再更新数据库?因为并发场景下,先删缓存可能导致另一个线程读到旧数据并写入缓存,造成数据不一致。

说白了,没有完美的方案,只有最适合当前业务的方案。Cache-Aside 模式胜在简单、可控,适合绝大多数业务场景。如果你追求更高的一致性,可以考虑引入消息队列或者订阅 binlog 来同步缓存。

公众号:蓝海资料掘金营,微信deep3321