《Redis设计与实现》学习。
前面学习了Redis的一些基础数据结构和支持的对象,那么Redis服务器内部构造是什么样的?如何保存和使用这些对象等内容呢?
Redis为什么快?
多路复用IO机制、数据结构简单、内存中存取。
1、数据库
Redis将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中。
struct redisServer {// ...// 保存服务器中所有数据库redisDb *db;}
(⊙o⊙)…,redis不就是内存数据库吗,为什么还需要数组保存,难道内部有多个库?
了解了一下,redis中没有类似关系型数据库的表概念,所以说这多个数据库相当于在内存中划分出多个空间,多个db相当于多个命名空间可以用来存储不同类型的数据,而且即使只用了一个库,其他库也不会占用太多内存(一个空库占内存<1M)。
注:这是单机模式,集群模式下不支持SELECT切换库,只支持db0一个库。
初始化服务器时,程序根据dbnum属性决定应该创建多少个数据库,默认16个。
默认情况下Redis客户端目标数据库为0号库,可以用过SELECT命令切换。
服务器中还有一个redisClient的结构记录了客户端当前使用的目标数据库是哪一个,SELECT命令一操作,这个结构的指针就要变一下指向库(指向redisDb数组的不同索引)
1)数据库操作
添加键:将一个新键值添加到键空间字典里面,其中键为字符串对象,值为任意类型
删除键:在键空间里删除键所对应的的键值对对象
同理,更新等其他操作
什么是键空间?
typedef struct redisDb {// ..// 键空间dict *dict;} redisDb;
Redis是一个键值对数据库服务器,服务器中每个数据库都由一个redis.h/redisDb结构表示,其中,redisDb结构的字典保存了数据库中所有的键值对,我们将这个字典称为键空间。(操作redis中的键值,实际就是操作这个字典结构)
2)维护操作
数据库读写时,服务器不仅会对键空间执行指定的读写操作,还会进行一些额外的维护操作。
读取后,服务器根据键是否存在来更新服务器的键空间命中次数或不命中次数
读取后,服务器会更新键的LRU时间(这个时间用于计算键的闲置时间)
读取时,发现键已过期,服务器会先删除过期键(通过expire命令客户端可以设置某个键的生存时间,Time To Live TTL;也就是客户端经常操作的设置过期时间,在经过指定时间后服务器会自动删除生存时间为0的键,但是这里说的是恰好读取到过期键时删除,那么对于没读取到的过期键,如何删除?)
客户端Watch了一个键,服务器对这个键修改后会标记脏键
数据库开启通知功能时,键修改后会发送数据库通知
3)过期键如何删除?
Redis有四个不同的命令可以用来设置键的生存时间:
expire key ttl: 将键key的生存时间设为ttl秒
pexpire key ttl:...ttl毫秒
expireat key timestamp:将生存时间设为timestamp所指定秒数时间戳
pexpireat key timestamp:...指定的毫秒时间戳
之前提过redisDb结构中有一个expires字典保存了数据库中所有键的过期时间,过期字典。键空间的键和过期字典中的键都指向同一个键对象,不会产生浪费。
如何检查一个键是否过期?
先检查这个键是否存在于过期字典中,如果存在那么取得键的过期时间;检查当前UNIX时间戳是否大于键的过期时间,如果是则过期。
如果一个键过期了,什么时候被删除?
定时删除:定时器
设置键的过期时间的同时,创建一个定时器(timer)让定时器在键的过期时间到来时执行删除。
优点:这种方式对内存友好,因为定时器会立即删除到时间的键释放空间
缺点:CPU不友好,过期间比较多的情况下,删除行为会占用相当长的CPU时间
惰性删除:延迟删除,每次读的时候删除
不管键是否过期,每次读取时检查是否过期,过期就删除;没过期则返回。
优点:CPU友好,程序只在读取时进行过期检查,所以,不能立即释放内存,不友好;如果内存中有很多的过期键并且它们都不再被访问了,那么就会造成这部分键一直不释放,内存泄漏。
定期删除:定时任务性质
每隔一段时间程序数据库进行一次检查,删除里面的过期键。
定期删除是对以上两种删除方式的一种折中策略,定期删除每隔一段时间执行一次删除操作,通过限制删除操作的时长和频率来减少CPU的消耗,同时也减少了过期键的内存浪费。
Redis服务器实际使用惰性删除和定期删除两种策略相结合的方式。
AOF(追加日志)、RDB(快照)和复制过程中的过期键处理?
RDB:生成RDB文件时,已过期的键不会保存到新创建的RDB文件中
AOF:过期键被删除后,程序会向AOF文件追加一条DEL命令记录该键被删除
复制:主服务器数据复制到从服务器上,主服务器在删除一个过期键后会显示发送一个DEL命令到从服务器;从服务器在执行客户端发送的读命令时,遇到过期键也不会特殊处理(正常读取);从服务器只有在接收到DEL命令后才删除这个键。
2、RDB
Redis是内存数据库,这样在机器宕机就会导致数据丢失风险,因此要想办法将数据库的状态保存到磁盘上 -----> RDB(Redis Database 内存快照)。
RDB文件是一个压缩的二进制文件,可以通过这个文件进行数据库状态的还原。RDB文件保存在硬盘上可以避免宕机导致Redis数据丢失的风险。
1)如何创建RDB
Redis有两个命令可以生成RDB文件:SAVE和BGSAVE。
SAVE:阻塞Redis服务器进程(redis是单线程读写,不是只有一个线程,也不是只有一个进程)直到RDB文件创建完毕为止,这期间服务器不能处理任何请求命令。
BGSAVE:派生一个子进程,由子进程来创建RDB,服务器进程基础处理命令(保存时有修改的话,这样导致快照数据不准确啊,这是内存快照,是一个时间点数据。。)
RDB和AOF的方式如何选择,因为AOF的频率更快,所以数据库恢复时如果有AOF优先使用AOF:

