关于:缓存穿透、缓存击穿、缓存雪崩、热点数据失效

来自Wikioe
跳到导航 跳到搜索


关于

项目通常会引入NoSQL技术(即:基于内存的数据库,如redis),来应对大数据量的需求,以规避磁盘读/写速度比较慢的问题而存在的性能弊端;
但是,引入redis等又有可能出现缓存穿透,缓存击穿,缓存雪崩等问题,并需要开发对这些问题进行考量并作出应对。

缓存穿透(请求一个“一定不存在的数据”)

缓存穿透:请求一个不存在的数据,缓存和数据库都查不到这个数据,每次都会去数据库查询。

比如:用一个不存在的用户id获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。

解决方案

有很多种方法可以有效地解决缓存穿透问题:

  1. BloomFilter:(“布隆过滤器”)将所有可能存在的数据哈希到一个足够大的bitmap中:每次查询的时候都先去BloomFilter判断,如果没有就直接返回null;如果存在再进行查缓存、DB查询;
  2. 缓存空值:如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),仍然为这些key在缓存中设置对应的值为null
    • 要对这些key设置过期时间,以防止真的有数据;
    //伪代码
    public object GetProductListNew() {
        int cacheTime = 30;
        String cacheKey = "product_list";
    
        String cacheValue = CacheHelper.Get(cacheKey);
        if (cacheValue != null) {
            return cacheValue;
        }
    
        cacheValue = CacheHelper.Get(cacheKey);
        if (cacheValue != null) {
            return cacheValue;
        } else {
            //数据库查询不到,为空
            cacheValue = GetProductListFromDB();
            if (cacheValue == null) {
                //如果发现为空,设置个默认值,也缓存起来
                cacheValue = string.Empty;
            }
            CacheHelper.Add(cacheKey, cacheValue, cacheTime);
            return cacheValue;
        }
    }
    
  • BloomFilter可以结合缓存空值用;
  • 针对于一些恶意攻击,攻击带过来的大量key是不存在的,可以考虑使用BloomFilter进行过滤。

缓存击穿(被大量请求的“单个热点数据”过期)

缓存击穿:大量的请求同时查询同一个key时,此时这个key正好失效了,就会导致同一时间,这些请求都会去查询数据库,这样的现象我们称为缓存击穿。

解决方案

针对“热点数据过期”:

  1. 设置热点数据永不过期:
    • 物理不过期,针对热点key不设置过期时间;
    • 逻辑过期,把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建;


针对“大量请求”:

  1. 采用分布式锁,只有拿到锁的第一个线程去请求数据库,然后插入缓存,当然每次拿到锁的时候都要去查询一下缓存有没有;
    //伪代码
    public object GetProductListNew() {
        int cacheTime = 30;
        String cacheKey = "product_list";
        String lockKey = cacheKey;
    
        String cacheValue = CacheHelper.get(cacheKey);
        if (cacheValue != null) {
            return cacheValue;
        } else {
            synchronized(lockKey) {
                cacheValue = CacheHelper.get(cacheKey);
                if (cacheValue != null) {
                    return cacheValue;
                } else {
                    // 这里一般是sql查询数据
                    cacheValue = GetProductListFromDB(); 
                    CacheHelper.Add(cacheKey, cacheValue, cacheTime);
                }
            }
            return cacheValue;
        }
    }
    

缓存雪崩(缓存数据“集体过期”)

缓存雪崩:当某一时刻发生大规模的缓存失效的情况。

可能出现的情况:

  1. 数据过期时间相同,
  2. 缓存服务宕机。

解决方案

针对服务器宕机:

  1. 采用集群,降低服务宕机的概率;
  2. ehcache本地缓存 + Hystrix限流&降级;【???】
    • ehcache 本地缓存:在 Redis Cluster 完全不可用的时候,ehcache 本地缓存做临时支持;
    • Hystrix进行限流 & 降级 ,比如一秒来了5000个请求,我们可以设置假设只能有一秒 2000个请求能通过这个组件,那么其他剩余的 3000 请求就会走限流逻辑;


针对过期时间相同:

  • 事前:
    1. 均匀过期:设置不同的过期时间。
    2. 分级缓存:第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。
    3. 热点数据缓存永远不过期。
      • 物理不过期,针对热点key不设置过期时间
      • 逻辑过期,把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建
  • 事中:
    1. 互斥锁(或分布式锁):在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量。降低系统的吞吐量,以减小数据库压力。
    2. 使用熔断机制,限流降级:当流量达到一定的阈值,直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上将数据库击垮,至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。

热点数据失效

热点数据失效:缓存的“热点数据”“集体过期”;【击穿 + 雪崩???】

解决方案

针对缓存过期:

  1. 为缓存设置不同的失效时间(原有的失效时间基础上增加一个随机值),避免集体失效;
  2. 采用加锁或队列,来保证不会有大量的线程对数据库一次性进行读写;(缓存击穿的解决办法)
    • 加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。甚至可能带来分布式锁的问题,因此,在真正的高并发场景下很少使用!
  3. 永不失效,就是采用定时任务对快要失效的缓存进行更新缓存和失效时间;
  4. (设置过期标志更新缓存:)
    //伪代码
    public object GetProductListNew() {
        int cacheTime = 30;
        String cacheKey = "product_list";
        //缓存标记
        String cacheSign = cacheKey + "_sign";
    
        String sign = CacheHelper.Get(cacheSign);
        //获取缓存值
        String cacheValue = CacheHelper.Get(cacheKey);
        if (sign != null) {
            return cacheValue;   //未过期,直接返回
        } else {
            CacheHelper.Add(cacheSign, "1", cacheTime);
            ThreadPool.QueueUserWorkItem((arg) -> {
                //这里一般是 sql查询数据
                cacheValue = GetProductListFromDB(); 
                //日期设缓存时间的2倍,用于脏读
                CacheHelper.Add(cacheKey, cacheValue, cacheTime * 2);                 
            });
            return cacheValue;
        }
    }
    
    • 缓存标记:记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际key的缓存;
    • 缓存数据:它的过期时间比缓存标记的时间延长1倍,例:标记缓存时间30分钟,数据缓存设置为60分钟。这样,当缓存标记key过期后,实际缓存还能把旧数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存。

其他缓存概念

缓存预热

缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。

  • 如果不进行预热,那么Redis初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。


缓存预热解决方案:

  1. 数据量不大的时候,工程启动的时候进行加载缓存动作;
  2. 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
  3. 数据量太大的时候,优先保证热点数据进行提前加载到缓存。

缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。 降级的最终目的是保证核心服务可用,即使是有损的。

  • 降级一般是有损的操作,所以尽量减少降级对于业务的影响程度。
  • 而且有些服务是无法降级的(如加入购物车、结算)。


以参考日志级别设置预案:

  1. 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
  2. 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
  3. 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
  4. 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。


服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略。

例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认数据(或访问服务的内存数据)给用户
  • 在项目实战中通常会将部分热点数据缓存到服务的内存中,这样一旦缓存出现异常,可以直接使用服务的内存数据,从而避免数据库遭受巨大压力。