Redis的底层数据结构

         一、Redis的数据结构简介

二、简单动态字符串SDS

三、链表

四、字典

五、跳跃表

六、整数集合

七、压缩列表

八、总结


 

一、Redis的数据结构简介

Redis 的底层数据结构一共有6种,分别为:简单动态字符串(SDS)、链表、字典、跳跃表、整数集合、压缩列表。

Redis的数据类型一共有5种,分别为:字符串对象、列表对象、哈希对象、集合对象、有序集合对象。这五大数据类型(数据对象)都是由一种或几种数据结构组成。

在命令行中,可以使用 OBJECT ENCODING key查看key的数据结构

二、简单动态字符串SDS

redis是使用C语言编写的,但是string数据类型并没有使用C语言的字符串,而是重新编写一个简单的动态字符串(simple dynamic string,缩写为SDS)。

/*
 * 保存字符串对象的结构
 */
struct sdshdr {
    
   // buf 中已占用空间的长度
   int len;
    
   // buf 中剩余可用空间的长度
   int free;
  
   // 数据空间
   char buf[]
};

 

使用SDS保存字符串Redis,具体表示如下:

                                              图片来自《Redis设计与实现》 黄健宏著

各字段含义分别为:
    free表示buf数组中剩余的空间数量。
    len记录了buf数组中已存储的字节长度。
    buf数组是一个char类型的数据,记录具体存储的字符串,并且以 ‘\0’(空字符) 作为结束标识符。

与C语言的字符串相比,SDS定义多出两个属性free、len,redis作者为何不直接使用C语言的字符串呢?原因为:

1、获取字符串长度复杂度为O(1)

由于C语言没有存储字符串长度,每次获取字符串长度时,需要循环整个字符串进行计算,时间复杂度为O(N)。
SDS记录了存储的字符串的长度,获取字符串长度时,直接获取len的属性值即可,时间复杂度为O(1)。SDS中设置和更新长度是API中自动完成,无需手动进行操作。

2、杜绝缓冲区溢出

C语言在进行两个字符串拼接时,一旦没有分配足够的内存空间,就会造成溢出。
SDS在修改字符串时,会先根据len的值,检查内存空间是否足够。如果不足,会先分配内存空间,再进行字符串修改。这样,就杜绝了缓冲区溢出。

3、减少修改字符串时带来的内存重新分配次数

C语言不记录字符串长度,所以当修改字符串时,会重新分配内存。如果是正常字符串,内存空间不够,则会产生溢出。如果是缩短字符串,不重新分配内容空间,则会产生泄露。
SDS采用空间预分配惰性释放空间两种优化策略:
空间预分配:对字符串进行增长操作,会分配出多余的未使用空间,为以后扩展作准备。在一定程度上,这可以减少内存重新分配的次数。
惰性释放空间:对字符串经过缩短操作,并不会立即释放这些空间,而是使用free来记录这些空间的数量。当进行增长操作时,这些记录的空间就可以被重新利用。SDS提供了相应API进行手动释放空间,所以不会造成内存浪费。

4、二进制安全

C语言的字符串中不能包含空字符(因为C语言是以空字符判断字符串结尾的),所以不能保存一些二进制文件(有可能包含空字符,如图片)。
SDS是以len来判断字符串结尾,所以SDS结构可以存储图片等,并且都是以二进制方式进行处理。

5、兼容部分C字符串函数

SDS结构中,buf字段保存的字符串同样是以空字符结尾,所以可以兼容C语言的部分字符串操作API。

总结:

                                             表来源:《Redis设计与实现》

三、链表

Redis使用C语言编写,但C语言并没有内置链表这种数据结构,所以redis构建了自己的链表实现。

链表使用非常广泛,如列表键、发布与订阅、慢查询、监视器等。构成链表结构的节点为链表节点,数据结构为:

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

多个listNode可以通过prev和next指针构成双端链表,使用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;

 

各字段含义分别为:
head 表头指针。
tail 表尾指针。
len 链表长度计数器
dup、free、match 多态链表所需的类型特定的函数。

Redis链表结构示例图,如下所示:

Redis链表实现的特性如下:

1、双端

链表节点带有prev和next指针,可以快速获取前置和后置节点,时间复杂度都是O(1)。

2、无环

 头节点prev指针和尾节点next指针都指向null,对链表访问以NULL为终点。

3、带表头指针和表尾指针

可以快速获取表头节点和表尾节点。

4、有链表长度计数器

可以快速获取链表长度。 

5、多态

链表可以保存各种不同类型的值,通过list的dup、free、match三个属性,为节点设计“值类型特定的函数”。

四、字典

字典又称为符号表(symbol table)、关联数组(associative array)或者映射(map)。字典中存储key-value键值对,并且key不重复。字典在Redis中广泛应用,例如Redis数据库的底层实现就是字典。

Redis使用的C语言没有内置这种数据结构,所以Redis构建了自己的字典实现。

