目录
引言
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,很多新特性并没有讲到(比如文中的快表)。
最后祝大家生活愉快:)