Redis底层数据结构及原理

image

1、底层数据类型查看

OBJECT ENCODING  key 

  该命令是用来显示那五大数据类型的底层数据结构。

  比如对于 string 数据类型:

  image

  我们可以看到实现string数据类型的数据结构有 embstr 以及 int。

再比如 list 数据类型:

  image

2、简单动态字符串SDS

SDS定义

image

free:还剩多少空间

len:字符串长度

buf:存放的字符数组

 

SDS空间预分配

为减少修改字符串带来的内存重分配次数,sds采用了“一次管够”的策略:

  • 若修改之后sds长度小于1MB,则多分配现有len长度的空间
  • 若修改之后sds长度大于等于1MB,则扩充除了满足修改之后的长度外,额外多1MB空间

image

SDS惰性空间释放

为避免缩短字符串时候的内存重分配操作,sds在数据减少时,并不立刻释放空间。

image

SDS与C 字符串比较

相对于 C 语言对于字符串的定义,SDS多出了 len 属性以及 free 属性。为什么不使用C语言字符串实现,而是使用 SDS呢?这样实现有什么好处?

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

  由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。通过 strlen key 命令可以获取 key 的字符串长度。

  ②、杜绝缓冲区溢出

  我们知道在 C 语言中使用 strcat  函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。

  ③、减少修改字符串的内存重新分配次数

  C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。

  而对于SDS,由于len属性和free属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:

  1、空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。

  2、惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)

  ④、二进制安全

  因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。

  ⑤、兼容部分 C 字符串函数

  虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数。

image

 

 

字符串对象的底层实现有三种可能:int, embstr,raw

如果一个字符串对象,保存的值是一个整数值,并且这个整数值在 long 的范围内,那么 redis 用整数值来保存这个信息,并且将字符串编码设置为 int

如果字符串对象保存的是一个字符串, 并且长度大于 32 个字节,它就会使用前面讲过的SDS(简单动态字符串)数据结构来保存这个字符串值,并且将字符串对象的编码设置为raw

如果字符串对象保存的是一个字符串, 但是长度小于 32 个字节,它就会使用embstr来保存了,embstr编码不是一个数据结构,而是对 SDS 的一个小优化,当使用 SDS 的时候,程序需要调用两次内存分配,来给 字符串对象 和 SDS 各自分配一块空间,而embstr只需要一次内存分配,因为他需要的空间很少,所以采用 连续的空间保存,即将 SDS 的值和 字符串对象的值放在一块连续的内存空间上。这样能在短字符串的时候提高一些效率。

编码使用条件
int可以用 long 保存的整数
embstr字符串长度小于 32 字节(或者浮点数转换后满足)
raw长度大于 32 的字符串

 

3、列表(ziplist、linkedlist、quicklist)

在 Redis 3.2 版本之前,列表对象底层由 压缩列表和双向链表配合实现,当元素数量较少的时候,使用压缩列表,当元素数量增多,就开始使用普通的双向链表保存数据。

但是这种实现方式不够好,双向链表中的每个节点,都需要保存前后指针,这个内存的使用量 对于 Redis 这个内存数据库来说极其不友好。

因此在 3.2 之后的版本,作者新实现了一个数据结构,叫做 quicklist. 所有列表的底层实现都是这个数据结构了。它的底层实现基本上就是将 双向链表和压缩列表进行了结合,用双向的指针将压缩列表进行连接,这样不仅避免了压缩列表存储大量元素的性能压力,同时避免了双向链表连接指针占用空间过多的问题。

ziplist结构定义

此数据结构是为了节约内存而开发的。和各种语言的数组类似,它是由连续的内存块组成的,这样一来,由于内存是连续的,就减少了很多内存碎片和指针的内存占用,进而节约了内存。

struct ziplist<T>{
    // 整个压缩列表占用字节数
    int32 zlbytes;
    // 最后一个节点到压缩列表起始位置的偏移量,可以用来快速的定位到压缩列表中的最后一个元素
    int32 zltail_offset;
    // 压缩列表包含的元素个数
    int16 zllength;
    // 元素内容列表,用数组存储,内存上紧挨着
    T[] entries;
    // 压缩列表的结束标志位,值永远为 0xFF.
    int8 zlend;
}

 

image

然后文中的entry的结构是这样的:

image

元素遍历

先找到列表尾部元素:

image

然后再根据ziplist节点元素中的previous_entry_length属性,来逐个遍历:

image

元素新增

在列表键的内容比较少时,列表键会使用压缩列表,那么压缩列表为什么不能用于大的列表键呢?

