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 实战,其实是一条线:选对工具 → 防住异常 → 优化性能。
4.5 总结:缓存优化的三个层次
聊了这么多,其实就三句话:
- 选型层:本地缓存扛热点,分布式缓存保一致,两级缓存是王道。
- 防护层:穿透用布隆过滤器,击穿用互斥锁,雪崩用随机过期时间。
- 优化层:Redis 数据结构选对、内存管好、Pipeline 用起来。