Redis做为一个不少大厂用来解决并发和快速响应的利器,极高的性能让它获得不少公司的青睐,我认为Redis的高性能和其底层的数据结构的设计和实现是分不开的。使用过Redis的同窗可能都知道Redis有五种基本的数据类型:string、list、hash、set、zset;这些只是Redis服务对于客户端提供的第一层面的数据结构。其实内部的数据结构仍是有第二个层面的实现,Redis利用第二个层面的一种或多种数据类型来实现了第一层面的数据类型。我想有和我同样对Redis底层数据结构感兴趣的人,那咱们就一块儿来研究一下Redis高性能的背后的实现底层数据结构的设计和实现。html
这里咱们主要研究的是第二层面的数据结构的实现,其Redis中五种基本的数据类型都是经过如下数据结构实现的,咱们接下来一个一个来看:node
String类型无论是在什么编程语言中都是最多见和经常使用的数据类型,Redis底层是使用C语言编写的,可是Redis没有使用C语言字符串类型,而是自定义了一个Simple Dynamic String (简称SDS)做为Redis底层String的实现,其SDS相比于C语言的字符串有如下优点:git
下面是Redis中SDS的部分源码。sds源码github
typedef char *sds;
/* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */ struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; 复制代码
其实咱们能够经过源码发现sds
的内部组成,咱们发现sds被定义成了char类型,难道Redis的String类型底层就是char吗?其实sds为了和传统的C语言字符串保持类型兼容,因此它们的类型定义是同样的,都是char *,可是sds不等同是char。web
真正存储数据的是在sdshdr
中的buf
中,这个数据结构除了能存储字符串之外,还能够存储像图片,视频等二进制数据,。SDS为了兼容C语言的字符串,遵循了C语言字符串以空字符结尾的惯例, 因此在buf
中, 用户数据后总跟着一个\0
. 即图中 "数据" + "\0" 是为所谓的buf
。另外注意sdshdr有五种类型,其实sdshdr5是不使用的,其实使用的也就四种。定义这么多的类型头是为了能让不一样长度的字符串可使用不一样大小的header。这样短字符串就能使用较小的 header,从而节省内存。redis
SDS概览以下图:算法
除了sdshdr5以外,其它4个header的结构都包含3个字段数据库
\0
结束符在内)。
\0
字节)。
Redis对外暴露的是list
数据类型,它底层实现所依赖的内部数据结构其实有几种,在Redis3.2版本以前,链表的底层实现是linkedList
和zipList
,可是在版本3.2以后 linkedList
和zipList
就基本上被弃用了,使用quickList
来做为链表的底层实现,ziplist虽然被被quicklist替代,可是ziplist仍然是hash和zset底层实现之一。编程
这里咱们使用Redis2.8版本能够看出来,当我插入键 k5 中 110条比较短的数据时候,列表是ziplist编码,当我再往里面插入10000条数据的时候,k5的数据编码就变成了linkedlist。api
Redis3.2版本以前,list
底层默认使用的zipList
做为列表底层默认数据结,在必定的条件下,zipList
会转成 linkedList
。Redis之因此这样设计,由于双向链表占用的内存比压缩列表要多, 因此当建立新的列表键时, 列表会优先考虑使用压缩列表, 而且在有须要的时候, 才从压缩列表实现转换到双向链表实现。在什么状况下zipList
会转成 linkedList
,须要知足一下两个任意条件:
这两个条件是能够修改的,在 redis.conf 中:
list-max-ziplist-value 64
list-max-ziplist-entries 512
复制代码
注意:这里列表list的这个配置,只有在Redis3.2版本以前的配置中才能找到,由于Redis3.2和3.2之后的版本去掉了这个配置,由于底层实现不在使用ziplist,而是采用quicklist来做为默认的实现。
当链表entry数据超过5十二、或单个value 长度超过64,底层就会将zipList转化成linkedlist编码,linkedlist是标准的双向链表,Node节点包含prev和next指针,能够进行双向遍历;还保存了 head 和 tail 两个指针。所以,对链表的表头和表尾进行插入的时间复杂度都为O (1) , 这是也是高效实现 LPUSH 、 RPOP、 RPOPLPUSH 等命令的关键。
虽然Redis3.2版本之后再也不直接使用ziplist来实现列表建,可是底层仍是间接的利用了ziplist来实现的。
压缩列表是Redis为了节省内存而开发的,Redis官方对于ziplist的定义是(出自Redis源码中src/ziplist.c注释):
The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values,where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time. However, because every operation requires a reallocation of the memory used by the ziplist, the actual complexity is related to the amount of memory used by the ziplist
翻译:ziplist是一个通过特殊编码的双向链表,它的设计目标就是为了提升存储效率。ziplist能够用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。它能以O(1)的时间复杂度在表的两端提供
push
和pop
操做。
ziplist 将列表中每一项存放在先后连续的地址空间内,每一项因占用的空间不一样,而采用变长编码。当元素个数较少时,Redis 用 ziplist 来存储数据,当元素个数超过某个值时,链表键中会把 ziplist 转化为 linkedlist,字典键中会把 ziplist 转化为 hashtable。因为内存是连续分配的,因此遍历速度很快。
ziplist 是一个特殊的双向链表,ziplist没有维护双向指针:prev next;而是存储上一个 entry的长度和 当前entry的长度,经过长度推算下一个元素在什么地方,牺牲读取的性能,得到高效的存储空间,这是典型的"时间换空间"。
ziplist使用连续的内存块,每个节点(entry)都是连续存储的;ziplist 存储分布以下:
每一个字段表明的含义。
zlbytes
: 32bit,表示ziplist占用的字节总数(也包括
zlbytes
自己占用的4个字节)。
zltail
: 32bit,表示ziplist表中最后一项(entry)在ziplist中的偏移字节数。
zltail
的存在,使得咱们能够很方便地找到最后一项(不用遍历整个ziplist),从而能够在ziplist尾端快速地执行push或pop操做。
zllen
: 16bit, 表示ziplist中数据项(entry)的个数。zllen字段由于只有16bit,因此能够表达的最大值为2^16-1。这里须要特别注意的是,若是ziplist中数据项个数超过了16bit能表达的最大值,ziplist仍然能够来表示。那怎么表示呢?这里作了这样的规定:若是
zllen
小于等于2^16-2(也就是不等于2^16-1),那么
zllen
就表示ziplist中数据项的个数;不然,也就是
zllen
等于16bit全为1的状况,那么
zllen
就不表示数据项个数了,这时候要想知道ziplist中数据项总数,那么必须对ziplist从头至尾遍历各个数据项,才能计数出来。
entry
: 表示真正存放数据的数据项,长度不定。一个数据项(entry)也有它本身的内部结构。
zlend
: ziplist最后1个字节,是一个结束标记,值固定等于255。
ziplist每个存储节点、都是一个 zlentry。zlentry的源码在ziplist.c 第 268行
/* We use this function to receive information about a ziplist entry. * Note that this is not how the data is actually encoded, is just what we * get filled by a function in order to operate more easily. */ typedef struct zlentry { unsigned int prevrawlensize; /* prevrawlensize是指prevrawlen的大小,有1字节和5字节两种*/ unsigned int prevrawlen; /* 前一个节点的长度 */ unsigned int lensize; /* lensize为编码len所需的字节大小*/ unsigned int len; /* len为当前节点长度*/ unsigned int headersize; /* 当前节点的header大小 */ unsigned char encoding; /*节点的编码方式:ZIP_STR_* or ZIP_INT_* */ unsigned char *p; /* 指向节点的指针 */ } zlentry; 复制代码
由于ziplist采用了一段连续的内存来存储数据,减小了内存碎片和指针的内存占用。其次表中每一项存放在先后连续的地址空间内,每一项因占用的空间不一样,而采用变长编码,并且当节点较少时,ziplist更容易被加载到CPU缓存中。这也是ziplist能够作到压缩内存的缘由。
经过上面咱们已经清楚的了解的ziplist的数据结构,在ziplist中每一个zlentry都存储着前一个节点所占的字节数,而这个数值又是变长的,这样的数据结构可能会引发ziplist的连锁更新
。假设咱们有一个压缩链表 entry1 entry2 entry3 .......,entry1的长度正好是 253个字节,那么按照咱们上面所说的,entry2.prevrawlen 记录了entry1的长度,使用1个字节来保存entry1的大小,假如如今在entry1 和 entry2之间插入了一个新的 new_entry节点,而new_entry的大小正好是254,那此时entry2.prevrawlen就须要扩充为5字节;若是entry2的总体长度变化又引发了entry3.prevrawlen的存储长度变化,如此连锁的更新直到尾结点或者某一个节点的prevrawlen足以存放以前节点的长度,固然删除节点也是一样的道理,只要咱们的操做的节点以后的prevrawlen发生了改变就会出现这种连锁更新。
因为ziplist连锁更新的问题,也使得ziplist的优缺点极其明显;ziplist被设计出来的目的是节省内存,这种结构并不擅长作修改操做。一旦数据发生改动,就会引起内存从新分配,可能致使内存拷贝。也使得后续Redis采起折中,利用quicklist替换了ziplist。
基于上面所说,咱们已经知道了ziplist的缺陷,因此在Redis3.2版本之后,列表的底层默认实现就使用了quicklist来代替ziplist和linkedlist?接下来咱们就看一下quicklist的数据结构是什么样的,为何使用quicklist做为Redis列表的底层实现,它的优点相比于ziplist优点在哪里,接下来咱们就一块儿来看一下quicklist的具体实现。下面是我基于Redis3.2的版本作的操做,这里咱们能够看到列表的底层默认的实现是quicklist
对象编码。
quicklist总体的数据结构以下:
quicklist源码 redis/src/quicklist.h结构定义以下:
typedef struct quicklist {
quicklistNode *head; // 头结点 quicklistNode *tail; // 尾结点 unsigned long count; // 全部ziplist数据项的个数总和 unsigned long len; //quicklistNode的节点个数 int fill : QL_FILL_BITS; //ziplist大小设置,经过配置文件中list-max-ziplist-size参数设置的值。 unsigned int compress : QL_COMP_BITS; //节点压缩深度设置,经过配置文件list-compress-depth参数设置的值。 unsigned int bookmark_count: QL_BM_BITS; quicklistBookmark bookmarks[]; } quicklist; 复制代码
其实就算使用的quicklist结构来代替ziplist,那quicklist也是有必定的缺点,底层仍然使用了ziplist,这样一样会有一个问题,由于ziplist是一个连续的内存地址,若是ziplist过小,就会产生不少小的磁盘碎片,从而下降存储效率,若是ziplist很大,那分配连续的大块内存空间的难度也就越大,也会下降存储的效率。如何平衡ziplist的大小呢?那这样就会取决于使用的场景,Redis提供了一个配置参数list-max-ziplist-size
能够调整ziplist的大小。
当取正值的时候,表示按照数据项个数来限定每一个quicklist节点上的ziplist长度。好比,当这个参数配置成5的时候,表示每一个quicklist节点的ziplist最多包含5个数据项。当取负值的时候,表示按照占用字节数来限定每一个quicklist节点上的ziplist长度。这时,它只能取-1到-5这五个值,每一个值含义以下:
当咱们数据量很大的时候,最方便访问的数据基本上就是队列头和队尾的数据(时间复杂度为O(1)),中间的数据被访问的频率比较低(访问性能也比较低,时间复杂度是O(N),若是你的使用场景符合这个特色,Redis为了压缩内存的使用,提供了list-compress-depth
这个配置可以把中间的数据节点进行压缩。quicklist内部节点的压缩算法,采用的LZF——一种无损压缩算法。
这个参数表示一个quicklist两端不被压缩的节点个数。参数list-compress-depth
的取值含义以下:
quicklist是由一个个quicklistNode的双向链表构成。
typedef struct quicklistNode {
struct quicklistNode *prev; struct quicklistNode *next; unsigned char *zl; 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 quicklistLZF { unsigned int sz; char compressed[]; } quicklistLZF; 复制代码
quicklist中的每一个节点都是一个quicklistNode,其中各个字段的含义以下:
quicklist结构结合ziplist和linkedlist的优势,quicklist权衡了时间和空间的消耗,很大程度的优化了性能,quicklist由于队头和队尾操做的时间复杂度都是O(1),因此Redis的列表也能够被做用队列来使用。
经过上图咱们可以看到hash键的底层默认实现的数据结构是ziplist,随着hash键的数量变大时,数据结构就变成了hashtable,虽然这里的咱们看到的对象编码格式hashtable,可是Redis底层是使用字典dict来完成了Hash键的底层数据结构,不过字典dict的底层实现是使用哈希表来实现的。Redis服务对于客户端来讲,对外暴露的类型是hash,其底层的数据结构实现有两种,一种是压缩列表(ziplist),另一种则是字典(dict);关于ziplist的,咱们在说链表(list)的时候已经说过了,这里不重复去说了。咱们这里就着重的去看一下字典(dict)的具体实现。
这里仍是要说一下什么状况下会从ziplist
转成hashtable
呢?redis.conf中提供了两个参数
hash-max-ziplist-entries 512
hash-max-ziplist-value 64 复制代码
字典算是Redis比较重要的一个数据结构了,Redis数据库自己就能够当作是一个大的字典,Redis之因此会有很高的查询效率,其实和Redis底层使用的数据类型是有关系的,一般字典的实现会用哈希表做为底层的存储,redis的字典实现也是基于时间复杂度为O(1)的hash算法。
Redis源码其结构定义以下:dict源码定义
typedef struct dictEntry {
void *key; union { void *val; uint64_t u64; int64_t s64; double d; } v; struct dictEntry *next; } dictEntry; 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; /* This is our hash table structure. Every dictionary has two of this as we * implement incremental rehashing, for the old to the new table. */ typedef struct dictht { dictEntry **table; unsigned long size; unsigned long sizemask; unsigned long used; } dictht; typedef struct dict { dictType *type; void *privdata; dictht ht[2]; long rehashidx; /* rehashing not in progress if rehashidx == -1 */ unsigned long iterators; /* number of iterators currently running */ } dict; 复制代码
下图能更清晰的展现dict的数据结构。
经过上图和源码咱们能够很清晰的看到,一个字典dict的构成由下面几项构成:
这里最重要的仍是dictht这个结构,dictht定义了一个哈希表,其结构由如下组成:
总体看下来,有点相似于Java中HashMap的实现,在处理哈希冲突和数组的大小都是和Java中的HashMap是同样的,可是这里有一点不同就是关于扩容的机制,Redis这里利用了两个哈希表,另一个哈希表就是扩容用的。Redis中的字典和Java中的HashMap同样,为了保证随着数据量增大致使查询的效率问题,要适当的调整数组的大小,也就是rehash,也就是咱们熟知扩容。咱们这里不说Java中的HashMap的扩容了,这里主要看一下Redis中对于字典的扩容。
那么何时才会rehash呢?条件: 1. 服务器目前没有执行的BGSAVE命令或者BGREWRUTEAOF命令,而且哈希表的负载因子大于等于1; 2. 服务器目前正在执行BGSAVE命令或者BGREWRUTEAOF命令,而且哈希表的负载因子大于等于5;
那究竟是如何进行rehash的,根据上面源码和数据结构图能够看到,字典中定义一个大小为2的哈希表数组,前面咱们也说到了,在不进行扩容的时候,全部的数据都是存储在第一个哈希表中,只有在进行扩容的时候才会用到第二个哈希表。当须要进行rehash的时候,将dictht[1]的哈希表大小设置为须要扩容以后的大小,而后将dictht[0]中的全部数据从新rehash到dictht[1]中;并且Redis为了保证在数据量很大的状况rehash不太过消耗服务器性能,其采用了渐进式rehash,当数据量很小的时候咱们一次性的将数据从新rehash到扩容以后的哈希表中,对Redis服务的性能是能够忽略不计的,可是当Redis中hash键的数量很大,几十万甚至上百万的数据时,这样rehash对Redis带来的影响是巨大的,甚至会致使一段时间内Redis中止服务,这是不能接受的。
Redis服务在须要rehash的时候,不是一次性将dictht[0]中的数据所有rehash到dictht[1]中,而是分批进行依次将数据从新rehash到dictht[1]的哈希表中。这就是采用了分治的思想,就算在数据量很大的时候也能避免集中式rehash带来的巨大计算量。当进行rehash的期间,对字典的增删改查都会操做两个哈希表,由于在进行rehahs的时候,两个哈希表都有说句,当咱们在一个哈希表中查找不到数据的时候,也会去另外一个哈希表查数据。在rehash期间的新增,不会在第一个哈希表中新增,会直接把新增的数据保存到第二个哈希表中这样能够确保第一个哈希表中的数据只减不增,直到数据为空结束rehash。
熟悉Java的同窗可能会想起HashMap中扩容算法,其实包括从容量的设计上和内部的结构都有不少类似的地方,有兴趣的同窗能够去了解一下,也能够参考我写的这篇文章《Java1.8中HashMap的骚操做》,相比于Redis中的字典的rehash的方式,我更喜欢的是Java中对于HashMap中精妙的rehahs的方式,其思想仍是很是值得咱们去借鉴的。