缓存是提升系统吞吐量最简单的手段,但它引入的问题也比你想象的更多。击穿、穿透、雪崩——这三个听起来像地质灾害的名词,是每一个高并发系统设计者必须跨越的三道坎。

缓存的基本模型#

在深入三个核心问题之前,先建立一个标准的缓存读写模型:

标准缓存读取流程:

客户端 → 缓存层(Redis)→ [命中] → 直接返回
                         → [未命中] → 数据库层 → 写入缓存 → 返回

标准缓存写入流程:

方案 A(Cache Aside):先更新数据库,再删除缓存
方案 B(Read/Write Through):通过缓存层代理读写
方案 C(Write Behind):只写缓存,异步刷入数据库

在绝大多数互联网场景中,Cache Aside(旁路缓存)是最常用的模式——代码直接操作 Redis 和数据库,简单透明。但正是这种简单性,隐藏着三大问题。

问题一:缓存穿透(Cache Penetration)#

定义:查询一个根本不存在的数据,缓存中没有,数据库中也没有。每次请求都会穿透缓存直接打到数据库。

正常请求:
  查询 user_id=123 → 缓存命中 → 返回(快)

穿透请求:
  查询 user_id=-1 → 缓存未命中 → 查数据库 → 数据库也没有 → 返回空
  查询 user_id=-1 → 缓存未命中 → 查数据库 → 数据库也没有 → 返回空
  查询 user_id=-1 → 缓存未命中 → 查数据库 → 数据库也没有 → 返回空
  
  如果每秒 10000 次这样的请求,数据库直接被压垮

穿透通常由恶意攻击或业务 Bug 引起——攻击者构造大量不存在的 ID,绕过缓存直接攻击数据库。

解决方案#

方案 1:缓存空值

public User getUserById(Long userId) {
    String key = "user:" + userId;
    String cached = redis.get(key);
    
    if (cached != null) {
        if ("NULL_PLACEHOLDER".equals(cached)) {
            return null;  // 之前查过,数据库中不存在
        }
        return JSON.parseObject(cached, User.class);
    }
    
    // 缓存未命中,查数据库
    User user = userMapper.selectById(userId);
    if (user != null) {
        redis.setex(key, 3600, JSON.toJSONString(user));
    } else {
        // 关键:把「不存在」也缓存起来,但过期时间设短一些
        redis.setex(key, 300, "NULL_PLACEHOLDER");  // 5 分钟过期
    }
    return user;
}

优点:简单直接。缺点:如果攻击者用大量不同的随机 ID,会创建大量空值缓存,浪费内存。

方案 2:布隆过滤器(Bloom Filter)

布隆过滤器原理:

用一个 bit 数组 + 多个哈希函数来判断一个元素「可能存在」或「一定不存在」

初始化:把所有已存在的 user_id 加入布隆过滤器
  user_id=1  → hash1, hash2, hash3 → 对应 bit 位置 1
  user_id=2  → hash1, hash2, hash3 → 对应 bit 位置 1
  user_id=3  → hash1, hash2, hash3 → 对应 bit 位置 1
  ...

查询时:
  user_id=999 → hash1, hash2, hash3 → 如果有一个 bit 位是 0 → 一定不存在 → 直接返回
  user_id=123 → hash1, hash2, hash3 → 所有 bit 位都是 1 → 可能存在 → 继续查缓存/数据库

关键特性:
  - 判断「不存在」是 100% 准确的
  - 判断「存在」有极小的误判率(可配置,通常 < 0.1%)
  - 空间效率极高:100 万个 ID 只需要约 1.2MB 内存
public User getUserById(Long userId) {
    // 第一道防线:布隆过滤器
    if (!bloomFilter.mightContain(userId)) {
        return null;  // 100% 确定不存在
    }
    
    // 第二道防线:Redis 缓存
    String key = "user:" + userId;
    String cached = redis.get(key);
    if (cached != null) {
        return JSON.parseObject(cached, User.class);
    }
    
    // 第三道防线:数据库
    User user = userMapper.selectById(userId);
    if (user != null) {
        redis.setex(key, 3600, JSON.toJSONString(user));
    }
    return user;
}

生产环境建议:布隆过滤器 + 缓存空值双重防护。布隆过滤器拦截绝大部分无效请求,缓存空值兜底布隆过滤器的误判。

问题二:缓存击穿(Cache Breakdown)#

定义:一个热点 key 在过期的瞬间,大量并发请求同时到达,全部穿透到数据库。