3、AOF
Redis还提供了AOF的持久化功能,AOF(Append Only File,日志追加)通过保存Redis服务器所执行的写命令来记录数据库的状态。
1)AOF持久化
命令追加、文件写入、文件同步三步。
命令追加:写命令执行完后,服务器会以协议格式将写命令追加到服务器状态的aof_buf缓冲区末尾。
文件写入与同步:Redis服务器进程是一个事件循环,服务器在处理文件事件时可能会执行写命令将内容追加到aof_buf缓冲区里面,服务器每次结束一个事件Loop前会调用flushXX函数将aof_buf中的内容写入和保存到AOF文件里面。文件写入是写入到AOF文件,但是操作系统会有文件写入缓冲区,系统就有立即同步的函数(fsync和fdataasync)将缓冲区内容保存到磁盘中。
AOF的频率:appendfsync控制
追加模式值=always,每个事件循环都要将aof_buf中的所有内容写入AOF文件,并同步AOF文件,这种模式最慢,但最安全。
值=everysec,每个事件循环都要将aof_buf缓冲区的内容写入AOF文件,并且每隔1秒进行一次AOF文件同步。效率还可以,故障时丢失1s的命令。
值=no,服务器在每个事件循环都要将aof_buf缓冲区的所有内容写入AOF文件,什么时候进行文件同步由操作系统控制。这种模式不需要调用同步操作,所以这种情况下写入AOF的速度是最快的,但这种模式会在系统缓存中积累一定数据后再同步到磁盘(操作系统机制),同步的时间是最长的,故障会丢这部分积累的所有命令。
2)AOF如何进行数据恢复

创建不带网络连接的伪客户端(fake client),因为Redis命令只能在客户端上下文中执行,所以要一个客户端;又因为载入AOF使用命令不是来源网络请求而是AOF文件,所以不需要网络连接。
AOF中读取写命令
伪客户端执行写命令,重复读写操作,直到所有命令都处理完毕
3)AOF重写(☆☆☆)
AOF是保存写命令来记录数据库状态,这样就有一个明显的问题,长时间的保存、重复命令很多、AOF文件越来越大、恢复起来时间很长,怎么办?-> AOF重写。
Redis通过创建一个新的AOF文件来替代现有的AOF文件,两者状态相同但新的AOF文件中不会有任何冗余命令,新的AOF体积小。如何实现?
新的AOF文件,要获取一个键A的值,不是去读取老的AOF文件中对A的操作后然后得到最终A的值,而只需要读取当前数据库中A的状态,然后用一条PUSH命令表示即可(简单高效)。
AOF的重写通过aof_rewrite函数实现,这个函数执行创建新的AOF时会因为大量的写入操作而导致调用线程长时间阻塞,(redis是单线程读写!!!)如果直接在读写线程中进行处理阻塞了正常的情况,肯定不行。因此Redis将重写放到子进程中执行:
子进程执行不阻塞父进程
子进程带有主进程的数据副本,使用子进程而不是子线程,不需要锁也可以保证数据安全(子进程中就一个重写操作,没有同步数据修改的操作,所以安全;资源也是隔离的)
但是,同样问题,子进程执行时主进程不阻塞就会有差异数据(浮动垃圾,hh),这样子进程创建的新AOF就不准确了。如何解决子进程处理时,主进程的修改?
Redis设置一个AOF重写缓冲区,在服务器创建这个子进程时开始使用,当Redis服务器执行完一个写命令后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,如下:

