Redis的底层数据结构

目录

 

引言

Redis底层数据结构

1.SDS(Simple Dynamic String)

2.链表(list)

3.字典(dict)

4.跳跃表(zskiplist)

5.整数集合(intset)

6.压缩列表(ziplist)

7.快表(quicklist)

小结


引言

Redis是目前非常火热的一个非关系型数据库,它内置的数据类型一共有5种,string,list,hash,set,zset。

最近读了《Redis设计与实现》这本书,感觉还挺好的,这次就结合书中所讲来说一下这几个数据类型的底层实现。

Redis底层数据结构

上面所说的5种结构都是我们可以直接接触到的,很常用的数据结构。对于这些数据结构来说,它底层还依赖着若干个底层数据结构,并且能够根据不同的使用场景切换底层实现,以达到最高的效率。这也是Redis之所以这么快的一个原因。

对于使用者来说,底层数据结构是透明的。对于Redis服务器来说,底层数据结构才是它真正关注的,不同的处理的底层结构标志着不同的处理方法。

1.SDS(Simple Dynamic String)

在C语言中表示字符串通常使用char数组,但是char数组有个缺点就是它的长度是固定的,这就导致修改char数组中保存的字符串时可能会发生一些低效率的操作(如加长字符串)。

Redis中使用sds.h/sdshdr这个结构体来表示SDS。

typedef struct sdshdr{
    //buf数组中已使用的字节数量,也就是字符串长度
    int len;
    //记录buf数组中未使用字节的数量。len + free = buf总长度 - 1 (最后一位是'\0')
    int free;
    //字节数组,用来保存字符串
    char buf[];
}sdshdr;

结构体的内容很简单,就是封装一个char数组,然后用两个变量来标识char数组的状态。

为什么使用SDS比直接使用char数组要好呢?

从根源上来说,SDS中也使用的是char数组,因此char数组的缺点依然存在。只是SDS中使用len和free这两个变量来动态标记char数组的已使用/未使用长度,这就能够让Redis在操作时不必以数组长度来作为判断变量。通俗来说就是SDS中有用来专门记录长度的变量,在C语言中获取字符串长度的时间复杂度是O(n),通过len和free去做记录,减少了获取字符串长度带来的开销(空间换时间)。buf的长度也不再是存储的字符串的长度了,可以有多余空间,这个特性使得buf中的字符串在长度修改能减少性能损耗,而且在字符串长度修改时能够通过len和free两个变量判断是否有充足空间,防止缓冲区溢出,带来了安全性。

SDS的动态伸缩策略

1.空间预分配

在SDS中存储的字符串发生变长操作,如果可使用的长度不足了,那么会触发扩容。在扩容时,修改后字符串的长度小于1m,那么buf的长度会变为1m长度,然后把多余的部分设置为“未使用”,并用free记录。如果修改后的长度大于1m,那么buf的长度会变为实际长度+1m,也用free标记未使用的空间。

空间预分配通过额外分配空间,减少了扩容的次数。比如说字符串“123”,先修改为“1234”,接着又修改为“12345”。预分配策略就能在字符串从123变为1234时额外分配1m的空间,这样第二次修改为12345时就无需再扩容了。用书中的一句话说就是SDS把N次修改,N次扩容的情况优化为N次修改,最多N次扩容。

2.惰性删除

SDS中存储的字符串发生缩短操作,即“12345”变为“123”。这时在SDS中的操作是把len变为3,free变为(free+2),但是buf数组是不会发生改变的,不会删除多余的空间。这就是惰性删除。

惰性删除首先能降低频繁修改数组带来的损耗,并且如果字符串后续又进行了增长操作,那么又可以去重复使用这部分空间,减少了二次增长时发生的可能性。

2.链表(list)

链表大家肯定都很熟悉了,这里就不再多解释,主要说一下Redis中的链表的一些特性。

Redis的链表是一个名为list的结构体

typedef struct list {
    //头指针
    listNode * head;
    //尾指针
    listNode * tail;
    //链表长度
    unsigned long len;
    
    ....一些操作链表的函数
}list;

链表节点也是一个结构体

typedef struct listNode {
    //前缀节点
    struct listNode * pre;
    //后缀节点
    struct listNode * next;
    //内容
    void * value;
}listNode;