时间线:

T0: 热点 key "product:999" 在缓存中,QPS 50000,全部命中缓存
T1: key 过期了
T2: 50000 个并发请求同时发现缓存未命中
T3: 50000 个请求同时打到数据库
T4: 数据库扛不住,连接池耗尽,服务雪崩

这就是「击穿」——一个点的缓存失效,导致整个数据库被打穿

解决方案#

方案 1:互斥锁(Mutex Lock)

public Product getProductById(Long productId) {
    String key = "product:" + productId;
    String cached = redis.get(key);
    
    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }
    
    // 缓存未命中,尝试获取分布式锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 10);  // 10秒过期
    
    if (locked) {
        try {
            // 双重检查:获取锁后再查一次缓存
            cached = redis.get(key);
            if (cached != null) {
                return JSON.parseObject(cached, Product.class);
            }
            
            // 只有获得锁的线程才查数据库
            Product product = productMapper.selectById(productId);
            redis.setex(key, 3600, JSON.toJSONString(product));
            return product;
        } finally {
            redis.del(lockKey);  // 释放锁
        }
    } else {
        // 未获得锁的线程短暂等待后重试
        Thread.sleep(50);
        return getProductById(productId);  // 重试
    }
}

优点:保证只有一个线程回源数据库。缺点:其他线程需要等待,增加了响应时间。

方案 2:逻辑过期

@Data
public class CachedProduct {
    private Product product;
    private long expireTime;  // 逻辑过期时间
}

public Product getProductById(Long productId) {
    String key = "product:" + productId;
    String cached = redis.get(key);
    
    if (cached != null) {
        CachedProduct cachedProduct = JSON.parseObject(cached, CachedProduct.class);
        
        if (cachedProduct.getExpireTime() > System.currentTimeMillis()) {
            // 未过期,直接返回
            return cachedProduct.getProduct();
        }
        
        // 逻辑过期了,但物理上还在缓存中
        // 异步更新,当前请求返回旧数据
        if (redis.setnx("rebuild:" + key, "1", 10)) {
            CompletableFuture.runAsync(() -> rebuildCache(productId));
        }
        return cachedProduct.getProduct();  // 返回旧数据(稍有过期但可接受)
    }
    
    // 首次加载
    Product product = productMapper.selectById(productId);
    CachedProduct cachedProduct = new CachedProduct();
    cachedProduct.setProduct(product);
    cachedProduct.setExpireTime(System.currentTimeMillis() + 3600_000);
    redis.set(key, JSON.toJSONString(cachedProduct));  // 不设 Redis TTL!
    return product;
}

private void rebuildCache(Long productId) {
    Product product = productMapper.selectById(productId);
    CachedProduct cachedProduct = new CachedProduct();
    cachedProduct.setProduct(product);
    cachedProduct.setExpireTime(System.currentTimeMillis() + 3600_000);
    redis.set("product:" + productId, JSON.toJSONString(cachedProduct));
    redis.del("rebuild:product:" + productId);
}

优点:不阻塞任何请求。缺点:在缓存刷新期间,部分请求会读到稍旧的数据。但对于商品详情这类场景,几秒钟的延迟完全可以接受。

方案 3:永不过期 + 后台主动刷新

对于超级热点数据(比如首页推荐位),直接设置永不过期,用一个后台线程定时刷新:

@Scheduled(fixedRate = 300_000)  // 每 5 分钟刷新一次
public void refreshHotProducts() {
    List<Long> hotProductIds = getTop100HotProducts();
    for (Long productId : hotProductIds) {
        Product product = productMapper.selectById(productId);
        redis.set("product:" + productId, JSON.toJSONString(product));
    }
}

问题三:缓存雪崩(Cache Avalanche)#

定义:大量缓存 key 在同一时间集中过期,导致大量请求同时打到数据库。

缓存雪崩 vs 缓存击穿:

击穿:一个热点 key 过期 → 数据库被打穿
雪崩:大量 key 同时过期 → 数据库被打崩

场景:
  凌晨 2:00 批量导入 100 万个商品到缓存,统一设置 24 小时过期
  次日凌晨 2:00,100 万个 key 同时过期
  所有商品查询同时打到数据库 → 数据库宕机

解决方案#

方案 1:过期时间加随机值

// 在基础过期时间上加一个随机偏移
int baseExpire = 3600;  // 1 小时
int randomOffset = ThreadLocalRandom.current().nextInt(0, 600);  // 0-600 秒随机
redis.setex(key, baseExpire + randomOffset, value);

