Redis学习笔记

[toc]

此文章用来记录Redis学习笔记,学习路径是极客时间上蒋德钧老师的《Redis核心技术与实战》

解构键值型数据库

我们知道Redis是一个典型的键值型数据库,那一个普通的键值型数据库整体上应该有哪几个模块组成呢?

一个键值型数据库主要包括以下几个模块:访问框架、操作模块、索引模块、存储模块

如下图所示:

Redis的数据结构

Redis支持的数据结构,也即数据存储类型是我们熟知的五大种:字符串(string)、列表(list)、哈希(hash)、集合(set)、有序集合(sorted set)

其实,我们常说的这五种数据结构只是Redis在键值对中存储的数据形式,可以理解为是经过Redis底层操作或者修改之后展示给客户端看到的数据结构。那Redis底层真正的数据结构是什么呢?

简单来说,Redis底层的数据结构是6种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。他们和数据类型的对应关系如下图所示:

Redis用一个全局哈希表来存储键值对,每一个键值对作为一个哈希桶被保存在全局哈希表中,即可以通过O(1)的时间复杂度找到这个哈希桶,即找到键值对的数据。但是哈希桶中存的并不是真正的键值对数据,而是指向他们的指针。所以,哈希桶中存的数据就不受数据类型的限制,所有的数据类型都存在哈希桶中,并通过指针找到真正的数据。

那底层的这几种数据类型是怎么组织数据呢?

我们先介绍一下压缩列表和跳表这两种数据结构。

  1. 压缩列表

    压缩列表其实类似数组,但是他和普通数组不一样的地方在于,压缩列表在表头有三个字段 zlbytes、zltail和zllen,分别表示列表长度、列表尾的偏移量和列表中entry的个数,在表尾还有一个zlend,表示列表结束。

    这样的数据结构,保证了再查找压缩列表查找第一个和最后一个元素的时候是O(1)的时间复杂度,但是查找其他的元素的时候仍然是O(n)的复杂度。

  2. 跳表

    跳表是基于有序链表的改进,是通过增加多级索引来实现快速查找数据,整个过程是跳着找数据,所以被称为跳表。如下图所示:

现在我们来整理下这几个数据结构的时间复杂度:

Redis 数据类型丰富,每个类型的操作繁多,我们通常无法一下子记住所有操作的复杂度。所以,最好的办法就是掌握原理,以不变应万变。

单线程的Redis为什么那么“快”

我们知道,Redis是单线程的,那为什么单线程的Redis能做到那么快呢?因为在我们的认知里,多线程要优于单线程。下面我们就来分下下底层的原因。

  • 首先,我们所说的Redis是单线程是说Redis的网络IO和键值对的读写是通过一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程。但是Redis的其他功能,比如持久化,异步删除,集群数据同步等都是由额外的线程来执行的。所以严格意义上说,Redis并不是单纯的单线程。

  • 我们通常说的多线程更有优势是基于多线程的应用建立在很优秀的架构和设计上的,如果没有良好的架构设计,多线程的性能可能并没有我们想象中的那么好,最简单的一点就是多线程意味着更多的资源消耗,而解决和协调多线程之间的资源会是一个很麻烦的程序,而这个麻烦的过程本身就会消耗很多的资源。下面这个图能展示实际中线程数和吞吐率的关系:

    线程数与系统吞吐量

下面我们来看看为什么单线程的Redis能做到那么快~

概括起来主要是两点。

  1. 第一是Redis大部分操作是在内存上操作的,而且得益于Redis底层高效的数据结构,比如哈希表和跳表,这是其中的一个原因。

  2. 第二就是Redis采用了多路复用的技术,使其在网络IO中能并发处理大量的客户端请求。

在了解多路复用技术时,我们先来了解下一个IO网络模型都包括哪些环节,并且哪些环节是容易造成阻塞的。

以一个get请求为例,Redis接收客户端的一个get请求,大致经历了以下几个环节。

  1. Redis需要监听客户端请求(bind/listen);
  2. 和客户端建立链接(accept);
  3. 从socket中读取请求(recv);
  4. 解析客户端发送的请求(parse);
  5. 根据请求类型读取键值数据(get);
  6. 给客户端返回结果,即往socket中写回数据(send)。

下图总结了下以上的几个环节:

Redis基本IO模型

以上的环节中,存在阻塞风险的分别是accept和recv环节。

在Redis监听到客户端有一个请求过来,但是一直没有建立请求,将会阻塞在accept环节,这会导致后面的请求都被阻塞。

当Redis通过recv从客户端读取数据的时候,如果一直没有数据读取进来,也将会阻塞在recv环节。

不过,还有一种非阻塞的网络模式,那就是socket网络模式。

socket之所以能做到非阻塞是因为有三个函数的存在。

在socket模型中,不同的请求会返回不同类型的套接字。

socket()方法会返回主动套接字,然后调用linsten()方法,将主动套接字转换为监听套接字,此时,可以监听客户端请求。最后,调用accept()方法接收客户端的请求,并返回已连接套接字。

Redis套接字类型与非阻塞设置

当然,我们针对已连接的套接字也可以设置非阻塞模式。

