了解 Redis

一、什么是 Redis

Redis 是一个开源、基于内存、使用 C 语言编写的 key-value 数据库,并提供了多种语言的 API。它的数据结构十分丰富,基础数据类型包括:string(字符串)、list(列表,双向链表)、hash(散列,键值对集合)、set(集合,不重复)和 sorted set(有序集合)。主要可以用于数据库、缓存、分布式锁、消息队列等...

以上的数据类型是 Redis 键值的数据类型,其实就是数据的保存形式,但是数据类型的底层实现是最重要的,底层的数据结构主要分为 6 种,分别是简单动态字符串双向链表压缩链表哈希表跳表整数数组。各个数据类型和底层结构的对应关系如下:

数据类型和底层结构的对应关系

stringlisthashsetsorted set
简单动态字符串双向链表、压缩链表压缩链表、哈希表压缩链表、整数数组压缩链表、跳表

各个底层实现的时间复杂度如下:

数据类型和底层结构的对应关系

跳表双向链表压缩链表哈希表整数数组
O(logN)O(N)O(N)O(1)O(N)

可以看出除了 string 类型的底层实现只有一种数据结构,其他四种均有两种底层实现,这四种类型为集合类型,其中一个键对应了一个集合的数据;

1.1 Redis 键值是如何保存的呢?

Redis 为了快速访问键值对,采用了哈希表来保存所有的键值对,一个哈希表对应了多个哈希桶,所谓的哈希桶是指哈希表数组中的每一个元素,当然哈希表中保存的不是值本身,是指向值的指针,如下图。

其中哈希桶中的 entry 元素中保存了key 和value 指针,分别指向了实际的键和值。通过 Redis 可以在 O(1)的时间内找到键值对,只需要计算 key 的哈希值就可以定位位置,但从下图可以看出,在 4 号位置出现了冲突,两个 key 映射到了同一个位置,这就产生了哈希冲突,会导致哈希表的操作变慢。虽然 Redis 通过链式冲突解决该问题,但如果数据持续增多,产生的哈希冲突也会越来越多,会加重 Redis 的查询时间;

Redis 保存数据示意图

为了解决上述的哈希冲突问题,Redis 会对哈希表进行rehash操作,也就是增加目前的哈希桶数量,使得 key 更加分散,进而减少哈希冲突的问题,主要流程如下:

  1. 采用两个 hash 表进行操作,当哈希表 A 需要进行扩容时,给哈希表 B 分配两倍的空间;
  2. 将哈希表 A 的数据重新映射并拷贝给哈希表 B;
  3. 释放 A 的空间。

上述的步骤可能会存在一个问题,当哈希表 A 向 B 复制的时候,是需要一定的时间的,可能会造成 Redis 的线程阻塞,就无法服务其他的请求了。

针对上述问题,Redis 采用了渐进式 rehash,主要的流程是:Redis 还是继续处理客户端的请求,每次处理一个请求的时候,就会将该位置所有的 entry 都拷贝到哈希表 B 中,当然也会存在某个位置一直没有被请求。Redis 也考虑了这个问题,通过设置一个定时任务进行 rehash,在一些键值对一直没有操作的时候,会周期性的搬移一些数据到哈希表 B 中,进而缩短 rehash 的过程。

1.2 Redis 为什么采用单线程呢?

首先要明确的是 Redis 单线程指的是网络 IO键值对读写是由一个线程来完成的,但 Redis 持久化、集群数据等是由额外的线程执行的。了解 Redis 使用单线程之前可以先了解一下多线程的开销。

通常情况下,使用多线程可以增加系统吞吐率或者可以增加系统扩展性,但多线程通常会存在同时访问某些共享资源,为了保证访问共享资源的正确性,就需要有额外的机制进行保证,这个机制首先会带来一定的开销。其实对于多线程并发访问的控制一直是一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果。即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。

这也是 Redis 使用单线程的主要原因。

值得注意的是在 Redis6.0 中引入了多线程。在 Redis6.0 之前,从网络 IO 处理到实际的读写命令处理都是由单个线程完成的,但随着网络硬件的性能提升,Redis 的性能瓶颈有可能会出现在网络 IO 的处理上,也就是说单个主线程处理网络请求的速度跟不上底层网络硬件的速度。针对此问题,Redis 采用多个 IO 线程来处理网络请求,提高网络请求处理的并行度,但多 IO 线程只用于处理网络请求,对于读写命令,Redis 仍然使用单线程处理!!!

