0. 前言
本文通过学习黄建宏老师的《Redis的设计与实现》以及其对应的redis-3.0-annotated源码,精炼、简化其中的内容,以供快速学习。
全篇两万字。
1. 数据结构
redis的五大数据结构是对客户端的操作而言的,实际上在其内部对这五大数据结构有着更细的划分,根据数据形式、长度等的不同,选择不同的内部编码。
这么做的目的在于节约内存,提高速度。
下面先从内部编码开始讲起。
1.1 字符串(sds)
struct sdshdr{
// 字符串的实际长度
int len;
// buf的剩余长度
int free;
// 实际串
char buf[];
}
优点:
-
O(1)获取字符串长度
通过(buf-sizeof(sdshdr))拿到sdshdr指针,然后通过该指针获取len或者free。 -
避免strcat可能的缓冲区溢出
strcat(dest,src,默认以为dest的空间是足够容纳src的,但实际如果不够,则会溢出。sds重写了该逻辑。 -
减少修改字符串导致的内存重分配次数
一般来说,append字符的时候,需要对原串扩容,trim的时候,需要对原串缩容。
但是我们并不需要每次append/trim的时候都扩容缩容。
空间预分配:“需要扩容时,扩容之后len变成了n,则free也分配n”。
惰性空间释放:不使用的空间,不释放,而是“转移到”free,以备使用。(真正释放通过另外的API实现)
二进制安全:sdshdr.buf保证以’\0’结尾,但接受在’\0’之前存储任意的数据。比如有个串’hello\0world\0’,C语言的字符串只能识别hello,sds可以识别整个串(是否读到结尾由len决定)。
在内部,redis会根据字符串长度,选择不同的sds类型,分为
SDS_TYPE_5
SDS_TYPE_8
SDS_TYPE_16
SDS_TYPE_32
SDS_TYPE_64
分别代表当string_len小于等于2^n的时候选择的SDS类型,如:当string_len小于2^5时,选择SDS_TYPE_5。
int、raw和embstr
除了根据string_len划分SDS_TYPE,根据内存分配是否连续,sds又被细分为int、raw和embstr。
int很好理解,如果字符串实际是一个int类型,那么其内部编码就是使用int(毕竟int保存整数比str保存更省空间)。
embstr表示字符串内存是连续分配的,raw则是非连续分配。
为什么这么区分?
一次内存IO为64byte,(CPU缓存行64byte),
redisObject本身占用16byte,sds本身占用4byte,最终sds的buf可用44byte。
如果客户端的字符串长度小于等于44byte,就可以将buf内联到sds上,此时称为embstr,否则就只能另外找个空间存放,称为raw。
可以看出,对于embstr,分配和释放只需要一次操作,对于raw,需要两次。
通过结合CPU本身的IO特性(内存预读),减少内存IO次数,提高效率。
这跟mysql的btree,通过磁盘预读一页大小(4k),异曲同工。
1.2 链表(list)
// 双向链表
struct listNode{
struct listNode* prev;
struct listNode* next;
void* value;
}
struct list{
// 头结点
listNode* head;
// 尾节点
listNode* tail;
// 链表长度
unsigned long len;
// 节点复制函数
void* (*dup)(void* ptr);
// 节点释放函数
void (*free)(void* ptr);
// 节点对比函数
int (*match)(void* ptr,void* key);
}
特点:
- 多态:节点的value使用的是void*,可以保存任意数据。
1.3 字典(dict、hashtable)
struct dictEntry{
void* key;
// TODO
union{
void* val;
uint64_t u64;
int64_t s64;
}v;
// 下一个节点
struct dictEntry *next;
}
struct dictht{
// 哈希表数组
dictEntry** table;
// 哈希表大小
unsigned long size;
// 恒等于size-1,用于计算索引值,index=hash(key)&sizemask
unsigned long sizemask;
// 已用节点个数
unsigned long used;
}
// 一系列操作函数
struct dictType{
/*
计算哈希值
复制键
对比键
销毁键
销毁值
*/
}
//
struct dict{
// 多态方法接口类
dictType* type;
// TODO
void* privdata;
// 持有两个哈希表,方便渐进式操作
dectht ht[2];
// rehash索引,默认为-1
int trehashidx;
}
底层dictEntry是存储元素的节点,dictht是一个哈希表,dictType是操作哈希表的一些函数,dict则是redis真正使用的字典数据结构。
其中dictEntry+dictht其实就是一个拉链法的HashMap。
dictType单独抽象各种操作函数接口,是为了形成不同数据类型的不同操作方法(多态)。
rehash
rehash能够保证哈希表的容量维持在一个合理的范围(动态扩容、缩容)。
ht[0]用于平时存储数据,ht[1]用于扩容缩容时使用。
步骤如下:
- 为ht[1]分配空间,如果是扩容,则ht[1]分配ht[0].used*2空间,如果是缩容,则分配ht[0].used空间
- 将ht[0]上的所有键值对rehash到ht[1]上,释放ht[0]。
- 将ht[1]置为ht[0],新建一个空的ht[1]。
分配的空间会自动向上调整为2的幂次
负载因子(已用百分比):load_factor = ht[0].used / ht[0].size
扩容条件:
若负载因子大于等于1,且服务器没有进行BGSAVE(异步RDB)或BGREWRITEAOF(重写AOF),进行扩容。
若服务器正在进行BGSAVE或BGREWRITEAOF,且负载因子大于等于5,进行扩容。
缩容条件
负载因子小于0.1,进行缩容。
渐进式rehash
rehash的过程并不是一次性的,因为redis是单线程,如果数据过多,会导致rehash阻塞住服务器,因此采取逐步完成的方法,称为渐进式。
步骤如下:
- 为ht[1]分配空间,字典同时持有ht[0]、ht[1]
- rehashidx置为1,表示开始rehash,操作点为rehashidx
- 每次服务器操作数据的时候,就将ht[rehashidx]的数据rehash到ht[1]上,并让rehashidx++
- 直到rehashidx==size
- ht[1]变为ht[0],rehashidx置为-1,表示rehash结束
渐进式的主要难点在于,在rehash过程中,服务器的新操作该如何处理。
由于redis是单线程,因此解决该问题很简单:如果是查找/更新/删除,那么先查找ht[0],再查找ht[1]。如果是插入,那么只操作ht[1]。
如果是多线程,就需要考虑对ht[0]和ht[1]的竞态处理。
1.4 跳表(zskiplist)
跳表(跳跃表)是一种有序数据结构,平均查找时间复杂度O(logN),最坏O(N)。非常适合范围操作。
大多数情况下跳表的效率可以和平衡树媲美,但是写法比平衡树简单。
/*
* 跳跃表节点
*/
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
/*
* 跳跃表
*/
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
1.5 整数集合(intset)
当一个集合只包含整数且数量不多时,redis会采用整数集合(intset)作为底层实现。
intset是升序不重复集合。
struct intset{
// 编码方式
uint32_t encoding;
// 集合中元素个数
uint32_t length;
// 保存元素的数组
int8_t contents[];
}
尽管contents是int8_t类型,但是实际他所装载的元素类型由encoding决定(支持INT16、INT32、INT64)
升级
由于contents实际存储类型由encoding决定,因此会出现变化的可能,这个变化,称之为升级。
比如如果一开始存放的是INT16,此时加入一个INT32的元素,就会对contents进行升级。
升级:
将contents扩容到对应大小,然后将contents原来的所有数据都升级到新的大小。
redis不支持降级。
1.6 压缩列表(ziplist)
当列表项较少,且要么是小整数,要么是短字符串,就会使用压缩列表。
压缩列表是为了节约内存而开发。
ziplist的整体布局
<zlbytes><zltail><zllen><entry><entry><zlend>
-
zlbytes 是一个无符号整数,保存着 ziplist 使用的内存数量。
通过这个值,程序可以直接对 ziplist 的内存大小进行调整,
而无须为了计算 ziplist 的内存大小而遍历整个列表。 -
zltail 保存着到达列表中最后一个节点的偏移量。
这个偏移量使得对表尾的 pop 操作可以在无须遍历整个列表的情况下进行。 -
zllen 保存着列表中的节点数量。
当 zllen 保存的值大于 2**16-2 时,
程序需要遍历整个列表才能知道列表实际包含了多少个节点。 -
entry 则保存着若干个元素节点
-
zlend 的长度为 1 字节,值为 255 ,标识列表的末尾。
entry结构
<previous_entry_length><encoding><content>
- previous_entry_length:记录前一个节点的长度,通过指针运算可以获得前一个指针地址(反向遍历)
- encoding:保存数据的编码类型
- content:保存数据值
连锁更新
上述所有条目都是严格按字节大小分配,且不同情况下所占字节数不同。
由于previous_engty_length记录了前一个节点的长度,但是如果前一个节点的内容变了,恰好导致所占长度发生变化,就会触发连锁更新。
尽管如此,由于连锁更新的要求比较苛刻(数据连续且都介于临界长度),因此发生概率较低,一般不影响性能。
1.7 对象(redisObject)
redis并没有直接使用sds、list等上述的数据结构,而是将这些数据结构作为编码手段。基于上述手段,形成五大数据类型的对象系统。
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码类型
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS;
// 引用计数
int refcount;
// 指向编码结构的指针,如指向sds、list
void *ptr;
} robj;
-
type:五大类型:REDIS_STRING、REDIS_LIST、REDIS_HASH、REDIS_SET、RESID_ZSET。
对于键来说,永远是字符串对象。对于值来说,则是上述五种之一。可以通过TYPE key 查看value的type。
-
encoding:指向底层的编码实现:int、embstr、raw、ht、linkedlist、ziplist、intset、skiplist,每种对象都至少使用了两种不同的编码。redis根据本字段动态选择对应的实现函数进行调用(多态)
可以使用OBJECT ENCODING key查看value的编码
- refcount:引用计数,用于内存释放以及对象共享。由于共享对象需要进行比对操作,对于整数值,比对复杂度为O(1),对于字符串,比对复杂度为O(n),对于复杂对象(如列表),比对复杂度是O(n^2),因此redis只对整数值进行对象共享。
- lru:记录上次访问时间,可用于各种淘汰算法。
string
如果是int可以表示的整数,使用int;
如果字符串长度小于44byte,使用embstr,否则使用raw。
hash
如果键值所占字符串长度都小于64byte,且键值对个数少于512,使用ziplist,否则使用hashtable。(条件可配置)
list
如果保存的字符串元素长度都小于64byte,且个数少于512,使用ziplist,否则使用linkedlist。
set
如果集合元素都是整数,且保存数量少于512,则使用intset,否则使用hashtable。(条件可配置)
zset
如果元素长度都小于64byte,且个数少于128,则使用ziplist,否则使用skiplist。
注意:实际上zset选择skiplist时,会同时使用dict
typedef struct zset{
zskiplist* zsl;
dict* dict;
}zset;
dict保存了 “成员:分数”的map映射,能够O(1)查找成员分值。
两者通过引用计数共享指针,不会占用额外内存。
该结构结合了map的O(1)查找效率以及skiplist的返回操作特性。
上述所有类型的编码选择都是动态的,比如说原本是ziplist,但是由于客户端的操作导致不满足使用ziplist的要求,就会改变成对应的编码类型。
2. 单机数据库
2.1 数据库对象
typedef struct redisDb {
// 数据库号码
int id;
// 数据库键空间,保存着数据库中的所有键值对
dict *dict;
// 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
dict *expires;
// 正处于阻塞状态的键
dict *blocking_keys;
// 可以解除阻塞的键
dict *ready_keys;
// 正在被 WATCH 命令监视的键
dict *watched_keys;
// 被淘汰的元素,其内保存了闲置时间以及对应的key
struct evictionPoolEntry *eviction_pool;
// 数据库的键的平均 TTL(剩余生存时间) ,统计信息
long long avg_ttl;
} redisDb;
dict
struct redisServer {
......
// 数据库数量,默认16
int dbnum;
// 数据库集合
redisDb *db;
......
};
redisServer 持有一个redisDb对象,就是一个db数组,默认大小是16。
redisClient持有一个redisDb对象,通过select命令,改变RedisClient指向的redisDb对象,实现数据库切换。
redisServer持有非常多的属性,将在后面“服务器”章节补充阐述。
2.2 过期删除策略
定期删除
每隔一段时间进行一次清理,清理逻辑如下:
serverCron周期性的被调用,其会执行activeExpireCycle函数,在规定时间内,分多次遍历数据库,并随机检查一部分数据是否过期。当数据库遍历完成,又开始下一轮遍历。
惰性删除
客户端每次操作键值对的时候,都先检查是否过期,如果过期,则删除该数据,并返回空。
生成RDB文件时,会忽略过期数据;
载入RDB文件时,主服务器会忽略过期数据,从服务器会保留所有数据。
重写AOF文件时,会忽略过期数据。
主从模式下,主服务器删除过期数据时,通知从服务器也删除。从服务器不存在“过期”的概念。
(因为如果从服务器遇到过期数据就删除,那么此时主服务器还没有删除,导致数据不一致)
由于客户端检查键是否过期的时候,此时要么已经被定期删除了,要么就是惰性删除策略发现已经过期了,因此,redis对键的过期延迟,仅与网络延迟有关。
2.3 RDB持久化
SAVE阻塞服务器进行持久化,BGSAVE则是另开线程进行持久化。
BGSAVE过程中,如果执行BGREWRITEAOF,BGREWRITEAOF会在BGSAVE结束后执行。
BGREWRITEAOF过程中,如果执行BGSAVE,BGSAVE会被拒绝。
redis会拒绝其他的同时持久化行为。(如BGSAVE中调用SAVE)
RDB支持配置“固定时间内修改了N次之后进行RDB”,如:
save 900 1
save 300 10
save 60 10000
分别表示:
900秒内修改了1次
300秒内修改了10次
60秒内修改了10000次,触发RDB。
上述数据保存在redisServer.saveparam中。
此外:
redisServer.ditry表示距离上次RDB后修改了多少次数据库;
redisServer.lastsave记录了上次RDB的时间戳,该值用于跟saveparam中的时间进行比较。
RDB文件结构
<REDIS> <db_version> <数据> <EOF> <check_sum>
<REDIS>固定为“REDIS”
<db_version>为RDB文件的版本号
<EOF>标注数据结尾
<check_sum>8字节长的校验和,由前面所有内容计算出来的,服务器会将载入RDB文件时求的的校验和跟该值进行比对。
其中,数据部分还会细分编码规则,该部分比较复杂琐碎,感兴趣的同学自行搜索。
2.4 AOF持久化
AOF:append only file。
RDB是通过保存数据实现持久化,AOF是通过记录“写”命令实现持久化。
AOF持久化分为三步:append、write、sync
理解该三步之前,先考虑操作系统的写文件操作,其实是分两步,第一步写到文件中(其实在缓冲区中),第二步将文件同步到磁盘上。
append:每当有修改数据库数据的命令发生时,将该命令追加到aof_buf中;
write:将aof_buf内容写到aof文件中;
sync:将aof文件同步到磁盘上。
同步的时机由appendfsync配置选项决定:
always:写即同步(速度最慢)
everysec:每次写入文件,当上次同步的时间距离现在超过一秒钟,则进行同步(默认,足够快,即便宕机也只丢失一秒数据)
no:不主动同步,同步时机由操作系统自己决定(速度最快,但是数据安全性无法保障)。
AOF文件读取过程
由于redis命令只能在客户端执行,因此首先创建一个伪客户端(fake client),然后读取AOF文件,逐条分析得到命令并执行。
AOF重写
显然,如果有十条命令都是操作同一个数据,那么我们只需要记录最后一次状态即可,而不需要记录十次。
因此,随着时间的推移,AOF文件的体量会越来越大,需要进行重写。
重写的逻辑非常简单,另开一个线程,将数据库现有的所有k-v,全部转为存储命令(如字符串是set)。
为了保证数据一致性(重写AOF过程中,redis对外依旧提供服务,这时候可能发生了数据改变),redis提供了一个AOF缓冲区,保存重写过程中发生的所有写操作。
当AOF重写完毕,会通知主线程,然后主线程会将AOF缓冲区的内容追加到新的AOF文件中。
比如说现在数据库中有k1,k2,在重写过程中多出了k3,修改了k1,那么:
重写AOF文件中有k1,k2;
AOF缓冲区中有k3,k1修改命令。
然后将AOF缓冲区的内容追加到新的AOF文件中,得到k1,k2,k3,k1修改命令。
如果一个命令超出了缓冲区的大小,redis会将一个命令拆分为多个命令
2.5 事件
redis是一个事件驱动程序,包括文件事件和时间事件。
redis的事件驱动模型称为“file event handler”,分为四部分:套接字(socket)、IO多路复用程序、事件dispatcher、事件handler。
redis实现了常见的多种多路复用方法,包括evport,epoll,kqueue,select(按效率排序)。在编译时会通过预编译命令(ifdef)自动选择效率最高的。
redis将事件状态分为两类:ae.AE_READABLE和ae.AE_WRITABLE。
- ae.AE_READABLE:客户端对套接字执行connect、write、close,对服务端来说套接字有可读事件。
- ae.AE_WRITABLE:客户端对套接字执行read操作,对服务端来说套接字有可写事件。
文件事件
最常见的事件处理器:acceptTcpHandler(连接处理器)、readQueryFromClient(命令请求处理器)、sendReplyToClient(命令回复处理器)。
请求过程模拟:
redis启动,处于服务状态,监听套接字;
客户端发起连接,服务端监听到对应的READABLE事件,通过acceptTcpHandler处理连接;
客户端发起命令,服务端监听到对应的READABLE事件,通过readQueryFromClient读取处理命令;
服务端处理完毕,触发WRITEABLE事件,通过sendReplyToClient回复命令。
时间事件
时间事件主要分两类,一个是定时事件(触发一次),一个是定期事件(周期性触发)。
时间事件组成:
id:递增的全局ID号
when:毫秒精度的时间戳,记录了事件触发的时间点
timeProc:事件处理回调函数,到达when的时候被调用。如果返回AE_NOMORE,说明是定时事件,触发一次就被删除,否则返回一个整数值,表示距离下次触发本事件的毫秒数,该返回值会用于更新事件下一次的执行时间。
redis的定时任务采用的是无序链表,每次时间事件执行器触发时,都会遍历所有时间,查找已到达的事件进行执行。
时间事件基本靠serverCron函数负责执行,redis会周期性的执行该函数。
文件事件与时间事件的调度规则(重点)
由于redis是单线程的,那么必然存在一个调度顺序,当文件事件和时间事件同时发生的时候,该怎么进行调度?各个任务调度多久?
三个基本考虑方向:
- redis认为文件事件比时间事件更重要。
- 尽可能减少阻塞。
- 各类IO多路复用函数(如epoll),都提供了一个timeout,表示阻塞多久就返回。
基于上述三点,可以开始设计调度规则:
假如距离最近的下一个时间事件还有remaind_ms,那么epoll超时时间设为timeout,即:在时间事件到来之前,让服务器阻塞监听所有事件。
epoll返回,开始执行文件事件,然后执行时间事件。
这个方法,将对时间事件的忙等(轮询),转为epoll的timeout阻塞时间,并在这段时间内,充分等待了所有事件的到来。
2.6 客户端
redisServer持有一个容纳redisClient对象的链表,记录了客户端的所有状态。
redisClient解析如下:
typedef struct redisClient{
......
/*fd记录了客户端正在使用的文件描述符,若fd=-1,表示fake client*/
int fd;
/*默认情况下,客户端没有名字*/
robj* name;
/*flags记录了客户端的role,是一个位图,如:
- REDIS_MASTER:表示主从复制过程中的主服务器(是从服务器的客户端)
- REDIS_SLAVE:表示主从复制过程中的从服务器(是主服务器的客户端)
- REDIS_MULTI:表示客户端正在执行事务
- ......
*/
int flags;
/*输入缓冲区,用于保存客户端发送的命令,如set key value*/
sds querybuf;
/*redis将querybuf中想信息解析到argv和argc中,如:
argv[0]="set"
argv[1]="key"
argv[2]="value"
argc=3
*/
robj** argv;
int argc;
/*显然argv[0]是执行命令,后面的是执行参数,redis根据argv[0]找到对应的执行函数,即redisCommand*/
struct redisCommand* cmd;
/*输出缓冲区,执行redisCommand之后得到的结果保存在输出buf中,其中bufpos表示当前buf已用长度*/
char buf[REDIS_REPLY_CHUNK_BYTES];
int bufpos;
/*buf是定长的(16kb),如果buf不够大,就会用到reply,相当于一个可变长的输出缓冲区*/
list* reply;
/*0表示客户端未通过身份验证,1表示通过了验证*/
int authenticated;
/*创建客户端的时间*/
time_t ctime;
// 最后一次互动的时间
time_t lastinteraction;
// 输出缓冲区第一次到达【软性限制】的时间
/*
尽管存在reply变长除数缓冲区,但是为了限制资源消耗,实际上还存在输出缓冲区的限制:
硬性限制:if : 输出缓冲区>硬性限制 == > 关闭客户端连接
软性限制:if : 输出缓冲区∈(软性限制, 硬性限制) ==> 将第一次到达软性限制的时间记录在obuf_soft_limit_reached_time中,
并监听该时间,如果长期处于该状态,就断开客户端连接。
*/
time_t obuf_soft_limit_reached_time;
// 用于执行lua脚本的fake client(长期存在)
redisClient* lua_client;
......
}redisClient;
2.7 服务器
命令请求的执行过程
redis> set key value
ok
上述命令执行过程如下:
- 用户键入命令set key value
- 客户端将set key value转成协议格式,发送给服务器
- 服务端检测到客户端的套接字READABLE,调用命令请求处理器进行处理
- 命令请求处理器读取协议格式的内容,并将其保存到redisClient对象的输入缓冲区中
- 命令请求处理器解析redisClient对象的输入缓冲区,提取成redisClient.argv和redisClient.argc
- 命令请求处理器调用命令执行器,解析argv和argc命令
- 命令执行器根据argv[0]中的执行命令,在command table中查找对应的redisCommand对象,并将该对象保存到redisClient.cmd中
- 命令执行的前置处理:命令执行器根据cmd对象,执行预备操作(如校验内存、身份验证、服务器是否打开监视器功能等)
- 所有的参数以及处理结果都保存在了redisClient中,因此执行client->cmd->proc(client),该步骤会将结果保存在输出缓冲区中(redisClient.buf)
- 命令执行的后置处理:记录执行时长、日志、AOF等
- 客户端的套接字变为WRITEABLE,回复处理器将输出缓冲区中的结果发送给客户端(协议格式),并清空输出缓冲区
- 客户端解析协议格式的回复结果,并输出成正常可读的形式。
简化版流程:
- 客户端将命令发送给服务端
- 服务端解析该命令,并将解析结果并保存到redisClient对象中
- redisClient执行该命令,并将结果保存到out_buf中
- 服务端将out_buf内容返回给客户端
serverCron
redis.c中的initServer()函数中,调用了aeCreateTimeEvent,将serverCron注册到定时任务链表中。
serverCron主要处理:
- 检查redisServer对象的状态属性,并调用对应处理函数
- 调用clientsCron,检查客户端是否连接超时,以及缓冲区是否超限
- 调用databasesCron,处理过期键
- 检查AOF缓冲区,若有数据则写到文件中
- 关闭超限的客户端
在服务端,涉及到serverCron的属性以及解析如下:
struct redisServer{
/*
服务器默认每100ms执行一次serverCron函数。
为了减少系统调用次数,redisServer中缓存了unixtime(秒级时间戳)和mstime(毫秒级时间戳),该值有serverCron更新(对于精度要求不高的处理会直接使用该时间缓存值)
*/
time_t unixtime;
long long mstime;
/*默认每十秒更新一次的LRU时钟缓存,每个redisObject都持有一个lru属性,当需要计算键的空转(idle)时长时,
使用lruclock-redisObject.lru
*/
unsigned lruclock:22;
/*
通过抽样的形式,估算并记录最近一秒钟服务器命令的请求数量;
客户端使用info stats可以查看抽样结果(ops_sec_samples的平均值)
*/
long long ops_sec_last_sample_time; // 上次抽样时间
long long ops_sec_last_sample_ops; // 上次抽样时已执行的命令个数
long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES]; // 抽样结果
int ops_sec_idx; // ops_sec_samples的索引值,
// 已使用的内存峰值,每次执行serverCron的时候,会检查当前内存用量,保留峰值在stat_peak_memory中
size_t stat_peak_memory;
/*
关闭标识,1关闭,0无动作
redis注册了SIGTERM信号处理函数sigtermHandler,该函数会打印日志并将shutdown_asap置为1。
然后另外执行关闭操作,保证后续的数据保存和资源释放。
(不在sigtermHandler进行数据保存或资源回收,因为该函数可能无法操作一些属性)
*/
int shutdown_asap;
/*前面说到,如果BGSAVE期间调用BGREWRITEAOF,则BGREWRITEAOF会被延迟,
延迟的手段就是置aof_rewrite_scheduled为1,通过定时扫描该值检查是否需要执行被延迟的BGREWRITEAOF*/
int aof_rewrite_scheduled;
/*下述两个属性用于检查持久化的运行状态
redisCron会通过扫描下述的两个属性值是否为-1,来判断当前是否正在执行持久化,
一方面:如果存在非-1值,redis就会执行wait3函数,检查执行持久化的进程是否有信号发送给主进程(当持久化结束后子进程会发送信号给主进程)。
如果有信号到来,redis会进行持久化操作结束之后的一些处理工作。
另一方面:如果都是-1,说明此时没有任何持久化操作,redis会进行三项检查(以此保证数据安全):
- 1. 检查aof_rewrite_scheduled是否为1(是否有延迟aof),如果有,则开始aof重写
- 2. 检查服务的自动保存条件是否被满足,如果满足了,且没有其他持久化操作正在执行(比如上一条),则执行BGSAVE
- 3. 检查AOF重写条件是否满足,且没有其他持久化操作正在执行,则进行AOF重写
*/
pid_t rdb_child_pid; // 记录执行rdb(BGSAVE)的子进程ID,默认为-1
pid_t aof_child_pid; // 记录执行aof(BGREWRITEAOF)的子进程ID,默认为-1
// 记录serverCron的执行次数,用于执行那些“每执行serverCron N次,就执行”的函数
int cronloops;
}
总的来说,redis通过定时扫描各种状态位属性的方式,实现了非常多的细节处理以及监控。
服务器初始化
大致分为四步:
- 初始化redisServer对象的一些属性
- 加载配置文件,命令行配置
- 初始化redisServer中的其他对象(clients链表、db数组等)
- 载入AOF文件,若无则载入RDB文件,还原数据库状态
- 启动事件循环,开始监听事件
在redis.c的initServerConfig函数中,最开头就进行了redisServer对象的一些参数的初始化,并接着加载配置选项(包括命令行配置以及配置文件)。
3. 多机数据库
3.1 主从复制
首先,主从复制并不是说简单的数据转移手段,而是一种架构模式,它的作用是持续性的。
如果我们在端口为127.0.0.1:12345的机器上执行:
SLAVEOF 127.0.0.1 6379
那么,机器12345称为从服务器,6379称为主服务器,两者构成主从复制结构。
在主服务器上的修改操作,会同步到从服务器上。
SYNC
SYNC是2.8以前的复制算法。
主从复制功能分为“同步”和“命令传播”。
同步即刚开始构成主从复制结构时的数据同步,而命令传播指的是后续主服务器所有命令会传播给从服务器进行执行。
同步:
12345发送SLAVEOF命令后,需要先同步主服务器的数据,通过SYNC命令完成,步骤如下:
- 从服务器发送SYNC给主服务器
- 主服务器执行BGSAVE,生成一个RDB文件,并开启一个缓冲区记录从现在开始的所有写命令
- 将RDB文件发送给从服务器,从服务器载入该文件
- 主服务器将缓冲区中的所有写命令同步给从服务器
缺陷:
如果主从复制断线,当从服务器重连的时候,需要从头开始同步数据。
PSYNC
2.8以后的复制算法,使用PSYNC代替SYNC
PSYNC两种模式:
- full resynchronization完全重同步:跟SYNC的同步一样
- partial resynchronization部分重同步:同步断线之后的数据
部分重同步原理:
主从服务器都会维护一个复制偏移量,表示数据发送的字节个数。同时会维护一个fifo的固定大小缓冲队列(复制积压缓冲区),记录传播的数据。
假设发送/接收了100字节数据,那么主从服务器的复制偏移量均为100,
如果此时从服务器断线了,主从结构断开。
主服务器继续传播发送了10字节数据,复制偏移量变成110,随后从服务器重连成功,从服务器发送PSYNC命令,并携带了自己的复制偏移量100。
主服务器检查100之后的数据是否在复制积压缓冲区中,如果在,则返回CONTINUE,告知从服务器执行部分重同步,否则执行完全重同步。
此外,没个服务器都有自己的运行ID,从服务器会保存主服务器的运行ID,如果二次重连的时候发现运行ID不同(可能是连上另一台主服务器),那么必定执行完全重同步。
PSYNC命令实现:
从服务器发送:
PSYNC ? -1 :表示从服务器第一次进行同步,执行完全重同步
PSYNC <runid> <offset>
主服务返回:
+FULLRESYNC <runid> <offset>: runid是主服务器的id,从服务器会保存。offset是主服务器的复制偏移量,从服务器会使用该值进行初始化
+CONTINUE :表示执行部分重同步,告知从服务器准备接受断线之后的数据,随后主服务器会发送数据
-ERR:表示主服务器无法识别PSYNC(低于2.8),必须使用SYNC
复制的实现
- 从服务器发送
SLAVEOF 127.0.0.1 6379 - 从服务器保存127.0.0.1到redisServer.masterhost,保存端口到redisServer.masterport。
- 从服务器connect到主服务器,得到对应的fd,为该fd关联一个文件事件处理器进行复制工作。
- 主服务器accept后,得到对应的cfd
- 从服务器向主服务器发送ping,用于检查套接字连接是否正常,以及数据传输是否正常。不正常则进行重连
- 主服务器发送pong,表示接收到ping
- 身份校验(主服务器密码保存在requirepass中,从服务器的输入密码保存在masterauth中)
- 从服务器发送自己的端口信息给主服务器(目前仅用于打印输出该信息)
- 同步
- 命令传播
- 从服务器每秒发送一次心跳给主服务器,REPLCONF ACK <offset>,用于检查网络状态,检查数据丢失并重传,辅助实现min-slaves选项
min-slaves选项:
min-slaves-to-write 1 :从服务器个数少于1个,主服务器进入只读模式
min-slaves-max-lag 10:从服务器延迟大于10秒,主服务器进入只读模式
3.2 sentinel 哨兵模式(高可用方案)
简单来说,哨兵模式就是通过一个检测系统,检测所有的主从服务器,当主服务器故障之后,哨兵会重新挑选一个从服务器称为主服务器,而原来的主服务器重新上线后会降级为从服务器。
步骤:
启动并初始化sentinel
通过
redis-sentinel /path/sentinel.conf
或者
redis-server /path/sentinel.conf --sentinel
启动sentinel模式。
启动之后,会初始化sentinel服务器,核心步骤就是加载适用于sentinel的代码片段。
比如加载sentinel端口(26379),加载sentinel.c/sentinelcmds命令表(ping,sentinel,subscribe,unsubscribe,psubscribe,punsubscribe,info)。
然后初始化一个sentinelState对象。
struct sentinelState{
// 当前纪元,用于故障转移
unit64_t current_epoch;
// 保存所有被sentinel监视的服务器
dict* masters;
// 是否进入tilt模式
int tilt;
// 进入tilt模式的时间
mstime_t tilt_start_time;
// 最后一次执行时间处理器的时间
mstime_t previous_time;
// 正在执行的脚本数量
int running_scripts;
// FIFO队列,包含了所有需要执行的用户脚本
list* scripts_queue;
}
所谓的epoch
——配置纪元/当前纪元,其实就是一个“进行了多少次选举”的计数器。
dict* masters:key是服务器名,value是sentinelRedisInstance对象。
struct sentinelRedisInstance{
// 实例状态,如SRI_S_DOWN表示主观下线状态
int flags;
// 服务器名 ip:port格式
char* name;
char* runid;
// 配置纪元,用于故障转移
uint64_t config_epoch;
// 实例地址,保存着ip和port
sentinalAddr* addr;
// 多久无响应则判断为主观下线
mstime_t down_after_period;
// 判断为客观下线所需的票数
int quorum;
// 故障转移时,可以同时对新的主服务器进行同步的从服务器数量
int parallel_syncs;
// 刷新故障转移状态的最大时限
mstime_t failover_timeout;
}
当初始化sentinelState
以及master字典中的sentinelRedisInstance
结束之后,会创建2个连向主服务器的网络连接。
- 命令连接:专门想主服务器发送命令,接收命令回复
- 订阅连接:订阅主服务器的
__sentinel__:hello
频道
注册主服务器
sentinel默认每十秒发送一次INFO,获取主服务器的信息。
主服务器拥有从服务器的信息,该信息也会传递给sentinel。
注册从服务器
sentinel通过主服务器与所有的从服务器信息建立了关联,也会跟它们创建命令连接
和订阅连接
,并发送INFO获取相应的信息,进而为这些从服务器创建sentinelRedisInstance对象并持有。
sentinel互联
所有sentinel之间会建立一条命令连接
信息互通
sentinel默认每两秒,发送一次信息:
publish __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"
s开头的表示sentinel的信息,m开头的表示master的信息。
当订阅连接建立之后,sentinel会向服务器发送subscribe __sentinel__:hello
,该信息会广播给所有sentinel,该信息会被sentinel存储。
sentinel和通过命令连接发送该信息,通过订阅连接接收信息结果
主观下线
sentinel默认每秒给所有连接的节点(主从服务器、其他sentinel)发送ping命令,判断对端是否在线。
若长时间未收到回复,则判断该节点主观下线,修改该节点对应实例对象的flags=SRI_S_DOWN。
客观下线
当sentinel将某个节点置为主观下线时,会询问其他sentinel是否也认为该节点进入了下线状态(主观或客观均可),当sentinel从其他sentinel得到了一定数量的下线判断之后,就判定该节点为客观下线状态,并对该节点进行故障转移。
故障转移
在故障转移之前,首先会选举在所有sentinel中选举一个老大,由该老大对下线服务器进行故障转移操作。
选举的规则大致如下:
- 谁最先发现节点主观下线的谁做老大
- 同时间段有多个sentinel都发现了节点主观下线,在询问其他sentinel是否也发现该节点下线的时候,会获得对方的选票(每个sentinel只能投一次票)
- 如果规定时间内没有选出来,那就隔一段时间再次进行。
选出sentinel老大之后,该老大会对下线服务器进行故障转移:
- 在已下线的主服务器属下的所有从服务器中选出一个作为主服务器(状态最好、数据最新、优先级最高的)
- 变换主从架构,(所有其他从服务器认新主服务器)
- 已下线的主服务器设为新主服务器的从服务器
小结
sentinel是特殊模式下的server;
sentinel会监控所有节点,但只与主服务器建立两条连接,与其他sentinel建议一条命令连接;
sentinel通过定时发送心跳检验在线状态,若未收到回复,则判断对方为主观下线,随后询问其他所有sentinel,如果收到了足额的下线判断,就判定该主服务器为客观下线状态,随后开始选举sentinel老大,进行故障转移。
无论是sentinel还是集群,都只需要对master做故障转移,因为sentinel节点或者从节点断线了,并不影响主要服务。而且,除了master,其他节点也没有“转移”的说法。
3.3 集群(分布式数据库方案)
握手
集群是通过节点握手
搭建的。
假设现在分别启动了三个redis,7001,7002,7003。
这三个节点都开启了cluster-enabled
选项,
在7001里面执行:
cluster meet 127.0.0.1 7002
cluster meet 127.0.0.1 7003
这样就搭建好了一个三节点的集群。
集群节点保留所有的原内容,新增了集群模式专用的数据结构。
// 每个节点都保存的集群状态
struct clusterState{
clusterNode* myself;
uint64_t currnetEpoch;
// 集群的状态
int state;
// 集群节点数量
int size;
// 集群所有节点名单
dict* nodes;
}
struct clusterNode{
// 节点创建时间
mstime_t ctime;
// 节点名
char name[REDIS_CLUSTER_NAMELEN];
// 节点状态(上下线)、标识等
int flags;
// 当前配置纪元,用于故障转移
uint64_t configEpoch;
// 节点IP和端口
char ip[REDIS_IP_STR_LEN];
int port;
// 保存连接节点所需信息
clusterLink* link;
}
struct clusterLink{
// 连接创建时间
mstime_t ctime;
int fd;
// 输出缓冲区,保存着待发送给其他节点的msg
sds sndbuf;
//输入缓冲区,保存从其他节点接收的msg
sds rcvbuf;
// 与这个链接关联的节点
struct clasterNode* node;
}
槽指派
redis通过分片的形式保存数据库的键值对,集群的所有数据库被分为16384个槽(slot),只有所有slot被指派完毕,集群才处于ok状态,反之fail。
slot从0开始,slot∈[0,16383]
127.0.0.1:7000>cluster info
cluster_state:fail
通过cluster addslots分配槽
127.0.0.1:7000>cluster addslots 0 1 2 3 4 ... 5000
127.0.0.1:7001>cluster addslots 5000 5001 5002 5003 ... 10000
127.0.0.1:7002>cluster addslots 10001 10002 10003 10004 ... 16383
127.0.0.1:7000>cluster info
cluster_state:ok
每个clasterNode都持有一个 char slots[16384/8]的位图,对应的二进制位若为1,表示当前节点负责该槽。
clasterState持有一个clusterNode* slots[16384],记录了所有槽的指派信息。
客户端查询与MOVED
首先,客户端发起查询的时候,一定是固定向某个节点发起。
随后该节点会通过CRC16(key)&16383
得到键的所属槽,进而查看自己的slots位图,看该槽是否归自己所管。
如果归自己,就返回数据,如果不归自己管,就返回一个MOVED错误
(该错误其实不算错误,只是一个操作),该错误会永久改变客户端的连接到正确的槽归属节点上,随后正确的节点会返回数据。
如:
127.0.0.1:7001>get msg // 客户端向7001请求msg的数据
-> Redirected to slot [6754] located at :7002 // MOVED错误
"hello world" // MOVED将客户端改成连向7002,并得到7002的返回结果
127.0.0.1:7002>______ // 随后客户端变成跟7002通信
重新分片与ASK、ASKING
redis-trib可以实现槽的迁移,如果客户端访问了正在迁移过程中的槽,服务端就会返回给客户端ASK错误(该错误也不算一个错误,只是一个操作),该错误会改变本次请求到正确的槽归属节点上,随后正确的节点会返回数据。
流程:
127.0.0.1:7001>get msg // 客户端向7001请求msg的数据
// 7001告知客户端msg数据在7002 // ASK错误
// 客户端发送 ASKING 给 7002
"hello world" // 7002返回结果
127.0.0.1:7001>______ // 随后客户端依旧跟7001通信
clusterState持有一个clusterNode* importing_slots_from[16384]
数组,该数组表示第i个槽是否正在从importing_slots_from[i]节点迁移过来。(为null表示没有迁移行为)
clusterState持有一个clusterNode* migrating_slots_to[16384]
数组,该数组表示第i个槽是否正迁移给migrating_slots_to[i]节点。(为null表示没有迁移行为)
集群的复制与故障转移
集群的复制与主从复制逻辑基本一致;
集群的故障转移跟哨兵类似,主观下线改成疑似下线(probable fail,PFAIL),客观下线改成已下线(FAIL)。
集群的每个节点会定期向其他所有节点发送ping,若在规定时间内没有收到回复,则标记该节点为疑似下线,随后询问其他节点,若半数以上的节点都证明该节点疑似下线,那么该节点改为已下线状态。
故障转移:
从节点发现自己的主节点下线之后,就广播告知所有其他主节点,让他们投票从下线主节点的所有从节点中选举一个新的主节点。
其实可以这么理解:哨兵的选举权都转移给了主节点
4. 独立功能
4.1 发布与订阅
订阅分两种,订阅频道
、订阅模式
.
-
订阅频道:redisServer持有一个dict<频道名称,redisClient*>(
dict* pubsub_channels
),凡是订阅了对应频道的都会加入到该容器中,当给频道发送消息的时候,就会遍历频道给所有redisClient发送消息。 -
订阅模式:redisServer持有一个list<robj*,redisClient*>链表,凡是订阅了对应模式的都会挂到链表上,当给某个模式发送消息的时候,就会遍历该链表,给订阅了对应模式的client发送消息。
4.2 事务
redis使用MULTI
、EXEC
、WATCH
命令实现事务。
- MULTI:开启事务,接下来的所有命令都会发送给服务器,并存储到服务器持有的redisClient.mstate中,mstate对象结构如下
struct multiState{ // fifo的命令队列,存有argv、argc和redisCommand指针 multiCmd* commands; // 队中命令个数 int count; }
- EXEC:当客户端执行EXEC时,服务器会遍历执行该客户端存储在事务队列中的所有命令,并将结果挨个返回
- WATCH:WATCH是一个乐观锁,在EXEC之前执行,表示监控某个键在当前事务中不应该发生变化,如果发生了变化,EXEC会执行失败。
原理:
redisDb持有一个dict* watched_keys<key,redisClient*>,存储所有客户端监视的键。所有对数据库进行修改的命令,在执行之后都会调用multi.c/touchWatchKey
检查watched_keys,并修改对应客户端的REDIS_DIRTY_CAS。如果WATCH的键发生了变动,REDIS_DIRTY_CAS就置为1。当服务器接收到EXEC之后,先检查REDIS_DIRTY_CAS,如果为1,则取消该次事务。
原子性
redis仅仅保证一个事务原子性的执行,但是不保证事务的正确执行,因为redis没有事务回滚(redis作者觉得回滚不应该发生在生产阶段)。
如
MULTI
get key
get
get key
EXEC
value // 正常返回
error // 错误返回
value // 正常返回
2.6.5版本之后,如果在一个事务中发起了不存在的命令,会导致整个事务的所有命令都不被执行
一致性
一致性即保证数据库执行命令前后,数据符合数据库本身的定义和要求,没有非法或者无效的错误数据。
redis保证不让错误命令入队;
redis保证命令-数据不匹配时不执行该命令(如用字典命令操作字符串key);
redis保证服务器宕机时:数据库空白(未开启持久化)、通过RDB恢复到一致状态、通过AOF恢复到一致状态;
隔离性
单线程架构,必然隔离。
持久性
持久性指的是事务执行完毕,数据被持久化到磁盘上。由于redis是内存数据库,因此其事务的持久性取决于持久化方案。
显然,只有AOF模式下且appendfsync设为always(总是写并同步)时,才保证了持久性,其他情况都可能丢失数据。(需要关闭判定优先级更高的no-appendfsync-on-rewrite(默认关闭))
4.3 sort命令
redis进行sort的做法比较特别,他采用了一种“包装类”的做法,这样统一了所有数据结构的排序操作。
对于任意的排序对象,都会使用redisSortObject对象进行包装。
struct redisSortObject{
// 参与排序的原始对象
robj* obj;
union{
double score; // 排序数字时使用
robj* cmpojb; // 排序带有by选项的字符串时使用
}u;
}
如果对数值型的数据排序,就会将原数据转为double存储到u.score中,然后通过对u.score进行排序,排序之后,RedisSortObject持有的原始对象指针也就有序了。
如果是字符串型的数据,则直接根据RedisSortObject持有的原始对象进行排序。
而asc
和desc
选项,决定了排序是使用大于等于还是小于等于。
二进制位数组
二进制位数组即可操作二进制的数组,redis采用sds存储二进制位数组。一个字节的char是8位二进制。
二进制命令最难实现的是bitcount
,统计一个二进制串中1的个数,又称计算汉明重量
。
遍历
显然,遍历一遍二进制串,然后通过计数器记录1的个数是最简单的,但是如果要统计一个500MB的文件,那么需要遍历50010241024*8=4194304000(41亿次)。
这显代价太高
查表法
空间换时间思想。
理论基础:有限的集合,其排列方式是有限的。
举一个极端的例子,如果我们提前保存了1MB二进制串的所有可能的1的个数,那么我们就可以把500MB的文件查询500次表就可以得到结果。但是这个不太现实,保存32位可能出现的所有排列的表,就需要是十多个GB。
当然不会这么干,创建8位的表需要数百个字节(提高八倍遍历速度),创建16位的表需要数百个KB(提高16倍遍历速度)。
variable-precision SWAR算法
简单来说,就是通过一个swar公式进行统计。
该公式一次可以处理32位,且不需要额外内存。
因此,他比16位查表法快2倍。
不过,可以循环执行swar,理论上能够无限提高效率,但受限于缓存大小。
redis的实现
redis结合了查表法和variable-precision SWAR。
如果待求位数组的大小>=128:
调用:swar中一次性载入128位,调用4次swar。
否则:
调用查表法:记录8位表的表:0000 0000 到1111 1111所有二进制位的汉明重量。
个人疑惑
为什么redis的事务不让客户端打包所有命令之后再一次性commit给服务端执行?这样减少了通信次数,保证了原子性,也不需要WATCH功能。
跟大佬讨论之后,得到答案如下:
如果在客户端打包,就相当于pipeline,而pipeline不保证原子性
,因为缓冲区存在大小限制。对于一个小的命令包,redis-server保证其原子性(一次性执行),但是若是较大的命令包,会被拆分,这就不一定保证其原子性了。
还有一个原因,对redis-server来说,pipeline对其来说是透明的(也就是说,接收pipeline的时候没有做特殊校验,不然就可以采取对大命令包进行先拆后合的操作)
因此,个人猜想,如果想要取消WATCH又要保证事务原子性,至少需要满足以下几个条件:
- 1.在传输协议上要标志数据包是事务
- 2.客户端需要打包所有命令,且缓冲区要足够大
- 3.服务端需要有个接收队列,且缓冲区要足够大
满足第一条的开销就特别大了,因为redis中事务使用的频率远小于非事务命令,这就导致对于任意命令都需要检查其是否是事务。
如果采用“先发一个命令告知服务器开启事务,然后客户端打包所有命令进行提交执行”,就个pipeline类似,对缓冲区有所要求。