字典使用哈希表作为底层实现,一个哈希表包含多个哈希节点,每个哈希节点保存一个键值对

哈希表节点的数据结构,如下所示:

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

各字段含义分别为:
key属性保存着键值对中的键。
v属性保存着键值对中的值,可以是指针val、uint64_t整数、int64_t整数。
next是指向另一个哈希表节点的指针,用以解决多个哈希值冲突问题。

哈希表的数据结构,如下所示:

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

各字段含义分别为:
table是一个数组,数组元素是dictEntry结构的指针,每个dictEntry保存一个键值对。
size 记录哈希表的大小。
sizemask 值总是等于size-1,这个属性和哈希值一起决定“一个键应该被放到table数组的哪个索引上”。
used 记录哈希表目前已有节点的数量。

hash表结构示例图,如下所示:

                                           一个大小为4的空哈希表

下图展示的是“在哈希表中,将两个索引值相同的键连在一起”。

字典数据结构,如下所示:

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

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;

字典数据结构中,各字段值含义分别为:
type 属性是一个指向dictType结构的指针,每个dictType机构保存了一簇用于操作特定类型键值对函数。Redis会为“用途不同的字典”设置“不同的类型特定函数”。
privdata 属性保存了需要传给那些类型特定函数的可选参数。
ht 属性是一个长度为2的数组,数组中的每个元素都是一个哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在进行rehash时使用。
trehashidx 属性记录了rehash目前的进度。如果没有进行rehash,则它的值为-1。

下图所示为普通状态下的字典结构。

当一个新的键值对要添加到字典中去时,会涉及到一系列的操作,如计算索引、解决冲突、扩容等。下面对这些操作进行描述。

1、哈希算法

添加键值对时,首先要根据“键值对中的键”计算出哈希值和索引值,然后再根据索引值进行放入。伪代码如下所示:

 #使用字典设置的哈希函数,计算键key的哈希值。
 hash = dict->type->hashFunction(key);
 #使用哈希表的sizemask属性和哈希值,计算出索引值。根据情况不同,ht[x]可以是ht[0]或者ht[1]。
 index = hash & dict->ht[x].sizemask;

2、解决键冲突

当“有两个或以上数量的键值”被分配到了“哈希表数组的同一个索引上”时,就发生了键冲突。

Redis的哈希表使用单向链表解决键冲突问题,每个新的键总是添加到单项链表的表头

3、rehash(扩展或收缩)

哈希表具有负载因子(load factor),其始终需要保持在一个合理的范围之内。当hashI表保存的键值对过多或过少时,就需要对哈希表进行rehash(重新散列)操作,步骤许下:

(1) 为字典的ht[1]分配空间,空间大小:如果是扩展操作,则为ht[0].used * 2 ,也就是扩展为当前哈希表已使用空间的1倍;如果是收缩,则减小1倍。

(2) 将ht[0]内的数据重新计算哈希值和索引,并放到新分配的ht[1]空间上。

(3) 全部迁移完成后,将ht[1]设置为ht[0],释放ht[0],并创建一个空白的哈希表为ht[1],为下次rehash做准备。

4、哈希表的扩展与收缩触发条件

哈希表扩展条件:

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

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

以上条件中任意一条被满足,程序自动开始对哈希表进行扩展;

负载因子算法:负载因子 = 哈希表已保存的节点数量 / 哈希表大小。

哈希表收缩条件:

当负载因子小于0.1时,程序自动进行收缩操作。

5、渐进式rehash

渐进式rehash就是:在ht[0]的键值对向ht[1]迁移的过程中,如果数据量过大,则不能一次性迁移; 否则,会对服务器性能造成影响,而是分成多次,渐进式的进行迁移。

在rehash期间,会维持一个索引计数器rehashidx,并把迁移工作分配到每次的添加、删除、查找、更新操作中,当rehash工作完成后,rehashidx会增加1。ht[0]的值全部迁移完成后,程序会将rehashidx这是为-1,标识最终的rehash完成。

6、渐进式rehash期间的表操作

在渐进式rehash期间,ht[0]和ht[1]中都有数据。当查找时,会先在ht[0]中进行,若在ht[0]中没找到,则继续到ht[1]中找。添加操作一律会添加到ht[1]中。

五、跳跃表

跳跃表(skiplist)数据结构特点是:每个节点中有多个指向其他节点的指针,从而快速访问节点。

跳跃表zskiplist由跳跃表节点zskiplistNode组成。

跳跃表节点的数据结构,如下所示:

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

各字段含义如下所示:
层:为一个数组,数组中的每个数据都包含前进指针和跨度
前进指针:指向表尾方向的其他节点的指针,用于从表头方向到表尾方向快速访问节点。
跨度:记录两个节点之间的距离,跨度越大,两个节点相聚越远。所有指向NULL的前进指针的跨度都为0。
后退指针:用于从表尾节点向表头节点访问,每个节点都有后退指针,并且每次只能后退一个节点。
分值:节点的分值是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大排列。
成员对象:是一个指向字符串的指针,字符串则保存着一个SDS值。

