了解Redis数据结构

173 阅读7分钟

本文章基本是根据一本书《Redis设计与实现》——黄健宏,基于Redis2.9的一本书

书中有一章是名作【对象】,在书里的排版是第八章,我觉得这个对象的概念放在前面讲比较好。

Redis并不是直接使用数据结构来实现键值对数据库。而是用对象。对象中包含了指向底层实现数据结构的指针。我们创建的键都是字符串对象,而值对象可以是任意一种对象。这样的好处就是:针对不同的场景,对象可以用不同的数据结构(这些数据结构也是很多文章讨论的)实现,从而优化效率,看下面的思维导图。

总共有五种类型对象:

  1. 字符串对象
  2. 列表对象
  3. 哈希对象
  4. 集合对象
  5. 有序集合对象

TYPE key :用于查看该key对应的值是什么对象

image-20211007165128291.png

image-20211007165325881.png

image-20211008090236039.png

1.SDS(simple dynamic string)

struct sdshdr{
	int len;//表示保存的字符串长度
	int free;//表示buf数组中还未使用的字节数量
	char buf[];//存放数据的地方,这本书的作者把这个char数组叫做字节数组而不是字符数组,书中p16第6行,作者本人是这么解释的:Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据(没看到这个解释前,真的很疑惑)
}

SDS与C字符串的区别

书上有一小节专门写了他们两的区别,而且面试特别爱问

常数复杂度获取字符串长度

C语言的字符串以'\0'为字符串结束的标志,要获取C语言字符串的长度,需要遍历整个字符串,时间复杂度O(N)

Redis的SDS,直接访问len变量就可以获取长度了,时间复杂度O(1)

杜绝缓冲区溢出

C语言的字符串在拼接的时候可能会溢出缓冲区

Redis的SDS,在拼接前如果发现空间不够,会先扩展SDS空间,在拼接字符串(不过是用Redis提供的API,才有这些功能)

减少修改字符串时带来的内存重分配次数

C语言的字符串:

  1. 在对字符串进行拼接时,需要额外申请内存空间来存放拼接后的字符串(强行拼接就缓冲区溢出了)
  2. 在对字符串进行截断时,截断后面的字符串部分需要释放内存(不释放就内存泄漏了)

Redis的SDS(空间换时间,在有可能浪费空间的情况下,提升对字符串修改时,Redis的响应速度,对SDS的修改还是挺常见的):

  1. 空间预分配:SDS的API会申请额外的空间,方便在增长字符串时减少分配内存的次数
  2. 惰性空间释放:在对SDS保存的字符串截断时,只修改SDS结构体中的free和len变量就可以了,不需要释放内存

二进制安全

C语言的字符串,以'\0'作为字符串结束的标志,所以如果字符串里本身包含'\0',在这个字符后的数据就读取不到了,就是字符串内容不能有'\0'的意思

Redis的SDS,可以存放图片,音频等等,因为SDS保存的数据里可以包含'\0'这个字符,SDS用len来判断字符串是否结束,而不是'\0'

2.链表

先看看节点的代码,没有什么特别的,就是跟一般编程语言有的链表差不多。Redis列表的实现方式之一

typedef struct listNode{//双向链表
    struct listNode *prev;
    struct listNode *next;
    void *value;
}listNode;
typedef struct list{
    listNode *head;
    listNode *tail;
    unsigned long len;
    void *(*dup)(void *ptr);//复制链表节点所保存的值
    void (*free)(void *ptr);//释放链表节点锁保存的值
    int (*match)(void *ptr,void *key);//对比链表 节点所保存的值和另一个输入值是否相同
}

3.字典

typedef struct dict{
	dictht ht[2];//两个哈希表(应该是hashtable的缩写吧),因为就两个哈希表比较特别,所以没有写出其他变量
    
    //...还有一些其他的变量没写
}

一个字典包含两个哈希表(这个哈希表的结构跟Java的差不多,一个节点数组,然后用拉链法来解决冲突),一般只用ht[0],ht[1]在rehash的时候使用。将元素放进哈希表中也是跟Java类似,算出键的哈希值,在和节点数组大小-1进行按位与运算,算出下标放进去,有冲突就拉链法(头插法)解决。

