redis底层设计(一)——内部数据结构

  redis是一个key-value存储系统。和Memcached相似,它支持存储的value类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操做,并且这些操做都是原子性的。在此基础上,redis支持各类不一样方式的排序。与memcached同样,为了保证效率,数据都是缓存在内存中。区别的是redis会周期性的把更新的数据写入磁盘或者把修改操做写入追加的记录文件,而且在此基础上实现了master-slave(主从)同步。前端

1 内部数据结构redis

1.1 简单动态字符串sds:算法

  Sds (Simple Dynamic String,简单动态字符串)是Redis 底层所使用的字符串表示,它被用在几乎全部的Redis 模块中。数据库

1.1.1 sds的用途:api

  a.实现字符串对象(StringObject):数据库的键老是包含一个sds值,而数据库的值保存的是String类型的时候值中包含sds值,不然包含的是long类型的值。数组

  b.在redis程序内部用做char*类型的替代品:char*类型的功能单一抽象层次低不能支持redis的一些经常使用操做(长度计算和追加操做),缓存

1.1.2 redis中的字符串:安全

  在C 语言中,字符串能够用一个\0 结尾的char 数组来表示。好比说,hello world 在C 语言中就能够表示为"hello world\0" 。这种简单的字符串表示在大多数状况下都能知足要求,可是,它并不能高效地支持长度计算和追加(append)这两种操做:
  • 每次计算字符串长度(strlen(s))的复杂度为θ(N) 。服务器

  • 对字符串进行N 次追加,一定须要对字符串进行N 次内存重分配(realloc)。数据结构

  在Redis 内部,字符串的追加和长度计算并很多见,而APPEND 和STRLEN 更是这两种操做在Redis 命令中的直接映射,这两个简单的操做不该该成为性能的瓶颈。Redis 除了处理C 字符串以外,还须要处理单纯的字节数组,以及服务器协议等内容,因此为了方便起见,Redis 的字符串表示还应该是二进制安全的:程序不该对字符串里面保存的数据作任何假设,数据能够是以\0 结尾的C 字符串,也能够是单纯的字节数组,或者其余格式的数据。(这就是redis用sds替换char*的缘由:sds能够高效的实现追加和长度计算,而且仍是二进制安全的)

typedef char *sds;
  struct sdshdr {
  // buf 已占用长度
  int len;
  // buf 剩余可用长度
  int free;
  // 实际保存字符串数据的地方
  char buf[];
};

其实类型sds是char*的别名,而结构sdshdr则保存了len、free、bug这三个参数属性。经过len 属性,sdshdr 能够实现复杂度为θ(1) 的长度计算操做。另外一方面,经过对buf 分配一些额外的空间,并使用free 记录未使用空间的大小,sdshdr 可让执行追加操做所需的内存重分配次数大大减小

1.1.3 优化追加操做:

  当执行追加操做时,好比如今给key=‘Hello World’的字符串后追加‘ again!’则这时的len=18,free由0变成了18,此时的buf='Hello World again!\0                  ',也就是buf的内存空间是18+18+1=37个字节,其中‘\0’占1个字节redis给字符串多分配了18个字节的预分配空间,因此下次还有append追加的时候,若是预分配空间足够,就无须在进行空间分配了。在当前版本中,当新字符串的长度小于1M时,redis会分配他们所需大小一倍的空间,当大于1M的时候,就为他们额外多分配1M的空间。

思考:这种分配策略会浪费内存资源吗?

答:执行过APPEND 命令的字符串会带有额外的预分配空间,这些预分配空间不会被释放,除非该字符串所对应的键被删除,或者等到关闭Redis 以后,再次启动时从新载入的字符串对象将不会有预分配空间。由于执行APPEND 命令的字符串键数量一般并很少,占用内存的体积一般也不大,因此这通常并不算什么问题。另外一方面,若是执行APPEND 操做的键不少,而字符串的体积又很大的话,那可能就须要修改Redis 服务器,让它定时释放一些字符串键的预分配空间,从而更有效地使用内存。

1.1.4 小结:

  a.redis的字符串表示为sds,而不是C字符串(以\0结尾的char*)。

  b.对比C字符串,sds有如下特性:高效的执行长度计算;高效的执行追加操做;二进制安全

  c.sds会为追加操做进行优化,加快追加操做的速度,并下降内存分配的次数,代价是多占用了一些内存 ,并且这些内存不会被主动释放。

 

