基础

数据结构

String

SDS: 简单动态字符串

相较于c的字符串,有三点优点:

  1. 不仅可以存储文本数据,还可以存储二进制数据,因此可以存储音频视频等。
  2. 内部len属性记录了长度,时间复杂度o(1)
  3. 安全,拼接字符串不会发生溢出,空间不够会自动扩容。

List

List 类型的底层数据结构是由双向链表或压缩列表实现的:

  • 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表

quicklist

quicklist是双向链表和压缩链表的混合体,压缩链表分成多段,每段串起来。

Hash

Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

  • 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了

listpack

  • total_bytes:和ziplist中一样,listpack中也记录了整个listpack的总字节数
  • size:和ziplist中一样,listpack中也记录了整个listpack的总元素的个数
  • 但是在listpack中并没有记录最后一个元素的地址,这个是和ziplist中不同的.

Set

Set 类型的底层数据结构是由哈希表或整数集合实现的:

  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

ZSet

Zset 类型的底层数据结构是由压缩列表或跳表实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

image-20230528143014698

压缩列表非常节省空间,具体体现在,它用一系列连续的entry保存数据,就节省了一个8字节指针的位置。

总的来说redis 是用一个哈希表来保存所有键值对。具体每个哈希桶保存的是指向具体值的指针。

  1. 哈希冲突:链式哈希。

  2. rehash操作:

    为了让rehash更高效,redis默认使用两个全局哈希表:哈希表1和哈希表2, 一开始,刚插入数据时候,默认使用哈希表1,此时的哈希表2没有被分配空间。随着数据的增多,redis开始执行rehash操作:

    1. 给哈希表2分配更多空间,例如是哈希表1的两倍;
    2. 把哈希表1的数据映射并拷贝到哈希表2;
    3. 释放哈希表1;

    第二步设计大量的数据拷贝,会造成redis线程阻塞。Redis采用渐进式rehash

    第二步拷贝数据时候,Redis仍然正常处理客户端的请求,每处理一个请求时候,从哈希表1中的第一个索引开始,顺带着将这个索引位置上的所有entries 拷贝到哈希表2 ;每次伴随着客户端的请求处理一个。把一次大量拷贝的开销,分摊到多次处理请求的过程中。

image-20230528144654084

Redis有5种底层数据结构:哈希表,双向链表,整数数组,跳表,压缩列表。

  • 压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
image-20230528150419550
* **跳表**在链表的基础上增加了多级索引,通过索引未知的几个跳转,实现数据的快速定位。
image-20230528150537718

高性能io模型

通常所说的Redis单线程,主要是指Redis的网络io和键值对读写是由一个线程来完成,这也是Redis对外提供键值存储服务的主要流程。

但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

为什么用单线程

  • 多线程访问共享资源的时候,需要有额外的机制保证正确性,带来额外的开销

单线程为什么快

  • 大部分操作在内存完成,高效的数据结构,本来访问就快
  • 多路复用机制:epoll

持久化机制

AOF日志 避免数据丢失

image-20230528162942788

先执行命令再写日志,可以不用做命令的检查,既然能执行肯定是对的命令,直接记录在aof文件就可以了。

还有一个好处:命令执行后才记录日志,所以不会阻塞当前的写操作。

三种写回策略

  • Always,同步写回:每个写命令执行完就同步的将日志写回磁盘;
  • Everysec, 每秒写回,每个命令执行完,先把日志写到aof文件的内存缓冲区,每隔1s再写入磁盘
  • No, 操作系统控制写回。先写到缓冲区,操作系统决定什么时候写回。

AOF文件重写机制

aof文件会随着命令越来越多变得越来越大,会带来性能问题:

这里的“性能问题”,主要在于以下三个方面:一是,文件系统本身对文件大小有限制,无法保存过大的文件;二是,如果文件太大,之后再往里面追加命令记录的话,效率也会变低;三是,如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用。

我们知道,AOF 文件是以追加的方式,逐一记录接收到的写命令的。当一个键值对被多条写命令反复修改时,AOF 文件会记录相应的多条命令。但是,在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。

image-20230528163814882

AOF重写会阻塞吗?

aof日志主线程写回,但是重写过程是后台线程bgrewriteaof线程完成的,避免阻塞主线程。

image-20230528165024375
  1. AOF 日志重写的时候,是由 bgrewriteaof 子进程来完成的,不用主线程参与,我们今天说的非阻塞也是指子进程的执行不阻塞主线程。但是,你觉得,这个重写过程有没有其他潜在的阻塞风险呢?如果有的话,会在哪里阻塞?

    风险一:Redis 主线程 fork 创建 bgrewriteaof 子进程时,内核需要创建用于管理子进程的相关数据结构,这些数据结构在操作系统中通常叫作进程控制块(Process Control Block,简称为 PCB)。内核要把主线程的 PCB 内容拷贝给子进程。这个创建和拷贝过程由内核执行,是会阻塞主线程的。而且,在拷贝过程中,子进程要拷贝父进程的页表,这个过程的耗时和 Redis 实例的内存大小有关。如果 Redis 实例内存大,页表就会大,fork 执行时间就会长,这就会给主线程带来阻塞风险。

    风险二:bgrewriteaof 子进程会和主线程共享内存。当主线程收到新写或修改的操作时,主线程会申请新的内存空间,用来保存新写或修改的数据,如果操作的是 bigkey,也就是数据量大的集合类型数据,那么,主线程会因为申请大空间而面临阻塞风险。因为操作系统在分配内存空间时,有查找和锁的开销,这就会导致阻塞。

  2. AOF 重写也有一个重写日志,为什么它不共享使用 AOF 本身的日志呢?

    如果都用 AOF 日志的话,主线程要写,bgrewriteaof 子进程也要写,这两者会竞争文件系统的锁,这就会对 Redis 主线程的性能造成影响。

