Redis的数据结构介绍

咱们都知道Redis是用C语言编写的内存数据库。可是因为C几乎没有提供任何数据结构的封装,因此Redis为了实现更快,更安全的操做,本身在内部封装了一系列的数据结构。 其中包括了简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表,下面来一一介绍(画的图有点丑。。)。redis

简单动态字符串(SDS)

SDS定义

在redis中,只有字符串字面量才会用C字符串来表示(好比打印日志),其它都使用SDS来表示(好比键值对的键都是用SDS表示的字符串)。算法

SDS的结构:

struct sdshdr {
  // 记录buf数组已使用的字节数,也就是SDS字符串的长度
  int len;
  // 记录buf数组中未使用字节的数量
  int free;
  // 字节数组,用于保存字符串
  char buf[];
}
复制代码

SDS为了能够重用C字符串函数库里的函数,因此遵循了用空字符结尾,但这个空字符不计入len属性中。数据库

SDS的特色

  1. 常数复杂度获取字符串长度。C字符串若是要获取字符串长度,必须从头至尾遍历整个字符串,因此致使复杂度为O(N)。可是SDS自己在属性中记录了长度,因此获取SDS长度的复杂度为O(1)。
  2. 杜绝缓冲区溢出。C字符串若是在拼接字符串操做时,已分配的内存空间不足以放下拼接后的字符串,那么将会形成缓冲区溢出。可是SDS会根据所需空间和自身空间来动态扩展空间大小。
  3. 经过未使用空间减小了内存重分配次数。C字符串在每次拼接或截断操做时,都要从新分配内存空间以防止缓冲区溢出或内存泄漏。而SDS经过未使用空间实现了空间预分配和惰性空间释放两种优化策略来减小内存重分配次数。
    1. 空间预分配:若是SDS修改以后,长度将小于1MB,那么将会分配和SDS长度一样大小的未使用空间。若是长度将大于1MB,那么将直接分配1MB的未使用空间。
    2. 惰性空间释放:若是SDS的长度缩短时,多余的空间并不会被当即释放,而是用未使用空间将他们留在SDS中,未之后可能的增长预留空间。固然,SDS也能够经过手动调用API来释放未使用空间,以避免形成内存泄漏。
  4. 二进制安全 。因为C字符串会将遇到的第一个空字符判断为字符串结尾,因此致使C字符串只能保存文本,而不能保存像图片、视频等二进制数据,因此C字符串被称为字符数组。而SDS会以处理二进制的方式来处理SDS存放再buf数组里的数据,SDS不是以空字符判断结尾的,而是经过len属性的值来判断字符串是否结束。因此SDS的API是二进制安全的,能够存放各类数据,因此SDS被称为字节数组。
  5. 兼容部分C字符串的函数。由于SDS与C字符串同样遵循以空字符结尾,因此可让那些保存文本数据的SDS重用一部分C字符串函数库的函数。

链表

当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表做为列表键的底层实现。同时,在发布与订阅、慢查询、监视器等功能也用到了链表。数组

链表和链表节点的实现

链表结构

typedef struct list {
  // 表头节点
  listNode *head;
  // 表尾节点
  listNode *tail;
  // 链表所包含的节点数量
  unsigned long len;
  // 节点值复制函数
  void *(*dup)(void *ptr);
  // 节点值释放函数
  void (*free)(void *ptr);
  // 节点值对比函数
  void (*match)(void *ptr, void *key);
} list;
复制代码

链表结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len。而dup、free和match则是用于实现多台所需的类型特定函数,从而实现能够保存各类不一样类型的值。安全

链表节点结构

typedef struct listNode {
  // 前置节点
  struct listNode *prev;
  // 后置节点
  struct listNode *next;
  // 节点的值
  void *value;
}listNode;
复制代码

多个listNode能够经过prev和next指针组成双端链表。可是无环,由于表头节点的prev指针和表尾节点的next指针都指向NULL,因此对链表的访问以NULL为终点。 服务器

在这里插入图片描述

字典

Redis的数据库就是使用字典做为底层来实现的。能够把数据库中全部的对象都看做是键值对,而这个键值对就是保存在表明数据库的字典里的。另外,哈希键的底层也是经过字典实现的。数据结构

字典的实现

字典结构

typedef struct dict {
  // 类型特定函数(我以为这个应该是至关于Java中的泛型)
  dictType *type;
  // 私有数据
  void *privdata;
  // 哈希表数组,字典存储使用ht[0],ht[1]在rehash迁移字典数据时使用
  dictht ht[2];
  // rehash索引,当rehash不在进行时,值为-1
  int trehashidx;
} dict;
复制代码

