欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈
Redis之所以性能这么快,一部分得益于优秀的底层数据结构,本篇文章将重点讲解Redis6的底层数据结构。
1. Redis源码目录
从redis官网下载源码(中文网、英文网),我下载的redis-6.2.6版本的源码,源码目录/src,核心分类如下:
- Redis
基本的数据结构(骨架)- 简单动态字符串sds.c
- 整数集合intset.c
- 压缩列表ziplist.c
- 快速链表quicklist.c
- 字典dict.c
- Stream的底层实现结构listpack.c 和rax.c (一般不用)
- Redis
数据类型的底层实现- redis对象object.c
- 字符串t_string.c
- 列表t_list.c
- 字典t_hash.c
- 集合和有序集合t_set.c和t_zset.c
- 数据流t_stream.c
- Redis的数据库实现
- 数据库的底层实现db.c
- 持久化rdb.c和aof.c
- Redis服务端和客户端实现
- 事件驱动ae.c和ae_epoll.c
- 网络连接anet.c和networking.c
- 服务端程序server.c
- 客户端程序redis-cli.c
- 其他
- 主从复制replication.c
- 哨兵sentinel.c
- 集群cluster.c
- 其他数据结构,如hypelong.c、geo.c
- 其他功能,如pub/sub、Lua脚本
2. 简单动态字符串(SDS)
2.1 数据结构
sds.h 文件:
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; //当前字符数组的长度,已用的字节长度
uint8_t alloc; // 当前字符数组总共分配的内存大小,字符串最大字节长度
unsigned char flags; // 表示sds的类型,是sdshdr8,还是sdshdr16。
char buf[]; // 字符串真正的值,长度由alloc控制
};
解释说明:
- Redis中字符串的实现,SDS有多种结构(sds.h):
- sdshdr5 (2^5=32byte)
- sdshdr8 (2 ^ 8=256byte)
- sdshdr16 (2 ^ 16=65536byte=64KB)
- sdshdr32 (2 ^ 32byte=4GB)
- sdshdr64,2的64次方byte=17179869184G用于存储不同的长度的字符串。
len表示 SDS 的长度,使我们在获取字符串长度的时候可以在 O(1)情况下拿到,而不是像 C 那样需要遍历一遍字符串。alloc可以用来计算 free 就是字符串已经分配的未使用的空间,有了这个值就可以引入预分配空间的算法了,而不用去考虑内存分配的问题。buf表示字符串数组,真存数据的。- SDS遵循C字符串以\0结尾的惯例,存储在buf中(不同于nginx的底层实现,nginx实现时不保存最后一个\0),但是不计算最后一个字符的长度到len中
- 保留c风格buf的好处是可以重用一部分c函数库的函数 注:Redis 规定了字符串的长度不得超过 512 MB。
示例图如下:
2.2 SDS创建
以下为 SDS 的创建函数,主要实现是 sdsnewlen 函数:
/* Create an empty (zero length) sds string. Even in this case the string
* always has an implicit null term. */
sds sdsempty(void) {
return sdsnewlen("",0);
}
/* Create a new sds string starting from a null terminated C string. */
sds sdsnew(const char *init) {
size_t initlen = (init == NULL) ? 0 : strlen(init);
return sdsnewlen(init, initlen);
}
/* Duplicate an sds string. */
sds sdsdup(const sds s) {
return sdsnewlen(s, sdslen(s));
}
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen); //根据字符串长度获取SDS结构的类型
/* Empty strings are usually created in order to append. Use type 8
* since type 5 is not good at this. */
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
int hdrlen = sdsHdrSize(type); //获取SDS结构体的长度
unsigned char *fp; /* flags pointer. */
sh = s_malloc(hdrlen+initlen+1); //申请一个完整SDS的内存空间
if (sh == NULL) return NULL;
if (init==SDS_NOINIT)
init = NULL;
else if (!init)
memset(sh, 0, hdrlen+initlen+1);
s = (char*)sh+hdrlen; //s指向SDS结构的buf
fp = ((unsigned char*)s)-1; //地址后退1位,则fp指向flags
switch(type) {
case SDS_TYPE_5: {
*fp = type | (initlen << SDS_TYPE_BITS);
break;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s); //将sh这个指针转化为 struct sdshdr8 指针
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_16: {
SDS_HDR_VAR(16,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_32: {
SDS_HDR_VAR(32,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_64: {
SDS_HDR_VAR(64,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
}
if (initlen && init)
memcpy(s, init, initlen); //将字符串拷贝到buf
s[initlen] = '\0'; //结束符
return s; //注意这里返回的是s而不是sh
}
2.3 SDS扩容与缩容
SDS 既然被称为动态字符串,那么就有扩容和缩容的功能,见以下源码:
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
size_t avail = sdsavail(s); //获取剩余可用空间:字段alloc减去字段len所得的值
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK; // s[-1]就是获取flag字段
int hdrlen;
/* Return ASAP if there is enough space left. */
if (avail >= addlen) return s;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype); //指针回退到SDS结构的起始位置
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC) //SDS_MAX_PREALLOC的值是 1MB,扩容后字符串串长度如
newlen *= 2; //果小于 1MB则长度翻倍,否则每次增加 1M
else
newlen += SDS_MAX_PREALLOC;
type = sdsReqType(newlen);
/* Don't use type 5: the user is appending to the string and type 5 is
* not able to remember empty space, so sdsMakeRoomFor() must be called
* at every appending operation. */
if (type == SDS_TYPE_5) type = SDS_TYPE_8;
hdrlen = sdsHdrSize(type);
if (oldtype==type) { //类型不变,直接往后增加内存空间
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else { //否则重新创建字符串,析构旧字符串
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len); //设置字符串已用长度(给len字段赋值)
}
sdssetalloc(s, newlen); //设置字符串总长度(给alloc字段赋值)
return s;
}
/* Reallocate the sds string so that it has no free space at the end. The
* contained string remains not altered, but next concatenation operations
* will require a reallocation.
*
* After the call, the passed sds string is no longer valid and all the
* references must be substituted with the new pointer returned by the call. */
sds sdsRemoveFreeSpace(sds s) {
void *sh, *newsh;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen, oldhdrlen = sdsHdrSize(oldtype);
size_t len = sdslen(s);
size_t avail = sdsavail(s);
sh = (char*)s-oldhdrlen;
/* Return ASAP if there is no space left. */
if (avail == 0) return s; //没有多余空间,直接返回原字符串
/* Check what would be the minimum SDS header that is just good enough to
* fit this string. */
type = sdsReqType(len); //通过实际使用的长度计算出SDS结构类型
hdrlen = sdsHdrSize(type); //通过SDS结构类型,得到该结构的sizeof
/* If the type is the same, or at least a large enough type is still
* required, we just realloc(), letting the allocator to do the copy
* only if really needed. Otherwise if the change is huge, we manually
* reallocate the string to use the different header type. */
if (oldtype==type || type > SDS_TYPE_8) { //类型没有改变,修改分配的内存大小
newsh = s_realloc(sh, oldhdrlen+len+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+oldhdrlen;
} else { //否则重新创建新的SDS,释放旧SDS
newsh = s_malloc(hdrlen+len+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
sdssetalloc(s, len);
return s;
}
2.4 SDS与字符串区别
C语言没有Java里面的String类型 ,只能是靠自己的char[]来实现,字符串在 C 语言中的存储方式,想要获取 「Redis」的长度,需要从头开始遍历,直到遇到 '\0' 为止。所以,Redis 没有直接使用 C 语言传统的字符串标识,而是自己构建了一种名为简单动态字符串 SDS(simple dynamic string)的抽象类型,并将 SDS 作为 Redis 的默认字符串。
| C语言String字符串 | SDS | |
|---|---|---|
获取字符串长度 | 需要从头开始遍历,直到遇到 '\0' 为止,时间复杂度O(N) | 记录当前字符串的长度,直接读取即可,时间复杂度 O(1) |
内存重新分配 | 分配内存空间超过后,会导致数组下标越级或者内存分配溢出 | 空间预分配:SDS 修改后,len 长度小于 1M,那么将会额外分配与 len 相同长度的未使用空间。如果修改后长度大于 1M,那么将分配1M的使用空间。惰性空间释放 :有空间分配对应的就有空间释放。SDS 缩短时并不会回收多余的内存空间,而是使用 free 字段将多出来的空间记录下来。如果后续有变更操作,直接使用 free 中记录的空间,减少了内存的分配。 |
二进制安全 | 二进制数据并不是规则的字符串格式,可能会包含一些特殊的字符,比如 '\0' 等。前面提到过,C中字符串遇到 '\0' 会结束,那 '\0' 之后的数据就读取不上了 | 根据 len 长度来判断字符串结束的,二进制安全的问题就解决了 |
| 缓冲区问题 | API是不安全的,字符串拼接时可能会造成缓冲区溢出 | API是安全的,拼接时会先检查给定的SDS空间是否足够,不会造成缓冲区溢出 |
| 数据类型 | 只能保存文本数据 | 可以保存文本和二进制数据 |
| 库函数 | 可以使用所有<string.h>库中的函数 | 可以使用一部分<string.h>库中的函数 |
SDS相比较于字符串的优势如下:
- 获取字符串的长度时间复杂度由O(N)降到O(1)
- 避免缓冲区溢出
- 减少修改字符串时带来的内存重分配次数。内存分配会涉及复杂算法,且可能需要系统调用,非常耗时。
- 二进制安全:c语言的结束符限制了它只能保存文本数据,不能保存图片,音频等二进制数据
3. 整数集合(intset)
3.1 数据结构
当一个集合只包含整数值元素,且数量不多时,会使用整数集合作为底层实现;数据结构如下:
typedef struct intset {
uint32_t encoding; // 编码方式,INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64等
uint32_t length; // 长度
int8_t contents[]; // 内容,数组内容类型取决于encoding属性,并不是int8_t。按照大小排序,没有重复
} intset;
encoding字段用于保存元素使用的类型length字段表示元素的个数,也即是contents数组的长度。contents字段是实际保存元素的地方,数组中的元素有以下两个特性:- 元素不重复;
- 元素在数组中由小到大排列;
如下:
3.2 升级和降级
当我们要将一个新元素添加到整数集合里,并且新元素的类型比整数集合现有所有的元素类型都要长时,集合要先进行升级才能添加新数据,升级步骤如下:
- 根据类型,扩展大小,分配空间
- 将底层数组数据都转换成新的类型,并放置到正确位置
- 新元素添加到底层数组里面
Redis 中整数集合的升级是在函数
intsetUpgradeAndAdd中完成的,我们对照着源码看看具体是如何实现的::
/* Return the required encoding for the provided value. */
// 判断新元素什么类型的编码
static uint8_t _intsetValueEncoding(int64_t v) {
if (v < INT32_MIN || v > INT32_MAX)
return INTSET_ENC_INT64;
else if (v < INT16_MIN || v > INT16_MAX)
return INTSET_ENC_INT32;
else
return INTSET_ENC_INT16;
}
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
//获取整数集合当前的编码类型,存到变量 curenc
uint8_t curenc = intrev32ifbe(is->encoding);
//获取新增元素的编码类型,存到变量 newenc
uint8_t newenc = _intsetValueEncoding(value); //升级步骤一
//获取整数集合元素数量
int length = intrev32ifbe(is->length);
//如果新增元素小于0,则将新增元素添加在数组前面
//需要注意的是:当前函数是已经确定整数集合要升级的,那么新增的元素要么最大,要么最小
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. */
//_intsetGetEncoded 函数是获取旧编码方式时第 length 个元素的值
//如果新增元素插在数组开头,则prepend的值为1,即原 第length个元素现在插入在length+1的位置
//_intsetSet函数是以整数集合记录的编码方式在contents数组的某个位置保存值
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);
//整数集合元素加1
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}
注意:
- 从较短整数到较长整数的转换,并不会更改元素的值;
- 集合编码元素的方式,由数组中长度最大的那个元素来决定。
- 添加元素可能导致升级,所以添加新元素的时间复杂度为O(N)
- 不支持降级,升级后将一直保持新的数据类型
升级的好处:
- 提高灵活性:整数集合可以通过自动升级底层数组来适应新的元素,我们可以将int16,int32,int64类型的整数添加到集合中,不必担心类型错误
- 节约内存,可以同时保持int16,int32,int64类型的数值,又可以确保升级操作只会在需要的时候进行,尽量节省内存空间
3.3 总结
- 整数集合是集合键的底层实现之一
- 整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,该表这个数组的类型
- 升级操作作为整数集合带来的灵活性,并且尽可能节约了内存
- 整数集合只支持升级操作,不支持降级操作
4. 字典(dict)
Redis 的字典使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
4.1 数据结构
在 Redis 的 src/dict.h 文件中,可看到哈希表的定义:
typedef struct dictht {
dictEntry **table; //哈希表数组
unsigned long size; //哈希表大小,即table数组的大小
unsigned long sizemask; //哈希表大小掩码,用于计算索引值,总是等于 size-1
unsigned long used; //该哈希表已有节点的数量
} dictht;
哈希表节点使用 dictEntry 结构表示,其定义也在 src/dict.h 文件中,定义如下:
typedef struct dictEntry {
void *key; //键
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; //值
struct dictEntry *next; //指向下一个节点
} dictEntry;
以上定义可看出哈希表节点保存着一个键值对,其中值用一个联合体定义,可以是一个指针,或者一个有/无符号的64位整形,或者是一个双精度浮点数。next 字段是指向另一个哈希表节点的指针,这个指针可以将斗个哈希值相同的键值对连在一起,作用是为了解决键冲突的问题。
接下来,我们看看字典 dict结构:
typedef struct dictType {
//计算哈希值的函数
uint64_t (*hashFunction)(const void *key);
//复制键的函数
void *(*keyDup)(void *privdata, const void *key);
//复制值的函数
void *(*valDup)(void *privdata, const void *obj);
//比较键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
//销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
//销毁值得函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;
//字典结构
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
type和privdata字段是针对不同类型的键值对,为创建多态字典而设计的。- type 字段是一个指向
dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数。 - privdata 字段则保存了需要传给那些类型特定函数的可选参数。
- ht 字段是一个包含两个项的数组,数组中的每个项都是一个
dictht哈希表,一般情况下字典只使用ht[0]这个哈希表,ht[1]哈希表只会在对ht[0]哈希表进行 rehash 时使用。 - rehashidx 字段也是和 rehash 有关,用于记录目前 rehash 的进度,如果没有 rehash,值就是 -1。
- iterators 字段记录了当前字典正在进行中的迭代器数量。
结构图如下所示:
字典的创建源码如下:
static void _dictReset(dictht *ht)
{
ht->table = NULL;
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}
/* Create a new hash table */
dict *dictCreate(dictType *type,
void *privDataPtr)
{
dict *d = zmalloc(sizeof(*d));
_dictInit(d,type,privDataPtr);
return d;
}
/* Initialize the hash table */
int _dictInit(dict *d, dictType *type,
void *privDataPtr)
{
_dictReset(&d->ht[0]);
_dictReset(&d->ht[1]);
d->type = type;
d->privdata = privDataPtr;
d->rehashidx = -1;
d->iterators = 0;
return DICT_OK;
}
4.2 哈希算法
-
redis使用MurmurHash2算法计算键的hash值,MurmurHash2算法优点为即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且计算速度也是很快;
-
哈希值与sizemask取与,得到哈希索引
-
哈希冲突(两个或以上数量键被分配到哈希表数组同一个索引上):链地址法解决冲突,因为dictEntry节点组成的链表没有指向链表表尾的考虑,为了速度考虑,总是将新的节点添加到链表的表头位置(复杂度O(1)) Redis 计算哈希值和索引值的方法如下:
//使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
//使用哈希表的 sizemask 属性和哈希值,计算出索引值
//根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
Redis 目前使用了两种不同的哈希算法:
- MurmurHash2 算法,这种算法最初是由 Austin Appleby 于 2008 年发明,优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。MurmurHash2 算法具体可参考其主页: code.google.com/p/smhasher/。
- 基于 djb 算法实现的一个大小写无关的散列算法:具体信息可参考 www.cse.yorku.ca/~oz/hash.ht… 。
使用哪种算法取决于具体应用所处理的数据:
- 命令表及 Lua 脚本缓存都用到了算法 2。
- 算法 1 的应用则更加广泛:数据库、集群、哈希键、阻塞操作等功能都用到了这个算法。
4.3 rehash
对哈希表进行扩展或收缩,以使哈希表的负载因子维持在一个合理范围之内,负载因子 = 保存的节点数(used)/ 哈希表大小(size)。
扩展和收缩哈希表的工作可以通过执行 rehash(重新散列) 操作来完成,Redis 对字典的哈希表执行 rehash 的步骤如下:
- 为字典的
ht[1]哈希表分配空间,这个空间大小取决于要执行的操作 : - 如果执行的是扩展操作,则
ht[1]的大小为第一个大于等于ht[0].used*2的;
- 如果执行的收缩操作,则
ht[1]的大小为第一个大于等于ht[0].used的;
- 将保存在
ht[0]中的所有键值对 rehash 到ht[1]上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]的指定位置上。 - 当
ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次 rehash 做准备。 哈希表的负载因子计算公式为:
// 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
哈希表扩展的触发条件:
- 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1;
- 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5;
对应的源码如下:
static unsigned int dict_force_resize_ratio = 5;
/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
/* Incremental rehashing already in progress. Return. */
if (dictIsRehashing(d)) return DICT_OK;
/* If the hash table is empty expand it to the initial size. */
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
/* If we reached the 1:1 ratio, and we are allowed to resize the hash
* table (global setting) or we should avoid it but the ratio between
* elements/buckets is over the "safe" threshold, we resize doubling
* the number of buckets. */
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
return dictExpand(d, d->ht[0].used*2); //翻倍
}
return DICT_OK;
}
哈希表收缩的触发条件:哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作。 对应的源码如下:
int htNeedsResize(dict *dict) {
long long size, used;
size = dictSlots(dict);
used = dictSize(dict);
//负载因子小于 0.1
return (size > DICT_HT_INITIAL_SIZE &&
(used*100/size < HASHTABLE_MIN_FILL));
}
/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL
* we resize the hash table to save memory */
void tryResizeHashTables(int dbid) {
if (htNeedsResize(server.db[dbid].dict))
dictResize(server.db[dbid].dict);
if (htNeedsResize(server.db[dbid].expires))
dictResize(server.db[dbid].expires);
}
/* Resize the table to the minimal size that contains all the elements,
* but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{
unsigned long minimal;
if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
minimal = d->ht[0].used;
if (minimal < DICT_HT_INITIAL_SIZE)
minimal = DICT_HT_INITIAL_SIZE;
return dictExpand(d, minimal);
}
注:哈希表不管是扩展还是收缩,其最终大小都是 2 的 n 次幂,其计算源码如下:
/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
//当扩展时,size 参数的值为 ht[0].used * 2
//当收缩时,size 参数的值为 max(ht[0].used, 4)
unsigned long i = DICT_HT_INITIAL_SIZE;
if (size >= LONG_MAX) return LONG_MAX + 1LU;
while(1) {
if (i >= size)
return i;
i *= 2;
}
}
4.4 渐进式rehash
扩展和收缩哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面,但是,rehash 动作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。原因是当哈希表里存放的键值对数量太多时,一次性将这些键值对全部 rehash 到 ht[1] 的话,庞大的计算量可能会导致服务器在一段时间内停止服务。
渐进式 rehash 的步骤如下:
- 为
ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。 - 在字典中维持一个索引计数器变量
rehashidx,并将它的值设置为 0,表示 rehash 工作正式开始。 - 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将
ht[0]哈希表在rehashidx索引上的所有键值对 rehash 到ht[1],当 rehash 工作完成之后,程序将rehashidx属性的值增一。 - 随着字典操作的不断执行,最终在某个时间点上,
ht[0]的所有键值对都会被 rehash 至ht[1],这是程序将rehashidx属性的值设为 -1,表示 rehash 操作已经完成。 对应的源码如下:
int incrementallyRehash(int dbid) {
/* Keys dictionary */
if (dictIsRehashing(server.db[dbid].dict)) {
dictRehashMilliseconds(server.db[dbid].dict,1);
return 1; /* already used our millisecond for this loop... */
}
/* Expires */
if (dictIsRehashing(server.db[dbid].expires)) {
dictRehashMilliseconds(server.db[dbid].expires,1);
return 1; /* already used our millisecond for this loop... */
}
return 0;
}
/* Rehash for an amount of time between ms milliseconds and ms+1 milliseconds */
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
/* This function performs just a step of rehashing, and only if there are
* no safe iterators bound to our hash table. When we have iterators in the
* middle of a rehashing we can't mess with the two hash tables otherwise
* some element can be missed or duplicated.
*
* This function is called by common lookup or update operations in the
* dictionary so that the hash table automatically migrates from H1 to H2
* while it is actively used. */
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
渐进式 rehash 的好处在于它采取分而治之的方式,将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 而带来的庞大计算量。
参考文档:
Redis 设计与实现(第一版)
Redis3.20阅读-SDS实现
Redis(二):基础之五种常见数据结构与使用方法
redis 系列,要懂redis,首先得看懂sds(全网最细节的sds讲解)
# redis-6.06 底层数据结构系列