多路复用的高性能IO模型就是以上的模式的实现

Linux中的IO多路复用机制指的是允许一个线程处理多个IO流,就是我们常听说的select/epoll机制。简单来说,就是允许Redis在单线程运行中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的链接请求和数据请求,一旦有请求到达,就通知Redis处理,从而达到一个Redis线程处理多个IO流的效果。

下图解释了多路复用机制的实现:

FD即为套接字。

Redis的数据备份机制

AOF

AOF即Append Only File,从命名也可以看出来AOF模式就是文件存储的方式。

Redis在每次写入数据之后,都会将执行的命令写入日志中。和数据库的redo log(重做日志)记录修改之后的数据不同的是,Redis的日志文件中记录的是一条条的执行命令,这些命令以文本形式保存。

我们以set testkey testvalue这条命令来看下Redis的日志中记录的是什么内容:

其中,*3表示当前命令有三个部分,每部分都是由$+数字开头,后面紧跟着具体的命令、键或值。这里,数字表示这部分中的命令、键或值一共有多少字节。例如,
“`$$3 set“`表示这部分有 3 个字节,也就是“set”命令。

为了避免检查语句的开销,Redis在每次将执行命令写入日志中的时候,并不会检查命令的正确与否。所以Redis采用的是命令执行成功之后才会将其写入日志中。这样还有一个好处,在命令执行完之后再写入日志,不会阻塞当前的写操作。

但是AOF还是存在两个潜在的风险:

  1. 一是如果刚执行完一条命令,还没来得及写入日志就宕机了,就会导致这条命令在日志中保存不上,如果发生恢复数据的操作,就会导致这条数据丢失。

  2. 二是写日志的操作,虽然不会对当前的写操作造成阻塞,但是会会下一个请求造成阻塞,因为写日志的操作是在磁盘上进行的,磁盘写入压力太大时,会造成后续操作的阻塞。

以上两个风险都是基于日志写入磁盘的风险,Redis针对这个问题,提供了三种日志回写策略:

Always:同步写回,每个命令执行完立即写入日志
Everysec:每秒写回,每个命令执行完,先写入内存缓冲区,每隔一秒写入日志一次
No:由操作系统控制写回,操作系统控制何时将内存缓冲区的内容写入日志

下图总结了三种方式的优缺点:

我们知道,一般的日志文件都是很大的,不管是业务上的日志文件,还是MySQL等数据的binlog和redolog日志,因为每次的操作都会被记录下来。同样,Redis的AOF日志也会有很多,那当日志文件太大时就会出现性能问题,这与Redis追求高性能的特点是不相符的。所以,针对这个问题,Redis有一个AOF重写机制

重写机制就是Redis会新建一个AOF日志文件,并根据当前数据库键值对的数据情况,为每个键值对创建一个命令,来写入重写日志中。重写日志还有一个好处,那就是”多变一“,就是把原来旧文件中的多条命令合并成了一个命令。这个怎么理解呢?我们知道,旧的AOF日志文件是每次操作都会写入,那就会有一个命令重复更新多次,就会被记录多次的操作日志,而重新日志是根据当前的状态生成的一个命令,所以,就保存当前的状态就可以,而不用去记录之前的操作过程,所以就省去了很多没用的操作记录。

具体如下图的例子:

AOF日志是有主线程写入,但是重写机制是在后台子进程gbrewriteaof来完成的,这也是为了避免阻塞主线程。

Redis的AOF重写机制,可以总结为”一个拷贝 两处日志“

  • 一个拷贝即是,每次执行重写时,主线程都会fork出后台bgrewriteaof子进程。fork会把主线程的内存拷贝一份给子进程,这样子进程就拿到了数据库的最新数据,就可以做重写操作了,又不会阻塞主线程的正常操作。

  • 两处日志即是,主线程继续将新的操作写入AOF日志,而子进程也会进行日志重写操作,将”简化后“的日志写入新的AOF日志,完成日志重写过程。

在重写过程中新写入的操作也会被记录到重写日志的缓冲区,这样就保证了重写日志也能记录到最新的操作请求,等重写日志完成,我们就可以用新的AOF日志来替代旧的日志。

这样可以在保证请求不中断的情况下,给AOF日志”瘦身“和”减负“。

RDB

Redis的AOF日志机制最大程度保证了数据的完整和安全,但是有一个问题就是,在执行AOF日志恢复的时候需要一条条的命令执行写入,这样的操作是很慢的,会导致数据恢复的很慢,这在有些业务场景会影响到正常使用。所以,我们就需要第二种能快速恢复数据的备份机制–RDB(Redis DataBase)

RDB机制是快照机制,就是将某个时间点的数据做个快照,我们知道快照恢复是很快的。

RDB有两个问题需要考虑,一个是对哪些数据做快照,另一个问题是对数据做快照时,还能正常写入操作数据吗?

首先肯定是要对全量数据做快照,这里就有另一个问题,全量快照会是一个很大的工程,必将会很耗时耗资源,那Redis是怎么处理的呢?

Redis提供了两个命令来生成RDB文件,分别是save和bgsave。

  1. save:在主线程执行,这会导致阻塞

  2. bgsave:开辟一个子进程来生成RDB文件,这也是Redis 默认的方式