首先它是一个双端链表,每个节点都pre和next两个指针,头节点和尾节点都有记录。

其次在Redis链表的struct中还包含一个len用来记录链表长度,这一块在查询链表长度时能把时间复杂度降到O(1)。并且节点中存储的值的指针是void*。这样就可以存储更多类型的数据。

3.字典(dict)

字典就是一个key/value映射表,就像常用的map那样。字典在Redis中应用的非常广泛,是一个具有普适性的底层数据结构。

字典的底层实现是一个hash表,hash表中有多个节点,每个节点存储一个键值对。

我感觉字典就是类似于Java中HashMap的实现。为了好理解,我把它分为三层。字典->hash表->hash表节点。依次来看看他们的实现吧。

字典

typedef struct dict {
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //hash表
    dictht ht[2];
    //rehash状态玛
    int rehashidx;
}dict;

其中dictType中定义了一组操作特定键值对的操作函数。privdata中则是存放了使用这些函数需要用到的参数。

主要是就是ht这个变量,它存储了两个hash表,为什么是两个呢?是在扩容的时候会用到。

最后rehashidx记录了rehash的进度,如果没有在进行rehash,那么rehashidx的值为-1。

hash表

typedef struct dictht {
    //hash表数组
    dicEntry **table;
    //hash表大小
    unsigned long size;
    //hash表掩吗,用于计算索引值,总是等于size-1
    unsigned long sizemask;
    //hash表中已有节点的数量
    unsigned long used;
}dictht;

hash表中有一个hash数组,并记录了表的大小和已有节点的大小。hash表掩码这个字段可以与hash值进行与运算,高效的计算出对象在hash表数组中的位置。

hash表节点

typedef struct dicEntry {
    //hash键
    void *key;
    //值
    union {
        void *val;
        uin64_tu64;
        ini64_ts64;
    }v;
    //下一个节点
    struct dicEntry *next;
}dicEntry;

hash表节点是类似于一个单链表节点,Redis用链表来处理hash冲突。节点的值的可选项非常丰富,可以是个一个指针,也可以是一个uint64_t整数,或者ini64_t整数。多样化的类型可以让Redis存储更多类型的数据。

hash表的扩容/收缩策略

当hash表的容量足够大且满足一些条件时,hash表会进行扩容操作。

只要满足以下条件即进行扩容:

1.服务器没有在执行RDB和AOF持久化操作且负载因子大于1。

2.服务器正在执行RDB和AOF持久化操作且负载因子大于5。

(ps:这里说的持久化操作都是非阻塞后台操作,持久化操作分为阻塞操作和非阻塞阻塞)

负载因子 = ht[0].used / ht[0].size。

例如hash表大小为4,实际存储了8个节点。那么负载因子就是8/2=4。

在上面提到了字典中存储了长度为2的hash表数组ht。一般情况下数据存储在ht[0]中。发生扩容时,就会发生两个hash表换位操作。

扩容操作分为以下几步:

1.计算扩容后的size。计算方法是找出比当前usedsize大的2的N次方的数,例如当前usedsize是10,那么扩容后的size就是2^4=16。

2.申请空间,并让ht[1]指向它。

3.把rehashidx置为0,标志rehash开始。

4.利用渐进性rehash把ht[0]的数据转移到ht[1]中,并且实时修改rehashidx标志位。

5.使ht[0]指向ht[1],释放原hash表的内存,设置rehashidx标志位为-1。标志rehash结束,整个扩容的过程也结束了。

(ps:渐进性rehash:把一次rehash操作拆分成多次操作,用rehashidx标志位记录当前进度,在渐进性rehash的过程中对该hash表的操作会在ht[0]和ht[1]两个表中进行。)

收缩操作也扩容操作大同小异,不同点在于它的触发条件只有一个:当负载因子小于0.1时进行收缩。不用考虑持久化操作是否正在执行。

4.跳跃表(zskiplist)

跳跃表相对于其他数据结构来说在平时用的较少。在我看来,跳跃表像带有多条链条的链表,一个节点可以链接多个节点,并且根据其中的分值进行顺序划分。

