Redis设计与实现(一)数据结构

1,简单动态字符串(SDS)

redis的字符串不是直接用c语言的字符串,而是用了一种称为简单动态字符串(SDS)的抽象类型,并将其作为默认字符串。

redis中包含字符串值的键值对在底层都是由SDS实现的

(1)SDS定义

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

SDS遵循C字符串以空字符结尾的惯例,但是那1个字节不计算在len中

(2)SDS与C语言字符串的区别

1、常数复杂度获取字符串长度

C语言如果要获取字符串的长度,需要从第一个字符开始,遍历整个字符串,直到遍历到\0符号,时间复杂度是O(N),即字符串的长度。

  而redis由于已经存储了字符串的长度,因此,时间复杂度是O(1)。这样,避免了获取大字符串长度时时间的缓慢。

2、杜绝缓冲区溢出

  C语言给字符串开辟一个存储空间,如果对此存储空间的使用超过开辟的空间,会导致内存溢出。例如使用字符串拼接等方式时,就很容易出现此问题。而如果每次拼接之前都要计算每个字符串的长度,时间上又要耗费很久。

  redis的SDS中内置一个sdscat函数,也是用于字符串的拼接。但是在执行操作之前,其会先检查空间是否足够。如果free的值不够,会再申请内存空间,避免溢出。

3、减少内存分配次数

C语言的字符串长度和底层数组之间存在关联,因此字符串长度增加时需要再分配存储空间,避免溢出;字符串长度减少时,需要释放存储空间,避免内存泄漏。

redis的sds,主要是通过free字段,来进行判断。通过未使用空间大小,实现了空间预分配和惰性空间释放

1)空间预分配

当需要增长字符串时,sds不仅会分配足够的空间用于增长,还会预分配未使用空间。

  分配的规则是,如果增长字符串后,新的字符串比1MB小,则额外申请字符串当前所占空间的大小作为free值;如果增长后,字符串长度超过1MB,则额外申请1MB大小

  上述机制,避免了redis字符串增长情况下频繁申请空间的情况。每次字符串增长之前,sds会先检查空间是否足够,如果足够则直接使用预分配的空间,否则按照上述机制申请使用空间。

/*
 * 对 sds 中 buf 的长度进行扩展,确保在函数执行之后,
 * buf 至少会有 addlen + 1 长度的空余空间
 * (额外的 1 字节是为 \0 准备的)

 * 返回值
 *  sds :扩展成功返回扩展后的 sds
 *        扩展失败返回 NULL
 *
 * 复杂度
 *  T = O(N)
 */
sds sdsMakeRoomFor(sds s, size_t addlen) {

    struct sdshdr *sh, *newsh;
    // 获取 s 目前的空余空间长度
    size_t free = sdsavail(s);
    size_t len, newlen;

    // s 目前的空余空间已经足够,无须再进行扩展,直接返回
    if (free >= addlen) return s;
    // 获取 s 目前已占用空间的长度
    len = sdslen(s);
    sh = (void*) (s-(sizeof(struct sdshdr)));

    // s 最少需要的长度
    newlen = (len+addlen);
    // 根据新长度,为 s 分配新空间所需的大小
    if (newlen < SDS_MAX_PREALLOC)
        // 如果新长度小于 SDS_MAX_PREALLOC 默认1M
        // 那么为它分配两倍于所需长度的空间
        newlen *= 2;
    else
        // 否则,分配长度为目前长度加上 SDS_MAX_PREALLOC
        newlen += SDS_MAX_PREALLOC;
    // T = O(N)
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);

    // 内存不足,分配失败,返回
    if (newsh == NULL) return NULL;

    // 更新 sds 的空余长度
    newsh->free = newlen - len;
    // 返回 sds

    return newsh->buf;

}

2)懒惰空间释放

  懒惰空间释放用于优化sds字符串缩短的操作

  当需要缩短sds的长度时,并不立即释放空间,而是使用free来保存剩余可用长度,并等待将来使用。当有剩余空间,而有有增长字符串操作时,则又会调用空间预分配机制。

  当redis内存空间不足时,会自动释放sds中未使用的空间,因此也不需要担心内存泄漏问题。