ziplist 是连续存储的数据结构,内存是没有冗余的(前面的文章讲过的 SDS 中就有冗余空间), 也就是说,每一次新增节点,都需要进行内存申请,然后将如果当前内存连续块够用,那么将新节点添加,如果申请到的是另外一块连续内存空间,那么需要将所有的内容拷贝到新的地址。

也就是说,每一次新增节点,都需要内存分配,可能还需要进行内存拷贝。当 ziplist 中存储的值太多,内存拷贝将是一个很大的消耗。

也是因此,Redis 只在一些数据量小的场景下使用 ziplist

级联更新

再次看看entry元素的结构,有一个previous_entry_length字段,他的长度要么都是1个字节,要么都是5个字节:

  • 前一节点的长度小于254字节,则previous_entry_length长度为1字节
  • 前一节点的长度大于254字节,则previous_entry_length长度为5字节

假设现在存在一组压缩列表,长度都在250字节至253字节之间,突然新增一新节点new

长度大于等于254字节,会出现:

image

程序需要不断的对压缩列表进行空间重分配工作,直到结束。

除了增加操作,删除操作也有可能带来“连锁更新”。

请看下图,ziplist中所有entry节点的长度都在250字节至253字节之间,big节点长度大于254字节,small节点小于254字节。

image

 

 

级联更新的时间复杂度很差,但是其实不用怕,因为级联更新造成 Redis 性能压力的概率极其低。

 

quicklist结构定义

struct ziplist_compressed{
    int32 size;
    byte[]  compressed_data;
}
struct quicklistNode {
    quicklistNode* prev;
    quicklistNode* next;
    // 指向压缩列表
    ziplist* zi; 
    // ziplist 的字节总数
    int32 size;
    // ziplist 的元素总数
    int 16 count;
    // 存储形式,是原生的字节数组,还是 LZF 压缩存储
    int2 encoding;
}
struct quicklist{
    // 头结点
    quicklistNode* head;
    // 尾节点
    quicklistNode* tail;
    // 元素总数
    long count;
    // ziplist 节点的个数
    int nodes;
    // LZF 算法压缩深度
    int compressDepth;
}

从结构定义中可以看到,quicklist 的定义和 链表的很像,本质上也是一个双端的链表,只是把普通的节点换成了 quicklistNode, 在这个节点中,保存的不是一个简单的值,而是一个 ziplist。

纯粹的使用 Linkedlist, 也就是普通链表来存储数据有两个弊端:

  1. 每个节点都有自己的前后指针,指针所占用的内存有点多,太浪费了。
  2. 每个节点单独的进行内存分配,当节点过多,造成的内存碎片太多了。影响内存管理的效率。

因此,定义了 quicklist, 将 linkedlist 和 ziplist 结合起来,形成一个,将多个 ziplist 通过前后指针互相连接起来的结构,可以在一定程度上缓解上面说到的两个问题。

为了进一步节约内存,Reids 还可以对 ziplist 进行压缩存储,应用 LZF 算法压缩。

quicklist中每个ziplist大小

既然 quicklist 本质上是将 ziplist 连接起来,那么每个 ziplist 存放多少的元素,就成为了一个问题。

太小的话起不到应有的作用,极致小的话(为 1 个元素), 快速列表就退化成了普通的链表。

太大的话性能太差,极致大的话(整个快速列表只用一个 ziplist), 快速列表就退化成了 ziplist.

quicklist 内部默认定义的单个 ziplist 的大小为 8k 字节. 超过这个大小,就会重新分配一个 ziplist 了。这个长度可以由参数list-max-ziplist-size来控制。

 

4、字典dict(hashtable)

dict结构定义

typedef struct dict{
  // 类型特定函数
  dictType *type;
  // 私有数据
  void *private;
  // 哈希表
  dictht ht[2];
  // rehash 索引,当当前的字典不在 rehash 时,值为-1
  int trehashidx;
}
  • type 和 private

这两个属性是为了实现字典多态而设置的,当字典中存放着不同类型的值,对应的一些复制,比较函数也不一样,这两个属性配合起来可以实现多态的方法调用。

  • ht[2]

这是一个长度为 2 的 dictht结构的数组(dictht就是哈希表)。有且只有俩元素ht[0]和ht[1];其中,ht[0]存放的是redis中使用的哈希表,而ht[1]和rehashidx和哈希表的rehash有关。

  • trehashidx

这是一个辅助变量,用于记录 rehash 过程的进度,以及是否正在进行 rehash 等信息。

字典这个数据结构,本质上是对 hashtable的一个简单封装,因此字典的实现细节主要就来到了 哈希表上。

哈希表定义:

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

哈希表中节点定义:

typedef struct dictEntry{
  // 键
  void *key;
  // 值
  union {
    void *val;
    uint64_tu64;
    int64_ts64;
  }v;
  // 指向下一个节点的指针
  struct dictEntry *next;
} dictEntry;

 

image

上图是一个没有处在 rehash 状态下的字典。可以看到,字典持有两张哈希表ht[0]和ht[1],其中一个的值为 null, 另外一个哈希表的 size=4, 其中两个位置上已经存放了具体的键值对,而且没有发生 hash 冲突。

节点增加

新建key的过程:


image

哈希冲突情况:

image

Redis 的哈希表处理 Hash 冲突的方式和 Java 中的 HashMap 一样,选择了分桶的方式,也就是常说的链地址法。Hash 表有两维,第一维度是个数组,第二维度是个链表,当发生了 Hash 冲突的时候,将冲突的节点使用链表连接起来,放在同一个桶内。

由于第二维度是链表,我们都知道链表的查找效率相比于数组的查找效率是比较差的。那么如果 hash 冲突比较严重,导致单个链表过长,那么此时 hash 表的查询效率就会急速下降。

扩容和缩容

当哈希表过于拥挤,查找效率就会下降,当 hash 表过于稀疏,对内存就有点太浪费了,此时就需要进行相应的扩容与缩容操作。

想要进行扩容缩容,那么就需要描述当前 hash 表的一个填充程度,总不能靠感觉。这就有了 负载因子 这个概念。

负载因子是用来描述哈希表当前被填充的程度。计算公式是:负载因子=哈希表以保存节点数量 / 哈希表的大小.

在 Redis 的实现里,扩容缩容有三条规则:

  1. 当 Redis 没有进行 BGSAVE 相关操作,且 负载因子>1的时候进行扩容。
  2. 负载因子>5的时候,强行进行扩容。
  3. 负载因子<0.1的时候,进行缩容。

根据程序当前是否在进行 BGSAVE 相关操作,扩容需要的负载因子条件不相同。

这是因为在进行 BGSAVE 操作时,存在子进程,操作系统会使用 写时复制 (Copy On Write) 来优化子进程的效率。Redis 尽量避免在存在子进程的时候进行扩容,尽量的节省内存。

扩容和缩容的数量

扩容:第一个大于等于ht[0].used * 22^n(2的n次方幂)。

缩容:第一个大于等于ht[0].used2^n(2的n次方幂)。

哈希表的扩容都是2倍增长的,最小是4。

扩容步骤:


image

缩容步骤:

image

渐进式rehash

在 Java 的 HashMap 中,实现方式是 新建一个哈希表,一次性的将当前所有节点 rehash 完成,之后释放掉原有的 hash 表,而持有新的表。

而 Redis 不是,Redis 使用了一种名为渐进式 hash 的方式来满足自己的性能需求。

rehash 需要重新定位所有的元素,这是一个 O(N) 效率的问题,当对数据量很大的字典进行这一操作的时候,比较耗时。

对于单线程的 Redis 来说,表示很难接受这样的延时,因此 Redis 选择使用一点一点搬的策略。

Redis 实现了渐进式 hash. 过程如下:

  1. 假如当前数据在 ht[0] 中,那么首先为 ht[1] 分配足够的空间。
  2. 在字典中维护一个变量,rehashindex = 0. 用来指示当前 rehash 的进度。
  3. 在 rehash 期间,每次对字典进行增删改查操作,在完成实际操作之后,都会进行 一次 rehash 操作,将 ht[0] 在rehashindex 位置上的值 rehash 到 ht[1] 上。将 rehashindex 递增一位。
  4. 随着不断的执行,原来的 ht[0] 上的数值总会全部 rehash 完成,此时结束 rehash 过程。 将 rehashindex 置为-1。

在上面的过程中有两个问题没有提到:

1、假如这个服务器很空余呢?中间几小时都没有请求进来,那么同时保持两个 table, 岂不是很浪费内存?

解决办法是:在 redis 的定时函数里,也加入帮助 rehash 的操作,这样子如果服务器空闲,就会比较快的完成 rehash.

2、在保持两个 table 期间,该哈希表怎么对外提供服务呢?

解决办法:对于添加操作,直接添加到 ht[1] 上,因此这样才能保证 ht[0] 的数量只会减少不会增加,才能保证 rehash 过程可以完结。而删除,修改,查询等操作会在 ht[0] 上进行,如果得不到结果,会去 ht[1] 再执行一遍。

 

渐进式 hash 带来的好处是显而易见的,他采用了分而治之的思想,将 rehash 操作分散到每一个对该哈希表的操作上以及定时函数上,避免了集中式 rehash 带来的性能压力。

