Redis 底层数据结构

Redis为了高效存储和操作数据,自定义了一系列数据结构,如简单动态字符串(SDS)避免了C字符串的缓冲区溢出问题,提供常数时间获取长度;链表支持双向遍历和节点灵活增删;字典使用哈希表实现,支持多态和动态扩展;跳跃表用于有序集合,支持快速查找;整数集合节省内存,适用于小整数集合;压缩列表在内存有限时压缩存储数据。
摘要由CSDN通过智能技术生成

Redis 中的数据结构与对象

引言:

参考《Redis设计与实现》
这本书写的太好了,所以本文仅作简要概述。

1. 简单的动态字符串

Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。

SDS的数据结构

struct sdshdr {

    // 记录buf数组中已使用字节的数量,等于SDS所保存字符串的长度
        int len;
    // 记录buf数组中未使用字节的数量
        int free;
    // 字节数组,用于保存字符串
        char buf[];
};
图示:

不带有未使用空间的SDS

image-20220412221704701

带有未使用空间的SDS

image-20220412221712555

作用:

  • 获取字符串长度的操作为O(1)

  • 杜绝缓冲区溢出

    • 一个典型的例子,假设C程序中的两个C字符串s1(“Redis”),s2(“MongoDB”)在内存中紧邻,若程序员决定执行复制另一个字符串s3(“Cluster”)到s1末尾,但是忘记了在复制前为s1额外分配新空间,则复制后的s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外地修改。Redis中对一个SDS进行修改时,会有修改前检测机制,动态的为SDS分配新内存空间,从而杜绝了缓冲区溢出问题。

      image-20220412221920747

2. 链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。

作为一种常用数据结构,链表内置在很多高级的编程语言里面,因为Redis使用的C语言并没有内置这种数据结构,所以Redis构建了自己的链表实现。

节点数据结构

多个listNode可以通过prev和next指针组成双端链表,如图3-1所示。

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

image-20220413215122234

链表数据结构

虽然仅仅使用多个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;
list

dup、free和match成员是用于实现多态链表所需的类型特定函数:

  • dup函数用于复制链表节点所保存的值;

  • free函数用于释放链表节点所保存的值;

  • match函数则用于对比链表节点所保存的值和另一个输入值是否相等。

image-20220413215145459

Redis的链表实现的特性

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

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

3. 字典

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。

在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。

字典的数据结构

Redis中的字典由dict.h/dict结构表示:

typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash 索引
    // 当rehash 不在进行时,值为-1
    in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
  • type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:

  • ype属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。

  • 而privdata属性则保存了需要传给那些类型特定函数的可选参数。

  • ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。

  • 除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。

image-20220420195535149

字典中的hash表

Redis字典所使用的哈希表由dict.h/dictht结构定义:

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

image-20220420194154679

哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:

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

展示了如何通过next指针,将两个索引值相同的键k1和k0连接在一起。

image-20220420194451190

字典的扩展

扩展条件:

  • 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。

  • 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

为什么执行BGSAVE或BGREWRITEAOF不进行收缩呢?

因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。

字典的收缩

**收缩条件:**当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

扩容和收缩的具体算法

扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

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做准备。

4. 跳跃表(sorted set)

https://mp.weixin.qq.com/s/BPTrtRs_rQNCUmK05jO9aQ

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

跳表图解

image-20220504204752203

跳表的数据结构

typedef struct zskiplist {
    // 表头节点和表尾节点
    structz skiplistNode *header, *tail;
    // 表中节点的数量
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;
  • ·header:指向跳跃表的表头节点。

  • ·tail:指向跳跃表的表尾节点。

  • ·level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。

  • ·length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

节点的数据结构

typedef struct zskiplistNode {
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];
    // 后退指针
    struct zskiplistNode *backward;
    // 分值
    double score;
    // 成员对象
    robj *obj;
} zskiplistNode;
  • ·层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。

  • ·后退(backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。

  • ·分值(score):各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。

  • ·成员对象(obj):各个节点中的o1、o2和o3是节点所保存的成员对象。

注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性都不会被用到,所以图中省略了这些部分,只显示了表头节点的各个层。

5. 整数集合(Set)

整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。

整数集合的数据结构

image-20220504204538096

Set 的数据结构

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

contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。

  • length属性记录了整数集合包含的元素数量,也即是contents数组的长度。

  • 虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值:

  • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)。

  • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647)。

  • 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)。

升级

升级规则

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。

升级操作

1)根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。

2)将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。

3)将新元素添加到底层数组里面。

降级

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

6. 压缩列表(ziplist)

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。作用:压缩内存

压缩列表图解

image-20220504213735984

·列表zlbytes 表示压缩列表的占用的字节数。

·列表zltail属性,表示如果我们有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量x,就可以计算出表尾节点entryN的地址。

·列表zllen属性,表示压缩列表包含多少个节点。

节点结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M1w7LQas-1666073642837)(C:/Users/ZWK/AppData/Roaming/Typora/typora-user-images/image-20220504214027525.png)]

每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是以下三种长度的其中一种:

  • 长度小于等于63(2 6–1)字节的字节数组;

  • 长度小于等于16383(2 14–1)字节的字节数组;

  • 长度小于等于4294967295(2 32–1)字节的字节数组;

而整数值则可以是以下六种长度的其中一种:

  • 4位长,介于0至12之间的无符号整数;

  • 1字节长的有符号整数;

  • 3字节长的有符号整数;

  • int16_t类型整数;

  • int32_t类型整数;

  • int64_t类型整数。

每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成

previous_entry_length:

previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节:

  • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。

  • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。

encoding:

节点的encoding属性记录了节点的content属性所保存数据的类型以及长度:

  • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;

  • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录;

content:

节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。

图7-9展示了一个保存字节数组的节点示例:

  • 编码的最高两位00表示节点保存的是一个字节数组;

  • 编码的后六位001011记录了字节数组的长度11;

image-20220504214400553

连锁更新

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

image-20220504214601852

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

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

image-20220504214622940

​ 因为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将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”(cascade update),图7-13展示了这一过程。

image-20220504214736212

除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值