Redis 缓存击穿、穿透、雪崩的产生原因以及解决思路

转自:
https://mp.weixin.qq.com/s/c5CMTs0qZS31XBU4oBJniw
https://mp.weixin.qq.com/s/D5ycZviQZQpYXxC7uPEPFA

大家都知道,计算机的瓶颈之一就是 I/O,为了解决内存与磁盘速度不匹配的问题,就产生了缓存,将一些热点数据放在内存中,随用随取,以降低连接到数据库的请求,避免数据库挂掉。

需要注意的是,无论是击穿还是后面谈到的穿透与雪崩,本质上都是在高并发的前提下,缓存中的热点 key 失效了。

缓存击穿

关键字:单一热点数据、高并发、数据失效。

在这里插入图片描述

造成缓存击穿问题,有两个主要原因。

  • key 过期。在 Redis 中,key 有过期时间,如果在某一时刻(假如商城做活动,零点开始)key 失效了,那么零点之后对某一个商品的查询请求将全都压到数据库上,导致数据库崩了
  • key 被页面置换淘汰。内存是有限的,要时时刻刻缓存新的数据,淘汰旧的数据,所以在一定的页面置换策略中,如果某些商品在做活动之前无人问津,那么势必会被淘汰

解决方案

  • 过期时间 + 随机值。对于热点数据,我们不设置过期时间,这样就可以把请求都放在缓存中处理,充分把 Redis 高吞吐量性能利用起来。或者对过期时间再加一个随机值
  • 预热。预先把热门数据提前存入 Redis 中,并设置热门数据的过期时间为超大值
  • 使用锁。当发现缓存失效的时候,不是立即从数据库加载数据,而是先尝试获取分布式锁,只有获取锁成功才执行数据库查询和写数据到缓存的操作,获取锁失败则说明当前有线程在执行数据库查询操作,让当前线程睡眠一段时间后在重试,这样只让一个请求去数据库读取数据

正常的处理请求如下图所示。

在这里插入图片描述

key 的过期在所难免,当高流量来到 Redis 时,根据 Redis 的单线程特性,可以认为任务是在队列里依次执行的,当请求到达 Redis 时发现key 过期,进行一个操作:设置锁。

  1. 请求到达 Redis,发现 Redis key 过期,查看有没有获取锁,如果没有则回到队列后面排队
  2. 设置锁,注意此时应该是 setnx(),而不是 set(),因为可能有其他线程已经设置锁了
  3. 获取锁,拿到锁了就去数据库取数据,请求返回后再释放锁

图片

public Object getData(String id) {
    String desc = redis.get(id);
        // 缓存为空,过期了
        if (desc == null) {
            // 互斥锁,只有一个请求可以成功
            if (redis(lockName)) {
                try
                    // 从数据库取出数据
                    desc = getFromDB(id);
                    // 写到 Redis
                    redis.set(id, desc, 60 * 60 * 24);
                } catch (Exception ex) {
                    LogHelper.error(ex);
                } finally {
                    // 确保最后删除,释放锁
                    redis.del(lockName);
                    return desc;
                }
            } else {
                // 否则睡眠200ms,接着获取锁
                Thread.sleep(200);
                return getData(id);
            }
        }
}

如果获得锁并去拿数据的请求突然挂了怎么办?也就是锁没有释放,但其他进程都在等锁。

解决办法是:对锁设置一个过期时间,如果到达了过期时间还没释放就自动释放。

如果是锁超时呢?也就是在设定的时间里数据没有取出来,锁又过期了。

常见的思路是,将锁的过期时间值递增,但是想想就不靠谱,因为第一个请求可能超时,如果后面的请求也超时呢?在接连多次超时之后,锁的过期时间值势必会变得特别大,这样做弊端太多。

另一个思路是,再开启一个线程进行监控,如果取数据的线程没有挂掉,就适当得延迟锁的过期时间。

在这里插入图片描述

缓存穿透

缓存穿透的主要原因是有很多请求都在访问数据库中不存在的数据(例如一个卖书的商城一直被请求查询茶叶产品)。由于 Redis 缓存主要是用来缓存热点数据,对于数据库中不存在的数据是没办法缓存的,这种异常流量就会直接到达数据库并且返回"没有"的查询结果。