Redis中的跳跃表用的也不是很多,因为跳跃表支持分值排序,所以主要用在sortset类型结构的底层实现。跳跃表分为zskiplist和zskiplistNode两个结构头体表示。

typedef struct zskiplist {
    //头节点,尾节点
    struct zskiplistNode *header, *tail;
    //节点数量
    unsigned long length;
    //表中层数最大的节点的层数
    int level;
}zskiplist;

zskiplist中的变量和链表的很像,只有level是跳跃表中独有的,标志层数最大的节点的层数。

typedef struct zskiplistNode {
    //曾
    struct zskiplistlevel {
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned int span;
    } level[];
    //后退指针
    struct zskiplistNode *backward;
    //分值
    double score;
    //值
    robj *obj;
}zskiplistNode;

跳跃表节点中的level数组保存了能“跳跃”到其他节点的指针,就是因为这些跳跃指针,才让跳跃表的检索性能比一般链表强。后退指针很好理解,指向上一个节点。分值可以划分出节点的顺序,整个跳跃表的节点的排列顺序是根据分值排列的。

5.整数集合(intset)

整数集合是用来保存整数的集合。首先它只能保存整数,能根据数的大小动态升级存储整数的类型长度,它有三个档位,分别是int16_t、int32_t、ini64_t,分别代表16位,32位长度,64位长度的整数。其次它是有序的,按照整数的大小从小到大排序。然后整数集合内部存储的值是不允许重复值的。当在Redis的set和中存储少量的整数时,那么Redis就会选择整数集合作为数据结构。

typedef struct intset {
    //编码方式(存储单元长度)
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
}intset;

其中contents数组的类型是int8_t,其实这只是个名义上的类型,它的实际类型是根据ecoding来决定的。

整数集合的升级策略

假设在创建整数集合时向其中加入了几个很小的数字,那么整数集合的ecoding就是int16_t。接着向该集合中加入了一个很大的数字,16位长度是无法保存的,必须升级位32位才能存储,这时整数集合的contents数组就要发生升级操作了。

contents的类型会变为int32_t。每个存储单元都要变长。具体的操作流程分为以下几点。

1.根据新的类型计算出新的总长度。

2.申请长度,把新插入的数字放到新数组。

3.遍历原数组,依次放到新数组。

升级带来的好处是很多的,首先它能节省内存,在不必要时只申请很小的容量,根据内容动态升级。而且它能带来存储数据的灵活性,不论是16位,32位,64位长度的数据,都可以直接存到整数集合中,整数集合会自动根据存储的数的大小进行升级。

(ps:Redis不支持整数集合的降级)

6.压缩列表(ziplist)

压缩列表是为了用更小内存存储数据而产生的一种数据结构。它只是Redis申请的一段连续的内存空间。

其中包含以下的属性:

zlbytes:压缩列表总长度。

zltail:压缩列表尾节点的偏移量(压缩表的一个特征就是它的遍历方式,后面会简单说明)。

zlen:压缩列表包含的节点数。

zlend:标志压缩列表末尾,恒定0xFF。

entryX:存储的数据。

其中entryX中又包含多个部分:

previous_entry_length : 前一个节点的大小。

encoding:当前节点的编码方式。

content:节点值。

previous_entry_length和encoding的长度是根据实际情况去设定的,previous_entry_length最短是1个字节,最长是5个字节。encoding则允许是1,2,5个字节长度。这两个属性如果是非1字节长度时,那么首个字节就会变为标识位,剩下位用来表示值。

例如当previous_entry_length是1个字节大小时时表示前一个节点的大小。当它变为5个字节时,第一个自节点会变为0xFF,系统读到这里就明白后四个字节表示前一个节点的大小。这种设计在Redis中运用的非常多。

压缩列表的遍历

压缩列表只支持从后向前遍历,首先通过zltail和zlbytes找到最后一个节点,然后通过尾节点查到前一个节点的大小,然后指针偏移这个量就到了前一个节点的位置,这样依次遍历整个压缩列表。

连锁更新带来的问题

压缩独有的设计会带来一个连锁更新的问题。

