Redis底层数据实现

  • 时间:
  • 浏览:0
  • 来源:跟我学网络

Redis底层使用了6种数据结构来实现上层的各种数据结构,本文将对这6种数据结构分别进行简单的介绍。本文中的图片来自《Redis设计与实现第二版》。

Redis数据结构和底层数据结构的对应关系如下:

简单动态字符串 Redis没有直接使用C字符串来保存字符串值,而是自己实现了名为SDS的结构体。Redis中数据的值都是使用sds保存。



sds是一个结构体,包含三个成员。

上图中的sds中保存了字符串"Redis"。

SDS相对于单纯使用C字符串有以下优点:

1. 记录了长度信息,strlen命令的开销变小

2. 防止了缓冲区溢出,如果使用C的字符数组保存数据,那么当字符串的长度超过数组时会覆盖掉后面的数据,造成溢出。而SDS在保存字符串的时候会首先检查空间是否足够,不够的话会先补足缺少的空间。

3. C字符串每次修改都要重新分配内存,开销较大,而SDS使用空间预分配和惰性空间释放两个策略避免频繁空间分配操作。

   空间预分配:

   (1)如果SDS占用的空间小于1MB,那么每次扩展空间的时候会额外为SDS多分配一倍的空间,如果SDS占有的空间大于1MB,那么每次扩展之后额外分配1MB。

   (2)如对上面保存了redis的sds赋值redis-server,那么空间不够,会重分配,重分配之后额外给一倍就是12+12=24。

  惰性释放:

 惰性释放指sds占用的空间缩小之后不会马上回收,而是标记起来,等下次要用的时候就直接取消标记,这样就省去了回收和重新分配的开销。

可以看出上面的两个特性虽然对CPU友好,减少了执行的指令,但是对内存不太友好,不过选择这样实现是Redis的特性,牺牲了内存而保证执行速度。

4. SDS二进制安全,C字符串使用空字符来标志字符串结束,那么就无法表示含有空字符的字符串,而SDS使用长度来标志结束,可以含有空字符。

链表 链表是一种访问较慢,修改很快的数据结构。Redis列表的底层实现是链表。链表的节点是一个如下的结构体,可以看出是一个双向链表。

除了节点之外,Redis专门针对链表做了一些封装:

可以看到,里面还专门放了三个指向函数的指针,用来提供复制,释放,对比操作。

字典

字典类似于Java中的Map,Python中的dict,是一种保存键值对的数据结构。Redis中的Hash由字典实现。其实Redis自身可以看成一个大的dict。

dict的实现如下:

table属性是一个数组,里面包含多个dictEntry,每个dictEntry保存了一个键值对。下面是一个空的dict。

dictEntry是一个如下的结构体:

dictEntry里面包含一个dictEntry*的next,可以看出,这里是使用的拉链法来解决键冲突的,即hash值相同的键会保存在一个链表中。最后在dictht之上,Redis还做了一层封装,真正使用的dict是这样的:

其中dictType的作用是针对不同需要的dict而封装了不同的dict操作函数:

而privatedata保存了需要传递给类型特定函数的参数。

ht属性中保存了两个dictht,这是为了rehash,也就是在容量不足时将一个表的数据迁移到另一个表中。rehashidx是是否在进行rehash的标志位,如果不是那么值为-1,一个没有进行rehash的dict 如图所示:

Redis使用MumurHash2来计算键的hash值,在键冲突时,为了提高速度,总是将冲突的键插入在链表的头部。

dict 的rehash

dict的rehash和java中的hashMap rehash的过程几乎相同,唯一的不同是dict支持缩小,以扩大的过程来看,主要分为三个步骤:

1. 初始化新表,将ht[1]的大小设置为ht[0]的两倍,注意这里ht的大小也是2的幂次,所以每次扩大两倍,而且bucket index的算法也和java HashMap一样,是hash&(length-1),将对应的属性都更新好之后就为table属性分配新的内存空间,使ht[1]可以开始存放数据。