1.2 双向链表

  链表做为数组以外的一种经常使用序列抽象,是大多数高级语言的基本数据类型,由于C 语言自己不支持链表类型,大部分C 程序都会本身实现一种链表类型,Redis 也不例外——它实现了一个双向链表结构。

1.2.1 双向链表的应用:

  双向链表做为一种通用的数据结构,在Redis 内部使用得很是多:它既是Redis 列表结构的底层实现之一,还被大量Redis 模块所使用,用于构建Redis 的其余功能。

注意:redis列表使用两种数据结构做为底层实现:双向链表和压缩列表。由于双向链表占用的内存比压缩列表的要多,因此在建立新的列表键时,列表会优先考虑使用压缩列表做为底层实现,而且在有须要的时候,才会从压缩列表实现转换到双向链表实现。

  除了实现列表类型之外,双向列表还被不少redis内部模块所应用:

  a.事务模块使用双向链表来按顺序保存输入的命令;

  b.服务器模块使用双向链表来保存多个客户端;

  c.订阅/发送模块使用双向链表来保存订阅模式的多个客户端;

  d.时间模块使用双向链表来保存时间事件(time event)

除此以外,其实相似的应用还有不少。

1.2.2 双向链表的实现:

  双向链表是由listNode和list两个数据结构组成,以下:

其中listNode是双向链表的节点,包含prev(前驱指针)、next(后继指针)和value(数值);list是双向链表自己,包含head(表头指针)、tail(表尾指针)、len(节点数量)、dup(复制函数)、free(释放函数)和match(对比函数)

举个例子:当删除一个listNode时,若是包含这个节点的list的list->free函数不为空,那么删除函数就会先调用list->free(listNode->value)清空节点的值,再执行余下的删除操做(好比说释放节点)。

从结构上总结出他们的性能特征:

a. listNode带有prev和next两个指针,所以对链表的遍历能够在两个方向上进行:从表头到表尾,或者从表尾到表头。

b.list保存了head和tail两个指针,所以对链表的表头和表尾进行插入的复杂度都是θ(1)——这是实现LPUSH、RPOP、RPUSH、LPOP的关键。

c.list带有保存九点数量的len属性,因此计算链表长度的复杂度为θ(1),因此LLEN的命令性能很高。

 1.2.3 迭代器:

  redis为双向链表实现了一个迭代器,这个迭代器能够从两个方向对双向链表进行迭代:

  *沿着节点的next指针前进,从表头向表尾迭代;

  *沿着节点的prev指针前进,从表尾向表头迭代;

1.2.4 小结:

  *redis实现了本身的双向链表结构;

  *双向链表主要有两个做用:

    -做为redis列表类型的底层实现之一;

    -做为通用数据结构,被其余功能模块所使用;

  *双向链表及其节点的性能特性以下:

    -节点带有前驱和后继指针,访问前驱节点和后继节点的时间复杂度为θ(1),而且堆链表的迭代能够在从表头到表尾和从表尾到表头两个方向进行;

    -链表带有只想表头和表尾的指针,所以对表头和表尾进行处理的复杂度为θ(1);

    -链表带有记录节点数量的属性,因此能够在θ(1)复杂度内返回链表的节点数量(长度);

 

1.3 字典

  字典,又名映射(map)或关联数组(associative array),他是一种抽象的数据结构,由一集键值对组成,各个键值对的键各不相同,程序能够将新的键值对添加到字典中,或者基于键进行查找、更新或删除操做。

1.3.1字典的应用

  字典的主要用途有如下两个:

  1)实现数据库键空间(key space)

  redis是一个键值对数据库,数据库中的键值对就是由字典保存:每一个数据库都有一个与之相对应的字典,这个字典被称为键空间(key space)。当用户添加一个键值对到数据库时(不论数据库是什么类型),程序就将该键值对添加到键空间;当用户从数据库删除一个键值对时,程序就会将这个键值对从键空间删除

  2)用做Hash类型键的其中一种底层实现

  redis的Hush类型键使用如下两种数据结构做为底层实现:字典、压缩列表。由于压缩列表比字典更节省内存,因此程序在建立新Hush键时,默认使用压缩列表做为底层实现,当有须要时,程序才会将底层实现从压缩列表转换为字典。

1.3.2 字典的实现

  在众多可能的实现中,redis选择了高效且实现简单的哈希表做为字典的底层实现。

/*
* 字典
**
每一个字典使用两个哈希表,用于实现渐进式rehash
*/
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录rehash 进度的标志,值为-1 表示rehash 未进行
int rehashidx;
// 当前正在运做的安全迭代器数量
int iterators;
} dict;

