redis-六种数据结构

六种数据结构

简单动态字符串,链表,字典,跳跃表,整数集合,压缩列表

1. 简单动态字符串

redis使用了一种名为简单动态字符串(Simple dynamic string, SDS)的抽象类型来当做默认字符串表示。

1.1 SDS的定义

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

1.2 SDS与C字符串的优点

1.2.1 常数复杂度获取字符串长度。

1.2.2 杜绝缓冲区溢出。

SDS在扩展值时,会先检查目前所剩空间是否足以扩展,不满足会自动扩展。

1.2.3 减少修改字符串时带来的内存重分配次数。

有两个优化策略:空间预分配和惰性空间释放。

  • 空间预分配。如果对SDS修改后,SDS的长度小于1MB,那边会分配和len属性一样大的未使用空间,即len值和free的值是相同的。如果大于等于1MB,那么会分配1MB的未使用空间。
  • 惰性空间释放。释放空间不是真正的释放,不会使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节数量记录起来,并等待将来使用。在有需要时,可以调用相应的API,真正的释放SDS的未使用空间。

1.2.4 二进制安全。

使用了len来保存字符串长度,而不是像c语言一样靠'\0'来判断字符串结尾。所以,他不会对二进制数据做过多的解释,是二进制安全的。

1.2.5 兼容部分C字符串函数。

SDS的结尾是'\0'结尾的,所以兼容部分C字符串函数。

2. 链表

链表被广泛用于实现redis的各种功能,比如列表键,发布与订阅,慢查询,监视器等。

2.1 链表实现的特性

  • 双端:链表节点有prev和next指针。
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL。
  • 带表头指针和表尾指针:list结构的head指针和tail指针。
  • 带链表长度计数器:list结构的len属性来对list持有的链表节点进行计数。
  • 多态:链表节点使用void*指针来保存节点值,使得链表可以用于保存各种不同类型的值。(类似于java的多态)

3.字典

字典,又称为符号表(symbol table),关联数组(associative array)或映射(map),是一种用于保存键值对的抽象数据结构。字典被广泛用于实现redis的各种功能,其中包括数据库和哈希键。

3.1 字典的实现

普通状态下的字典.png

哈希表结构如下所示:

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

每个dictEntry结构保存这一个键值对。dictEntry有一个指向下一个结点的指针,用于哈希冲突时,用链地址法(解决键冲突问题)来保存数据。新节点都是保存到链表的表头问题,为了性能考虑,复杂度为O(1)。

3.2 哈希算法

# 使用字典设置的哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);

# 使用哈希表的sizemask属性和哈希值,计算出索引值。根据使用情况不同,ht[x] 可以是ht[0] 或者 ht[1]
index =  hash & ht[x].sizemask;

redis目前使用MurmurHash2算法来计算键的哈希值。

3.3 rehash

rehash的步骤:

  • 1.为ht[1]分配空间。如果是扩展操作,则ht[1]的大小为第一个大于等于ht[0].used*2的2^n的数字(比如ht[0].used=3,那么ht[1]=8,因为第一个大于等于6的2^3 = 8)。如果是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2^n(比如ht[0].used=3, 那么ht[1]=4)
  • 2.将保存在ht[0]的所有键值对rehash到ht[1]上面。
  • 3.键值对迁移完成后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

3.4 哈希表的扩展与收缩条件

扩展(下面两个条件任意一个被满足,则自动扩展)

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

当哈希表的负载因子小于0.1时,程序自动对哈希表做收缩操作。

3.5 渐进式rehash

rehash并不是一次性完成,因为可能会对服务器性能造成影响,所以采用rehash的方式来进行。
步骤如下:

  • 1.为ht[1]分配空降,字典同时持有两个 哈希表。
  • 2.在字典中为维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
  • 3.在rehash进行期间,每次对字典执行添加,删除,查找或者更新操作时,程序除了执行指定的操作之外,还会顺带将ht[0]哈希表zairehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成时,rehashidx属性的值+1.
  • 4.随之字典操作的不断进行,最终,ht[0]的所有键值对都会被rehash到ht[1],这时程序将rehashidx属性的值设置为-1,表示rehash操作已经完成。

在渐进式rehash期间,字典的查找,删除,更新等操作会在两个哈希表上进行。但是新添加的键值对一律保存到ht[1]里面,而ht[0]则不做任何操作。保证ht[0]的键值对数量只减不增,并随着rehash的进行最终变成空表。

4. 跳跃表

跳跃表的插入待研究:随机生成层数后,如何将各层的各个指针连起来呢?
跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找。redis只有两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。

