Redis[0]-基础数据结构

303 阅读14分钟

1.字典

整个Redis数据库的所有key和value组成了一个全局字典,还有带过期时间的key集合也是一个字典(dict),Hash、zset中存储value和score值得映射也是字典结构;

struct dict{
    ...
    dictType *type;//类型特定函数,根据类型选择相应的Hash函数,函数指针
    dictht ht[2];
    int trehashidx; //rehash索引当rehash不在进行时 值为-1
}

字典内部包含两个HashTable结构,但通常只有一个HashTable有数据,,在字典扩缩容时,需要分配新得HashTable,进行渐进式搬迁,这时候两个HashTable存储的分别是新旧数据,搬迁结束后,旧的HashTable会被删除,新的取而代之。

1.1 哈希表结构

字典的核心结构在于内部HashTable的结构,

//哈希表结构
struct dictht{
    //哈希表数组,C语言中,*号是为了表明该变量为指针,有几个*         号就相当于是几级指针,这里是二级指针,理解为指向指针的指针
    dictEntry** table;//一维数组
    long size;//第一维数组长度
    long used;//hash 元素个数
}
//哈希节点结构
struct dictEntry{
    void* key;
    void* value;
    dictEntry* next;//链表下一个元素
}

字典内部结构和HashMap基本类似,采用数组+链表的结构,结构图如下:

1.2 哈希算法

//伪码:使用哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);
//伪码:使用哈希表的sizemask和哈希值,计算出在ht[0]或ht[1]的索引值
index = hash & dict->ht[x].sizemask;
//源码定义
#define dictHashKey(d, key) (d)->type->hashFunction(key)

redis使用MurmurHash算法计算哈希值,该算法最初由Austin Appleby在2008年发明,MurmurHash算法的无论数据输入情况如何都可以给出随机分布性较好的哈希值并且计算速度非常快,目前有MurmurHash2和MurmurHash3等版本。

1.3 rehash

随着操作的进行,散列表中保存的键值对会也会不断地增加或减少,当散列表内的键值对过多或过少时,需要定期进行rehash,以减少Hash冲突和内存浪费,rehash的时间由加载因子(load_factor)控制。

加载因子(load_factor):是空间和性能综合考虑的产物,在HashMap默认为0.75,Redis默认为1;

扩容条件

当Hash表中元素个数等于第一维数组的长度时,就会开始扩容,扩容新数组是原数组2倍,但如果Redis正在做bgsave,为了减少内存页的过多分离,Redis会尽量不去扩容,但是如果元素的个数已经达到第一维数组长度的5倍时,这个时候就会强制扩容。

缩容条件

当Hash表中的元素个数小于等于第一维数组长度的10%时,会进行缩容,且不会考虑Redis是否在bgsave。

扩容步骤

  • 为ht[1]分配空间,扩展操作时ht[1]的大小为第一个大于等于ht[0].used*2的2^n;收缩操作时ht[1]的大小为第一个大于等于ht[0].used的2^n ;
  • 将保存在ht[0]中的键值对重新计算键的散列值和索引值,然后放到ht[1]指定的位置上。
  • 将ht[0]包含的所有键值对都迁移到了ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并创建一个新的ht[1]哈希表为下一次rehash做准备;

1.4 渐进式Rehash

以上Rehash对于数据量较大时,需要对所有数据进行rehash并且移位,显然会很耗时,对于Redis显然是不允许的,为了解决这一问题,redis采用渐进式rehash策略,把rehash和移位操作穿插在执行redis客户端指令中,分批完成。

  • 在渐进式rehash过程中,字典会同时使用ht[0]和ht[1]两个Hash表,新添加的数据则会直接存储在ht[1],ht[0]不会做任何新增任务,而删除、查找、修改都会使用两个Hash表,在ht[0]修改完成后会rehash到ht[1],这样使得ht[0]最终变成空的。
  • 另外如果后续没有操作,Redis也会有定时任务保证rehash的进行。

2.动态字符串

字符串:SDS(Simple Dynamic String)动态字符串

struct SDS<T>{
	T capacity;//数组容量
	T len;//实际长度
	byte flags//标志位
	byte[] content;//实际数据
}

使用N+1长度的字符数组来表示字符串,尾部使用'\0'作为结尾标志,len可以直接获取字符串的长度,通过strlen快速获取长度,时间复杂度O(1),且在append字符串时候可以验证,避免缓冲区溢出。

2.1 空间预分配

对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。 对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 len和capacity 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)

3.链表

Redis的链表相当于Java中的LinkedList,意味着它的插入和删除操作非常快,时间复杂度为O(1),但是索引定位很慢,时间复杂度为O(n),结构定义如下:

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

列表中的每个元素都使用双向指针顺序,串起来可以支持前后遍历,当列表弹出最后一个元素,该数据结构自动被删除,内存被回收;

4.压缩链表

Redis为了节约空间,Zset和hash容器对象在元素个数较少的时候,采用压缩列表(zipList)进行存储,压缩列表是一块连续的内存空间,元素紧挨着存储没有任何冗余空隙,结构定义如下:

struct zipList<T>{
    int32 zlbytes;//整个压缩列表占用的字节数
    int32 zltail_offset;//最后一个元素距离压缩列表起始位置的便宜量,用于快速定位到最后一个节点
    int16 zllength;//元素个数
    T[] entries;元素内容
    int8 zlend;//压缩列表结束标志为,0xFF
}

压缩列表为了支持前后遍历,才会有zltail_offset这个字段,用于快速定位最后一个节点,接着倒序遍历

struct entry{
    int<var> prevlen;//前一个entry的字节长度
    int<var> encoding;//元素类型编码
    optional byte[] content;//元素类容
}

encoding:存储了元素内容的编码类型信息,zipList通过这个字段决定后面content的形式。 prevlen:存储前一个entry的字节长度,如果前一个entry小于254字节,就会用一个字节存储,否则就会用5个字节存储。

结构图如下: 如果存储的是Hash,Key和Value会作为两个独立的entry紧挨着存储,如果是Zset,Value和Score会紧挨着存储。

4.1 新增元素

zipList因为是紧凑存储,没有冗余空间,意味着插入元素就需要调用realloc扩展内存,取决于内存分配算法和当前zipList内存大小,realloc可能会重新分配内存空间,并将之前的内容一次拷贝到新的地址,如果zipList占用内存太大,重新分配和拷贝内存就会有很大的消耗,所以zipList不适合存储大型的字符串,存储的元素也不能过多。

4.2 级联更新

Redis为了节约内存,prevlen字段的长度是随着前一个entry的大小而变化,如果前面的额entry的大小小于254字节,prevlen就会用1个字节存储,超过254个字节,prevlen就会用5个字节表,所以当前一个entry的大小刚好从253个字节修改为254个字节,那么他的下一个entry的prevlen就需要更新,从一个字节扩展到5个字节,如果后面这个entry扩展后也刚好超过254个字节,那么再后面的entry也需要更新。 如果zipList的每个entry都恰好存储253个字节,那么第一个entry修改就会导致后续所有的entry级联更新,这就是一个比较耗时的操作了。

5.快速列表

快速列表结构是Redis在3.2版本后新加的,链表对每个节点增加pre和next字段,且存在较大的内存碎片化,新增快速链表quickList,实际上是 zipList 和 linkedList 的混合体,把多个节点组成zipList,多个zipList组成大的链表节点quickListNode,形成链表,这样只需要在每个quickListNode节点上增加pre和next指针,节约内存及减少了内存碎片化。

struct quickList{
    quicklistNode *head;//头结点
    quicklistNode *tail;//尾结点
    long count; //元素总数
    long nodes; //zipList 节点个数
    int compress;//压缩深度
}
  • fill: 16bit,ziplist大小设置,存放list-max-ziplist-size参数的值。
  • compress: 16bit,节点压缩深度设置,存放list-compress-depth参数的值。
struct quickListNode {
    quicklistNode *prev; //上一个node节点
    quicklistNode *next; //下一个node
    zipList *zl;//保存的数据 压缩前ziplist 压缩后压缩的数据
    int32 sz; //zipList的字节总数
    int16 count;//count of items in ziplist
    int2 encoding;//RAW==1 or LZF==2 
    int2 container;//NONE==1 or ZIPLIST==2 */
    int1 recompress; 
} quicklistNode;
  • zl: 数据指针。如果当前节点的数据没有压缩,那么它指向一个ziplist结构;否则,它指向一个quicklistLZF结构。
  • recompress: 当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。
  • encoding: 表示ziplist是否压缩了(以及用了哪个压缩算法)。目前只有两种取值:2表示被压缩了(而且用的是LZF压缩算法),1表示没有压缩。

5.1 结构图

5.2 ziplist 切割大小

quicklist 本质上是将 ziplist 连接起来,那么每个 ziplist 存放多少的元素,太小的话就成了普通的链表,太大的话zipList的效率太低(修改),quickli 内部默认定义的单个 ziplist 的大小为 8k 字节. 超过这个大小,就会重新分配一个 ziplist 了。这个长度可以由参数list-max-ziplist-size来控制。

5.3 zipList压缩

压缩后节点结构定义:

struct quicklistLZF {
    unsigned int sz;//表示压缩后的ziplist大小
    char compressed[];//,存放压缩后的ziplist字节数组
} quicklistLZF;

quicklistLZF结构表示一个被压缩过的ziplist。

list-compress-depth:redis参数,可以指定压缩深度默认0,所有的节点都不压缩

这当中有空间和时间的权衡,如果将一个 ziplist 压缩,那么要从它里面读取值,必然要先解压,会造成性能变差,为了支持快速的 push/pop 操作,quicklist 两端的第一个 ziplist 不进行压缩,这时压缩深度为 1,如果压缩深度为 2, 则是两端各自两个ziplist不压缩。

6.跳跃列表

6.1 跳表

