图解 redis 之数据结构篇

1,225 阅读10分钟
原文链接: zhuanlan.zhihu.com
刚开始学redis,记录下自己的点滴过程,本文主要是针对redis的一些基础数据与算法.首先列出本文纲要

一.简单动态字符串(sds)

动态,即灵活性,可变化,主要是针对final String 和char[]而言,我们先看下它的数据结构

struct sdshdr{
    char [] buf; //字节数组,用来保存字符串
    int len;//  buf中已使用的长度,不包括\0,等于sds所保存的字符串长度
    int free;// 记录buf数组剩余空间的长度
}

假设现在又sds为buf分配5个字符,5个空余空间,则可用下图表示

<img src="https://pic3.zhimg.com/v2-6efe59669eedc205431b26268f0eed22_b.png" data-rawwidth="800" data-rawheight="228" class="origin_image zh-lightbox-thumb" width="800" data-original="https://pic3.zhimg.com/v2-6efe59669eedc205431b26268f0eed22_r.png">

那么,我们谈谈这样设计的好处:

  1. 常数时间获取字符串长度

由于它底层保存了len,因此可以直接获取sds字符串长度,所以它是以O(1)的时间复杂度获取字符串长度;

2. 避免缓冲区溢出

它与char[]数组长度固定不同的是:它每次在插入元素前会先检查sds空间是否满足,若不满足,则做适当的扩充.因此避免了缓冲区溢出的可能性;


此外,在C字符串中,字符串形式固定为"sdds\0",即保存了n个字符的字符串是通过char[n+1]实现的,因此c字符串的长度和底层char数组是固定的,因为每次插入或删除字符,都要重新分配新的内存,来保存新值,因此它是一个很耗时的操作,那么sds是怎么优化的呢?

3. 减少了连续内存重分配的次数

sds采用空间预分配和惰性空间释放两种策略减少了内存重分配的次数


  • 空间预分配:用于优化SDS的字符串增长操作,当sds的api对sds添加内容时,程序不仅会为sds分配修改后所需的空间,还会分配额外的空间(free),具体策略如下
    • 如果现有空间足够存放修改后内容,则不进行扩展,直接插入
    • 如果现有空间不足以存放修改后内容,且在修改后sds的len小于1MB,则扩展后的空间len==free,比如修改后空间为15byte,那么分配的空间为31byte:15byte(len)+15byte(free)+1byte
    • 如果不足以存放修改后内容,且修改后len>1MB,则分配1MB的未使用时间.比如修改后为20MB,则新空间为20MB(len)+1MB(free)+1byte.

在后两种情况后,加入我们再插入abc,因为已有足够空间,所以不需要再重新分配内存,因此,空间预分配策略减少了增长字符串时导致的内存重分配次数.
  • 惰性空间释放:用于优化SDS缩减操作.当api对sds进行缩短操作后,程序不会释放缩短后空余出的空间,而是使用free属性把他们记录下来,当我们插入的时候就可以减少内存重分配了.

当然,你也不需要担心,我们以后不添加内容时,造成的内存泄露,因为sds提供了释放空间的API

4. 二进制安全

C字符串的字符必须某种编码(比如ASCII),并且除末尾外,其余地方不能出现空字符串\0,否则会被提前结束,(\0是c字符串的结束标识符),比如acvf\0sdsdsd\0,只能识别到acvf.

而这个特性注定它只能保存文本数据,而不能保存图片,音频等二进制数据.为了确保redis适用于不同的应用场景,SDS的API都是二进制安全的,即所有的SDS都会以处理二进制方式来处理SDS中buf数组内容,程序不会对它的内容进行限制和过滤,以保证它的原有状态.这也是把buf设计成字节数组的原因.举个例子,由于sds是根据len属性来判断字符串的结束,所以对下图数据我们将读取到redis\0cluster

<img src="https://pic3.zhimg.com/v2-6a2f42808be623d14734f7262bf9d4ce_b.png" data-rawwidth="1130" data-rawheight="252" class="origin_image zh-lightbox-thumb" width="1130" data-original="https://pic3.zhimg.com/v2-6a2f42808be623d14734f7262bf9d4ce_r.png">

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

由于SDS的buf数组以\0结尾,符合c字符串特征,因此,它可以使用C字符串的一些api


二.链表

<img src="https://pic3.zhimg.com/v2-e03032e7bd4be0ebb06f4c30cdb4c80a_b.png" data-rawwidth="992" data-rawheight="318" class="origin_image zh-lightbox-thumb" width="992" data-original="https://pic3.zhimg.com/v2-e03032e7bd4be0ebb06f4c30cdb4c80a_r.png">
struct listNode{
    struct listNode *prev//前置节点
    struct listNode *next//后置节点
    void *value //节点值
}listNode;

struct list{
    listNode *head;
    listNode *tail;
    unsigned long len//常数时间获取链表长度
    ....
}list;

链表结构为双向无环结构(tail.next==null,header.pre==null),可直接获取节点个数,它被广泛用在redis的各种功能,如列表键,发布和订阅,慢查询,监视器等

三.字典(dict)

字典是一种用来存放键值对的数据结构,它在redis中应用非常广泛,增删改查都是基于它,如set a hello 就是创建了一个key为a值为hello的键值对.

它使用哈希表作为底层实现,一个哈希表中可有多个哈希节点,每个节点又保存一个键值对

哈希表节点:

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

struct dictEntry{
    void *key;
    union{void val;
          uint64_tu64;
          int64_ts64}v;
    struct dictEntry *next//指向下个节点形成链表,以解决哈希冲突
}

字典:dict(摘自redis设计与实现)

