Redis数据结构与对象底层相关知识总结


之前看到经常讨论Redis如何如何,自己完全没听说过这个东西,觉得还是有必要学习以下,就找了一些视频和学习资料开始学习,其中最好的资料就是黄建宏先生的《Redis 设计与实现》和其配套的Redis3.0版本的源码。这本书深入浅出地讲解了Redis,使我获益匪浅。
到目前为止我大概看了一小半,将Redis的数据结构相关知识基本过了一遍,今天简单的总结一下。

什么是Redis

Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。

Redis的value类型简介

上面说到,Redis是Key-value型的数据库,在Redis中,所有Key都是字符串类型(REDIS_STRING)的,而Value的值就多种多样了。共有五种类型,分别为string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和hash(哈希类型)。对于这五种类型,在底层又有6种数据结构的支持,分别为简单动态字符串(SDS),双端链表,字典,跳跃表,整数集合和压缩列表

下面我们来一一介绍这六种数据结构

底层数据结构一:简单动态字符串(SDS)

SDS的结构定义如下所示:

struct sdshdr {

    // 记录 buf 数组中已使用字节的数量
    // 等于 SDS 所保存字符串的长度
    int len;

    // 记录 buf 数组中未使用字节的数量
    int free;

    // 字节数组,用于保存字符串
    char buf[];

};

可以看到,Redis的这一数据结构SDS是基于C语言的字节数组进一步实现的,多了两个int型变量len和free。

len变量记录buf数组记录已使用字节的数量,等于SDS所保存字符串的长度,而free变量记录buf数组中未使用字节的数量。

多了两个变量,显而易见地牺牲了一定的空间,但是有很多的好处:

1.可以以常数级速度获取字符串长度:
获取字符串长度只需要访问len属性即可,这对于一些需要经常访问字符串长度的应用来说可以节省大量的时间。
2.杜绝缓冲区溢出:
“当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间(free属性)是否满足修改所需的要求, 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小, 然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小, 也不会出现前面所说的缓冲区溢出问题。(选自《Redis 设计与实现》)
3.减少修改字符串时带来的内存重分配次数:
在说明这个问题之前我们先要搞清楚两个概念:缓冲区溢出和内存泄漏

如果程序执行的是增长字符串的操作, 比如拼接操作(append), 那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。

如果程序执行的是缩短字符串的操作, 比如截断操作(trim), 那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。

而内存重分配比较耗时,这种操作应该尽量避免。为了尽可能减少这种操作,Redis内部采用了两种策略:空间预分配和惰性空间释放

所谓空间预分配就是说在字符串需要增长的时候,程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。

所谓惰性空间释放是指在字符串需要缩短的时候,并不立刻收回内存,而是将那部分不用的空间仍然作为SDS的未使用空间,尽量避免以后再进行增长操作的时候空间不够用。

综合这两种策略,一放一留,程序有效地减少了内存重分配的次数,而这两种策略若没有len变量和free变量是不太可能实现的。

此外,由于采用了根据len属性而不是空字符来判断字符串结尾,SDS较C字符串还有“二进制安全(可以保存任意格式的二进制数据)”这一特性。

SDS和C字符串的对比如下表所示:SDS和C字符串的对比

底层数据结构二:双端链表

链表节点的结构为:

typedef struct listNode {

    // 前置节点
    struct listNode *prev;

    // 后置节点
    struct listNode *next;

    // 节点的值
    void *value;

} listNode;

链表的结构为:

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;

链表这一块仅就实现而言没有太多好说的,值得注意的有以下几点:

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

底层数据结构三:字典

这个标题或许要打一个引号,因为Redis 的字典使用是哈希表作为底层实现的
让我们先来看字典结构的具体内容

