Redis(三)——数据结构

一、字符串

这是redis自己构建的名为简单动态字符串(simple dynamic string),简称SDS。它是以C语言结构体形式存储,len表示长度,free表示未使用字节的数量,buf[]表示字节数组(字符串数组)
相对于C字符串的优势
1.通过len在O(1)时间获取长度
2.杜绝发生缓冲区溢出的可能。
对SDS修改时,先检查空间大小,不够则扩容
3.减少内存重分配次数

  • 空间预分配:扩容时,长度=SDS.len*2+1,这里的1是'\0'
  • 惰性空间释放:缩短SDS后不立即回收多余的字节,用free记录等待将来使用。有需要时可以通过相关API真正释放SDS的多余空间。

4.二进制安全(输入什么,最后还能返回原封不动的输入)
5.保留部分C字符串的语法

SDS 除了保存数据库中的字符串值以外,SDS 还可以作为缓冲区(buffer):包括 AOF 模块中的AOF缓冲区以及客户端状态中的输入缓冲区。

 

二、字典dict

redis的字典使用了哈希表作为底层实现,一个哈希表里面可以有很多个哈希表节点,每个哈希表节点就保存了字典中的一个键值对。

1.哈希表

typedef struct dictht
{
    //哈希表数组,C语言中,*号是为了表明该变量为指针,有几个* 号就相当于是几级指针,这里是二级指针,理解为指向指针的指针
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    //该哈希已有节点的数量
    unsigned long used;
} dictht;

2.哈希表节点