4.1 跳跃表的实现

跳跃表的实现.png

4.1.1 跳跃表结构

上图最左边是zskiplist结构,有以下属性:

  • header:指向跳跃表的表头节点;
  • tail: 指向跳跃表的表尾节点;
  • level: 记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
  • length: 记录目前跳跃表的长度,也就是跳跃表目前包含节点的数量(表头节点不计算在内)。
typedef struct zskiplist {
    //表头节点和表尾节点
    struct zskiplistNode *header, *tail;

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

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

4.1.2 跳跃表节点结构

位于zskiplist结构右方的是四个zskiplistNode结构,即跳跃表节点。有以下属性:

  • 层(level): L1,L2,L3代表各个层。每层有两个属性:前进指针和跨度。跨度表示前进指针指向节点和当前节点的距离。当程序从表头向表尾遍历时,访问会沿着尾的前进指针进行。
  • 后退指针(backward):节点用BW来表示,指向前一个节点。
  • 分值(score):1.0,2.0,3.0是节点所保存的分值。在跳跃表中,节点按照各自所保存的分值从小到大排列。
  • 成员对象(obj):各个节点中的o1,o2,o3是节点所保存的成员对象。
typedef struct zskiplistNode
{
    //后退指针
    struct zskiplistNode *backward;

    // 分值
    double score;

    // 成员对象
    robj *obj;
    // 层
    struct zskiplistLevel
    {
        // 前进指针
        struct zskiplistNode *froward;

        // 跨度
        unsigned int span;
    } level [];
} zskiplistNode;

每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32中间的值作为level数组的大小,这个大小就是层的“高度”。

两个节点的跨度越大,它们相距得越远。跨度实际上是用来计算排位的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点按照成员对象在字典序中的大小来排序,小的节点排在前面(靠近表头的方向)。

5. 整数集合

整数集合是集合键的底层实现之一。它可以保存类型为int16_t, int32_t或者int64_t的整数值,并且保证集合中没有重复元素。

typedef struct intset
{
    // 编码方式
    uint32_t encoding;

    // 集合包含的元素数量
    uint32_t length;

    // 保存元素的数组
    int8_t contents[];
} intset;

contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序的排列,并且数组中不包含任何重复项。虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值。

重点:底层数组 有序,无重复。

5.1 升级

当我们要将一个新元素加到整数集合里面,并且类型比整数集合现有所有元素的类型都要长时,整数集合要先进行升级,然后才能添加进去。向整数集合添加新元素的时间复杂度是O(N).
步骤:

  • 1).根据新元素的类型,扩展整数集合底层的数组的空间大小(包括为新元素分配空间)。
  • 2).将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位置上,在放置元素过程中,需要维持底层数组的有序性质不变。
  • 3).将新元素添加到底层数组里面。

因为引发升级的新元素长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么大于所有现有元素,要么小鱼所有现有元素。所以新元素只会被放置在底层数组的最开头(索引为0),或者底层数组的最末尾(索引length-1)。

5.2 升级的好处

  • 一个是提升整数集合的灵活性(可以将不同类型的整数放置到集合中,而不必担心类型错误)。
  • 另一个是节约内存,只在有需要的时候进行升级,如果都是int16_t类型的值,数组底层实现就会是int16_t类型的数组了。

5.3 降级

整数集合不支持降级,一旦升级,编码就会保持升级后的状态。

6. 压缩列表

压缩列表是列表键和哈希键的底层实现之一。

6.1 压缩列表的实现

压缩列表是redis为了节约内存而开发的。有一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。


压缩列表的各个组成部分.png

压缩列表各个组成部分的详细说明.png

6.2 压缩列表节点构成

有三个属性:previous_entry_length, encoding, content.

  • previous_entry_length:记录了前一个节点的长度。长度可以使1字节或者5字节。作用:可以通过这个值计算出指向前一个节点起始地址的指针p。
  • encoding: 记录content所保存的数据的类型以及长度。
  • content: 负责保存节点的值,可以是一个字节数组或者证书。

6.3 连锁更新

因为前一节点的长度大于等于254字节,那么previous_entry_length属性需要5字节长来保存,而小于等于254字节,previous_entry_length属性需要1字节长来保存。而又因为是连续空间,所以新增或者删除节点都可能引起连锁更新,但这种操作出现的几率不高。
尽管连锁更新的复杂度较高,但他真正造成性能问题的几率还是很低的:

  • 首先,压缩列表里要恰好有多个连续的,长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见。
  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响。比如说,对三五个节点进行连锁更新是绝对不会影响性能的。

你可能感兴趣的:(redis-六种数据结构)