type属性和privdata属性是针对不一样类型的键值对,为建立多态字典而设置的。函数

哈希表结构

typedef struct dictht {
  // 哈希表节点数组
  dictEntry **table;
  // 哈希表大小
  unsigned long size;
  // 哈希表大小掩码,用于计算索引值,老是等于size - 1
  unsigned long sizemask;
  // 该哈希表已有节点的数量
  unsigned long used;
} dictht;
复制代码

sizemask属性和哈希值一块儿决定一个键应该被放到table数组的哪一个索引上面。性能

哈希表节点结构

typedef struct dictEntry {
  // 键
  void *key;
  // 值,用union结构存储数据,用于压缩空间
  union {
    void *val;
    uint64_t u64;
    int64_t s64;
  } v;
  // 指向下个哈希表节点,造成链表(拉链法解决哈希冲突)
  struct dictEntry *next;
} dictEntry;
复制代码

在这里插入图片描述

哈希算法

当要将一个新的键值对添加到字典里面时,程序须要先根据键值对的键计算出哈希值,再根据哈希表的sizemask和哈希值计算出索引值,而后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。 Redis使用MurmurHash2算法来计算键的哈希值,这种算法的优势在于,即便输入的键是有规律的,算法仍能给出一个很好的随机分布性,而且算法的计算速度也很是快。优化

rehash

扩展和收缩哈希表的工做能够经过执行rehash(从新散列)操做来完成,Redis对字典的哈希表执行rehash的步骤以下:

  1. 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操做,以及ht[0]当前包含的键值对数量(也就是ht[0].used属性的值)
    1. 若是要执行的是扩展操做,那么ht[1] 的大小为第一个大于等于ht[0].used乘以2的2的n次方幂。好比ht[0].used的值为4,那么4乘以2等于8又等于2的三次方幂,因此ht[1]的大小将被分配为8.
    2. 若是要执行的是收缩操做,那么ht[1] 的大小为第一个大于等于ht[0].used的2的n次方幂。好比ht[0].used的值为4,那么4等于2的2次方幂,因此ht[1]的大小将被分配为2.
  2. 将保存在ht[0]中的全部键值对rehash到ht[1]上面,rehash指的是从新计算键的哈希值和索引值,而后将键值对按照索引值放到ht[1]对应的位置上。
  3. 当ht[0]中的全部键值对都迁移到了ht[1]以后,ht[0]的空间将会被释放,而后将ht[1]设置为ht[0],并再建立一个ht[1]空表,为下一次rehash作准备。

渐进式rehash

为了不rehash对服务器性能形成影响,服务器并非一次性将ht[0]里面的全部键值对所有rehash到ht[1],而是分屡次、渐进式的将ht[0]里面的键值对慢慢的rehash到ht[1]。这里就用到了rehashidx属性,当程序处理rehash期间时,rehashidx值被设置为0,当rehash操做完成时,又将它设置为-1.
渐进式rehash的好处在于它采起分而治之的方式,将rehash键值对所需的计算工做均摊到对字典的每一个增删改查操做上,从而避免了集中式rehash带来的庞大计算量。
另外,在rehash期间,字典的删除、查找、更新操做会在两个哈希表上进行,若是在ht[0]没有找到的话,就回去ht[1]找。而添加操做则所有在ht[1]进行,即全部新添加的键值对都会存到ht[1]里面。

跳跃表

跳跃表是一种有序数据结构,它经过在每一个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,还能够经过顺序行操做来批量处理节点。在Redis中用跳跃表来做为有序集合的底层实现之一。

跳跃表的实现

跳跃表结构(zskiplist)

typedef struct zskiplist {
  // 表头节点和表尾节点
  struct zskiplistNode *header, *tail;
  // 表中节点的数量
  unsigned long length;
  // 表中层数最大的节点的层数
  int level;
} zskiplist;
复制代码

level属性用于在O(1)复杂度内获取跳跃表中层数最高的那个节点的层数,注意,表头节点的层高并不能算在里面。

跳跃表节点

typedef struct zskiplistNode {
  // 后退指针
  struct zskiplistNode *backward;
  // 分值
  double score;
  // 成员对象
  robj *obj;
  // 层
  struct zskiplistLevel {
    // 前进指针
    struct zskiplistNode * forward;
    // 跨度
    unsigned int span;
  } level[];
} zskiplistNode;
复制代码

