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;
}
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)); // 缓存未命中时自动加载
}
}
本地缓存 vs 分布式缓存
这个问题其实没有标准答案。我根据项目规模来选。
| 特性 | 本地缓存 | 分布式缓存 |
|---|---|---|
| 速度 | 极快(内存访问) | 较快(网络IO) |
| 容量 | 受限于单机内存 | 可水平扩展 |
| 一致性 | 各节点数据不一致 | 全局一致 |
| 典型实现 | Caffeine, Guava Cache | Redis, Memcached |
- 单机应用或延迟要求极高的场景,用本地缓存。比如配置信息、字典数据。
- 多实例部署、需要数据共享的场景,用分布式缓存。
- 最佳实践:本地缓存做一级缓存,分布式缓存做二级缓存。本地缓存扛高频访问,分布式缓存做数据同步。
我曾经在一个金融项目中用过两级缓存。本地缓存用 Caffeine,过期时间30秒;分布式缓存用 Redis,过期时间10分钟。热点数据从本地缓存拿,冷数据从 Redis 拿。效果非常好,数据库 QPS 从 5000 降到了 200。
知识体系总览
下面这张图把缓存可靠性的核心问题串起来了。你可以把它当作一个检查清单,设计缓存方案时对照着看。
公众号:蓝海资料掘金营,微信deep3321