接下来看第二个问题,生成快照时,如何能保证快照数据的实时完整又不影响数据正常的读取呢?

这里就要借助操作系统提供的写时复制技术(Copy-On-Write,COW),在执行快照的时候正常处理读写操作。

Redis开辟了一个子进程bgsave来执行执行RDB,如果是读操作,那子进程和主线程互不影响。那主要解决的问题就是写操作如何保证同步,并不影响主线程。

解决方式就是,在主线程有写操作的时候会复制一份数据出来,生成该数据的副本。然后子进程bgsave就可以把这个副本数据写进快照中,这样就解决了写操作同步的问题。

如下图所示:

解决完上面两个问题,还有一个问题需要我们考虑。应该多久做一次快照?

  • 如果时间太久,则数据的完整性就不能很好的保证。

  • 如果时间太短,则会导致资源消耗过多,而且全量快照需要fork子进程,虽然子进程不会阻塞主线程,但是fork子进程这个过程本身会阻塞主线程,所以频繁的fork子进程来创建快照也是会阻塞主线程的,进而影响性能。

有一种解决办法是我们在第一次全量快照之后,后续的快照用增量快照的方式进行,就是我们只保存修改的数据,对没有改动的数据快照也不动。但是这样的话,我们需要去知道哪些数据没有被修改,这个”记住“数据没有被修改,就需要用额外的元数据去记录哪些数据被修改了,这将会带来更多额外的空间消耗。这样做是有些得不偿失的。

到这里,你可以发现,虽然跟 AOF 相比,快照的恢复速度快,但是,快照的频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销,那么,还有什么方法既能利用 RDB 的快速恢复,又能以较小的开销做到尽量少丢数据呢?

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。

这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势,颇有点“鱼和熊掌可以兼得”的感觉。

主从库如何实现数据一致

我们知道Redis是以集群的方式部署服务,具体实现就是读写分离的主从库模式。那主从库之间是怎么做到数据同步的呢?

主从库间如何进行第一次同步?

当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。

例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据:

replicaof  172.16.19.3  6379

接下来,我们就要学习主从库间数据第一次同步的三个阶段了。你可以先看一下下面这张图,有个整体感知,接下来我再具体介绍。

  1. 第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。

    具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。

    主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。

  2. 在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。

    具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。

    但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。

  3. 最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。

    具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

主从库间网络断了怎么办?

从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。

当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。

repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

  • 刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大。

  • 同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。

不过,有一个地方我要强调一下,因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。因此,我们要想办法避免这一情况,一般而言,我们可以调整 repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 – 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值。

哨兵机制

Redis的主从集群模式中,如果主库挂掉了如何能保证服务保持正常呢?这里面涉及到几个问题,
一个是如何判断主库是否挂了。
二是如果主库挂了应该选择哪个从库作为主库。
三是如何把新主库的信息传给从库和客户端。

以上三个问题就需要哨兵机制来解决,那哨兵的主要任务就对应以上三个问题:监控、选主、通知。

监控任务中,哨兵通过周期性的给主库和其他从库发送ping命令来判断其服务是否正常。

如果是从库,没有在规定时间内响应哨兵的ping命令,那哨兵就把从库标记为”主观下线“。从库的影响不大,一个哨兵标记为下线即为下线状态,对整体服务影响不大。但是主库就不一样了,因为这个下线状态会存在”误判“的情况。如果集群网络压力较大,网络拥塞,或者主库本身压力比较大的情况下,容易导致误判。

那为了解决误判的情况,就需要引入多个哨兵,来组成哨兵集群来判断主库的状态。这里就要引入”主观下线“和”客观下线“两个概念。如果一个哨兵判断主库下线,那会把主库标记为”主观下线“,如果有多个哨兵都判断主库下线,那主库才会被标记为”客观下线”,如果被标记为客观下线,那就表示主库是真的挂了,就要开始选择新的主库。

下面的图可以帮助理解,哨兵集群少数服从多数地判断主库“客观下线”的过程:

如何选定新主库?
哨兵选主的过程可以总结为“筛选+打分“的过程。

即按一定的条件把从库筛选一遍,然后再按一定的条件,给筛选出来的从库打分,获得分数高的从库即成为新的主库。

在筛选从库的时候,除了要检查从库当前的网络连接状况,还要判断它之前的网络连接状态。具体怎么判断呢?

我们使用配置项 down-after-milliseconds * 10。其中,down-after-milliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。

筛选完,就要开始给从库打分,打分共分为三轮。分别是从库优先级、从库复制进度和从库ID。只要在某一轮中出现最高分,即选用这个从库作为新主库。

第一轮:优先级最高的从库得分高。

用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。比如,你有两个从库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。在选主时,哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。如果从库的优先级都一样,那么哨兵开始第二轮打分。

第二轮:和旧主库同步程度最接近的从库得分高。

上节课我们介绍过,主从库同步时有个命令传播的过程。在这个过程中,主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 这个值记录当前的复制进度。此时,我们想要找的从库,它的 slave_repl_offset 需要最接近 master_repl_offset。如果在所有从库中,有从库的 slave_repl_offset 最接近 master_repl_offset,那么它的得分就最高,可以作为新主库。

