Redis数据结构
SDS(Simple Dynamic String 简单动态字符串)
struct sdshdr{ //记录buf数组已使用的字节的数量 //等于SDS所保存字符串的长度 int len; //记录buf数组中未使用的字节数量 int free; //字节数组,用于保存字符串 char buf[]; } 相比于C字符串,SDS具有以下优点: 1.常数复杂度获取字符串长度 2.杜绝缓冲区溢出 3.减少修改字符串长度所需的内存重分配次数 4.二进制安全 5.兼容部分C字符串函数
链表
typedef struct listNode { //前置节点 struct listNode *prev; //后置节点 struct listNode *next; //节点的值 void *value; } listNode;
typedef struct list { //表头节点 listNode *head; //表尾节点 listNode *tail; //链表所包含的节点个数 unsigned long len; //节点值复制函数 void *(*dup)(void *ptr); //节点值释放函数 void *(*dup)(void *ptr); //节点值对比函数 void (*match)(void *ptr, void *key); } list; Redis链表实现的特点可以总结如下: 1.双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都为O(1) 2.无环:表头prev和表尾next都指向NULL,对链表的访问以NULL为重点 3.带表头指针和表尾指针:list中的head和tail 4.带链表长度计数器:使用list结构中的len属性对持有的链表节点进行计数 5.多态:链表节点使用void*指针保存节点值,并且可以通过list结构中的dup,free,match三个属性 为节点值设置类型特定函数,所以链表可用于保存各种不同类型的值。 Redis中的链表被广泛用于实现Redis的各种功能,比如列表建、发布与订阅、慢查询、监视器等。
字典
//哈希表 typedef struct dictht { //哈希表数组 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩码,用于计算索引值,总是等于size - 1 unsigned long sizemask; //该哈希表已有节点的数量 unsigned long used; } dictht; table属性是一个数组,数组中的每一个元素都是一个指向dict.h/dictEntry结构的指针, 每个dictEntry结构保存一个键值对。
//哈希表节点 typedef struct dicEntry { //键 void *key; //值 union { void *val; uint64_t u64; int64_t s64; } v; //指向下个哈希表节点,形成链表 struct dictEntry *next; } dictEntry; key 属性保存着键值对中的键。 v 属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数, 又或者是一个int64_t整数。 next 属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起, 以此来解决键冲突的问题。
//字典 typedef struct dict { //类型特定函数 dictType *type; //私有数据 void *privdata; //哈希表 dictht ht[2]; //rehash索引 //当rehash不再进行时,值为-1 int trehashidx; } dict; type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的: 1.type 属性是指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数, Redis会为用途不同的字典设置不同的类型特定函数。 typedef struct dictType { //计算哈希值的函数 unsigned int (*hashFunction)(const void *key); //复制键的函数 void *(*keyDup)(void *privdata, const void *key); //复制值的函数 void *(*valDup)(void *privdata, const void *obj); //对比键的函数 void *(*keyCompare)(void *privdata, const void *key, const void *key2); //销毁键的函数 void (*keyDestructor)(void *privdata, void *key); //销毁值的函数 void (*valDestructor)(void *privdata, void *obj); } dictType; 2.privdata 属性则保存了需要传给那些类型特定函数的可选参数。 3.ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下, 字典只使用ht[0]的哈希表,ht[1]哈希表只会对ht[0]进行rehash时使用。 4.rehashidx,它记录了rehash目前的进度,如果目前没有进行rehash,那么它的值为-1。
哈希算法: Redis计算哈希值和索引值的方法如下: 1.使用字典设置的哈希函数,计算键key的哈希值 hash = dict->type->hashFunction(key); 2.使用哈希表的sizemask属性和哈希值,计算出索引值 根据情况不同,x可以是0或者1 index = hash & dict->ht[x].sizemask; 举例如下:
Redis数据库哈希算法: 当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。 这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,而且算法的速度也非常快。
Redis解决键冲突: 当有两个或者以上数量的键被分配到哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。 Redis使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表 可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了 键冲突的问题。 举个例子:
假设程序将键值对k2和v2添加到上图的哈希表中,并且计算得出k2索引值为2,与k1发生冲突,解决冲突的办法 就是使用next指针将键k2和k1所在的节点连接起来。 因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的 表头位置(复杂度为O(1)),排在其他已有节点的前面。 如下图所示
rehash 当哈希表中的键值对逐渐增多或者减少时,为了让哈希表的负载因子(load factor)维持在一个合理的 范围之内,当哈希表保存的键值对太多或者太少时,程序需要对哈希表进行扩展或收缩 Redis对字典的哈希表执行rehash的步骤如下: 1.为字典的ht[1]哈希表分配空间,这个哈希的空间取决于要执行的操作,以及ht[0]当前包含的键值对 数量(也即ht[0].used属性的值) 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于 ht[0].used * 2 的 2的n次方幂; 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used 的 2的n次方幂; 2.将保存在ht[0]中的所有键值对都迁移到ht[1]之后(ht[0]变为空表),释放ht[0],并将ht[1]设置为 ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。 举例如下:
ht[0].used当前的值为4,4*2 = 8,而8(2的三次方)恰好是第一个大于等于 8 的 2的n次方,所以程序会将 ht[1]哈希表的大小设置为8。 图4-9展示了ht[1]在分配空间之后的字典的样子
将ht[0]包含的四个键值对都rehash到ht[1],如图4-10所示。
释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,如图4-11所示。至此,哈希表扩展完毕。
哈希表的扩展与收缩 当以下条件的任意一个被满足时,程序会自动开始对哈希表执行扩展操作 1.服务器当前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1. 2.服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5. 其中哈希表的负载因子可以通过如下公式计算: //负载因子 = 哈希表已保存的节点数量 / 哈希表大小 load_factor = ht[0].used / ht[0].size 根据BGSAVE命令或者BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是 因为在执行BGSAVE命令或者BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操 作系统都采用写时复制(copy_on_write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高 执行扩展操作所需的复杂因子,从而尽可能避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存 写入操作,最大限度地节约内存。 另一方面,当哈希表负载因子小于0.1时,程序对哈希表执行收缩操作。 渐进式rehash 上面所说的rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。 这样做的原因在于,如果ht[0]里只保存着四个键值对,那么rehash可以瞬间完成;但是,如果键值对数量是 四百万、四千万甚至四亿,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器 在一段时间内停止服务。 因此,为了避免对服务器性能造成影响,采用渐进式rehash。 渐进式rehash步骤 1.为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。 2.在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。 3.在rehash进行期间,每次对字典执行添加,删除,查找,或者更新操作时,程序除了执行指定的操作以外, 还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将 rehashidx属性的值增一。 4.ht[0]的所有键值对都被rehash至ht[1],这时将rehashidx属性的值设为-1,表示rehash操作已完成。 渐进式rehash的好处在于它采取分而治之的思想,将rehash键值对所需的计算工作均摊到对字典的每个添加, 删除,查找,和更新操作上,从而避免了集中式rehash的庞大计算量。 图4-12至图4-17展示了完整的rehash过程:
渐进式rehash执行期间的哈希表操作: 因为在渐进式rehash过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在此期间,字典的删除,查找, 更新等操作会在两个哈希表中进行 查找会先在ht[0]中进行,如果ht[0]中不存在,会继续到ht[1]中查找。 新添加到字典的键值对一律被保存到ht[1]中。
跳跃表
//跳跃表节点 typedef struct zskiplistNode { //层 struct zskiplistLevel { //前进指针 struct zskiplistNode *forward; //跨度 unsigned int span; } level []; //后退指针 struct zskiplistNode *backward; //分值 double score; //成员对象 robj *obj; } zskiplistNode; 1.层 跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层 来加快访问其他节点的速度。一般来说,层数越多,访问其他节点的速度越快。 每次创建一个新跳跃表节点时,程序都会根据幂次定律(power law,越大的数出现的概率越小)随机生成一个 介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。 图5-2展示了高度为1层,3层和5层的节点。
2.前进指针 每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。 3.跨度 层的跨度(level[i].span属性)用于记录两个节点之间的距离: ·两个节点之间的跨度越大,它们相距得就越远。 ·指向NULL的所有前进指针跨度都为0,因为它们没有连接任何节点。 跨度是用来计算排位(rank)的: 在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的 结果就是目标节点在跳跃表中的排位。
4.后退指针 节点的后退指针(backward属性)用于从表尾向表头方向访问节点: 每个节点只有一个后退指针,所以每次 只能后退至前一个节点。 5.分值和成员 节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。 节点的成员对象(obj属性)是一个指针,指向一个字符串对象,而字符串对象则保存一个SDS值。 在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的: 分值 相同的节点将按照成员对象在字典序中的大小来进行排序。
//跳跃表 typedef struct zskiolist { //表头节点和表尾节点 struct zskiplistNode *header, *tail; //表中节点的数量 unsigned long length; //表中层数最大的节点的层数 int level; } zskiplist; header和tail指针分别指向跳跃表的表头和表尾节点。 length属性记录节点的数量。 level属性则用于获取跳跃表中层高最大的节点的层数,表头节点的层高不计算在内。
整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时, Redis就会使用整数集合作为集合键的底层实现。
整数集合的实现 整数集合是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t 的整数值,并且保证集合中不会出现重复元素。 typedef struct intset { //编码方式 uint32_t encoding; //集合包含的元素数量 uint32_t length; //保存元素的数组 int8_t contents[]; } intset; contents 数组是整数集合的底层实现: 整数集合的每个元素都是contents数组的一个数组项(item),各个项 在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。 length 属性记录了整数集合包含的元素数目,即contents数组的长度。 encoding 属性决定contents数组的真正取值类型,而不是contents的声明类型int8_t 1.如果encoding属性为INTSET_ENC_INT16, 那么contents就是一个int16_t类型的数组(-32768~32767) 2.如果encoding属性为INTSET_ENC_INT32, 那么contents就是一个int32_t类型的数组(-2147483648~2147483647) 3.如果encoding属性为INTSET_ENC_INT64, 那么contents就是一个int64_t类型的数组(-9223372036854775808~9223372036854775807)
升级 每当我们要将一个新元素添加到整数集合中,并且新元素的类型比整数集合中的现有所有元素的类型都要 长时,整数集合需要先升级(upgrade),然后才能将新元素添加到整数集合中。 升级整数集合并添加新元素共分为三步进行: 1.根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。 2.将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位置上, 而且在放置元素的过程中,需要继续维持底层数组的有序性不变。 3.将新元素添加到底层数组里面 升级的好处 1.提升整数集合的灵活性 2.尽可能的解决内存 降级 整数集合不支持降级的操作,一旦对数组升级,编码就会一直保持升级后的状态
压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么 是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来作为列表键的底层实现。 另外,当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字 符串,那么Redis会使用压缩列表来做哈希键的底层实现。 压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存组成的顺序型数据结构。一个压缩列表 可以包含多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。 图7-1展示了压缩列表的各个组成部分,表7-1则记录了各个组成部分的类型、长度以及用途。
压缩列表节点的构成 每个压缩列表节点可以保存一个字节数组或者一个整数值。 字节数组可以是一下三种长度的其中一种: 1.长度小于等于63字节的字节数组 2.长度小于等于16383字节的字节数组 3.长度小于等于4294967295字节的字节数组 而整数值则可以是以下六种长度的其中一种: 1.4位长,介于0至12之间的无符号整数 2.1字节长的有符号整数 3.3字节长的有符号整数 4.int16_t 类型整数 5.int32_t 类型整数 6.int64_t 类型整数 每个压缩列表节点都由 previous_entry_length、encoding、content三个部分组成,如图7-4所示:
previous_entry_length 该属性以字节为单位,记录了压缩列表中前一个节点的长度。该长度可以是1字节或者5字节: 1.如果前一节点的长度小于254字节,那么该属性长度为1字节: 前一节点的长度就保存在这一个字节里面。 2.如果前一节点的长度大于等于254字节,那么该属性长度为5字节: 其中属性的第一字节会被设置为 0xFE(254),而之后的四个字节则用于保存前一节点的长度。
因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点 的起始地址来计算出前一个节点的起始地址。 压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针, 那么通过这个指针以及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达 压缩列表的表头结点。
encoding 节点的encoding属性记录了节点的content属性所保存数据的类型和长度: 1.一字节、两字节或者五字节,值的最高位为00、01或者10的是字节数组编码: 这种编码表示节点的content 属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录; 2.一字节长,值的最高位以11开头的是整数编码: 这种编码表示节点的content属性保存着整数值,整数值的 类型和长度由编码除去最高两位之后的其他位记录;
content 节点的content属性负责把偶才能节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的 encoding属性决定。
对象
typedef struct redisObject { //类型 unsigned type:4; //编码 unsigned encoding:4; //指向底层数据结构的指针 void *ptr; //... } robj; 类型 对象的type属性记录了对象的类型,这个属性可以是表8-1列出的常量的其中一个。
tips: 当我们称呼一个数据库键为“字符串键”是,我们指的是“这个数据库键所对应的值为字符串对象”; 当我们称呼一个键为“列表键”时,我们指的是“这个数据库键对应的值为列表对象”...
编码和底层实现 对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。 encoding属性记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为底层实现,这个属性值 可以是表8-3列出的常量的其中一个。
每种类型的对象都至少可以使用两种不同的编码,表8-4列出了每种类型的对象可以使用的编码:
通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了 Redis的灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在 某一场景下的效率。 举个例行: 在列表对象包含的元素比较少时,Redis使用压缩列表作为列表对象的底层实现: 1.因为压缩列表对双端链表更节约内存,并且在元素数量较少时,在内存中以连续块的方式保存的压缩列表 比起双端链表可以更快被载入到缓存中; 2.随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从 压缩列表转向功能更强、也更适合保存大量元素的双端链表上面。 字符串对象 字符串对象的编码可以是int、raw或者embstr 1.如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型表示,那么字符串对象会将整数值 保存在字符串对象结构的ptr属性里面(将void*转换为long),并将字符串对象的编码设置为int
2.如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个 简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw
3.如果字符串对象保存的是一个字符串值,并且这个字符串的长度小于等于32字节,那么字符串对象将使用 embstr编码的方式来保存这个字符串值。 embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject 结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配来分别创建redisObject结构和sdshdr结 构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr 两个结构,如图8-3所示:
embstr编码的字符串对象在执行命令时,产生的效果和raw编码的字符串是一样的,但是使用embstr编码的 字符串来保存短字符串有以下好处: 1)embstr编码将创建的字符串对象所需的内存分配次数由两次降为一次 2)释放embstr编码的字符串对象也只需要一次内存释放函数,而raw需要两次 3)embstr编码的字符串对象的所有数据都保存在一块连续的内存中,相比于raw,能更好的利用缓存带来的 优势。 列表对象 列表的对象编码可以是压缩列表(ziplist)或者双端链表(linkedlist).
注意,linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象在哈希 对象、集合对象和有序集合中都会出现,字符串对象是Redis五种类型对象中唯一一种会被其他四种类型对象嵌套 的对象。
编码转换 当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码: 1.列表对象保存的字符串元素的长度都小于64字节; 2.列表对象保存的元素数量小于512个。 不能满足这两个条件的列表对象使用linkedlist编码。 这两个上限值可以修改,通过配置文件中关于list-max-ziplist-value和list-max-ziplist-entries 选项。 下面代码展示了列表对象因保存了长度太长的元素而进行编码转换的情况:
哈希对象 哈希对象的编码可以是ziplist(压缩列表)或者hashtable(字典). ziplist编码的哈希对象使用 压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先 保存了键的压缩列表节点推入到压缩列表的表尾,然后将保存了值的压缩列表节点推入到压缩列表的表尾,因此 1.保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后; 2.先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后添加到哈希对象的键值对会被放在压缩 列表的表尾方向。
hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存 1.字典的每个键都是一个字符串对象,对象中保存了键值对的键; 2.字典的每个值都是一个字符串对象,对象中保存了键值对的值。
编码转换 当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码: 1.哈希对象保存的所有键值对的键和值的字符串长度都小于64字节; 2.哈希对象保存的键值对数量小于512个。 不能同时满足这两个条件的哈希对象需要使用hashtable编码 这两个上限值可以修改,通过配置文件中关于hash-max-ziplist-value和hash-max-ziplist-entries 选项。 下面代码展示了列表对象因保存了长度太长的元素而进行编码转换的情况:
集合对象 集合对象的编码可以是intset或者hashtable。 intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合中。
hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象 包含了一个集合元素,而字典的值全部被设置为NULL。
编码的转换 当集合对象可以同时满足以下两个条件时,对象使用intset编码: 1.集合对象保存的所有元素都是整数值; 2.集合对象保存的元素个数不超过512个。 不能同时满足这两个条件的集合对象使用hashtable编码。 第二个条件上限值可以修改,通过配置文件中set-max-intset-entries选项。
有序集合对象 有序集合对象的编码可以是ziplist或者skiplist。 ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点 来保存,第一个节点保存元素的成员(member),而第二个节点则保存元素的分值(score)。 压缩列表内的集合元素按分值大小从小到大进行排序。 typedef struct zset { zskiplist *zsl; dict *dict; }
zset结构中的zsl跳跃表按分值从小到大保存了所有的元素集合,每个跳跃表节点都保存一个集合元素: 跳跃表节点的object属性保存了元素的成员, 跳跃表节点的score属性则保存了元素的分值。 除此之外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存 了一个集合元素: 字典的键保存了元素的成员,而字典的值保存元素的分值 通过这个字典,程序可以用O(1)复杂度查找给定成员的分值。 有序集合每个元素的成员对象都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。值得 一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但是这两种数据结构都会通过指针来共享 相同元素的成员和分值,不会产生重复,因此不会浪费内存。 编码的转换 当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码: 1.有序集合中的元素个数小于128个; 2.有序集合保存的所有元素成员的长度都小于64字节 不能同时满足这两个条件的有序集合对象使用skiplist编码。 这两个上限值可以修改,通过配置文件中zset-max-ziplist-value和zset-max-ziplist-entries选项。 下面代码展示了有序集合对象因包含过多的元素而进行编码转换的情况: