在我们日常使用redis开发中,缓存穿透、击穿和雪崩都是无法避免的问题。这也是我们在面试中经常被问到的一个问题。接下来,我们将解释这三种问题的各种解决方案。
缓冲区击穿
缓存击穿意味着 Key 非常热,并且在某些时间点以高并发访问。当Key在故障时刻突破缓存(Redis),直接向数据库(DB)请求,导致数据库出现问题。
解决方案一:使用互斥锁
这个方案的思路比较简单,就是只允许一个线程查询数据库,而其他线程等待查询数据库的线程执行完毕重新将数据添加到缓存中,其他线程可以获取数据从缓存中。
如果是单机系统,可以用synchronized或者lock来处理。分布式系统可以使用redis的setnx操作。
独立环境
- 单机环境下的实现原理是当缓存数据过期时,大量请求进来,只有第一个线程可以访问数据库,其他线程暂停。当主线程查询数据并释放锁时,其他线程可以直接读取缓存中的数据。
public String get(key){
//从缓存中获取数据
String value = redis.get(key);
if(value == null){ //缓存中的数据不存在
i()){ //获取锁
//从数据库中获取数据
value=db.get(key);
//更新缓存数据
if(value!=null){
redis.set(key, value, expire_secs);
}
//释放锁
reenLock.unlock();
}else{ //获取锁失败
//暂停100ms再取数据
T(100);
值 = redis.get(key);
}
}
}
分布式环境
- 当缓存失败时,首先判断该值为空。与其立即检查数据库,不如先使用缓存工具的一些操作,以操作成功的返回值(如Redis的setnx)设置一个互斥键。当操作返回成功时,然后检查数据库并重置缓存。否则,重试整个 get cached 方法。
public String get(key){
//从缓存中获取数据
String value = redis.get(key);
if(value == null){ //表示缓存值已经过期
//设置3分钟的超时时间,防止下次缓存过期删除操作失败时检查数据库
i(key_mutex,1, 3*60) == 1){ //=1 表示设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
}else{ //此时表示其他线程同时加载了db并设置回缓存。此时再次尝试获取缓存值
sleep(50);
获取(键);//重试
}
}否则{
返回值;
}
}
方案二:热点数据永不过期
需要注意的是,这里所说的永不过期并不是要将热点数据的生命周期设置为无限制。而是将过期时间存储在 key 对应的 value 中。如果发现过期,则通过后台异步线程重建缓存。
从实用的角度来看,这种方法对性能非常友好。唯一的缺点是在重建缓存时,其他线程(不重建缓存的线程)可能会访问到旧数据,但对于一般的互联网功能来说还是可以忍受的。
public String get(Sting key){
V v = redis.get(key);
字符串值 = v.getValue();
长超时 = v.getTimeout();
if <= Sy()){
// 异步更新后台异常执行
(new Runnable(){
public void run(){
String keyMutex = "mutex:" + key;
if(redis. setnx(keyMutex, "1")){
//3 分钟超时以避免互斥锁持有者崩溃
redis.expire(keyMutex, 3 * 60);
String dbValue = db.get(key);
redis.set(key, dbValue);
redis.delete(keyMutex);
}
}
});
}
返回值;
}
解决方案3:定期刷新
在后台定义一个作业(计划任务)来主动更新缓存的数据。例如,如果某个缓存中数据的过期时间为 30 分钟,则作业会每隔 29 分钟定期刷新一次数据(将从数据库中找到的数据更新到缓存中)。
这种方案很容易理解,但是会增加系统的复杂度。比较适合那些比较固定的按键。对于缓存粒度大的服务,key分散的不适合,实现也比较复杂。
缓存穿透
缓存穿透是指用户恶意发起大量请求,查询redis和数据库中不可用的数据。为了容错,如果无法从数据库(DB)中找到数据,则不会将其写入redis。这会导致每个请求都在数据库(DB)中进行查询,从而失去缓存的意义,导致数据库因压力过大而挂起。
方案一:缓存空数据
缓存空数据是一种简单粗暴的方法。如果一个查询返回的数据是空的(无论是数据不存在还是系统故障),我们仍然缓存空结果,但是它的过期时间会很短,不会超过五分钟。
虽然这种方法可以拦截大量的穿透请求,但是这个空值并没有什么实际的业务。而且,如果发送大量对不存在数据的渗透请求(如恶意攻击),会浪费缓存空间。如果这个null值过大,它会消除自己缓存中的数据,从而降低我们的缓存命中率。
//伪代码
public object GetProductListNew() {
int cacheTime = 30;
字符串 cacheKey = "product_list";
String cacheValue = Cac(cacheKey);
if (CacheValue != null) {
return cacheValue;
}
cacheValue = Cac(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
//数据库无法查询,为空
cacheValue = GetProductListFromDB();
if (cacheValue == null) {
//如果发现为空,设置一个默认值并缓存
cacheValue = ;
}
//product_list,空值,过期时间
Cac(cacheKey, cacheValue, cacheTime);
返回缓存值;
}
}
解决方案 2:布隆过滤器
该技术在缓存之前增加了一层屏障,它存储了当前数据库中存在的所有密钥。当业务系统有查询请求时,首先查询该key是否存在于BloomFilter中。如果不存在,则表示数据库中不存在该数据,所以不检查缓存,直接返回null。如果存在,继续后续流程,先在缓存中查询,如果缓存中不存在,再在数据库中查询。
//伪代码
String get(String key) {
String value = redis.get(key);
if (value == null) {
if(!bloom(key)){
//如果不存在则返回
return null;
}else{
//如果可能,检查数据库
value = db.get(key);
redis.set(键,值);
}
}
返回值;
}
缓存雪崩
缓存雪崩是指缓存服务器重启或者缓存(Redis)中的数据同时大量过期。由于查询数据量大,数据库压力过大,甚至宕机。
解决方案一:锁队列
锁定队列只是为了减轻数据库的压力,并不能提高系统的吞吐量。假设在高并发下重建缓存时key被锁定,最后1000个请求中有999个被阻塞。也会导致用户等待超时,这是治标不治本的方法!
注意:高并发场景下,尽量不要使用!
//伪代码
public object GetProductListNew() {
int cacheTime = 30;
字符串 cacheKey = "product_list";
字符串锁键 = 缓存键;
String cacheValue = Cac(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
synchronized(lockKey) {
cacheValue = Cac(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
//这个一般是sql查询数据
cacheValue = GetProductListFromDB();
Cac(cacheKey, cacheValue, cacheTime);
}
}
返回缓存值;
}
}
方案二:设置过期时间的随机值
避免为缓存设置类似的有效期,并为有效期添加一个随机值(1-5分钟),以使故障时间均匀分布。这样,每个缓存的过期时间的重复率就会降低,很难引起集体失效事件。
redis.set(键,值,随机);
解决方案3:设置过期标志更新缓存
缓存标志:记录缓存数据是否过期。如果已经过期,会触发另一个线程在后台更新实际key的缓存;
缓存数据:其过期时间是缓存标记的两倍。例如,标记缓存时间为 30 分钟,数据缓存设置为 60 分钟。这样,当缓存标记键过期时,实际缓存可以将旧数据返回给调用者,直到另一个线程完成后台更新后才会返回新缓存。
//伪代码
public object GetProductListNew() {
int cacheTime = 30;
字符串 cacheKey = "product_list";
//缓存标签
String cacheSign = cacheKey + "_sign";
字符串符号 = Cac(cacheSign);
//获取缓存值
String cacheValue = Cac(cacheKey);
if (sign != null) {
return cacheValue; //没有过期,直接返回
} else {
Cac(cacheSign, "1", cacheTime);
T((arg) -> {
//这个一般是sql查询数据
cacheValue = GetProductListFromDB();
//日期设置为脏读缓存时间的两倍
Cac(cacheKey, cacheValue, cacheTime * 2);
});
返回缓存值;
}
}
概括
- 缓冲区崩溃
key对应的数据存在,但是在redis中过期了。这时候如果有大量的并发请求来,如果这些请求发现缓存过期了,一般会从后端的db加载数据,设置回缓存。这时,大并发请求可能会瞬间压垮后端数据库。这个问题一般通过互斥,热点数据永不过期,定期刷新过期时间来解决。
- 缓存穿透
数据源中不存在key对应的数据。每次从缓存中获取不到这个key的请求时,都会将请求发送到数据源,可能会压垮数据源。例如,使用一个不存在的用户id获取用户信息,无论是在缓存中还是在数据库中,如果黑客利用这个漏洞进行攻击,可能会破坏数据库。一般通过缓存空数据和布隆过滤器来解决这个问题。
- 缓存雪崩
当缓存服务器重启或者某段时间内大量缓存失效时,也会给后端系统(比如DB)带来很大的压力。这个问题通常通过锁定队列、设置过期时间和随机值来解决。