深入分析Redis的数据结构

一、 Redis常用数据结构:

Redis的数据结构都是以一个唯一的key字符串作为数据结构的名称,而该key对应的value可以是不同的结构,并因此产生了不同的数据结构。

参考:结合老钱的Redis深度历险Redis设计与实现这两本书,并互作补充,因为后者是14年基于redis3.0的书,所以与当前的redis5.0会有一定的差异。

1.0 总览:

首先Redis是一个键值对数据库服务器并且负责维护一个数据库键空间(可以先近似理解为一个哈希表);

图片来自Redis设计与实现,其中定义了三个键值对:

K: "alphabet" V: 一个列表,存储着三个字符串-> "a", "b", "c"
K: "book" V: 一个哈希表,存储着三个映射关系-> "name -> R", "author -> J", "publisher -> M "
K: "message" V: 一个String对象,"hello wrold"

复制代码

并且无论是键还是值,其本质上都是一个对象,并通过redisObject对象头记录当前数据结构的信息

typedef struct redisObject{
    unsigned type:4; //redis对象的类型
    unsigned encoding:4;//编码
    unsigned lru:22;//记录了对象最后一次被命令程序访问的时间
    int refcount;//引用计数(借鉴操作系统中的引用计数法,当该值归零时,对象就会被回收)
    void *ptr;//指向底层数据结构的指针;
}
复制代码

举个例子:我们通过redis命令新建了一个String的数据结构:key:hello value: wrold

那在redis中会按照String短字符串的编码格式,并通过ptr指针进行指向

1.1 字符串:

1.1.1 字符串介绍:

即一个key对应一个字符串;

127.0.0.1:6379> SET msg "hello wrold" //msg为key,是字符串的名称,"hello wrold"是该字符串的值
OK
127.0.0.1:6379> GET msg
"hello wrold"
复制代码

1.1.2 实现原理:

在C语言中,字符串是以字节数组的形式存储在内存中,而redis是使用SDS(simple Dynamic String)的结构体存储字符串的信息:

struct SDS<T>{
    T alloc; //分配给数组的容量
    T len; //数组的实际长度
    byte flags;
    char buf[]; //数组内容
}
复制代码

alloc与len泛型变量T是因为Redis中为了适应不同大小的字符串,所需要的用来存储字符串的大小的变量值也不一样,比如字符串很小,那么一个unit8_t的变量便可以存储它的长度,大一点则需要使用unit16_t或unit32_t、unit64_t,这样可以省略一定的存储空间,可见redis对内存占用做了比较极致的优化;而这也暗示了字符串可分配的最大空间为unit64_t所能表示的的最大值:512M字节

(1)为什么还有alloc与len之分(与ArrayList有异曲同工之妙):

alloc表示分配给数组的长度,len表示已经使用的数组长度,这样将字符串大小与空间容量分开,就可以提供一定的冗余空间,便于之后字符串的动态扩展;但如果容量不够扩展则需要进行扩容,当字符串大小小于1M时,默认为扩容一倍(这个一倍是基于扩展后的字符串大小),否之则每次扩容1M

(2)为什么不复用原有的字节数组形式?而要使用SDS?

因为C语言中的字符串的局限性是字符串需要以/0结尾,并且中间不可以有这个字节,如果有,就会提前判断这个字符串结束了,所以诸如hello wrold这样的字符串,只会判断到hello便结束了,而SDS的API都是以处理二进制的方式进行处理的,所以是二进制安全的

又因为C语言中字符串是以\0结尾的,而想要获取字节数组的长度信息需要调用标准库函数strlen,而这个方法的时间复杂度为O(n),单线程的redis承受不起,所以每次需要获取字符串长度时,只需要访问SDSlen属性,而这个复杂度是O(1)

另外就是以\0结尾可以复用原有的C库函数。

1.1.3 Redis是如何维护字符串缓存的(Redis最核心的功能还是缓存)

最核心的点在于如何判断哪个字符串是热点数据,操作系统中定义了很多数据页置换算法Redis也借鉴并使用引用计数法,当引用个数为0时,该字符串就会被回收。引用计数值存储在字符串对象头中,每个Redis数据结构对象都会有一个对象头用于标识该数据结构对象的相应信息。

1.2 List(列表):

1.2.1 介绍:

即一个key对应一个列表

127.0.0.1:6379> rpush likes game code sport //列表名为likes,值为后三个字符串
(integer) 3
127.0.0.1:6379> rpop likes //右进右弹出 为栈
"sport" 
127.0.0.1:6379> lpop likes //右进左出列 为队列
"game"
127.0.0.1:6379>
复制代码

1.2.2 实现原理:

  1. 可以通过连接listNode从而形成一个双向链表(但无环),并通过一个名为list的数据结构去维护这个列表,并存储头尾节点、链表长度等信息(此处与AQS中维护阻塞队列的方法一致,都使用额外的空间去存储链表头节点与尾节点的信息

list的数据结构如下:

typedef struct list{
    listNode *head;
    listNode *tail;
    unsigned long len;
    ....
}list;
复制代码

最明显的好处是可以快速定位到链表的首尾节点,时间复杂度为O(1),如果是尾插,则插入的时间复杂度也为O(1)但链表查找的时间复杂度为O(n),这点没有改变

  1. 在早期版本中,Redis在内存占用这方面简直优化到极致,因为链表本身会带来很多空间碎片以及过多的指针,所以使用zipList代替listNode,即元素少时用ziplist,元素多时用linkedlist

zipList是一块连续的内存空间,即一个zipList中可以存储多个值,这样便可以节省传统的listNode带来的前后指针开销,以及可以更好的利用空间。减少空间碎片。

redis后期的更新中,使用quickList代替linkedList和ziplist,即无论什么情况,列表对象都是使用quicklist实现的

我们通过命令查看之前新建的likes列表的信息(即查看likes的对象头信息):

127.0.0.1:6379> debug object likes
Value at:00007FE145C6AEB0 refcount:1 encoding:quicklist serializedlength:19 lru:6400970 lru_seconds_idle:1511 ql_nodes:1 ql_avg_node:1.00 ql_ziplist_max:-2 ql_compressed:0 ql_uncompressed_size:17
复制代码

可以看到likes的编码方式为quicklist,并由ziplist组成

1.3 字典(hash):

1.3.1 介绍:

字典,又称为符号表或映射(map),即一个key对应map数据结构

127.0.0.1:6379> hmset course OOP java FP Kotlin //Key为course(可以理解为map数据结构的名称,后面为两个键值对)
OK
127.0.0.1:6379> hget course OOP //从名为course的map中取键OOP对应的值
"java"
复制代码

1.3.2 实现原理:

底层使用与hashMap一致的数据结构,即table + EntryList的方式,也是用拉链法解决冲突,不同的只有rehash的过程Redis中是使用渐进式rehash,即当前在rehash的过程中,仍然对外提供服务(原理与CopyOnWriteArrayList类似):

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2]; //此处创建了两个dictht,即两个哈希表(table + EntryList的结构)
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;
复制代码

两个哈希表在非扩容的时候只有ht[0]会对外提供使用,而ht[1]只会在对ht[0]rehash的时候提供使用,并且通过rehashidx这个值来标识当前有没有在rehash

具体过程为:首先渐进式rehash是相对于集中式rehash而言的,集中式即像HashMap一样直接全部rehash,而Redis改变了这一缺陷,将rehash的过程穿插在每一次对字典的操作上;

并且在此过程中:ht[0]与ht[1]两个哈希表同时对外提供服务,在每一次对该字典进行查改删操作时都会在两个哈希表上同时进行,而增加新的键值对时只会在新的哈希表上进行,从而达到数据的同步;

1.4 set(集合)

1.4.1 介绍:

即一个key对应一个set结构,与HashSet无异,放入的值具有唯一性,实现原理也是通过设置hash的值为NULL来实现的(与HashSet借助HashMap实现一致);

127.0.0.1:6379> sadd names xiaoming
(integer) 1
127.0.0.1:6379> sadd names xiaohong
(integer) 1
127.0.0.1:6379> sadd names xiaohong //重复添加,返回0表示已经存在
(integer) 0
复制代码

1.5 zset(有序列表)

1.5.1 介绍:

即一个key对应一个zset结构,该结构兼具SortedSet和HashMap的特点,一方面是一个Set,保证了数据的唯一性,另一方面可以对每个值赋予一个score值,用于代表排序的权重;

127.0.0.1:6379> zadd blogs 100 juejin //创建一个名为blogs的zset结构,并插入score值和名称
(integer) 1
127.0.0.1:6379> zadd blogs 99 CSDN
(integer) 1
127.0.0.1:6379> zadd blogs 98 coolshell
(integer) 1
127.0.0.1:6379> zrange blogs 0 -1 //将blogs中所有的键值对按照score值得大小升序排列
1) "coolshell"
2) "CSDN"
3) "juejin"

127.0.0.1:6379> zadd blogs 100 coolshell //加入重复的值时,会加入失败
(integer) 0
复制代码

1.5.2 实现原理:

底层是通过一种叫跳跃表(skiplist)的数据结构实现的,我们带着两个问题去分析它;

(1)为什么非要用复杂的跳跃表,普通的链表不可以吗?

  1. zset要支持随机查找,而链表随机查找的时间复杂度接近O(n),而跳跃表支持平均O(logN),最坏O(n)的节点查找,大部分情况下,跳跃表的查找效率可以与平衡树相媲美;
  2. 这个随机查找效率是如何实现的呢?(个人觉得有点类似于MySQL中分槽的机制) 图片取自极客时间。

原理是通过建立多级索引机制:

可以看到通过建立多级索引的机制,原本需要进行n次比较的操作,现在只需要寥寥几次,效率可见一般;

(2)那么Redis是如何构建所需的跳跃表结构的呢?

首先score值value值存放在一个名为zskiplistNode的数据结构中:

typedef struct zskiplistNode{
    struct zskiplistNode *backward; //后退指针
    doule score; //score值
    robj *obj; //成员对象,即我们存放的值
    //层次
    struct zskiplistLevel{
        struct zskiplistNode *forward; //前进指针
        unsigned int span; //跨度
    }level[];
} zskiplistNode;
复制代码

而这个数据结构还包括了zskiplistLevel这个结构体,用于建立需要的层级,实现图如下:

zskiplistLevel这个结构体中的前进指针指向了其对应Level的下一个Node节点,如上图中第二个节点的L4层的前进指针指向了第四个节点(即下一个拥有四个Level结构体的节点),并且跨度值为2。

Redis列表一样,也是为了降低时间复杂度,通过一个zskiplist数据结构去保存首尾节点,以及当前zset的相关状态信息;

(3)是如何确定节点的层级关系的呢?

通过幂次定律,(即越大的数出现的概率越小)来随机初始化层级,即层级与存储的value是没有关系的。

猜你喜欢

转载自juejin.im/post/5e60c652f265da572815d457