4. 缓存策略优化:本地缓存 vs 分布式缓存、缓存穿透/击穿/雪崩的应对、Redis实战技巧

缓存这东西,说白了就是用空间换时间。但怎么换、换多少、换完之后怎么保证不出事,才是真正的学问。我见过太多团队一上来就上 Redis,结果业务量一上来,缓存穿透直接把数据库打挂了。嗯,这一章咱们就把缓存策略聊透。

核心观点:没有万能的缓存方案,只有适合当前业务场景的取舍。本地缓存和分布式缓存不是二选一,而是可以协同作战的。

4.1 本地缓存 vs 分布式缓存:选型背后的逻辑

先问一个问题:你的数据是每个节点独享,还是所有节点共享?这个问题的答案,基本决定了你应该用本地缓存还是分布式缓存。

对比维度 本地缓存 分布式缓存
存储位置 JVM 堆内(如 Caffeine、Guava) 独立缓存服务(如 Redis、Memcached)
访问速度 纳秒级(内存直接读取) 毫秒级(网络 I/O 开销)
数据一致性 各节点独立,存在不一致 全局统一,强一致性可配置
容量上限 受限于单机内存 可水平扩展,容量大
典型场景 配置信息、静态字典、热点数据 用户会话、全局计数器、共享数据

我个人习惯的做法是:两级缓存。本地缓存扛第一波热点请求,分布式缓存做兜底和数据同步。举个例子,我在做电商商品详情页优化时,把 SKU 的基础信息放本地缓存(Caffeine,过期时间 5 秒),把库存和价格这种实时性要求高的数据放 Redis。这样既保证了热点数据的极速响应,又避免了本地缓存数据过于陈旧。

实战技巧:本地缓存建议用 Caffeine,它的 W-TinyLFU 淘汰算法比 LRU 更适应突发流量。设置最大条目数时,别超过堆内存的 5%,否则 GC 压力会让你怀疑人生。

4.2 缓存穿透、击穿、雪崩:三个要命的坑

这三个问题,我当年在某个金融项目中全踩了一遍。那次事故之后,我养成了一个习惯:上线前必须做缓存异常场景的压测。

4.2.1 缓存穿透:查了个不存在的数据

用户请求一个缓存和数据库中都不存在的数据(比如一个不存在的商品 ID),请求直接打到数据库。如果并发量一大,数据库瞬间就跪了。

解决方案:

  • 缓存空对象:查询结果为空时,也缓存一个空值(比如 "NULL"),设置较短的过期时间(30-60 秒)。
  • 布隆过滤器:在缓存前面加一层布隆过滤器,判断 key 是否存在。不存在直接返回,不用查数据库。

我曾经踩过的坑:布隆过滤器的误判率不是 0%。如果业务对数据一致性要求极高(比如订单号),布隆过滤器可能会误杀正常请求。建议用 Counting Bloom Filter 或者配合缓存空对象做双重保障。

4.2.2 缓存击穿:热点 key 过期瞬间

一个热点 key(比如某个爆款商品的库存)在过期的一瞬间,大量并发请求同时涌入,全部穿透到数据库。这比穿透更可怕,因为数据是真实存在的,数据库压力会瞬间爆表。

解决方案:

  • 互斥锁(Mutex):只让一个线程去查数据库重建缓存,其他线程等待。Redis 里可以用 SETNX 实现。
  • 逻辑过期:缓存永不过期,但数据里带一个逻辑过期时间。后台异步线程去刷新缓存,保证数据最终一致。
// 伪代码:互斥锁方案
public String getData(String key) {
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    // 尝试获取锁
    if (redis.setnx("lock:" + key, "1", 3)) {
        try {
            value = db.query(key);
            redis.set(key, value, 60);
            return value;
        } finally {
            redis.del("lock:" + key);
        }
    } else {
        // 没拿到锁,等待重试
        Thread.sleep(50);
        return getData(key); // 递归重试
    }
}