1.3 Redis 单线程为什么还这么快?

IO 多路复用机制:使其在网络 IO 操作中能并发处理大量的客户端请求从而实现高吞吐率

IO 多路复用机制是指一个线程处理多个 IO 流,也就是常说的 select/epoll 机制。在 Redis 运行单线程的情况下,该机制允许内核中同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果,进而提升并发性。

Redis 是基于内存的,绝大部分请求都是内存操作,十分的迅速;

Redis 具有高效的底层数据结构,为优化内存,对每种类型基本都有两种底层实现方式;

主要执行过程是单线程,避免了不必要的上下文切换和资源竞争,不存在多线程导致的 CPU 切换和锁的问题;

二、Redis 数据丢失问题

由上一小节我们大概了解了 Redis 的存储和快的主要原因,通常情况下我们会把 Redis 当作缓存使用,将后端数据库中的数据存储在内存中,然后从内存中直接读取数据,响应速度会非常快。但是如果服务器宕机了,内存中的数据也就会丢失,当然我们可以重新从后端数据库中恢复这些缓存数据,但是频繁访问数据库,会给数据库带来一定的压力;另一方面数据是从慢速的数据库中读取的,性能肯定比不上 Redis,也会导致这些数据的应用程序响应变慢。

所以对 Redis 来说,实现数据的持久化,避免从后端恢复数据是至关重要的,目前 Redis 持久化主要有两大机制,分别是AOF(Append Only File)日志RDB 快照

2.1 AOF 日志

AOF 日志是写后日志,也就是 Redis 先执行命令,然后将数据写入内存,最后才记录日志,如下图:

Redis AOF 操作过程

AOF 日志中记录的是 Redis 收到的每一条命令,这些命令都是以文本的形式保存的,例如我们以 Redis 收到 set key value 命令后记录的日志为例,AOF 文件中保存的数据如下图所示,其中*3 代表当前命令分为三部分,每部分都是通过$+数字开头,其中数字表示该部分的命令、键、值一共有多少字节。

Redis AOF 日志内容

AOF 为了避免额外的检查开销,并不会检查命令的正确性,如果先记录日志再执行命令,就有可能记录错误的命令,再通过 AOF 日志恢复数据的时候,就有可能出错,而且在执行完命令后记录日志也不会阻塞当前的写操作。但是 AOF 是存在一定的风险的,首先是如果刚执行一个命令,但是 AOF 文件中还没来得及保存就宕机了,那么这个命令和数据就会有丢失的风险,另外 AOF 虽然可以避免对当前命令的阻塞(因为是先写入再记录日志),但有可能会对下一次操作带来阻塞风险(可能存在写入磁盘较慢的情况)。这两种情况都在于 AOF 什么时候写入磁盘,对于这个问题 AOF 机制提供了三种选择(appendfsync 的三个可选值),分别是Always、Everysec、No具体如下:

AOF 写入磁盘的机制

Always同步写回:每个命令执行完立马写入磁盘
Everysec每秒写回:每个命令执行完,先把日志写入 AOF 文件的缓冲区,每隔一秒把缓冲区的内容写入磁盘
No操作系统的写回:每个命令执行完,先把日志写入 AOF 文件的缓冲区,由操作系统决定何时把缓冲区的内容写入磁盘

我们可以根据不同的场景来选择不同的方式:

  1. Always 可靠性较高,数据基本不丢失,但是对性能的影响较大;
  2. Everysec 性能适中,即使宕机也只会丢失 1 秒的数据;
  3. No 性能好,但是如果宕机丢失的数据较多。

虽然有一定的写回策略,但毕竟 AOF 是通过文件的形式记录所有的写命令,但如果指令越来越多的时候,AOF 文件就会越来越大,可能会超出文件大小的限制;另外,如果文件过大再次写入指令的话效率也会变低;如果发生宕机,需要把 AOF 所有的命令重新执行,以用于故障恢复,数据过大的话这个恢复过程越漫长,也会影响 Redis 的使用。

此时,AOF 重写机制就来了:

AOF 重写就是根据所有的键值对创建一个新的 AOF 文件,可以减少大量的文件空间,减少的原因是:AOF 对于命令的添加是追加的方式,逐一记录命令,但有可能存在某个键值被反复更改,产生了一些冗余数据,这样在重写的时候就可以过滤掉这些指令,从而更新当前的最新状态。

