内部数据结构

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 层的后退指针,用于从表尾方向向表头方向迭代。

 

以上是关于内部数据结构的主要内容,如果未能解决你的问题,请参考以下文章

底部导航 如何从片段内部更改片段

如何以编程方式从片段内部设置ViewPager选项卡?

EasyClick 运行代码片段出Null

EasyClick 运行代码片段出Null

# Java 常用代码片段

# Java 常用代码片段