就像下图所示,旧主库的 master_repl_offset 是 1000,从库 1、2 和 3 的 slave_repl_offset 分别是 950、990 和 900,那么,从库 2 就应该被选为新主库。

当然,如果有两个从库的 slave_repl_offset 值大小是一样的(例如,从库 1 和从库 2 的 slave_repl_offset 值都是 990),我们就需要给它们进行第三轮打分了。

第三轮:ID 号小的从库得分高。

每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。

至此,新主库就被选出来了,“选主”这个过程就完成了。

哨兵集群

我们知道Redis主从库切换是通过哨兵来实现的,那如果哨兵挂了,怎么办呢?这时候就要引入哨兵集群了。

如果你部署过哨兵集群的话就会知道,在配置哨兵的信息时,我们只需要用到下面的这个配置项,设置主库的 IP 和端口,并没有配置其他哨兵的连接信息。

sentinel monitor <master-name> <ip> <redis-port> <quorum> 

这些哨兵实例既然都不知道彼此的地址,又是怎么组成集群的呢?要弄明白这个问题,我们就需要学习一下哨兵集群的组成和运行机制了。

基于 pub/sub 机制的哨兵集群组成

哨兵实例之间可以相互发现,要归功于 Redis 提供的 pub/sub 机制,也就是发布 / 订阅机制。

哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP 和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端口。

除了哨兵实例,我们自己编写的应用程序也可以通过 Redis 进行消息的发布和订阅。所以,为了区分不同应用的消息,Redis 会以频道的形式,对这些消息进行分门别类的管理。所谓的频道,实际上就是消息的类别。当消息类别相同时,它们就属于同一个频道。反之,就属于不同的频道。只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换。

在主从集群中,主库上有一个名为“sentinel:hello”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。

如下图所示:

哨兵是如何知道从库的 IP 地址和端口的呢?

这是由哨兵向主库发送 INFO 命令来完成的。就像下图所示,哨兵 2 给主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵 1 和 3 可以通过相同的方法和从库建立连接。

基于 pub/sub 机制的客户端事件通知

从本质上说,哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供 pub/sub 机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。

频道有这么多,一下子全部学习容易丢失重点。为了减轻你的学习压力,我把重要的频道汇总在了一起,涉及几个关键事件,包括主库下线判断、新主库选定、从库重新配置

知道了这些频道之后,你就可以让客户端从哨兵这里订阅消息了。具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息。

举个例子,你可以执行如下命令,来订阅“所有实例进入客观下线状态的事件”:

SUBSCRIBE +odown

当然,你也可以执行如下命令,订阅所有的事件:

PSUBSCRIBE  *

当哨兵把新主库选择出来后,客户端就会看到下面的 switch-master 事件。这个事件表示主库已经切换了,新主库的 IP 地址和端口信息已经有了。这个时候,客户端就可以用这里面的新主库地址和端口进行通信了。

switch-master <master name> <oldip> <oldport> <newip> <newport>

好了,有了 pub/sub 机制,哨兵和哨兵之间、哨兵和从库之间、哨兵和客户端之间就都能建立起连接了,再加上我们上节课介绍主库下线判断和选主依据,哨兵集群的监控、选主和通知三个任务就基本可以正常工作了。不过,我们还需要考虑一个问题:主库故障以后,哨兵集群有多个实例,那怎么确定由哪个哨兵来进行实际的主从切换呢?

由哪个哨兵执行主从切换?

确定由哪个哨兵执行主从切换的过程,和主库“客观下线”的判断过程类似,也是一个“投票仲裁”的过程。

在具体了解这个过程前,我们再来看下,判断“客观下线”的仲裁过程。哨兵集群要判定主库“客观下线”,需要有一定数量的实例都认为该主库已经“主观下线”了。

任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。

如下图所示:

一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。

这个所需的赞成票数是通过哨兵配置文件中的 quorum 配置项设定的。例如,现在有 5 个哨兵,quorum 配置的是 3,那么,一个哨兵需要 3 张赞成票,就可以标记主库为“客观下线”了。这 3 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader 选举”。因为最终执行主从切换的哨兵称为 Leader,投票过程就是确定 Leader。

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:

  1. 拿到半数以上的赞成票;

  2. 拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

以 3 个哨兵为例,假设此时的 quorum 设置为 2,那么,任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以了。

需要注意的是,如果哨兵集群只有 2 个实例,此时,一个哨兵要想成为 Leader,必须获得 2 票,而不是 1 票。所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。因此,通常我们至少会配置 3 个哨兵实例。这一点很重要,你在实际应用时可不能忽略了。

数据增多,是该加内存还是加实例

保存大量数据,通常有扩大云主机内存和切片集群两种方法。实际上,这两种方法分别对应Redis应对数据量增多的两种方案:纵向扩展(scale up)横向扩展(scale out)

纵向扩展的好处是实施起来简单,快捷。

潜在的问题是,内存数据增多时,进行RDB数据持久化的时候,主线程fork子进程的时候可能会造成阻塞。还有就是硬件和成本的控制,毕竟升级服务器资源是需要不少花费的,而且我们知道,内存和磁盘越大的服务器越贵。

对比起来的话,横向扩展是扩展性更好的一种方案。在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。

不过,在横向扩展组建切片集群的时候也有几个问题需要解决。

  1. 数据切片后,在多个实例之间如何分布?

  2. 客户端怎么确定想要访问的数据在哪个实例上?

数据切片和实例的对应分布关系

从Redis3.0开始官方提供了一个名为Redis Cluster的方案,用于切片集群

具体来说,Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。

具体的映射过程分为两大步:首先根据键值对的 key,按照CRC16 算法计算一个 16 bit 的值;然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

我们在部署 Redis Cluster 方案时,可以使用 cluster create 命令创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。

当然, 我们也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。

假设集群中不同 Redis 实例的内存大小配置不一,如果把哈希槽均分在各个实例上,在保存相同数量的键值对时,和内存大的实例相比,内存小的实例就会有更大的容量压力。遇到这种情况时,你可以根据不同实例的资源配置情况,使用 cluster addslots 命令手动分配哈希槽。

如下图所示:

示意图中的切片集群一共有 3 个实例,同时假设有 5 个哈希槽,我们首先可以通过下面的命令手动分配哈希槽:实例 1 保存哈希槽 0 和 1,实例 2 保存哈希槽 2 和 3,实例 3 保存哈希槽 4。

redis-cli -h 172.16.19.3 –p 6379 cluster addslots 0,1
redis-cli -h 172.16.19.4 –p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5 –p 6379 cluster addslots 4

在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 5 取模,再根据各自的模数结果,就可以被映射到对应的实例 1 和实例 3 上了。

另外,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。

客户端如何定位数据?

在定位键值对数据时,它所处的哈希槽是可以通过计算得到的,这个计算可以在客户端发送请求时来执行。但是,要进一步定位到实例,还需要知道哈希槽分布在哪个实例上。

一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。

那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?这是因为,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

  • 在集群中,实例有新增或删除,Redis 需要重新分配哈希槽;

  • 为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍。

此时,实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致了,那该怎么办呢?

Redis Cluster 方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

重定向机制分为两种情况,分别对应两个命令,这里我就用两个命令分别代表两种情况。MOVED和ASK

MOVED

那客户端又是怎么知道重定向时的新实例的访问地址呢?当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址。

GET hello:key
(error) MOVED 13320 172.16.19.5:6379

其中,MOVED 命令表示,客户端请求的键值对所在的哈希槽 13320,实际是在 172.16.19.5 这个实例上。通过返回的 MOVED 命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。这样一来,客户端就可以直接和 172.16.19.5 连接,并发送操作请求了。

我画一张图来说明一下,MOVED 重定向命令的使用方法。可以看到,由于负载均衡,Slot 2 中的数据已经从实例 2 迁移到了实例 3,但是,客户端缓存仍然记录着“Slot 2 在实例 2”的信息,所以会给实例 2 发送命令。实例 2 给客户端返回一条 MOVED 命令,把 Slot 2 的最新位置(也就是在实例 3 上),返回给客户端,客户端就会再次向实例 3 发送请求,同时还会更新本地缓存,把 Slot 2 与实例的对应关系更新过来。

ASK

需要注意的是,在上图中,当客户端给实例 2 发送命令时,Slot 2 中的数据已经全部迁移到了实例 3。在实际应用时,如果 Slot 2 中的数据比较多,就可能会出现一种情况:客户端向实例 2 发送请求,但此时,Slot 2 中的数据只有一部分迁移到了实例 3,还有部分数据没有迁移。在这种迁移部分完成的情况下,客户端就会收到一条 ASK 报错信息,如下所示:

GET hello:key
(error) ASK 13320 172.16.19.5:6379

这个结果中的 ASK 命令就表示,客户端请求的键值对所在的哈希槽 13320,在 172.16.19.5 这个实例上,但是这个哈希槽正在迁移。此时,客户端需要先给 172.16.19.5 这个实例发送一个 ASKING 命令。这个命令的意思是,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送 GET 命令,以读取数据。

看起来好像有点复杂,我再借助图片来解释一下。

在下图中,Slot 2 正在从实例 2 往实例 3 迁移,key1 和 key2 已经迁移过去,key3 和 key4 还在实例 2。客户端向实例 2 请求 key2 后,就会收到实例 2 返回的 ASK 命令。

ASK 命令表示两层含义:第一,表明 Slot 数据还在迁移中;第二,ASK 命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例 3 发送 ASKING 命令,然后再发送操作命令。

和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。所以,在上图中,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求。这也就是说,ASK 命令的作用只是让客户端能给新实例发送一次请求,而不像 MOVED 命令那样,会更改本地缓存,让后续所有命令都发往新实例。

缓存异常

在实际应用 Redis 缓存时,我们经常会遇到一些异常问题,概括来说有 4 个方面:缓存中的数据和数据库中的不一致;缓存雪崩;缓存击穿和缓存穿透。

数据一致性问题