AOF 重写的过程是通过主线程 fork 后台的 bgrewriteaof 子进程来实现的,可以避免阻塞主进程导致性能下降,整个过程如下:

  • AOF 每次重写,fork 过程会把主线程的内存拷贝一份 bgrewriteaof 子进程,里面包含了数据库的数据,拷贝的是父进程的页表,可以在不影响主进程的情况下逐一把拷贝的数据记入重写日志;
  • 因为主线程没有阻塞,仍然可以处理新来的操作,如果这时候存在写操作,会先把操作先放入缓冲区,对于正在使用的日志,如果宕机了这个日志也是齐全的,可以用于恢复;对于正在更新的日志,也不会丢失新的操作,等到数据拷贝完成,就可以将缓冲区的数据写入到新的文件中,保证数据库的最新状态。

2.2 RDB 快照

上一小节里了解了避免 Redis 数据丢失的 AOF 方法,这个方法记录的是操作命令,而不是实际的数据,如果日志非常多的话,Redis 恢复的就很缓慢,会影响到正常的使用。

这一小节主要是讲述的另一种 Redis 数据持久化的方式:内存快照。即记录内存中的数据在某一时刻的状态,并以文件的形式写到磁盘上,即使服务器宕机,快照文件也不会丢失,数据的可靠性也就得到了保证,这个文件称为 RDB(Redis DataBase)文件。可以看出 RDB 记录的是某一时刻的数据,和 AOF 不同,所以在数据恢复的时候只需要将 RDB 文件读入到内存,就可以完成数据恢复。但为了 RDB 数据恢复的可靠性,在进行快照的时候是全量快照,会将内存中所有的数据都记录到磁盘中,这就有可能会阻塞主线程的执行。Redis 提供了两个命令来生成 RDB 文件,分别是savebgsave

  • save:在主线程中执行,会导致阻塞;
  • bgsave:会创建一个子进程,该进程专门用于写入 RDB 文件,可以避免主线程的阻塞,也是默认的方式。

我们可以采用 bgsave 的命令来执行全量快照,提供了数据的可靠性保证,也避免了对 Redis 的性能影响。执行快照期间数据能不能修改呢?如果不能修改,快照过程中如果有新的写操作,数据就会不一致,这肯定是不符合预期的。Redis 借用了操作系统的写时复制,在执行快照的期间,正常处理写操作。

主要流程为:

  • bgsave 子进程是由主线程 fork 出来的,可以共享主线程的所有内存数据;
  • bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件中;
  • 如果主线程对这些数据都是读操作,例如 A,那么主线程和 bgsave 子进程互不影响;
  • 如果主线程需要修改一块数据,如 C,这块数据会被复制一份,生成数据的副本,然主线程在这个副本上进行修改;bgsave 子进程可以把原来的数据 C 写入 RDB 文件;

写时复制机制保证快照期间数据可修改

通过上述方法就可以保证快照的完整性,也可以允许主线程处理写操作,可以避免对业务的影响。那多久进行一次快照呢

理论上来说快照时间间隔越短越好,可以减少数据的丢失,毕竟 fork 的子进程不会阻塞主线程,但是频繁的将数据写入磁盘,会给磁盘带来很多压力,也可能会存在多个快照竞争磁盘带宽(当前快照没结束,下一个就开始了)。另一方面,虽然 fork 出的子进程不会阻塞,但 fork 这个创建过程是会阻塞主线程的,当主线程需要的内存越大,阻塞时间越长;

针对上面的问题,Redis 采用了增量快照,在做一次全量快照后,后续的快照只对修改的数据进行记录,需要记住哪些数据被修改了,可以避免全量快照带来的开销。

2.3 混合使用 AOF 日志和 RDB 快照

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

在 Redis4.0 提出了混合使用 AOF 和 RDB 快照的方法,也就是两次 RDB 快照期间的所有命令操作由 AOF 日志文件进行记录。这样的好处是 RDB 快照不需要很频繁的执行,可以避免频繁 fork 对主线程的影响,而且 AOF 日志也只记录两次快照期间的操作,不用记录所有操作,也不会出现文件过大的情况,避免了重写开销。

通过上述方法既可以享受 RDB 快速恢复的好处,也可以享受 AOF 记录简单命令的优势。

对于 AOF 和 RDB 的选择问题:

  • 数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择;
  • 如果允许分钟级别的数据丢失,可以只使用 RDB;
  • 如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。

三、Redis 数据同步