当子进程完成AOF重写工作后向父进程发送信号,父进程调用一个信号处理函数执行:
将AOF重写缓冲区所有内容写入到新的AOF文件中(这样看来,新的AOF文件也不是没有冗余命令的),新的AOF这会儿正确了!
新的AOF改名,新老替换
信号处理时,父进程阻塞。
4、事件
Redis服务器是一个事件驱动程序,服务器主要处理以下两类事件:
1)文件事件:网络通信的事件
Redis基于Reactor模式开发了独有的网络事件处理器,文件事件处理器(File Event Handler)。文件事件处理器的构成如下图所示。

采用IO多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来关联不同的事件处理器
文件事件(就是之前提到过的读写线程)以单线程方式运行,通过IO多路复用技术实现了高性能的网络通信模型
多个事件并发出现时,IO多路复用程序会将所有产生事件的套接字放到一个队列中,以有序、同步、每次一个套接字的方式进行事件派发
2)时间事件:定点执行的事件
Redis的时间时间事件分为两类:定时事件(指定时间后执行一次)、周期事件(每隔XX时间执行一次)。
服务器将所有的时间事件放到一个无序链表中,当时间事件执行器运行时会遍历整个链表,查找已到达时间的事件进行处理。
这两种事件如何调度,如下:

5、服务器
Redis服务器是一对多的服务器程序(一个服务器可以有多个客户端)。
命令请求的执行过程(SET key value 这个语句的执行过程)
发送命令请求:客户端将命令请求转成协议格式,通过连接服务器的套接字发送
读取命令请求:服务器读取命令请求,并将其保存到客户端状态的输入缓冲区中;然后分析缓冲区命令,调用命令执行器
命令执行器-查找命令:根据关键字set、get、del等去查redisCommand字典,找到命令(命令表使用的是大小写无关的)
命令执行器-预备操作:现在服务器获取了命令的函数、参数等信息,再做一些检查工作
调用命令对应的函数,执行
后续,检查是否慢查询、是否需要保存AOF等;之后就可以处理下一个命令了
6、集群
Redis用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制另一个服务器,被复制的服务器为主服务器(master),进行复制的服务器为从服务器(slave)。
1)复制功能
复制功能分为:同步和命令传播两个操作(这两个功能有点存量和增量保持一样)。
同步
同步操作用于将从服务器的数据库状态更新至主服务器的状态。

① 从服务器发送SYNC命令
② 主服务器执行BGSAVE(RDB的命令,生成当前快照),并在一个缓冲区记录之后的写命令
③ BGSAVE执行完成后,主服务器发送RDB文件到从服务器上,从更新自己库状态
④ 然后将缓冲区的写命令发送给从服务器(主服务器阻塞发送?还是一直有缓冲区,直到主从一致呢?)
命令传播
在服务器的数据库状态被修改导致主从不一致时,让主从重新一致。
同步命令将主从达到了一个一致状态,但每当主服务器数据有修改时,就可能导致主从出现差异。因此为让从服务器保持一致,主服务器要执行命令传播操作:将造成主从不一致的写命令发送从服务器执行。
上述复制过程存在2种情况
① 初次复制:从服务器第一次复制主服务器 or 复制的主不是之前的主
② 断线后重复制:处于命令传播阶段的主从服务器之间断连,之后从重新连接继续复制(这种情况下,从服务器会重新发送SYNC命令,再来一遍,这个SYNC过程非常耗时、耗资源,这一步没有必要,应该改进为断点续传 or 增量补齐)
解决上述问题,Redis2.8版本开始使用PSYNC命令代替SYNC。PSYNC具有完整重同步和部分重同步两种模式:
① 初次复制情况,和上面的①差不多
② 部分重同步用于断线重连情况,指定情况下,主服务器将从服务器断开这段时间的写命令发送从服务器即可
2)部分重同步实现
部分重同步功能由三个部分组成:复制偏移量(主从的复制偏移量)、主服务器的复制积压缓冲区、服务器运行ID
复制偏移量
主从服务器在复制过程中都会维护一个复制偏移量,主发送N个字节数据,自身偏移+N;从接收N个字节,自身偏移+N(对比主从偏移量,可以得出主从是否一致)。
复制积压缓冲区
主服务器维护的固定长度的、FIFO队列,默认大小1M。固定长度指的是当入队元素数量大于队列长度时,最先入队的才会被弹出(满了才出)。
主服务器进行命令传播时,不仅将写命令发给所有从服务器,还将写命令入队到复制积压缓冲区,如下:

复制积压缓冲区为队列中每个字节记录其复制偏移量(例如记录ABC这个词,A的offset=10、B=11)。从服务器重连后通过PSYNC命令将自己的复制偏移量10发给主服务器,主服务器拿到从的偏移后开始判断如何操作:(这就是上面说的“指定情况下”)
① offset(10)以后的数据在复制积压缓冲区内,主服务器执行部分冲同步,同步缓冲区命令
② offset的数据不在积压缓冲区了,主服务器执行完整重同步
服务器运行ID
每个服务器,无论主从都要自己的运行ID,启动时自动生成,长度40的十六进制随机字符串。
从服务器初次复制时,主服务器会发送给从服务器自己的运行ID(发送ID给从做标识),重连后从向主服务器发送自己之前保存的masterID,如果主没变(主服务比较自己的ID和从发的ID一样),主继续尝试执行(指定情况下)部分重同步;如果主变了,直接完整重同步。
2)哨兵
Sentinel是Redis高可用性解决方案:由一个或多个Sentinel实例组成的Sentinel系统可以监控任意多个主服务器,以及主服务器属下的从服务器。监视主服务器宕机时进行从服务器的升级。
服务器与哨兵系统结构,如下:

哨兵的本质是一个运行在特殊模式下的Redis服务器,哨兵的启动和Redis服务器的启动类似。服务Redis服务器启动后会载入RDB或者AOF文件来还原数据库,Sentinel不需要数据库所以不会执行这些。
对于每个被哨兵监视的主从服务器来说,哨兵会创建两个异步网络连接:命令连接(用于收发命令)、订阅连接(订阅主服务器__sentinel__:hello频道)。主从都有这两个连接!!!
为什么两个连接?
Redis目前发布和订阅功能中发送的消息不会保存,如果在信息发送时,客户端不在线,这个消息就丢了。为了不丢消息,使用了订阅连接;命令连接是向主服务发命令的,必不可少。
哨兵默认每10向监视的主服务器发送INFO命令,分析回复信息来获取当前主服务的状态。
主观下线
哨兵默认每秒向建连的主、从、其他哨兵发送PING命令,判断对方是否在线,自己判断的叫主观下线(PING不通了,主下线了吗)。
哨兵A发现主服务器S下线了,但是不一定主服务器真的掉线了,哨兵A会向其他监视这个主服务器S的哨兵询问,当哨兵A获取半数已下线的判断后,哨兵A会将这个主判定为下线,并将S判定为客观下线,然后执行S的故障转移。
谁来执行故障转移???
领头哨兵
当一个主服务器被判断为客观下线时,监视这个主服务器的各个哨兵会选出一个领头哨兵执行故障转移。如何选举?
① 所有在线哨兵都有资格参选(监视这个主服务器的)
② 每次选举后,无论是否选出,所有哨兵的配置纪元(类似于Kafka中的epoch,选举周期)自增1次
③ 每次配置纪元中所有哨兵都可以推举一个哨兵作为领导,每个哨兵在这个纪元只能推举一次,推举后不变
④ 每个发现主服务器客观下线的哨兵都要求其他哨兵投自己一票(因为是它发现的)
⑤ 哨兵之间会私信要求投票自己(例如ABC都向D发送要当领导,A先说的,那么D选A)
⑥ 唱票,半数机制;如果没选出,再选,直到选出
故障转移
首先不看内容想一下,最好是选主从同步的从服务器作为新主,如果没有同步的从服务器呢?
怎么找这个从呢?
① 过滤掉已经下线的从服务器
② 去掉最近5s内没有回复过领头哨兵的服务器
③ 去掉所有与旧主之间断连超过down-after-milliseconds * 10ms的,数据不新鲜了
④ 领头哨兵根据从服务器优先级排序,选择最高优先级的从;如果有多个相同最高优先级的从,按照从的复制偏移量排序,选择最大的复制偏移量;如果复制偏移量还有多个一样的,从服务器ID排序,选出ID最小的。
没有同步的就选个最新的,选完之后以新主为准。




