缓存问题详解
一、缓存穿透(查不存在的数据)
问题描述:请求查询数据库中根本不存在的数据(比如ID=-1的数据),导致每次请求都绕过缓存直接访问数据库。
解决方案:
- 缓存空对象:
- 原理:即使数据库查询为空,也把这个”空结果”缓存起来(例如存为
null
) - 示例代码:
1
2
3
4
5
6
7
8
9
10
11// 伪代码示例
Object data = cache.get(key);
if(data == null) {
data = db.query(key);
if(data == null) { // 数据库也没有
cache.set(key, "NULL", 300); // 缓存空值5分钟
} else {
cache.set(key, data, 3600); // 缓存真实数据1小时
}
}
return data.equals("NULL") ? null : data; - 注意事项:要给空值设置较短的TTL(如5分钟),避免占用太多内存
- 原理:即使数据库查询为空,也把这个”空结果”缓存起来(例如存为
- 布隆过滤器(Bloom Filter):
- 数据结构原理:
- 使用一个很大的位数组(bit array)和多个哈希函数
- 插入元素时:用多个哈希函数计算元素的哈希值,将对应位数组位置设为1
- 查询元素时:用同样的哈希函数计算,如果所有对应位都是1,则”可能存在”;如果有任一位是0,则”肯定不存在”
- 特点:
- 空间效率极高(相比HashSet)
- 判断存在时不一定存在,如果判断不存在则一定不存在
- 数据结构原理:
二、缓存击穿(热点key突然失效)
问题描述:缓存击穿是指当一个热点key在缓存中过期时,大量并发请求同时涌入,导致所有请求都直接访问数据库,造成数据库瞬时压力过大的现象。这种情况通常发生在高并发场景下对热点数据的访问。
解决方案:
- 互斥锁方案:
互斥锁方案通过控制只有一个线程能够重建缓存来解决击穿问题。
核心概念解释:
- 互斥锁(Mutex Lock):一种同步机制,确保同一时间只有一个线程可以访问特定资源或执行特定代码段。在分布式系统中,需要使用分布式锁来实现跨进程的互斥。
- 分布式锁:在分布式环境下协调多节点访问共享资源的机制,常用Redis的SETNX命令(SET if Not eXists)实现,确保只有一个客户端能成功获取锁。
具体实现如下:
- 当发现缓存失效时,线程首先尝试获取分布式锁
- 获取锁成功的线程负责查询数据库并更新缓存
- 其他线程等待锁释放后直接从缓存获取数据
- 为避免死锁,需要设置合理的锁超时时间
- 逻辑过期方案:
将过期时间存储在value中来实现热点key物理永不过期,逻辑判断过期:
- 缓存数据包含实际数据和逻辑过期时间
- 读取时检查逻辑过期时间
- 如果数据已逻辑过期,触发异步更新
- 始终返回数据,可能是脏数据
数据结构示例:
1 | { |
方案对比
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
互斥锁 | 强一致性,不会返回旧数据 | 等待锁的线程会有延迟,实现较复杂 | 对一致性要求高的场景 |
逻辑过期 | 无等待时间,性能更好 | 可能返回脏数据,实现复杂 | 高并发且允许短暂不一致的场景 |
在实际应用中,可以根据业务需求选择合适的方案,或者组合使用这两种方案。对于核心业务数据可以采用互斥锁方案保证强一致性,对于非核心数据可以采用逻辑过期方案提高性能。
三、缓存雪崩(大量key同时失效)
问题描述:大量缓存key在同一时间过期,或Redis服务宕机,导致所有请求涌向数据库。
解决方案:
差异化过期时间:
1
2
3// 基础过期时间 + 随机偏移量(0-300秒)
int expireTime = 3600 + (int)(Math.random() * 300);
cache.set(key, value, expireTime);多级缓存架构:
1
用户请求 → CDN缓存 → 前端本地缓存 → 应用级缓存(如Caffeine) → 分布式缓存(Redis) → 数据库