\[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kdIgrhl3-1573733502227)(https://i.loli.net/2019/11/14/cfYdF2s7x8jLBIC.png)\]

  1. 层:每次建立一个新跳跃表节点的时候,程序都根据幂次定律(越大的数出现的几率越小)随机生成一个介于1和32之间的值做为level数组的大小,这个大小就是层的高度。
  2. 前进指针:每一个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点,当程序遍历跳跃表的时候,就是根据每一个层的前进指针来移动的。
  3. 跨度:层的跨度用于记录两个节点之间的距离,这个是用于来计算节点的排位的。在查找某个节点的过程当中,将沿途访过的全部层的跨度累加起来,获得的就是当前节点在跳跃表中的排位。
  4. 后退指针:节点的后退指针用于从表尾向表头方向访问节点,但后退指针每次只能后退至前一个节点,而不能跳跃多个节点。
  5. 分值和成员:节点的分值是一个double类型的浮点数,也就是表明着节点的排位。跳跃表中的全部节点都按分值从小到大排序。节点的成员对象是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。在一个跳跃表中,各个节点的成员对象必须是惟一的,可是分值能够相同。分值相同的节点按照成员对象在字典序中的大小来排序,成员对象较小的节点会排在前面(靠近表头的方向)。

整数集合

整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,且元素数量很少时,将会使用整数集合做为集合的底层实现。

整数集合的实现

typedef struct intset {
  // 编码方式
  uint32_t encoding;
  // 集合包含的元素数量
  uint32_t length;
  // 保存元素的数组
  int8_t contents[];
} intset;
复制代码

encoding的类型能够是int16_t,int32_t或者int64_t。其中虽然contents被声明为int8_t,但实际上contents数组中不会保存int8_t类型的值,真正的类型仍是取决于encoding属性的值。注意,若是contents数组中包含了不一样整数类型的值,那么encoding将被设置为占用空间最大的那个类型。同时,其余值也将被升级编码为该类型。

升级

当咱们要将一个新元素添加到整数集合里时,而且新元素的类型比整数集合现有元素的类型都要长时,咱们将须要先将整数集合进行升级,才能将新元素添加进去。
升级整数集合并添加新元素分三步进行:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组的其余元素都转换为新类型,并保存与原来相同的顺序放置。
  3. 最后再将新元素添加到数组中。

压缩列表

压缩列表是列表建和哈希键的底层实现之一。当列表键或哈希键中的元素较少时,将会使用压缩列表来做为他们的底层实现。

压缩列表的实现

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序性数据结构。一个压缩列表能够包含任意多个节点,一个节点能够保存一个SDS或一个整数值。

\[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GKvIvNi0-1573733502233)(https://i.loli.net/2019/11/14/7hqXRxWIcN4QMwb.png)\]

  1. zlbytes:记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或计算zlend的位置时使用。
  2. zltail:记录压缩列表表尾节点距离压缩列表的起始地址有多少个字节,经过zltail能够经过O(1)复杂度肯定表尾节点的地址。
  3. zlen:记录压缩列表的节点数量,当这个值等于UINT16_MAX时,节点的真实数量须要遍历整个压缩列表才能获得。
  4. entryX:压缩列表包含的各个节点。
  5. zlend:特殊值0xFF,用于标记压缩列表的结尾。

压缩列表节点的实现

每一个压缩列表节点能够保存一个字节数组或者一个整数值。压缩列表节点由三部分组成。

在这里插入图片描述

previous_entry_length

记录了压缩列表中前一个节点的长度。previous_entry_length属性自身的长度能够是1字节或5字节。

  • 若是前一个节点的长度小于254字节,那么previous_entry_length属性的长度为1字节,前一节点的长度就保存在这1字节里。
  • 若是前一个字节的长度大于等于254字节,那么previous_entry_length属性的长度为5字节。其中1字节将被设置为oxFE,而其它4字节用于保存前一节点的长度。 程序能够经过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。压缩列表的从表尾向表头的遍历操做就是利用这一原理实现的。

encoding

  • 记录了节点的content属性所保存数据的类型以及长度。一字节、两字节或五字节长、值的最高位为00、0一、10的表示节点的content属性保存着字节数组,数组的长度为去掉encoding的最高两位以后的位记录。
  • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着的是整数值。

content

负责保存节点的值,节点值能够是字节数组或整数,具体由encoding决定。

连锁更新

若是当前压缩列表的节点长度都小于254字节,那么用于记录前一个字节长度的属性previous_entry_length只须要用一个字节保存,可是如今要新加一个字节长度大于254字节的节点到压缩列表中来,那么将会形成连锁更新,由于新加节点的后一个节点保存了这个节点的长度,须要将previous_entry_length扩展为5字节的,而后继续相似的扩展直到最后一个节点。

参考

Redis的设计与实现 黄建宏 著

相关文章
相关标签/搜索