4、二进制安全

  SDS 的 API 都是二进制安全的: 所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设。数据在写入时是什么样的,它被读取时就是什么样。

  sds考虑字符串长度,是通过len属性,而不是通过\0来判断

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

  redis兼容c语言对于字符串末尾采用\0进行处理,这样使得其可以复用部分c语言字符串函数的代码,实现代码的精简性。

2,链表

列表键的底层之一是链表。(底层也有可能是压缩列表)

当列表键包含了许多元素,或者元素是比较长的字符串的时候,就会用到链表作为列表键的底层实现。,

节点结构

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

其中prev指向前一个节点,next指向后一个节点,value存储着节点本身的值。多个listNode组成双向链表,如下图所示:

链表结构

/*
 * 双端链表结构
 */
typedef struct list {
    // 表头节点
    listNode *head;
    // 表尾节点
    listNode *tail;
    // 节点值复制函数
    void *(*dup)(void *ptr);
    // 节点值释放函数
    void (*free)(void *ptr);
    // 节点值对比函数
    int (*match)(void *ptr, void *key);
    // 链表所包含的节点数量
    unsigned long len;
} list;

链表如下图所示:

redis的链表特性如下:

  1)双向:每个listNode节点带有prev和next指针,可以找到前一个节点和后一个节点,具有双向性。

  2)无环:list链表的head节点的prev和tail节点的next指针都是指向null。

  3)带表头指针和尾指针:即上述的head和tail,获取头指针和尾指针的时间复杂度O(1)。

  4)带链表长度计数器;即list的len属性,记录节点个数,因此获取节点个数的时间复杂度O(1)。

  5)多态:链表使用void*指针来保存节点的值,可以通过list的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存不同类型的值。

3,字典

字典,又称符号表、关联数组、映射,是一种保存键值对的抽象数据结构。

  每个键(key)和唯一的值(value)关联,键是独一无二的,通过对键的操作可以对值进行增删改查。

  redis中字典应用广泛,对redis数据库的增删改查就是通过字典实现的。即redis数据库的存储,和大部分关系型数据库不同,不采用B+tree进行处理,而是采用hash的方式进行处理。

  字典还是hash的底层实现之一。

  当hash键包含了许多元素,或者元素是比较长的字符串的时候,就会用到字典作为hash键的底层实现。

(1)哈希表

redis的字典,底层是使用哈希表实现,每个哈希表有多个哈希节点,每个哈希节点保存了一个键值对。

/*
 * 哈希表
 * 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
 */
typedef struct dictht {
    // 哈希表数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;
    // 该哈希表已有节点的数量
    unsigned long used;
} dictht;
  • 其中,table是一个数组,里面的每个元素指向dictEntry(哈希表节点)结构的指针,dictEntry结构是键值对的结构;
  • size表示哈希表的大小,也是table数组的大小;
  • used表示table目前已有的键值对节点数量;
  • sizemask一直等于size-1,该值与哈希值一起决定一个属性应该放到table的哪个位置。

  大小为4的空哈希表结构如下图所示:

(2)哈希表节点

/*
 * 哈希表节点
 */
typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;
  • 其中,key表示节点的键;union表示key对应的值,可以是指针、uint64_t整数或int64_t整数;
  • next是指向另一个哈希表节点的指针,该指针将多个哈希值相同的键值对连接在一起,避免因为哈希值相同导致的冲突。

  哈希表节点如下图(左边第一列是哈希表结构,表节点结构从左边第二列开始)所示:

(3)字典

/*
 * 字典
 */
typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
    // 目前正在运行的安全迭代器的数量
    int iterators; /* number of iterators currently running */
} dict;
  • type用于存放用于处理特定类型的处理函数;
  • privdata用于存放私有数据,保存传给type内的函数的数据;
  • rehash是一个索引,当没有在rehash进行时,值是-1;
  • ht是包含两个项的数组,每个项是一个哈希表,一般情况下只是用ht[0],只有在对ht[0]进行rehash时,才会使用ht[1]。

