面试必问:redis底层数据结构

前言
redis是通过对象来表示存储的数据的,redis 也是键值对存储的方式,那么每存储一条数据,redis至少会生成2个对象,一个是redisObject,用来描述具体数据的类型的,比如用的是那种数据类型,底层用了哪种数据结构,还有一个对象就是具体存储的数据。 这个存储对象数据就是通过redisObject这个对象的指针来指引的。
 

Redis为什么要使用2个对象?两个对象的好处
redis在执行命令的时候,就可以通过redisObject 的类型和编码来确定是否可以执行相应的命令,不用等操作具体的数据是才发现不行
针对不同的场景,可以为对象设置不同的数据结构,从而优化了对象在不同场景下的使用效率
可以基于redisObject中的refcount 引用计数进行内存回收机制,自动释放对象所占用的内存
可以让多个数据库来共享一个对象来节省空间
redis可以根据REDIS_LRU_BITS 记录最后一次访问时间,针对时间较长对象的进行删除

redisObject对象解析

redisObject结构

typedef struct redisObject{
    //类型
    unsigned type:4;
 
    //编码
    unsigned encoding:4;
 
    //对象最后一次被访问的时间
    unsigned lru:REDIS_LRU_BITS
 
    //引用计数
    int refcount
 
    //指向底层实现数据结构的指针
    void *ptr;
    ….. 
} 

ptr指针指向的就是每种数据类型的具体的数据结构

type 表示的是使用的那种数据结构,常用的有5种,string,hash,list,set,zset。 type就是来存这个的

encoding 标注了这个对象具体的数据结构类型

每一种redisObject对象对应底层都会有至少2种数据结构

介绍完了redisObject了,下面介绍每一种具体的数据类型及其对应的底层数据结构

String 类型

在c语音中,定义的字符串是不可被修改的,因此redis设计了可变的字符串长度对象,接SDS(simple dynamic string),实现原理:

struct sdshdr{
    //记录buf数组中已存的字节数量,也就是sds保存的字符串长度
    int len;
 
    // buf数组中剩余可用的字节数量
    int free;
 
    //字节数组,用来存储字符串的
    char buf[];
}

参数解析:

len : 保存的字符串长度。获取字符串的长度就是O(1)
free:剩余可用存储字符串的长度
buf:保存字符串
这样设计的优点:

当用户修改字符串时sds api会先检查空间是否满足需求,如果满足,直接执行修改操作,如果不满足,将空间修改至满足需求的大小,然后再执行修改操作
空间预分配
如果修改后的sds的字符串小于1MB时(也就是len的长度小于1MB),那么程序会分配与len属性相同大小的未使用空间(就是再给未使用空间free也分配与len相同的空间)   例:字符串大小为600k,那么会分配600k给这个字符串使用,再分配600k的free空间在那。
惰性空间释放,当缩短sds的存储内容时,并不会立即使用内存重分配来回收字符串缩短后的空间,而是通过free将空闲的空间记录起来,等待将来使用。真正需要释放内存的时候,通过调用api来释放内存
通过空间预分配操作,redis有效的减少了执行字符串增长所需要的内存分配次数
如果修改后sds大于1MB时(也就是len的长度大于等于1MB),那么程序会分配1MB的未使用空间  例:字符串大小为3MB,那么会分配3MB给这个字符串使用,再分配1MB的free空间在那。

字符串由3种编码实现。

1、int 整数值实现

存储的数据是整数时,redis会将键值设置为int类型来进行存储,对应的编码类型是REDIS_ENCODING_INT

2、embstr  
由sds实现 ,字节数 <= 39

存储的数据是字符串时,且字节数小于等于39 ,用的是embstr

优点:1、创建字符串对象由两次变成了一次

           2、连续的内存,更好的利用缓存优势

缺点:1、由于是连续的空间,所以适合只读,如果修改的话,就会变成raw

           2、由于是连续的空间,所以值适合小字符串

3、raw 

由sds实现 字节数 > 39

List 类型

list是由压缩链表和双向链表实现的

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

1、压缩链表:ziplist
适用于长度较小的值,因为他是由连续的空间实现的。

存取的效率高,内存占用小,但由于内存是连续的,在修改的时候要重新分配内存

在数据量比较小的时候使用的是ziplist

