Redis数据结构与对象

Redis数据结构与对象

参考于《Redis设计与实现》

  我们经常被问到的一个Redis问题:Redis的数据结构有几种?

  这个问题我的第一感觉是:这不就是字符串、哈希(hash)、列表(list)、集合(set)和有序集合(zset)吗。

  然而,这答案在redis中时叫做对象,而不是数据结构。真正的数据是 简单动态字符串(SDS)、字典(dict)、双端链表(linkedlist)、压缩列表(ziplist)、整数集合(intset)和跳跃表(skiplist);

redis的五种对象则是使用这些数据结构来实现的;

接下来就先介绍一下这些数据结构,再介绍一下这些Redis对象是分别由些什么数据结构来实现的;

Redis数据结构与对象

Redis底层数据结构一共有6种:

  1. 简单动态字符串
  2. 双向链表
  3. 压缩列表
  4. 哈希表
  5. 跳表
  6. 整数数组

------------------------------------------------------------------------------

简单动态字符串SDS

在Redis中,C字符串只会作为字符串字面量用在一些无须对字符串值修改的地方,比如打印日志;

SDS(simple dynamic string)简单动态字符串,用作Redis默认字符串表示用于保存数据库中的字符串值之外,SDS还被用作缓冲区:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区

SDS定义

Struct sdsstr {

       int len; // SDS所保存字符串的长度

 

       int free; // 记录buf数组中未使用字节的数量

 

       char buf[]; // 字节数组,用于保存字符串

}

SDS与C字符串的区别

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

在SDS中,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈。

杜绝缓冲区溢出

在C字符串中,如果使用strcat函数在一个字符串的末尾接入其他字符串,新接入的字符串可能会覆盖的内存中其他已存在的字符串,造成缓冲区溢出;

       而SDS的结构(len free属性)保证了不会造成缓冲区溢出,因为SDS的API在对SDS字符串进行修改时,API会先通过SDS的属性检查是否满足修改的需求,如果不满足,则先扩展了SDS的空间至执行修改所需的大小,然后才执行实际的修改操作;

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

在C字符串中,每次增长或缩短一个C字符串,程序都要对保存这个C字符串的数组进行一次内存重新分配。如果在拼接操作中没有重新分配空间,则会造成缓冲区溢出;如果在截断操作中没有重新分配空间,则会造成内存泄漏;

因为内存重新分配涉及复杂的算法,而且需要执行系统调用,所以这是一个比较耗时的操作。

通过未使用的空间:SDS的空间预分配(free)和惰性空间释放(free)两种优化策略。

  1. 空间预分配:在进行修改后,若SDS的长度小于1MB,则程序会分配和len大小一样的未使用空间(free);而SDS的长度若大于等于1MB,则程序将会分配1MB的未使用空间(free)。通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重新分配次数。
  2. 惰性空间释放:SDS需要缩短长度时,程序不会立即使用内存重新分配来回收多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用;SDS也提供了相应的API来真正释放SDS的未使用空间。

二进制安全

在C字符串中是以 \0 空字符表示字符串结尾,而在二进制数据中,可能会读取到结尾空字符之前的空字符数据,被认为是结尾空字符。

       SDS使用len属性的值而不是空字符来判断字符串是否结束,使得SDS不仅可以保存文本数据,还可以保存任意格式的二进制数据。

------------------------------------------------------------------------------

双向链表list

用于 redis list 对象的底层实现

链表节点实现

typedef struct listNode {

       struct listNode *prev; // 前置节点

       struct listNode *next; // 后置节点

       void *value; // 节点的值

}

链表实现

typedef struct list {

       struct listNode *head; // 表头节点

       struct listNode *tail; // 表尾节点

       unsigned long len; // 链表所包含的节点数量

       void *(*dup) (void *ptr); // 节点值复制函数

       void *(*free) (void *ptr); // 节点值释放函数

       int (*match) (void *ptr, void *key); // 节点值对比函数

}

Redis的链表实现的特性

双端节点:节点有前后置指针属性;

无环链表:表头的prev指向null,表尾的next指向null;

有表头指针和表尾指针:字面意思;

