Redis底层数据结构笔记

1、SDS(简单动态字符串)

  • SDS在数据库中被用来存储字符串。

  • 数据定义及结构

    struct sdshdr{
    	int len;    //已使用的字节长度
        int free;   //未使用的字节长度
        char buf[]; //存储的字节
    };
    

  • 特性

    SDS 的特性都来源于它的数据结构(是与 C 的字符串相比较而获得的优点)
    (1)、以常数复杂度获得长度。保留了 len 变量
    (2)、杜绝缓冲区溢出。扩展之前先根据 len 值判断是否会溢出。C 中的字符扩展需要手动判断
    (3)、减少修改带来的内存重分配次数。
        空间预分配:对SDS扩展之后,空间小于 1M ,分配给 free 同样大小的值。总空间为 len*2+1(加的1是结束	符'\0' 的大小)。修改后空间大于 1M ,分配给free 1M 空间。总空间 len+ 1MB +1。不需要扩展就不动。
        惰性空间释放:SDS 缩短时,不会立即回收空间。而是用 free 属性记录下来。使用API可以手动释放。		
    (4)、二进制安全:C字符串遇到空字符分割的字符串会停下来,不在读取后面的内容。buf数组不是用来保存字符的,而是二进制数据,所有SDS的API都以处理二进制的方式处理buf 里面的数据。使用len 判断字符串是否结束而不是结束符。
    (5)、兼容C的部分字符串函数:任然保留了 '\0'结束标志,虽然自己不用这个判断字符的结束,但是这使得它可以使用C的部分字符串库函数。
    

2、链表(列表键的底层实现)

  • 当一个列表键包含了比较多的元素,或者元素时比较长的字符串时,Redis 使用链表作为列表键的实现

  • 链表节点

    typedef struct listNode{
        struct listNode *prve;   //前一个节点
        struct listNode *next;   //后一个节点
        void *value;             //节点的值
    }listNode;
    
  • 链表

    typedef struct list{
        listNede *head;							//头节点
        listNode *tail;                           //尾节点
        unsigned long len;						//链表长度
        void *(*dup) (void *ptr);				 //节点复制函数
        void *(*free) (void *ptr);				 //节点释放函数
        void *(*match) (void *ptr,void *key);     //节点值对比函数
    } list;
    

  • 特性

    特性来自数据结构
    (1)、双端:每个节点都有前后指针,获取某个节点的前后置节点复杂度为 O(1)
    (2)、无环:头尾指向 NULL,链表的访问以 NULL 结束
    (3)、有头尾指针:找到头尾节点的复杂度为 O(1)
    (4)、有长度计数器:已有节点的个数。获取长度复杂度为 O(1)
    (5)、多态:使用 void* 保存节点值,可以存放不同的数据类型
    

