2. 缓存基础:缓存穿透、缓存击穿、缓存雪崩
在数据库工程中,缓存层是抵御高并发读压力的第一道防线。然而,当缓存与数据库之间的数据一致性或访问模式出现异常时,会引发三种典型的缓存故障:缓存穿透、缓存击穿和缓存雪崩。理解它们的定义、成因及经典解决方案,是构建高可用缓存系统的前提。
2.1 三种缓存故障的定义与成因
| 故障类型 | 定义 | 核心成因 | 影响范围 |
|---|---|---|---|
| 缓存穿透 | 请求查询一个缓存和数据库中都不存在的数据,导致每次请求都直接打到数据库。 | 恶意攻击(如使用不存在的ID)、业务逻辑漏洞(如查询空值未做缓存)。 | 数据库连接数飙升,可能拖垮数据库。 |
| 缓存击穿 | 某个热点Key在缓存中过期失效的瞬间,大量并发请求同时穿透缓存,直接访问数据库。 | 热点数据过期时间设置不当;高并发场景下单个Key失效。 | 数据库瞬间压力激增,可能导致数据库宕机。 |
| 缓存雪崩 | 在同一时刻,大量缓存Key同时过期失效,或缓存节点宕机,导致海量请求涌入数据库。 | 批量Key设置了相同的过期时间;缓存服务集群故障。 | 数据库被压垮,引发系统级雪崩。 |
关键区分:穿透是“查不到”,击穿是“单个热点过期”,雪崩是“大量Key同时过期或缓存不可用”。
2.2 经典解决方案对比
| 故障类型 | 解决方案 | 实现要点 | 适用场景 |
|---|---|---|---|
| 缓存穿透 | 1. 缓存空对象 | 将数据库查询为null的结果也写入缓存,设置较短的过期时间(如30-60秒)。 | 空值查询频率较高,且数据更新不频繁的场景。 |
| 2. 布隆过滤器 | 在缓存层之前加一层布隆过滤器,判断请求的Key是否一定不存在,直接拦截非法请求。 | 数据量极大,且Key集合相对固定的场景(如用户ID、商品ID)。 | |
| 缓存击穿 | 1. 互斥锁(Mutex Key) | 当缓存失效时,只允许一个线程去数据库加载数据,其他线程等待或重试。 | 并发量极高,且对一致性要求严格的场景。 |
| 2. 逻辑过期 | 缓存中存储数据的逻辑过期时间,后台异步线程负责更新缓存,避免热点Key在高峰期失效。 | 允许短暂的数据不一致,追求极致性能的场景。 | |
| 缓存雪崩 | 1. 过期时间打散 | 在基础过期时间上增加一个随机值(如1-5分钟),避免大量Key同时过期。 | 批量加载缓存数据的场景(如定时任务预热)。 |
| 2. 缓存集群高可用 | 使用Redis Sentinel或Redis Cluster,避免单点故障;配合本地缓存(如Caffeine)做二级缓存。 | 对缓存服务可用性要求极高的生产环境。 |
2.3 缓存穿透、击穿、雪崩的请求流程对比
下图展示了三种故障在请求链路中的不同表现位置,帮助理解其本质差异。
2.4 代码示例:互斥锁解决缓存击穿
以下是一个使用Redis分布式锁(SETNX)防止缓存击穿的典型实现。当缓存失效时,只有一个线程能获取锁并加载数据,其他线程等待或返回降级数据。
public String getProductById(String productId) {
// 1. 先从缓存中获取
String product = redis.get("product:" + productId);
if (product != null) {
return product;
}
// 2. 缓存未命中,尝试获取分布式锁
String lockKey = "lock:product:" + productId;
boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 3. 双重检查:获取锁后再次检查缓存
product = redis.get("product:" + productId);
if (product != null) {
return product;
}
// 4. 从数据库加载数据
product = productDao.getById(productId);
if (product != null) {
redis.set("product:" + productId, product, 300, TimeUnit.SECONDS);
} else {
// 缓存空对象,防止穿透
redis.set("product:" + productId, "", 60, TimeUnit.SECONDS);
}
return product;
} finally {
// 5. 释放锁
redis.del(lockKey);
}
} else {
// 6. 未获取到锁,等待重试或返回降级数据
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductById(productId); // 递归重试
}
}
工程建议:互斥锁方案适用于并发量极高且对数据一致性要求严格的场景。如果允许短暂的不一致,可以考虑“逻辑过期”方案,由后台线程异步更新缓存,避免锁竞争带来的性能损耗。
2.5 缓存雪崩的预防策略
缓存雪崩的破坏力最大,预防策略需要从“过期时间”和“缓存架构”两个维度同时入手。
- 过期时间打散:在设置缓存过期时间时,在基础时间上增加一个随机偏移量(如基础时间 + random(1min, 5min)),避免大量Key在同一时刻失效。
- 多级缓存:使用本地缓存(如Caffeine)作为一级缓存,Redis作为二级缓存。即使Redis集群故障,本地缓存仍能提供部分数据服务。
- 缓存预热 + 慢加载:在业务低峰期提前加载热点数据,并控制数据库加载的并发度,避免缓存失效时数据库瞬间被打满。
- 集群高可用:部署Redis Sentinel或Redis Cluster,确保缓存服务不因单点故障而整体不可用。
注意:缓存雪崩的预防需要结合业务场景。例如,对于秒杀系统的商品库存缓存,除了打散过期时间外,还应配合限流和熔断机制,保护数据库不被突发流量冲垮。