带链表的长度计数器:记录了链表中的节点数量;

多态:链表节点使用 *void 指针来保存节点值,可以保存各种不同类型的值;

------------------------------------------------------------------------------

字典(dict)

       字典,又称符号表(symbol table)、关联数组(associative array)、或映射(map);

       在字典中,一个键(key)可以和一个值(value)进行关联成为键值对(map);

字典的实现

哈希表

typedef struct dictht {

       dictEntry **table; // 哈希表数组

       unsigned long size; // 哈希表大小

       unsigned long sizemask; // 掩码,用于计算索引值;size-1

       unsigned long used; // 哈希表已使用的数量

}

哈希表节点

哈希表节点是哈希表数组中的元素

typedef struct dictEntry {

       void *key; // 键

       union {

       void *val;

       uint64_t u64;

       int64_t s64;

} v;

struct dictEntry *next; // 指向下一个哈希表节点,形成链表

}

字典

字典中存储了哈希表的信息

typedef struct dict {

       dictType *type; // 类型特定函数

       void *privdata; // 私有数据

       dictht ht[2]; // 哈希表

       int trehashidx; // rehash索引,当不在rehash时,值为-1

}

哈希算法

当要将一个新的键值对添加到字典里边时,则需要通过哈希算法得出键应该存入的索引位置,再进行存在操作;

       Redis计算哈希值和索引的方法如下:

  1. 哈希值:调用字段设置的哈希函数计算键key的哈希值

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

  1. 索引值:使用哈希值与sizemask掩码进行与运算得索引值

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

解决键冲突

       当计算出来的索引值在哈希表上已经有数据了时,则会将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。

rehash

rehash 重新散列:对哈希表的大小进行相应的扩展或收缩

步骤如下:

  1. 为字典ht[1]哈希表分配空间,这个空间大小取决于要执行的操作,以及当前ht[0]包含的键值对数量(ht[0].used);
  1. 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2幂次数(2n);
  2. 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used 的2幂次数(2n);

 

  1. 将所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值放置到ht[1]哈希表的指定位置上
  2. 将ht[0]中的所有键值对都迁移到了ht[1]上时(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备;

哈希表的扩展与收缩

扩展

满足一下任意一个条件时,程序会自动开始对哈希表进行扩展操作

  1. 服务器目前没有在执行BGSAVEBGWRITEAOF命令时,并且哈希表的负载因子大于等于1;
  2. 服务器目前正在执行BGSAVEBGWRITEAOF命令时,并且哈希表的负载因子大于等于5;

注:哈希表的负载因子可通过公式:

// 负载因子 = 哈希表已保存节点数量 / 哈希表大小

Load_factor = ht[0].used / ht[0].size;

计算得出;

 

为什么执行BGSAVEBGWRITEAOF命令时负载因子会扩展到5?:

       在执行BGSAVEBGWRITEAOF命令时,Redis会创建当前服务器的子进程执行,而大多数操作系统都会采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高扩展操作所需的负载因子,从而尽可能地避免子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。

收缩

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

渐进式rehash

rehash是将ht[0]的全部键值对迁移到ht[1]中;

在redis中,rehash操作不是一次性、集中式地完成的,而是分多次、渐进式地完成;原因是:在一个哈希表中如果有千万、亿级的键值对,若一次性的进行迁移,其庞大的计算量可能会导致服务器在一段时间内停止服务;

因此,为了避免rehash对服务器性能造成影响,rehash的操作就变成了渐进式rehash,将ht[0]哈希表中的键值对分多次、渐进式的迁移到ht[1]哈希表中;

渐进式rehash步骤

  1. 为ht[1]哈希表分配空间
  2. 将字典中的rehashidx索引计数器变量的值置为0,表示rehash工作正式开始;
  3. 在rehash进行期间,每次对字典执行增删改查操作时,程序除了执行指定操作之外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]上,rehash完后将rehashidx的值加一
  4. 当ht[0]哈希表的所有键值对都被rehash到ht[1]时,再将rehashidx的值置为-1.表示rehash操作执行完成

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作平摊到堆字典的每个增删改查操作上,从而避免了集中式rehash而带来的庞大计算量造成的影响;

