分布式缓存与Redis

151 阅读20分钟

什么是缓存?

狭义:加速CPU交换的存储器

广义:用于数据高速交换的存储介质,可以是硬件也可以是软件。

缓存存在的意义就是通过开辟一个新的数据交换缓冲区,来解决原始数据获取代价太大的问题,提高读写性能。

缓存的基本思想

利用时间局限性原理,通过空间换时间来达到加速数据获取的目的。

  • 时间局限性原理,即被获取过一次的数据在未来会被多次引用
  • 以空间换时间,开辟一块高速独立空间,提供高效访问
  • 性能成本 Tradeoff,访问延迟越低/性能越高,等容量成本越大

优势:

  • 提升访问性能
  • 降低网络拥堵
  • 减轻服务负载
  • 增强可扩展性

代价:

  • 增加系统的复杂度。
  • 系统部署及运行的费用也会更高。
  • 存在一致性问题

缓存读写模式

Cache Aside(旁路缓存)

业务应用方对于写,是更新 DB 后,直接将 key 从 cache 中删除,然后由 DB 驱动缓存数据的更新;而对于读,是先读 cache,如果 cache 没有,则读 DB,同时将从 DB 中读取的数据回写到 cache。

特点:业务端处理所有数据访问细节,同时利用 Lazy 计算的思想,更新 DB 后,直接删除 cache 并通过 DB 更新,确保数据以 DB 结果为准,大幅降低 cache 和 DB 中数据不一致的概率。

适用于对数据一致性要求比较高的业务,或者是缓存数据更新比较复杂的业务 image.png

Read/Write Through(读写穿透)

对于 Cache Aside 模式,业务应用需要同时维护 cache 和 DB 两个数据存储方,过于繁琐,于是就有了 Read/Write Through 模式。业务应用只关注一个存储服务即可,业务方的读写 cache 和 DB 的操作,都由存储服务代理。

存储服务收到业务应用的写请求时,会首先查 cache,如果数据在 cache 中不存在,则只更新 DB,如果数据在 cache 中存在,则先更新 cache,然后更新 DB。而存储服务收到读请求时,如果命中 cache 直接返回,否则先从 DB 加载,回写到 cache 后返回响应。

特点:存储服务封装了所有的数据处理细节,业务应用端代码只用关注业务逻辑本身,系统的隔离性更佳。另外,进行写操作时,如果 cache 中没有数据则不更新,有缓存数据才更新,内存效率更高。 image.png

Write Behind Caching(异步缓存写入)

Write Behind Caching 模式与 Read/Write Through 模式类似,也由数据存储服务来管理 cache 和 DB 的读写。不同点是,数据更新时,Read/write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。

该模式的特点是,数据存储的写性能最高,非常适合一些变更特别频繁的业务,特别是可以合并写请求的业务,比如对一些计数业务。

缺点,即数据的一致性变差,甚至在一些极端场景下可能会丢失数据。比如系统 Crash、机器宕机时,如果有数据还没保存到 DB,则会存在丢失的风险。所以这种读写模式适合变更频率特别高,但对一致性要求不太高的业务,这样写操作可以异步批量写入 DB,减小 DB 压力。 image.png

三大缓存问题的解决方案

缓存穿透

定义:频繁查询数据库中不存在的数据,导致每次请求直达数据库。

造成的原因:在系统设计时,更多考虑的是正常访问路径,对特殊访问路径、异常访问路径考虑相对欠缺。大概率是遭到了攻击。

解决方案:

  • 在接口层增加校验,比如用户鉴权校验,参数做校验,不合法的参数直接代码Return
  • 缓存空对象:对不存在的数据也在Redis建立缓存,值为空,并设一个较短的过期时间TTL,实现简单,维护方便,但有额外内存消耗
  • 布隆过滤器:构建一个 BloomFilter 缓存过滤器,缓存全量的 key,在请求进入Redis之前先判断是否存在,如果不存在直接拒绝请求,内存占用少,但实现复杂,存在误判的可能性。

布隆过滤器: BloomFilter是一个非常有意思的数据结构,不仅仅可以挡住非法 key 攻击,还可以低成本、高性能地对海量数据进行判断。

算法: 首先分配一块内存空间去初始化一个比较大数组  bitMap,初始值全部设为 0,加入元素时,采用 k(=3) 个相互独立的 Hash 函数计算,然后将元素 Hash 映射的 K个位置全部设置为 1。检测 key 时,仍然用这 k 个 Hash 函数计算出 k 个位置,如果位置全部为 1,则表明 key 存在,否则不存在。