缓存和数据库的数据不一致是如何发生的?
这里的“一致性”包含了两种情况:

  1. 缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
  2. 缓存中本身没有数据,那么,数据库中的值必须是最新值。

不过,当缓存的读写模式不同时,缓存数据不一致的发生情况不一样,我们的应对方法也会有所不同

读写缓存

如果要对数据进行增删改,就需要在缓存中进行,同时还要根据采取的写回策略,决定是否同步写回到数据库中。

  • 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据一致;
  • 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。

使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障,那么,此时,数据库就没有最新的数据了。

所以,对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。
不过,需要注意的是,如果采用这种策略,就需要同时更新缓存和数据库。所以,我们要在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性,也就是说,两者要不一起更新,要不都不更新,返回错误信息,进行重试。否则,我们就无法实现同步直写。

只读缓存

对于只读缓存来说,如果有数据新增,会直接写入数据库;而有数据删改时,就需要把只读缓存中的数据标记为无效。这样一来,应用后续再访问这些增删改的数据时,因为缓存中没有相应的数据,就会发生缓存缺失。此时,应用再从数据库中把数据读入缓存,这样后续再访问数据时,就能够直接从缓存中读取了。

那么,这个过程中会不会出现数据不一致的情况呢?考虑到新增数据和删改数据的情况不一样,所以我们分开来看。

  1. 新增数据

    如果是新增数据,数据会直接写到数据库中,不用对缓存做任何操作,此时,缓存中本身就没有新增数据,而数据库中是最新值,这种情况符合我们刚刚所说的一致性的第 2 种情况,所以,此时,缓存和数据库的数据是一致的。

  2. 删改数据

    如果发生删改操作,应用既要更新数据库,也要在缓存中删除数据。这两个操作如果无法保证原子性,也就是说,要不都完成,要不都没完成,此时,就会出现数据不一致问题了。这个问题比较复杂,我们来分析一下。

    • 先删除缓存,再更新数据库

    如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了。

    我来举个例子说明一下,可以先看看下面的图片。

    应用要把数据 X 的值从 10 更新为 3,先在 Redis 缓存中删除了 X 的缓存值,但是更新数据库却失败了。如果此时有其他并发的请求访问 X,会发现 Redis 中缓存缺失,紧接着,请求就会访问数据库,读到的却是旧值 10。

    • 先更新数据库,再删除缓存

    如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。这个时候,如果有其他的并发请求来访问数据,按照正常的缓存访问流程,就会先在缓存中查询,但此时,就会读到旧值了。

    我还是借助一个例子来说明一下。

    应用要把数据 X 的值从 10 更新为 3,先成功更新了数据库,然后在 Redis 缓存中删除 X 的缓存,但是这个操作却失败了,这个时候,数据库中 X 的新值为 3,Redis 中的 X 的缓存值为 10,这肯定是不一致的。如果刚好此时有其他客户端也发送请求访问 X,会先在 Redis 中查询,该客户端会发现缓存命中,但是读到的却是旧值 10。

总结以上两种情况产生的数据不一致问题

如何解决数据不一致问题?

  1. 重试机制(解决操作数据库和操作缓存其中一个失败的情况)

具体来说,可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka 消息队列)。当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了。否则的话,我们还需要再次进行重试。如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。

  1. 并发请求下的解决方案

如果是并发请求,即使是两个操作都成功,也有可能会产生数据不一致问题

同样,我们按照不同的删除和更新顺序,分成两种情况来看。在这两种情况下,我们的解决方法也有所不同。

情况一:先删除缓存,再更新数据库。

假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:
1. 线程 B 读取到了旧值;
2. 线程 B 是在缓存缺失的情况下读取的数据库,所以,它还会把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。

等到线程 B 从数据库读取完数据、更新了缓存后,线程 A 才开始更新数据库,此时,缓存中的数据是旧值,而数据库中的是最新值,两者就不一致了。

这种情况可以使用延迟双删来解决。即:在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作。

之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除。所以,线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。这个时间怎么确定呢?建议你在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,以此为基础来进行估算。

//操作伪代码

redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)

情况二:先更新数据库值,再删除缓存值。

如果线程 A 删除了数据库中的值,但还没来得及删除缓存值,线程 B 就开始读取数据了,那么此时,线程 B 查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。

这种情况「理论」来说是可能发生的,但实际真的有可能发生吗?

其实概率「很低」,这是因为它必须满足 3 个条件:

  1. 缓存刚好已失效
  2. 读请求 + 写请求并发
  3. 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)

仔细想一下,条件 3 发生的概率其实是非常低的。
因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的。
这么来看,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
所以,我们应该采用这种方案,来操作数据库和缓存。

关于redis数据一致性问题的分析,可以参考Kaito大佬的讲解:缓存和数据库一致性问题,看这篇就够了

缓存雪崩

缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。

缓存雪崩一般是由两个原因导致的

第一个原因是:缓存中有大量数据同时过期,导致大量请求无法得到处理。

解决方案

  1. 微调过期时间
    首先,我们可以避免给大量的数据设置相同的过期时间。如果业务层的确要求有些数据同时失效,你可以在用 EXPIRE 命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加 1~3 分钟),这样一来,不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,同时也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。

  2. 服务降级
    所谓的服务降级,是指发生缓存雪崩时,针对不同的数据采取不同的处理方式。
    当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;
    当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。

