内部数据结构
Posted liushoudong
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了内部数据结构相关的知识,希望对你有一定的参考价值。
1、Simple Dynamic String:简单动态字符串,Sds
是Redis底层所使用的字符串表示,sds在Redis中的主要作用:
- 实现字符串对象
- 在Redis程序内部用作char*类型的替代品
char*类型功能单一,抽象层次低,不能高效地支持一些Redis常用的操作(比如追加操作和长度计算操作)-->每次计算字符串长度(strlen(s))的复杂度为O(N);对字符串进行N次追加,必定需要对字符串进行N次内存重分配(realloc)
sds既可以高效地实现追加和长度计算,并且它还是二进制安全的
1 typedef char * sds; 2 3 struct sdshdr { 4 // buf已占用长度-->通过len属性,sdshdr可以实现复杂度为O(1)的长度计算操作 5 int len; 6 // buf剩余可用长度-->通过对buf分配一些额外的空间,并使用free记录未使用空间的大小,sdshdr可以让执行追加操作所需的内存重分配次数大大减少 7 int free; 8 // 实际保存字符串数据的地方 9 char buf[]; 10 }; 11 12 struct sdshdr { 13 len = 11; 14 free = 0; 15 buf = "hello world "; //buf的实际长度为len+1 16 };
//优化追加操作-->sdshdr结构减少追加(append)操作所需的内存重分配次数
//当调用SET命令创建sdshdr时,sdshdr的free属性为0,Redis也没有为buf创建额外的空间,而在执行APPEND之后,Redis为buf创建了多于所需空间一倍的大小
//sdsMakeRoomFor函数描述了sdshdr的这种内存预分配优化策略,伪代码如下
1 def sdsMakeRoomFor(sdshdr, required_len): 2 #预分配空间足够,无须再进行空间分配 3 if (sdshdr.free >= required_len): 4 return sdshdr 5 6 #计算新字符串的总长度 7 newlen = sdshdr.len + required_len 8 9 #如果新字符串的总长度小于SDS_MAX_PREALLOC 10 #那么为字符串分配2倍于所需长度的空间 11 #否则就分配所需长度加上SDS_MAX_PREALLOC数量的空间 12 if newlen < SDS_MAX_PREALLOC: 13 newlen *= 2 14 else: 15 newlen += SDS_MAX_PREALLOC 16 17 #分配内存 18 newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1) 19 20 #更新free属性 21 newsh.free = newlen - sdshdr.len 22 23 #返回 24 return newsh
在目前版本的Redis中,SDS_MAX_PREALLOC的值为1024*1024,也就是说,当大小小于1MB的字符串执行追加操作时,sdsMakeRoomFor就为它们分配多于所需大小一倍的空间;当字符串的大小大于1MB,那么sdsMakeRoomFor就为它们额外多分配1MB的空间
这种分配策略会浪费内存吗?
执行过APPEND命令的字符串会带有额外的预分配空间不会被释放,除非该字符串所对应的键被删除,或者等到关闭Redis之后,再次启动时重新载入的字符串对象将不会有预分配空间。
因为执行APPEND命令的字符串键数量通常并不多,占用内存的体积通常也不大,所以这一般并不算什么问题。
另一方面,如果执行APPEND操作的键很多,而字符串的体积又很大的话,那可能就需要修改Redis服务器,让它定时释放一些字符串键的预分配空间,从而更有效地使用内存。
sds模块的API
Redis 的字符串表示为 sds ,而不是 C 字符串(以 结尾的 char*)。
• 对比 C 字符串,sds 有以下特性:
– 可以高效地执行长度计算(strlen);
– 可以高效地执行追加操作(append);
– 二进制安全;
• sds 会为追加操作进行优化:加快追加操作的速度,并降低内存分配的次数,代价是多占 用了一些内存,而且这些内存不会被主动释放。
2、双端链表
双端链表在Redis内部的应用:是Redis列表结构的底层实现之一,还被大量Redis模块所使用,用于构建Redis的其他功能
Redis列表使用两种数据结构作为底层实现:双端链表;压缩列表
因为双端链表占用的内存比压缩列表要多,所以当创建新的列表键时,列表会优先考虑使用压缩列表作为底层实现,并且在有需要的时候,才从压缩列表实现转换到双端链表实现
1 //双端链表的实现由listNode和list两个数据结构构成,listNode是双端链表的节点,list是双端链表本身 2 typedef struct listNode { 3 //前驱节点 4 struct listNode *prev; 5 6 //后继节点 7 struct listNode *next; 8 9 //listNode带有prev和next两个指针,因此对链表的遍历可以在两个方向上进行 10 11 //值-->listNode的value属性的类型是void *,说明这个双端链表对节点所保存的值的类型不做限制 12 void *value; 13 } listNode; 14 15 //对于不同类型的值,有时候需要不同的函数来处理这些值,因此,list类型保留了三个函数指针--dup、free和match,分别用于处理值的复制、释放和对比匹配。在对节点的值进行处理时,如果有给定这些函数,那么它们就会被调用 16 typedef struct list { 17 //表头指针 18 listNode *head; 19 20 //表尾指针 21 listNode *tail; 22 23 //list保存了head和tail两个指针,因此对链表的表头和表尾进行插入的复杂度都为O(1),这是高效实现LPUSH、RPOP、RPOPLPUSH等命令的关键 24 25 //节点数量 26 unsigned long len; 27 28 //list带有保存节点数量的len属性,所以计算链表长度的复杂度仅为O(1),保证了LLEN命令不会成为性能瓶颈 29 30 //复制函数 31 void *(*dup)(void *ptr); 32 //释放函数 33 void (*free)(void *ptr); 34 //比对函数 35 int (*match)(void *ptr, void *key); 36 } list;
1 //迭代器,Redis为双端链表实现了一个迭代器,这个迭代器可以从两个方向对双端链表进行迭代 2 3 typedef struct listIter { 4 //下一节点 5 listNode *next; 6 7 //迭代方向 8 int direction; 9 } listIter;
• Redis 实现了自己的双端链表结构。
• 双端链表主要有两个作用:
– 作为 Redis 列表类型的底层实现之一;
– 作为通用数据结构,被其他功能模块所使用;
• 双端链表及其节点的性能特性如下:
– 节点带有前驱和后继指针,访问前驱节点和后继节点的复杂度为 O(1) ,并且对链表 的迭代可以在从表头到表尾和从表尾到表头两个方向进行;
– 链表带有指向表头和表尾的指针,因此对表头和表尾进行处理的复杂度为 O(1) ;
– 链表带有记录节点数量的属性,所以可以在 O(1) 复杂度内返回链表的节点数量(长度);
3、字典
字典dictionary,又名映射map或关联数组associative array
Redis中字典的主要用途:
实现数据库键空间-->Redis 是一个键值对数据库,数据库中的键值对就由字典保存:每个数据库都有一个与之相对 应的字典,这个字典被称之为键空间(key space)
用作Hash类型键的其中一种底层实现-->因为压缩列表比字典更节省内存,所以程序在创建新 Hash 键时,默认使用压缩列表作为底层 实现,当有需要时,程序才会将底层实现从压缩列表转换到字典
字典的实现:
实现字典的方法有很多种:
• 最简单的就是使用链表或数组,但是这种方式只适用于元素个数不多的情况下;
• 要兼顾高效和简单性,可以使用哈希表;
• 如果追求更为稳定的性能特征,并且希望高效地实现排序操作的话,则可以使用更为复 杂的平衡树;
1 //字典 每个字典使用两个哈希表,用于实现渐进式rehash 2 typedef struct dict { 3 //特定于类型的处理函数 4 dictType *type; 5 6 //类型处理函数的私有数据 7 void *privdata; 8 9 //哈希表(2个) 10 dictht ht[2]; 11 12 //dict类型使用了两个指针分别指向两个哈希表,其中0号哈希表(ht[0])是字典主要使用的哈希表,而1号哈希表(ht[1])则只有在程序对0号哈希表进行rehash时才使用 13 14 //记录rehash进度的标志,值为-1表示rehash未进行 15 int rehashidx; 16 17 //当前正在运作的安全迭代器数量 18 int iterators; 19 } dict;
1 //哈希表 2 3 typedef struct dictht { 4 //哈希表节点指针数组(桶,bucket) 5 dictEntry **table; 6 7 //table属性是一个数组,数组的每个元素都是一个指向dictEntry结构的指针。每个dictEntry都保存着一个键值对,以及一个指向另一个dictEntry结构的指针 8 9 //指针数组的大小 10 unsigned long size; 11 12 //指针数组的长度掩码,用于计算索引值 13 unsigned long sizemask; 14 15 //哈希表现有的节点数量 16 unsigned long used; 17 } dictht; 18 19 //哈希表节点 20 typedef struct dictEntry { 21 //键 22 void *key; 23 24 //值 25 union { 26 void *val; 27 uint64_t u64; 28 int64_t s64; 29 } v; 30 31 //链后继节点 32 struct dictEntry *next; 33 34 //next属性指向另一个dictEntry结构,多个dictEntry可以通过next指针串联成链表,dictht使用链地址法处理键碰撞 35 } dictEntry;
创建新字典-->
新创建的两个哈希表都没有为 table 属性分配任何空间:• ht[0]->table 的空间分配将在第一次往字典添加键值对时进行; • ht[1]->table 的空间分配将在 rehash 开始时进行;
添加键值对到字典-->
根据字典所处的状态,将一个给定的键值对添加到字典可能会引起一系列复杂的操作:
• 如果字典为未初始化(也即是字典的 0 号哈希表的 table 属性为空),那么程序需要对 0 号哈希表进行初始化;
• 如果在插入时发生了键碰撞,那么程序需要处理碰撞;
• 如果插入新元素使得字典满足了 rehash 条件,那么需要启动相应的 rehash 程序;
添加新元素到空白字典-->
当第一次往空字典里添加键值对时,程序会根据 dict.h/DICT_HT_INITIAL_SIZE 里指定的大 小为 d->ht[0]->table 分配空间(在目前的版本中,DICT_HT_INITIAL_SIZE 的值为 4 )。
添加新键值对时发生碰撞处理-->
链地址法
添加新键值对时触发了rehash操作-->
对于使用链地址法来解决碰撞问题的哈希表dictht来说,哈希表的性能依赖于它的大小(size 属性)和它所保存的节点的数量(used 属性)之间的比率:
• 比率在 1:1 时,哈希表的性能最好;
• 如果节点数量比哈希表的大小要大很多的话,那么哈希表就会退化成多个链表,哈希表 本身的性能优势就不再存在;
为了在字典的键值对不断增多的情况下保持良好的性能,字典需要对所使用的哈希表(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 )。
什么时候 dict_can_resize 会为假?一个数据库就是一个字典,数据库里的哈希类型键也是一个字典,当 Redis 使用子进程对数据库执行后台持久化任务时(比如执行 BGSAVE 或 BGREWRITEAOF 时),为了最大化地利用系统的 copy on write ?机制,程序会暂时将 dict_can_resize 设为假,避免执行自然 rehash ,从而减少程序对内存的触碰(touch)。当持久化任务完成之后,dict_can_resize 会重新被设为真。
另一方面,当字典满足了强制 rehash 的条件时,即使 dict_can_resize 不为真(有 BGSAVE 或 BGREWRITEAOF 正在执行),这个字典一样会被 rehash 。
rehash执行过程-->
字典的 rehash 操作实际上就是执行以下任务:
1. 创建一个比 ht[0]->table 更大的 ht[1]->table ;
2. 将 ht[0]->table 中的所有键值对迁移到 ht[1]->table ;
3. 将原有 ht[0] 的数据清空,并将 ht[1] 替换为新的 ht[0] ;
开始Rehash:
这个阶段有两个事情要做: 1. 设置字典的 rehashidx 为 0 ,标识着 rehash 的开始; 2. 为 ht[1]->table 分配空间,大小至少为 ht[0]->used 的两倍;
Rehash进行中:
在这个阶段,ht[0]->table 的节点会被逐渐迁移到 ht[1]->table ,因为 rehash 是分多次进行的,字典的 rehashidx 变量会记录 rehash 进行到 ht[0] 的哪个索引位置上。
节点迁移完毕:
到了这个阶段,所有的节点都已经从 ht[0] 迁移到 ht[1] 了
Rehash完毕:
在 rehash 的最后阶段,程序会执行以下工作:
1. 释放 ht[0] 的空间;
2. 用 ht[1] 来代替 ht[0] ,使原来的 ht[1] 成为新的 ht[0] ;
3. 创建一个新的空哈希表,并将它设置为 ht[1] ;
4. 将字典的 rehashidx 属性设置为 -1 ,标识 rehash 已停止;
渐进式rehash-->
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
dictRehashMilliseconds 可以在指定的毫秒数内,对字典进行 rehash 。 当 Redis 的服务器常规任务执行时,dictRehashMilliseconds 会被执行,在规定的时间内, 尽可能地对数据库字典中那些需要 rehash 的字典进行 rehash ,从而加速数据库字典的 rehash 进程(progress)
在哈希表进行 rehash 时,字典还会采取一些特别的措施,确保 rehash 顺利、正确地进行:
• 因为在 rehash 时,字典会同时使用两个哈希表,所以在这期间的所有查找、删除等操作, 除了在 ht[0] 上进行,还需要在 ht[1] 上进行
• 在执行添加操作时,新的节点会直接添加到 ht[1] 而不是 ht[0] ,这样保证 ht[0] 的节 点数量在整个 rehash 过程中都只减不增
字典的收缩-->
如果哈希表的 可用节点数比已用节点数大很多的话,那么也可以通过对哈希表进行 rehash 来收缩(shrink) 字典
收缩 rehash 和上面展示的扩展 rehash 的操作几乎一样,它执行以下步骤:
1. 创建一个比 ht[0]->table 小的 ht[1]->table ;
2. 将 ht[0]->table 中的所有键值对迁移到 ht[1]->table ;
3. 将原有 ht[0] 的数据清空,并将 ht[1] 替换为新的 ht[0] ;
1 //字典的收缩规则 2 3 //检查字典的使用率是否低于系统允许的最小比率,是返回1,否则返回0 4 int htNeedsResize(dict *dict) { 5 long long size, used; 6 7 //哈希表已用节点数量 8 size = dictSlots(dict); 9 10 //哈希表大小 11 used = dictSize(dict); 12 13 //当哈希表的大小大于DICT_HT_INITIAL_SIZE并且字典的填充率低于REDIS_HT_MINFILL时返回1 14 return (size && used &&size > DICT_HT_INITIAL_SIZE && 15 (used*100/size < REDIS_HT_MINFILL)); 16 }
在默认情况下,REDIS_HT_MINFILL 的值为 10 ,也即是说,当字典的填充率低于 10% 时,程 序就可以对这个字典进行收缩操作了。
字典收缩和字典扩展的一个区别是:
• 字典的扩展操作是自动触发的(不管是自动扩展还是强制扩展);
• 而字典的收缩操作则是由程序手动执行
字典的迭代-->
对字典进行迭代实际上就是对字典所使用的哈希表进行迭代:
• 迭代器首先迭代字典的第一个哈希表,然后,如果 rehash 正在进行的话,就继续对第二 个哈希表进行迭代。
• 当迭代哈希表时,找到第一个不为空的索引,然后迭代这个索引上的所有节点。
• 当这个索引迭代完了,继续查找下一个不为空的索引,如此循环,一直到整个哈希表都迭 代完为止。
#迭代过程伪码
1 def iter_dict(dict): 2 3 #迭代0号哈希表 4 iter_table(ht[0]->table) 5 6 #如果正在执行rehash,那么也迭代1号哈希表 7 if dict.is_rehashing(): 8 iter_table(ht[1]->table) 9 10 def iter_table(table): 11 12 #遍历哈希表上的所有索引 13 for index in table: 14 15 #跳过空索引 16 if table[index].empty(): 17 continue 18 19 #遍历索引上的所有节点 20 for node in table[index]: 21 #处理节点 22 do_something_with(node)
字典的迭代器有两种:
• 安全迭代器:在迭代进行过程中,可以对字典进行修改。
• 不安全迭代器:在迭代进行过程中,不对字典进行修改。
1 //迭代器的数据结构定义 2 typedef struct dictIterator { 3 dict *d; //正在迭代的字典 4 5 int table, //正在迭代的哈希表的号码(0或者1) 6 index, //正在迭代的哈希表数组的索引 7 safe; //是否安全 8 9 dictEntry *entry, //当前哈希节点 10 *nextEntry; //当前哈希节点的后继节点 11 } dictIterator;
• 字典由键值对构成的抽象数据结构。
• Redis 中的数据库和哈希键都基于字典来实现。
• Redis 字典的底层实现为哈希表,每个字典使用两个哈希表,一般情况下只使用 0 号哈希 表,只有在 rehash 进行时,才会同时使用 0 号和 1 号哈希表。
• 哈希表使用链地址法来解决键冲突的问题。
• Rehash 可以用于扩展或收缩哈希表。
• 对哈希表的 rehash 是分多次、渐进式地进行的。
4、跳表
跳跃表(skiplist)是一种随机化的数据,这种数据结构以有序的方式在层次化的链表中保存元素,它的效率可以和平衡树媲美——查找、删除、添加等操作都可以在对数期望时间下完成, 并且比起平衡树来说,跳跃表的实现要简单直观得多。
跳表的构成-->
表头head :负责维护跳表的节点指针
跳表节点:保存着元素值,以及多个层
层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于底层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次
表尾:全部由NULL组成,表示跳表的末尾
Redis对跳表的修改:
允许重复的score值:多个不同的member的score值可以相同
进行对比操作时,不仅要检查score值,还要检查member:当score值可以重复时,单靠score值无法判断一个元素的身份,所以需要连member域都一并检查才行
每个节点都带有一个高度为1层的后退指针,用于从表尾方向向表头方向迭代:当执行ZREVRANGE或ZREVRANGEBYSCORE这类以逆序处理有序集的命令时,就会用到这个属性
1 //跳表 2 typedef struct zskiplist { 3 4 //头节点,尾节点 5 struct zskiplistNode *head, *tail; 6 7 //节点数量 8 unsigned long length; 9 10 //目前表内节点的最大层数 11 int level; 12 } zskiplist; 13 14 //跳表的节点 15 typedef struct zskiplistNode { 16 17 //member对象 18 robj *obj; 19 20 //分值 21 double score; 22 23 //后退指针 24 structzskiplistNode *backward; 25 26 //层 27 struct zskiplistLevel { 28 //前进指针 29 struct zskiplistNode *forward; 30 31 //这个层跨越的节点数量 32 unsigned int span; 33 } level[]; 34 } zskiplistNode;
应用-->
跳跃表在 Redis 的唯一作用,就是实现有序集数据类型。 跳跃表将指向有序集的 score 值和 member 域的指针作为元素,并以 score 值为索引,对有序集元素进行排序。
• 跳跃表是一种随机化数据结构,它的查找、添加、删除操作都可以在对数期望时间下完成。
• 跳跃表目前在 Redis 的唯一作用就是作为有序集类型的底层数据结构(之一,另一个构 成有序集的结构是字典)。
• 为了适应自身的需求,Redis 跳跃表进行了修改,包括:
1. score 值可重复。
2. 对比一个元素需要同时检查它的 score 和 memeber 。
3. 每个节点带有高度为 1 层的后退指针,用于从表尾方向向表头方向迭代。
以上是关于内部数据结构的主要内容,如果未能解决你的问题,请参考以下文章