Redis 源码学习笔记
前言
查看编码命令:OBJECT ENCODING key
判断对象类型:type key
五大数据类型实现原理
String 实现原理
推荐书籍: Redis 设计与实现
推荐博客:https://www.cnblogs.com/ysocean/p/9102811.html#_label0
字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,其它几种数据类型构成的元素也是字符串。注意字符串的长度不能超过512M。
为什么字符串长度不能超过 512M?
// 源码定义(检查字符串长度)
static int checkStringLength(redisClient *c, long long size) {
if (size > 512*1024*1024) {
addReplyError(c,"string exceeds maximum allowed size (512MB)");
return REDIS_ERR;
}
return REDIS_OK;
}
三种编码
- int 编码:保存的是可以用 long 类型表示的整数值。
- raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)
- embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)
127.0.0.1:6379> set str1 121
OK
127.0.0.1:6379> set str2 qweqweqwe
OK
127.0.0.1:6379> set str3 qweqweqweqweqweqweqweqdhjajdskasjhdiqweuyaasddsa
OK
127.0.0.1:6379> object encoding str1
"int"
127.0.0.1:6379> object encoding str2
"embstr"
127.0.0.1:6379> object encoding str3
"raw"
区别:
embstr 使用只分配一次内存空间(因此redisObject和sds是连续的),raw 需要分配两次内存空间(分别为redisObject和sds分配空间)
因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。
编码的转换
当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。
对于 embstr 编码,由于 Redis 没有对其编写任何的修改程序(embstr 是只读的),在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。
SDS 定义
struct sdshdr{
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
为什么要有 len ?
- 复杂度:在 C 中获取一个字符串的长度复杂度为 O(N),定义 len 后复杂度为 O(1);
- 二进制安全:用 len 判断字符串结束,可以保存除文本数据以外的图片、音频等数据
在 C 中使用 ‘\0’ 来标志一个字符串的结束,不能保存空字符否则会被认为是字符串的结尾。如果有一个以空字符来分割单词的特殊特殊数据,则 C 只能读取第一个单词。
而 Redis 用 buf[] 存储二进制数据,以 len 来判断结束,不会出现以上问题
为什么要有 free ?
- 空间预分配
在 C 中分配空间是一个比较耗时的工作,但 Redis 作为缓存数据库,数据的频繁修改是常态,而每次增长字符串都需要执行一次内存重分配(C 语言字符串处理特性),对性能产生很大影响。
- 修改后 SDS 长度小于 1MB
- 分配和 len 属性同样大小的未使用空间,这时 len 和 free 的值相同,下次修改的值长度如果小于 free 就不用重新分配空间。此时 buf 长度:free + len + 1 Byte
- 修改后 SDS 长度大于 1MB
- 分配 1 MB 未使用空间,此时 buf 实际长度 1Mb + len + 1 Byte
- 惰性空间释放
当字符串缩短时,程序并不立即使用内存重分配来回收多余的空间,而是用 free 记录下来,以便以后使用。
SDS 提供了相应的 API 可以在我们需要的时候真正地释放 SDS 的未使用的空间。所以不必担心惰性空间释放策略会浪费内存。
为什么SDS 中 buf 数组仍然在一串字符串后面添加 ‘\0’ 标志 ?
SDS 中保存的文本数据可以根据这个标志调用 C 语言原有的函数(<string.h>),如 strcasecmp(sds->buf, “hello world”)进行字符串对比,strcat(c_string, sds->buf)进行字符串追加等。
List 实现原理
双向链表
// 链表的结点
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
void *的指针是C语言中最简单的一种指针,它存放的是一个地址,并且没有给出任何操作上的提示。但是任何类型的指针都能赋给void *的指针。void *的指针也能强制转换成任何类型的指针
**说明使用者可以根据情况自行转换成任意数据类型(多态的由来)
**
typedef struct list {
// 头节点
listNode *head;
// 尾节点
listNode *tail;
// 链表中的节点数
unsigned int len;
// 节点值复制函数
void *(*dup) (void *ptr);
// 节点值释放函数
void (*free) (void *ptr);
// 节点值对比函数
int (*match) (void *ptr, void *key);
} list;
dup 函数用于复制链表节点所保存的值
free 函数用于释放链表节点所保存的值
match 函数则用与对比链表节点所保存的值和另一个输入值是否相等
特性:
- 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置和后置节点
- 无环:表头节点和表尾节点都指向 NULL
- 带表头指针和表尾指针:可以通过 head 和 tail 获取头节点和尾节点,复杂度为 O(1)
- 带链表长度
- 多态:可以通过 list 结构的 dup 、free 和 match 三个属性为节点值设置类型的特定函数,所以链表可以保存各种不同类型的值。
基本命令参考 redis 学习笔记: https://blog.csdn.net/weixin_41975177/article/details/113835947
**
**
压缩列表
是列表键和哈希键的底层实现之一(连续空间)
当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 redis 就会使用压缩列表来做列表键的底层实现
连锁更新:
每个节点的 previous_entry_length
属性都记录了前一个节点的长度:
- 如果前一节点的长度小于
254
字节, 那么previous_entry_length
属性需要用1
字节长的空间来保存这个长度值。 - 如果前一节点的长度大于等于
254
字节, 那么previous_entry_length
属性需要用5
字节长的空间来保存这个长度值。
现在, 考虑这样一种情况: 在一个压缩列表中, 有多个连续的、长度介于
250
字节到253
字节之间的节点e1
至eN
因为
e1
至eN
的所有节点的长度都小于254
字节, 所以记录这些节点的长度只需要1
字节长的previous_entry_length
属性, 换句话说,e1
至eN
的所有节点的previous_entry_length
属性都是1
字节长的。这时, 如果我们将一个长度大于等于
254
字节的新节点new
设置为压缩列表的表头节点, 那么new
将成为e1
的前置节点
因为
e1
的previous_entry_length
属性仅长1
字节, 它没办法保存新节点new
的长度, 所以程序将对压缩列表执行空间重分配操作, 并将e1
节点的previous_entry_length
属性从原来的1
字节长扩展为5
字节长。
现在, 麻烦的事情来了 ——
e1
原本的长度介于250
字节至253
字节之间, 在为previous_entry_length
属性新增四个字节的空间之后,e1
的长度就变成了介于254
字节至257
字节之间, 而这种长度使用1
字节长的previous_entry_length
属性是没办法保存的。
因此, 为了让
e2
的previous_entry_length
属性可以记录下e1
的长度, 程序需要再次对压缩列表执行空间重分配操作, 并将e2
节点的previous_entry_length
属性从原来的1
字节长扩展为5
字节长。
正如扩展
e1
引发了对e2
的扩展一样, 扩展e2
也会引发对e3
的扩展, 而扩展e3
又会引发对e4
的扩展……为了让每个节点的previous_entry_length
属性都符合压缩列表对节点的要求, 程序需要不断地对压缩列表执行空间重分配操作, 直到eN
为止。
Redis 将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”
字典实现原理
Hash 表
typedef struct dictht{
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点数量
unsigned long used;
}
实例:
解决键冲突
由于 dictEntry 节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置,排在其他已有节点的前面
字典
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
// 保留需要传给那些特定函数的可选参数
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
设置两个 hash 表的原因:
- 为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及 ht[0] 当前包含的键值对数量 (也即是 ht[0].used 属性的值):
• 如果执行的是扩展操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n (2 的 n 次方幂);
• 如果执行的是收缩操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n- 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上。
- 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。
有序集合键实现
跳跃表 + 字典
跳跃表
一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
Redis 两个地方用到了 跳跃表,一个是 有序集合键,一个是集群点中的内部数据结构
跳跃表节点
typedef struct zskiplistNode {
sds ele;
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
/**
* 跨度实际上是用来计算元素排名(rank)的,
* 在查找某个节点的过程中,将沿途访过的所有层的跨度累积起来,
* 得到的结果就是目标节点在跳跃表中的排位
*/
unsigned long span;
} level[];
} zskiplistNode;
跳跃表
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
**原理:**参考链接:https://www.jianshu.com/p/c2841d65df4c(原文更详细)
考虑一个有序表
从该有序表中搜索元素 < 23, 43, 59 > ,需要比较的次数分别为 < 2, 4, 6 >,总共比较的次数为 2 + 4 + 6 = 12 次。有没有优化的算法吗? 链表是有序的,但不能使用二分查找。类似二叉搜索树,我们把一些节点提取出来,作为索引。得到如下结构:
这里我们把 < 14, 34, 50, 72 > 提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。
我们还可以再从一级索引提取一些元素出来,作为二级索引,变成如下结构:
这里元素不多,体现不出优势,如果元素足够多,这种索引结构就能体现出优势来了。
跳表结构
其中 -1 表示 INT_MIN, 链表的最小值,1 表示 INT_MAX,链表的最大值。
跳表具有如下性质:
(1) 由很多层结构组成
(2) 每一层都是一个有序的链表
(3) 最底层(Level 1)的链表包含所有元素
(4) 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
(5) 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。
跳表的搜索
例子:查找元素 117
(1) 比较 21, 比 21 大,往后面找
(2) 比较 37, 比 37大,比链表最大值小,从 37 的下面一层开始找
(3) 比较 71, 比 71 大,比链表最大值小,从 71 的下面一层开始找
(4) 比较 85, 比 85 大,从后面找
(5) 比较 117, 等于 117, 找到了节点。
跳表的插入
先确定该元素要占据的层数 K(采用丢硬币的方式,这完全是随机的)
然后在 Level 1 … Level K 各个层的链表都插入元素。
例子:插入 119, K = 2
如果 K 大于链表的层数,则要添加新的层。
例子:插入 119, K = 4
跳表的高度
n 个元素的跳表,每个元素插入的时候都要做一次实验,用来决定元素占据的层数 K,跳表的高度等于这 n 次实验中产生的最大 K,待续。。。
跳表的空间复杂度分析
根据上面的分析,每个元素的期望高度为 2, 一个大小为 n 的跳表,其节点数目的期望值是 2n。
跳表的删除
在各个层中找到包含 x 的节点,使用标准的 delete from list 方法删除该节点。
例子:删除 71
集合
整数集合
当一个集合只包含整数值元素,并且这个集合的元素不多时,Redis 就会使用整数集合作为集合键的底层实现。
typedef struct intset {
// 保存元素所使用的类型的长度
uint32_t encoding;
// 元素个数
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
contents
数组的int8_t
类型声明比较容易让人误解,实际上,intset
并不使用int8_t
类型来保存任何元素,结构中的这个类型声明只是作为一个占位符使用:在对contents
中的元素进行读取或者写入时,程序并不是直接使用contents
来对元素进行索引,而是根据encoding
的值,对contents
进行类型转换和指针运算,计算出元素在内存中的正确位置。在添加新元素,进行内存分配时,分配的空间也是由encoding
的值决定。
升级
如果添加一个值,并且新元素类型比现有类型都要长时,整数集合需要升级,如以前编码为
int8_t
添加一个int16_t
的数据,那么整数集合需要升级
升级步骤
- 根据新元素类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将转换后的元素放置到正确的位上,而且需要保持有序性
- 将新元素加入底层数组里面
降级
整数集合不支持降级