不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整我们的哈希函数。布隆过滤器说某个元素不在,那么这个元素一定不在。 反证法,如果这个 key 存在,那它每次 Hash 后对应的 Hash 值位置肯定是 1,而不会是 0。布隆过滤器说某个元素存在,小概率会误判。 因为它存的是 key 的 Hash 值,而非 key 的值,所以有概率存在这样的 key,它们内容不同,但多次 Hash 后的 Hash 值都相同。

优势: 全内存操作,性能很高。另外空间效率非常高. 缺点: 布隆过滤器有可能会产生一定的误判,我们一般可以设置这个误判率,大概不会超过5%,其实这个误判是必然存在的,要不就得增加数组的长度,5%以内的误判率一般的项目也能接受,不至于高并发下压倒数据库。

缓存击穿

定义:某个热点Key在缓存中过期的瞬间,有大量请求并发访问数据库。

造成的原因:,跟我们日常写缓存的过期时间有关。

解决方案

  • 热点key缓存永不过期
  • 设置 key逻辑过期
    • 查询到数据时通过对逻辑过期时间判断,来决定是否需要重建缓存
    • 如果过期则开启一个独立线程进行重建缓存,通过互斥锁保证单线程执行,其他线程无需等待,直接返回查询到的旧数据
    • 高可用,性能好,不保证数据强一致性,有额外内存消耗,实现复杂
  • 使用互斥锁
    • 当缓存失效时,不立即去load db,先使用 Redis 的 setnx 去设置一个互斥锁,确保重建过程只有一个线程执行,其他线程等待,当操作成功返回时再进行 load db的操作并回设缓存,否则重试get缓存的方法。
    • 数据强一致性好,但有死锁风险,实现简单,没有额外内存消耗

缓存雪崩

定义:某一段时间内,大量缓存key同时失效或者Redis服务宕机,导致大量请求同时到达DB,DB 瞬时压力过重雪崩。

造成的原因:

  • 缓存不支持 rehash,一般是由于较多缓存节点不可用。
  • 缓存支持 rehash 时,则大多跟流量洪峰有关。流量洪峰到达,引发部分缓存节点过载 Crash,然后因 rehash 扩散到其他缓存节点,最终整个缓存体系异常。
  • 采用 rehash 策略,即把异常节点请求平均分散到其他缓存节点。在一般情况下,一致性 Hash 分布+rehash 策略可以很好得运行。