//哈希表节点定义dictEntry结构表示,每个dictEntry结构都保存着一个键值对。
typedef struct dictEntry
{
    //
    void *key;
    //
    union
    {
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

key属性保存着键值中的键,而v属性则保存着键值对中的值,其中键值(v属性)可以是一个指针,或uint64_t整数,或int64_t整数。 next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,解决键冲突问题。

哈希表和哈希表节点以拉链法解决哈希冲突的图如下:

3.字典

typedef struct dict
{
    //类型特定函数
    void *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    //rehash 索引 当rehash不在进行时 值为-1
    int trehashidx;
} dict;

type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。

  • type属性是一个指向dictType结构的指针,每个dictType用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
  • privdata属性则保存了需要传给给那些类型特定函数的可选参数
  • ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表, 一般情况下,字典只使用ht[0] 哈希表, ht[1]哈希表只会对ht[0]哈希表进行rehash时使用
  • rehashidx记录了rehash目前的进度,如果目前没有进行rehash,值为-1

4.rehash

即扩容/收缩时改变空间搬数据,步骤一般如下:

  • 为ht[1]哈希表分配空间:如果是扩展,ht[1]的大小为第一个大于等于ht[0].used*2的2的n次方幂。如:ht[0].used=3则ht[1]的大小为8,ht[0].used=4则ht[1]的大小为8;如果是收缩,ht[1]的大小为 第一个大于等于ht[0].used*2的2的n次方幂。
  • 将ht[0]上的键值对rehash到ht[1]上,rehash的意思是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
  • 当所有ht[0]的键值对都迁移到ht[1]上时,释放ht[0],将ht[1]置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

和Java的HashMap不同,并且是渐进式rehash。

渐进式rehash的意思是:不会一次性把ht[0]的键值对都迁移到ht[1]上,因为数据太大时会造成卡顿,导致服务器在一段时间内无法工作。步骤如下:

  • 为 ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
  • 在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为0,表示rehash工作正式开始。
  • 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值+1。
  • 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

简言之,牺牲后续每次增删改查操作的一点点时间来换取整个rehash操作不卡顿。(这里贴书上一张rehash过程的图)

另外,新增操作不会操作ht[0],直接在ht[1]上新增,这样保证ht[0]只减不增。

5.收缩和扩容的条件

负载因子 = 健值对数量/哈希表大小

服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子>=1,会进行扩容。

服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子>=5,会进行扩容。

负载因子<=0.1,会进行收缩。

 

三、跳跃表skiplist

有序,查找平均复杂度O(logn),最坏复杂度O(n)。

1.跳跃表节点

typedef struct zskiplistNode{
    struct zskiplistLevel{
        struct zskiplistNode *forward;//前进指针
        unsigned int span;//跨度
    } level[];
    
    struct zskiplistNode *backward;//后退指针
    double score;//分值
    robj *obj;//成员对象指针,指向一个SDS字符串对象
}zskiplistNode;

2.跳跃表

typedef struct zskiplist{
    struct zskiplistNode *header,*tail;//头尾节点指针
    unsigned long length;//跳跃表长度
    int level;//最大层数
}zskiplist;

有序集合sort set为什么叫zset的原因,盲猜:跳跃表叫skiplist,这里的zskiplist好像是特殊的跳跃表,因为redis的这个命名所以redis有序集合叫zset。

3.图示

  • 表头是有32层的,即跳跃表的层数最高是32层;
  • 每次创建节点层数都是根据幂次定律(越大的数越小)随机生成的,但第一层索引是level[0];
  • 前进指针指向后面第一个有相同层数的节点,并且记录一个跨度;
  • 节点排序第一关键字是分值,第二关键字是字符串的字典序,从小到大;
  • 创建节点、判断范围内是否存在的时间复杂度是O(1);
  • 插入、查找、都是O(logn),最坏O(n);
  • 释放、范围删除的时间复杂度是O(n),范围删除需要处理好层数之间的指针关系,过程如下:(待补)

4.为什么不用哈希表和平衡树而是跳跃表?

(1)哈希表是无序的,并且只能单点查询,这里经常需要范围查询

(2)操作上,平衡树的子树调整逻辑复杂,跳跃表操作简单,效率也可以与平衡树媲美

(3)内存上,平衡树每个节点包含2个指针,据说redis平均层数1.33

 

四、整数集合

当集合只有整数并且数量不多,redis就会使用整数集合,有序不重复。

1.数据结构

typedef struct intset{
    uint32_t encoding;//编码方式
    uint32_t length;//集合数量
    int8_t contents[];//保存集合元素的数组
}intset;

contents并不保存int8_t类型的元素,类型其实是由encoding决定的,encoding属性的值可以是INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64,然后集合数据类型对应取int16_t、int32_t、int64_t。

2.升级

如果新元素类型比原来的大,会引发升级,步骤如下:

  • 根据新的类型,扩展原数组的空间(空间重分配)
  • 将元素放在正确的位上,过程保持有序
  • 引发升级的新元素长度都比原来的长,要么巨大,要么巨小(负数),所以新元素的索引要么是0、要么是length-1

每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组已有的元素类型进行类型转换,所以整数集合添加新元素的时间复杂度是O(n)。但整数集合的使用是在元素是整数且数量不多的情况下,所以不会有太大的时间性能损耗。

升级的好处是:提高整数集合的灵活性、节约内存。

整数集合不支持降级

 

五、压缩列表ziplist

是列表键和哈希键的底层实现之一。当一个列表键只包含少量的列表项并且每个列表项是最小整数值或者较短字符串,redis就会使用压缩列表。

压缩列表是redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。

1.压缩列表的数据结构

  • zlbytes:4字节,记录整个压缩列表占用的内存字节数
  • zltail:4字节,记录表尾节点距离起始地址有多少个字节(偏移),通过这个属性可以确定表尾节点的地址
  • zlen:2字节,记录节点数量
  • entry:不定,保存若干个压缩列表节点,节点长度看节点自身
  • zlend:1字节,特殊值0xFF,标记是压缩列表的末端

2.压缩列表节点的数据结构

  • previous_entry_length:记录前一个节点的字节长度,属性类型可以是1字节或者5字节。设为1字节的时候直接保存前一个节点的长度,例如0x05表示前1个节点长度为5;设为5字节的时候第一个字节会被固定设为0xEF,后面4个字节表示前一个节点的长度,例如0xFE00002766表示前一个节点长度为0x00002766(10086)。该属性的作用体现于 从表尾节点向表头节点进行遍历。(内存连续,省去了不连续情况下头尾节点指针占用的内存)
  • encoding:记录content属性保存的节点的 类型及长度,类似上面的拆分字节分别表示。
  • content:保存节点的值,可以是字节数组或者整数。encoding(00001011)+content("hello world"),00表示保存的是字节数组,001011表示字节数组长度是11。

3.连锁更新

previous_entry_length记录前一个节点长度,如果添加一个新的节点,引发下一个节点的previous_entry_length溢出需要重分配,可能会引起后续一连串节点的空间重分配,这就是连锁更新。最坏情况是对压缩列表执行N次内存重分配,每次内存重分配最坏时间复杂度是O(N),所以最坏时间复杂度是O(N2)。只有满足 压缩列表中有连续并且多个长度较小的节点,才会对性能造成影响,概率不大,放心使用。

 

六、快速列表quicklist

redis3.2之后,list底层使用快速列表实现,快速列表是压缩列表ziplist和双向链表linkedlist混合体,大概长这样,很形象。

 

七、数据结构与redis对象(数据类型)的关系

  • 字符串对象string = SDS
  • 列表对象list = 压缩列表ziplist + 双向链表linkedlist = 快速列表quicklist
  • 哈希对象hash = 压缩列表ziplist + 字典dict
  • 集合对象set = 整数集合intset(只有整数并且数量不多) ||  字典dict(其他情况)
  • 有序集合对象zset = 压缩列表ziplist + 跳跃表skiplist

 

 


参考&引用

https://blog.csdn.net/u010412301/article/details/64923131

《redis设计与实现》

https://www.cnblogs.com/hunternet/p/12624691.html

posted @ 2020-08-20 11:15  守林鸟  阅读(228)  评论(0编辑  收藏  举报