完整的字典结构如下图所示:

(4)哈希算法

  要将新的键值对加到字典,程序要先对键进行哈希算法,算出哈希值和索引值,再根据索引值,把包含新键值对的哈希表节点放到哈希表数组指定的索引上。

  redis实现哈希的代码是:

  hash =dict->type->hashFunction(key);

    index = hash& dict->ht[x].sizemask;

  算出来的结果中,index的值是多少,则key会落在table里面的第index个位置(第一个位置index是0)。

  其中,redis的hashFunction,采用的是murmurhash2算法,是一种非加密型hash算法,其具有高速的特点。

(5)键冲突解决

  当两个或者以上的键被分配到哈希表数组的同一个索引上,则称这些键发生了冲突。

  为了解决此问题,redis采用链地址法。被分配到同一个索引上的多个节点可以用单链表连接起来。

  因为没有指向尾节点的指针,所以总是将新节点加在表头的位置。(O(1)时间)

(6)rehash(重新散列)

  随着操作进行,哈希表保存的键值对会增加或减少,为了让哈希表的负载因子(load factor)维持在一个合理范围,当一个哈希表保存的键太多或者太少,需要对哈希表进行扩展或者收缩。扩展或收缩哈希表的过程,就称为rehash。

  rehash步骤如下:

  1、给字典的ht[1]申请存储空间,大小取决于要进行的操作,以及ht[0]当前键值对的数量(ht[0].used)。假设当前ht[0].used=x。

  如果是扩展,则ht[1]的值是第一个大于等于x*2的2n的值。例如x是30,则ht[1]的大小是第一个大于等于30*2的2n的值,即64。

  如果是收缩,则ht[1]的值是第一个大于等于x的2n的值。例如x是30,则ht[1]的大小是第一个大于等于30的2n的值,即32。

  2、将保存在ht[0]上面的所有键值对,rehash到ht[1],即对每个键重新采用哈希算法的方式计算哈希值和索引值,再放到相应的ht[1]的表格指定位置。

  3、当ht[0]的所有键值对都rehash到ht[1]后,释放ht[0],并将ht[1]设置为ht[0],再新建一个空的ht[1],用于下一次rehash。

 

rehash条件:

  负载因子(load factor)计算:

  load_factor =ht[0].used / ht[0].size,即负载因子大小等于当前哈希表的键值对数量,除以当前哈希表的大小。

 

扩展:

  当以下任一条件满足,哈希表会自动进行扩展操作:

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

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

 

收缩:

  当负载因子小于0.1时,redis自动开始哈希表的收缩工作。

(7)渐进式rehash

  redis对ht[0]扩展或收缩到ht[1]的过程,并不是一次性完成的,而是渐进式、分多次的完成,以避免如果哈希表中存有大量键值对,一次性复制过程中,占用资源较多,会导致redis服务停用的问题。

  渐进式rehash过程如下:

  1、为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两张哈希表。

  2、将字典中的rehashidx设置成0,表示正在rehash。rehashidx的值默认是-1,表示没有在rehash。

  3、在rehash进行期间,程序处理正常对字典进行增删改查以外,还会顺带将ht[0]哈希表上,rehashidx索引上,所有的键值对数据rehash到ht[1],并且rehashidx的值加1。

  4、当某个时间节点,全部的ht[0]都迁移到ht[1]后,rehashidx的值重新设定为-1,表示rehash完成。

 

  渐进式rehash采用分而治之的工作方式,将哈希表的迁移工作所耗费的时间,平摊到增删改查中,避免集中rehash导致的庞大计算量。

  在rehash期间,对哈希表的查找、修改、删除,会先在ht[0]进行。

  如果ht[0]中没找到相应的内容,则会去ht[1]查找,并进行相关的修改、删除操作。而增加的操作,会直接增加到ht[1]中,目的是让ht[0]只减不增,加快迁移的速度。