如下是用于处理dict 类型的API ,它们的做用及相应的算法复杂度:

注意:dict 类型使用了两个指针分别指向两个哈希表,其中0号哈希表(ht[0])表示字典主要使用的哈希表,而1号哈希表(ht[1])表示只有在程序对0号哈希表进行rehash时才使用。

 

/*
* 哈希表
*/
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组的大小
unsigned long size;
// 指针数组的长度掩码,用于计算索引值
unsigned long sizemask;
// 哈希表现有的节点数量
unsigned long used;
} dictht;

table属性是一个数组,数组的每一个元素都是一个指向dictEntry结构的指针。每一个dictEntry都保存着一个键值对,以及一个指向另外一个dictEntry结构的指针:

/*
* 哈希表节点
*/
typedef struct dictEntry {
//
void *key;
//
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 链日后继节点
struct dictEntry *next;
} dictEntry;

next属性指向另外一个dictEntry结构,多个dictEntry能够经过next指针串连成链表,dictht使用链地址法来处理键碰撞(链地址法:将所有具备一样哈希地址的而不一样keyword的数据元素链接到同一个单链表中。假设选定的哈希表长度为m,则可将哈希表定义为一个有m个头指针组成的指针数组T[0..m-1]。凡是哈希地址为i的数据元素,均以节点的形式插入到T[i]为头指针的单链表中。并且新的元素插入到链表的前端,这不只因为方便。还因为经常发生这种事实:新近插入的元素最有可能不久又被访问。)哈希表例子:

1.3.3 添加键值对到字典

  根据字典所处的状态,将一个给定的键值对添加到字典可能会引发一系列复杂的操做:

  *若是字典未初始化(字典的0号哈希表的table属性为空),那么程序须要怼0号哈希表进行初始化;

  *若是在插入时发生了键碰撞,那么程序须要处理碰撞;

  *若是新插入的元素使得字典知足了rehash条件,那么须要启动相应的rehash程序;

下面分别介绍添加操做在以上三种状况下的执行:

  (1)字典为空

  程序会根据dict.h/DICT_HT_INITIAL_SIZE 里指定的大小为d->ht[0]->table 分配空间(在目前的版本中,DICT_HT_INITIAL_SIZE 的值为4 )。下面是添加键值对后的样子:

  (2)添加新键值对时发生碰撞处理

  在哈希表实现中,当两个不一样的键拥有相同的哈希值时,咱们称这两个键发生碰撞,而哈希表实现必须对碰撞进行处理。通常会采用链地址法(使用链表将多个哈希值相同的节点串连在一块儿),以下:

  对于一个新的键值对key4 和value4 ,若是key4 的哈希值和key1 的哈希值相同,那么它们将在哈希表的0 号索引上发生碰撞。经过将key4-value4 和key1-value1 两个键值对用链表链接起来,就能够解决碰撞的问题:

 

  (3)触发rehash操做:

  对于使用链地址法来解决碰撞问题的哈希表dictht 来讲,哈希表的性能依赖于它的大小(size属性)和它所保存的节点的数量(used 属性)之间的比率:

    • 比率在1:1 时,哈希表的性能最好;
    • 若是节点数量比哈希表的大小要大不少的话,那么哈希表就会退化成多个链表,哈希表自己的性能优点就再也不存在;

  以下:

  对于上面这个哈希表,平均每次失败查询须要5个节点,效率极低。为了在字典的键值对不断增多的状况下保持良好的性能,字典须要对所使用的哈希表(ht[0])进行rehash 操做:在不修改任何键值对的状况下,对哈希表进行扩容,尽可能将比率维持在1:1左右。dictAdd 在每次向字典添加新键值对以前,都会对哈希表ht[0] 进行检查,对于ht[0] 的size 和used 属性,若是它们之间的比率ratio = used / size 知足如下任何一个条件的话,rehash 过程就会被激活:

  1. 天然rehash :ratio >= 1 ,且变量dict_can_resize 为真。
  2. 强制rehash : ratio 大于变量dict_force_resize_ratio (目前版本中,dict_force_resize_ratio 的值为5 )。