<img src="https://pic2.zhimg.com/v2-6ba048265cda1b1e57759996c87631d9_b.png" data-rawwidth="972" data-rawheight="1152" class="origin_image zh-lightbox-thumb" width="972" data-original="https://pic2.zhimg.com/v2-6ba048265cda1b1e57759996c87631d9_r.png">

<img src="https://pic4.zhimg.com/v2-ec2997e973981b46a2e662e2aacf0797_b.png" data-rawwidth="1282" data-rawheight="760" class="origin_image zh-lightbox-thumb" width="1282" data-original="https://pic4.zhimg.com/v2-ec2997e973981b46a2e662e2aacf0797_r.png">对于hash算法以及hash求索引和java中的HashMap思路大同小异,这里就不提了,下面说说它的重新散列rehash

对于hash算法以及hash求索引和java中的HashMap思路大同小异,这里就不提了,下面说说它的重新散列rehash

  • rehash

随着操作的不断执行,哈希表键值对数目也会变化,为了始终控制负载因子在合适的范围,程序需要对哈希表大小进行扩展或收缩,扩展和收缩工作可通过重新散列完成,步骤如下:

  1. 为字典的h[1]哈希表分配空间,大小取决于要执行的操作:扩展操作则h[1].size为第一个大于h[0].size*2的2^n,若是收缩,则h[1].size为第一个大于h[0].size的2^n,如h[0].size=3,那么扩展时3*2=6,则h[1].size=2^3,若收缩,则为2^2
  2. 把h[0]中所有键值对重新散列(rehash)到h[1]
  3. 当2完成后,释放h[0],把h[1]设置为h[0],并在h[1]新建一个空白hash表,为下次rehash做准备

上面的一切看似完美,但是假象如果有千万级数据需要迁移,那么这样集中式的处理必将耗费大量时间,所以redis采用分而治之的思想,把rehash操作分摊到接下来的数据库操作上,过程如下:

<img src="https://pic4.zhimg.com/v2-fc6890d4c9856f8d4a509a2020306b93_b.png" data-rawwidth="1318" data-rawheight="522" class="origin_image zh-lightbox-thumb" width="1318" data-original="https://pic4.zhimg.com/v2-fc6890d4c9856f8d4a509a2020306b93_r.png">图解部分,可查阅<<redis设计与实现33页>>

图解部分,可查阅<<redis设计与实现33页>>

四. 跳跃表

跳跃表是一种有序的数据结构,通过在节点中维持多个指向其它节点的指针,达到快速访问的目的.由于节点中按照score排序,所以被用来实现sorted set,下面贴出它的示意图(null标志着遍历结束)

&amp;amp;amp;lt;img src="https://pic3.zhimg.com/v2-4f521f403eaed76164d4bdb8d7fb5d66_b.png" data-rawwidth="1102" data-rawheight="416" class="origin_image zh-lightbox-thumb" width="1102" data-original="https://pic3.zhimg.com/v2-4f521f403eaed76164d4bdb8d7fb5d66_r.png"&amp;amp;amp;gt;下面我们先介绍它的跳跃节点:一个跳跃节点由以下几部分组成:

下面我们先介绍它的跳跃节点:一个跳跃节点由以下几部分组成:
  • 层level:跳跃表节点的level数组可以包含多个元素,每个元素都是一个指向其他节点的指针.层由前进指针和跨度组成,这两个属性可以唯一确定一个前方节点.
  • 后退节点backword(bw),指向前置节点的指针
  • 分值(score):score,double类型,跳跃表的节点是按分值大小升序排列,如果分值相同,按照成员对象在字典序中的大小排序
  • 成员对象obj,指向一个字符串对象

下面看看跳跃表节点的数据结构代码

typedef struct zskiplistNode {
    robj *obj;//成员对象obj
    double score;//分值
    struct zskiplistNode *backward;//后退节点
    struct zskiplistLevel {
        struct zskiplistNode *forward;//前进节点
        unsigned int span;//跨度
     } level[];//层数组,元素指向其他节点(表头除外)
   } zskiplistNode

再看看跳跃表的数据结构代码

typedef struct zskiplist {
       struct zskiplistNode *header, *tail;//头尾
       unsigned long length;//长度,以O(1)获取跳跃表长度
       int level;//表中层最大的节点的层数
    } zskiplist;

五. 整数集合


整数集合(intset)是集合建的底层实现之一,当一个集合只包含整数值元素,且数量不多时,redis会采用intset作为集合建的底层实现.它可以保存int16_,int32_,int64_的整数值,并且保证集合中元素不重复,接下来看看它的数据结构代码

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

需要提一点:contents数组存放的元素类型是由encoding决定,encodin是int16_,int32_,int64_,contents元素类型也将对应为int16_,int32_,int64_

  • 升级

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

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

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

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

  • 升级的好处:
    • 提升整数集合的灵活性:由于可以自动升级,我们可以随意的插入元素
    • 尽可能地节约内存:C字符串中要让一个数组同时保存16,32,64位是直接声明一个int64数组,而redis的intset它可以先用16位存,等遇到更大的时,再升级,避免了盲目使用大内存

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

最后看看intset数据结构示意图:

&amp;amp;amp;lt;img src="https://pic3.zhimg.com/v2-867b56401da0d668ab45a152f2532d5a_b.png" data-rawwidth="684" data-rawheight="240" class="origin_image zh-lightbox-thumb" width="684" data-original="https://pic3.zhimg.com/v2-867b56401da0d668ab45a152f2532d5a_r.png"&amp;amp;amp;gt;

redis的基本数据结构已经介绍完,接下来让我们学习 图解redis之对象