Redis基本数据结构有哪些?

                                                                                        每日一道八股文


Redis基本数据类型有String(字符串)、List(列表)、Hash(哈希)、Set(集合)、和Sorted Set(有序集合),其实这只是Redis键值对的数据类型、也就是数据的保存形式,而这也不是面试官想听到的答案,这里,我们要说的数据结构,是要去看看它们的底层实现。

Redis底层数据结构一共有6种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组,接下来我们看一下对应的关系图,如下。

除了为了实现键到值的快速访问,Redis使用一个哈希表来保存所有键值对,一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶,每个哈希桶中保存了键值对的数据,哈希表的key 是String类型的,value对应的就是接下来要讲的五种基本数据类型以及他们的底层原理。

1. 动态字符串

String(字符串)的底层实现是动态字符串

Redis中的String 类型的底层数据结构是一种动态字符串,从源码中我们可以看出,Redis底层对于字符串的定义SDS,即Simple Dynamic String 结构。

使用sds取代C默认的char*类型的原因

基因为 char* 类型的功能单一,抽象层次低,并且不能高效地支持一些 Redis 常用的操作(比如追加操作和长度计算操作),所以在 Redis 程序内部,绝大部分情况下都会使用 sds 而不是 char* 来表示字符串。

SDS的数据结构

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

Redis底层使用SDS相比于C字符串的优势有哪些?

C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符'\0'。但是C语言使用的这种简单的字符串表示方式,并不能满足Redis对字符串在安全性、效率以及功能方面的要求,下面来聊聊为什么SDS比C字符串更适合用于Redis。

1、SDS获取字符串长度复杂度为O(1),C字符串为O(N)

C字符串必须遍历整个字符串,SDS的len属性记录了SDS本身的长度。这样确保了获取字符串长度的工作不会成为Redis的性能瓶颈。

2、SDS杜绝了缓存区溢出

C字符串不记录自身长度除了会导致获取字符串长度复杂度高之外,还带来的另一个问题就是容易造成缓存区溢出(buffer overflow)。

与C字符串不同,SDS的空间分配策略完全杜绝了发生缓存区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓存区溢出问题。

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

C语言中对字符串进行N次追加,必定需要对字符串进行N次内存重分配(realloc)。

Redis优化了追加操作,进行空间预分配以及惰性空间释放.

接下来用一个例子来说明:

redis> SET msg "hello world"
OK
​
redis> APPEND msg " again!"
(integer) 18
​
redis> GET msg
"hello world again!"

首先,SET 命令创建并保存hello world到一个sdshdr中,这个sdshdr 的值如下:

struct sdshdr {
    len = 11;
    free = 0;
    buf = "hello world\0";
}

当执行APPEND命令时,相应的sdshdr 被更新,字符串" again!"会被追加到原来的"hello world"之后:

struct sdshdr {
    len = 18;
    free = 18;
buf = "hello world again!\0                  ";   // 空白的地方为预分配空间,共 18 + 18 + 1 个字节}

注意,当调用SET命令创建 sdshdr 时,sdshdr的free属性为0, Redis也没有为buf创建额外的空间——而在执行APPEND之后, Redis为buf创建了多于所需空间一倍的大小。

在这个例子中,保存 "hello world again!"共需要18 + 1个字节, 但程序却为我们分配了18 + 18 + 1 = 37个字节——这样一来,如果将来再次对同一个sdshdr 进行追加操作,只要追加内容的长度不超过free属性的值,那么就不需要对buf 进行内存重分配。

比如说,执行以下命令并不会引起buf的内存重分配,因为新追加的字符串长度小于18:

redis> APPEND msg " again!"
(integer) 25

再次执行APPEND命令之后,msg 的值所对应的sdshdr结构可以表示如下:

struct sdshdr {
    len = 25;
    free = 11;
    buf = "hello world again! again!\0           ";     // 空白的地方为预分配空间,共 18 + 18 + 1 个字节
}

sds.c/sdsMakeRoomFor函数描述了sdshdr的这种内存预分配优化策略,以下是这个函数的伪代码版本:

def sdsMakeRoomFor(sdshdr, required_len):

    # 预分配空间足够,无须再进行空间分配
    if (sdshdr.free >= required_len):
        return sdshdr

    # 计算新字符串的总长度
    newlen = sdshdr.len + required_len

    # 如果新字符串的总长度小于 SDS_MAX_PREALLOC
    # 那么为字符串分配 2 倍于所需长度的空间
    # 否则就分配所需长度加上 SDS_MAX_PREALLOC 数量的空间
    if newlen < SDS_MAX_PREALLOC:
        newlen *= 2
    else:
        newlen += SDS_MAX_PREALLOC

    # 分配内存
    newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)

    # 更新 free 属性
    newsh.free = newlen - sdshdr.len

    # 返回
    return newsh

二进制安全

C语言中,用'\0'表示字符串的结束,如果字符串本身就有'\0'字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。

C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。

为了确保Redis可以适用于各种不同的使用场景(保存文本、图像、音视频等),SDS的API都是二进制安全的(binary-safe),所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样的。

这也是将SDS的buf属性成为字节数组的原因----Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

2. 双向链表

List的底层实现是双向链表

双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。一般我们都构造双向循环链表。

链表的特点是易于插入和删除(O1),内存利用率高、可以灵活调整链表长度,确定是随机访问困难,需要遍历(On),由于C语言没有实现链表,Redis实现了自己的链表数据结构

链表节点的定义:

