Redis基础数据结构-基于2.8

SDS

SDS是Redis中String的底层数据结构,数据结构如下,SDS保留了传统的C字符串表达方式即数组的最后一个元素是'/0'结尾。此外还添加了两个字段len和free,其中len表示字符串长度,free代表空闲空间。

class sds {
    int len;
    int free;
    char[] buf;
}

那么这两个添加的元素有什么作用呢?

  • 常数复杂度获取字符长度。首先第一点就是C数组是不记录长度的,那么为了获取字符串的长度每次就得遍历数组内的全部元素,这无疑会增加时间损耗,有了len就可以实时记录数组长度,获取字符串长度的时间复杂度由O(n)变成了O(1)
  • 杜绝缓冲溢出。我们知道传统的C数组是不记录本身长度的,默认会认为你为要纳入数组的元素分配了足够的内存,如果不对边界进行校验的话可能就会发生缓冲区溢出的情况,举个例子,比如两个字符串数组S1,S2长度都为6,S1 = [ 'H','e', 'l', 'l', 'o', '/0']S2 = [ 'W','o', 'r, 'l', 'd, '/0']两个数组刚好在一段连续的内存中即长度为12的内存中,如果S1数组的index = 6 添加字符'w',由于S1长度不足7,那么可能就会溢出到S2的 index = 0 的内存里。而SDS添加元素的API里通过对free的校验若内存不足则进行扩展,则完全避免了这种缓冲溢出的情况。
  • 减少内存分配的次数。上面提到如果free的空间不满足添加元素长度的需求则需要对SDS的buf进行扩容。此外还有减少元素时回收内存避免内存泄漏。Redis对性能要求是很高的,尤其是内存数据修改更是频繁,频繁的更改和扩容会对性能产生巨大影响,因此Redis使用两种策略来避免这种情况。第一是空间预分配,空间预分配会在buf长度小于1MB时分配和len相等的free长度,大于1MB时固定额外分配1MB的空间。第二是惰性空间释放,buf缩容时free会增加但不会实际回收对应内存,以便后续使用。
  • 二进制安全。C数组中除了字符串的末尾之外是不能记录空字符的,否则会被认为是字符串的结尾。而SDS由于len等有标识字符长度所以是二进制安全的可以用来保存各种数据。
  • 兼容C函数API。由于SDS的本质还是buf这个字符数组,所以本质上可以使用如strcmp等C函数。

LinkedList(链表)

典型的双端链表结构,无环,带有链表计数器,支持多态

// 节点数据结构
class ListNode {
  ListNode prev;
  ListNode next;
  int value;
}
    
class List {
  ListNode head;
  ListNode tail;
  long len;
  // 赋值连表保存的值
  void dup();
  // 比较连表的值和某个输入值
  int match();
  // 释放连表保存的值
  void free();
}
   

Dict(字典)

数据结构:字典是Redis最基础的结构,实现和Java的HashMap类似。即拉链法解决hash冲突的方式。数据结构如下。

// 键值对数据结构
class DictEntry<K, V> {
    K key;
    V value;
    // 拉链法解决hash冲突,一个key一个bucket,相同buket路由到同一个链表
    dictEntry next; 
}
// hash表数据结构
class Dicht{
	DictEntry[] table;
    // hash表大小,即table数组大小
    int size;
    // 掩码,用于计算索引值,总是等于size减1
    int sizeMask = size - 1;
    // 键值对数量
    long used;
}
// dict字典数据结构
class Dict {
    // ht相当于master加replicat,ht[1]只在reshash的时候使用
    Dict ht[2];
    // -1 不在rehash,>= 0 rehash中
   	int rehashidx;
}

哈希冲突:当一个元素加入dict时,会先对hash函数计算对象的hash值,然后再和sizeMask作与运算,这里的与运算其实就是对size取余的操作(因为size为2nsizeMask = size - 1,那么sizeMask低位都为1),以便将hash值放在指定范围的数组下标内。如果产生hash冲突会通过拉链法将相同hash值的数据通过链表链接从而放到数组的一个bucket下标内。

扩展与收缩:hash表中有一个loadFactor即负载因子,计算方式:loadFactor= ht[0].used / ht[0].size,其实就是hash表的使用率,为了维持负载因子在合理的范围内,就需要对hash表进行扩展和收缩,触发扩展的条件是:loadFactor > 1 && noBGSAVEOrBGREWRITEAOF(没有BgSave或BgRewriteAof) || loadFactor > 5,触发收缩的条件是loadFactor < 0.1实现扩展收缩的条件是rehash,rehash大致步骤如下

  1. ht[1]分配空间,大小是第一个大于或等于ht[0].used*2的2的次幂。
  2. ht[0]的元素rehash到ht[1],因为此处sizeMask变了,所以对应的与运算的值也会发生改变。
  3. 交换ht[0]ht[1],即释放ht[0]的空间,将ht[0]指向ht[1]

