redis设计与实现 pdf_《Redis设计与实现》读书笔记(一)

60ab238d828ef3c959677c93f8adf7e7.png

引言

文章比较长,建议 先收藏。详细、系统地学习Redis,这本书无疑是最佳选择。刷再多的文章比不上看书哈哈,个人观点~

《Redis设计与实现》写得比较通俗易懂,零基础的也易上手,从实现底层出发介绍但又没有那么多让人看者犯晕的源码,涉及到稍微复杂一点的逻辑都用伪代码和流程图实现。唯一的缺点就是内容有一点点冗余。

0425f45dffbc0f2e1adfa07e2fd6b603.png

很早就买了,可惜看的太慢了,只有四分之一吧,也就是第一部分,下面把冗余的部分提炼一下,总结一下我看到的精华。

b7c902450c8cc1d31d6e0efddd4aeacc.png

本文主要介绍Redis的数据结构与对象部分。Redis以5大对象来满足键的数据结构:字符串对对象,列表对象,哈希对象,集合对象,有序集合对象。这些对象的底层实现依赖特定的编码类型,而某种编码类型又对应1种或多种数据结构。下面分别介绍简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表这6种基本数据结构在Redis中的实现。

先上文章思维导图:

31666390baa5ecc883147f1a430673b4.png

文章导读

  • 简单动态字符串(数据结构,区别,内存分配策略,复杂度)
  • 链表(数据结构,复杂度)
  • 字典(数据结构,哈希算法,渐进式rehash,复杂度)
  • 跳跃表(数据结构,插入,查找,删除,复杂度)
  • 整数集合(数据结构,升降级,复杂度)
  • 压缩列表(数据结构,连锁更新,复杂度)
  • 对象(存储结构,5大对象及编码结构,命令类型,内存回收,对象共享)
  • 总结

一、简单动态字符串

Redis中,涉及可以被修改的字符串值时,都用简单动态字符串(simple dynamic string,SDS)来实现。比如包含字符串值的键值对在底层的实现。C字符串(C语言中传统字符串,以空字符串结尾的字符数组)则用于无须对字符串进行修改的地方,比如日志打印。

SDS还被用作缓冲区,比如AOF模块中的AOF缓冲区,客户端状态中的输入缓冲区。

1.1 SDS定义

struct sdshdr{
    //buf已使用的字节数
    int len;
    //buf未使用的字节数
    int free;
    //字节数组,用于保存字符串
    char buf[];
}

buf遵循C字符串以空字符串结尾的惯例,保存空字符串的1字节空间不计算在SDS的len属性里面,并为空字符分配额外1字节空间,对用户来说是透明的。

bd9acc13f3fdb5b8accf907b34f349e5.png
SDS结构

如中展示了SDS的数据结构,5字节未使用空间,已使用5字节,buf存储了字符串值,最后一个字节保存了空字符'0'。这里要注意的是,free和len的计算不涉及空字符。

1.2 SDS与C字符串的区别

  1. SDS有常数级的时间复杂度获取字符串长度。
    由于C字符串不会记录自身长度,因此只能遍历,直到遇到结尾的空字符为止,时间复杂度为O(N)。而SDS对于字符串长度的记录都是在其API中执行的,所以时间复杂度为O(1)
  2. SDS杜绝缓冲区溢出。
    由于C字符串未记录自身长度,容易导致缓冲区溢出。在执行字符串拼接时,如果没有足够的空间,并且相邻内存地址被其他字符串占用时,字符串的数据将溢出,且容易意外修改相邻的字符串内容。相比而言,SDS会将这种情况扼杀在摇篮之中,SDS API先判断空间是否满足,如果不满足则将空间扩展至执行修改所需的大小
  3. SDS拥有内存分配策略,详见1.3。
  4. SDS API都是二进制安全的。
    C字符串的字符必须符合某种编码,并且中间不能有空字符,否则读取时会被误以为是字符串结尾。种种局限使得C字符串只能存文本,不能存图片,音频,视频,压缩文件等二进制数据。 为确保Redis对不同使用场景的支持,SDS API都是二进制安全的,也就是所有SDS API都会以二进制的方式存取buf中的数据,数据的写入和读出都是一个样的。由于SDS读取时并不是依靠空字符来判断结束的,而是len属性,所以是二进制安全的。
  5. 兼容部分C字符串函数
    SDS虽然都是二进制安全的,但也遵循以空字符结尾的习惯。SDS API总会在buf数组分配空间时多分配一个字节用于容纳空字符,这是为了保存文本的SDS重用一部分<string.h>库函数,避免代码重复。