// 100 万个 key 的过期时间被分散到 1 小时 ~ 1 小时 10 分钟之间
// 避免了集中过期

方案 2:多级缓存

请求 → L1 本地缓存(Caffeine)→ L2 分布式缓存(Redis)→ 数据库

特点:
  - L1 缓存容量小但速度极快(纳秒级)
  - L2 缓存容量大但需要网络 IO(毫秒级)
  - 即使 L2 全部失效,L1 仍然可以挡住一部分请求
  - L1 和 L2 的过期时间错开,避免同时失效
// 多级缓存实现
private final Cache<String, Product> localCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .build();

public Product getProduct(Long productId) {
    String key = "product:" + productId;
    
    // L1: 本地缓存
    Product product = localCache.getIfPresent(key);
    if (product != null) return product;
    
    // L2: Redis 缓存
    String cached = redis.get(key);
    if (cached != null) {
        product = JSON.parseObject(cached, Product.class);
        localCache.put(key, product);  // 回填 L1
        return product;
    }
    
    // 数据库
    product = productMapper.selectById(productId);
    if (product != null) {
        redis.setex(key, 3600 + randomOffset(), JSON.toJSONString(product));
        localCache.put(key, product);
    }
    return product;
}

方案 3:熔断降级

当数据库压力过大时,启动熔断机制,直接返回默认数据或缓存中的旧数据:

@HystrixCommand(fallbackMethod = "getProductFallback")
public Product getProduct(Long productId) {
    // 正常流程...
}

public Product getProductFallback(Long productId) {
    // 降级策略:
    // 1. 尝试从 Redis 获取过期数据(即使已过期也比没有好)
    // 2. 返回默认商品数据
    // 3. 返回 "系统繁忙,请稍后重试"
    return Product.defaultProduct();
}

缓存与数据库的一致性#

除了三大经典问题,缓存与数据库的一致性也是一个绕不开的话题。

双写一致性的几种策略#

策略 1:先删缓存,再更新数据库
  问题:删缓存和写数据库之间的窗口期内,其他线程会读到旧数据

策略 2:先更新数据库,再删缓存(Cache Aside,推荐)
  问题:极端场景下可能不一致(概率极低)
  
  场景:
    线程 A 读缓存未命中 → 读数据库得到旧值 v1
    线程 B 更新数据库为 v2 → 删除缓存
    线程 A 把 v1 写入缓存
    → 缓存中是旧值 v1
  
  但这个场景发生的前提是「读数据库比写数据库还慢」,在实际中概率极低

策略 3:延迟双删
  先删缓存 → 更新数据库 → 延迟 N 毫秒 → 再删一次缓存
  可以覆盖策略 2 的极端场景

策略 4:基于 binlog 的异步同步(Canal / Debezium)
  数据库变更 → binlog → Canal → 消息队列 → 缓存更新消费者
  最终一致性方案,适合对一致性要求不高的场景

推荐组合

强一致性场景(库存、余额):
  不使用缓存,直接读写数据库 + 分布式锁

准实时一致性场景(商品信息、用户信息):
  Cache Aside + 延迟双删 + TTL 兜底

最终一致性场景(文章、评论、推荐):
  Canal 订阅 binlog + 异步更新缓存 + 合理 TTL

缓存架构的演进路线#

阶段 1:单体应用 + 本地缓存
  HashMap / Guava Cache
  问题:多实例间缓存不一致

阶段 2:分布式缓存
  Redis / Memcached
  问题:单点故障、容量受限

阶段 3:多级缓存
  本地缓存 + Redis + CDN
  问题:一致性维护复杂

阶段 4:智能缓存
  根据访问模式动态调整 TTL
  热点数据自动识别和预加载
  冷数据自动淘汰

总结速查表#

问题 本质 方案
缓存穿透 查不存在的数据 布隆过滤器 + 缓存空值
缓存击穿 热点 key 过期 互斥锁 / 逻辑过期 / 永不过期
缓存雪崩 大量 key 同时过期 随机过期时间 + 多级缓存 + 熔断
数据一致性 缓存和数据库不同步 Cache Aside + 延迟双删 / binlog 同步

缓存设计没有银弹。每一个方案都是在一致性、可用性、延迟、复杂度之间做权衡。理解每一个方案的适用场景和局限性,才能在具体的业务场景中做出正确的架构选择。