解决方案

  • 每个Key的过期时间都加上一个随机值
  • 如果Redis是集群部署,将热点数据均匀分布在不同的Redis库中也能避免全部失效的问题
  • 给业务添加多级缓存 (浏览器缓存,反向代理服务器nignx,jvm内部建立本地缓存等等多个层面)
  • 给缓存业务添加降级限流策略(当redis有问题了,快速降级,比如服务失败,拒绝服务

缓存击穿缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了DB,而缓存击穿是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞。

缓存数据不一致以及并发竞争问题

数据不一致

不一致的问题大多跟缓存更新异常有关。

造成原因:

  • 更新 DB 后,写缓存失败,从而导致缓存中存的是老数据。
  • 缓存有多个副本时,更新某个副本失败,也会导致这个副本的数据是老数据。
  • 系统采用一致性 Hash 分布,同时采用 rehash 自动漂移策略,在节点多次上下线之后,也会产生脏数据。 解决方案:
  • cache 更新失败后,先重试,如果重试失败,则将失败的 key 写入队列机服务,待缓存访问恢复后,将这些 key 从缓存删除。这些 key 在再次被查询时,重新从 DB 加载,从而保证数据的一致性。
  • 不采用 rehash 漂移策略,而采用缓存分层策略,尽量避免脏数据产生。
  • 缓存时间适当调短,让缓存数据及早过期后,然后从 DB 重新加载,确保数据的最终一致性。

数据并发竞争

在高并发访问场景,一旦缓存访问没有找到数据,大量请求就会并发查询 DB,导致 DB 压力大增的现象。

造成原因:主要是由于多个进程/线程中,有大量并发请求获取相同的数据,而这个数据 key 因为正好过期、被剔除等各种原因在缓存中不存在,这些进程/线程之间没有任何协调,然后一起并发查询 DB,请求那个相同的 key,最终导致 DB 压力大增。

解决方案:

  • 使用全局锁,即当缓存请求 miss 后,先尝试加全局锁,只有加全局锁成功的线程,才可以到 DB 去加载数据。其他进程/线程在读取缓存数据 miss 时,如果发现这个 key 有全局锁,就进行等待,待之前的线程将数据从 DB 回种到缓存后,再从缓存获取。
  • 对缓存数据保持多个备份,即便其中一个备份中的数据过期或被剔除了,还可以访问其他备份,从而减少数据并发竞争的情况。

Hot Key和Big Key

Hot key

造成原因:突发热门事件发生时,超大量的请求访问热点事件对应的 key

解决方案:

  • 先找到热 key
    • 对于计划或是提前已知的事情,可以提前评估出可能的热 key
    • 突发事件,可以通过 Spark,对应流任务进行实时分析,及时发现新发布的热点 key。
    • 对于之前的事情,逐步发酵成为热 key 的,可以通过 Hadoop 对批处理任务离线计算,找出最近历史数据中的高频热 key。
  • 这些热 key 分散存在多个缓存节点,然后 client 端请求时,随机访问其中某个后缀的 hotkey,这样就可以把热 key 的请求打散,避免一个缓存节点过载
  • key 的名字不变,对缓存提前进行多副本+多级结合的缓存架构设计
  • 如果热 key 较多,还可以通过监控体系对缓存的 SLA 实时监控,通过快速扩容来减少热 key 的冲击
  • 业务端还可以使用本地缓存,将这些热 key 记录在本地缓存,来减少对远程缓存的冲击

Big key

在缓存访问时,部分 Key 的 Value 过大,读写、加载易超时的现象。

造成这些大 key 慢查询的原因:

  1. 大 key 很少时,存 Mc,对应的 slab 较少,导致很容易被频繁剔除,DB 反复加载。
  2. 大 key 很多,且被大量访问时,缓存组件的网卡、带宽很容易被打满。
  3. 大 key 缓存的字段较多,每个字段的变更都会引发对这个缓存数据的变更,同时这些 key 也会被频繁地读取,读写相互影响,也会导致慢查现象。
  4. 大 key 一旦被缓存淘汰,DB 加载可能需要花费很多时间。

解决方案:

  1. 存在 Mc 中,可以设计一个缓存阀值,当 value 的长度超过阀值,则对内容启用压缩;其次评估大 key 所占的比例,在 Mc 启动之初,让 Mc 预先分配足够多的 trunk size 较大的 slab。确保后面系统运行时,大 key 有足够的空间来进行缓存。
  2. 存在 Redis 中,让 client 在这些大 key 写缓存之前,进行序列化构建,然后通过 restore 一次性写入。
  3. 将大 key 分拆为多个 key。对这些大 key 设置较长的过期时间,比如缓存内部在淘汰 key 时,同等条件下,尽量不淘汰这些大 key。

Redist网络模型

用户空间 内核空间

Linux系统中一个进程使用的内存分为两部分:内核空间 用户空间

用户空间:不能直接调用系统资源,必须通过内核提供的接口来访问

内核空间:调用一切系统资源

Linux为提高IO效率,会在用户空间 内核空间都加入缓冲区:

写数据时, 把用户缓冲数据拷贝到内核缓冲区 再写入设备

读数据时, 从设备读数据到内核缓冲区 再拷贝到用户缓冲区

IO流程.png

网络模型-阻塞IO

顾名思义,就是两个阶段都必须阻塞等待。

阶段一:用户进程尝试读取数据,此时数据尚未到达,内核需要等待数据,此时用户进程也处于阻塞状态。即应用程序等待内核操作硬件拿到数据。

阶段二:内核加载出数据后,将数据拷贝到内核缓冲区,代表已就绪;将内核数据拷贝到用户缓冲区;拷贝过程中,用户进程依然阻塞等待。 阻塞IO.png

网络模型-非阻塞IO

第一个阶段直接返回 不阻塞,但会一直重试 CPU空转 ,第二个阶段阻塞

阶段二:

  1. 将内核数据拷贝到用户缓冲区
  2. 拷贝过程中,用户进程依然阻塞等待拷贝完成,用户进程解除阻塞,处理数据 非阻塞IO.png

网络模型-IO多路复用

无论是阻塞IO还是非阻塞IO,都不能充分发挥CPU的作用。

I/O多路复用是指利用单个线程来同时监听多个FD(文件描述符,Linux中 视频硬件 socket都是文件),并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

监听FD的方式、通知的方式又有多种实现,常见的有: select、poll、epoll

其中select和pool相当于是当被监听的数据准备好之后,他会把你监听的FD整个数据都发给你,你需要到整个FD中去找,哪些是处理好了的,需要通过遍历的方式,所以性能也并不是那么好。

select模式存在的三个问题:

  • 能监听的FD最大不超过1024
  • 每次select都需要把所有要监听的FD都拷贝到内核空间
  • 每次都要遍历所有FD来判断就绪状态

poll模式的问题:

  • poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降

epoll模式中如何解决这些问题的?

  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
  • 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  • 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD, 因此性能不会随监听的FD数量增多而下降。

它提供了三个函数:

  1. eventpoll的函数,包含红黑树(记录要监听的FD) 以及链表(记录已经就绪的FD)(内核空间)
  2. epoll_ctl,添加要监听的数据到红黑树上,关联回调函数,就是把就绪的 FD 数据添加到 list_head 中去。(内核空间)
  3. epoll_wait函数,在用户态创建一个空的events数组,接收就绪的 FD。当调用这个函数的时候,会去检查list_head是否为空,当然这个过程需要参考配置的等待时间,可以等一定时间,也可以一直等。如果不为空,将数据放入到events数组中,并且返回对应的操作数量。用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去拿数据。(用户空间)

目前的I/O多路复用都是采用的 epoll 模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的 FD 写入用户空间,不需要挨个遍历 FD 来判断是否就绪,提升了性能。

IO多路复用.png

网络模型-信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。 信号驱动IO.png

网络模型-异步IO

不仅是用户态在试图读取数据后,不阻塞,而且当内核的数据准备完成后,也不会阻塞

他会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。 异步IO.png

五种网络模型对比

阻塞IO与非阻塞IO的区别在第一阶段(用户程序等待内核操作硬盘拿到数据)中是否阻塞。 除了异步IO在第二阶段(数据拷贝)是非阻塞的,其他网络模型在第二阶段都是阻塞的。 IO比较.png

Redis网路模型

其中Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;

在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程,网络IO 是限制效率的主要点。

Redis是单线程的吗?

  • 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
  • 如果是聊整个Redis,那么答案就是多线程

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。

为什么使用单线程

  • 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
  • 多线程会导致过多的上下文切换,带来不必要的开销
  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣

缓存设计架构的常见考量点

image.png

读写方式

是全部整体读写,还是只部分读写及变更?是否需要内部计算?比如,获取粉丝列表肯定不能采用整体读写的方式,只能部分获取。另外在判断某用户是否关注了另外一个用户时,也不需要拉取该用户的全部关注列表,直接在关注列表上进行检查判断,然后返回 True/False 或 0/1 的方式更为高效。

KV size

不同业务数据缓存 KV 的 size。如果单个业务的 KV size 过大,需要分拆成多个 KV 来缓存。但是,不同缓存数据的 KV size 如果差异过大,也不能缓存在一起,避免缓存效率的低下和相互影响。

key 的数量

如果 key 数量不大,可以在缓存中存下全量数据,把缓存当 DB 存储来用。如果数据量巨大,则在缓存中尽可能只保留频繁访问的热数据,对于冷数据直接访问 DB。

读写峰值

对缓存数据的读写峰值,如果小于 10万 级别,简单分拆到独立 Cache 池即可。而一旦数据的读写峰值超过 10万 甚至到达 100万 级的QPS,则需要对 Cache 进行分层处理,可以同时使用 Local-Cache 配合远程 cache,甚至远程缓存内部继续分层叠加分池进行处理。

命中率

对于核心高并发访问的业务,需要预留足够的容量,确保核心业务缓存维持较高的命中率。为了持续保持缓存的命中率,缓存体系需要持续监控,及时进行故障处理或故障转移。同时在部分缓存节点异常、命中率下降时,故障转移方案,需要考虑是采用一致性 Hash 分布的访问漂移策略,还是采用数据多层备份策略。

过期策略

  • 可以设置较短的过期时间,让冷 key 自动过期;
  • 也可以让 key 带上时间戳,同时设置较长的过期时间,比如 key_20190801。