渐进式rehash:如果hash表的元素很多,一次性rehash可能会造成性能问题甚至一段时间不可用。所以rehash一般是渐进式的,渐进式的rehash步骤如下

  1. ht[1]分配空间,置rehashidx = 0
  2. 在元素进行添加、更新、查找、删除时会顺带将在索引为 rehashidx的元素从ht[0]rehash到ht[1]上。每个索引rehash完成后rehashidx++
  3. 最终所有索引的键值对完成rehash时,交换ht[0]ht[1],置rehashidx = -1

注意在渐进式rehash过程中,元素查找涉及两个hash表,会先查找ht[0]再对ht[1]进行查找

SkipList(跳跃表)

跳表是一种类似于树的层级结构,数据结构如下:

class SkipListNode{
    // 当前节点在每一层的标识,maxSize = 32
    Level[] level;
    // 后退指针,即指向上一节点
    SkipListNode backword;
    // 当前节点值
    Object value;
    // value的score值,通过这个进行排序,从小到大
    int score;
    // 每层的数据结构
    class Level {
        // 当前层级对应的下一个节点
        SkipListNode next;
        // 当前层级两个节点跨度,通过这个可以快速在任意层级判断当前的元素位置
        int span;
    }
}


class SkipList {
  	// 头节点
    SkipListNode head;
    // 尾节点
    SkipListNode tail;
    // 跳表元素长度
    int length;
    // 节点中层级最大的值
    int level;
}

跳表是一层一层向上选举节点,比如一组链表在Level[0]时是0->1->2->3->4->5->6->7->8],通过随机向上选举那么Level[1]的表示可能为0->3->6->8。同理可以继续晚上选举。通过这种方式从而达到O(logn)的查询时间复杂度。

IntSet(整数集合)

整数集合如其名记录一系列整数,是底层集合键的底层实现之一,数据结构如下

class IntSet{
    // int_8,int_32,int_64
    Object encoding;
    // 保存整数集合的
    int[] contents;
    // 整数集合的长度
    int length;
}

contents中的元素是有序且无重复的,当新加入的元素大于encoding所能支持的长度时,IntSet便会进行升级操作,即修改contents的分配内存策略,并重新索引其中的元素,需要注意的是并不支持降级操作。这种升级策略尽可能保证了数据结构的灵活性的同时节约了内存。

ZipList(压缩链表)

压缩链表是链表键和哈希键的底层实现之一。数据结构如下

// 压缩链表节点
class ZipListNode {
    // value的类型及长度
    int encoding;
    // 实际值
    Object contents;
    // 前一节点长度,1byte(前一节点长度小于255)或者5byte(前一节点长度大于255)
    Object previousEntryLength;
}
// 压缩链表
class ZipList{
    // 压缩链表字节数,4bytes
	int zlBytes;
    // 压缩链表结尾距离起始节点的地址偏移量,4bytes
    int zlTail;
    // 节点数量 2bytes 
    short zlLen;
    // 压缩链表元素,不定长
    ZipListNode[] entries;
    // 1byte 固定0Xff即255
    byte zllend;
}

压缩链表在链表键或哈希键较短时作为底层实现,可以大大节省内存资源。每一个压缩链表由头部,节点,尾部三部分组成。其中头部由压缩链表字节数(zlBytes)、压缩链表尾部地址(zlTail)、元素数量(zlLen)组成。尾部是固定的数字0xff即标识EOF。

而在头部和尾部中间便是实际存储的元素节点。压缩链表的节点由previous Entry Length(前一节点长度,单位byte)、contents(元素值)、encoding(元素编码组成)。通过前一元素的长度q以及当前元素的起始地址 p 可以很容易前一元素的地址即p-q。因此能够容易的实现从表尾向表头遍历。encoding保存了contents的数据类型及长度。encoding的最高两位如果11且encoding长度为1byte代表记录的是整数值,其他情况保存的是字节数组(字节数组低位标识数组最大长度)。

连锁更新:由于节点的previousEntryLength记录了前一节点的长度,当前一节点长度发生变化时很有可能产生连锁反应,即连锁更新。比如当前元素长度本来是254字节,其中previousEntryLength是1字节。但如果前一元素的长度超过了255,则当前元素的previousEntryLength变成了5,当前元素的字节长度变成了258,这样当前元素的下一个元素也要变化,这样就发生了连锁更新。连锁更新会影响性能,但一定范围内是性能损耗是比较小的。

posted @ 2021-09-01 16:34  M104  阅读(39)  评论(0编辑  收藏  举报