2. 初始化ht[1]之后就开始transfer数据了,由于都是2的幂次,那么重新计算index也将变得很简单,同时rehash之后的数据也会分布得很均匀,具体计算的方式就是index&length=length的dictEntry新的index=旧index+length,剩下的不变,length是ht[0]的长度。

3. 完成了transfer之后需要将ht[0]的空间都释放掉,然后将ht[0]指向ht[1],为ht[1]重新创建一个上图所示的空的dictht。

是否进行rehash由loadfactor决定,

loadfactor=ht[0].used/ht[0].size load factor=ht[0].used/ht[0].sizeloadfactor=ht[0].used/ht[0].size

Redis规定,在没有进行BGSAVE或BGREWIRTE的时候load factor大于1 就开始rehash,如果在进行BGSAVE或BGREWRITE,那么load factor大于5就开始rehash,这是因为rehash 需要的内存空间非常大,在BGSAVE或者BGREWRITE正在进行的时候内存资源比较紧张,要尽量避免rehash。

在rehash进行的过程中,程序还是可以正常执行命令的,但是新增的操作会统一添加到ht[1]中,保证ht[0]的只增不减,另外为了减少rehash线程的压力,每次对dict执行查询,更新,删除的时候会顺便将这个键值对转移到ht[1],转移的时候使用rehashidx进行计数,每次操作一个就增加1,当计数等于键值的时候就全部转移完毕。

跳跃表 跳跃表非常类似于平衡二叉树,是一种为了快速查找而设计的数据结构。跳跃表的查找时间复杂度是O(log(n)),最坏情况下是O(n) 。



Redis使用跳跃表来实现ZSET。

Redis的跳跃表数据结构成员如下:

1. header,表头节点

2. tail 表尾节点

l3. evel,当前表中层级最高的节点的层数

4. length,跳跃表的长度,即含有的节点的个数

一个跳跃表如图所示:

跳跃表节点定义如下:

这是一个嵌套定义的结构体,一共有四个属性:

level:level数组保存了到其它node的指针,每个节点可以指向很多个节点,查询的时候就通过顺序从这些指针开始递归地查找来查找数据。

backward:每个节点的backword指向逻辑顺序上靠后一位的一个节点,不管level的话,仅仅使用backward,所有的节点练成了一个链表。

score:所有的节点按照score升序排列,以此确定逻辑上的前后顺序,分值可以相同,当分值相同的时候按照obj的顺序排列,这里默认obj互相之间可以比较。

obj: 指向数据的指针

这里介绍一下跳跃表的查询时间复杂度分析:

首先,在每个节点插入的时候会随机产生一个层数,这个层数的计算方式如下

level = 1
// random()返回服从均匀分布的[0,1)上的随机数
while(random()<p && level<MaxLevel){
    level++;
}
return level;

在redis中maxLevel=32

要分析查找的性能,先看看查找的过程:

查找的过程从头节点的最上面开始,可以看到,头节点是满的32层,没有数据。查找要走的步数就是查找的时间复杂度,分析步数可以使用从结果倒推到起点的方法。

首先假设跳跃表最高有h层,结果在第j个数据的第i层的时候被找到,那第j列的第i层是起点。设C(k)为向上跳k层的期望步数,C(0)=0。

假设第j列只有i层,因为现在已经在第i层了,所以这个概率是(1-p),那么说明回到起点需要C(h-i)+1步

假设第j列存在第i+1层,这个概率是p,那么回到起点要C(h-i-1)+1

推出C(h-i)=(1-p)*(C(h-i)+1)+p*(C(h-i-1)+1)

代入k=h-i以及C(0)=0求得C(K)=k/p

那么查找一个数据的过程也应当是这样的,如果这个数据最终会在第几层被找到,那么需要走的步数就是层数/p,也就是时间复杂度的关键在于层数的阶。现在假设一共有n个数据,根据之前的层数算法,不同高度的层可以构成一个等比数列,显然最高层只有一个数据,即

