每日一道八股文
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,表示列表结束。
- zlbytes:是一个无符号 4 字节整数,保存着 ziplist 使用的内存数量。通过 zlbytes程序可以直接对 ziplist 的内存大小进行调整,无须为了计算 ziplist 的内存大小而遍历整个列表。
- zltail:压缩列表最后一个entry距离起始地址的偏移量,占4个字节。这个偏移量使得对表尾的pop操作可以在无须遍历整个列表的情况下进行。
- zllen:压缩列表的节点entry数目,占2个字节。当压缩列表的元素数目超过 2^16 - 2 的时候,zllen 会设置为2^16-1,当程序查询到值为2^16-1,就需要遍历整个压缩列表才能获取到元素数目。所以zllen 并不能替代zltail。
- entryX:压缩列表存储数据的节点,可以为字节数组或者整数。
- 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 来保存集合元素。