Redis学习之旅 底层数组结构
有人会问,redis的数据结构不是string list set hash zset么?怎么又出来一个底层数据结构?
Redis自行构建了自己的VM,设计了五种底层的数据结构来实现上层的五种对外的数据结构
本质上,一切设计都是为了保证redis在执行命令时的快,从这个角度出发去理解Redis的底层数据结构会更容易一些
本质
- 加速!加速!加速!
字符串SDS(Simple Dynamic String)
- 先考虑个问题,如果在内存中直接使用字符数组(类似java)来保存上层String类型的数据,那么做STRLEN之类的命令,是否每次都要检查当前的字符串占用了多少个字符?
- 为了进一步加速,Redis在底层设计了sds来实现String(官网介绍),其他的复杂结构也是在String的基础上进一步进行实现的
//源码文件位置src/adlist.h
struct sdshdr {
//当前字符串的长度
long len;
//剩余的可用字符
long free;
//真正的字符串内容
char buf[];
};
- 从上面的结构定义可以看出来,这样的设计有几个好处
- 每次构造String或者扩容时是按照大于当前String长度的方式来保存的,可以避免因为字符串小幅度的修改引起频繁的创建对象与复制迁移对象
- 保存当前的length,可以让STRLEN命令从O(n)复杂度下降为O(1)
- 记录剩余的可用字符数量,可以用于在剩余不多的情形下做扩容操作
整数集合(IntSet)
- 整数集合,在数据量较少时用来实现set集合
typedef struct intset {
// 编码类型
uint32_t encoding;
// 长度 最大长度:2^32
uint32_t length;
// 内容
int8_t contents[];
} intset;
- 和后面的压缩表一样,它也是一种压缩内存的实现方式,整体的结构类似于下面这样,可以通过编码与偏移快速找到对应的值
链表(LinkedList)
- 链表又分为单向与双向链接,Redis这里使用的是双向链表,看一下它的定义
//源码文件位置src/adlist.h
typedef struct list{
//链表头节点
listNode *head;
//链表尾节点
listNode *tail;
//复制方法
void *(*dup)(void *ptr);
//释放方法
void (*free) (void *ptr);
//对比方法
int (*match) (void *ptr,void *key);
//链表当前节点数量
unsigned long len;
}list;
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode
- 由上述结构看出来,Redis里的链表不仅仅是一个双向链表,同时链表内还和SDS一样,缓存了当前链表的长度,保证长度查询方法复杂度为O(1),同时还持有链表头与链表尾的两个指针
字典(Dict HashTable)
- 首先我们知道,Redis是一个K-V存储中间件,因此即使是最普通的String类型,它也是有一个key和一个value的,查看Redis官网,你会发现 get set 命令的时间复杂度都是O(1),原因就是它的底层是使用的哈希表来实现的
//源码位置 src/dict.h
typedef struct dict {
//里面持有了一堆方法
dictType *type;
//私有的一些数据
void *privdata;
//两张哈希表
dictht ht[2];
// rehash的计数器,rehash 不在进行时,值为 -1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
//正在运行的迭代器数量
unsigned long iterators; /* number of iterators currently running */
} dict;
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;
//dict hash table
typedef struct dictht {
//持有的头节点
dictEntry **table;
//当前hashTable容量大小
unsigned long size;
//哈希表大小掩码,用于计算索引值 总是等于 size - 1
unsigned long sizemask;
//当前HashTable的节点数量
unsigned long used;
} dictht;
typedef struct dictEntry {
//键引用
void *key;
//值引用
union {
//字符引用地址
void *val;
//转换出的无符号数地址(如果可以转的话)
uint64_t u64;
//转换出的有符号数地址(如果可以转的话)
int64_t s64;
//转换出的浮点数地址(如果可以转的话)
double d;
} v;
//向下的指针
struct dictEntry *next;
} dictEntry;
- 从上面的结构体可以看出,它和Java 的hashMap(JDK1.7)设计的非常相近,都是使用数组下面挂单向链表的形式来做的
- 它的值不仅仅保存了字符引用,还将无符号、有符号、浮点数均做了计算并缓存,保存下次参与计算时,直接向对应的位取值就能直接计算,少了计算时的转换过程(为了加速真是无限使用空间换时间的思想)
- 它的rehash也被称为渐进式rehash,规避短时间大量的数据迁移影响业务处理速度(还是分而治之提速的思路),具体来讲有以下几点
- 创建hash结构时会创建两个hashTable(dictht ht[2]😉,一开始直接使用dictht[0],另一张哈希表不工作
- 当需要进行rehash时,给dictht[1]分配空间,此时两个哈希表同时工作,设置计数器rehashidx为0
- 每一次有请求来对某个key做CRUD时,命中了dictht[0]中的某个key的时候,就会将结果迁移到dictht[1]中,同时将rehashidx自增
- 就这么一步步的把原始dictht[0]中的数据全部迁移到dictht[1]中,当dictht[0]为空时,rehash结束 ,rehashidx设置为-1,下一次就是从1向0迁移,周而复始
跳表(SkipList)
跳表是一种为了解决链表只能挨个遍历,在数据量大遍历耗时的场景下设计出来的结构,本质上还是一个链表
- 可以看到,跳表的每一层都是一个链表,只是随机抽取了下层的一部分数据组装成了链表
- 链表必须得是有序的,否则无法进行比较与跳跃
- Redis源码内规定了Redis跳表最高32层,定义于ZSKIPLIST_MAXLEVEL变量
- 设计跳表的目的就是为了加速CRUD的速度,使用单链表,CRUD的平均时间复杂度为o(N/2),使用跳表后,平均时间复杂度为O(logN)
//源码位置 src/server.h
typedef struct zskiplistNode {
// sds类型的元素
sds ele;
//分值
double score;
//回溯指针,第一层可以组织为双向链表
struct zskiplistNode *backward;
struct zskiplistLevel {
//前向节点
struct zskiplistNode *forward;
//距离下一个节点的跨度
unsigned long span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
//头尾节点
struct zskiplistNode *header, *tail;
//长度
unsigned long length;
//层级
int level;
} zskiplist;
Redis作者也专门说过Redis的跳表与普通跳表相比有三处不同点:
- this implementation allows for repeated scores. 允许分值重复
- the comparison is not just by key (our ‘score’) but by satellite data. 对比的时候既比较分值还比较对象数据
- there is a back pointer, so it’s a doubly linked list with the back pointers being only at “level 1”. 存在回溯指针,第一层是有一个双向链表,可以进行回退
压缩表(ZipList)
前面我们看到了大量使用空间换时间的加速玩法 ,那么Redis是不是就从来没考虑过节约空间内存的事呢?答案其实是否定的,zipList就是为了节约内存而开发出的一种顺序型数据结构,常见于list和hash的键中
//源码位置 src/ziplist.c
typedef struct zlentry {
//前面一个节点的字节编码长度
unsigned int prevrawlensize; /* Bytes used to encode the previous entry len*/
//前面一个节点的长度
unsigned int prevrawlen; /* Previous entry len. */
//用来编码的字节数,像数字类型的一直只使用一个字节
unsigned int lensize; /* Bytes used to encode this entry type/len.
For example strings have a 1, 2 or 5 bytes
header. Integers always use a single byte.*/
//用来表示实际节点长度的字节
unsigned int len; /* Bytes used to represent the actual entry.
For strings this is just the string length
while for integers it is 1, 2, 3, 4, 8 or
0 (for 4 bit immediate) depending on the
number range. */
//头大小
unsigned int headersize; /* prevrawlensize + lensize. */
//编码格式
unsigned char encoding; /* Set to ZIP_STR_* or ZIP_INT_* depending on
the entry encoding. However for 4 bits
immediate integers this can assume a range
of values and must be range-checked. */
//指向节点起始位置的指针
unsigned char *p; /* Pointer to the very start of the entry, that
is, this points to prev-entry-len field. */
} zlentry;
光看这个可能看的一脸懵比,引用一下里的结构图,就大致能明白它是怎么设计的
- zlbytes (zip list bytes ):压缩表的长度(单位: 字节),是一个32位无符号整数
- zltail (zip list tail):压缩表最后一个Entry节点的偏移量,反向遍历ziplist或者pop尾部节点的时候有用
- zllen(zip list length):一共有多少个entry节点
- entry[]:节点数组,每个entry里是一个字符串或者整数
- zlend(zip list end):结束符,值为0xFF,用来标记这个ZIPLIST到此结束
由上可知,压缩表的存储较为紧凑,是通过一段连续的内存来实现数据压缩
通过头部的信息可以找到entry数组的开始与结束
通过entry内部记录的偏移量可以计算出前一个与下一个节点的偏移量,达到快速遍历的效果
快表(QuickList)
由ziplist组成的双向链表。一个quicklist可以有多个quicklist节点,它很像B树的存储方式。这是在redis3.2版本中新加的数据结构,用在列表的底层实现。
typedef struct quicklist {
//头节点
quicklistNode *head;
//尾巴节点
quicklistNode *tail;
//所有的压缩表中的节点数
unsigned long count; /* total count of all entries in all ziplists */
//节点个数
unsigned long len; /* number of quicklistNodes */
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
typedef struct quicklistNode {
//前置节点
struct quicklistNode *prev;
//后置节点
struct quicklistNode *next;
//ziplist引用
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;
快表是在压缩表的基础上构建出来的一种数据结构,大概的样子就是这个样子
总结
以上就是Redis底层的几种数据结构,下面归纳下,redis是如何构建上层的数据类型的
首先明确的是,redis是一个K-V存储,因此所有的K-V对都是都过dict hash table来实现的
下面总结的是value的实现
- String :SDS
- List: ZipList,LinkedList
- Hash : DictHashTable ,ZipList
- Set:IntSet,DictHashTable
- Sorted Set:ZipList,SkipList