渐进式rehash执行期间的哈希表操作

       在渐进式rehash进行的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删、改、查等操作都会在两个哈希表上进行。例如查操作,现在ht[0]中查找,若没找到,则再到ht[1]中查找;

       而新增操作的键值对一律会被保存到ht[1]哈希表中,而不对ht[0]进行操作

美团对渐进式rehash的优化

由于rehash会新建ht[1]并分配ht[0].used*2的 2的幂次空间大小,当申请扩展的内存空间大小不足时,则会导致Redis的满容驱逐状态触发(驱逐key达到释放内存效果)

而且还会引起一些其他的问题:

  1. 在tablesize级别与现有keys数量不在同一个区间内,主从切换后,由于Redis全量同步,从库tablesize降为与现有Key匹配值,导致内存倾斜
  2. Redis Cluster下的某个分片由于Key数量较多,提前Resize,导致集群分片内存不均
  3. 等等

Redis Rehash 机制优化

       针对在Redis满容驱逐状态下,如何避免因Rehash而导致Redis抖动的这种问题

  1. 在Redis Rehash源码实现的逻辑上,加了一个判断条件,如果现有的剩余内存不够触发Rehash操作所需申请的内存大小,则不进行Resize操作
  2. 通过提前运营进行规避,比如容量预估将Rehash占用的内存考虑在内,或者通过监控定时扩容

------------------------------------------------------------------------------

跳跃表skiplist

       跳跃表是一种有序数据结构,支持平均O(logN)、最坏O(N)复杂度的节点查找,跳跃表的效率可以和平衡树相媲美;Redis使用跳跃表作为有序集合键的底层实现之一;

       Redis在实现有序集合键和集群节点中作为内部数据结构使用到了跳跃表,此外没有其他地方使用到跳跃表;

       当一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis就会使用跳跃表来作为有序集合的底层实现

跳跃表的实现

typedef struct zskiplistNode {

       struct zskiplistNode *backword; // 后退指针

       double score; // 分值

       robj *obj; // 成员对象

       // 层

       struct zskiplistLevel {

              struct zskiplistNode *forward; // 前进指针

              unsigned int span; // 跨度

} level[];

} zskiplistNode;

每个跳跃表节点的层高都是1-32之间的随机数;

多个节点可以有相同的分值,但是每个节点的成员对象必须是唯一的;

按分值排序,分值一样时按对象大小排序;

------------------------------------------------------------------------------

整数集合 intset

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

整数集合的实现

typedef struct intset {

       uint32_t encoding; // 编码方式

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

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

} intset;

contents数组的真正类型取决于encoding属性的值;

集合升级

若一开始整数集合中的数据为16位整数,这时新增了一个64位的整数,则Redis会将该整数集合进行升级,将16位的整数扩展到64位,contents数组也升级为64位的数组,再将数据存入到新的contents数组中;

升级的好处:

  1. 提升整数集合的灵活性
  2. 尽可能的节约内存

注:整数集合不支持降级操作,即64位contents数组即使删除了64位的整数,只剩16位的整数,contents数组也不会降级为16位的数组;

------------------------------------------------------------------------------

压缩列表 ziplist

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

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

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

压缩列表的构成

zlbytes

zltail

zllen

entry1

entry2

……

entryN

zlend

  1. zlbytes:记录整个压缩列表占用的内存字节数;
  2. zltail:记录压缩列表表尾节点距离压缩列表的起始地址有多少个字节;
  3. zllen:记录压缩列表包含的节点数量;
  4. entry:压缩列表包含的节点;
  5. zlend:特殊值(0xFF),用于标记压缩列表的末端;

总结

压缩列表是一种为节约内存而开发的顺序型数据结构。

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

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

增/删节点可能引发连锁更新操作,但出现几率不高;

------------------------------------------------------------------------------

Redis键值保存格式

使用一个全局的哈希表来保存每一个键值对,值为一个数据对象类型;可以在O(1)的时间复杂度找到该数据对象;

数据结构的时间复杂度

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值