当list对象同时满足以下两个条件是,使用的ziplist编码

 1.list对象保存的所有字符串元素长度都小于64字节

 2.list对象保存的元素数量小于512个

缺点:

极端情况下会出现连锁更新现象。因为我们的previous_entry_length 记录了entry 的长度,如果每个长度都是250 - 253之间,那么如果在头部插一个新的entry节点,第一个节点的长度大于254字节,那么后一个节点的previous_entry_length 值就会从1个字节变为 5字节,那么这个entry的长度就大于了254字节,后面一个节点的previous_entry_length 值也得更新,后面的都得更新,导致整行的列表都得更新。
只能存储小数据的值
2、双向链表:linkedlist
在使用redis的list数据结构时,存储数据较大时,list对象已经不满足上面描述的ziplist条件,则会使用linkedlist

优点:修改效率高

缺点:保存前后指针,会占内存。

3、快速列表:quicklist

结合了压缩列表和双向链表的优点,也解决了他们的缺点 

// 快速列表节点
typedef struct quicklistNode {
    struct quicklistNode *prev; //上一个node节点
    struct quicklistNode *next; //下一个node
    unsigned char *zl;            //保存的数据 压缩前ziplist 压缩后压缩的数据
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
 
// 快速列表
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes ziplist大小设置 */
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off 节点压缩深度设置*/
} quicklist;
 
// 被压缩的列表
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

Hash 类型

底层是由ziplist 或hashtable 实现的

ziplist在上面介绍过了,下面来聊聊hashtable

Hashtable

 一个哈希表里面可以有多个哈希节点,而每个哈希节点就保存了字典中的一个键值对

