来源:《Redis设计与实现》 ---黄健宏
Redis的线程模型是单线程,IO复用。
RDB持久化
Redis是内存数据库,为了防止进程退出导致服务器中的数据库状态消失,Redis提供了RDB持久化功能,可以将Redis在内存中的数据库状态保存在磁盘里面,避免数据意外丢失。
RDB持久化可以手动执行,也可以配置服务器定期执行。生成的RDB文件是一个经过压缩的二进制文件。
RDB文件的创建和载入
创建
SAVE
SAVE命令会阻塞Redis服务器进程,知道RDB文件创建完成为止,在阻塞期间,服务器不能处理任何命令请求。
BGSAVE
BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)可以继续处理命令请求。
BGSAVE期间父进程会拒绝SAVE命令和BGSAVE命令,如果有BGREWRITEAOF命令,则会延迟到BGSAVE执行完再执行。
载入
RDB文件的载入工作是在服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就会自动载入RDB文件。
因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:
- 如果服务器开启了AOF持久化功能,则会优先使用AOF文件来还原数据库。
- 如果服务器关闭了AOF持久化功能,则使用RDB文件来还原数据库。
AOF持久化
AOF持久化通过保存Redis服务器所执行的写命令来记录数据库状态。
实现
命令追加
当AOF持久化功能打开的时候,服务器在执行完一个写命令后,会以协议格式将被执行的写命令追加到服务器的aof buf缓冲区的末尾。
Redis的服务器进程就是一个事件循环,这个循环中的文件事件负责接收客户端的命令请求,处理并回复,而时间事件则负责执行一些定时的任务。服务器在结束一个事件循环之前,会调用flushAppendOnlyFile函数检查是否需要将aof buf缓冲区的内容写入到AOF文件里面,伪代码如下:
def eventLoop():
while True:
# 处理文件事件
processFileEvents()
# 处理时间事件
processTimeEvents()
# 处理aof buf缓冲区
flushAppendOnlyFile()
载入
因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。
- 创建一个不带网络连接的伪客户端
- 从AOF文件中分析并读取一条写命令
- 使用伪客户端执行被读出的写命令
- 重复步骤2和步骤3,知道AOF文件中的所有写命令都处理完成
AOF重写
如果不做处理的话,AOF文件会随着服务器运行的时间的增加而越来越大,同时使得使用AOF文件来还原的时间越来越长。
AOF重写会遍历数据库中当前所有的键值对,用一条命令记录当前键值对的状态代替之前记录这个键值对的多条命令。
客户端
Redis用redisClient结构来保存客户端当前的状态信息
typedef struct redisClient {
//...
int fd;
int flags;
sds querybuf;
robj **argv;
int argc;
char buf[REDIS_REPLY_CHUNK_BYTES];
int bufpos;
list *reply;
int authenticated;
} redisClient;
客户端属性
套接字描述符 fd
- 伪客户端的fd属性的值为-1,主要用于处理AOF文件还原数据库状态,以及执行Lua脚本中包含的Redis命令。
- 普通客户端的fd属性的值为大于-1的整数,表示客户端套接字的描述符。
标志 flags
flags可以是单个标志或者多个标志的或,如:
- REDIS_MASTER表示客户端代表的是一个主服务器,REDIS_SLAVE表示客户端代表的是一个从服务器
- REDIS_LUA_CLIENT表示客户端是专门用于处理Lua脚本里面包含的Redis命令的伪客户端
输入缓冲区 querybuf
保存客户端发送的命令请求
命令与命令参数 argv与argc
保存从querybuf解析得到的命令参数以及命令参数的个数。argv是一个数组,第一项argv[0]是要执行的命令,之后的项是命令的参数。
输出缓冲区 buf和reply
有两个输出缓冲区,一个大小固定,一个大小可变
- 固定大小的缓冲区用于保存那些长度较小的回复
- 可变大小的缓冲区用于保存那些长度较大的回复
固定大小的缓冲区大小由REDIS_REPLY_CHUNK_BYTES指示,值为16*1024,即16KB。
可变大小的缓冲区由链表来连接多个字符串对象,可以保存非常长的命令回复。
身份验证 authenticated
为0表示客户端未通过身份验证,为1表示客户端已经通过了身份验证。如果服务器启用了身份验证,当未通过身份验证时,服务器会拒绝所有的非AUTH的命令。
复制
旧版复制功能的实现
Redis2.8版本之前使用的复制功能的实现
- 同步,将从服务器的数据库状态更新至主服务器当前所处的数据库状态
- 命令传播,主服务器的数据库状态被修改时,将对应的命令传播给从服务器
同步
当从服务器收到SLAVEOF命令时,会首先将服务器状态更新至和主服务器的当前服务器状态一致
- 从服务器向主服务器发送SYNC命令
- 主服务器执行BGSAVE,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令
- 主服务器将RDB文件发送给从服务器,从服务器载入该RDB文件,更新自己的数据库状态
- 主服务器将记录在缓冲区的所有写命令发送给从服务器,从服务器执行所有的写命令
旧版复制功能的缺陷
从服务器对主服务器的复制可以分为两种情况
- 初次复制:从服务器之前没有复制过主服务器,一切重新开始
- 断线重连:处于命令传播阶段的主从服务器因为网络原因中断了复制,但从服务器通过自动重连重新连上了主服务器,并继续复制主服务器。这种情况下,如果还是重新同步的话,效率会非常低下。
新版复制功能的实现
Redis2.8开始,使用PSYNC命令代替SYNC命令来执行复制时的同步操作。有完整重同步和部分重同步两种模式。
- 完整重同步用于处理初次复制情况,执行步骤和SYNC命令的执行步骤一样
- 部分重同步用于处理断线重连后的复制情况。重连后,主服务器只需要将断开连接期间的写命令发送给从服务器,从服务器执行这些写命令即可。
部分重同步的实现
部分重同步的实现由以下三个功能构成:
- 主从服务器的复制偏移量
- 主服务器的复制积压缓冲区
- 服务器的运行ID
复制偏移量
- 当主服务器向从服务器传播N个字节的数据时,会将自己的复制偏移量加上N
- 当从服务器收到主服务器传播的N个字节数据时,会将自己的复制偏移量加上N
如果复制偏移量一样,则主从服务器处于一致状态,否则处于不一致状态。
复制积压缓冲区
主服务器维护了一个固定长度的FIFO队列,默认大小为1MB。当主服务器进行命令传播的时候,会同时将其写入复制积压缓冲区。
从服务器断线重连后,会将自己的复制偏移量发送给主服务器,如果:
- 从服务器复制偏移量之后的数据都在主服务器的复制缓冲区里,则执行部分重同步;
- 否则,执行完整重同步。
服务器运行ID
每个服务器都会有一个自己的运行ID,从服务器会记录下自己上次复制的主服务器的ID,重连同步时,会将该ID发给主服务器,主服务器判断如果ID相同则可以尝试部分重同步,否则执行完整同步。
Sentinel
Sentinel是Redis的高可用解决方案:由一个或多个Sentinel实例组成Sentinel系统可以监视任意多个主从服务器,在主服务器下线时,自动将某个从服务器升级为新的主服务器。
集群
Redis集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。
节点
一个Redis集群通常由多个节点组成,在刚开始的时候,每个节点是互相独立的,都处于一个只包含自己的集群当中,要组建一个集群,需要使用CLUSTER MEET命令来完成。
CLUSTER MEET <ip> <port>
向一个节点发送CLUSTER MEET命令,会让该节点向ip:port指定的节点进行握手,当握手成功时,该节点会将ip:port指定的节点添加到自己的集群中。
启动节点
Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启集群模式。
集群数据结构
每个节点会使用clusterNode结构来记录自己的状态,同时为集群中的所有其他节点创建一个对应的clusterNonde结构。
struct clusterNonde {
mstime_t ctime; // 创建节点的时间
char name[REDIS_CLUSTER_NAMELEN]; // 节点的名字
int flags; //节点标识,记录节点的角色(如主从),以及节点的状态(如在线或下线)
uint64_t configEpoch; //节点当前的配置纪元,用于实现故障转移
char ip[REDIS_IP_STR_LEN]; //节点的IP地址
int port; //节点的端口号
clusterLink *link; //保存连接节点所需的有关信息
};
// clusterNode保存了连接节点所需的有关信息
typedef struct clusterLink{
mstime_t ctime; //连接的创建时间
int fd; //TCP套接字描述符
sds sndbuf; //输出缓冲区
sds rcvbuf; //输入缓冲区
struct clusterNode *node; //与这个连接相关联的节点,如果没有则为NULL
} clusterlink;
// 每个节点都保存这一个clusterState结构,表示在当前节点的视角下,集群目前的状态
typedef struct clusterState {
clusterNonde *myself; //指向当前节点的指针
unit64_t currentEpoch; //集群当前的配置纪元,用于实现故障转移
int state; //集权当前的状态,在线还是下线
int size; //集群中至少处理者一个槽的节点的数量
dict *nodes; //集群节点名单(包括myself),健为节点名字,值为clusterNode结构
}
CLUSTER MEET命令实现
当节点A收到对B进行CLUSTER MEET的命令后,会进行握手来确认彼此的存在:
- A为B创建一个clusterNode结构,并添加到自己的clusterState.nodes里
- A向B发送MEET消息
- B为A创建clusterNode结构,并添加到自己的clusterState.nodes里
- B向A返回PONG消息
- A知道B已经接受了自己的MEET消息,并向B发送PING消息
- B知道A成功接受了自己的PONG消息。至此握手完成
槽指派
Redis集群通过分片的方式来保存数据库中的键值对。集群的整个数据库被分成了16384个槽(slot),数据库中的每个键都属于这16384个槽之一,每个节点可以处理多个槽。
- 当数据库里的16384个槽都有节点处理时,集群处于上线状态
- 只要有一个槽没有被处理,集群处于下线状态
通过CLUSTER ADDSLOTS命令,可以将一个槽或多个槽指派给节点负责。
记录节点的槽指派信息
clusterNode结构的slots熟悉和numslot属性记录了节点负责处理哪些槽。
struct clusterNode{
unsigned char slots[16384/8];
int numslots; //该节点需要处理的槽的数量
};
用二进制位来表示节点是否需要处理对应的槽,为1时表示该节点需要处理对应的槽。
节点处理记录自己的槽信息之外,还会将自己的slots数组发送给集群中的其他节点,以此来告知自己处理哪些槽。
在集群中执行命令
当客户端发送与数据库键有关的命令时,接收命令的节点会计算该键属于哪个槽,如果:
- 键所在的槽指派给了自己,则直接执行命令
- 键所在的槽没有指派给自己,则向客户端返回MOVED错误,使客户端向正确的节点再次发送指令
单机服务器可以处理多个数据库,而节点服务器只能使用0号数据库。
重新分片
Redis集群的重新分片由集群管理软件redis-trib负责执行,对单个槽进行重新分片的步骤如下:
- redis-trib向目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,让其准备好从源节点导入属于槽slot的键值对
- redis-trib向源节点发送CLUSTER SETSLOT <slot> MGRATING <target_id>命令,使其做好迁移准备
- redis-trib向源节点发送CLUSTER GETKEYSINSLOT <slot> <count>命令,获得最多count个属于槽的键名(Redis记录了每个槽的所有键)
- 对于每一个键名,redis-trib都向源节点发送MIGRATE <target_ip> <target_port> <key_name> 0 <timeout>命令,将键原子的转移到目标节点
- 重复3和4,直到属于该槽的所有键值对都被迁移到目标节点
- redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT <slot> NODE <target_id>命令,将槽指派给目标节点,然后广播给整个集群
ASK错误
在重新分片期间,当客户端向源节点发送的命令的键正好属于被迁移的槽时:
- 源节点会检查键是否在自己的数据库里,如果找到的话,直接执行客户端的命令
- 如果没找到,则向客户端返回一个ASK错误,指引客户端转向目标节点再次发送命令
复制集
Redis集群中的每个节点可以是一个复制集,包含一个主节点和若干从节点,当主节点下线时,从节点可以被选举成为主节点,并继续处理命令请求。
发布与订阅
通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道,当有其他客户端向被订阅的频道发送消息时,该频道的所有订阅者都会收到这条消息。
频道的订阅与退订
Redis将所有频道的订阅关系记录在了服务器的pubsub_channels字典里面,键是被订阅的频道,值是一个链表,链表里记录了所有订阅这个频道的客户端。
struct redisServer {
dict *pubsub_channels;
};
模式的订阅与退订
客户端还可以进行模式订阅(正则),服务器将所有的模式订阅关系保存在了pubsub_patterns里
struct redisServer {
list *pubsub_patterns;
};
pubsub_patterns是一个链表,每个节点包含着一个pubsubPattern结构,保存着订阅的模式和客户端。
发送消息
当一个Redis客户端执行PUBLISH <channel> <message>命令时,服务器会:
- 将消息发送给该频道的所有订阅者
- 遍历pubsub_patterns链表,将消息发送给pattern匹配的订阅者
事务
Redis有实现事务功能,可以将多个命令请求打包,一次性,按顺序的执行多个命令。在事务执行期间,服务器不会中断事务去执行其他客户端的命令请求。
事务以MULTI命令开始,接着输入多个要执行的命令,最后以EXEC命令提交事务给服务器执行。
redis> MULTI
OK
redis> SET "name" "A"
QUEUED
redis> GET "name"
QUEUED
redid> EXEC
1) OK
2) "A"
MULTI命令会将客户端的状态切换为事务状态,此后如果接受到其他命令,如果:
- 命令是EXEC,DISCARD,WATCH,MULTI四个命令之一,则立即执行这个命令
- 如果是这四个命令之外的命令,服务器会将这个命令放入一个FIFO队列中
WATCH命令
每个Redis数据库都保存着一个watched_keys字典,键是某个被WATCH命令监视的数据库键,值是一个链表,记录了监视该键的客户端。
typedef struct redisDb {
dict *watched_keys;
} redisDb;
监视机制的触发
所有对数据库进行修改的命令,在执行之后,都会对watched_keys字典进行检查,如果存在,则会将所有监视该键的客户端的REDIS_DIRTY_CAS标识打开,表示该客户端的事务安全性已经被破坏。
判断事务安全性
当服务器接收到EXEC命令是,会检查这个客户端的REDIS_DIRTY_CAS标识是否打开,
- 如果REDIS_DIRTY_CAS标识被打开,则拒绝执行事务
- 否则,执行被提交的事务
事务的ACID性质
原子性
Redis中的事务要么全部执行,要么一个都不执行,所以具有原子性。
与关系型数据库事务最大的区别是,Redis不支持事务回滚机制,即使队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到全部执行完毕
Redis的作者解释原因说,不支持事务回滚是因为这种复杂的回滚功能和Redis追求的简单高效的设计主旨不符合,且事务执行错误一般通常是编程错误导致的,应该由开发者负责避免。
一致性
一致性指如果数据库在执行事务前是一致的,那么在事务执行之后,不论执行是否成功,数据库也应该是一致的。
一致指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。
隔离性
隔离性指如果有多个事务并发的执行,各个事务之间不会互相影响。Redis使用单线程来执行事务,并且服务器保证事务执行期间不会对事务进行中断,所以具有隔离性。
耐久性
耐久性指一个事务执行完毕时,执行这个事务的结果已经保存在磁盘里面了,即使执行事务后停机,执行的结果也不会丢失。Redis事务只是简单的执行一组Redis命令,故其耐久性由服务器所使用的持久化模式决定。
Lua脚本
Redis服务器内嵌了一个Lua环境
创建Lua环境
- 创建一个基础的Lua环境,之后所有的修改都时针对这个环境
- 载入多个函数库到Lua环境中,让Lua脚本可以使用这些函数库进行数据操作
- 创建全局表格redis,这个表格包含了对Redis进行操作的函数,比如执行Redis命令的redis.call函数
- 使用Redis自制的随机函数替换原来Lua的带副作用的随机函数
- 创建排序辅助函数,Lua环境使用这个函数来对一部分Redis命令的结果进行排序,从而取消这些命令的不确定性
- 创建redis.pcall的错误报告辅助函数,提供更详细的错误信息
- 对Lua环境中的全局环境进行保护,防止用户在Lua脚本执行中,将额外的全局变量添加到Lua环境中
- 将修改的Lua环境保存到服务器状态的lua属性中,等待执行Lua脚本
替换随机函数
为了保证相同的脚本可以在不同的机器上产生相同的结果,Redis要求所有传入服务器的Lua脚本,以及Lua环境中的所有函数,都必须时无副作用的纯函数。替换后的随机函数有以下特征:
- 对于相同的seed来说,math.random总产生相同的随机数序列
- 除非在脚本中使用math.randomseed显示修改seed,否则每次脚本运行时,都是用固定的math.randomseed(0)来初始化seed
创建排序辅助函数
比如对一个集合来说,因为集合元素的排列时无序的,为了消除不确定性,服务器会为Lua环境创建一个排序辅助函数__redis__compare_helper,当Lua脚本执行一个带有不确定性的命令之后,会对命令的返回值做一次排序,一次来保证有相同的输出。
保护Lua全局环境
当一个脚本试图创建一个全局变量时,服务器会报告一个错误,但是Redis并未禁止修改已存在的全局变量
redis> EVAL "x = 10" 0
(error) ERR Error running script
redis> EVAL "redis = 1; return redis" 0
(integer) 1
Lua环境协作组件
伪客户端
因为执行Redis命令必须有相应的客户端状态,所以为了执行Lua脚本中的Redis命令,专门创建了一个伪客户端,由这个伪客户端负责处理Lua脚本中的Redis命令。
lua_scripts字典
该字典的键是某个Lua脚本的SHA1校验和,值是对应的Lua脚本
Redis会将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本保存在lua_scripts字典里
EVAL命令的实现
- 根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数,函数名为f_<sha1>
- 将脚本保存在lua_scripts字典里
- 执行刚刚在Lua环境中定义的函数