文章目录
- 键值对数据库是怎么实现的?
- 动态字符串SDS
- C 语言字符串的缺陷
- SDS结构设计
- 整数集合
- 整数集合结构设计
- 整数集合的升级操作
- 哈希表
- 哈希表结构设计
- 哈希冲突
- 链式哈希
- Rehash
- 渐进式rehash
- rehash触发条件
- 压缩列表
- 压缩列表结构设计
- 连续更新
- 压缩列表的缺陷
- quicklist
- quicklist结构设计
- 跳表
- 跳表结构设计
- 跳表节点查询过程
- 为什么用跳表而不用平衡树
Redis为什么那么快?
除了它是内存数据库,使得所有的操作都在内存上进行之外,还有一个重要因素,它实现的数据结构,使得我们对数据进行增删查改操作时,Redis 能高效的处理。
键值对数据库是怎么实现的?
Redis的键值对中的key就是字符串对象,而value可以是字符串对象,也可以是集合数据类型的对象,比如List对象、Hash对象、Set对象和Zset对象。
举个例子,我这里列出几种Redis新增键值对的命令:
> SET name "xiaolincoding"
OK
> HSET person name "xiaolincoding" age 18
0
> RPUSH stu "xiaolin" "xiaomei"
(integer) 4
- 第一条命令:name是一个字符串键,因为键的值是一个字符串对象。
- 第二条命令:person是一个哈希表键,因为键的值是一个包含两个键值对的哈希表对象。
- 第三条命令:stu是一个列表键,因为键的值是一个包含两个元素的列表对象。
这些键值对是如何保存在Redis中的呢?
Redis时使用了一个哈希表
保存所有键值对,哈希表的最大好处就是让我们可以用O(1) 的时间复杂度来快速查找到键值对。哈希表其实就是一个数组,数组中的元素叫哈希桶。
Redis的哈希桶是怎么保存键值对数据的呢?
哈希桶存放的是指向键值对数据的指针,这些指针可以找到键值对数据,然后因为键值对的值可以保存字符串对象和集合数据类型的对象,所以键值对的数据结构中并不直接保存值本身。而是保存了指向键和值的指针,分别指向了实际的键对象和值对象,这样一来,即使是值是集数据,也可以通过值指针找到。
特别说明下,void * key 和 void * value 指针指向的是 Redis 对象,Redis 中的每个对象都由 redisObject 结构表示,如下图:
对象结构里包含的成员变量:
- type,标识该对象是什么类型的对象(String 对象、 List 对象、Hash 对象、Set 对象和 Zset 对象);
- encoding,标识该对象使用了哪种底层的数据结构;
- ptr,指向底层数据结构的指针。
我画了一张 Redis 键值对数据库的全景图,你就能清晰知道 Redis 对象和数据结构的关系了:
动态字符串SDS
我们都知道Redis中保存的key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。
Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串,也就是 Redis 的 String 数据类型的底层数据结构是 SDS。
既然 Redis 设计了 SDS 结构来表示字符串,肯定是 C 语言的 char* 字符数组存在一些缺陷。
要了解这一点,我们要先看看 char* 字符数据的结构。
C 语言字符串的缺陷
- 获取字符串长度的需要通过运算
- 非二进制安全。
- 不可修改
c语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。
比如下图就是字符串 “xiaoxin”的 char* 字符数组的结构:
在C语言中,对字符串操作时, char* 指针只是指向字符数组的起始位置,而字符数组的结尾位置就是用 “\0" 表示,意思时指字符串的结束。
因此,C语言标准库中的字符串操作函数就通过判断字符是不是"\0" 来决定要不要停止操作,如果当前字符串不是"\0",说明字符串还没结束,可以继续操作,如果当前字符是"\0"则说明字符串结束了,就要停止操作。
举个例子,C 语言获取字符串长度的函数 strlen
,就是通过字符数组中的每一个字符,并进行计数,等遇到字符为 “\0” 后,就会停止遍历,然后返回已经统计到的字符个数,即为字符串长度。下图显示了 strlen 函数的执行流程:
很明显,C语言获取字符串长度的时间复杂度是O(N)。所以需要改进。
C语言字符串用"\0"字符作为结尾标记有个缺陷。假设有个字符串中有个"\0"字符,这时在操作这个字符串就会提早结束,比如 “xiao\0lin” 字符串,计算字符串长度的时候则会是 4,如下图:
因此,除了字符串的末尾之外,字符串里面不能含有"\0"字符,否则最先被程序读入的"\0"字符串会被误认为是字符串结尾,这个限制是的C语言的字符串只能保存文本数据,不能保存像图片、音频、视频这样的二进制数据。
除此之外,C语言标准库中字符串的操作函数是很不安全的,对程序员很不友好,稍微一不注意,就会导致缓冲区溢出。
举个例子,strcat 函数是可以将两个字符串拼接在一起。
//将 src 字符串拼接到 dest 字符串后面
char *strcat(char *dest, const char* src);
C语言的i富川是不会记录自身的缓冲区大小的,所以strcat函数假定程序员在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容。但是如果缓冲区没有足够多的内存,就会发生缓冲区溢出将可能会造成程序运行终止。
而且,strcat函数和strlen函数类似,时间复杂度也很高,也都需要先通过便利字符串才能得到目标字符串的末尾,然后对于strcat来说,还要在遍历源字符串才能完成追加,对字符串的操作效率不高。
通过以上分析,我们可以得知C语言的字符串不足之处以及可以改进的地方:
- 获取字符串长度的时间复杂度为O(N)。
- 字符串以"\0"作为结束字符标识,字符串里面不能含有"\0"字符,因此不能保存二进制数据。
- 字符串操作函数不高效且不安全,比如有缓冲区一处的风险,有可能会造成程序运行终止。
SDS结构设计
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
- len:buf中已保存的字符串长度,不包含结束标识。这样获取字符串长度的时候,只需要返回这个成员变量值就行
- alloc:buf申请的总的字节数,不包含结束标识。这样在修改字符串的时候,可以通过
alloc - len
计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将SDS的空间扩展至执行修改所需的多小,然后才执行实际的修改操作,所以SDS既不需要手动修改SDS的大小,也不会出现缓冲区溢出的问题。 - flags:SDS的类型,用来控制SDS的头大小。
- buf[]:字符数组,用来保存实际数据,不仅可以保存字符串,也可以保存二进制数据。
O(1)时间复杂度获取字符串长度:
C 语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。
而 Redis 的 SDS 结构因为加入了 len 成员变量,那么获取字符串长度的时候,直接返回这个成员变量的值就行,所以复杂度只有 O(1)。
二进制安全:
因为 SDS 不需要用 “\0” 字符来标识字符串结尾了,而是有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据。但是 SDS 为了兼容部分 C 语言标准库的函数, SDS 字符串结尾还是会加上 “\0” 字符。
因此, SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。
通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。
不会发生缓冲区溢出:
C 语言的字符串标准库提供的字符串操作函数,大多数(比如 strcat 追加字符串函数)都是不安全的,因为这些函数把缓冲区大小是否满足操作需求的工作交由开发者来保证,程序内部并不会判断缓冲区大小是否足够用,当发生了缓冲区溢出就有可能造成程序异常结束。
所以,Redis 的 SDS 结构里引入了 alloc 和 len 成员变量,这样 SDS API 通过 alloc - len
计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够用。
而且,当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小,以满足修改所需的大小。
SDS 扩容的规则代码如下:
hisds hi_sdsMakeRoomFor(hisds s, size_t addlen){
... ...
// s目前的剩余空间已足够,无需扩展,直接返回
if (avail >= addlen)
return s;
//获取目前s的长度
len = hi_sdslen(s);
sh = (char *)s - hi_sdsHdrSize(oldtype);
//扩展之后 s 至少需要的长度
newlen = (len + addlen);
//根据新长度,为s分配新空间所需要的大小
if (newlen < HI_SDS_MAX_PREALLOC)
//新长度<HI_SDS_MAX_PREALLOC 则分配所需空间*2的空间
newlen *= 2;
else//否则,分配长度为目前长度+1MB
newlen += HI_SDS_MAX_PREALLOC;...}
- 如果所需的 sds 长度小于 1 MB,那么最后的扩容是按照翻倍扩容来执行的,即 2 倍的newlen。
- 如果所需的 sds 长度超过 1 MB,那么最后的扩容长度应该是 newlen 1MB。
在扩容 SDS 空间之前,SDS API 会优先检 查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」。
这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数。
所以,使用 SDS 即不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。
节省内存空间
SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。
Redis 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同。
比如 sdshdr16 和 sdshdr32 这两个类型,它们的定义分别如下:
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
可以看到:
- sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2 的 16 次方。
- sdshdr32 则都是 uint32_t,表示表示字符数组长度和分配空间大小不能超过 2 的 32 次方。
之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。
整数集合
整数集合是Set对象的底层实现之一。当一个Set对象只包含整数值元素,并且元素数量不大时,就会使用整数集合这个数据结构作为底层实现。
- Redis会确保IntSet中的元素唯一、有序
- 具备类型升级机制,可以节省内存空间。
- 底层采用二分查找方式来查询。
整数集合结构设计
整数集合本质上是一块连续内存空间,它的结构定义如下:
typedef struct intset {
//编码方式,支持存放16位、32位、64位整数
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
可以看到,保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。比如:
- 如果 encoding 属性值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t;
- 如果 encoding 属性值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t;
- 如果 encoding 属性值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t;
不同类型的 contents 数组,意味着数组的大小也会不同。
这样设计也是为了可以快速的使用数组下标来获取元素。
整数集合的升级操作
- 升级编码,按照新的编码方式扩展数组的空间大小
- 按照倒序依次将数组中的元素拷贝到扩容后的正确位置。
- 将新元素加入了数组末尾。
整数集合有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展元素数组的空间大小,然后再将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。
整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后再将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。
按照倒序依次将数组中的元素拷贝到扩容后的正确位置。
举个例子,假设有一个整数集合里有 3 个类型为 int16_t 的元素。
现在,往这个整数集合中加入一个新元素 65535,这个新元素需要用 int32_t 类型来保存,所以整数集合要进行升级操作,首先需要为 contents 数组扩容,在原本空间的大小之上再扩容多 80 位(4x32-3x16=80),这样就能保存下 4 个类型为 int32_t 的元素。
扩容完 contents 数组空间大小后,需要将之前的三个元素转换为 int32_t 类型,并将转换后的元素放置到正确的位上面,并且需要维持底层数组的有序性不变,整个转换过程如下:
整数集合升级有什么好处呢?
如果要让一个数组同时保存 int16_t、int32_t、int64_t 类型的元素,最简单做法就是直接使用 int64_t 类型的数组。不过这样的话,当如果元素都是 int16_t 类型的,就会造成内存浪费的情况。
整数集合升级就能避免这种情况,如果一直向整数集合添加 int16_t 类型的元素,那么整数集合的底层实现就一直是用 int16_t 类型的数组,只有在我们要将 int32_t 类型或 int64_t 类型的元素添加到集合时,才会对数组进行升级操作。
因此,整数集合升级的好处是节省内存资源。
整数集合支持降级操作吗?
不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。比如前面的升级操作的例子,如果删除了 65535 元素,整数集合的数组还是 int32_t 类型的,并不会因此降级为 int16_t 类型。
整数集合是有序的?
那么是如何保证有序呢?如果新添加的元素类型和当前整数集合的元素类型相同,那么就回去当前数组集合中找与新元素一样的元素的下表pos(使用二分查找),如果找到了相同元素,那么无需插入,直接结束并返回插入失败。如果没有找到相同元素,那么就找比新元素大的元素中的最小的那个元素下标pos。然后首先会进行数组扩容,然后将下标pos之后的元素都向右移动一位。最后再将新元素放到pos位置上来。
如果新添加的元素类型比当前整数集合的元素类型要大,那么先进行数组扩容,如果新元素是正数,那说明比所有当前集合中的元素都要大,则将新元素放入扩容数组的末尾,如果新元素是负数,那么说明比当前所有集合中的元素都要小,那么将新元素放入扩容数组的首位。
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;
if (success) *success = 1;
/* Upgrade encoding if necessary. If we need to upgrade, we know that
* this value should be either appended (if > 0) or prepended (if < 0),
* because it lies outside the range of existing values. */
if (valenc > intrev32ifbe(is->encoding)) {
/* This always succeeds, so we don't need to curry *success. */
return intsetUpgradeAndAdd(is,value);
} else {
/* Abort if the value is already present in the set.
* This call will populate "pos" with the right position to insert
* the value when it cannot be found. */
if (intsetSearch(is,value,&pos)) {
if (success) *success = 0;
return is;
}
is = intsetResize(is,intrev32ifbe(is->length)+1);
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
}
_intsetSet(is,pos,value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}
/* Upgrades the intset to a larger encoding and inserts the given integer. */
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding);
uint8_t newenc = _intsetValueEncoding(value);
int length = intrev32ifbe(is->length);
int prepend = value < 0 ? 1 : 0;
/* First set new encoding and resize */
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is,intrev32ifbe(is->length)+1);
/* Upgrade back-to-front so we don't overwrite values.
* Note that the "prepend" variable is used to make sure we have an empty
* space at either the beginning or the end of the intset. */
while(length--)
_intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
/* Set the value at the beginning or the end. */
if (prepend)
_intsetSet(is,0,value);
else
_intsetSet(is,intrev32ifbe(is->length),value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}
static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) {
int min = 0, max = intrev32ifbe(is->length)-1, mid = -1;
int64_t cur = -1;
/* The value can never be found when the set is empty */
if (intrev32ifbe(is->length) == 0) {
if (pos) *pos = 0;
return 0;
} else {
/* Check for the case where we know we cannot find the value,
* but do know the insert position. */
if (value > _intsetGet(is,max)) {
if (pos) *pos = intrev32ifbe(is->length);
return 0;
} else if (value < _intsetGet(is,0)) {
if (pos) *pos = 0;
return 0;
}
}
while(max >= min) {
mid = ((unsigned int)min + (unsigned int)max) >> 1;
cur = _intsetGet(is,mid);
if (value > cur) {
min = mid+1;
} else if (value < cur) {
max = mid-1;
} else {
break;
}
}
if (value == cur) {
if (pos) *pos = mid;
return 1;
} else {
if (pos) *pos = min;
return 0;
}
}
哈希表
哈希表是一种保存键值对的数据结构。
哈希表中的每一个key都是独一无二的,程序可以根据key查找到与之关联的value,或者通过key来更新value,又或者根据key来删除整个key-value等。
哈希表优点在于,它能以O(1)的复杂度快速查询数据。怎么做到的呢?将key通过hash函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。
但是存在的风险也是有,在哈希表大小固定的情况下,随着数据不断增多,那么哈希冲突的可能性也会越高。
解决哈希冲突的方式,有很多种。
Redis采用了链式哈希
来解决哈希冲突,再不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接,一边这些数据在表中仍然可以被查询到。
哈希表结构设计
Redis的哈希表结构如下:
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值,总等于size-1
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;
可以看到,哈希表是一个数组(dictEntry **table),数组中的每个元素是一个指向哈希表节点(dictEntry)的指针。
哈希表节点的结构如下:
typedef struct dictEntry {
//键值对中的键
void *key;
//键值对中的值
union {
void *val; // 可以指向任意类型的指针
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
dictEntry结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接起来,以解决哈希冲突的问题,这就是链式哈希。
另外,这里还跟你提一下,dictEntry 结构里键值对中的值是一个「联合体 v」定义的,因此,键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。
哈希冲突
哈希表实际上是一个数组,数组里的每一个元素就是一个哈希桶。
当一个键值对的键经过Hash函数计算后得到哈希值,再将哈希值%哈希表大小取模计算,得到的结果就是该key-value对应的数组元素位置,也就是第几个哈希桶。
什么是哈希冲突呢?
哈希冲突就是指不同的键经过哈希函数计算机后的哈希值在进行取模运算后对应的哈希桶相同,就造成了冲突。
因此,当有两个以上数量的key被分配到了哈希表中同一个哈希桶时,此时称这些key发生了冲突。
链式哈希
那怎么解决哈希冲突呢,Redis使用了链式哈希。
链式哈希实现的方式就是每个哈希表节点都有一个next指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。
但是,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时也会增加,毕竟链表的查询时间复杂度是 O(n)。
要想解决这一问题,就需要进行rehash,也就是对哈希表的大小进行扩展。
接下来,看看Redis是如何实现的rehash的。
Rehash
在实际使用哈希表时,Redis定义一个dict结构体,这个结构体里定义了两个哈希表。
typedef struct dict{
// 两个哈希表,交替使用,用于rehash操作
dictht ht[2];
} dict;
为什么要定义两个哈希表呢,如果需要对哈希表的大小进行扩展,肯定需要创建一个新的哈希表,将原来的哈希表中的元素转移到新哈希表中,这样才算完成扩展。
在正常服务请求阶段,插入的数据,都会写入到哈希表1,此时的哈希表2没有被分配空间。
随着数据逐步增多,触发了rehash操作,这个过程分为三步:
- 给哈希表2分配空间,一般大小是哈希表1的两倍。
- 将哈希表1的数据迁移到哈希表2中。
- 迁移完成后,哈希表1的空间会被释放,并把哈希表2设置为哈希表1,然后在哈希表2新创建一个空白的哈希表,为下次rehash做准备。
这个过程看起来很简单,但是第二步很有问题,如果哈希表1的数据量非常大,那么在迁移至哈希表2的时候,因为会设计大量的数据拷贝,此时可能会对Redis造成阻塞,无法服务其他请求。
Redis不仅会因为哈希表元素过多进行rehash,也会因为哈希表元素过少进行rehash。也就是哈希表不仅会扩容也会收缩。
渐进式rehash
为了避免rehash在数据迁移过程中,因拷贝数据的耗时,影响Redis性能的情况,所以Redis采用了渐进式rehash,也就是将数据的迁移工作不再是一次性迁移完成,而是分多次迁移。
渐进式rehash步骤如下:
- 给哈希表2分配空间;
- 在rehash进行期间,每次对哈希表元素进行新增、删除、查找或更新操作时,Redis除了会指向对应的操作之外,还会顺序将哈希表1中索引位置上的所有key-value迁移到哈希表2上;
- 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把哈希表1的所有key-value迁移到哈希表2,从而完成rehash操作。
这样就巧妙地把一次性大量数据钱以工作的开销,分摊到了多次处理请求的过程中,避免了一次性rehash的耗时操作。
在进行渐进式rehash的过程中,会有两个哈希表,所以在渐进式rehash进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。
比如,查找一个key的值的话,先回在哈希表1里面进行查找,如果没找到,就会继续到哈希表2里面进行查找。
另外,在渐进式rehash进行漆面,新增一个key-value时,会被保存到哈希表2里面,而哈希表1则不再进行任何添加操作,这样保证了哈希表1的key-value数量只会减少,随着rehash操作的完成,最终哈希表1就会变成空表。
rehash触发条件
rehash的触发条件跟负载因子有关系。
负载因子可以通过下面这个公式计算:
负载因子=哈希表已保存节点数量/哈希表大小
触发rehash操作的条件,主要有两个:
- 当负载因子大于等于1,并且Redis没有在执行RDB快照或者没有进行AOF重写的时候,就会进行rehash操作。
- 当负载因子大于等于5时,此时说明哈希冲突非常严重了,不管有没有在执行RDB快照或者AOF重写,都会强制进行rehash操作。
压缩列表
哈希表那种存数据都是需要一个指针指向数据节点,首先指针需要占用内存空间,而且数据在内存中存放的位置是不连续的,容易产生内存碎片。
压缩列表的最大特点,就是它被设计成一种内存紧凑的数据结构,占用一块连续的内存空间,不仅可以利用CPU缓存,而且会针对不同长度数据,进行相应编码,这样就可以有效地节省内存开销。
但是,压缩列表也是有缺陷的:
- 不能保存过多的元素,否则查询效率就会降低。
- 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。
所以说,压缩列表只能保存数量较少的对象或者元素值不大的对象。
压缩列表结构设计
压缩列表是Redis为了节约内存而开发的,它是由连续内存块组成的顺序型竖锯结构,类似于数组。
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头字段信息定位到到,复杂度是O(1),而查找其他元素时,就没有那么搞笑了,只能逐个查找,复杂度就是O(n)了。因此压缩列表不适合保存过多的元素。
压缩列表节点的构成如下:
压缩列表中包含三部分内容:
- prevlen:记录了前一个节点的长度,目的是为了实现从后向前遍历。
- encoding:记录了当前节点实际数据的类型和长度,类型主要由字符串和整数
- data:记录了当前节点的实际数据,可以是字符串或整数。类型和长度都由encoding决定。
prevlen和encoding根据数据的大小和类型进行不同的空间分配,这样就可以节省内存。
压缩列表里的每个节点中的prevlen属性都记录了前一个节点的长度,而且prevlen属性的空间大小跟前一个节点长度值有关,比如:
- 如果前一个节点的长度小于254字节,那么prevlen属性需要用1字节的空间来保存这个长度值。
- 如果前一个节点的长度大于等于254字节,那么prevlen属性需要用5字节的空间来保存这个长度值。
1111 1110
encoding的编码分为字符串和整数两种
如果当前节点的数据是整数,则encoding会使用1字节的空间进行编码,也就是encoding长度为1字节。通过encoding确认了整数类型,就可以确认整数数据的实际大小了,比如如果encoding编码确认了数据是int16整数,那么data的长度就是int15的大小。
如果当前节点的数据是字符串,根据字符串的长度大小,encoding会使用1/2/5字节进行编码,encoding的前两个bit标识数据的类型,后续的其他bit标识字符串数据的实际长度,即data的长度。
连续更新
压缩列表除了查找复杂度高的问题,还有一个问题。
压缩列表新增某个元素或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的prevlen占用空间都发生变化,从而引起连锁更新问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能下降。
我们记得,压缩列表节点的prevlen属性会根据前一个节点的长度进行不同的空间大小分分配:
- 如果前一个节点的长度小于254字节,那么prevlen属性需要用1字节的空间来保存这个长度值。
- 如果前一个节点的长度大于等于254字节,那么prevlen属性需要用5字节的空间来保存这个长度值。
现在假设一个压缩列表中有多个连续的、长度在250到253之间的节点,如下图:
因为这些节点长度值小于254字节,所以prevlen属性需要用1个字节的空间来保存这个长度值。
这时,如果将一个长度大于等于254字节的新节点加入到压缩列表的表头节点,如下图:
这里就出现问题了,e1的前一个节点的长度大于254字节,那么e1的prevlen属性就需要5个字节,但此时只有1个字节,所以需要扩展,重新分配压缩列表的内存空间。
e1更新了之后,e1的长度也超过254了,那么e2也需要更新,以此类推,直到为尾节点中间的所有节点都需要更新。这也就是连锁更新。
压缩列表的缺陷
空间扩展操作也就是重新分配内存,因此连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会导致直接影响到压缩列表的访问性能。
所以说,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是数量变大了,会导致内存重新分配,最糟糕的是会有连锁更新的问题。
因此,压缩列表只使用于保存的节点数量不多的场景。
quicklist
quicklist是双向链表和压缩列表的组合,因为一个quicklist就是一个链表,而链表中的每个元素又是一个压缩列表。
在前面将压缩列表的时候,压缩列表是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计,如果保存的元素增加,或者元素变大了,压缩列表会有连锁更新的问题。
quicklist的解决办法:通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。
quicklist结构设计
quicklist的结构跟链表的结构体类似,都包含了表头和表尾,区别在于quicklist的节点是quicklistNode。
typedef struct quicklist{
// quicklist 的链表头
quicklistNode *head;
// quicklist 的链表尾
quicklistNode *tail;
// 所有压缩列表中的元素总个数
unsigned long count;
// quicklistNode的个数
unsigned long len;
}
typedef struct quicklistNode {
//前一个quicklistNode
struct quicklistNode *prev;
//下一个quicklistNode
struct quicklistNode *next;
//quicklistNode指向的压缩列表
unsigned char *zl;
//压缩列表的的字节大小
unsigned int sz;
//压缩列表的元素个数
unsigned int count : 16;
} quicklistNode;
可以看到,quicklistNode结构体里包含了前一个节点和下一个节点指针,这样就形成了一个双向链表。链表里保存的是压缩列表。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。
跳表
Redis只有Zset对象的底层使用了跳表,跳表的优势是能支持平均O(logN)的复杂度去查找。
zset结构体里有两个数据结构:一个是跳表、一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。
typedef struct zset{
dict *dict;
zskiplist *zsl;
} zset;
Zset对象在执行数据插入或是数据更新的过程中,会一次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。
Zset对象既能支持范围查询,这是因为它使用了跳表。又可以以常数复杂度获取元素权重,这是因为采用了哈希表。
跳表结构设计
我们都知道,链表在查找元素的时候,需要从表头开始逐一查找,所以查询效率很低,复杂度是O(n),于是就出现了跳表。举个例子,现在链表总长度是100,查询数据最坏情况下需要查询100次才能查到,我现在想让链表的第一个元素用一个新的指针指向第五十个元素,然后查询次数直接降到了50次。我还可以让第一个元素指向第25个元素,25指向50,50指向75,75指向100,这样查询最多也就需要25次。以此类推,只要使用更多的指针指向其他元素,查询的效率也会大大提升,复杂度由O(b)降到了O(logN)。
跳表是在链表基础上改进过来的,实现了一种多层的有序链表。这样的好处是能快速定位数据。
图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:
- L0 层级共有 5 个节点,分别是节点1、2、3、4、5;
- L1 层级共有 3 个节点,分别是节点 2、3、5;
- L2 层级只有 1 个节点,也就是节点 3 。
如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。
可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。
那跳表是怎么实现多层级的呢?
我们来看下跳表节点的数据结构:
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
Zset对象要同时 元素 和 元素的权重,对应到跳表节点结构里就是sds类型的ele变量和double类型的score变量。每个跳表节点都有一个后向指针,指向前一个节点,目的是为了方便从跳表的为节点开始访问节点,这样倒序查找时很方便。
跳表是一个有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的level数组。
level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。
第一眼看到跨度的时候,以为是遍历操作有关,实际上并没有任何关系,遍历操作只需要用前向指针(struct zskiplistNode *forward)就可以完成了。
跨度实际上是为了计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。
举个例子,查找图中节点 3 在跳表中的排位,从头节点开始查找节点 3,查找的过程只经过了一个层(L2),并且层的跨度是 3,所以节点 3 在跳表中的排位是 3。
问题来了,由谁定义哪个跳表节点是头节点呢?这就介绍「跳表」结构体了,如下所示:
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
- 头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
- 长度:便于在O(1)时间复杂度获取跳表节点的数量。
- 跳表的最大层数:便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量。
跳表节点查询过程
查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的SDS类型的元素和元素的权重进行判断。
- 如果当前节点的权重小于要查找的权重时,跳表就会访问该层上的下一个节点。
- 如果当前节点的权重等于要要查找的权重时,并且当前节点的SDS类型数据小于要查找的数据时,跳表就会访问该层上的下一个节点。
如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的level数组里的下一层指针,然后眼着下一层指针继续查找,这就相当于跳到了下一层接着查找。
如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的:
- 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
- 但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
- 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
- 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。
为什么用跳表而不用平衡树
- 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
- 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
- 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。