跳跃表的数据结构,如下所示:

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

各字段含义如下所示:
header指向跳跃表的表头节点。
tail指向跳跃表的表尾节点。
level记录节点中的最大层数(不含表头节点)。
length记录跳跃表的长度,即跳跃表目前包含的节点数量(不含表头节点)。


跳跃表节点由很多层构成(L1、L2 ...),每个层都带有两个属性:前进指针和跨度。
每个跳跃表节点都包含成员对象(obj)、分值(score)、后退指针(backward)。头结点也包含这些属性,但不会被用到。
此处只是介绍跳跃表的结构相关内容。关于跳跃表节点中层的形成、对象的插入、删除、查询等操作的原理,在此处不做详解。

下图所示为一个跳跃表结构示例。

 

六、整数集合

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

整数集合可以保存int16_t、int32_t、int64_t的整数值,并且不会出现重复元素。

整数集合的数据结构,如下所示:

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

各字段含义如下所示:
encoding编码方式有三种INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64,分别对应的是int16_t、int32_t、int64_t类型。
length记录整数集合的元素数量,即contents数组的长度。
contents数组存储的是集合中的每个元素,类型是int8_t,但存储数据的实际类型取决于编码方式encoding。

整数集合的升级操作

整数集合中原来保存的是小类型(如:int16_t)的整数,当插入比其类型大(如int_64_t)的整数时,会把“整数集合里元素的数据类型”都转换成“大的数据类型”,这个过程称为升级。

升级整数集合并添加新元素步骤如下:

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

(2)将现有的所有元素的类型转换成与新元素相同的类型,在保持原有数据有序性不变的情况下,把转换后的元素放在正确的位置上。

(3)将新元素添加到数组里。

新元素引发升级的条件是:“新元素的数据类型”比“所有元素的数据类型”都大。

  • 当小于所有元素时,新元素放在底层数组的最开头。
  • 当大于所有元素时,新元素放在底层数据的最末尾。

升级操作的好处

  • 提升整数的灵活性,可以任意的向集合中放入3中不同类型的整数,而不用担心类型错误。
  • 节约内存,整数集合中只有大类型出现的时候,才会进行升级操作。

整数集合不支持降级操作。

七、压缩列表

压缩列表(ziplist)是Redis为了节约内存而开发,是一系列特殊编码的连续内存块组成的顺序型数据结构。

一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。

压缩列表的结构,如下图所示。

各字段含义分别为:

每个压缩列表含有若干个节点,而每个节点都由三部分构成(previous_entry_length、encoding、content)。

压缩列表节点的结构,如下图所示:

 

各字段含义分别为:

  • previous_entry_length 存储的是前一个节点的长度,由于压缩列表内存块连续,使用此属性值,可以计算前一个节点的地址。压缩列表就是使用这一原理进行遍历。如果前一节点长度小于254字节,那么previous_entry_length属性本身长度为1字节,存储的值就是前一节点的长度;如果大于254个字节,那么previous_entry_length属性本身长度为5个字节,前一个字节为0xFE(十进制254),“之后四个字节”存储“前一节点的长度”。
  • encoding 记录本节点的content属性所保存数据的类型及长度,其本身长度为1字节、2字节或5字节。值的最高位为00、01或10的是字节数组的编码,最高位以11开头的是整数编码。
  • content 保存节点的值,可以是一个字节数组或者整数。

连锁更新

对压缩列表进行添加节点或删除节点时,有可能会引发连锁更新。
由于每个节点的 previous_entry_length 存在两种长度(1字节或5字节),当所有节点previous_entry_length都为1个字节时,若新节点的长度大于254个字节,那么新节点的后一个节点的previous_entry_length原来为1个字节,无法保存新节点的长度,这时就需要对其进行空间扩展,即“新节点的后一个节点的previous_entry_lengthprevious_entry_length属性”由原来的1个字节增加4个字节,变为5个字节。如果增加后原节点的长度超过了254个字节,则后续节点也要空间扩展。以此类推,最极端的情况是一直扩展到最后一个节点完成。这种现象称为连锁更新。在日常应用中,全部连锁更新的情况属于非常极端的,不常出现。

 

八、总结

Redis的底层数据结构共有6种,分别为:简单动态字符串(SDS)、链表、字典、跳跃表、整数集合、压缩列表。

在redis命令下,可以使用 OBJECT ENCODING 命令,查看键值对象的底层编码方式。

思考:Redis字典底层结构实现与java(1.6之前) 中的hashmap非常相像,都是使用单项链表解决键冲突问题。jdk1.8以上已经是用红黑树解决多个键冲突问题,不知redis的键冲突是否也可以用红黑树来解决?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值