1.3.4 rehash执行过程:

  1.建立一个比ht[0]->table 更大的ht[1]->table;

  2.将ht[0]->table中的全部键值对前一代ht[1]->table;

  3.将原有ht[0]的数据清空,并将ht[1]替换成新的ht[0];

 

  下面具体介绍rehash的完整过程:

  a.开始rehash

  设置字典的rehashidx为0,标志着rehash的开始;为ht[1]->table分配空间,大小至少是ht[0]->table 的两倍;

  b.rehash进行中

  在这个阶段,ht[0]->table 的节点会被逐渐迁移到ht[1]->table ,由于rehash 是分屡次进行的,字典的rehashidx 变量会记录rehash 进行到ht[0] 的哪一个索引位置上:

  c.rehash完毕:

  在rehash 的最后阶段,程序会执行如下工做:
  1. 释放ht[0] 的空间;
  2. 用ht[1] 来代替ht[0] ,使原来的ht[1] 成为新的ht[0] ;
  3. 建立一个新的空哈希表,并将它设置为ht[1] ;
  4. 将字典的rehashidx 属性设置为-1 ,标识rehash 已中止;
  如下是字典rehash 完毕以后的样子:

  

  对比字典rehash以前和以后,新的ht[0]空间更大,而且字典原有的键值对也没有被修改或者删除。

  1.3.5 渐进式rehash:

  在一个有不少键值对的字典里,某个用户在添加新键值对时触发了rehash  过程,若是这个rehash 过程必须将全部键值对迁移完毕以后才将结果返回给用户,这样的处理  方式将是很是不友好的。另外一方面,要求服务器必须阻塞直到rehash 完成,这对于Redis 服务器自己也是不能接受的。为了解决这个问题,Redis 使用了渐进式(incremental)的rehash 方式:经过将rehash 分散到多个步骤中进行,从而避免了集中式的计算。
  渐进式rehash 主要由_dictRehashStep 和dictRehashMilliseconds 两个函数进行:
  • _dictRehashStep 用于对数据库字典、以及哈希键的字典进行被动rehash ;
  • dictRehashMilliseconds 则由Redis 服务器常规任务程序(server cron job)执行,用于对数据库字典进行主动rehash ;

   _dictRehashStep:每次执行_dictRehashStep ,ht[0]->table 哈希表第一个不为空的索引上的全部节点就会所有迁移到ht[1]->table 。在rehash 开始进行以后(d->rehashidx 不为-1),每次执行一次添加、查找、删除操做,_dictRehashStep 都会被执行一次。由于字典会保持哈希表大小和节点数的比率在一个很小的范围内,因此每一个索引上的节点数量不会不少(从目前版本的rehash 条件来看,平均只有一个,最多一般也不会超过五个),因此在执行操做的同时,对单个索引上的节点进行迁移,几乎不会对响应时间形成影响。

  dictRehashMilliseconds: 能够在指定的毫秒数内,对字典进行rehash 。当Redis 的服务器常规任务执行时,dictRehashMilliseconds 会被执行,在规定的时间内,尽量地对数据库字典中那些须要rehash 的字典进行rehash ,从而加速数据库字典的rehash进程。

  在哈希表进行rehash 时,字典还会采起一些特别的措施,确保rehash 顺利、正确地进行:
  • 由于在rehash 时,字典会同时使用两个哈希表,因此在这期间的全部查找、删除等操做,除了在ht[0] 上进行,还须要在ht[1] 上进行。
  • 在执行添加操做时,新的节点会直接添加到ht[1] 而不是ht[0] ,这样保证ht[0] 的节点数量在整个rehash 过程当中都只减不增。

   1.3.6 字典的收缩:

  上面描述了经过rehash对字典的扩展,若是哈希表的也用节点数比已用节点数搭不少,那么也能够经过哈希表进行rehash来收缩字典。执行步骤以下:

  1)建立一个比ht[0]->table 小的ht[1]->table ;

  2)将ht[0]->table中的全部键值对迁移到ht[1]->table ;

  3)将原有的ht[0]的数据清空,并将ht[1]替换成ht[0];  

  字典的收缩规则由htNeedsResize函数定义:

/*
* 检查字典的使用率是否低于系统容许的最小比率
**
是的话返回1 ,不然返回0 。
*/
int htNeedsResize(dict *dict) {
long long size, used;
// 哈希表已用节点数量
size = dictSlots(dict);
// 哈希表大小
used = dictSize(dict);
// 当哈希表的大小大于DICT_HT_INITIAL_SIZE
// 而且字典的填充率低于REDIS_HT_MINFILL 时
// 返回1
return (size && used && size > DICT_HT_INITIAL_SIZE &&
(used*100/size < REDIS_HT_MINFILL));
}

  在默认状况下,REDIS_HT_MINFILL 的值为10 ,也便是说,当字典的填充率低于10% 时,程序就能够对这个字典进行收缩操做了。
  字典收缩和字典扩展的一个区别是:
    • 字典的扩展操做是自动触发的(无论是自动扩展仍是强制扩展);
    • 而字典的收缩操做则是由程序手动执行。
  所以,使用字典的程序能够决定什么时候对字典进行收缩:
    • 当字典用于实现哈希键的时候,每次从字典中删除一个键值对,程序就会执行一次htNeedsResize 函数,若是字典达到了收缩的标准,程序将当即对字典进行收缩;
    • 当字典用于实现数据库键空间(key space) 的时候, 收缩的时机由redis.c/tryResizeHashTables 函数决定。

  1.3.7 字典的迭代:

  字典有本身的迭代器实现——对字典进行迭代实际上就是对字典所使用的哈希表进行迭代:

    • 迭代器首先迭代字典的第一个哈希表,而后,若是rehash正在进行的话,就继续对第二个哈希表进行迭代;

    • 当迭代哈希表时,找到第一个不为空的索引,而后迭代这个索引上的全部节点。

    • 当这个索引迭代完了,继续查找下一个不为空的索引,如此循环,一直到整个哈希表都迭代完为止。

  字典的迭代器有两种:

    • 安全迭代器

    • 不安全迭代器

/*
* 字典迭代器
*/
typedef struct dictIterator {
dict *d; // 正在迭代的字典
int table, // 正在迭代的哈希表的号码(0 或者1)
index, // 正在迭代的哈希表数组的索引
safe; // 是否安全?
dictEntry *entry, // 当前哈希节点
*nextEntry; // 当前哈希节点的后继节点
} dictIterator;

如下是这个迭代器的api:

  1.3.8 小结:    

    • 字典是由键值对构成的抽象数据结构;

    • Redis 中的数据库和哈希键都是基于字典来实现的;

    • Redis 字典的底层实现为哈希表,每一个字典使用两个哈希表,通常状况下只使用0号哈希表,只有在rehash进行时,才会使用0号和1号哈希表;

    • 哈希表使用链地址法来解决键冲突的问题;

    • rehash能够用于扩展和收缩哈希表;

    • 对哈希表的rehash是分屡次、渐进式地进行。

 

 1.4 跳跃表

  跳跃表是一种随机化的数据,这种数据结构以有序的方式在层次化的链表中保存元素,以下图:

  从上图中咱们能够看出跳跃表的结构组成:

    • 表头(head):负责维护跳跃表的节点指针。
    • 跳跃表节点:保存着元素值,以及多个层。
    • 层:保存着指向其余元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提升查找的效率,程序老是从高层先开始访问,而后随着元素值范围的缩小,慢慢下降层次。
    • 表尾:所有由NULL 组成,表示跳跃表的末尾。

  1.4.1 跳跃表的实现:

  a.容许重复的score值:多个不一样的member的score值能够相同;

  b.进行对比操做时,不只要检查score值,还要检查member:当score值能够重复时,单靠score值没法判断一个元素的身份,因此须要连member域都一并检查才行;

  c.每一个节点都带有一个高度为1层的后腿指针,用于从表头方向向表尾方向迭代:当执行ZERVRANGE或ZREVRSNGEBYSCORE这类以逆序处理有序集的命令时,就会用到这个属性。

typedef struct zskiplist {
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数
int level;
} zskiplist;
跳跃表的节点由redis.h/zskiplistNode 定义:
typedef struct zskiplistNode {
// member 对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
//
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 这个层跨越的节点数量
unsigned int span;
} level[];
} zskiplistNode;

  1.4.2 跳跃表的应用:

  和字典、链表或者字符串这几种在redis中大量使用的数据结构不一样,跳跃表在redis的惟一做用就是实现有序集数据类型。

  跳跃表将指向有序集的score值和member域的指针做为元素,并以score值为索引,对有序集元素进行排序。

  1.4.3 小结:

  跳跃表是一种随机化数据结构,它的查找、添加、删除操做均可以在对数指望时间下完成;

  跳跃表目前在redis的惟一做用就是做为有序集类型的底层数据结构(之一,另外一个构成有序集的结构是字典);

  为了适应自身的需求,redis基于William Pugh 论文中描述的跳跃表进行了修改,包括:

  a.score值可重复;

  b.对比一个元素须要同时检查它的score值和member域;

  c. 每一个节点带有高度为1层的后退指针,用于从表尾方向向表头方向迭代。 

相关文章
相关标签/搜索