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;
}
三种编码
  1. int 编码:保存的是可以用 long 类型表示的整数值。
  2. raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)
  3. 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 ?

  1. 空间预分配

在 C 中分配空间是一个比较耗时的工作,但 Redis 作为缓存数据库,数据的频繁修改是常态,而每次增长字符串都需要执行一次内存重分配(C 语言字符串处理特性),对性能产生很大影响。

    • 修改后 SDS 长度小于 1MB
      • 分配和 len 属性同样大小的未使用空间,这时 len 和 free 的值相同,下次修改的值长度如果小于 free 就不用重新分配空间。此时 buf 长度:free + len + 1 Byte
    • 修改后 SDS 长度大于 1MB
      • 分配 1 MB 未使用空间,此时 buf 实际长度 1Mb + len + 1 Byte
  1. 惰性空间释放

当字符串缩短时,程序并不立即使用内存重分配来回收多余的空间,而是用 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 函数则用与对比链表节点所保存的值和另一个输入值是否相等

img

特性:

  • 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置和后置节点
  • 无环:表头节点和表尾节点都指向 NULL
  • 带表头指针和表尾指针:可以通过 head 和 tail 获取头节点和尾节点,复杂度为 O(1)
  • 带链表长度
  • 多态:可以通过 list 结构的 dup 、free 和 match 三个属性为节点值设置类型的特定函数,所以链表可以保存各种不同类型的值。

基本命令参考 redis 学习笔记: https://blog.csdn.net/weixin_41975177/article/details/113835947

**
**

压缩列表

是列表键和哈希键的底层实现之一(连续空间)

当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 redis 就会使用压缩列表来做列表键的底层实现

img

连锁更新:

每个节点的 previous_entry_length 属性都记录了前一个节点的长度:

  • 如果前一节点的长度小于 254 字节, 那么 previous_entry_length 属性需要用 1 字节长的空间来保存这个长度值。
  • 如果前一节点的长度大于等于 254 字节, 那么 previous_entry_length 属性需要用 5 字节长的空间来保存这个长度值。

现在, 考虑这样一种情况: 在一个压缩列表中, 有多个连续的、长度介于 250 字节到 253 字节之间的节点 e1eN

因为 e1eN 的所有节点的长度都小于 254 字节, 所以记录这些节点的长度只需要 1 字节长的 previous_entry_length 属性, 换句话说, e1eN 的所有节点的 previous_entry_length 属性都是 1 字节长的。

这时, 如果我们将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的表头节点, 那么 new 将成为 e1 的前置节点

image

因为 e1previous_entry_length 属性仅长 1 字节, 它没办法保存新节点 new 的长度, 所以程序将对压缩列表执行空间重分配操作, 并将 e1 节点的 previous_entry_length 属性从原来的 1 字节长扩展为 5 字节长。

现在, 麻烦的事情来了 —— e1 原本的长度介于 250 字节至 253 字节之间, 在为 previous_entry_length 属性新增四个字节的空间之后, e1 的长度就变成了介于 254 字节至 257 字节之间, 而这种长度使用 1 字节长的 previous_entry_length 属性是没办法保存的。

因此, 为了让 e2previous_entry_length 属性可以记录下 e1 的长度, 程序需要再次对压缩列表执行空间重分配操作, 并将 e2 节点的 previous_entry_length 属性从原来的 1 字节长扩展为 5 字节长。

正如扩展 e1 引发了对 e2 的扩展一样, 扩展 e2 也会引发对 e3 的扩展, 而扩展 e3 又会引发对 e4 的扩展……为了让每个节点的 previous_entry_length 属性都符合压缩列表对节点的要求, 程序需要不断地对压缩列表执行空间重分配操作, 直到 eN 为止。

Redis 将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”

image

字典实现原理

Hash 表
typedef struct dictht{
    // 哈希表数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引
    // 总是等于 size - 1
    unsigned long sizemask;
    // 该哈希表已有节点数量
    unsigned long used;
}

实例:

image.png

解决键冲突

由于 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 表的原因:

  1. 为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及 ht[0] 当前包含的键值对数量 (也即是 ht[0].used 属性的值):
    • 如果执行的是扩展操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n (2 的 n 次方幂);
    • 如果执行的是收缩操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n
  2. 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上。
  3. 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。

image

有序集合键实现

跳跃表 + 字典

跳跃表

一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

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(原文更详细)

考虑一个有序表

image.png

从该有序表中搜索元素 < 23, 43, 59 > ,需要比较的次数分别为 < 2, 4, 6 >,总共比较的次数为 2 + 4 + 6 = 12 次。有没有优化的算法吗? 链表是有序的,但不能使用二分查找。类似二叉搜索树,我们把一些节点提取出来,作为索引。得到如下结构:

image.png

这里我们把 < 14, 34, 50, 72 > 提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。

我们还可以再从一级索引提取一些元素出来,作为二级索引,变成如下结构:

image.png

这里元素不多,体现不出优势,如果元素足够多,这种索引结构就能体现出优势来了。

跳表结构

其中 -1 表示 INT_MIN, 链表的最小值,1 表示 INT_MAX,链表的最大值。

image.png

跳表具有如下性质:

(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, 找到了节点。

image.png

跳表的插入

先确定该元素要占据的层数 K(采用丢硬币的方式,这完全是随机的)

然后在 Level 1 … Level K 各个层的链表都插入元素。

例子:插入 119, K = 2

image.png

如果 K 大于链表的层数,则要添加新的层。

例子:插入 119, K = 4

image.png

跳表的高度

n 个元素的跳表,每个元素插入的时候都要做一次实验,用来决定元素占据的层数 K,跳表的高度等于这 n 次实验中产生的最大 K,待续。。。

跳表的空间复杂度分析

根据上面的分析,每个元素的期望高度为 2, 一个大小为 n 的跳表,其节点数目的期望值是 2n。

跳表的删除

在各个层中找到包含 x 的节点,使用标准的 delete from list 方法删除该节点。

例子:删除 71

image.png

集合

整数集合

当一个集合只包含整数值元素,并且这个集合的元素不多时,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 的数据,那么整数集合需要升级

升级步骤

  1. 根据新元素类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将转换后的元素放置到正确的位上,而且需要保持有序性
  3. 将新元素加入底层数组里面

降级

整数集合不支持降级

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值