勿以浮沙筑高台
SDS
Redis是基于C语言实现的但是并没有使用C语言的传统字符来实现存储,而是针对String类型专门做了一个叫简单动态字符串(simple dynamic string,SDS)的抽象类型, 并将 SDS 用作 Redis的默认字符串(String)表示。
127.0.0.1:6379> set name context
OK
比如新建了一个key-value,这个操作其实将key和value分别包装了为2个不同的SDS。
127.0.0.1:6379> RPUSH name2 context1 context2 context3
(integer) 3
上面这个情况,一共包含了4个SDS,分别是name2 context1 context2 context3
那么包装为SDS有什么好处呢?为什么Reids要这样设计呢?就要从SDS的数据结构解析起
SDS的数据结构
struct sdshdr{
// 记录buf数组已使用字节数量
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
SDS数据结构由free剩余空间,len字节长度,buf字节数组组成。
SDS数据结构解析
1.为什么要有free字段。
- 作用1:作为数据库属性避免内存
在我们平时编码过程是不是很少对字符串进行添加修改操作,倘若真的需要一个字符串直接声明一个开辟一个新的内存空间,此后长时间不操作这个空间了。但是对于我们数据库来说,我们却要经常对数据进行修改追加。倘若这样的操作在内存当中势必造成不必要的QBS。因此在Redis中采用了空间换时间的做法,free代表冗余字段,在context写入时,会开辟相同空间的冗余free空间。
当context的数据内容小于1MB,则buf的字节长度为2len+1。
举例:新增context,则free存储为5,len为5,buf对应的空间节点长度为5+5+1。
当context的数据内容大于1MB,则buf的字节长度为len+1MB.length。
举例:新增一个context的30MB,则buf长度为30MB.length。
不必担心浪费内存问题,在SDS中提供了相应的API,当内存不够用时就会释放空间。
- 作用2:防止内存溢出
在原本的C语言当中,倘若2个相邻的内存空间C1和C2
如果开发者忘记了对C1的内存空间进行扩容则会发生C2的内存空间上存储的是C1的追加内容,比如追加-hello。这时候就造成了内存溢出的问题。在C语言中我们称之为野指针。
但是在Redis当中,会对追加的内容进行判断,倘若小于free字段就会进行存储,大于则会增加free进行存储。
1.为什么要有len字段。
-
优化时间复杂度
Reids为了优化查询效率将查询的时间复杂度从O(n)降为了O(1)。
举例:比如插入reids这个字段,因为R E I D S 这个5个字节会存在不同的内存分片当中,当我查询的长度的时候,就是O(5)的时间复杂度,但是如果我记录了len,那么时间复杂度就是O(1)。避免了查询时间和查询长度所用的时间一样,长期占用内存空间。 -
定义buff字节结束位置。
在传统C语言当中以空格’\0’来结束字符串的结束,,但是这样会对中间包含空格的数据产生误读,比如以下
这个数据结构读取出来就是Red,造成了误读。所以在C语言中被要求字符串当中不能包含空格。
而在redis当中len记录的长度和buff的起始位置加+len的偏移量和’\0’加以判断字符串的结尾。
还是上图,读出来的是"Red is"
1.为什么是buff字段。
- 二级制安全
虽然数据库一般用于保存文本数据, 但使用数据库来保存二进制数据的场景也不少见因此, 为了确保 Redis 可以适用于各种不同的使用场景, SDS 的 API 都是二进制安全的(binary-safe),这也就是为什么是buff字段的原因,因为每个字节都是存储的对应的二进制。这样保证了没有对字符串的过滤,包装。保证了安全性。
另外扩展了redis,对图片,音频,视频等存储的扩展。
小结SDS
1.二级制安全
2.优化时间复杂度
3.减少内存开销,用空间换取时间
List
基础准备:
ziplist,在我们传统的list内存分配是翻倍分配,比如存储的内存为10个,这时最加一个,变为了11个。但是List数组内存会分配为20个,这时候造成了9个内存空间的浪费。
ziplist就是按需分配,需要11个内存空间则分配11个空间。
List的本质就是一个双向链表在基础当中我已经提到过了,那么他是怎么实现的呢,我们看下图
链表和链表节点的实现
在图最左边竖着的是quickList是整个List的结构,存储了长度,头标签栈地址,尾标签栈地址和个数。
List的结构图
Copytypedef struct quicklist {
quicklistNode *head; /*指向头节点(左侧第一个节点)的指针。*/
quicklistNode *tail; /*指向尾节点(右侧第一个节点)的指针。*/
unsigned long count; /* 所有ziplist数据项的个数总和。 */
unsigned long len; /* 所有ziplist数据项的个数总和。 */
int fill : QL_FILL_BITS; /* : 16bit,ziplist大小设置 */
unsigned int compress : QL_COMP_BITS; /*16bit,节点压缩深度设置 */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
quicklistNode结构
Copytypedef struct quicklistNode {
struct quicklistNode *prev; //上一个node节点
struct quicklistNode *next; //下一个node
unsigned char *zl; //保存的数据 压缩前ziplist 压缩后压缩的数据
unsigned int sz; /* ziplist的存储字节大小 */
unsigned int count : 16; /* ziplist 存储字节长度*/
unsigned int encoding : 2; /*表示ziplist是否压缩了,1代表没有压缩 RAW==1 or LZF==2 */
unsigned int container : 2; /*存储类型 NONE==1 or ZIPLIST==2,是一个预留字段*/
unsigned int recompress : 1; /* 这个节点是否是ziplist */
unsigned int attempted_compress : 1; /* 节点是否能压缩 1代表不能,这个值只对Redis的自动化测试程序有用,我们不用管*/
unsigned int extra : 10; /*其它扩展字段。 */
} quicklistNode;
中间Node通过前尾地址进行标记。
注解:前面标有*的都是指向的内存地址不是实际的值。
多个 listNode 可以通过 prev 和 next 指针组成双端链表。
Redis 的链表实现的特性可以总结如下:
双端: 链表节点带有 prev 和 next 指针, 获取某个节点的前置节点和后置节点的复杂度都是O(1) 。
无环: 表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL , 对链表的访问以 NULL为终点。
带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针, 程序获取链表的表头节点和表尾节点的复杂度为 O(1) 。
带链表长度计数器: 程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为 O(1) 。
多态: 链表节点使用 void* 指针来保存节点值, 并且可以通过 list 结构的 dup 、 free 、match 三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。
常用操作
** 1.从头或者尾插入:**
当我们执行插入操作时,首先会通过quicklist去寻找node节点,找到node节点之后会有2个情况:
- 如果插入的数据没有超过ziplist数组长度,则直接插入ziplist当中。
- 当超过时,会新建一个新的Node节点,修改pre额next节点地址来进行保存
2.从中间插入
- 当插入位置所在的ziplist大小没有超过限制时,直接插入到ziplist中就好了。
- 当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小没有超过限制,那么就转而插入到相邻的那个quicklist链表节点的ziplist中。倘若context的内容空间不够,则插入到上一个nodelist中的尾当中,或者下一个node的头当中,只不过下一个需要移动内存。
- 当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小也超过限制,这时需要新创建一个quicklist链表节点插入
.
3.查找
直接根据Node节点存储的ziplist数组查找值。
4.删除
quicklist 在区间删除时,会先找到 start 所在的 quicklistNode,计算删除的元素是否小于要删除的count,如果不满足删除的个数,则会移动至下一个 quicklistNode 继续删除,依次循环直到删除个数和count相同为止。
5.ZIPList
压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
zlbytes:记录整个压缩列表占用的内存字节数:在对压缩列表进行内
存重分配, 或者计算 zlend 的位置时使用。
zltail
字段 | 解释 |
---|---|
zlbytes | Value记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。 |
zltail | 偏移量,用于获取内存地址的结束位置 |
zllen | 记录了压缩列表包含的节点数量: 当这个属性的值小于UINT16_MAX ( 65535 )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entryX | 保存的节点地址 |
zlend | 特殊符号,0xFF,标记节点的末尾 |
entryX节点的组成
字段 | 解释 |
---|---|
previous_entry_length | 以字节为单位, 记录了压缩列表中前一个节点的长度 |
encoding | 编码格式 |
content | 内容 |
遍历ziplist数组
因为记录了前一个节点的长度,因此从表尾向表头的遍历过程就是双指针维持空窗,在前面用了lend字段去标识最后一个节点,所以只要获得了最后一个content的地址减去前一个地址长度,就是当前内存地址,依次内推遍历所有content。
如图:
Hash
Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 而每个哈希表节点就保存了字典中的一个键值对。
字典
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
字段 | 解释 |
---|---|
type | 类型特定函数,为创建多态字典而设置的 |
privdata | 类型特定函数,为创建多态字典而设置的 |
ht | ht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用 |
Hash表结构
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
字段 | 解释 |
---|---|
table | 是一个哈希表数组,里面每个元素指向Hash表节点 |
size | table数组大小 |
sizemask | 哈希表大小掩码,用于计算索引值,总是等于 size - 1,因为是从0开始的 |
Hash表节点
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
字段 | 解释 |
---|---|
key | 属性保存着键值对中的键, |
next | 属性是指向另一个哈希表节点的指针(指向下一个内存地址) |
union | 值 |
这3者关系如图,即字典包含Hash表,表包含节点,节点包含键值对。
哈希算法
当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
解决键冲突
Hash算法,不同的key计算出来的地址也不一定完全不同,可能计算在了同一个key上,我们称之为键值冲突。
在Redis当中使用链地址法来解决键冲突:即每个Hash表节点上有个next节点,多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用
这个单向链表连接起来, 这就解决了键冲突的问题。
另外在Hash节点当中只有next属性记录了表头位置,为了效率考虑,因此插入的位置都链表的表头。
Hash表的刷新(rehash)
基础准备:负载因子
负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。
所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。
反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成浪费,但是此时索引效率高。
当随着对Hash表的操作,键值对越来越多或越来越少,为了让Hash的负载因子维持在一个合理的范围,需要对Hash进行收缩或者扩展操作。
1.首先需要进行计算:
- 扩展操作:扩展的ht[1]的大小为ht[0]的节点数量的2的次方,即ht[0].used*2
- 收缩操作:即ht[1].used=ht[0].used
2.将ht[0]的键值对重新计算Hash复制到ht[1]上
3.释放掉ht[0],将ht[1]变为变为ht[0],并新建ht[1]一个全新的表。
渐进式Hash表的刷新(rehash)
上面阐述了Hash表为了保证查询的效率和空间碎片的平衡,重新rehash了来,但是当数量过大,比如上千万的rehash,进行rehash操作就导致内存泄露或者数据库宕机问题。
因此真正的rehash过程是渐进式的,具体步骤如下:
- 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
- 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对rehash 到 ht[1] ,当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
- 随着rehash不断的推进,最终会在某个时间进行刷新完成。并将rehashidx标记为-1。
渐进式Hash表的刷新(rehash)时操作
在rehash期间进行操作时,删除,修改和查找操作都会在ht[0],ht[1]同时操作一遍,如果ht[0]没有查找到的值,会在ht[1]里再一次查找。
在rehash操作期间的新增操作都只会在ht[1]里进行,这样保证了ht[0]的值只减不增。
Set
Hashtable
如图所示,Set的本质其实就是一个Hashtable,是String类型的无需集合,基于Hashtable实现,时间复杂度为O(1)
ZSet
跳跃表
跳跃表数据结构如上图
zskiplist
字段 | 解释 |
---|---|
header | 指向跳跃表的表头节点。 |
tail | 指向跳跃表的表尾节点 |
level | 记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内) |
length | 记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。 |
在zskiplist右边的是节点zskiplistNode,该结构属性包含如下:
跳跃表节点对象
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
字段 | 解释 |
---|---|
Level | 向图所指,标记层级,每个层级包含二个属性,分别是前进指针和跨度,前进指针用于下一个内容读取的地址,跨度代表这下一个内容读取地址到现在的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。 |
backward(后退指针BW) | 和前进指针相反,指向前一个内存地址 |
score | 分值,在跳跃表当中,分值从小到大进行保存 |
obj | 成员对象,存储的value |
层(level):
数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。每次就创建一个新的跳跃表的时候,程序会根据**幂次定律**生成一个1到32之间的数字,这个数字就代表了层的高度。
如图
为什么要有层级
拥有层级是为了快速查找,二分法查找。
只不过在这里层级变成了我们的数组下标,
- 先从最高层级开始寻找层级中间开始寻找。
- 如果不在上层级,则在下层级。下层级则将toplevel=midlevel
- 然后又从0-toplevel这个层级中分割。看需要寻找的数值大于还是小于这个区间,
- 直到想要找的数等于当前层级对应的数值。
注意,这里的层高又算法生成,不是图上所示中间层高,自寻查询幂次定律
跨度
层的跨度,用于记录两个节点之间的距离:
- 两个节点之间的跨度越大, 它们相距得就越远。
- 指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点。
用于计算在节点的排位位,比如
- 从节点A到到节点B的距离为2,节点B到C的距离为3,则该数据存储在此跳跃表中的排位为5。
有了排位就可以对数据进行排序。
backward
后退指针,每个层级数组之间的只有一个后退指针,用于从表尾向表头查找,实际用处,二分查找数据。
score和obj
score是一个double的浮点数据类型,决定了数据存储哪一个节点数组,从小到大排列数据。obj是一个指针,指向字符串,而字符串对象里则有一个SDS的值。
在一个跳跃表对象当中,成员对象必须是唯一的,但是分值却是可以相同的。
当分值相同的时候,会根据成员对象的长度决定所处的字节数组。
比如,当三个对象的score都是11时,那么当对象length小的会排在靠近表头的字节数组里,length大的则靠近表尾。
注意:
- 层高不代表Node的个数,层高1-32,不代表只有32个节点。节点层高可以相同,是由算法生成出来了的,层高越大,Node数量越少,层高越小,Node数量越多。
- 层高里不存存储任何数据。obj里才有数据
- 层高不代表层级。
最后,以下图路径讲解跳跃表流程。就从第一个节点的L4开始举例。细小红线
- header-找到第一个节点L4
- 根据前进指针指针找到第二个Node节点的L4
- 到达L4后,当前指针对应的层高不属于1层级,则往下寻找,在2层高找到L1的层级。
- 找到Node3节点,继续找到Node4节点
- 最后找到NULL结束。
跳跃表的gif动图讲解。
参考文章:
Redis 底层中的 SDS 以及 对象系统简介
Redis源码剖析和注释(七)— 快速列表(quicklist)
Redis底层数据结构之Hash
Redis Zset类型跳跃表算法实现
跳跃表详解