3、字典(哈希键的底层实现)

  • 当一个哈希键包含的元素比较多,又或者键值对中的元素都是比较长的字符串时,会使用字典作为哈希键的底层实现

  • 字典中的数据结构

    //字典:
    typedef struct  dict{
        //类型特定函数,计算 hash 值、复制键值、销毁键值、对比键值的函数
        dictType *type;
        //私有数据,为针对不同类型的键值对,创建多态字典而设计的
        void *privdata;
        //哈希表数组
        dictht ht[2];
        //rehash 索引
        int rehashidx;
    } dict;
    
    //哈希表
    typedef struct dictht{
        //hash 表数组
        dictEntry **table;
        //hash表的大小
        unsigned long size;
        //hash掩码  size-1
        unsigned long sizemask;
        //已用节点数量
        unsigned long used;
    }dictht;
    
    //哈希表节点
    typedef struct dictEntry{
        //键
        void *key;
        //值
        union{
            void *val;
            uint64_tu64;
            int64_tu64;
        };
        //下一个指针,用于 hash冲突
        struct dictEntry *next;
    }
    

  • 哈希算法

    //比如要存储键值对<k0,v0>
    int hash = dict->type->hashFunction(k0);
    //注意是 & 不是 % ,不明白
    int index = hash & ht[0].sizemask;      
    //接着就把该键值对存放在 ht[0] 的table的dictEntry的index位置
    
  • 键值冲突解决

    使用单向链表,但是要注意的是,插入时不是插入在后面而是在最前面,主要是为了提升插入时的效率。没有了遍历链表找到尾节点再插入的时间浪费
    
  • 哈希表的扩展和收缩

    负载因子:used/size
    (1)、当服务器在执行 BGSAVE 或者 BGREWRITEAOF 命令时,如果负载因子大于等于 1,就开始 rehash
    (2)、如果在执行上面两条命令,如果负载因子大于等于 5,才开始 rehash,提高阈值是为了尽量避免在子进程执行任务时进行hash,避免不必要的内存写入。
    (3)、当负载因子小于 0.1 时,开始收缩
    
  • 渐进式 rehash

    步骤:
    	1)、为 ht[1]分配空间
    	2)、将字典中维持的 rehashidx 值置为0,标识开始 rehash
    	3)、每次对字典执行 CRUD 操作时,程序除了完成指定的 CRUD 操作外还会顺带将 ht[0] 上的键值对 rehash 到ht[1],每 rehash 一个键值对,rehashidx++
    	4)、完全 rehash 之后,重新将 rehashidx 置为 -1。释放 ht[0],将ht[1]设置为 ht[0],重新建立一个ht[1],并创建一个空的 table
    	
    CRUD 操作时顺带 rehash 时注意点
    	1)、增加新的键值对只会往 ht[1] 上增加
    	2)、删除和修改会同时在两张表上进行
    	3)、查找时会现在 ht[0] 查找,没找到再去 ht[1] 找。
    	
    一个小疑点:我本来觉得时边操作顺带 rehash ,就是找到了指定键值对,操作完之后,直接把这个也 rehash 到ht[1],但是删除和修改是同时在两张表执行的,就是说,这个数据会在两张表同时存在(吗?)。问题是:他是操作谁就把谁直接 rehash 还是按一定的顺序来 rehash 的。希望大佬解决  PS:书上的例子时顺序 rehash 的,但是他这个例子并没有表明时在 CRUD 时顺待执行的
    

4、跳跃表(有序集合的实现之一)

  • 如果一个有序集合包含的元素比较多或者元素的成员是比较长的字符串时,Redis 会使用跳跃表作为集合键的底层实现

  • 跳跃表的数据结构

    • 跳跃表
    //跳跃表
    typedef struct zskiplist{
        //头尾节点
        structz skipListNode *header , *tail;
        //节点的数量
        unsigned long length;
        //表中层数自大的节点的层数
        int level;
    } zskiplist;
    
    • 跳跃表属性解析

      (1)、头尾节点:直达首尾节点
      (2)、level:节点中层数最大的节点的层数,头节点不计入
      (3)、length: 节点的数量,头节点不算
      
    • 跳跃表节点

    typedef struct zskiplistNode{
    	//层
        struct zskiplistlevel{
            //前进指针
        	struct zskiplistNode *forward; 
            //跨度
            unsigned int span;
        } level[];    
        //后退节点
        struct zskiplistNode *backward;
        //分值
        double score;
        //成员对象,是一个 SDS 
        robj *obj;
    } zskiplistNode;
    
    • 节点属性介绍

      (1)、level[] :节点的很多层 ,节点的层数是在创建的时候根据幂次定律算出来的(1~32之间的整数); forward指针:指向下一个节点的同层(不确定,是猜测);span:跨度,就是用来计算排位,将从根节点到该节点所经的全部跨度加起来就是该节点的排位。跳跃表是一个有序的数据结构。
      (2)、backword 指针:指向前面一个数据,可以用于逆序遍历。
      (3)、score:节点按分值大小进行排序,由小到大。
      (4)、obj: 存储的对象,只能是 SDS,前面的链表啥都能存储 
      

  • 一点注意:

    1、节点的分值可以相同,但是成员对象 SDS 必须是唯一的。
    2、分值相同时,按照 sds 的字典序进行排序
    3、有序的数据结构,增删改查平均时间复杂度 O(log N), PS:这里没讲,我觉得是和 span 有关的,要想 logN,就得能直接找到中间下标,跳跃表里面存储了 length,可以计算出 length/2,然后应该是根据 span 来跳着找 length/2,肯定不是用 forward 来遍历了,但是利用 span 应该也不能保证就能一次找到 len/2 的位置吧。。。。(存疑,等待大佬解决----)
    4、跳跃表是有序集合的实现之一
    