RDB快照 宕机实现快速恢复

Redis所有数据都在内存中,为了提供所有数据的可靠性保证,执行的是全量快照

RDB文件的生成是否会阻塞主线程?

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

  • save: 在主线程执行,会导致阻塞。
  • bgsave: 创建一个子线程,准们用于写入rdb文件,这是默认配置。

快照时数据能修改吗?

  • 写时复制 copy-on-write 技术

现在Linux的fork()使用写时拷贝页来实现新进程的创建,它是一种可推迟甚至避免数据拷贝的技术,刚开始时内核并不会复制整个地址空间,而是让父子进程共享地址空间,只有在写时才复制地址空间,使得父子进程都拥有独立的地址空间,即资源的复制是在只有需要写入时才会发生,因此而称之为Copy on Write(COW)。

在此之前都是以读的方式去和父进程共享资源,这样,在页根本不会被写入的场景下,fork()立即执行exec(),无需对地址空间进行复制,fork()的实际开销就是复制父进程的一个页表和为子进程创建一个进程描述符,也就是说只有当进程空间中各段的内存内容发生变化时,父进程才将其内容复制一份传给子进程,大大提高了效率。

简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。

此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

image-20230528171022301

主从同步

redis的高可靠性:

1. 数据尽量少丢失:aof 和 rdb
1. 服务尽量少中断:增加副本冗余量

Redis提供了主从库模式, 采用读写分离的方式:

  • 读操作:主库从库都可以
  • 写操作,先到主库执行,再由主库将写操作同步给从库

主从库之间如何第一次同步

replicaof 172.16.19.3 6379	// 当前库变成从库,从主库19.3复制数据
redis 5.0 以前是slaveof 命令
image-20230528172243690
  • runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。
  • offset, 此时设置为-1, 表示第一次复制
  • FULLRESYNC: 响应表示第一次复制采用的全量复制

主从级联模式分担全量复制时候的压力

在刚才介绍的主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以通过“主 - 从 - 从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上

主从库:基于长连接的命令传播

主从库网络断了怎么办

**增量复制: **

image-20230528173432896

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

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

image-20230528173344774

哨兵机制

在 Redis 主从集群中,哨兵机制是实现主从库自动切换的关键机制,它有效地解决了主从复制模式下故障转移的这三个问题:

  1. 主库真的挂了吗
  2. 应该选择哪个从库为主库
  3. 怎么把新主库的信息通知给从库和客户端

对应哨兵机制主要负责的三个任务:

监控是指哨兵进程在运行时,周期性地给所有的主从库发送 PING 命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的 PING 命令,哨兵就会判定主库下线,然后开始自动切换主库

然后开始按照一定规则选主,最后会通知

主观下线和客观下线

哨兵进程是PING命令检测主从库的连接情况,用来判断实例的状态, 如果ping超时了,哨兵就会把超时的主库或者从库标记为“主观下线”。

如果是从库,简单的标记为“主观下线“就行了。如果是主库,主从切换开销很大,误判就会带来很大的开销。

哨兵通常采用多实例组成的集群模式进行部署,成为哨兵集群

image-20230528174707665

如何选新主库

筛选+打分

  1. 主从切换过程中,客户端只能读,不能写了

哨兵集群

部署哨兵集群命令

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

这里的ip和端口只要设置主库的。

那么,哨兵实例怎么知道彼此的地址呢

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

发布订阅机制

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

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

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

哨兵向主库发送INFO命令,主库就会把从库列表发送给哨兵

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

每个哨兵实例也可以提供pub/sub机制,客户端从哨兵订阅消息。

从哪个哨兵执行主从切换

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

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

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

切片集群

切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。回到我们刚刚的场景中,如果把 25GB 的数据平均分成 5 份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。如下图所示:

image-20230528181136799

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

Redis Cluster

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

映射过程分为两步:

  1. 首先根据键值对的key,按照crc16算法计算一个 16 bit 的值;
  2. 然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

客户端如何定位数据

客户端和实例建立联系以后,实例会给自己的哈希槽信息发给客户端。Redis实例也会把自己的哈希槽信息发给其他与他相连的实例。

客户端收到哈希槽信息会缓存起来到本地。

但是实例和哈希槽的对应关系不是一成不变的:

  • 集群中,实例增加或者删除,Redis需要重新分配哈希槽
  • 为了负载均衡,Redis需要把哈希槽在所有实例上都重新分布一遍

这个时候,实例是能感知到变化的,但是客户端缓存的没变。Redis Cluster提供了重定向机制

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

缓存

image-20230529002731000