// 字典 数据结构
typedef struct dict {//管理两个dictht,主要用于动态扩容。
    //类型特定函数
    dictType *type;
 
    //私有数据
    void *privdata;
 
    // 哈希表
    dictht ht[2];
 
    // rehash索引,当rehash不再进行是,值为 -1
    long rehashidx; /* 扩容标志rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;
 
 
/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
//定义一个hash桶,用来管理hashtable
typedef struct dictht {
    // hash表数组,所谓的桶
    dictEntry **table;
 
    // hash表大小,元素个数
    unsigned long size;
 
    // hash表大小掩码,用于计算索引值,值总是size -1 
    unsigned long sizemask;
 
    // 该hash表已有的节点数量
    unsigned long used;
} dictht;
 
//hash节点
typedef struct dictEntry {
    //键
    void *key;
 
    // 值
    union { 
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
 
    // 指向下一个节点,解决碰撞冲突
    struct dictEntry *next;
} dictEntry;
 
 
//定义hash需要使用的函数
typedef struct dictType {
    // 计算哈希值的函数
    uint64_t (*hashFunction)(const void *key);
 
    //复制键的函数
    void *(*keyDup)(void *privdata, const void *key);
 
    // 复制值的函数
    void *(*valDup)(void *privdata, const void *obj);
 
    // 对比键的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
 
    //销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
 
    // 销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

 参数解析:

dict中

type标识dictType类型,privatedata是指向特定数据类型的数组;

ht包含了两个哈希表的数组,其中一个ht[0]保存哈希表,ht[1]只会在对ht[0]进行rehash时使用

dicht中

table是一个数组,其中一个数组元素都是一个指向哈希表节点的指针,每一个哈希表节点都保存了一对键值对。

size记录了table的大小,也即是哈希表的大小

used记录了当前已使用节点的数量

sizemask=size-1 ,决定了一个键应该放tabl数组的哪个索引上

dicEntry中

key保存键值对中的键,

v保存这键值对中的值

next为指向另一个哈希表几点的指针,当有hash冲突的时候

哈希表的扩展和收缩
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作

1:服务器目前没有在执行bgsave命令或者bgrewriteaof命令、并且哈希表的负载因子大于等于1

2:服务器目前正在执行bgsave命令或者bgrewriteaof命令并且哈希表的负载因子大于等于5

rehash
rehash 跟Java中的hashmap扩容机制差不多

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或减少,为了让哈希表的负载因子维持在一个合理的

范围内当哈希表保存的键值对数量太多或者太少时程序需要对哈希表的大小进行相应的扩展或者收缩

redis对字典的哈希表执行rehash的步骤如下;

1、为字典ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作以及ht[0]当前包含的键值对数量(ht[0].used)

2、如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的n次幂

3、如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次幂

4、将保存在ht[0]中的所有键值对rehash到ht[1]上面rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上

5、当ht[0]包含的所有键值对都迁移到了ht[1]之后释放ht[0]将ht[1]设置为ht[0],并在ht[1]新创键一个空白哈希表为下一次rehash做准备

为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1]而是分多次将ht[0]里面的键值对慢慢地rehash到ht[1]

在进行渐进式rehash的过程中,字典会同时使用ht[0] ht[1]两个哈希表所以在渐进式rehash进行期间字典的删除 查找 更新等操作会在两个哈希表上进行 新添加到字典的键值对一律会保存到ht[1]里面则ht[0]不再进行任何添加操作

渐进式hash
如果哈希表里保存的键值对数量是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将 ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。

以下是哈希表渐进式 rehash 的详细步骤:

1、为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。

2、在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。

3、在rehash进行期间,**每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,**还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对

4、rehash到ht[1],当rehash工作完成之后,程序将rehashidx+1(表示下次将rehash下一个桶)。

5、随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash 而带来的庞大计算量。而memcached通过也需要rehash,但是它是另外单独开一个线程,专门执行rehash操作。这就是区别与Redis的做法,个人任何Redis的作者方法更胜一筹。因为这种分治算法,将全部负载,均摊到了每次的操作过程中,在单个线程中实现。这个就是迁移线程,memcached中的迁移线程。

Set 类型
底层使用的是intset 或hashtable 实现的

hashtable 上面介绍过了,下面介绍intset

intset 整数集合
有序不重复的连续空间,特性如下:

        1、节约内存,但由于是连续空间,修改效率不高

        2、集合中的数都是整数时,且数据量不超过512个时,使用intset

        3、整数集合是集合键的底层实现之一

       4、 整数集合的底层实现是数组,这个数组以有序、无重复的方式保存集合元素,在有需要的时候,程序会根据新添加元素的类型,改变这个数组的类型。

        5、升级操作为整数集合带来了操作上的灵活性,并且尽可能的节约了内存。

        6、整数集合只支持升级操作,不支持降级操作

typedef struct intset {
    // 编码类型 int16_t、int32_t、int64_t
    uint32_t encoding;  
 
    // 长度 最大长度:2^32
    uint32_t length;  
 
    // 用来存储数据的柔性数组  
    int8_t contents[];  
} intset;

Zset 类型
底层是由ziplist 或 skiplist 实现的,ziplist 上面介绍过了,下面介绍skiplist

zkiplist 跳表
跳表(Skiplist)是一个特殊的链表,相比一般的链表,有更高的查找效率,可比拟二叉查找树,平均期望的查找、插入、删除时间复杂度都是O(logn)。具有层次结构的链表,可支持范围查询

跳表的第一层是一个双向链表,其他层级的都是单向链表

它由很多层结构组成,每一层都是一个有序的链表,默认是升序,也可以根据创建映射时所提供的Comparator进行排序,具体取决于使用的构造方法
最底层(Level 1)的链表包含所有元素
如果一个元素出现在Level i 的链表中,则它在Level i 之下的链表也都会出现
每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素

// 跳表节点
typedef struct zskiplistNode {
    // 存放至该节点的数据
    robj *obj;
 
    // 数据的分数值,zset根据这个分数值进行升序排序
    double score;
 
    // 指向链表的上一个节点
    struct zskiplistNode *backward;
 
    // 跳表的层级,每一条数据代表着一层
    struct zskiplistLevel {
        // 指向下一个节点的指针
        struct zskiplistNode *forward;
 
        // 表示这个指针跳跃了多少个节点
        unsigned int span;
    } level[];
} zskiplistNode;
 
// 跳表
typedef struct zskiplist {
    // 头节点指针,尾节点指针
    struct zskiplistNode *header, *tail;
 
    // 跳表的长度,不包括头指针
    unsigned long length;
 
    // 跳表的层数
    int level;
} zskiplist;

到这里我们常用的redis的几种数据结构以及底层的实现原理也大致了解完了。

个人觉得配合图理解起来更为直观一些。

我本人在工作中也经常通过图来描述一些复杂的场景。有些场景文字描述比较难以理解,但是图片可以更形象,更直观的表达出来。

  • 17
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值