typedef struct listNode { 
    // 前置节点   
    struct listNode *prev;
    // 后置节点
    struct listNode *next;
    // 节点值
    void *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;

每个节点listNode可以通过prev和next指针分布指向前一个节点和后一个节点组成双端链表,同时每个链表还会有一个list结构为链表提供表头指针head、表尾指针tail、以及链表长度计数器len,还有三个用于实现多态链表的类型特定函数

  • dup:用于复制链表节点所保存的值

  • free:用于释放链表节点所保存的值

  • match:用于对比链表节点所保存的值和另一个输入值是否相等

双向链表图

3. 压缩列表

List、Hash、Sorted Set(有序集合) 底层使用使用了压缩列表。

它是我们常用的zset,list和hash 结构的底层实现之一,目的是节省内存。

压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。

  1. zlbytes:是一个无符号 4 字节整数,保存着 ziplist 使用的内存数量。通过 zlbytes程序可以直接对 ziplist 的内存大小进行调整,无须为了计算 ziplist 的内存大小而遍历整个列表。

  2. zltail:压缩列表最后一个entry距离起始地址的偏移量,占4个字节。这个偏移量使得对表尾的pop操作可以在无须遍历整个列表的情况下进行。

  3. zllen:压缩列表的节点entry数目,占2个字节。当压缩列表的元素数目超过 2^16 - 2 的时候,zllen 会设置为2^16-1,当程序查询到值为2^16-1,就需要遍历整个压缩列表才能获取到元素数目。所以zllen 并不能替代zltail。

  4. entryX:压缩列表存储数据的节点,可以为字节数组或者整数。

  5. zlend:压缩列表的结尾,占一个字节,恒为 0xFF。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

当我们容器对象的元素个数小于一定条件时,redis会使用ziplist的方式存储,减少内存的使用。因为在redis中的集合容器中,很多情况使用的链表实现,链表是随机IO,不连续,效率低,ziplist是一块连续的内存块,它的读写是顺序I/O ,效率高于随机I/O 。

4. 哈希表

Hash 、Set 的底层实现用到了哈希表
 

哈希表结构

1、table:用于存储键值对

2、size: 表示哈希表的数值大小。

3、used:表示哈希表中已经存储的键值对个数。

4、sizemask:大小永远为size-1,该属性用于计算哈希值。

字典结构

字典结构包含了2个哈希表,还有一些其他属性,比如rehashindex,type等,主要是与rehash相关,还与rehashindex属性相关。

Redis哈希冲突解决方法:链地址法解决哈希冲突,不过不同的是Redis会将新添加的键值对放在链表的头结点位置。

Redis负载因子公式

    //Redis负载因子计算公式
    //负载因子 = 哈希表已保存节点数量 / 哈希表大小
    load_factor = ht[0].used / ht[0].size
    // HashMap 负载因子
    threshold = 0.75 * capacity

rehash 条件

Redis哈希表不仅提供了扩容还提供了收缩机制,扩容与收缩都是通过 rehash 完成的。与 HashMap 一样,Redis 中的哈希表想要执行 rehash 扩容操作也是需要一定条件的,主要为以下 2 个:

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

2、服务器目前正在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

收缩rehash的条件:

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

rehash 扩容过程

Redis 字典 rehash 过程比较有意思的是它通过 2 个哈希表实现,当没有在 rehash 时:rehashidx 的值为 -1,且使用哈希表 0 存储键值对,哈希表 1 什么也不存储。

  • 为字典的 ht[1] 哈希表分配空间,分配的大小如下

    • 扩容:ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n

    • 收缩:ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n

  • 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上,这个过程会重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上

  • 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。

下面是 rehash 前后的一个对比

5、跳表

Sorted Set(有序集合)的底层是使用跳表实现的。

跳跃表(skiplist)是一种随机化的数据, 由 William Pugh 在论文《Skip lists: a probabilistic alternative to balanced trees》中提出, 跳跃表以有序的方式在层次化的链表中保存元素, 效率和平衡树媲美 —— 查找、删除、添加等操作都可以在对数期望时间下完成, 并且比起平衡树来说, 跳跃表的实现要简单直观得多。

从图中可以看到, 跳跃表主要由以下部分构成:

1、表头(head):负责维护跳跃表的节点指针。

2、跳跃表节点:保存着元素值,以及多个层。

3、层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次。

4、表尾:全部由 NULL 组成,表示跳跃表的末尾。

篇幅有限,跳表详细信息单独讲。

6、整数数组

set的底层实现用到了整数数组

整数集合(intset)用于有序、无重复地保存多个整数值, 根据元素的值, 自动选择该用什么长度的整数类型来保存元素。

举个例子, 如果在一个 intset 里面, 最长的元素可以用 int16_t 类型来保存, 那么这个 intset 的所有元素都以 int16_t 类型来保存。

另一方面, 如果有一个新元素要加入到这个 intset , 并且这个元素不能用 int16_t 类型来保存 —— 比如说, 新元素的长度为 int32_t , 那么这个 intset 就会自动进行“升级”:先将集合中现有的所有元素从 int16_t 类型转换为 int32_t 类型, 接着再将新元素加入到集合中。

根据需要, intset 可以自动从 int16_t 升级到 int32_t 或 int64_t , 或者从 int32_t 升级到 int64_t 。

Intset 是集合键的底层实现之一,如果一个集合:

1、只保存着整数元素;

2、元素的数量不多;

那么 Redis 就会使用 intset 来保存集合元素。

关注微信公众号,了解更多Java与大数据,算法等

​​​​​​​

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值