(8)总结

  • 字典在redis中广泛应用,包括数据库和hash数据结构。
  • 每个字典有两个哈希表,一个是正常使用,一个用于rehash期间使用。
  • 当redis计算哈希时,采用的是MurmurHash2哈希算法。
  • 哈希表采用链地址法避免键的冲突,被分配到同一个地址的键会构成一个单向链表。
  • 在rehash对哈希表进行扩展或者收缩过程中,会将所有键值对进行迁移,并且这个迁移是渐进式的迁移。

4,跳跃表

跳跃表(skiplist)是一种有序的数据结构,它通过每个节点中维持多个指向其他节点的指针,从而实现快速访问。

跳跃表平均O(logN),最坏O(N),支持顺序遍历查找。

  在redis中,有序集合(sortedset)的其中一种实现方式就是跳跃表。

  当有序集合的元素较多,或者集合中的元素是比较常的字符串,则会使用跳跃表来实现。

跳跃表是由各个跳跃表节点组成:

/* ZSETs use a specialized version of Skiplists */
/*
 * 跳跃表节点
 */
typedef struct zskiplistNode {
    // 成员对象
    robj *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;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;

上图最左边就是跳跃表的结构:

  header和tail:是跳跃表节点的头结点和尾节点,

  length:是跳跃表的长度(即跳跃表节点的数量,不含头结点),

  level:表示层数中最大节点的层数(不计算表头结点)。

 因此,获取跳跃表的表头、表尾、最大层数、长度的时间复杂度都是O(1)。

跳跃表节点:

  层:节点中用L1,L2表示各层,每个层都有两个属性,前进指针(forward)和跨度(span)。每个节点的层高是1到32的随机数

  前进指针:用于访问表尾方向的节点,便于跳跃表正向遍历节点的时候,查找下一个节点位置;

  跨度:记录前进指针所指的节点和当前节点的距离,用于计算排位,访问过程中,将沿途访问的所有层的跨度累计起来,得到的结果就是跳跃表的排位。

  后退指针:节点中用BW来表示,其指向当前节点的前一个节点,用于反向遍历时候使用。每次只能后退至前一个节点。

  分值:各节点中的数字就是分值,跳跃表中,节点按照分值从小到大排列

  成员对象:各个节点中,o1,o2是节点所保存的成员对象。是一个指针,指向一个字符串对象。

  表头节点也有后退指针,分值,成员对象,因为不会被用到,所以图中省略。

  分值可以相同,成员对象必须唯一。

  分值相同时,按照成员对象的字典序从小到大排。

跨度用来计算排位:

5,整数集合

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

它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。

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

contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项,各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项

升级:

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

  根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上(从后往前),而且在放置元素的过程中,需要继续位置底层数组的有序性质不变。将新元素添加到底层数组里面。将encoding属性更改。

  整数集合添加新元素的时间复杂度为O(N)。因为引发升级的元素要么最大要么最小,所以它的位置要么是0要么是length-1。

升级的好处:

  • 提升整数集合的灵活性,可以随意将int16,int32,int64的值放入集合。
  • 尽可能地节约内存

降级:

  整数集合不支持降级操作

6,压缩列表

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

  当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。

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

一个压缩列表有一下几个组成部分:

每个压缩列表节点可以保存一个字节数组或者一个整数值,而每个节点都由previous_entry_length、encoding、content三个部分组成。

  • previous_entry_length:

  节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。因为有了这个长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。压缩列表的从表尾向表头遍历操作就是使用这一原理实现的。

  • encoding:

  节点的encoding属性记录了节点的content属性所保存数据的类型以及长度

  • content:

  节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。

连锁更新:

  由于previous_entry_length可能是一个或者五个字节,所有插入和删除操作带来的连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所有连锁更新的最坏复杂度为O(N^2)。

  但连锁更新的条件比较苛刻,而且压缩列表中的数据量也不会太多,因此不需要注意性能问题,平均复杂度仍然是O(N)。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值