大纲:
- 简单动态字符串SDS
- 链表
- 字典
- 跳跃表
- 整数集合
- 压缩列表
阅读本文你将收货什么:
- 了解Redis底层的六种数据结构。
- 了解每种数据结构的实现方式以及设计上的优点。
Redis为什么这么快?
作为高速KV数据库,Redis的速度已经经过各大小公司的实战考验了,至于为什么这么快,各个理由从google上一搜大同小异,今天我们来聊一聊其底层实现的六大数据结构。
Redis的高效与其基本的数据结构也是密不可分的,为了满足效率和安全这些需求,Redis根据自身需要量身定制了数据结构。注:Redis基于这些数据结构创建了字符串对象,列表对象,哈希对象,集合对象和有序集合对象的对象系统,以此实现键值对数据库。
一.简单动态字符串(simple dynamic string,SDS)
SDS:每个sdshdr结构表示一个SDS值
struct sdshdr {
// 记录buf数组中已使用字节的数量
// 等于SDS所保存字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[ ];
};
- free属性值为0,表示这个SDS没有分配任何未使用空间。
- len属性值为5,表示这个SDS保存了一个五字节长的字符串。
- buf属性是一个char类型的数组,以’\0’结尾,不计算在len属性中。
优点:
- 以’\0’结尾可以直接使用C字符串函数库里的函数。
- 常数复杂度获取字符串长度O(1),只需要访问len属性即可。
- 杜绝缓冲区溢出,当SDS API需要对SDS进行修改时,会先检查SDS的空间是否满足需要,不满足则自动扩容,避免溢出。
- 减少修改字符串时带来的内存重分配次数。因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。
- 空间预分配
如果对SDS修改后,其长度小于1M将分配和len属性同样大小的未使用空间,这时候len属性与free属性值相同。
如果对SDS修改后其长度大于1M,那么程序会分配1M的未使用空间。 - 惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作,当SDS的API需要缩短其保存的字符串时,程序不立即使用内存重新分配来回收多出来的字节,而是使用free属性将其记录,留待以后使用。
- 二进制安全。所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,数据在写入时是什么样的,它被读取时就是什么样。
二.链表
listNode:每个链表节点用一个listNode结构来表示
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value
}listNode;
list:虽然多个listNode结构可以组成链表,但由list来持有链表操作方便许多
typedef struct list {
// 表头结点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *prt);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match) (void *ptr, void *key);
}list;
图2·由list结构和listNode结构组成的链表
特性总结:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度为O(1)
- 无环:表头节点的pre和表尾节点的next都指向NULL,对链表的访问以NULL为终点。
- 带表头和表尾指针:通过list结构的head指针和tail指针,获取链表头尾节点的复杂度为O(1).
- 带链表长度技术器,使用list结构的len属性来对list持有的链表节点计数,获取链表中节点数量的复杂度为O(1)。
- 多态:链表节点使用void* 指针来保存节点的值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定的函数,用来保存各种不同类型的值。
###三.字典
三.字典
哈希表:Redis字典所使用的哈希表由dictht结构定义
typedef struct dictht {
// 哈希表数组
dictRntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于size-1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
}dictht;
图3·一个空的哈希表
- table属性是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
- size属性记录了哈希表的大小,也即table数组的大小,而used属性记录了哈希表目前已有节点(键值对)的数量。
- sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。
哈希表节点:哈希表节点使用dictEntry结构表示,每个结构保存着一个键值对
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
}v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry;
图4·连在一起的键K1和键K0
- key属性保存着键值对中的键,而v属性保存着键值对中的值,值可以是一个指针,或uint64_t整数,或int64_t整数。
- next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,一次解决键冲突的问题。
字典:Redis中的字典由dict结构表示
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引
// 当rehash不在进行时,值为-1
int treashidx;
}dict;
- type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同类型的函数。
- privadata属性保存了需要传给那些类型特定函数的可选参数。
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare) (void *privdata, const void *key, const void *key2);
// 销毁键的函数
void (*keyDestructor) (void *privdata, void *key);
// 销毁值的函数
void (*valDestructor) (void *privdata, void *obj);
}dictType;
图五·普通状态下的字典
- 哈希算法:
当要将一个新的键值对添加到字典里时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组指定索引上面。 - 解决键冲突:
Redis的哈希表使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点用next指针构成一个单向链表。 - rehash:
1.为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量。
- 扩展:那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n;
- 收缩:那么ht[1]的的大小为第一个大于等于ht[0].used的2^n.
- 将保存在ht[0]中的所有键值对rehash到ht[1]上面。
- 当ht[0]包含的所有键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
- 渐进式rehsah:
1.为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
2.在字典中维持一个索引计数器变量rehashidx,并设置0,表示rehash工作开始。
3.在rehash期间,每次对字典进行增删改查外,顺带将ht[0]哈希表在rehashidx索引上所有键值对rehash到ht[1],当rehash完成后,将rehashidx增一。
4. 当rehash完成时,将rehashidx设置为-1,表示rehash操作完成。
!!!渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到每个增删改查操作上,避免集中式rehash带来的庞大计算量
渐进式rehash期间hash表操作:删、查、改操作先ht[0]后ht[1],新增直接在ht[1]上。
四.跳跃表
跳跃表节点:由zskiplistNode结构定义
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
}level[];
}zskiplistNode;
图六·带不同层高的节点
- 层:
跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层越多,访问其他节点的速度就越快。
2.前进指针:
每个层都有一个指向表尾方向的前进指针(level[i].forward),用于从表头向表尾方向访问节点。 - 跨度:
层的跨度(level[i].span)用于记录两个节点之间的距离。跨度与操作无关,只是用于记算排位:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到结果就是目标节点在跳跃表中的排位。 - 后退指针:
后退指针(backward)用于从表尾向表头方向访问节点,每次只能后退至前一个节点。 - 分值和成员:
- 节点的分值(score)是一个double类型的浮点数,跳跃表中所有节点都按分值从小到大排序。
- 节点的成员对象(obj)是一个指针,指向一个字符串对象,字符串对象则保存着一个SDS值。
- 在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值可以相同。
跳跃表
typedef struct zskiplist {
// 表头结点和表尾节点
struct skiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
}zskiplist;
图七·带有zskiplist结构的跳跃表
- header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。
- 通过length属性记录节点的数量,获取跳跃表长度的复杂度为o(1)。
- level属性用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量。
五.整数集合
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
}intse;
图八·一个包含五个int16_t类型整数值的整数集合
- contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值从小到大排列,不重复。
- length属性记录了整数集合包含的元素数量。
- contents数组真正类型取决于encoding属性的值。
升级
当添加新元素到整数集合中,新元素比整数集合现有元素都要长,则进行升级。
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有所有元素都转换成新元素的类型,并将类型转换后的元素放置到正确的位置上。
- 将新元素添加到底层数组里面。
升级的好处:
- 提升灵活性:C语言是静态类型语言,避免类型错误,通常不会将两种不同类型的值放在同一个数据结构中。
- 节约内存:让一个数组可以同时保存int16_t、int32_t、int64_t三种类型最简单的做法就是直接使用int64_t作为整数集合的底层实现。整数集合技能保存不同类型的整数,又可以确保升级操作只会在必要的时候进行,这可以尽量节约内存。
降级
整数集合不支持降级。
六.压缩列表
压缩列表的构成
压缩列表是Redis为了节约内存而开发的,有一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
图九·包含三个节点的压缩列表
- 列表zlbytes属性值为0x50(十进制80),表示压缩列表总长为80字节。
- 列表zltail属性值为0x3c(十进制60),这表示如果我们有一个指向压缩列表起始指针的P,只要P加上偏移量60就能计算出表尾节点entry3的地址。
- 列表zllen属性值为0x3(十进制3),表示压缩列表包含三个节点。
压缩列表节点的构成
图十·前一节点长度为5字节
- previous_entry_length
节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个字节的长度,其长度可为1或5:
- 如果前一节点长度小于254字节,那么previous_entry_length长度为一个字节,前一个节点的长度就保存在这个字节里。
- 如果前一个节点的长度大于254字节,那么previous_entry_length属性的长度为5字节,第一个字节被设为0xFE,之后四个字节用于保存前一节点的长度。
因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的其实地址计算出前一个节点的起始地址。
- encoding
节点encoding属性记录了节点的content属性所保存数据的类型以及长度。
字节数组编码:
编码 | 编码长度 | content属性保存的值 |
00bbbbbb | 1字节 | 长度小于等于63字节的字节数组 |
01bbbbbb xxxxxxxx | 2字节 | 长度小于等于16 383字节的字节数组 |
10_ _ _ _ _ _ aaaaaaa bbbbbbbb cccccccc | 5字节 | 长度小于等于4 294 967 295字节的字节数组 |
整数编码:
编码 | 编码长度 | content属性保存的值 |
11000000 | 1字节 | int16_t 类型的数组 |
11010000 | 1字节 | int32_t 类型的数组 |
11100000 | 1字节 | int64_t 类型的数组 |
11110000 | 1字节 | 24位有符号整数 |
11111110 | 1字节 | 8位有符号整数 |
1111xxxx | 1字节 | 无意义 |
- content
节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度有节点的encoding属性决定。
连锁更新
图十一·添加新节点到压缩列表
- 因为每个节点的previous_entry_length属性都记录了前一个节点的长度
- 假设在一个压缩列表中所有节点长度都小于254字节,当插入一长度大于254字节的新节点并设置为表头节点,那么他下一个节点e1的previous_entry_length只有1个字节,没法保存大于254字节的长度,需要扩展。
- e1更新后e2也需要扩展,扩展e2也会引发对e3的扩展,e4······直到每个节点previous_entry_length都符合压缩列表对节点的要求。
因为连锁更新在最坏的情况下需要对压缩列表执行N次空间重分配操作,每次空间分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)。
尽管连锁更新的复杂度较高,但真正造成性能问题的几率很低
总结:
以上就是Redis的六种底层数据的各种实现分析,总结于《Redis设计与实现》,用于自己速览,也希望能帮助到对于redis感兴趣的各位!