内存键值数据库,采用哈希表作为索引,一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针
Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。
当哈希冲突越来越多,会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。所以,Redis 会对哈希表做 rehash操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的entries。
Redis 能够在实际业务场景中得到广泛的应用,就是得益于支持多样化类型的 value。
Redis通过网络框架访问键值数据库
单线程
String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:
Redis 是单线程,主要是指 Redis 的网络 IO和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的
因为多线程编程模式面临共享资源的并发访问控制问题,即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。
一方面,Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是 Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。
当 Redis监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。
在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。
针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。
类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。这样才能保证 Redis 线程,既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis无法处理实际到达的连接请求或数据。
用什么机制继续监听呢?
IO 多路复用机制是指一个线程处理多个 IO 流,比如就是我们经常听到的linux的select/epoll 机制。
简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个IO 流的效果。
为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制, 一旦监测到 FD 上有请求到达时,针对不同事件的发生,调用相应的处理函数。
Redis 的持久化模块能支持两种方式:日志(AOF)和快照(RDB)
写后日志。先执行命令,把数据写入内存,再记录日志
写后日志的好处是:先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。
1. 避免出现记录错误命令的情况
2. 不会阻塞当前的写操作
存在的风险
基于此,AOF 机制给我们提供了三个选择,也就是 AOF 配置项appendfsync 的三个可选值。
解决日志文件太大的问题
Redis 根据数据库的现状创建一个新的 AOF 文件,旧日志文件中的多条命令,在重写后的新日志中变成了一条命令。
一个拷贝,两处日志
一个拷贝,主线程会fork出bgrewriteaof子进程,内存会拷贝一份,然后由bgrewriteaof子进程逐一把拷贝的数据写成操作,记入重写日志。
两处日志,主线程仍然会处理新来的操作,此时的写操作记录在主线程的AOF日志的缓冲区中,即使宕机也能恢复,因为这个 AOF 日志的操作仍然是齐全的,可以用于恢复;等bgrewirteaof子进程拷贝完后,主进程的最新操作也会写入新的AOF文件
把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。
save:在主线程中执行,会导致阻塞;
bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是Redis RDB 文件生成的默认配置。
避免阻塞和正常处理写操作并不是一回事。此时,主线程的确没有阻塞,可以正常接收请求,但是,为了保证快照完整性,它只能处理读操作,因为不能修改正在执行快照的数据。
bgsave进程与主线程共享数据,但当主线程要修改数据时,会复制原来的数据生成一个副本,bgsave子进程就把这个副本数据写入RDB文件,这个过程中,主线程仍可以不受影响的修改数据
有两个方面的开销问题
改进,使用增量快照
但使用增量快照需要额外的元数据信息去记住哪些数据被修改了,比如只有32字节的键值对,为了记录被修改的元数据信息可能就需要8字节,这就有点得不偿失了。
况且,虽然快照恢复速度快,但是频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销。
如何做到使用RDB的快速恢复,兼顾较小开销又尽量少丢数据呢?
Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
关于AOF和RDB的选择上,有三点建议:
为什么说Redis具有高可靠性?主要在以下两方面
那么,持久化的AOF和RDB是保证了数据尽量少丢失的。那如何保证服务尽量少中断呢?
Redis的做法就是增加副本的冗余量,一份数据多个实例,即使其中一个实例出了异常,另外的实例也能正常使用。
如何保证数据副本的一致性呢?
主从库采用读写分离的方式
当启动多个Redis实例的时候,就可以通过replicaof(Redis5之前用slaveof)命令形成主从库的关系
在从库上执行
replicaof 主库ip 6379
之后会经历三个阶段来进行第一次同步
从以上分析不难发现,比较耗时的操作有两个
通过“主 - 从 - 从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上
具体就是选定一个从库来级联其他从库,让其他从库从这个指定的从库拉数据进行同步
如果主从库完成了全量复制,将会一直维护一个基于长连接的命令传播,可以避免频繁建立连接的开销。但这个过程中,存在网络断连或阻塞的风险点
在Redis2.8前网络断连会重新进行全量复制,开销非常大
在Redis2.8后,变成增量复制,把主从库网络断连期间主库收到的命令,同步给从库
网络断连的增量复制奥妙在于 repl_backlog_buffer 这个缓冲区,之前提到这个缓冲区是建立第一次复制时第二阶段用来记录生成RDB文件期间新的写操作。
repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。
主从库的连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的偏移量发给主库,主库判断断连期间写的偏移量和从库当前的偏移量,把两个偏移量中间的命令给从库同步就行
因为是个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。
解决办法是调整repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 X 操作大小 - 主从库间网络传输命令速度 X 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值。
在主库故障的时候通常会有三个问题
哨兵机制是实现主从库自动切换的关键机制,它有效地解决了主从复制模式下故障转移的这三个问题。
哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:
监控、选主(选择主库)和通知。
在监控中,从库一般标记为主观下线,但主库不能轻易被判断,需要判断为客观下线(既定事实的下线)才能下线,因为如果是哨兵的误判,后续的选主和通知会带来额外的开销
如何减少误判:
哨兵集群,使用少数服从多数的方式,当多数哨兵认为主库主观下线,则标记主库为客观下线
哨兵按照一定筛选条件去掉不符合条件的从库,再按照一定规则进行打分,分最高的成为新主库
一定筛选条件:从库的在线状态、稳定的网络连接
一定打分规则:从库配置的优先级、与主库的同步程度、id号最小的从库优先
首先,哨兵会按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库,然后,依次按照优先级、复制进度、ID 号大小再对剩余的从库进行打分,只要有得分最高的从库出现,就把它选为新主库。
哨兵实例通过以下命令连接主库
sentinel monitor <master-name> <ip> <redis-port> <quorum>
从命令可以看出哨兵实例是彼此不知道地址的,获取彼此地址主要依靠redis的pub/sub 机制
哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP 和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端口。
哨兵实际上是通过订阅“sentinel:hello”的频道实现互相通信。只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换
给主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。
基于哨兵自身的 pub/sub 功能,这实现了客户端和哨兵之间的事件通知。
订阅不同频道可以获取不同的信息
让客户端从哨兵这里订阅消息了。具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息
这里类似于主库的客观下线,也是一个投票的过程
当一个哨兵判断主库主观下线之后,通过频道像其他哨兵发送送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。
一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。赞成票数的规定数值是在配置文件中的quorum配置的,假如有5个哨兵,quorum配置的是3,那么这个哨兵至少要获得3张赞成票才能标记主库为客观下线。
leader选举
当获取了足够的赞成票后,这个哨兵会给其他哨兵发信息,表示自己想来执行这个主从切换的流程,先给自己投一张票,再让其他哨兵进行投票,投票选出来的就是leader。注意,每个哨兵只能投一次票,如果投给自己或别人了,就默认拒绝下次投票。只有获取半数以上的哨兵赞成票,才能成为leader