注意:互斥锁方案在高并发下会有一定的等待开销。如果业务允许短暂的不一致,逻辑过期方案更优雅。我在直播秒杀场景中用的就是逻辑过期,因为用户对几毫秒的库存延迟并不敏感。

4.2.3 缓存雪崩:大量 key 同时过期

缓存雪崩和击穿的区别在于:击穿是单个热点 key 过期,雪崩是一大片 key 同时过期。比如你给所有缓存设置了相同的过期时间(比如凌晨 0 点),一到点,所有缓存集体失效,数据库直接被打穿。

解决方案:

  • 过期时间加随机值:基础过期时间 + 随机偏移(比如 1-5 分钟),避免集体失效。
  • 多级缓存:本地缓存 + Redis 缓存 + 数据库,层层兜底。
  • 限流降级:缓存失效时,对数据库查询做限流,保护数据库不被冲垮。

我的经验:缓存雪崩的预防比事后处理更重要。建议在系统上线前,用压测工具模拟缓存集体失效的场景,看看数据库能不能扛住。我曾经在一个项目中因为没做这个测试,上线第一天数据库连接池就被打满了。

4.3 Redis 实战技巧:不只是 get/set

Redis 用得好不好,差别很大。很多人只会用 String 类型做缓存,其实 Redis 的潜力远不止于此。

4.3.1 数据结构选型

数据结构 适用场景 注意事项
String 简单 KV 缓存、计数器 大 value 会阻塞 Redis
Hash 对象缓存(如用户信息) 避免 field 过多(建议 < 1000)
List 消息队列、最新列表 注意 LTRIM 控制长度
Set 去重、标签系统 SINTER/SUNION 性能很好
Sorted Set 排行榜、延迟队列 ZRANGEBYSCORE 做范围查询

4.3.2 内存优化:别让 Redis 成为瓶颈

Redis 是内存数据库,内存就是钱。我见过一个项目,Redis 内存占用 80%,结果业务一增长就 OOM 了。

  • 设置 maxmemory:别让 Redis 无限制吃内存,设置上限并配置淘汰策略(推荐 allkeys-lru)。
  • 压缩 value:大文本用 Snappy 或 LZ4 压缩后再存,能省 50%-70% 内存。
  • 使用 Hash 代替 String:存储对象时,Hash 比 String 更省内存(尤其是 field 少的时候)。

4.3.3 管道与批量操作

如果你需要一次性操作多个 key,别用循环一个个发命令。用 Pipeline 批量发送,网络开销从 O(n) 降到 O(1)。

// Java 示例:Pipeline 批量写入
Jedis jedis = new Jedis("localhost");
Pipeline pipeline = jedis.pipelined();
for (int i = 0; i < 1000; i++) {
    pipeline.set("key:" + i, "value:" + i);
}
pipeline.sync(); // 一次性发送所有命令

注意:Pipeline 不是事务,它只是批量发送命令。如果中间某个命令失败,不会回滚前面的操作。需要事务的话用 MULTI/EXEC。

4.4 知识体系总览

下面这张图把缓存策略的核心逻辑串起来了。你想想看,从选型到异常处理,再到 Redis 实战,其实是一条线:选对工具 → 防住异常 → 优化性能。

缓存策略优化知识体系 缓存策略优化知识体系 本地缓存 分布式缓存 VS 缓存穿透 缓存击穿 缓存雪崩 布隆过滤器 缓存空对象 互斥锁 逻辑过期 过期时间加随机值 多级缓存 + 限流 Redis 实战技巧:数据结构选型 · 内存优化 · Pipeline 核心思路:选型 → 防异常 → 优化性能,三层递进,缺一不可

4.5 总结:缓存优化的三个层次

聊了这么多,其实就三句话:

  1. 选型层:本地缓存扛热点,分布式缓存保一致,两级缓存是王道。
  2. 防护层:穿透用布隆过滤器,击穿用互斥锁,雪崩用随机过期时间。
  3. 优化层:Redis 数据结构选对、内存管好、Pipeline 用起来。