typedef struct dict {

    // 类型特定函数
    dictType *type;

    // 私有数据
    void *privdata;

    // 哈希表
    dictht ht[2];

    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;

其中包含四个变量:
字典结构中的第一个变量是一个指针,指向一个名为dictType的结构,该结构保存了一簇用于操作特定类型键值对的函数,具体内容如下:

typedef struct dictType {

    // 计算哈希值的函数
    unsigned int (*hashFunction)(const void *key);

    // 复制键的函数
    void *(*keyDup)(void *privdata, const void *key);

    // 复制值的函数
    void *(*valDup)(void *privdata, const void *obj);

    // 对比键的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);

    // 销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);

    // 销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);

} dictType;

关于字典的API以上述这些函数为基础。

*字典结构的第二个变量为一个void类型的指针,指向了需要传给那些类型特定函数的可选参数

字典结构的第三个变量为一个元素数量为2的哈希表类型数组。一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。

字典结构的第四个变量为一个int型的变量 rehashidx,它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。

介绍完了字典结构的具体内容,让我们再来看哈希表的结构内容

typedef struct dictht {

    // 哈希表数组
    dictEntry **table;

    // 哈希表大小
    unsigned long size;

    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;

    // 该哈希表已有节点的数量
    unsigned long used;

} dictht;

其中也包含四个变量
table 属性是一个数组, 数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对。
size 属性记录了哈希表的大小, 也即是 table 数组的大小, 而 used 属性则记录了哈希表目前已有节点(键值对)的数量。
sizemask 属性的值总是等于 size - 1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。

介绍完了哈希表,我们再来看一下哈希表节点

typedef struct dictEntry {

    // 键
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;

} dictEntry;

key 属性保存着键值对中的键, 而 v 属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数(这实现了多态)。

next 属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。

下图展示了如何通过 next 指针, 将两个索引值相同的键 k1 和 k0 连接在一起在这里插入图片描述

底层数据结构四:跳跃表

跳跃表这一数据结构我之前没有接触过,据我目前的理解,它大概算是链表的一种一般形式,比链表的功能要更强。

跳跃表节点的结构如下:

typedef struct zskiplistNode {

    // 后退指针
    struct zskiplistNode *backward;

    // 分值
    double score;

    // 成员对象
    robj *obj;

    // 层
    struct zskiplistLevel {

        // 前进指针
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;

跳跃表的结构如下:

typedef struct zskiplist {

    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;

    // 表中节点的数量
    unsigned long length;

    // 表中层数最大的节点的层数
    int level;

} zskiplist;

下面给出一个跳跃表的实例来解释两个结构中变量的具体含义:
在这里插入图片描述
位于图片最左边的是 zskiplist 结构, 该结构包含了四个属性:
header :指向跳跃表的表头节点。
tail :指向跳跃表的表尾节点。
level :记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
length :记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

位于 zskiplist 结构右方的是四个 zskiplistNode 结构, 该结构包含以下属性:

层(level):节点中用 L1 、 L2 、 L3 等字样标记节点的各个层, L1 代表第一层, L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。

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

分值(score):各个节点中的 1.0 、 2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。成员对象(obj):各个节点中的 o1 、 o2 和 o3 是节点所保存的成员对象。

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

在首次看跳跃表的时候,我最大的问题就是它的应用,不知道这种数据结构能用来干什么。但是经过阅读源码及后面的学习,我明白了它是用来实现有序集合的(根据分值排序)。

底层数据结构五:整数集合

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

整数集合结构的定义如下所示:

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 类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )。

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

如果 encoding 属性的值为 INTSET_ENC_INT64 , 那么 contents 就是一个 int64_t 类型的数组, 数组里的每个项都是一个 int64_t 类型的整数值 (最小值为 -9,223,372,036,854,775,808 ,最大值为 9,223,372,036,854,775,807 )。

那么这里就有两个问题:

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

第二个问题是降级时的处理,Redis没有对降级的相关操作,一旦对数组进行了升级, 编码就会一直保持升级后的状态。

采用多级的好处是节约内存

底层数据结构六:压缩列表

压缩列表是一种多能的数据结构,可被用作列表键和哈希键的底层实现之一。当然,顾名思义,它的好处也是节约内存

压缩列表节点结构定义如下:

typedef struct zlentry {
    // prevrawlen :前置节点的长度   
     // prevrawlensize :编码 prevrawlen 所需的字节大小    
     unsigned int prevrawlensize, prevrawlen;
     
    // len :当前节点值的长度    
    // lensize :编码 len 所需的字节大小    
    unsigned int lensize, len;
    
    // 当前节点 header 的大小    
    // 等于 prevrawlensize + lensize    
    unsigned int headersize;
    
    // 当前节点值所使用的编码类型    
    unsigned char encoding;
    
    // 指向当前节点的指针    
    unsigned char *p;
} zlentry;

底层数据结构和上层Value五种类型的对应关系

字符串类型对象用SDS作为底层实现;

列表类型对象用压缩列表(ziplist)或双端链表(linkedlist)作为底层实现;

需要注意的是:
当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist 编码:
列表对象保存的所有字符串元素的长度都小于 64 字节;
列表对象保存的元素数量小于 512 个;

不能满足这两个条件的列表对象需要使用 linkedlist 编码。

哈希类型对象用压缩列表或字典作为底层实现l;

集合类型对象用整数集合或字典作为底层实现;

有序集合类型对象用压缩列表或者跳跃表作为底层实现。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值