redis底层存储 redis底层原理该如何回答_List



预先知识

1:Redis的数据都是存在内存中的。

2:Redis是以键值对的形式存储数据,键只能是字符串对象,而值对应着五种常见的数据结构:string,list,hash,set,sorted-set。

3:Redis支持主从同步,哨兵模式,Redis集群来保证高可用。

4:Redis支持持久化技术,删除策略,Lua脚本,事务等功能。

 


 

图中的RedisObject,是五种数据类型对应的底层结构,

type是五种常见的数据类型。

encoding是常见的数据类型的底层实现,其中string的encoding分为int,embstr,raw,list的encoding分为zipList,linkedList,hash的encoding分为zipList,hashtable,set的encoding分为zipList,hashtable,sorted-set的encoding分为zipList,skipedList。

refcount是当前对象被引用的次数。

Lru是最近访问的时间,可用于Redis的淘汰策略。

ptr指向value值,字符串就是字符串格式,链表就是链表格式等。其实每个数据类型的底层都是嵌套了字符串对象


typedef struct redisObject {//  总空间:  4 bit + 4 bit + 24 bit + 4 byte + 8 byte = 16 byte      unsigned type:4;        //  分别存储五种常用的数据类型,String,List,Set,Hash,Zset    unsigned encoding:4;    //  更细分,存储上面的编码方式    unsigned lru:LRU_BITS;  //  lru时间, 用于redis的淘汰机制的    int refcount;           //  共享对象,被引用了多少次      void *ptr;              //  指向sds地址,sds分多个结构体} robj;

 

String-字符串对象

String数据类型的底层分为:int,embstr,raw三种encoding。

set key value.

当value保存的是整数值,那么字符串对象会将整数值直接保存在redisObject中的ptr属性中,直接转换成long,并将encoding的编码设置为Int。

set user 001;redisObject{    type:string;    encoding:int;    ptr->001:}
sds{    //记录字节数组中未使用的空格数。    int free;    //字符串长度    int len;    //真正存储字符串地方:C语言是用字节数组标识字符串    char[] buff;}

当value保存的是字符串并且长度小于超过39字节,则采用embraw,若大于39字节,则用raw格式的encoding。但raw格式和embraw格式有不同点,是raw格式会调用两次内存分配函数来创建redisObject和ptr的结构,而embraw会调用一次内存分配函数同时创建redisObject和ptr的结构,如下图:
embstr:ptr和sds的内存地址是连续的,raw:ptr和sds不是连续的。

redis底层存储 redis底层原理该如何回答_数组_02

embstr和raw的区别:1:embstr编码的内存分配次数为1次,而raw需要两次内存分配。2:embstr释放空间只释放1次,raw释放2次。3:只要embstr的值被修改过,总会升级成raw格式。SDS在源码中也分为多种结构体,具体详情可以看源码熟悉以下结构体

sdshdr5 ,sdshdr8,sdshdr16,sdshdr32,sdshdr64

SDS字符串和C字符串的区别是什么?相同点            都是以'\0'结尾的字符数组。不同点

  • SDS字符串获取字符串长度的时间复杂度为O(1),而C字符串是O(n)。
  • SDS字符串可防止缓冲区溢出,用空间预分配和惰性空间释放技术来减少内存重分配次数,保证内存重分配最多是N次,而C字符串最少是N次。
  • SDS可以存储二进制安全,文本的数据,C字符串只能存储文本数据。同时SDS是以len字段来判断字符串是否结尾,而不是用\0来判断。这也说明了SDS字符串可以有多个\0, 而C语言的\0只标识结尾。

 


List-列表对象list数据类型的底层分为:zipList,linkedList,新版本有quickList。

quickList是zipList和linkedList的结合。

zipList图

redis底层存储 redis底层原理该如何回答_redis底层存储_03

redis底层存储 redis底层原理该如何回答_数组_04

typedf struct ziplist<T>{    //zipList列表中占用的字节总数, ziplist占用大小.    int32 zlbytes;    //最后一个entry元素距离起始位置的偏移量,用于快速定位最后一个节点, 从而可以在尾部进行pop或push    int32 zltail_offset;    //元素个数, entries的数组大小    int16 zllength;    //元素内容,     T[] entries;    //结束位, zipList中最后一个字节, 是一个结束标记,一般是225字节    int8 zlend;}ziplist
//entry的结构typede struct entry{    //前一个entry的长度, 这样就可以根据倒序遍历定位到前一个entry的位置, 因为知道了    int<var> prelen;    // 保存了content的编码类型    int<var> encoding;    // 元素内容    optional byte[] content;}entry

 

linkedList图

redis底层存储 redis底层原理该如何回答_redis底层存储_05

//链表typedef struct list {    //头指针, 指向头一个节点    listNode *head;    //尾指针, 指向尾部的一个节点    listNode *tail;    //节点拷贝函数, 用于链表转移复制时,对节点value拷贝的一个实现    void *(*dup)(void *ptr);    //释放节点函数,释放一个节点所占用的内存空间    void (*free)(void *ptr);    //判断两个节点是否是相等的函数    int (*match)(void *ptr, void *key);    //链表长度    unsigned long len;} list;
//链表中的节点的数据结构typedef struct listNode {    //前指针    struct listNode *prev;    //后指针    struct listNode *next;    //当前值    void *value;} listNode;

zipList和linkedList的区别是什么?内存:zipList是连续的内存地址,entrys都是连续的,没有前后指针,因为指针也是占用字节的。而linkedList每一个节点的内存都是单独分配的,然后通过指针来建联。

zipList列表俗称压缩列表,顾名思义最大的特点是节约内存。当使用列表对象时,必须满足以下两个条件,反之则升级为linkedList:1:列表对象保存的所有字符串元素的长度都小于64字节。(有一个大于64字节都不行)2:列表对象中的元素个数要小于等于512个。(多一个都不行)。具体可以更具配置文件中设置。


hash-哈希对象hash数据类型的底层为:zipList,hashtable其实这一块我当时有一个疑问,hash的encoding为zipList的时候是如何存储数据的?答:虽然是hash结构,但不能用惯性思维以为存储的就是hash结构!如果hash结构的encoing为zipList,则会将hash的key和value同时作为连续的两个entry存储。保证键在前,值在后,并且是连续的。
新增加的键值对会优先放在离表头近的地方。

redis底层存储 redis底层原理该如何回答_List_06

hashtable的底层就是一个dict(字典),整个dict有两个hashTable,下图为没有rehash状态的字段字典,一个用来存储数据的,一个为空, 如图

redis底层存储 redis底层原理该如何回答_数组_02

//这是hashTable的结构. dict有两个hashTable,// 目的为了实现渐进式rehash,将旧表换成新表 typedef struct dictht {    dictEntry **table;  //存储数据的二维数组,    unsigned long size; //  hashtable容量    unsigned long sizemask;  // size -1    unsigned long used;  // hashtable 元素个数 , 已有的节点数量} dictht;

typedef struct dictEntry {    void *key;    union {        void *val;        uint64_t u64;        int64_t s64;        double d;    } v;    struct dictEntry *next;} dictEntry;

hash最重要的两个特征:扩容和解决冲突。扩容:分为扩容和缩容,两个操作都跟负载因子有关系

负载因子=已有节点数(used)/哈希表大小(size)。没有执行bgsave和bgrewriteaof命令时,当负载因子大于等于1时,则扩容。
当执行bgsave和ghrewriteaof命令时,负载因子大于等于5时,则扩容。
当负载因子小于等于0.1时,缩容。
扩容期间,写操作无法避免,提高负载因子的目的是为了尽量减少写操作,减少不必要的内存消耗。

 

渐进式rehash的步骤:

1:假设当前数据都在ht[0]上,在进行扩容时,先要给ht[1]分配空间,空间跟ht[0]中已存在的节点数成幂函数。

2:字典中的rehashIdx为0,标识hash开始。

3:只有在主动触发crud时,才进行rehash过程,每次对数据进行crud时,会将ht[0]在rehashIdx索引上的所有键值对都迁移到新ht[1]上,当迁移完成时,rehashIdx自增+1,触发curd,又会循环进行3操作。

4:迁移完成后,释放老ht[0],将ht[1]设置为主存储hashtable,同时rehashIdx设置为-1.

冲突:采用拉链法解决hash冲突,新来的节点采用的都是头插法
当使用hash对象,必须满足一下条件,才会用zipList,反之则用hashTable1:哈希对象中的所有键值对的键值要小于64字节。2:哈希对象中的键值对数量要小于等于512个。具体可以更具配置文件中设置。


zset-集合对象set特点:跟插入元素的顺序没有关系,是无序的,并且元素不重复,底层数据类型存在两种:intset和hashtable。
intsetencoding对应三种编码格式,比如16位,32位,64位。length:集合中元素数量
contents:真正存储元素的地方,数组是按照从小到大有序排列的,并且不包含重复项。

//整数集合结构体typedef struct intset {    uint32_t encoding;  //编码格式,有如下三种格式,初始值默认为INTSET_ENC_INT16    uint32_t length;    //集合元素数量    int8_t contents[];  //保存元素的数组,元素类型并不一定是ini8_t类型,柔性数组不占intset结构体大小,并且数组中的元素从小到大排列。} intset;               
#define INTSET_ENC_INT16 (sizeof(int16_t))   //16位,2个字节,表示范围-32,768~32,767
#define INTSET_ENC_INT32 (sizeof(int32_t))   //32位,4个字节,表示范围-2,147,483,648~2,147,483,647
#define INTSET_ENC_INT64 (sizeof(int64_t))   //64位,8个字节,表示范围-9,223,372,036,854,775,808~9,223,372,036,854,775,807

intset存在升级过程,当存储的元素都是-32768~32767之间的元素时,encoding默认使用INT16存储,当插入40000这个元素时,会触发集合升级。

只要超过当前encoing的范围内就会升级,并且一旦升级是不会降级的

具体流程如下:1:根据新元素的类型,扩展intset整数集合的底层数组大小,并为新元素分配空间。2:将现有的encoding从INT16升级成INT32,同时给数组中的元素分配新空间,但要保证数组的有序性是不会发生变化的。3:最后新增的元素添加到对应的制定数组位置上。图解如下

redis底层存储 redis底层原理该如何回答_字符串_08

假设现在redisObject的encoing位intSet,同时intSet整数集合的底层使用的是INT16位来存储整型数组元素,当新来的元素位40000时,会触发整型升级。

  • 了解旧的存储格式
  • 数组中的每个元素占用16位,同时数组的长度为4,固旧数组的存储格式总共占用16*4=64位。
  • 确认新的存储格式
    • 触发整型升级为INT32,固每个元素应该占用32位,新数组的存储格式总共应占用32*5=160位,
  • 根据新的存储格式分配内存空间
    旧数组的存储格式是占用64位的,新数组的存储格式占用160位。

redis底层存储 redis底层原理该如何回答_字符串_09

  • 从后到前,依此修改空间地址,先修改元素位4的,分配新空间

 

redis底层存储 redis底层原理该如何回答_redis底层存储_10

redis底层存储 redis底层原理该如何回答_数组_11

  • 再为3分配新空间,同时移动空间地址。

redis底层存储 redis底层原理该如何回答_redis底层存储_12

  • 再为2分配新空间,同时移动空间地址

redis底层存储 redis底层原理该如何回答_字符串_13

 

  • 再为1分配新空间,同时移动空间地址。

redis底层存储 redis底层原理该如何回答_List_14

为什么整型集合升级时,是倒序分配空间的?

  • 这样可以避免覆盖地址问题,从后新增空间,并不会影响原有的位置,同时encoding分为INT16,INT32,INT64的目的是为了解决空间,只有在需要升级的时,才升级。升级操作的时间复杂度为O(n)

 

  • 在使用集合对象时,要保证存储的是整数即可。如果有字符串,则会使用hashtable结构,在查找元素的时候是通过二分查找查询元素。

 


sorted-set-有序集合对象简称z-set,底层的encoding分为两种:zipList和skipedList

zipList:满足以下两个条件,不满足则用跳表

1:[sore,value]键值对数量少于128个。2:每个元素的长度小于64字节。前期用zipList的好处是:

连续内存地址,可以省内存资源,但节点数量一旦多起来,会带来复制成本,降低性能,同时查找元素的时间复杂度为O(n),需要从头到尾遍历,查找效率低。


skipedList:由跳表跟hashtable一起组成层:即下图中的level高度,同级别的层指向下一个跳表节点同级别的层,比如L4就指向下一个链表结点的L4,中间是通过前进指针指向的,距离叫做跨度,如果跨度为0,则说明当前跳表结点的前进指针为null,跨度越大,说明距离越远。

后退指针BW:从表尾向表头访问结点,访问前一个链表结点,当后退指针为null,则说明从尾到头遍历结束,有点前驱结点的味道。

分值:所有节点的分值都是从小到大排序的,分值可以有相同的,相同则根据字典序进行排序。

成员:成员都是一个指向字符串对象的指针,必须是唯一的。


redis底层存储 redis底层原理该如何回答_List_15

//sorted-set的结构体typedef struct zset {    dict *dict;    zskiplist *zsl;} zset;
typedef struct zskiplist {    //header指向跳跃表的表头节点,tail指向跳跃表的表尾节点    struct zskiplistNode *header, *tail;    //记录跳跃表的长度(表头节点不计算在内)    unsigned long length;   //记录跳表中最大的高度(表头结点不计算在内)    int level;} zskiplist;
typedef struct zskipListNode{    //后退指针:当前节点的前驱结点    struct zskiplistNode *backward;    //分值    double score;    //成员对象    robj * obj;    //层    struct zskiplistLevel{        //前进指针:当前层的后继节点        struct zskipListNode *forward;        //跨度        int span;    };}

跳表的CRUD的时间复杂度是O(logN),查询效率高,但占用空间比较大。
为什么Redis用跳表而不用平衡二叉树?

  • 平衡树新增和删除, 可能会引起左旋和右旋, 而skipList只需要改变前后指针, 操作起来比较容易.
  • 在平衡树找到指定的范围后, 还需要通过中序遍历继续寻找其他不超过这个值的节点, 但对于跳表而言, 到了指定的范围, 只需要遍历就可以拿到, 有点唯一索引和普通索引的味道, 但是平衡树和跳表的时间复杂度都为O(logN)。