n∗ph−1=1 n*p^{h-1}=1n∗p 

h−1

 =1,可以解得h=log(p)1/n=log(1/p)n=O(log(n)) h=log(p)1/n=log(1/p)n=O(log(n))h=log(p)1/n=log(1/p)n=O(log(n))

整数集合

整数集合石集合的实现方式之一,当一个集合里面的内容全部是整数,且集合的大小不超过512的时候,使用整数集合来实现集合。

整数集合由以下结构体实现:

contents里面保存了整数集合内的数据,且按照大小升序排列。

encoding指示了contents里面保存的证书的类型,如果encoding是INTSET_ENC_INT16,那么contents里面的元素就都是16位的整数,假设存储了5个元素,那么占用的空间大小就是16*5=80

整数集合升级

之所以使用最小的int8_t来作为数组的最基本元素,是为了节约空间,但是当放入的整数太大int8_t放不下时集合就要通过调整编码的格式来升级。

例如当前的整数集合保存了1,2,3,4这几个整数,当前编码是16位整数,现在要将65535放入,65535需要用int32_t来表示,所以需要升级,升级分为三个步骤:

重新分配数组空间,由于类型变化了,那么需要的空间也要相应地调整,如之前是416=64,变化后应该是532=160

将已有的元素按新类型重新编码,然后调整他们的位置。需要注意的是,如果从前往后调整,那么有可能会覆盖到后面的数据,所以Redis是从后往前调整,调整位置的同时修改编码。

将新的元素插入到数组中去。在第三步插入的时候会根据每个元素原来的位置计算得出在新的数组中的位置,注意引发升级的时候新插入的数据一定是大于或者小于所有元素的,是正的就大是负的就小。

需要注意的是整数集合只有升级,没有降级。

压缩列表 压缩列表是Redis的重要数据结构,Redis list,hash,zset底层都使用了压缩列表作为实现方式。



压缩列表的格式如下:

可以看到,压缩列表的格式很类似一些文件的编码方式,在文件开头有一些元数据,记录数据区域的位置和数据的条数。这种紧凑的格式避免了内存对齐这样的为了方便访问而引发的内存开销,压缩列表是在数据结构中元素较少时采取的数据结构,使用压缩列表节约了内存开支,但是降低了访问的速度,不过对于规模较小的对象,这样的损失可以忽略不记。

压缩列表节点构成

为了能表示各种数据,压缩列表为每个entry都设置了一个encoding,指定entry可以保存如下几种数据:

1 长度小于63的字节数组

2 长度小于等于16383(2^{16}−1 )的字节数组

3 长度小于等于(2^{32}-1)的字节数组

4 4位长,介于0-12之间的整数

5 1字节长的有符号整数

6 3 字节长的有符号整数

7 int16_t类型的整数

8 int32_t类型的整数

9 int64_t类型的整数

其中后面几种都是整数

previous entry length 这个字段保存了前一个entry的长度,这个字段的长短会随着它需要保存的数字的大小发生变化,当前一个entry的长度小于254的时候,这个字段使用1字节来保存,当前一个entry的长度大于254,就使用5个字节来存这个数据。记录这个字段方便了从表尾向表头遍历整个压缩列表,每次往前访问一个元素只需要将指针减去这个值就可以了,而当前entry的长度可以根据编码的内容计算得知,所以往后遍历不需要额外保存本entry的长度。

连锁更新

为了省内存空间,previous entry length的大小会随着前一个entry的大小变化而变化,这种策略会导致当有连续很多个entry的长度都恰好大于等于250,小于254,并且这连续的多个entry的第一个entry的previous entry length恰好是1,当这个entry的前一个entry变大之后会到这这个entry的previous entry length变大,然后这个entry也会变大,然后就会导致后继的若干个entry的连锁变大以及更新,连锁更新发生的概率非常小,但是最坏情况下,连锁更新的时间复杂度为O(n^2)

原文:https://blog.csdn.net/fate_killer_liu_jie/article/details/85146290