缓存中的穿透,并发及雪崩

背景

         使用缓存的时候,最常见的场景莫过于查询缓存是否存在,如果不存在就去db取,然后再设置本地缓存, 如果缓存里就直接返回。但这种做法在高并发的情况下会出现缓存穿透,缓存并发,缓存雪崩问题。

伪代码如下:

if val := cache.get("key"); val == nil {
      val = db.read("key");
      cache.set("key",value, expiretime);
      return val;  
} else {
      return val;
}

缓存穿透

缓存穿透常见于客户端的恶意行为(注意是主观恶意还是过失恶意),一般场景下,查询db的开支(各方面:cpu,memory,time,brandwidth等),我们都可以认为是远大于查询缓存的(这也是为啥出现缓存的原因,引入了可靠性低的不一致因素,还是要用缓存),而恶意性查询则是查询没有结果的东西(往往还是轮询),则造成大量的无效请求传到了db层,最后引起db不堪重负挂掉,或者导致其它正常查询难以进行。

我们在设计缓存的时候,有两个基本逻辑

1.只会缓存已经存在的数据,而不存在的数据是不会缓存的

2.查询某个键不存在,则在db中读取并重建缓存

但是这里有个问题,假设某个客户端查询的数据本身就是不存在的,则按照逻辑1,该数据永远不存在就永远不会缓存起来;按照逻辑2,因为缓存不存在,每次都得透过缓存去查询db确实是否存在,并是否可以重建缓存。

解决思路

1.请求参数检查: 客户端传来的任意参数都认为是不可靠的,必须校验。 

对客户端传来的查询参数提前进行检查:例如,业务数据库设计时,所有的用户id都是正整数,则直接丢弃查询id为负数和小数,乃至不是数字的查询。

2.延期失效:这种场景是参数是合法的,但是对于db是没有意义的。

例如:业务数据库设计上用户id是从1开始的自增长uint,当前系统有100个用户,而客户端不断传来查询id=1000的用户的请求,此时id是合法的,然后却不符合数据库的实际情况,最后仍然是导致缓存层被穿透,db要做一次没有结果的查询。

这种场景没有统一的解决思路,每发生一次无效的缓存穿透,则让相同参数的缓存查询在一定时间内无法发生第二次,具体伪代码如下:

if val := cache.get("id:1000"); val == nil {
    val = db.read("id:1000"); 
    if val == nil {
        cache.set("id":1000, null, time.second*180);//这里的null要随业务逻辑而具体设定是什么值,null只作为区分
    } else  {
        cache.set("id:1000", val, expiretime);
    }
    return val
}
return val;

一定量的数据库查询是正常的, 缓存层要解决的是异常的,多余的,没必要的查询,千万别矫枉过正。

3.集合set查询

同样是查询参数本身是合法的,但对于数据库没有意义的场景,还有另外一种思路,把所有合法的用户id存到一个set集合中,在校验参数的同时,检查请求参数中的用户id是否存在这个set中,如果存在则继续,不存在则直接返回。

这种实现需要注意如果查询的参数很多,要同步所有可用参数的结果,业务逻辑上做起来并不容易。(布隆过滤器可以处理这种bigset的场景,但有千万之一的误差, 想了解的可以google)

缓存并发

缓存并发其实某种程度上也是缓存穿透的问题,但为了细化区分,前文中描述的缓存穿透,本质上是客户端发来的“意外”请求产生的,而这里缓存并发产生的穿透,则往往网站业务并发量大的时候产生的,我们来看看它产生的场景。

按照一般的行为,如果一个key缓存失效了,则会查询db,重新建立缓存设置超时时间。 但如果处于高并发场景下, 1000个请求同时过来请求同一个id,但缓存这时刚好失效,则1000个都透过了缓存去查询db,则此时会凭空产生999个多余的db查询,因为这999个查询只要等一下,等第一个查询完db并顺利写入缓存后,就可以顺利的从缓存拿到数据了。

解决思路

这里可以用锁来锁住,只有一个客户端可以获得锁去查询(使用redis实现分布式锁,可以参考我的redis里的文章)

for {
    if val := cache.get("id:99") ; val == nil {
        if redis.mutex() == true {
            if val = db.read("id:99"); val == nil {
                cache.set("id:99", null, time.second*180);
            } else {
                cache.set("id:99", val, expiretime);
            }
            redis.unmutex();
            return val;
        } else {
            t := math.rand()%500;//睡眠一个随机时间,然后重试
            time.sleep(t);
        }
    } else {   
        return val;
    }
}

通过模拟锁的设计,我们可以尽可能的肖平重建缓存时产生的高并发请求,把?拦截在db层之外了,当然,这里其实可以绕过缓存层,在业务层根据语言特性自己设计锁,信号量等等也都是可以的,好处就是把锁的业务压力从缓存层直接拆走,缺点是,锁的开发并不容易,用不好容易出问题。

缓存雪崩

缓存雪崩也是来自于设计缓存时一个看起来很合理的设计——缓存失效

大多数情况下,缓存都是存放在内存中,而内存的成本要高于硬盘,所以出于成本考虑,我们一般会把热数据放在缓存中,而冷数据放在硬盘中。

那么怎么区分热数据和冷数据呢,最常见的做法莫过于给缓存设置一个过期时间,当某个缓存过期时,则将改缓存逐出缓存服务中;而如果用户又需要访问这个缓存,则重建缓存。

但在高并发的场景下,这里又会产生新的问题,在上面的缓存并发中我们见识了同一个key在失效时,被高并发访问引起db崩溃的场景,那么这里我们就要考虑不同的key刚好在同一个时间失效的场景了。

经常我们设置缓存的时候设置一个固定的时间,特别是系统刚启动的时候短时间内会构建大量的缓存,而这些缓存愉快的工作一段时间后,又会在一个较短的时间内大量的失效,这就导致大量不同的业务的请求透过缓存直面db。

解决思路

上述两个时间点之间的较长时间内(即缓存都处于生效状态时),db层的时间片又比较闲,空闲不是问题,但如果忙起来的时候,会出现忙不过来的现象时,就又要考虑削峰平谷的问题了。(尽可能把不同的缓存重构时间分摊到整个系统的运行周期中,而不是在某个时间段集中处理),简单的处理方式是给缓存的失效时间增加一个不影响大局的随机数。

for {
    if val := cache.get("id:99") ; val == nil {
        if redis.mutex() == true {
            if val = db.read("id:99"); val == nil {
                t := math.rand()%100+80;
                cache.set("id:99", null, t);
            } else {
                t := math.rand()%(expiretime/2) + expiretime/2;
                cache.set("id:99", val, t);
            }
            redis.unmutex();
            return val;
        } else {
            t := math.rand()%500;//睡眠一个随机时间,然后重试
            time.sleep(t);
        }
    } else {   
        return val;
    }
}

小结

缓存是直面业务的服务,所以没有通用的解决方案,而是要根据业务情况具体分析再处理

发布了43 篇原创文章 · 获赞 37 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/qq_28119741/article/details/89644368
今日推荐