String(字符串)、List(列表)、
Hash(哈希)、Set(集合)和 Sorted Set(有序集合)
Redis使用了一个哈希表来保存所有的键值对,一个哈希表可以看做一个数组,数组中的每个元素称为一个哈希桶
redis采用拉链法来解决哈希冲突,即同一个哈希桶中的多个元素用一个链表保存,彼此之间使用指针链接
当拉链过长的时候就会有操作速度变慢的问题,因为查找冲突的元素需要去遍历链表
解决方法rehash
为了是操作更高效,Redis默认使用了两个全局哈希表,哈希表1和哈希表2
插入数据时默认使用哈希表1,哈希表2并没有分配空间,当数据逐渐增多时开始进行rehash
rehash可以分为3部分
然后两者的身份发生了改变,哈希表1变为待定状态
这样也会出现一个问题就是一次进行大量的数据迁移,会导致主线程的阻塞,无法服务其他请求
渐进式rehash
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求
时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝
到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的
entries。
集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表
压缩链表
压缩链表类似于一个数据,数组中的每一个元素都对应保存一个数据。
压缩链表的表头有三个字段zlbytes、zltail和zllen分别表示列表长度、链表尾的偏移量和链表中的entry个数,表尾还有一个zlend表示列表结束
查找定位第一个元素和最后一个元素通过表头三个字段时间复杂度为O(1),但查找其他元素时时间复杂度就变成O(N)了
跳表
跳表是在链表的基础上增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位
Redis 是单线程,主要是指 Redis 的网络 IO
和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。
对于一个多线程的系统来说,在有合理资源分配情况下,可以增加系统中处理请求操作的资源实体,进而提升系统能够同时处理的请求数,即吞吐率
但是采用多线程时,如果没有良好的系统设计,实际得到的结果是
多线程系统中通常会存在被多线程同时访问的资源,当多个线程要修改这个共享资源时,为了保证共享资源的正确性,需要有额外的机制来保证,而这个额外的机制就会带来额外的开销 -> 多线程编程模式面临的共享资源的 并发访问控制问题
Redis的操作都是在内存上完成,同时它采用了高效的数据结构,还有一方面就是Redis采用了多路复用机制,使其在网络IO中能并发处理处理大量的客户端请求
多路复用机制
处理一个 Get 请求,需要监听客户端请求 (bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析 客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结 果,即向 socket 中写回数据(send)。
在这一过程中bind/listen、accept、recv、parse 和 send 属于网络 IO 处 理,而 get 属于键值数据操作。既然 Redis 是单线程,那么,最基本的一种实现是在一个 线程中依次执行上面说的这些操作
其中accept()和recv()都属于潜在的阻塞点,当Redis监听到一个客户端有连接请求,但一直未能成功建立起连接的时候就会阻塞到accept()函数,同理当Redis通过recv()从一个客户端读取数据的时候,如果数据一个没有到达,Redis也会一直阻塞在recv()
对于这种情况,我们可以采用socket模型所支持的非阻塞模式
我们可以对监听套接字设置非阻塞模式:当Redis调用accept()但一直未有连接请求到达时,Redis可以返回处理其他的操作,同时也会有机制继续监听在套接字上等待后续连接请求,并在有请求时通知Redis,也就是IO多路复用机制
在 Redis 只运行单线程的情况下,该机制允许内核中,同 时存在多个监听套接字和已连接套接字。
为了在请求到达时通知到Redis线程,select/epoll提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数
回调机制的工作原理:
select/epoll 一旦监测到 FD 上有请求到达时,就 会触发相应的事件。
这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来, Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时, Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件 的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
我们可以把Redis当作缓存使用,但是这也会有一个不可忽略的问题:一旦服务器宕机,内存中的所有数据将全部丢失
出现这种情况的时候,我们可以从后端服务器中恢复这些数据,但是这会频繁访问数据库给数据库这会对数据库带来巨大的压力,同时在数据库中读取出来性能也比不上从Redis中读取。所以避免从后端数据中进行恢复至关重要。
AOF日志是一种写后日志,即Redis先执行命令,将数据写入内存然后才记录日志
为什么是写后日志?
AOF日志里记录的是Redis收到的每一条命令,这些命令都是以文本形式保存的
以"set testkey testvalue"命令记录的日志为例
命令以三部分组成,"* 3"表示命令有三个部分,每部分以"$ + 数字"开头后面紧跟这具体的命令、键或值,数字表示这部分中的命令、键或值一共有多少字节
同时Redis在向AOF里面记录日志的时候,并不会先对这些命令进行语法检查,先后日志的方式让系统先执行命令命令执行成功才会被记录到日志中,否则,系统就会直接向客户端报错,避免记录错误指令的情况
同时它在命令执行后才记录日志,不会阻塞当前的写操作
AOF日志有两个潜在的风险
可以发现这两个风险都是和AOF写回磁盘的时机相关的
三种写回策略
三种写回策略的优缺点:
AOF是以追加的形式进行写日志的,同时AOF日志又是记录的执行,这样很容易就会出现AOF日志过大的情况。
重写:当一个键值对被多条写命令反复修改时,AOF文件会记录相应的多条命令。在重写时根据这个键值对当前的最新状态,为他生成对应的写入指令,这样重写日志中一个键值对只用一条命令就行了。
重写过程是由后台子进程bgwriteaof来完成的,这也是为了避免阻塞主线程,导致数据库性能下降
重写的过程可以总结为: "一个拷贝,两处日志"
一个拷贝:每次执行重写时,主线程fork出后台的bgrewriteaof子进程,fork会把主线程的内存拷贝一份给bgrewriteaof子进程,这里面包含了数据库的最新数据。然后bgrewriteaof子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。
两处日志:
使用AOF方法进行故障恢复的时候,需要逐一把操作日志都执行一遍,操作日志非常多的话redis会恢复的很缓慢,影响到正常使用。这时内存快照(RDB)的方式是一种很好的选择。
内存快照:内存快照就是指内存中的数据在某一个时刻的状态记录,类似与照片,给朋友拍照,一张照片就能把朋友一瞬间的形象完全记下来。
对于Redis来说就是把某一时刻的状态以文件的形式写到磁盘上,这就是快照, 即是宕机快照文件也不会丢失,数据的可靠性也就得到了保证。快照文件就成为RDB文件。RDB就是Redis DataBase的缩写
和 AOF 相比,RDB 记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我们可以直接把 RDB 文件读入内存,很快地完成恢复。
对哪些数据做快照
Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中
但是对内存的全量数据做快照,把它们全部写入磁盘也会花费很多时间,全量数据越多,RDB文件越大,往磁盘上写数据的时间开销就越大同时redis的单线程模型决定了要尽量避免所有会阻塞主线程的操作,对于任何操作都需要考虑一个问题"它会阻塞主线程吗?"
Redis提供了两个命令来生成RDB文件,分别问save 和 bgsave
快照时数据能修改吗
如果快照执行期间数据不能被修改,是会有潜在问题的。对于刚刚的例子来说,在做快照的 20s 时间里,如果这 4GB 的数据都不能被修改,Redis 就不能处理对这些数据的写操作,那无疑就会给业务服务造成巨大的影响。你可能会想到,可以用 bgsave 避免阻塞啊。这里我就要说到一个常见的误区了,避免阻 塞和正常处理写操作并不是一回事。此时,主线程的确没有阻塞,可以正常接收请求,但 是,为了保证快照完整性,它只能处理读操作,因为不能修改正在执行快照的数据。 为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提 供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。
--来自《Redis核心原理与实战》
写时复制技术:
bgsave子进程是由主线程fork生成的,可以共享主线程的所有内存数据,bgsave子进程互相不影响,bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。
如果主线程对这些数据也都是读操作,那么主线程和bgsave子进程相互不影响。但是主线程如果要修改一块数据,这块数据就会被复制一份生成副本,主线程在副本上进行操作,子进程将元数据写入到RDB文件。
快照的时机
快照间隔时间过度会带来的问题,虽然bgsave执行时不阻塞主线程,但是如果频繁地执行全量快照,也会带来两方面的开销
增量快照:做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。
这时记录对哪些键值对进行了修改也是一种很大的资源消耗。
内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
redis高可靠性的两层含义:
Redis通过RDB和AOF保证了前者,而后者Redis通过增加副本冗余量,将一部分数据同时保存到多个实例上。即使有一个实例出现了故障,需要过一段时间才能恢复,其他实例也可以对外提供服务,不会影响业务使用。
Redis提供了主从库模式来保证数据副本的一致,主从库之间采用的是读写分离的方式
读操作:主库、从库都可以接收
写操作:首先到主库中执行,然后,主库将写操作同步给从库
主从库一旦采用读写分离,所有数据的修改只会在主库上进行,不用协调三个实例,主库有了最新的数据后,会同步给从库,这样就可以实现主从库的数据是一致的。
当启动多个Redis实例的时候,它们之间就可以通过replicaof(Redis5.0之前使用slaveof)命令形成主库和从库的关系
例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据:
replicaof 172.16.19.3 6379
第一阶段:主从库间建立连接、协商同步的过程,为全量复制做准备
从库和主库建立起连接,并告诉主库即将进行同步,主库确认恢复后,主从库间就开始同步
具体流程 : 从库向主库发送psync命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync命令包含了主库的runID和复制进度offset两个参数
主库收到psync命令后,会用FULLRESYNC响应命令带上两个参数:主库runID和主库的复制进度offset,返回给从库,从库收到响应后记录下这两个参数。
FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库
第二阶段:主库将所有数据同步给从库,从库收到数据后,在本地完成数据加载。(依赖内存快照生成的RDB文件)
主库执行bgsave命令,生成RDB文件,然后将文件发给从库,从库接收到RDB文件后,会先清空当前数据库,然后加载RDB文件。
第三阶段:主库会把第二阶段执行过程中新收到的写命令,再发送给从库(当主库完成RDB文件发送后,就会把此时replication buffer中的修改操作发送给从库,从库再重新执行这些操作)
主从库第一次完成数据同步的过程,对主库来说需要完成两个耗时的操作:生成RDB文件和传输RDB文件
如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于fork子进程生成RDB文件,进行数据的全量同步。fork这个操作是会阻塞主线程处理正常请求,从而导致主库相应应用程序的请求速度变慢。同时传输RDB文件也会占用主库的网络宽带,同样会给主库的资源使用带来压力。
主-从-从模式
可以通过主-从-从模式将主库生成RDB和传输RDB的压力,以级联的方式分散到从库上。
一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这 个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。
风险点:
网络断连或阻塞。如果网络断连,主从库之间就无法进行命令传播了,从库的数据自然也就没办法和主库保持一致了,客户端就可能从从库读到旧数据
从Redis2.8开始,网络断了之后,主从库会通过增量复制的方法继续同步。即只把网络断连期间主库收到的命令,同步给从库。
repl_backlog_buffer缓冲区
主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。 repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。
刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大。同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等
主从库的连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的 slave_repl_offset 发给主库,主库会判断自己的 master_repl_offset 和 slave_repl_offset 之间的差距。在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,master_repl_offset会大于 slave_repl_offset。此时,主库只用把master_repl_offset 和 slave_repl_offset之间的命令操作同步给从库就行。
如果从库的读取速 度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致
我们要想办法避免这一情况,一般而言,我们可以调整 repl_backlog_size 这个参 数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库 写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑 到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值
从库发生故障,客户端可以继续向主库或其他从库发送请求,进行相关的操作,但是如果主库发生了故障就会直接影响到从库的同步,这时就需要通过哨兵机制来选出最新的主库
哨兵主要负责的三个任务:监控、选主和通知
哨兵进程会使用ping命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。
主观下线:
如果哨兵发现主库或从库对ping的响应超时了,那么,哨兵就会先把它标记为“主观下线”
从库:直接标记为主观下线
主库:判断是否有误判的情况,因为一旦启动主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。
客观下线:只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”
“筛选 + 打分”
先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,
给剩下的从库逐个打分,将得分最高的从库选为新主库
通过部署多个哨兵实例就形成了一个哨兵集群。哨兵集群中的多个实例共同判断可以降低对主库下线的误判率。
部署哨兵集群时配置哨兵信息只需要用到下面这个配置项,设置主库的IP和端口,并没有配置其他哨兵的连接信息。
sentinel monitor
哨兵是如何组成集群的?
哨兵实例之间可以相互发现,要归功于Redis提供的pub/sub机制(发布/订阅机制)
哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP 和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端口。
Redis以频道的形式,对这些信息进行分门别类的管理。所谓的频道,时机上就是消息的类别,消息类别相同时,他们就属于同一个频道。只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换。
哨兵如何知道从库的ip地址和端口
这是由哨兵向主库发送 INFO 命令来完成的。就像下图所示,哨兵 2 给主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵 1和 3 可以通过相同的方法和从库建立连接。
哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供 pub/sub 机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。
客户端可以根据这些频道来从哨兵订阅信息
确定由哪个哨兵执行主从切换的过程,也是一个投票仲裁的过程
这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader 选举”。因为最终执行主从切换的哨兵称为 Leader,投票过程就是确定 Leader。在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。以 3 个哨兵为例,假设此时的 quorum 设置为 2,那么,任何一个想成为 Leader 的哨兵只要拿到2 张赞成票,就可以了
为了保存大量的数据,一般有两种方案进行处理使用更大内存主机和数据分片,分别对应纵向扩展和横向扩展
与纵向扩展相比,横向扩展是一个扩展性更好的方案。这是因为,要想保存更多的数据,采用这种方案的话,只用增加 Redis 的实例个数就行了,不用担心单个实例的硬件和成本限制
Redis Cluster采用哈希槽(Hash Slot),处理实例和数据的映射关系,映射过程可以描述为key按照CRC16算法计算一个16bit的值对16384取模,每个模数代表一个相应编号的hash slot 部署Redis Cluster方案时可以通过cluster create命令创建集群,Redis自动分配slot,也可以通过cluster meet命令手动建立实例见得连接形成集群,再通过cluster addslots命令,指定每个实例上的哈希槽个数
客户端在发送请求时,请求数据所处的哈希槽可以通过计算得到,客户端在与集群实例建立连接后,实例会把哈希槽的分配信息发送给客户端
在数据发生变化时,实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息
MOVED命令
由于负载均衡, Slot 2 中的数据已经从实例 2 迁移到了实例 3,但是,客户端缓存仍然记录着“Slot 2 在 实例 2”的信息,所以会给实例 2 发送命令。实例 2 给客户端返回一条 MOVED 命令,把 Slot 2 的最新位置(也就是在实例 3 上),返回给客户端,客户端就会再次向实例 3 发送 请求,同时还会更新本地缓存,把 Slot 2 与实例的对应关系更新过来
ASK命令
Slot 2 正在从实例 2 往实例 3 迁移,key1 和 key2 已经迁移过去,key3 和key4 还在实例 2。客户端向实例 2 请求 key2 后,就会收到实例 2 返回的 ASK 命令。ASK 命令表示两层含义:第一,表明 Slot 数据还在迁移中;第二,ASK 命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例 3 发送 ASKING 命令,然后再发送操作命令
与moved命令不同的是,ask命令不会更新客户端缓存的哈希槽分配信息