4. 缓存策略与缓存击穿:本地缓存 vs 分布式缓存

缓存这东西,说白了就是用空间换时间。但换不好,系统反而会崩得更快。我见过太多团队,上来就怼 Redis,结果热点 Key 一挂,数据库直接被打死。今天咱们就把缓存这摊事彻底捋清楚。

4.1 本地缓存 vs 分布式缓存:选型不是拍脑袋

先问自己一个问题:你的数据需要共享吗?如果答案是「不需要」,那本地缓存往往更香。

维度 本地缓存(Caffeine) 分布式缓存(Redis)
访问延迟 纳秒级(堆内访问) 毫秒级(网络 IO)
数据一致性 单机强一致 最终一致(需额外处理)
容量上限 受限于堆内存 可水平扩展
适用场景 配置、字典、不共享的热点数据 全局共享、跨服务数据
典型实现 Caffeine、Guava Cache Redis、Memcached

我个人习惯是:能用本地缓存解决的,绝不引入 Redis。为什么?少一次网络开销,少一个故障点。但要注意,本地缓存最大的坑是「数据孤岛」——每个节点各存各的,更新时容易不一致。

我的经验: 对于配置类数据,我通常用 Caffeine 做一级缓存,Redis 做二级兜底。Caffeine 的 Window TinyLFU 淘汰算法,在高并发下命中率比 LRU 高不少。

4.2 缓存穿透:别让请求打到数据库

缓存穿透,就是查一个根本不存在的数据。每次请求都绕过缓存,直接怼到数据库。我见过最夸张的一次,某个接口被刷了 10 万次,数据库连接池瞬间打满。

解决方案就三个:

  1. 缓存空值:查不到数据时,也缓存一个空对象(TTL 设短一点,比如 30 秒)。
  2. 布隆过滤器:请求进来先过布隆,不存在直接返回。注意布隆有误判率,但可以接受。
  3. 参数校验:非法参数直接拦截,别让它们进缓存层。

避坑指南: 我曾经用布隆过滤器时,忘了考虑布隆不支持删除。结果数据删了,布隆还说存在,导致空查询穿透。后来改用 Counting Bloom Filter 才解决。

4.3 缓存击穿:热点 Key 过期那几秒

缓存击穿,指的是一个热点 Key 在过期瞬间,大量请求同时涌入,直接打穿到数据库。这比穿透更危险——因为数据是真实存在的,只是缓存刚好过期。

我的处理三板斧:

  • 互斥锁(Mutex):第一个请求发现缓存过期,加锁去查数据库,其他请求等待。注意锁的粒度要细,别锁住整个缓存。
  • 逻辑过期:缓存永不过期,但存一个过期时间字段。后台异步线程去刷新。这样即使数据变了,用户拿到的也是旧数据,但系统不会崩。
  • 热点 Key 不过期:对于已知的热点,直接设置不过期,由后台任务定期更新。
// 伪代码:互斥锁方案
public Object getWithMutex(String key) {
    Object value = cache.get(key);
    if (value != null) return value;
    
    String lockKey = "lock:" + key;
    if (redis.setNx(lockKey, "1", 3, TimeUnit.SECONDS)) {
        try {
            value = db.query(key);
            cache.set(key, value, 60, TimeUnit.SECONDS);
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 等待 50ms 后重试
        Thread.sleep(50);
        return getWithMutex(key);
    }
    return value;
}
注意: 互斥锁方案在高并发下,如果数据库查询慢,会导致大量线程阻塞。建议配合「逻辑过期」一起用,双保险。

4.4 缓存雪崩:大面积过期引发的灾难

缓存雪崩,就是大量 Key 在同一时间过期,或者缓存节点挂了,导致请求全部打到数据库。这往往是「设计失误」造成的。

预防措施:

  • 过期时间加随机值:比如基础 TTL 60 秒,再加 0~30 秒随机偏移。避免集体过期。
  • 多级缓存:本地缓存 + Redis 双层。Redis 挂了,本地缓存还能扛一阵。
  • 限流降级:缓存失效时,对数据库查询做限流。比如每秒只允许 100 个查询,其余返回默认值。
  • 缓存预热:系统启动时,提前加载热点数据到缓存,别等用户来触发。

4.5 热点 Key 发现与处理

热点 Key 是缓存系统的头号杀手。一个 Key 的 QPS 达到几十万,Redis 单节点扛不住,数据库更扛不住。

怎么发现热点 Key?

  1. 客户端统计:在缓存客户端(如 Jedis、Lettuce)里埋点,统计每个 Key 的访问频率。
  2. Redis 自身监控:用 hotkeys 参数(Redis 4.0+),或者 MONITOR 命令(注意性能开销)。
  3. 代理层分析:如果用了 Twemproxy 或 Codis,可以在代理层做流量分析。

发现之后怎么处理?

  • 本地缓存分摊:把热点 Key 复制到每个应用节点的本地缓存里。注意数据一致性,可以接受短暂不一致。
  • Key 分散:把一个热点 Key 拆成多个子 Key,比如 hot:1、hot:2……请求时随机选一个。
  • 读写分离:Redis 集群模式下,热点 Key 的读请求分散到多个从节点。
我的实战: 之前做一个秒杀系统,商品详情页的 Key 在开抢瞬间 QPS 冲到 80 万。我用了「本地缓存 + Key 分散」的组合:每个商品拆成 128 个分片,应用启动时预热到本地缓存。Redis 的负载从 95% 降到了 20%。

4.6 整体缓存策略架构

下面这张图,是我在实际项目中沉淀下来的缓存分层架构。你照着搭,至少能扛住 90% 的缓存问题。

缓存分层架构与故障防护 客户端请求 L1: 本地缓存 (Caffeine) 纳秒级 · 热点数据 · 不共享 L2: 分布式缓存 (Redis) 毫秒级 · 全局共享 · 集群部署 L3: 数据库 (MySQL) 最后防线 · 限流保护 布隆过滤器 拦截穿透请求 互斥锁 / 逻辑过期 防止击穿 限流 / 降级 防止雪崩 核心原则:L1 命中率 > 90% · L2 兜底 · L3 限流保护 · 三层联动防雪崩

4.7 总结:缓存调优的黄金法则

说了这么多,其实核心就几条:

  • 分层:本地缓存扛热点,分布式缓存扛共享,数据库做最后防线。
  • 兜底:穿透、击穿、雪崩,每种场景都要有预案。
  • 监控:热点 Key 不是靠猜的,用数据说话。
  • 简单:能用本地缓存解决的,别引入 Redis。多一层缓存,多一个故障点。

我记得有一次线上事故,就是因为一个热点 Key 过期,导致数据库被打爆。从那以后,我所有项目的缓存层都强制加了「逻辑过期 + 互斥锁」双保险。别嫌麻烦,线上出问题的时候,你会感谢这些「多余」的代码。