Java笔记 ·

缓存小结(二)

缓存高可用

可以通过部署多个节点,同时设计一些方案让这些节点互为备份。这样,当某个节点故障时,它的备份节点可以顶替它继续提供服务。这些方案就是分布式缓存的高可用方案。主要有客户端方案中间代理层方案服务端方案三大类。

客户端方案

在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。

需要关注缓存的写和读两个方面:

  • 写入数据时,需要把被写入缓存的数据分散到多个节点中,即进行数据分片
  • 读数据时,可以利用多组的缓存来做容错,提升缓存系统的可用性。关于读数据,这里可以使用主从多副本两种策略,两种策略是为了解决不同的问题而提出的。

分片

分片算法常见的是Hash分片算法一致性Hash分片算法两种。

Hash分片算法

Hash分片算法就是对缓存的Key做哈希计算,然后对总的缓存节点个数取余。

如部署三个缓存节点组成一个缓存的集群,当有新的数据要写入时,先对这个缓存的 Key 做比如 crc32 等 Hash 算法生成 Hash 值,然后对 Hash 值模 3,得出的结果就是要存入缓存节点的序号。

最大的优点就是简单易理解,缺点是当增加或者减少缓存节点时,缓存总的节点个数变化造成计算出来的节点发生变化,从而造成缓存失效不可用

该方案适用于对缓存命中率不敏感的情况,如下面还有另外一层缓存来兜底的情况下。

一致性Hash分片算法

该算法中将整个 Hash 值空间组织成一个虚拟的圆环,然后将缓存节点的 IP 地址或者主机名做 Hash 取值后,放置在这个圆环上。当我们需要确定某一个 Key 需要存取到哪个节点上的时候,先对这个 Key 做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上“行走”,遇到的第一个缓存节点就是要访问的节点。

在增加和删除节点时,只有少量的 Key 会“漂移”到其它节点上,而大部分的 Key 命中的节点还是会保持不变,从而可以保证命中率不会大幅下降

其不足:

  • 缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。
  • 一致性 Hash 算法的脏数据问题

对于前者,可以引入虚拟节点的概念。它将一个缓存节点计算多个 Hash 值分散到圆环的不同位置,这样既实现了数据的平均,而且当某一个节点故障或者退出的时候,它原先承担的 Key 将以更加平均的方式分配到其他节点上,从而避免雪崩的发生。

对于后者,如何产生脏数据:在集群中有两个节点 A 和 B,客户端初始写入一个 Key 为 k,值为 3 的缓存数据到 Cache A 中。这时如果要更新 k 的值为 4,但是缓存 A 恰好和客户端连接出现了问题,那这次写入请求会写入到 Cache B 中。接下来缓存 A 和客户端的连接恢复,当客户端要获取 k 的值时,就会获取到存在 Cache A 中的脏数据 3,而不是 Cache B 中的 4。

脏数据解决方案:设置缓存的过期时间,这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。

数据分片最大的优势就是缓解缓存节点的存储和访问压力,但同时它也让缓存的使用更加复杂。在 MultiGet(批量获取)场景下,单个节点的访问量并没有减少,同时节点数太多会造成缓存访问的 SLA(即“服务等级协议”,SLA 代表了网站服务可用性)得不到很好的保证。根据木桶原则,SLA 取决于最慢、最坏的节点的情况,节点数过多也会增加出问题的概率,因此推荐 4 到 6 个节点为佳

主从机制

主从方式可以解决多数问题。Redis本身支持主从配置的方式,但 Memcached 并不支持。Memcached实现方案:每一组Master 配置一组 Slave,更新数据时主从同步更新。读取时,优先从 Slave 中读数据,如果读取不到数据就穿透到 Master 读取,并且将数据回种到 Slave 中以保持 Slave 数据的热度。

主从机制最大的优点就是当某一个 Slave 宕机时,还会有 Master 作为兜底,不会有大量请求穿透到数据库的情况发生,提升了缓存系统的高可用性

多副本

对于极端流量的场景下,一组 Slave 通常来说并不能完全承担所有流量,Slave 网卡带宽可能成为瓶颈。为解决该问题,可在其之前加一层副本层,当客户端发起查询请求时,请求首先会先从多个副本组中选取一个副本组发起查询,如果查询失败,就继续查询 Master/Slave,并且将查询的结果回种到所有副本组中,避免副本组中脏数据的存在。

基于成本的考虑,每一个副本组容量比 Master 和 Slave 要小,因此它只存储了更加热的数据。在这套架构中,Master 和 Slave 的请求量会大大减少,为了保证它们存储数据的热度,在实践中会把 Master 和 Slave 作为一组副本组使用

中间代理层方案

在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,在性能上会有一些损耗,而代理层中会内置高可用策略,帮助提升缓存系统的高可用,比如 Codis 会使用 Codis Ha 或者 Sentinel。

所有缓存的读写请求都是经过代理层完成的。代理层是无状态的,主要负责读写请求的路由功能,并且在其中内置了一些高可用的逻辑,不同的开源中间代理层方案中使用的高可用策略各有不同。

在 Twemproxy 中,Proxy 保证在某一个 Redis 节点挂掉之后会把它从集群中移除,后续的请求将由其他节点来完成;而 Codis 的实现略复杂,它提供了一个叫 Codis Ha 的工具来实现自动从节点提主节点,在 3.2 版本之后换做了 Redis Sentinel 方式,从而实现 Redis 节点的高可用

服务端方案

Redis 2.4 版本后提出的 Redis Sentinel (哨兵)方案:

由一个或多个Sentinel实例(instance)组成的Sentinel系统(system)可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