ht[1]是扩容或者收缩的时候用的

扩容或者收缩大体步骤是:

  1. 为ht[1]哈希表分配空间,如果是扩容,ht[1]的大小就是第一个大于等于ht[0].used*2的2^n(ht[0].used,这是哈希表里带的变量,表示哈希表包含多少个节点),如果是收缩,ht[1]的大小就是第一个大于等于ht[0]used的2^n
  2. 将保存在ht[0]中的所有键值对重新计算哈希值和下标并放到ht[1]中。(这个过程并不是一次性全部执行完的,而是参杂在每次对字典的增删改查的时候,每次增删改查就偷偷从ht[0]搬运到ht[1]一部分节点,rehash的过程中,新增加的节点直接放到ht[1],查询的时候先查找ht[0],再查找ht[1])
  3. ht[0]和ht[1]交换,ht[1]释放空间

哈希表的扩展与收缩的时机

负载因子=ht[0].used/ht[0].size 哈希表已有节点数除哈希表大小

扩展的时机:

  1. 没有执行BGSAVE或BGREWRITEAOF指令时,且负载因子>=1
  2. 正在执行BGSAVE或BGREWRITEAOF指令时,且负载因子>=5

为什么执行这两个指令的时候要提高扩容的阈值的呢?执行这两条指令时,Redis会创建服务器进程的子进程,大多操作系统会采用写时复制(copy-on-write),这个时候发生如果写操作,会浪费内存空间

收缩的时机:

  1. 负载因子<=0.1

4.跳跃表

对于跳跃表,直接看书我是挺难吸收的,建议先写下力扣1206题,能有个初步的理解。我是参考此人的题解写的 leetcode-cn.com/problems/de…

看下跳跃表节点代码:

typedef struct zskiplistNode{//跳跃表节点
    struct szkiplistNod *backward;//后退指针,这个指针是用来方便逆序查询用的
    double score;//分值
    robj *obj;//成员对象
    struct zskiplistLevel{
        struct zskiplistNode *forward;
        unsigned int span;//span,这个变量用来计算对象的排名用的,经过节点的跨度加起来就是该节点在跳跃表中的排名
    }level[];
}zskiplistNode;

Redis为什么用跳跃表而不是红黑树?

这个面试题有时候也会碰到,书中p38页第5行,有这么一句话如下。

在大部分情况下,跳跃表的效率可以和平衡树匹配,并且因为跳跃表的实现比平衡树要来的更为简单,所以有不少程序都是用跳跃表来代替平衡树

还有一篇文章:zhangtielei.com/posts/blog-… 这篇文章的作者在文末说,Redis的作者亲口回答如下

There are a few reasons:

  1. They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.

  2. A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.

  3. They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

这段话原文出处:

news.ycombinator.com/item?id=117…

我没有理解错误的话,结合上面引用的文章和作者的话,作者的意思大体是:

  1. 一个红黑树节点需要两个指针来指向另外两个节点,而Redis跳跃表每个节点平均需要的指针需要1.33个(Redis生成层数1的节点概率是1/4^1,层数2的概率是1/4^2,以此类推,最多32层),所以节约内存。特别注意:跳跃表每个节点所包含的平均指针数量跟生成节点层数的概率有紧密的关系。
  2. 跳跃表的范围查询比红黑树范围查询简单。(跳跃表的范围查询找到节点后等同于是链表的遍历,红黑树要中序遍历)
  3. 跳跃表的代码更容易实现和调试

其实我很想问,那什么情况用红黑树,什么情况又用跳跃表合适呢,红黑树有啥优点,都在说跳跃表比红黑树好。

5.整数集合

typedef struct intset{
    uint32_t encoding;//contents数组的真正类型。如果encoding=INTSET_ENC_INT16,那么contents数组里每项都是int16_t类型的整数值。
    uint32_t length;
    int8_t contents[];//虽然声明是int8_t,但仍是靠encoding决定
}

升级:如果插入一个很大数字,当前数组的类型装不下,encoding的值就会改变,contens数组里每一项也会调整。

6.压缩列表

压缩列表是一块连续的内存。差不多长下面这个样子

image-20211007143347511.png

每个部分的解释

image-20211007145548310.png