解决方案

  • 缓存空值。当请求的数据不在 Redis 中并且数据库中也不存在的时候,设置一个缺省值(比如:None),当后续再次进行查询时则直接返回空值或者缺省值
  • 对访问请求加一层过滤器,例如布隆过滤器、增强版布隆过滤器或者布谷鸟过滤器。在数据写入数据库的同时将这个 id 同步到过滤器中,当请求的 id 不在过滤器中则说明该请求查询的数据一定没有在数据库中保存,就不要再去数据库查询了
  • 除了使用过滤器,还可以增加一些参数检验。例如数据库的数据 id 一般都是递增的,如果请求 id = -10 这种参数,势必会绕过 Redis,要避免这种情况,可以进行参数的真实性检验等操作

图片

缓存雪崩

缓存雪崩,和缓存击穿类似,不同的是缓存击穿是一个热点 key 在某一时刻失效,而缓存雪崩则是大量的热点 key 在一瞬间失效。

缓存雪崩是发生在大量数据同时失效的场景,而缓存击穿(失效)则是在某个热点数据失效的场景,这是它们最大的区别。

网络上很多博客都在强调解决缓存雪崩的策略是设置随机过期时间,其实这个非常不准确。举个例子,银行在做活动,之前这个利息系数为 2%,过了零点系数将改为 3%,这种情况下能直接将用户对应的 key 改为随机过期吗?明显不可以,同样存钱,你存到年底利息是 200 万,别人却是 300 万。

正确的思路是,首先要看看这个 key 过期是不是和时点性有关。如果和时点性无关,则可以通过设置随机过期时间来解决;如果和时点性有关,例如上面说的银行某一天改变某系数的场景,那么就要利用强依赖击穿方案。大致策略是让先过去的线程更新一下所有 key,在后台更新热点 key 的同时,业务层将进来的请求延时一下,例如短暂地睡眠几毫秒或者几秒,给后面的更新热点 key 分散压力。

图片

解决方案

  • 过期时间添加随机值。这样一来,就不会导致在同一时刻热点数据全部失效,同时过期时间的差别也不会太大,既保证了在相近时间内失效,又能满足业务需求
  • 接口限流。限流,就是指我们在业务系统的请求入口前端控制每秒进入系统的请求数,避免过多的请求被发送到数据库。当访问的不是核心数据的时候,在查询方法上加上接口限流保护,比如设置 10000 req/s;如果访问的是核心数据接口,当缓存不存在时允许从数据库中查询并设置到缓存中。这样一来,就只有部分请求会发送到数据库,减轻了压力

Redis 故障宕机

一个 Redis 实例能支撑 10 万的 QPS,而一个数据库实例只有 1000 QPS。一旦 Redis 宕机,会导致大量的请求打到数据库上,从而发生缓存雪崩。

对于因缓存系统故障从而导致的缓存雪崩的解决方案有两种。

  • 服务熔断和接口限流
  • 构建高可用的缓存集群系统

服务熔断和限流

在业务系统中,针对高并发的场景,使用服务熔断来有损提供服务,从而保证系统的可用性。

服务熔断就是当从缓存获取数据发生异常时,直接返回错误数据给前端,防止所有的流量都打到数据库上导致宕机。

服务熔断和限流属于是在已经发生了缓存雪崩的情况下,思考如何降低缓存雪崩对数据库造成的影响的方案。

构建高可用的缓存集群

缓存系统构建一套 Redis 高可用集群,比如 Redis 哨兵集群或者 Redis Cluster 集群,这样即使 Redis 的主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务,避免了由于缓存实例宕机从而导致的缓存雪崩问题。

总结

缓存击穿(失效)指的是数据库有数据,缓存本应该也有数据,但是由于缓存过期了,导致 Redis 这层流量防护屏障被击穿了,请求直奔数据库。

缓存穿透指的是数据库本来就没有这个数据,请求直奔数据库,缓存系统形同虚设。

缓存雪崩指的是大量的热点数据无法在 Redis 缓存中处理(大面积的热点数据缓存失效、Redis 宕机),流量全部打到数据库上,给数据库造成极大的压力。