Redis Sentinel 不属于代理层模式,因为对于缓存的写入和读取请求不会经过 Sentinel 节点。Sentinel 节点在架构上和主从是平级的,是作为管理者存在的,所以可以认为是在服务端提供的一种高可用方案。

缓存穿透

一般来说,我们的核心缓存的命中率要保持在 99% 以上,非核心缓存的命中率也要尽量保证在 90%。

缓存穿透是指从缓存中没有查到数据,而不得不从后端系统(比如数据库)中查询的情况。更详细是查询缓存和数据库中均不存在,也就是一种查询不存在数据的现象

少量的缓存穿透不可避免,对系统也是没有损害的,主要有几点原因:缓存的容量有限,并且大部分的访问只会请求 20% 的热点数据。

对系统有害的缓存穿透是指大量的穿透请求超过了后端系统的承受范围,造成了系统的崩溃。

解决方案

一般来说我们会有两种解决方案:回种空值以及使用布隆过滤器。

回种空值

当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。

回种空值虽然能够阻挡大量穿透的请求,但当有大量获取数据库中不存在的数据的请求时,缓存中会有大量空值缓存,从而浪费缓存空间。当空间占满还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。所以使用前需要评估一下缓存容量是否能够支撑。

回种空值是一种最常见的解决思路,实现起来也最简单,如果评估空值缓存占据的缓存空间可以接受,那么可以优先使用这种方案。

布隆过滤器

1970 年布隆提出了一种被称为布隆过滤器的算法,由一个二进制数组和一个 Hash 算法组成,用来判断一个元素是否在一个集合中的算法。它的基本思路如下:

  • 把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值。
  • 将 Hash 值对数组长度取模后得到需要计入数组的索引值。
  • 将数组这个位置的值从 0 改成 1。

在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。

布隆过滤器拥有极高的性能,无论是写入操作还是读取操作,时间复杂度都是 O(1),是常量值。

存在的缺陷及解决方案

1.它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中。

产生原因:因为Hash算法存在一定的碰撞几率,而更长的 Hash 值会带来更高的存储成本和计算成本,但碰撞几率不一定会减少多少,所以又不能映射更长的Hash值。

解决方案使用多个 Hash 算法为元素计算出多个 Hash 值,只有所有 Hash 值对应的数组中的值都为 1 时,才会认为这个元素在集合中

布隆过滤器的误判有一个特点,就是它只会出现“false positive”的情况:当布隆过滤器判断元素在集合中时,这个元素可能不在集合中。但是一旦布隆过滤器判断这个元素不在集合中时,它一定不在集合中

2.不支持删除元素。

产生原因:也是Hash碰撞的原因。如果两个元素都是集合中的值,都有相同的Hash值,就会映射到同一数组位置,当删除一个元素后,该位置由1变为0,在判断另一个元素时就会得到其不在集合中的错误结论。

解决方案:数组中不再只有 0 和 1 两个值,而是存储一个计数。比如如果 A 和 B 同时命中了一个数组的索引,那么这个位置的值就是 2,如果 A 被删除了就把这个值从 2 改为 1。这个方案中的数组不再存储 bit 位,而是存储数值,也就会增加空间的消耗。

使用布隆过滤器的一些建议
  1. 选择多个 Hash 函数计算多个 Hash 值,这样可以减少误判的几率;
  2. 布隆过滤器会消耗一定的内存空间,所以在使用时需要评估业务场景下需要多大的内存,存储的成本是否可以接受。

隆过滤器会引入一个新的组件,也会引入一些开发上的复杂度和运维上的成本。所以只有在存在海量查询数据库中,不存在数据的请求时才会使用,在使用时也要关注布隆过滤器对内存空间的消耗。

狗桩效应

当有一个极热点的缓存项,它一旦失效会有大量请求穿透到数据库,这会对数据库造成瞬时极大的压力,我们把这个场景叫做“dog-pile effect”(狗桩效应)

解决方案

思路是尽量的减少缓存穿透后的并发,具体方案有如下两种:

  1. 在代码中,控制在某一个热点缓存项失效之后启动一个后台线程,穿透到数据库,将数据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都不再穿透而直接返回。
  2. 通过在 Memcached 或者 Redis 中设置分布式锁,只有获取到锁的请求才能够穿透到数据库。

解决缓存穿透问题的核心目标在于减少对于数据库的并发请求

缓存击穿

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

解决方案

  • 设置热点数据永远不过期。
  • 加互斥锁,在第一个请求上加锁,其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

缓存雪崩

缓存雪崩是指某一时刻缓存中数据大规模失效,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决方案

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  • 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
  • 设置热点数据永远不过期。

事前:使用集群缓存,保证缓存服务的高可用。如 Redis可以使用 主从+哨兵 ,Redis Cluster 来避免 Redis 全盘崩溃的情况。

事中:ehcache等本地缓存 + Hystrix限流&降级,避免MySQL被打死:

  • 使用 ehcache 等本地缓存的目的是为了Redis Cluster完全不可用的时候,本地缓存可以做个缓冲。
  • 使用 Hystrix进行限流 & 降级 ,通过限流通过降低同一时刻的请求数缓解压力。然后去调用我们自己开发的降级组件(降级),如设置的一些默认值之类的。以此来保护最后的 MySQL 不会被大量的请求给打死。

事后:开启Redis持久化机制,尽快恢复缓存集群。

参考资料

高并发系统设计40问

缓存穿透、缓存击穿、缓存雪崩区别和解决方案

阿里一面:关于【缓存穿透、缓存击穿、缓存雪崩、热点数据失效】问题的解决方案

Tip: 本文主要是极客时间高并发系统设计40问学习笔记。

参与评论