20、缓存可靠性模式:缓存穿透、缓存击穿、缓存雪崩、缓存一致性

聊到缓存,很多人的第一反应就是「快」。没错,缓存确实能让系统飞起来。但飞得越高,摔得越惨。我在项目中见过太多次缓存出问题导致整个系统瘫痪的案例了。今天咱们就把缓存可靠性这块硬骨头啃下来。

说白了,缓存就是把数据放在离计算更近的地方。但引入缓存之后,你面对的不再是简单的「存」和「取」,而是一整套可靠性问题。我习惯把这些问题分成四类:穿透、击穿、雪崩、一致性。咱们一个一个来。

缓存穿透:查了个不存在的东西

什么叫缓存穿透?就是请求的数据在缓存里没有,在数据库里也没有。每次请求都直接打到数据库上。你想想看,缓存本来是用来挡枪的,结果子弹全绕过去了。

我在项目中遇到过最典型的场景:用户恶意请求一个不存在的商品ID。缓存里没有,数据库里也没有。每次请求都穿透到数据库,数据库连接池瞬间被打满。

解决方案一:缓存空对象

既然数据不存在,那就把「不存在」这个结果也缓存起来。设置一个较短的过期时间,比如30秒。

// 缓存空对象示例
public Product getProduct(String id) {
    // 先从缓存取
    Object cacheValue = cache.get("product:" + id);
    if (cacheValue != null) {
        // 判断是否是空值标记
        if (cacheValue instanceof NullValue) {
            return null; // 直接返回,不查数据库
        }
        return (Product) cacheValue;
    }
    
    // 缓存没有,查数据库
    Product product = dao.getProduct(id);
    if (product == null) {
        // 缓存空对象,过期时间30秒
        cache.set("product:" + id, new NullValue(), 30);
        return null;
    }
    
    cache.set("product:" + id, product, 3600);
    return product;
}
解决方案二:布隆过滤器

在缓存前面再加一层布隆过滤器。请求来了先问布隆过滤器:这个ID存在吗?如果说不存在,直接返回,连缓存都不用查。

我的建议:布隆过滤器适合数据量大的场景。如果数据量小,缓存空对象就够用了。我曾经在一个电商项目里两种方案都用过,布隆过滤器对内存的消耗其实比想象中小。

缓存击穿:热点key过期了

缓存击穿和穿透不一样。击穿是指某个热点key在过期的那一瞬间,大量请求同时涌入,全部打到数据库上。

为什么会这样?你想想看,一个商品秒杀活动,商品详情页的缓存刚好在秒杀开始前几秒过期了。几千个请求同时来,缓存里没有,全去查数据库。数据库扛得住才怪。

解决方案:互斥锁

当缓存失效时,只让一个线程去查数据库重建缓存,其他线程等待。等缓存重建好了,后面的请求直接从缓存拿。

