使用redis中引来了常见的三种缓存问题,本篇就常见的解决方案来展开分析。

🎨本篇脑图速览

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_redis

🎯缓存穿透

缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

所谓穿透,就是直接透过了redis,直接透到数据库

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

常见的解决方案有两种:

  • 缓存空对象
  • 优点:实现简单,维护方便
  • 缺点:
  • 额外的内存消耗
  • 可能造成短期的不一致
  • 布隆过滤
  • 优点:内存占用较少,没有多余key
  • 缺点:
  • 实现复杂
  • 存在误判可能

对空值缓存

对空值缓存:如果一个查询返回的数据为空(不管是数据是否不存在),我们仍然把这个空结果(null)进行缓存,设置空结果的过期时间会很短,最长不超过五分钟。之后再访问这个数据将会从缓存中获取,保护了后端数据源;

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_缓存_02

  • 但是这种方法会存在两个问题: 1、如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键; 2、即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响

布隆过滤器

采用布隆过滤器:(布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的**二进制向量(位图)**和一系列随机映射函数(哈希函数)。

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_redis_03

布隆过滤器可以用于检索一个元素是否在一个集合中:

  1. 如果布隆过滤器判断存在,则放行【可能会误判】,后续过程跟普通查询redis过程是一样的
  2. 不存在,则直接返回,不走redis

所有可能存在的数据哈希到一个足够大的bitmaps中,一定不存在的数据会被这个bitmaps拦截掉,从而避免了对底层存储系统的查询压力。

  • 它的优点是空间效率和查询时间都远远超过一般的算法
  • 缺点是有一定的误识别率和删除困难。

误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突,数据x和数据y的哈希结果一样,如果只有x,判断y是否存在的时候,由于跟x的哈希值一样,导致布隆过滤器误以为y也存在

布隆过滤器的数据结构

布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。

布隆过滤器会通过 3 个操作完成标记:

  • 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  • 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;

举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_布隆过滤器_04

在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中

注意:此处为什么需要3个hash函数?

若只有1个hash函数,冲突的概率是很大的,都hash到同一个位置,导致误判的概率很大 因此使用多个hash函数,hash到多个位置,只有这几个位置都是1,才说明x存在,误判的概率会降低些

布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据

本质上是一个很大的位图,存储值的时候,用多个hash函数计算出他要存储的位置,比如x,对应hash后的结果是1,2,4, 把这几个位置标记为1。

查询的时候,对x做多次hash,只有所有hash后的位置标记位都是1,才可能存在

  • 若有一个为0,则肯定不存在。

为什么是可能存在?

因为可能x被删除后,又进来了一个y,y的hash结果跟x一模一样,此时会出现误判。

布隆过滤器为什么不好删除元素?

比如现在要删除x,对x做hash,找到了他的存储位置分别是【1,3,9】,我们如果直接把这三个位置改为0,可能会导致“删除”了其他元素

  • 比如y他的存储位置是【2,3,8】,x跟y共用了3这个位置,把3这个位置改为0,会导致y也被“删除”了,根据上边的原理发现不是全1了

🎈如何解决呢?

最简单的做法就是加一个计数器,就是说位数组的每个位如果不存在就是0,存在几个元素就存具体的数字,而不仅仅只是存1

那么这就有一个问题,本来存1,一位就可以满足了,但是如果要存具体的数字,可能需要更多的位数,所以带有计数器的布隆过滤器会占用更大的空间

布隆过滤器的典型应用

  • 数据库防止穿库。 Google Bigtable,HBase 和 Cassandra 以及 Postgresql 使用BloomFilter来减少不存在的行或列的磁盘查找。避免代价高昂的磁盘查找会大大提高数据库查询操作的性能。
  • 业务场景中判断用户是否阅读过某视频或文章,比如抖音或头条,当然会导致一定的误判,但不会让用户看到重复的内容。
  • 缓存宕机、缓存击穿场景,一般判断用户是否在缓存中,如果在则直接返回结果,不在则查询db,如果来一波冷数据,会导致缓存大量击穿,造成雪崩效应,这时候可以用布隆过滤器当缓存的索引,只有在布隆过滤器中,才去查询缓存,如果没查询到,则穿透到db。如果不在布隆器中,则直接返回。
  • WEB拦截器,如果相同请求则拦截,防止重复被攻击。用户第一次请求,将请求参数放入布隆过滤器中,当第二次请求时,先判断请求参数是否被布隆过滤器命中。可以提高缓存命中率。Squid 网页代理缓存服务器在 cache digests 中就使用了布隆过滤器。Google Chrome浏览器使用了布隆过滤器加速安全浏览服务
  • Venti 文档存储系统也采用布隆过滤器来检测先前存储的数据。
  • SPIN 模型检测器也使用布隆过滤器在大规模验证问题时跟踪可达状态空间。

其他手段

设置可访问的名单(白名单)

使用bitmaps类型定义一个可以访问的名单,名单id作为bitmaps的偏移量,每次访问和bitmap里面的id进行比较,如果访问id不在bitmaps里面,进行拦截,不允许访问。

进行实时监控

当发现Redis的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务。

小总结

缓存穿透产生的原因是什么?

  • 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力

缓存穿透的解决方案有哪些?

  • 缓存null值
  • 布隆过滤
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

🎯缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_布隆过滤器_05

使用互斥锁

因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用 **tryLock方法 + double check **来解决这样的问题。

锁粒度

只有查询缓存没有命中的情况下,才去加锁

假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行更新缓存的逻辑 假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就进入休眠,隔一段时间后再**重试【再次调用自己】**直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_redis_06

设置key永不过期

我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间【永不过期】,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。

我们把过期时间设置在 redis的value中

注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。

假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程A会开启一个 线程去进行重构数据的逻辑直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。

这种方案巧妙在于,异步的构建缓存【新开一个线程】

缺点在于在构建完缓存之前,返回的都是脏数据

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_redis_07

🎈对比

前者是牺牲了可用性,保证了一致性 后者是牺牲了一致性,保证了可用性 CAP

互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗 缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响

逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回旧数据,且实现起来麻烦

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_数据_08

提前预热

定时任务,提前从数据库查询出来,存到缓存里边,而不是等到用户高并发访问了,再去查询数据库,设置缓存

  • 之后等过了高峰期之后再删除无用无效缓存

🎯缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

大量key失效

  1. 不同的Key的TTL添加随机值

分散,不会同时过期,一起失效

  1. 给业务添加多级缓存

nginx缓存 + redis缓存 +其他缓存(ehcache等)

Redis宕机

利用Redis集群提高服务的可用性

如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。

给缓存业务添加降级限流策略

降级

由于爆炸性的流量冲击,对一些服务进行有策略的放弃,以此缓解系统压力,保证目前主要业务的正常运行。它主要是针对非正常情况下的应急服务措施:当此时一些业务服务无法执行时,给出一个统一的返回结果

降级方式

  • 延迟服务:比如发表了评论,重要服务,比如在文章中显示正常,但是延迟给用户增加积分,只是放到一个缓存中,等服务平稳之后再执行。
  • 在粒度范围内关闭服务(片段降级或服务功能降级):比如关闭相关文章的推荐,直接关闭推荐区
  • 页面异步请求降级:比如商品详情恢复有推荐信息/配送至等异步加载的请求,如果这些信息响应慢或者后端服务有问题,可以进行降级;
  • 页面跳转(页面降级):比如可以有相关文章推荐,但是更多的页面则直接跳转到某一个地址
  • 写降级:比如秒杀抢购,我们可以只进行Cache的更新,然后异步同步扣减库存到DB,保证最终一致性即可,此时可以将DB降级为Cache。
  • 读降级:比如多级缓存模式,如果后端服务有问题,可以降级为只读缓存,这种方式适用于对读一致性要求不高的场景

熔断

在固定时间窗口内,接口调用超时比率达到一个阈值,会开启熔断。

【当某服务出现不可用或响应超时的情况时,为了防止整个系统出现雪崩,暂时停止对该服务的调用。】

当经过了规定时间之后,服务将从熔断状态恢复过来,再次接受调用方的远程调用。

关于降级和限流,后续我们再单独开专栏来详细谈谈

🎯总结

redis_cli Redis 中所有非空数据库的大小 redis缓存空数据_redis_09