由于压缩列表每个节点中保存着前一个节点的大小,而且这个值的长度可以是1个字节长,也可以5个字节长。这时如果有个操作把首节点的值进行了修改,使首节点的长度恰好从之前的1个字节表示变为5个字节才能表示,这时第二个节点的previous_entry_length的长度就会增长。恰好第二个节点的改动使得第三个节点也发生了改动,这样连锁更新节点会带来很大的复杂性。

不过也不用太过担心,因为压缩列表本身就是用来存储少量数据的,就算最坏情况发生,连锁更新了所有节点,那也不会带来很大的性能开销,所以放心使用吧。

7.快表(quicklist)

快表就是一个由压缩列表的形成的链表,并保存了每个压缩列表中节点的数量,这使得它检索的性能会比较好。

快表的底层结构也分为三层:快表,快表节点,节点中的压缩列表。

快表

typedef struct quicklist {
    //头结点
    quicklistNode *head; 
    //尾节点
    quicklistNode *tail; 
    //保存数据的总量,所有压缩列表中保存节点的总和
    unsigned long count;  
    //快表节点数量(压缩列表的数量)
    unsigned int len;
    //单个压缩列表最长存储节点时
    int fill;
    //压缩等级
    unsigned int compress;
} quicklist;

快表的结构体中包含了很多数字,count,len这两个数字能加快整个快表的检索速度,而且在检索数量时能以0(1)的时间复杂度返回数据。fill则是表示单个压缩列表能存储的最大节点,如果单个压缩列表中存储的节点达到这个值,那么新的数据在存储时会新创建一个压缩列表,compress表示压缩等级,压缩等级越高,越省内存,但是检索效率也越低。

快表节点

typedef struct quicklistNode {
    //上一个node节点
    struct quicklistNode *prev; 
    //下一个node
    struct quicklistNode *next; 
    //保存的数据 压缩前是压缩列表, 压缩后压缩的数据
    unsigned char *zl; 
    //压缩列表所占的字节数
    unsigned int sz; 
    //压缩列表中的节点数
    unsigned int count;
    //编码方式
    unsigned int encoding;
    //存储数据的容器 (2代表压缩列表)
    unsigned int container;  
    //此节点之前是否被压缩过
    unsigned int recompress;
    //节点是否不能被压缩
    unsigned int attempted_compress;
    //拓展预留的长度
    unsigned int extra; 
} quicklistNode;

快表节点大多属性都是在描述存储内容的容器(压缩列表)的信息的。

节点中的压缩列表

typedef struct quicklistLZF {
    //总长度
    unsigned int sz; 
    //压缩后的数据
    char compressed[];
} quicklistLZF;

之前说过快表节点中会封装一个压缩列表,压缩列表一般是一段连续的内存。但是如果想要节省内存,就可以把压缩列表压缩为更小的数据结构。上面的这个quicklistLZF就是保存着压缩后的压缩列表。在压缩列表节点中可以通过ecoding属性来切换使用普通压缩列表还是quicklistLZF。

快表的优势

把快表和链表相比以下,二者只是存储的内容不同罢了。链表存储的单个节点的数据,快表存储的是装着多个节点的压缩列表。

首先回忆一下压缩列表的优点,没有next指针,内存连续。快表使用了压缩列表作为数据节点的容器,比起链表能节省更多的内存。接着来说说压缩列表的缺点,内存一次性申请,扩容会重新申请内存,删除节点会造成内存碎片。但是快表顶层使用链式结构挂载多个压缩列表,如果节点超过阈值会创建一个新的压缩列表,杜绝了压缩列表的扩容。

这样优化的设计使得快表是个既省内存又高效的数据结构,在3.0版本后加入到Redis中。

小结

Redis的底层数据结构是Redis数据库的基石,Redis高效,快速的背后离不开这些优化的数据结构。而且在开发者使用普通的数据类型时Redis能动态根据使用情况切换底层数据结构,在不同的情况下都能使用到最优的配置,重要的是这一切对于开发者来说都是透明的,不得不佩服Redis的设计团队。

文章到这里就结束了,谢谢你能看到这里,文中很多地方讲的很粗略,我建议大家去看《Redis设计与实现》这本书,最好能结合网上的大神的博客一起学习,因为书中使用Redis版本是2.x,很多新特性并没有讲到(比如文中的快表)。

最后祝大家生活愉快:)

 

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值