1.3 内存分配策略

由于C字符并不记录自身长度,并且需要一个字符空间保存空字符串,因此每次增长或缩短字符串时,就要对其进行一次内存重分配操作。增长字符串时要看空间是否够用,否则会有缓冲区溢出;缩短字符串要释放不用的空间,否则会有内存泄漏

Redis经常被用于速度要求严苛,数据被频繁修改的场合,每次修改字符串都要重新分配内存,就会占用很多时间。为避免这个问题,redis采用了空间预分配惰性空间释放两种策略。

空间预分配

空间预分配用于优化SDS字符串增长操作。在扩展SDS空间前,SDS API会先检查未使用空间够不够,如果不够,则进行空间预分配。此时,程序不仅会为SDS分配修改所必须要的空间,还为其分配额外未使用的空间。

  • 修改后的SDS<1MB,程序分配和len属性同样大小的未使用空间,此时SDS的len与free大小相等。比如修改后实际存储字符串的空间变为13字节,那么len=13,free=13,buf数组整体的长度=13+13+1(额外1字节保存空字符)。
  • 修改后SDS>=1MB。程序会分配1MB的未使用空间。比如修改后实际存储字符串的空间变为2MB,那么len=2M,free=1MB,buf数组整体的长度=2MB+1MB+1byte。

通过空间的预分配,将连续增长N次字符串需要的内存分配次数从一定需要N次变为最多N次。因而可以减少连续执行字符串增长操作所需的内存重分配的次数。

惰性空间释放

惰性空间的释放用于优化SDS字符串缩短操作。当SDS API需要缩短保存的字符串时,程序并不立即回收这部分内存,而是使用free属性将字节的数量记录,等待使用。与此同时,SDS提供了相关API,在有需要时,真正释放未使用空间,不需要担心惰性空间造成的内存浪费。

C字符串与SDS的区别简单来说:

1.4 SDS时间复杂度

SDS相关操作及时间复杂度:

二、链表

当一个列表键包含了数量比较多的元素,或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。

2.1 链表和链表节点的实现

typedef struct listNode{
    //前置节点
    struct listNode *prev;
    //后置节点
    struct listNode *next;
    //节点的值
    void *value;
}listNode;

节点由前驱后继组成,多个节点组成的链表为双端链表。

使用adlist.h/list来持有,操作链表:

typedef struct list{
    //表头节点
    listNode *head;
    //表尾节点
    listNode *tail;
    //链表所包含的节点数量
    unsigned long len;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值对比函数
    int (*match)(void *ptr,void *key);
}list;

整个链表串起来后,如下图:

64e271c96244fd15766f36a3b7cbc477.png

Redis的链表特性可以总结如下:

双端:链表节点带有prev和next指针,获取前置和后置节点的复杂度都是O(1)。 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。 带表头指针和表尾指针 带链表长度计数器 。头尾指针:将程序获取头尾节点的复杂度降为O(1)。长度计数器:将程序获取表长的复杂度降为O(1)。多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

2.2 链表时间复杂度

链表相关操作及时间复杂度:

三、字典

字典又称符号表关联数组映射,用于保存键值对的抽象数据结构。当一个哈希键包含的键值对比较多时,或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

3.1 字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,每个哈希表节点保存了字典中的一个键值对

哈希表

使用dict.h/dictht结构定义:

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

数组中的每个元素都是指向dict.h/dictht的结构,dictEntry就是一个键值对。

哈希表节点

哈希表节点使用dictEntry实现,每个dictEntry都存储着一个键值对:

typedef struct dictEntry{
    //键
    void *key;
    //值
    union{
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    //指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

键值对的值可以是一个指针,或一个uint64_t整数,或一个int64_t整数。next是指向另一个哈希节点的指针,可将多个哈希值相同的键值对连接在一起,以此来解决冲突。

d6b8d82eafbac0f3e4a75f634633d2f4.png

如图,表示的是两个哈希值相同的节点,通过指针连接在一起。

字典

Redis中的字典由dict.h/dict实现,由这个数据结构将字典组织在一起。

typedef struct dict{
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    //rehash索引
    //当rehash不在进行时,值为-1
    int rehashidx;
} dict;

type和privdata属性是针对不同类型的键值对,为丰富键值对的使用场景而设置的。

  • type属性是一个指向dictType的结构指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis为用途不同的字典设置不同类型特定函数。
typedef struct dictType{
    //计算哈希值的函数
    unsigned int (*hashFunction)(const void *key);
    //复制键的函数
    void *(*keyDup)(void *privdata,const void *key)
    ...
}
  • privdata属性保存了需要传给那些类型特定函数的可选参数。
  • ht属性是包含两个项的数组,每项都是一个哈希表,ht[0]平时使用,而ht[1]仅在rehash时使用。
  • rehashidx记录了rehash的进度,初始为-1。

3.2 哈希算法

Redis计算哈希值方法: hash=dict->type->hashFunction(key);
计算索引值的方法:index=hash & dict->ht[x].sizemask;

当字典被用作数据库的底层实现或哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。优点在于即使输入的键是有规律的,算法仍然能给出很好的随机分布性,并且计算速度飞快

3.3 解决键冲突

当有两个或以上的键被分配到哈希表的同个索引,那么就发生了冲突。Redis使用链地址法来解决冲突,被分配到索引的多个节点使用链表连接。为了提高速度,每次都是将新节点添加到链表的表头位置。

3.4 rehash

为了让哈希表的负载因子维持在一个合理的范围内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行响应的扩容或缩容。扩容和缩容通过执行rehash来完成,Redis中重新散列的步骤如下:

  1. 为字典ht[1]哈希表分配空间,大小取决于要执行的操作与ht[0]当前键值对的数量
  2. 将保存在ht[0]中的所有键值对存放到ht[1]指定的位置
  3. 当ht[0]的所有键值对都迁移完毕后,释放ht[0],并指向ht[1],并在ht[1]上创建一个空的哈希表,为下次rehash准备。

扩容与缩容场景

扩容操作场景:

  • 服务器目前没有在执行BGSAVE命令或BGREWRITEAOF命令,并且哈希表的负载因子>=1
  • 服务器正在执行BGSAVE命令或BGREWRITEAOF命令,并且哈希表的负载因子>=5

负载因子=哈希表已存储节点数/哈希表大小 load_factor=ht[0].used/ht[0].size

为什么根据BGSAVE命令或BGREWRITEAOF命令来判断是否扩展?
因为执行这些命令时,Redis需要创建当前服务器进程的子进程,大多数操作系统采用写时复制技术来优化子进程使用效率,此时提高负载因子,可以尽量避免子进程对哈希表扩展,避免不必要的内存写入操作,节约内存。

缩容操作场景:负载因子<0.1时,自动对哈希表执行收缩操作。

3.5 渐进式rehash的过程

rehash时会将ht[0]中所有的键值对rehash到ht[1],如果键值对很多并且一次性操作的话,容易导致服务器在一段时间内停止服务。为避免这种情况,Redis采用渐进式rehash,将ht[0]中的键值对分多次,慢慢的rehash到ht[1]之中。

步骤:

  1. 为ht[1]分配空间,让字典同时持有两个哈希表。
  2. 在字典中维持一个索引计数器变量rehashidx,将其设置为0,表示rehash正式开始。
  3. 在rehash进行期间,每次对字典进行添加,删除,查找或更新操作时,程序除了执行指定的操作外,还会将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成后,将rehashidx++。
  4. 某个时刻,ht[0]中的所有键值对都被rehash至ht[1],此时设置rehashidx=-1时,表示rehash操作已经完成。

这种方式的rehash的好处在于采用了分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个操作中,从而避免集中式rehash带来庞大计算量。

在rehash的期间,字典同时使用ht[0],ht[1]两个哈希表。对哈希表的操作会在两个表上进行,比如查找键时,先在ht[0]里面查找,如果为空,就继续到ht[1]里查找。在此期间,新增的键值对都会被添加到ht[1]中,ht[0]不承担任何添加操作,保证ht[0]中的键值对只能是越来越少

3.6 字典时间复杂度

字典相关操作及时间复杂度:

四、跳跃表

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

如果一个有序集合中包含的元素数量比较多,又或者有序集合中元素的成员是较长的字符串,Redis就会使用跳跃表来作为有序集合键的底层实现。Redis只有在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中作为内部数据结构。

4.1 跳跃表的实现

Redis的跳跃表由redis.h/zskiplistNoderedis.h/zskiplist两个数据结构定义。

typedef struct zskiplist{
    //表头节点和表尾节点
    structz zskiplistNode *header,* tail;
    //表中节点的数量
    unsigned long length;
    //表中层数最大的节点的层数
    int level;
} zskiplist;

跳跃表由zskiplist组织,通过多个跳跃表节点zskiplistNode组成一个跳跃表。值得注意的是,记录level时,表头节点的层高不会记录在内。

45a27e4cb54f235b7550c71f454d01fc.png

跳跃表节点

typedef strct zskiplistNode{
    //后退指针
    struct zskiplistNode *backward;
    //分值
    double score;
    //成员对象
    robj *obj;
    //层
    struct zskiplistlevel{
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned int span;
    }level[];
} zskiplistNode;
  1. 层--level

跳跃表的每个节点都会包含多个层,每次创建一个新跳跃表时,都会根据幂次定律,随机生成一个1~32之间的数作为层的大小。每个层都会包含前进指针和跨度。

前进指针(forword)用于访问下一个节点。跨度表示两个节点之间的距离,指向NULL的所有前进指针的跨度为0。跨度用于计算排位,访问某一结点的经过的跨度之和就是当前节点的排位。

注:幂次定律也是80-20法则。最重要的只占一小部分,越大的数出现的概率越小。Redis中对level的随机获取实现是:

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

2. 后退指针--backward

用于从表尾向表头方向访问节点,前进指针可以一次跳过多个节点,后退指针只能后退至前一个节点,因为每个节点只有一个后退指针。

3. 分值--score

分值是一个double类型的浮点数,跳跃表中节点都按照分值排序。

4. 成员对象--obj

是一个指针,指向字符串SDS对象。一个跳跃表中,对象必须是唯一的,但分值可以相同。相同时按对象字典序来排序。

思考

我看代码的时候就有个疑问,书上也没有给出清晰的解释,通过看帖子和自己的理解总结了一下。Redis的level个数为什么要用幂次定律生成(随机生成节点的层数)

通过幂次定律能保证越高level的结点数量越少 。保证索引等级越高,参与索引建立的元素越少,如果每层都有很多level,那么这个索引建立的就没有意义了。那么,为什么不用最均衡的方式,按照节点分数的排序情况均匀建立索引?考虑到下一个插入的元素具有随机性,这样设计不容易出现最坏的情况。如果每次都以均匀固定的方式建索引,维护的成本很高,跳跃表的优点就是维持结构平衡的成本低,完全依靠随机。跳跃表相比二叉树有一个优势就在于不需要主动rebalance去维护平衡。

4.2 跳跃表的操作

插入

初始跳跃表如下图:

ac8984124daf40ab3ee4683304c2789f.png

插入11.0,随机层数为2。

354915b7f7e0253900225e65e9d0a23b.png

观察可知,插入操作仅新增节点和指针变化,不需要对整体的平衡进行额外维护操作。

查找

522145a2c6b711bb3a6735ca0385da67.png

跳跃表查找10的过程如上图,由于数字标注的是查找顺序,所以不标注跨度以免引起歧义。此时跳跃表查找10,会先从header节点(O1)的最高层(L3)寻找,发现要查找的数小于23.5则返回,继续从下一个有后继的层开始寻找,当发现要查找的数小于11.0时,则从O1的下一层找,此时到O2的L1,发现要查找的数大于7.0,则从L1找,直到查找到相邻节点为止。

删除

节点的删除操作比较简单,查找到要删除的节点后,再处理好前后节点的前驱后继就可以啦~

拓展阅读:https://zhuanlan.zhihu.com/p/109946103

4.3 跳跃表的时间复杂度

跳跃表相关操作及时间复杂度:

五、整数集合

当一个集合只包含整数元素,并且元素不多时,Redis就会使用整数集合作为集合键的底层实现。

5.1 整数集合的实现

整数集合是Redis中用于保存整数值的集合抽象数据结构,可以保证集合有序不重复。每个intset.h/intset结构来表示一个整数集合:

typedef struct intset{
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

length属性记录了整数集合包含的元素数量,contents是整数集合的底层实现。contents存储元素的真实类型取决于encoding,比如encoding==INT_ENC_INT16时,contents数组中每个向都是int16_t类型的整数。可以为int16_t,int32_tint64_t

293f00ea970a77a0b626d691b269fed9.png
整数集合的数据结构

升级

当我们要将一个新元素添加至集合时,并且新元素的类型比现有集合类型都长时,整数集合就要升级。

步骤:

  1. 根据新元素类型,扩展数组空间,为新元素分配空间。
  2. 将底层数组现有所有元素都转为新元素相同类型,并将类型转换后的元素放到正确位置。
  3. 将新元素添加到底层数组。

由于每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有元素进行类型转换,所以添加的时间复杂度为O(N)

升级的好处

有两个好处,可以提升整数集合的灵活性,也能尽可能地节约内存
C语言是静态类型语言,一般数组中的元素类型都相同,使用升级可以不用担心类型兼容问题,提升灵活性。元素统一以最大类型存储,而不是都用int64_t,可节约内存。

降级

整数集合不支持降低,一旦升级就不能降级。

5.2 整数集合的时间复杂度

整数集合相关操作及时间复杂度:

六、压缩列表

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

6.1 压缩列表的构成

为节约内存而开发的,由一系列特殊编码连续内存块组成的顺序型数据结构。

8aa28356eedcc572915753b0128e7e4f.png
压缩列表的数据结构

结构比较简单,属性如下:

  • zlbytes:记录整个压缩列表占用内存字节数,进行内存重分配或计算zlend时使用。
  • zltail:记录压缩列表尾节点距离压缩列表起始地址多少字节。
  • zllen:节点数量。小于65535时,表示节点数量;大于时,需要遍历才能计算得出。
  • entryx:列表节点。
  • zlend:特殊值0xFF用于标记压缩列表的末端

6.2 压缩列表节点的组成

每个压缩列表节点可以是一个字节数组,也可以是一个整数。由previous_entry_length,encoding,content组成。

previous_entry_length

单位是字节,记录压缩列表前一个节点的长度。该属性长度为1字节或5字节,前两位表示该属性长度为2位还是10位。

  • 前一个节点的长度<254字节时,该属性只有2位,且前一节点的长度就保存在这两位。如0x05,表示前一个字节长度为5字节。
  • 前一个节点的长度>=254字节时,该属性有10位,且前两位表示这是一个5字节的长度,后8位表示前一个节点的长度。如0xFE0000,表示前一个字节长度为0x00002766,换算为10进制就是我们熟悉的数字。

encoding

encoding记录了节点的content属性所保存数据类型长度高两位表示存储的是字节数组还是整数。

content

存储节点的值。

6.3 连锁更新

多个连续的长度介于250字节到253字节之间的节点,插入新的头节点(长度大于等于245字节),后面节点的previous_entry_length就要新增4字节的空间(1字节变成5字节),需要进行内存重分配,由于前一个节点的变更,每个节点的previous_entry_length属性也需要记录之前的长度而发生相应的变更,所以会出现连锁更新。除了新增节点,删除节点也可能会遇到这种情况。

因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,每次重分配的的最坏时间复杂度

,所以连锁更新的
最坏时间复杂度为

虽然代价很高,但是出现的几率比较低,而且只要更新节点的数量不多,就不会对性能产生影响。因此ziplistPush命令的平均复杂度为

6.4 压缩列表的时间复杂度

压缩列表相关操作及时间复杂度:

七、对象

Redis没有直接使用前文的数据结构来实现键值对数据库,而是基于这些数据结构构建了一个对象系统,通过对象组织数据结构,包括字符串对象,列表对象,哈希对象,集合对象有序集合对象这5种对象。

使用对象的一个好处是可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。

7.1 对象的结构

Redis使用对象来表示数据库的键和值。每个对象都是一个redisObject结构,是一个按照位段存储的结构,节约内存:

typedef struct redisObject{
    //类型
    unsigned type :4;
    //编码
    unsigned encoding:4;
    //指向底层实现数据结构的指针
    void *ptr;
    ...
} robj;

其中,type是类型常量,记录对象的类型:

encoding记录对象使用的编码,即对象底层使用的具体数据结构:

cf176a49dd45477d6447aea33aee8b3c.png

使用object encoding命令可以查看键的值对象的编码,每个对象都至少使用两种不同编码常量:

9ebf0424154f17dfff32cb448a899607.png

Redis对象采用encoding属性来设置编码,从而决定底层数据结构,而不是为特定类型的对象关联一种固定编码。这种方式极大地提高了灵活性和效率。

7.2 字符串对象

字符串对象可以是int,rawembstr

  • 如果字符串对象保存的是整数值,且这个数值可用long表示,底层就会以REDIS_ENCODING_INT编码来实现。
  • 如果字符串对象是一个字符串值,且这个字符串长度>39字节,字符串将使用一个SDS保存,底层编码为REDIS_ENCODING_RAW
  • 如果字符串对象保存的是字符串,且这个字符串长度<=39字节,底层编码就是REDIS_ENCODING_EMBSTR,使用embstr编码的方式保存字符串。

ac259ce6da36a0b6e780d3bb8a1db232.png
raw编码的字符串对象

embstr编码

专门用于保存短字符串的一种优化编码方式,与raw的效果相同,都使用redisObject和sdshdr结构来表示字符串对象,但是raw会调用两次内存分配函数分别创建redisObject和sdshdr结构。embstr编码则通过调用一次内存分配函数来分配一块连续空间,空间依次包括redisObject和sdshdr俩结构。

使用embstr编码保存短字符串的优点

  • 内存分配次数由两次降为1次
  • 释放embstr字符串对象只需调用1次内存释放函数。
  • embstr字符串放在一块连续的内存中,能更好地利用缓存带来的优势.

注:浮点数的存储,在Redis底层也会以字符串的形式保存。在有需要时,程序会将字符串对象中的字符串值转为浮点数值执行运算操作,然后再将结果转为字符串值保存。

编码的转换

int->raw:对int编码的字符串对象执行后,保存的不再是整数值,而是字符串值时。比如整数追加字符串。

embstr->raw:Redis没有为embstr编写修改程序,所以是只读的,当embstr编码的字符串修改后,就变成raw编码的字符串对象。

7.3 列表对象

列表对象的编码是ziplist或linkedlist。

当列表可以同时满足以下两个条件时,列表对象使用ziplist编码:

  • 列表对象保存的所有字符串元素的长度都<64字节
  • 列表对象保存的元素数量<512个

否则使用linkedlist编码。

注:两条件的上限值可通过配置文件修改。

使用ziplist编码,执行RPUSH elements "a" "b" 1,后的数据结构:

838095cc3a5aee1232814910aca4702a.png
ziplist编码的列表对象

使用linkedlist编码,执行RPUSH elements "a" "b" 1,后的数据结构:

8b9e7f6126d747c07615dbb0fc71e5e8.png
linkedlist编码的列表对象

注:SDS对象都以StringObject代替

7.4 哈希对象

哈希对象的编码可以是ziplist或hashtable。

  • ziplist的数据结构:每当有新的键值对插入哈希对象时,Redis会先将保存键的压缩列表节点推入表尾,再将保存值的压缩列表节点推入表尾。
  • hashtable的数据结构:字典的每个键都是一个字符串对象,保存键;字典的每个值都是字符串对象,保存值

当哈希对象可以同时满足下两个条件时,使用ziplist编码

  • 哈希对象保存的所有键值对的值和键都<64字节
  • 哈希对象保存的键值对数量<512个

否则使用hashtable编码。

注:两条件的上限值可通过配置文件修改。

使用ziplist编码,执行HSET student name "madongmei" age 25 career "pick up trash"后的数据结构:

58730917c84d0a791953b94fff951598.png
ziplist编码的哈希对象

使用hashtable编码,执行HSET student name "madongmei" age 25 career "pick up trash"后的数据结构:

73d378d02e59e238a6b269560e85fd13.png
hashtable编码的哈希对象

7.5 集合对象

集合对象的编码可以是intset或hashtable。

如果以hashtable编码作为集合对象底层实现,那么字典的每个键都是一个字符串对象,值都是null

当集合对象同时满足以下两个条件时,使用intset编码:

  • 集合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量<=512个

否则使用hashtable编码。

注:两条件的上限值可通过配置文件修改。

使用intset编码,执行SADDnumbers 1 3 5后的数据结构:

77b0f90e7f681b2e495bd1e72e91314a.png
intset编码的集合对象

使用hashtable编码,执行SADDnumbers 1 3 5后的数据结构:

c35117ad171e657b531b06afec0071af.png
hashtable编码的集合对象

7.6 有序集合对象

有序集合的编码可以是ziplist或skiplist。

使用ziplist编码时,每个元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。

1173eb0a25cb61de267a8a05e611ee7c.png
ziplist编码的有序集合对象

如果是skiplist编码,使用zset结构:

typedef struct zset{
    zskiplist *zsl;
    dict *dict;
} zset;

dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:键保存元素,值保存分值。通过字典以O(1)查找给定成员的分值。有序集合元素都是字符串对象,分值都是double类型浮点数。zset的跳跃表和字典通过指针来共享相同元素的成员和分值,不会浪费额外内存。

9d41e1b0b6c55fc7978aba50e821b8a1.png
skiplist编码的有序集合对象

当有序集合对象同时满足以下两条件时,对象使用ziplist编码:

  • 有序集合保存的元素数量<128个
  • 有序集合保存的所有元素成员的长度都<64字节

否则使用skiplist编码。

注:两条件的上限值可通过配置文件修改。

思考

为什么有序集合需要同时使用跳跃表和字典来实现?

如果只使用字典存储,由于是无序的,所以每次在范围查询时,需要排序,时间复杂度为

和额外
的内存空间
,因为要创建一个数组存储排序后的元素。 如果只用跳跃表实现,根据成员 查找分值时复杂度将为
。综上,为了让有序集合的分值查找和范围查找都尽可能快地执行,Redis选择字典和跳跃表两种数据结构结合的方式。

7.7 类型检查与命令多态

Redis中用于操作键的命令可分为两种类型。一种是可对任何类型执行的,如del,expire,rename等。另一种命令只能对特定类型的键执行,如set,get,hdel,hset,rpush等。如果对特定类型使用其他类型的命令,那么就会报错。

类型检查的实现

为了确保只有制定类型的键可以执行某些特定命令,在执行前,Redis会先通过RedisObject的type属性检查输入键的类型是否正确。

多态命令的实现

Redis除了根据值对象判断键是否能够执行制定命令外,还会根据值对象的编码方式,选择正确的命令实现代码来执行。比如基于编码的多态,列表对象的编码可能是ziplist或linkedlist,所以需要多态命令执行对应编码的API。基于类型的多态是一个命令可以同时处理多种不同类型的键

7.8 内存回收

由于C语言没有内存回收机制,Redis在对象系统中构建了引用计数器技术实现内存回收机制。每个对象的引用计数器信息由redisObject的refcount来记录。当对象的引用计数值为0时,所占用的内存会被释放

7.9 对象共享

引用计数器还有共享对象的作用。如果两个不同键的值都一样(必须是整数值的字符串对象),则将数据库键的值指针指向一个现有的值对象,然后将被共享对象的引用计数加一。如果不是整数值的对象,则需要耗费大量的时间验证共享对象和目标对象是否相同,复杂度较高,消耗CPU时间,所以Redis不会共享包含字符串的对象

Redis在初始化服务时,会创建很多字符串对象,包含0~9999的整数(和Integer的常量池有点像),当需要时,就能直接复用。

f2e1b1ed0a145a3ff5b75ad1b70986d7.png
键共享整数100的字符串对象

7.10 对象的空转时长

redisObject还包含了lru属性,记录对象最后一个被命令程序访问的时间。object idletime命令可打印键的空转时长,就是当前时间减去lru时间计算得到的。

最后

能看到最后的都很厉害
本文主要对Redis的数据结构和对象编码进行整理,大部分都是书上的内容,还有我的一些小理解。下篇讲Redis数据库的实现如何将key和value组织在一起,如何进行查找映射的...
刚刚得到一个噩耗,5.2发布的Redis6.0貌似新增了多线程功能。。。开始头秃~

a5b4fbd443f322e021ee35d708494f77.png

文章如有错误,欢迎指出~
感觉不错就点个赞 吧,毕竟收藏=白嫖 ~

参考文章:
《Redis设计与实现》https://zhuanlan.zhihu.com/p/109946103

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值