5、整数集合(集合键的实现之一)

  • 当一个集合只包含整数值元素,并且集合的元素数量不多时,Redis 使用整数集合作为集合键的实现。

  • 数据结构

    typedef struct intset{
    	//编码方式
        uint32_t encoding;
        //集合元素数量
        uint32_t length;
        //保存的元素数组,有序的,从小到大排列
        int8_t contents[];
    }
    

  • 特性

    1、虽然  contents[] 被声明为 int8_t ,但是实际的编码类型是由 encoding 属性来决定的。
    2、当向一个 int16_t 的数组添加 int32_t 的元素时,数组会进行升级,所有元素都会变成 int32_t 类型。
    
  • 升级步骤

    1、根据新元素的类型,扩展数据底层空间大小,并为新元素分配空间
    2、将原来的元素转换为新元素的类型,并按原来的顺序放到对应的位置,保持有序性不变
    3、将新元素添加到数组里面
    4、更新 encoding 值和 length 值
    
    *****要注意的是,他是从后面开始插入的,最后在插入新元素的值,另外,数据长度减小时,不会降级*****
    

  • ​ 一点注意

    1、整数集合是集合键的实现之一
    2、整数集合 有序,不重复地保存数据,会进行动态升级,不会降级
    

6、压缩列表(列表键和哈希键的实现之一)

  • ​ 当一个列表键只包含列表项,并且每个列表选项要么是小整数值,要么是长度比较短的字符串时。Redis 会使用压缩列表来实现列表键。

  • 压缩列表

属性 类型 长度 说明
zlbytes uint32_t 4B 整个压缩列表占用的内存字节数(对列表进行内存重分配或者极计算 zlend 时会用到)
zltail uint32_t 4B 表尾节点(是节点不是 zlend 的位置)距离起始地址的字节数
zllen uint16_t 2B 压缩列表的节点数量
entry 列表节点(字节数组或者整数) 不定 压缩列表的节点
zlend uint8_t 1B 0xFF , 用来标记压缩列表末端

根据例子,zltail是最后一个节点的起始地址的偏移值。zlbytes-zltail-1 就是最后一个节点的长度(zlend 占一个字节)

  • 压缩列表的节点

  • 各个字段及用途
1、previous_entry_length:记录前面一个节点所占用的字节数,这个可以用于后序遍历。上面说了可以根据zltail 计算出最后一个节点的起始位置,减去这个值就正好是前面一个结点的起始位置,依次,可以进行后序遍历。这个属性可以是1或5字节长度,如果前一个entry长度小于254字节,就使1字节,否则5字节。
2、encoding:记录 content 属性保存的数据的类型以及长度。可以是 1、2、5 字节长,具体规则比较繁琐。
3、保存节点的值,可以是一个整数或者字节数组。
  • 连锁更新
	如果连续的多个节点(e1,e2,e3...),他们的长度都在 250~253 之间,现在在这些节点之前添加一个长度大于 254 的节点,那么 e1 的 previous_entry_length 属性就不得不变为 5 个字节,那么 e1 的长度就也会超过 254 字节,e2 的 previous_entry_length 属性也会变为 5 字节,...以此类推,引起连锁更新 。同样,删除节点也可能会引发连锁更新。

实际上这种情况比较少见,很少连续很多节点的长度在这个区间之内,对少量节点的更新也不会浪费太长时间,这种数据结构可以节省内存

posted @ 2022-05-08 20:47  心是冰冰的  阅读(43)  评论(0)    收藏  举报