跳表是一个随机的数据结构,利用了二分法的原理,把链表的每个节点进行分层,每个节点维护向下向前的指针,底层的节点每两个节点提取一个节点到上一级,查找时从最上层往下查找,时间复杂度O(logn),添加数据是,先从自底层添加,在随机1/2的概率决定是否上层添加,保证上层元素为下层的元素1/2; 结构图: 从上图可知要查找target,从L3往下查找只需要查找2次;

6.2 redis跳跃链表

如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时, Redis就会使用跳跃表来作为有序集合健的底层实现,redis的跳跃链表共有64层。 skipList结构定义如下

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

level可以知道当前跳跃表最高的层,从而开始从高向低进行查找。

typedef struct zskiplistNode {
    robj *obj;// 成员对象
    double score;// 分值
    struct zskiplistNode *backward;// 后退指针
    struct zskiplistLevel {// 层
        struct zskiplistNode *forward;// 前进指针
		// 跨度,节点在该层和前向节点的距离
        unsigned int span;
    } level[];
} zskiplistNode;
  • level:代表每个节点的层级,每个层级都会有forward和跨度,所以是一个数组
  • span:跨度,可以快速定位该节点的zkipList的排名,把顶层索引到该节点的路径上的span相加就是该节点的排名。

6.3 随机层数

对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数。直观上期望的目标是 50% 的 Level1,25% 的 Level2,12.5% 的 Level3,一直到最顶层,因为这里每一层的晋升概率是 50%。

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

Redis标准源码中的晋升概率只有25%,也就是代码中的ZSKIPLIST_P的值。所以官方的跳跃列表更加的扁平化,层高相对较低,在单个层上需要遍历的节点数量会稍多一点,单页因此可以节省存储索引的空间。 也正是因为层数一般不高,所以遍历的时候从顶层开始往下遍历会非常浪费。跳跃列表会记录一下当前的最高层数maxLevel,遍历时从这个maxLevel开始遍历性能就会提高很多。

6.4 顺序问题

在一个极端的情况下,zset 中所有的 score 值都是一样的,zset 的查找性能会退化为 O(n) 么? zset的排序元素不只看score值,如果score 值相同还需要再比较 value 值 (字符串比较)。

7.intset集合

当set集合元素全是整数是,redis底层的数据结构采用intset,可以保存 16,31,64 位的整数且保证不重复。 结构定义如下:

typedef struct intset{
    int32 encoding;// 编码方法,指定当前存储的是 16 位,32 位,还是 64 位的整数
    int32 length;// 集合中的元素数量
    int<T> contents;// 保存元素的数组
}

encoding 属性有三种取值,分别代表当前整数集合存储方式是用 16 位整数数组,32 位整数数组或者 64 位整数数组,当有16位以内的整数,16 位的整数的数组就可以放下。

** 整数集合分级的好处 **

  • 用能容纳数字的最小编码进行存储,可以有效的节约内存。
  • 整数集合封装了对三种整数之间的转换,使用我们不用考虑类型错误,可以不断的向整数集合内添加整数。提升了操作的灵活性。

9.紧凑列表

Redis5.0版本引入了一个心得数据结构listpack,他是zipList的改进版本,在存储空间更加节省,结构上也比zipList更精简,其结构如下:

struct listpack<T>{
    int32 total_bytes;//占用总字节数
    int16 size;//元素个数
    T[] entries;//原数列表
    int8 end;//结束标志符
}

相比zipList,少了一个zltail_offset字段,zipList通过字段定位最后一个元素,用于逆序遍历。listpack则采用了另一种方式:

struct lpentry<T>{
    int<var> encoding;//元素类型编码
    optional byte[] content;//元素内容
    int<var> length;//当前元素长度
}

lpentry相对ziplist元素结构元素长度放在了尾部,且不再是上一个元素的长度,通过listpack的总字节数和最后一个元素的长度计算出最后一个元素,且通过尾部长度逆向遍历;

由于lpentry元素相对上一个元素完全独立,所以彻底消除了级联更新的问题,不会因为上一个元素变化而影响到后续的元素;

8.总结

以上总结了redis的底层数据存储的结构,每种结构都充分考虑了效率及空间利用,Redis的基础数据类型都会根据自身的特性选择不同的数据结构,且可以动态调控参数进行数据结构的转换。

SDS动态字符串可以快速获取长度,杜绝了缓存区的溢出,通过内存预分配减少内存重分配。
Dict结构通过渐进式rehash,解决了rehash过程带来的冲突问题。
ziplist主要用于Hash和zset结构,通过紧凑型存储key和value减少内存碎片,节省内存。
quickList主要解决链表附件空间相对太高的问题,且链表每个节点都是单独分配,会加剧内存碎片化,所以采用zipList集合多个单节点组成ziplist链表
整数集合是集合键的底层实现之一,底层由数组构成,升级特性能尽可能的节省内存。
listpack紧凑列表主要是为了精简ziplist所引进的结构,目前还没有做好替换zipList的准备,zipList在redis中使用太广泛,会有很多兼容性的问题,目前只使用在新加的Stream数据结构中。