当 Redis 发生宕机的时候,可以通过 AOF 和 RDB 文件的方式恢复数据,从而保证数据的丢失从而提高稳定性。但如果 Redis 实例宕机了,在恢复期间就无法服务新来的数据请求;AOF 和 RDB 虽然可以保证数据尽量的少丢失,但无法保证服务尽量少中断,这就会影响业务的使用,不能保证 Redis 的高可靠性。

Redis 其实采用了主从库的模式,以保证数据副本的一致性,主从库采用读写分离的方式:从库和主库都可以接受读操作;对于写操作,首先要到主库执行,然后主库再将写操作同步到从库;

只有主库接收写操作可以避免客户端将数据修改到不同的 Redis 实例上,其他客户端进行读取时可能就会读取到旧的值;当然,如果非要所有的库都可以进行写操作,就要涉及到锁、实例间协商是否完成修改等一系列操作,会带来额外的开销;

3.1 主从库如何进行第一次数据同步

当存在多个 Redis 实例的时候,可以通过 replicaof 命令形成主库和从库的关系,在从库中输入:replicaof 主库 ip 6379 就可以在主库中复制数据,具体有三个阶段:

  • 首先是主从库建立连接、协商同步的过程,具体的从库向主库发送 psync 命令,代表要进行数据同步;psync 中包含了主库的 runID(Redis 启动时生成的随机 ID,初始值为:?)和复制进度 offset(设为-1,代表第一次复制)两个参数,主库接收到 psync 命令会,会用 FULLRESYNC 命令返回给从库,包含两个参数:主库 runID 和复制进度 offset;其中 FULLRESYNC 代表的全量复制,会将主库所有的数据都复制给从库;
  • 待从库接收到数据后,在本地完成数据加载,具体的主库执行 bgsave 命令,生成 RDB 文件,然后将文件发给从库,从库接收到 RDB 文件后,首先清空当前数据,然后再加载 RDB 文件;这个过程主库不会被阻塞,仍然可以接受请求,如果存在写操作,刚刚生成的 RDB 文件中是不包含这些新数据的,此时主库会在内存中用专门的 replication buffer 记录 RDB 文件生成后所有的写操作;
  • 最后,主库会把 replication buffer 中的修改操作发给从库,从库重新执行这些操作,就可以实现主从库同步了。

如果从库的实例过多,对于主库来说有一定的压力,主库会频繁 fork 子进程以生成 RDB 文件,fork 这个操作会阻塞主线程处理正常请求,导致响应变慢,Redis 采用了主-从-从的模式,可以手动选择一个从库,用来同步其他从库的数据,以减少主库生成 RDB 文件和传输 RDB 文件的压力;如下图:

级联的“主-从-从”模式

这样从库就可以知道在进行数据同步的时候,不需要和主库直接交互,只需要和选择的从库进行写操作同步就可以了,从而减少主库的压力。

3.2 主库如果挂了呢?

Redis 采用主从库的模式保证数据副本的一致性,在这个模式下如果从库发生故障,客户端可以向其他主库或者从库发送请求,但如果主库挂了,客户端就没法进行写操作了,也无法对从库进行相应的数据复制操作;

不管是写服务中断还是从库无法进行数据同步,都是不能接受的,所以当主库挂了以后,需要一个新的主库来代替挂掉的主库,这样就就会产生三个问题:

  1. 怎么判断主库是真的挂了,而不是网络异常?
  2. 主库如果挂了,该选择哪个从库作为新的主库?
  3. 怎么把新主库的相关信息通知给从库和客户端?

Redis 采用了哨兵机制应对这些问题,哨兵机制是实现主从库自动切换的关键机制,在主从库运行的同时,它也在进行监控、选择主库和通知的操作;

  • 监控。哨兵在运行时,周期性的给所有的主从库发送 PING 命令,检测是否仍在运行。如果从库没有响应哨兵的 PING 命令,哨兵就会将它标记为下线状态;如果主库没有在规定时间内响应哨兵的 PING 命令,哨兵也会判断主库下限,然后开始自动切换主库的流程。
  • 选主。主库挂了之后,哨兵需要按照一定的规则选择一个从库,并将他作为新的主库。
  • 通知。选取了新的主库后,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令和新主库建立连接,并进行数据复制;同时哨兵也会将新主库的消息发给客户端;

下图展示了哨兵的几个操作的任务:

哨兵机制的三项任务与目标

但这样也会存在一个问题,哨兵判断主从库是否下线如果出现失误呢?