第二个原因是:Redis 缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层

解决方案

  1. 在业务系统中实现服务熔断或请求限流机制。
    所谓的服务熔断,是指在发生缓存雪崩时,为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,我们暂停业务应用对缓存系统的接口访问。再具体点说,就是业务应用调用缓存接口时,缓存客户端并不把请求发给 Redis 缓存实例,而是直接返回,等到 Redis 缓存实例重新恢复服务后,再允许应用请求发送到缓存系统。

    服务熔断虽然可以保证数据库的正常运行,但是暂停了整个缓存系统的访问,对业务应用的影响范围大。为了尽可能减少这种影响,我们也可以进行请求限流。这里说的请求限流,就是指,我们在业务系统的请求入口前端控制每秒进入系统的请求数,避免过多的请求被发送到数据库。

  2. 事前预防
    通过主从节点的方式构建 Redis 缓存高可靠集群。如果 Redis 缓存的主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务,避免了由于缓存实例宕机而导致的缓存雪崩问题。

缓存击穿

缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。

解决方法

为了避免缓存击穿给数据库带来的激增压力,我们的解决方法也比较直接,对于访问特别频繁的热点数据,我们就不设置过期时间了。这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而 Redis 数万级别的高吞吐量可以很好地应对大量的并发请求访问。

缓存穿透

缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。此时,应用也无法从数据库中读取数据再写入缓存,来服务后续请求,这样一来,缓存也就成了“摆设”,如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力

发生缓存穿透一般有两种情况:

  1. 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;

  2. 恶意攻击:专门访问数据库中没有的数据。

解决方案:

  1. 缓存空值或缺省值。
    一旦发生缓存穿透,我们就可以针对查询的数据,在 Redis 中缓存一个空值或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为 0)。紧接着,应用发送的后续请求再进行查询时,就可以直接从 Redis 中读取空值或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。

  2. 使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。
    布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。
    当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:

    • 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
    • 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
    • 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。

    如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit 数组对应 bit 位的值仍然为 0。当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对应的 N 个位置。紧接着,我们查看 bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个不为 1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。

关于缓存雪崩、缓存击穿、缓存穿透的总结:

缓存污染、缓存淘汰策略

什么是缓存污染呢?
在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。

可以看出来缓存污染的解决其实就是依靠缓存淘汰策略,那缓存淘汰策略都有哪些呢?

Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略。我们可以按照是否会进行数据淘汰把它们分成两类:

  • 不进行数据淘汰的策略,只有 noeviction 这一种。
  • 会进行淘汰的 7 种其他策略。

会进行淘汰的 7 种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:

  • 在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。
  • 在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 后新增)三种。

总结:

默认情况下,Redis 在使用的内存空间超过 maxmemory 值时,并不会淘汰数据,也就是设定的 noeviction 策略。对应到 Redis 缓存,也就是指,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。Redis 用作缓存时,实际的数据集通常都是大于缓存容量的,总会有新的数据要写入缓存,这个策略本身不淘汰数据,也就不会腾出新的缓存空间,我们不把它用在 Redis 缓存中。

  • volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。
  • volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
  • volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对。
  • volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对。

ttl和random比较好理解,重点是lru算法和lfu算法。

LRU 算法

LRU 算法的全称是 Least Recently Used,从名字上就可以看出,这是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中。

那具体是怎么筛选的呢?LRU 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表最近最常使用的数据和最近最不常用的数据。我们看一个例子。

如果有一个新数据 15 要被写入缓存,但此时已经没有缓存空间了,也就是链表没有空余位置了,那么,LRU 算法做两件事:

  1. 数据 15 是刚被访问的,所以它会被放到 MRU 端;
  2. 算法把 LRU 端的数据 5 从缓存中删除,相应的链表中就没有数据 5 的记录了。

其实,LRU 算法背后的想法非常朴素:它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在 MRU 端;长久不访问的数据,肯定就不会再被访问了,所以就让它逐渐后移到 LRU 端,在缓存满时,就优先删除它。

不过,LRU 算法在实际实现时,需要用链表管理所有的缓存数据,这会带来额外的空间开销。而且,当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

所以,在 Redis 中,LRU 算法被做了简化,以减轻数据淘汰对缓存性能的影响。具体来说,Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。

Redis 提供了一个配置参数 maxmemory-samples,这个参数就是 Redis 选出的数据个数 N。例如,我们执行如下命令,可以让 Redis 选出 100 个数据作为候选数据集:

CONFIG SET maxmemory-samples 100

当需要再次淘汰数据时,Redis 需要挑选数据进入第一次淘汰时创建的候选集合。这儿的挑选标准是:能进入候选集合的数据的 lru 字段值必须小于候选集合中最小的 lru 值。当有新数据进入候选数据集后,如果候选数据集中的数据个数达到了 maxmemory-samples,Redis 就把候选数据集中 lru 字段值最小的数据淘汰出去。

这样一来,Redis 缓存不用为所有的数据维护一个大链表,也不用在每次数据访问时都移动链表项,提升了缓存的性能。