与此同时,渐进式 hash 也带来了一个问题,那就是 在 rehash 的时间内,需要保存两个 hash 表,对内存的占用稍大,而且如果在 redis 服务器本来内存满了的时候,突然进行 rehash 会造成大量的 key 被抛弃。

扩容rehash示例:

 

image

 

5、整数集合intset

intset结构定义

typedef struct intset{
    // 编码方法,指定当前存储的是 16 位,32 位,还是 64 位的整数
    int32 encoding;
    // 集合中的元素数量
    int32 length;
    // 保存元素的数组
    int<T> contents;
}
  • encoding 属性有三种取值,分别代表当前整数集合存储方式是用 16 位整数数组,32 位整数数组或者 64 位整数数组。
  • length 属性保存了当前整数集合中有多少个整数。
  • contents 是一个数组,具体是多少位整数的数组,取决 encoding 的值。

image

这是一个保存了 5 个整数的 intset 的结构图。因为存储的数字都很小,所以 encoding 的值是 16 位的整数。

升级降级

在 C 语言里,整数也是有很多种的。每当一个整数被添加到整数集合时,都需要先去判断 这个整数是否大于 当前编码方式 所能容放的 最大整数, 如果大于,就需要对当前的整数集合进行升级。

升级是指什么呢?假如当前的整数集合中只有一个数字 2. 那么我们用 16 位的整数的数组就可以放下。

当此时进来一个大于 32767(16 位整数的最大值) 的整数,我们就需要将当前的整数数组升级成一个 32 位整数的数组,同时,要将原来的所有整数转换成新的编码。

对于 64 位的升级类似于上面这样。

分级的好处:

  1. 用能容纳数字的最小编码进行存储,可以有效的节约内存。
  2. 整数集合封装了对三种整数之间的转换,使用我们不用考虑类型错误,可以不断的向整数集合内添加整数。提升了操作的灵活性。

与升级相对应的,当大的数字被删除之后,整数集合不会进行降级

 

6、跳跃表skiplist

skiplist结构定义

#define ZSKIPLIST_MAXLEVEL 32 //最大层数
#define ZSKIPLIST_P 0.25 //P
typedef struct zskiplistNode {
    // member 对象
    robj *obj;
    // 分值
    double score;
    // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
            //每一层中的前进指针
        struct zskiplistNode *forward;
        //这个层跨越的节点数量,x.level[i].span 表示节点x在第i层到其下一个节点需跳过的节点数。注:两个相邻节点span为1
        unsigned int span;
    } level[];
} zskiplistNode;
typedef struct zskiplist {
        // 头节点,尾节点
    struct zskiplistNode *header, *tail;
    //节点总数
    unsigned long length;
    //表中层数最大的节点的层数
    int level;
} zskiplis

 

查找元素

image

查找过程如上图蓝色箭头过程,会从最顶层链表的头节点开始遍历。以升序跳表为例,如果当前节点的下一个节点包含的值比目标元素值小,则继续向右查找。如果下一个节点的值比目标值大,就转到当前层的下一层去查找。重复向右和向下的操作,直到找到与目标值相等的元素为止。

 

插入元素

Redis 中,新添加一个节点时,会给该节点随机一个索引层数(如下随机算法),而且概率是 25%. 之后将该节点的各层索引与左右的索引相链接。

 

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

注意概率p值由ZSKIPLIST_P常量定义,值为0.25,即被插入到高层的概率为1/4。

 

插入流程

  • 按照前面讲过的查找流程,找到合适的插入第1层。注意zset允许分数score相同,这时会根据节点数据obj的字典序来排序。
  • 调用zslRandomLevel()方法,随机出要插入的节点的层数。
  • 调用zslCreateNode()方法,根据层数level、分数score和数据obj创建出新节点。
  • 每层遍历,修改新节点以及其前后节点的前向指针forward和跳跃长度span,也要更新最底层的后向指针backward。

image

跳表层数上限为啥是32?

根据前面的随机算法当level[0]有2的64次方个节点时,才能达到32层,因此层数上限是32完全够用了。

 

7、Redis五种数据类型的实现

redis对象

redis中并没有直接使用以上所说的各种数据结构来实现键值数据库,而是基于一种对象,对象底层再间接的引用上文所说的具体的数据结构。

字符串

image

其中:embstr和raw都是由SDS动态字符串构成的。唯一区别是:raw是分配内存的时候,redisobject和 sds 各分配一块内存,而embstr是redisobject和raw在一块儿内存中。

列表

image.png

hash

image

set

image

zset

image

 

 

参考列表

https://i6448038.github.io/2019/12/01/redis-data-struct/

https://segmentfault.com/u/doto_5cf7722c57196/activities

https://developer.aliyun.com/article/617666

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值