// 互斥锁重建缓存
public Product getProductWithLock(String id) {
    Product product = cache.get("product:" + id);
    if (product != null) {
        return product;
    }
    
    // 尝试获取锁
    String lockKey = "lock:product:" + id;
    if (redis.setnx(lockKey, "1", 10)) {
        try {
            // 拿到锁的线程查数据库
            product = dao.getProduct(id);
            cache.set("product:" + id, product, 3600);
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁的线程等待一会儿再试
        Thread.sleep(50);
        return getProductWithLock(id);
    }
    return product;
}
注意:互斥锁方案会增加请求延迟。如果对延迟要求极高,可以考虑「逻辑过期」方案——缓存永不过期,但存一个逻辑过期时间,后台异步更新。

缓存雪崩:大面积缓存同时失效

缓存雪崩比击穿更可怕。击穿是一个key失效,雪崩是一大片key同时失效。比如缓存服务器宕机了,或者大量key设置了相同的过期时间。

我记得有一次线上事故,运营同事在后台批量上架商品,所有商品缓存都设置了相同的过期时间。结果凌晨三点,缓存大面积失效,数据库直接被压垮。那叫一个酸爽。

解决方案:
  • 过期时间加随机值:比如基础过期时间3600秒,再加一个0-600秒的随机值。这样不会同时过期。
  • 多级缓存:本地缓存+分布式缓存。本地缓存扛住第一波流量。
  • 限流降级:缓存失效时,对数据库的请求做限流,保护数据库。
// 过期时间加随机值
int baseExpire = 3600;
int randomExpire = new Random().nextInt(600);
cache.set("product:" + id, product, baseExpire + randomExpire);

缓存一致性:数据到底对不对

缓存一致性是最头疼的问题。说白了就是:数据库里的数据改了,缓存里的旧数据怎么办?

我见过太多团队在这个问题上翻车了。有人先删缓存再更新数据库,有人先更新数据库再删缓存,各有各的坑。

Cache Aside 模式

这是最常用的模式。读的时候先读缓存,缓存没有就读数据库,然后回填缓存。写的时候先更新数据库,然后删除缓存。

// Cache Aside 写操作
public void updateProduct(Product product) {
    // 1. 更新数据库
    dao.updateProduct(product);
    // 2. 删除缓存
    cache.del("product:" + product.getId());
}

// Cache Aside 读操作
public Product getProduct(String id) {
    Product product = cache.get("product:" + id);
    if (product == null) {
        product = dao.getProduct(id);
        cache.set("product:" + id, product, 3600);
    }
    return product;
}
注意:Cache Aside 模式存在一个并发问题。线程A读缓存没命中,去查数据库;线程B更新数据库并删除缓存;线程A把旧数据写回缓存。解决方案:延迟双删,或者用分布式锁。

Read/Write Through 模式

这种模式把缓存当成数据源。应用程序只跟缓存打交道,缓存自己负责跟数据库同步。

// Read Through 示例(伪代码)
public class ReadThroughCache {
    private Cache cache;
    private Database db;
    
    public Product get(String id) {
        // 缓存自己决定是否要从数据库加载
        return cache.get("product:" + id, 
            key -> db.getProduct(id)); // 缓存未命中时自动加载
    }
}
我的经验:Read/Write Through 模式对缓存中间件要求高。一般用 Redis 配合自定义的加载器实现。我个人习惯在中小型项目里用 Cache Aside,简单可控。大型项目才上 Read/Write Through。

本地缓存 vs 分布式缓存

这个问题其实没有标准答案。我根据项目规模来选。

特性 本地缓存 分布式缓存
速度 极快(内存访问) 较快(网络IO)
容量 受限于单机内存 可水平扩展
一致性 各节点数据不一致 全局一致
典型实现 Caffeine, Guava Cache Redis, Memcached
我的建议:
  • 单机应用或延迟要求极高的场景,用本地缓存。比如配置信息、字典数据。
  • 多实例部署、需要数据共享的场景,用分布式缓存。
  • 最佳实践:本地缓存做一级缓存,分布式缓存做二级缓存。本地缓存扛高频访问,分布式缓存做数据同步。

我曾经在一个金融项目中用过两级缓存。本地缓存用 Caffeine,过期时间30秒;分布式缓存用 Redis,过期时间10分钟。热点数据从本地缓存拿,冷数据从 Redis 拿。效果非常好,数据库 QPS 从 5000 降到了 200。

知识体系总览

下面这张图把缓存可靠性的核心问题串起来了。你可以把它当作一个检查清单,设计缓存方案时对照着看。

缓存可靠性模式知识体系 缓存系统 缓存穿透 缓存击穿 缓存雪崩 缓存一致性 方案: ① 缓存空对象 ② 布隆过滤器 方案: ① 互斥锁 ② 逻辑过期 方案: ① 过期时间加随机值 ② 多级缓存 方案: ① Cache Aside ② Read/Write Through 核心原则:缓存是加速器,不是数据源
最后提醒一句:缓存不是银弹。引入缓存之前,先想清楚你的场景到底需不需要缓存。我曾经见过一个项目,每天只有几百个请求,硬是上了三层缓存,结果维护成本比收益还高。缓存是用来解决性能问题的,不是用来炫技的。

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