对于从库,下线影响不大,集群的对外服务也不会间断。但是如果哨兵误判主库下线,可能是因为网络拥塞或者主库压力大的情况,这时候也就需要进行选主并让从库和新的主库进行数据同步,这个过程是有一定的开销的,所以我们要尽可能的避免误判的情况。哨兵机制也考虑了这一点,它通常采用多实例组成的集群模式进行部署,也被称为哨兵集群;通过引入多个哨兵实例一起判断,就可以尽可能的避免单个哨兵产生的误判问题。这时候判断主库是否下线不是由一个哨兵决定的,只有大多数哨兵认为该主库下线,主库才会标记为“客观下线”。

简单的来说”客观下线“的标准是当 N 个哨兵实例,有 N/2 + 1 个实例认为该主库为下线状态,该主库就会被认定为“客观下线”。这样就可以尽量的避免单个哨兵产生的误判问题(N/2 + 1 这个值也可以通过参数改变);

如果判断了主库为主观下线,怎么选取新的主库呢?

上面有说道,这一部分也是由哨兵机制来完成的,选取主库的过程分为“筛选 和 打分”。主要是按照一定的规则过滤掉不符合的从库,再按照一定的规则给其余的从库打分,将最高分的从库作为新的主库。

  • 筛选。首先从库一定是正在运行的,还要判断从库之前的网络连接状态,如果总是断连并且超过了一定的阈值,哨兵会认为该从库的网络不好,也会将其筛掉。
  • 打分。哨兵机制根据三个规则依次进行打分:从库优先级、从库复制进度以及从库 ID 号;在某一轮有从库得分最高,那么它就是新的主库了,选主过程结束。如果该轮没有出现最高的,继续下一轮。
  1. 优先级最高的从库。用户可以通过 slave-priority 配置项,给不同的从库设置优先级。选主库的时候哨兵会给优先级高的从库打高分,如果一个从库优先级高,那么就是新主库;
  2. 从库复制进度最接近。主库的 slave_repl_offset 和从库 master_repl_offset 越接近,得分越高;
  3. ID 小的从库得分高。如果上面两轮也没有选出新主库,就会根据从库实例的 ID 来判断,ID 越小的从库得分越高。

由此哨兵可以选择出一个新的主库。

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

这个过程和判断主库“客观下线”类似,也是一个投票的过程。如果某个哨兵判断了主库为下线状态,就会给其他的哨兵实例发送is-master-down-by-addr的命令,其他实例会根据自己和主库的连接状态作出 Y 或 N 的响应,Y 相当于赞成票,N 为反对票。一个哨兵获得一定的票数后,就可以标记主库为“客观下线”,这个票数是由参数 quorum 设置的。如下图:

例如:现在有 3 个哨兵,quorum 配置的是 2,那么,一个哨兵需要 2 张赞成票,就可以标记主库为“客观下线”了。这 2 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

这个时候哨兵就可以给其他哨兵发送消息,表示希望自己来执行主从切换,并让所有的哨兵进行投票,这个过程称为“Leader 选举”,进行主从切换的哨兵称为 Leader。任何一个想成为 Leader 的哨兵都需要满足两个条件:

  • 拿到半数以上的哨兵赞成票;
  • 拿到的票数需要大于等于 quorum 的值。

以上就可以选出 Leader 然后进行主从库切换了。

四、Redis 集群

数据量过多如何处理?

当数据量过多的情况下,一种简单的方式是升级 Redis 实例的资源配置,包括增加内存容量、磁盘容量、更好配置的 CPU 等,但这种情况下 Redis 使用 RDB 进行持久化的时候响应会变慢,Redis 通过 fork 子进程来完成数据持久化,但 fork 在执行时会阻塞主线程,数据量越大,fork 的阻塞时间就越长,从而导致 Redis 响应变慢。

Redis 的切片集群可以解决这个问题,也就是启动多个 Redis 实例来组成一个集群,再按照一定的规则把数据划分为多份,每一份用一个实例来保存,这样客户端只需要访问对应的实例就可以获取数据。在这种情况下 fork 子进程一般不会给主线程带来较长时间的阻塞,如下图:

切片集群架构图

将 20GB 的数据分为 4 分,每份包含 5GB 数据,客户端只需要找到对应的实例就可以获取数据,从而减少主线程阻塞的时间。

展开阅读全文

本文系作者在时代Java发表,未经许可,不得转载。

如有侵权,请联系nowjava@qq.com删除。

编辑于

关注时代Java

关注时代Java