使用建议:

  • 优先使用 allkeys-lru 策略。这样,可以充分利用 LRU 这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。如果你的业务数据中有明显的冷热数据区分,我建议你使用 allkeys-lru 策略。
  • 如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用 allkeys-random 策略,随机选择淘汰的数据就行。
  • 如果你的业务中有置顶的需求,比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。

如何处理被淘汰的数据?

一般来说,一旦被淘汰的数据选定后,如果这个数据是干净数据,那么我们就直接删除;如果这个数据是脏数据,我们需要把它写回数据库

那怎么判断一个数据到底是干净的还是脏的呢?

干净数据和脏数据的区别就在于,和最初从后端数据库里读取时的值相比,有没有被修改过。
干净数据一直没有被修改,所以后端数据库里的数据也是最新值。在替换时,它可以被直接删除。
而脏数据就是曾经被修改过的,已经和后端数据库中保存的数据不一致了。此时,如果不把脏数据写回到数据库中,这个数据的最新值就丢失了,就会影响应用的正常使用。
这么一来,缓存替换既腾出了缓存空间,用来缓存新的数据,同时,将脏数据写回数据库,也保证了最新数据不会丢失。

不过,对于 Redis 来说,它决定了被淘汰的数据后,会把它们删除。即使淘汰的数据是脏数据,Redis 也不会把它们写回数据库。所以,我们在使用 Redis 缓存时,如果数据被修改了,需要在数据修改时就将它写回数据库。否则,这个脏数据被淘汰时,会被 Redis 删除,而数据库里也没有最新的数据了。

LFU算法

与 LRU 策略相比,LFU 策略中会从两个维度来筛选并淘汰数据:一是,数据访问的时效性(访问时间离当前时间的远近);二是,数据的被访问次数

LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

那么,LFU 策略具体又是如何实现的呢?
既然 LFU 策略是在 LRU 策略上做的优化,那它们的实现必定有些关系。
所以,我们就再复习下第 24 讲学习过的 LRU 策略的实现。为了避免操作链表的开销,Redis 在实现 LRU 策略时使用了两个近似方法:

  • Redis 是用 RedisObject 结构来保存数据的,RedisObject 结构中设置了一个 lru 字段,用来记录数据的访问时间戳;
  • Redis 并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如 10 个)的数据放入候选集合,后续在候选集合中根据 lru 字段值的大小进行筛选。

在此基础上,Redis 在实现 LFU 策略的时候,只是把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分。

  • ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
  • counter 值:lru 字段的后 8bit,表示数据的访问次数。

问题:Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255,这样可以吗?

显然,只有8bit记录的255次访问记录是不行的,因此,在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。

简单来说,LFU 策略实现的计数规则是:每当数据被访问一次时,首先,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。

下面这段 Redis 的部分源码,显示了 LFU 策略增加计数器值的计算逻辑。其中,baseval 是计数器当前的值。计数器的初始值默认是 5(由代码中的 LFU_INIT_VAL 常量设置),而不是 0,这样可以避免数据刚被写入缓存,就因为访问次数少而被立即淘汰。

double r = (double)rand()/RAND_MAX;
...
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;

使用了这种计算规则后,我们可以通过设置不同的 lfu_log_factor 配置项,来控制计数器值增加的速度,避免 counter 值很快就到 255 了。

为了更进一步说明 LFU 策略计数器递增的效果,你可以看下下面这张表。这是 Redis官网上提供的一张表,它记录了当 lfu_log_factor 取不同值时,在不同的实际访问次数情况下,计数器的值是如何变化的。

可以看到,当 lfu_log_factor 取值为 1 时,实际访问次数为 100K 后,counter 值就达到 255 了,无法再区分实际访问次数更多的数据了。而当 lfu_log_factor 取值为 100 时,当实际访问次数为 10M 时,counter 值才达到 255,此时,实际访问次数小于 10M 的不同数据都可以通过 counter 值区分出来。

正是因为使用了非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效地区分不同的访问次数,从而进行合理的数据筛选。从刚才的表中,我们可以看到,当 lfu_log_factor 取值为 10 时,百、千、十万级别的访问次数对应的 counter 值已经有明显的区分了,所以,我们在应用 LFU 策略时,一般可以将 lfu_log_factor 取值为 10

前面我们也提到了,应用负载的情况是很复杂的。在一些场景下,有些数据在短时间内被大量访问后就不会再被访问了。那么再按照访问次数来筛选的话,这些数据会被留存在缓存中,但不会提升缓存命中率。为此,Redis 在实现 LFU 策略时,还设计了一个 counter 值的衰减机制

简单来说,LFU 策略使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。

简单举个例子,假设 lfu_decay_time 取值为 1,如果数据在 N 分钟内没有被访问,那么它的访问次数就要减 N。如果 lfu_decay_time 取值更大,那么相应的衰减值会变小,衰减效果也会减弱。所以,如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1,这样一来,LFU 策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。


以上内容是从蒋德钧老师的《Redis核心技术与实战》课程中总结归纳的知识点。有想一起学习的同学,可以扫描下面的二维码购买课程,可以享受拼团价~

或者点击这里

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