前言
我们在上一篇:简单认识Redis结构中,简单了解了Redis整体的一个数据结构。知道Redis中所有的数据都存储在RedisDb的dict属性中。那么一个key-value数据是如何保存到里面的呢?将在这里进行详解。
一、认识dict结构
在之前我们说过,一个数据库数据的核心是dict,它是一个key-value的集合。数据库中的所有数据都存储在这个结构里面。dict结构的Redis源码如下:
struct dict {
dictType *type;
dictEntry **ht_table[2];
unsigned long ht_used[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
/* Keep small vars at end for optimal (minimal) struct padding */
int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
signed char ht_size_exp[2]; /* exponent of size. (size = 1<<exp) */
};
typedef struct dictEntry {
void *key;
//union 共用体 只能有一个成员拥有值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; /* Next entry in the same hash bucket. */
void *metadata[]; /* An arbitrary number of bytes (starting at a
* pointer-aligned address) of size as returned
* by dictType's dictEntryMetadataBytes(). */
} dictEntry;
我们将上面的Redis源码转换为图片为:
从上图中我们可以看到,一个dict中存在两个key-value(一个dictEntry就是一个key-value元素)元素组成的数组ht_table0和ht_table1(后续会将下标为0的数组ht_table0称为旧集合,下标为1的数组ht_table1称为新集合),每个key-value数组的元素,可能是空,也可能是一个key-value结构,而且一个 key-value结构可能又通过指针关联了其他的key-value元素,形成了一个链表。
二、add数据
那么为什么dict中使用了两个key-value数组呢?为什么数组元素中有些是空有些是key-value或者key-value链表没有规律呢?这样的一个结构设计是如何使用的呢?带着这样的问题我们来了解一下reids添加一个元素的过程。
1:add过程
下面先来看一下在Redis中添加一个key-value数据核心源码:
void dbAdd(redisDb *db, robj *key, robj *val) {
/* 获取key将其转换为sds数据格式 */
sds copy = sdsdup(key->ptr);
/* 使用sds格式的key,在数据库的dict中创建添加一个key-value数据,并将key关联到该key-value上 */
dictEntry *de = dictAddRaw(db->dict, copy, NULL);
...
/* 将val绑定到新创建的key-value关系数据上 */
dictSetVal(db->dict, de, val);
...
}
这份代码很容易理解,其核心是:dictAddRaw函数,是该函数在dict中创建了一个key-value的关系,并将key绑定到了该关系上。该函数的源码如下:
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
long index;
dictEntry *entry;
int htidx;
/* 检查是否正在进行rehash操作,如果是则执行一步rehash
if (dictIsRehashing(d)) _dictRehashStep(d);
/* 通过key 获取获取新元素应该放在数据的哪个下标下,并赋值给index。
* 如果返回的下标为-1,则表述元素已经存在,直接返回null */
if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
return NULL;
/* 判断是否正在进行rehash操作,如果正在进行rehash操作,则htidx为1,反之为0 */
htidx = dictIsRehashing(d) ? 1 : 0;
/* 创建一个空key-value关系 */
size_t metasize = dictMetadataSize(d);
entry = zmalloc(sizeof(*entry) + metasize);
if (metasize > 0) {
memset(dictMetadata(entry), 0, metasize);
}
/* 通过htidx获取一个key-value的集合,并将该集合的index下标下的数据数据关联到新key-value关系的next下,形成一个以新key-value为头元素的链表。 */
entry->next = d->ht_table[htidx][index];
/* 将以新key-value关系为头元素的链表,覆盖旧的数据 */
d->ht_table[htidx][index] = entry;
/* 记录该key-value集合中数据的数量 */
d->ht_used[htidx]++;
/* 将key关联到新的key-value关系上 */
dictSetKey(d, entry, key);
return entry;
}
从上面的源码中我们可以清楚的了解到一个数据在Redis中创建的过程:
- 当一个数据需要插入时,先判断dict是否正在进行rehash操作,如果是则先执行一步rehash。
- 在rehash结束之后,根据key获取一个下标值idx。如果这个key已经存在,那么获取的下标为-1,不执行插入,直接返回。
- 再判断dict是否正在执行rehash操作,如果正在执行rehash操作则后续的操作针对下标为1的新集合,反之操作下标为0的旧集合。操作内容为:创建一个新的空的key-value,使用idx为下标,获取集合中该下标下的值,将该值关联到新的key-value的next属性上,形成一个链表,并使用该链表覆盖旧的idx上的值。
- 将key关联到新的key-value上,并返回给上一层,用于关联value到新的key-value上。
2:rehash
在上面的添加流程中存在一个好像不太需要,但是又贯穿整个流程的操作,那就是rehash。添加之前需要先执行一步rehash,添加时又需要通过是否rehash确认操作哪一个key-value集合。
那么什么是rehash?又为什么需要rehash呢?
rehash顾名思义就是重新hash。在dict添加数据时,会通过一定的算法将一个key转换为一个最大不会超过指定key-value集合ht_table长度的下标,这个过程称之为hash。在随着dict中数据不断的增加过程中,会出现key计算的下标一致的情况。这个时候会将多个key-value组成一个链表放到指定集合的key计算的下标下。再随着数据的不断增加dict中的数据链表会不断的增长。对一个key的检索操作也会变得越来越复杂(链表的检索,只能从头到尾一个一个的去判断)。为了解决这个问题redis在dict中引入了rehash。正常默认情况下所有的数据都存储在旧集合中,当达到特定的条件后,会在下标为1的key-value集合下创建一个长度为旧集合长度2倍的新集合。并将旧集合中的数据迁移到新集合中,这个迁移过程就称作rehash。
在Redis中的rehash过程称之为渐进式rehash,在这个过程中dict每次只rehash一部分的数据,具体源码如下:
/* n 为步长,指本次对旧集合中的下标下的数据进行rehash操作 */
int dictRehash(dict *d, int n) {
/* 允许的空的访问次数 */
int empty_visits = n*10;
/* 一些是否需要执行rehash的判断 */
if (dict_can_resize == DICT_RESIZE_FORBID || !dictIsRehashing(d)) return 0;
if (dict_can_resize == DICT_RESIZE_AVOID &&
(DICTHT_SIZE(d->ht_size_exp[1]) / DICTHT_SIZE(d->ht_size_exp[0]) < dict_force_resize_ratio))
{
return 0;
}
/* 如果旧集合中有数据,则循环n次,或循环到旧集合中没有数据为止 */
while(n-- && d->ht_used[0] != 0) {
dictEntry *de, *nextde;
/* 断言 防止索引越界 */
assert(DICTHT_SIZE(d->ht_size_exp[0]) > (unsigned long)d->rehashidx);
/* 循环获取rehashidx下标下的数据,如果获取的数据为空则进入循环体,返回退出循环体 */
while(d->ht_table[0][d->rehashidx] == NULL) {
/* rehashidx加1 */
d->rehashidx++;
/* 每次获取到值为null的下标时,empty_visits减1,当empty_visits 减到0时,结束rehash操作 */
if (--empty_visits == 0) return 1;
}
/* 获取旧集合中非null的值,赋值给de */
de = d->ht_table[0][d->rehashidx];
/* 循环链表de */
while(de) {
uint64_t h;
nextde = de->next;
/* 获取key在新集合中的下标 */
h = dictHashKey(d, de->key) & DICTHT_SIZE_MASK(d->ht_size_exp[1]);
/* 将key在新集合下标下的数据关联到de的next上形成de开头的链表,并绑定到新集合下标下 */
de->next = d->ht_table[1][h];
d->ht_table[1][h] = de;
/* 新集合元素数量加1 */
d->ht_used[0]--;
/* 旧集合元素数量减1 */
d->ht_used[1]++;
/* 旧集合链表中的原下一个元素赋值给de,进行循环 */
de = nextde;
}
/* 清空旧集合指定下标下的数据 */
d->ht_table[0][d->rehashidx] = NULL;
/* rehashidx加1 */
d->rehashidx++;
}
/* 如果本次rehash直接完之后,旧集合中的元素数量为0,则执行方法体内代码 */
if (d->ht_used[0] == 0) {
/* 回收旧集合的内存空间 */
zfree(d->ht_table[0]);
/* 将下标为1的key-value集合赋值到下标0 */
d->ht_table[0] = d->ht_table[1];
d->ht_used[0] = d->ht_used[1];
d->ht_size_exp[0] = d->ht_size_exp[1];
/* 重置下标为1的集合为初始状态 */
_dictReset(d, 1);
/* 将是否在进行rehash操作的标识,设置为-1,表示rehash结束 */
d->rehashidx = -1;
return 0;
}
return 1;
}
以上代码为redis中rehash的过程,我们可以看到在redis中每次只rehash一部分的数据,渐进式的向前推进,直至旧集合全部执行rehash。
在源代码中dict存在一个rehashidx属性,该属性表示redis当前是否正在执行rehash,如果是,那么执行到了旧集合数组的什么位置(旧集合数据的一个下标)。
在dict rehash的过程中,每次都会将旧集合中rehashidx下标下的数据依次通过新集合重新获取一次在新集合中的下标,并与新集合下标下的数据形成新的链表,存储在新集合下标下。如果旧集合的rehashidx下标下的数据为null,则rehashidx加1取下一个下标下的数据,直到取到值继续执行,或者取了一定次数后仍旧未取到数据时,结束rehash操作。这样的操作重复执行n次即为dict的一次rehash操作。
从上述的流程中我们可以看出dict rehash的过程是一次性只rehash一部分的数据。因为Redis是单线程的原因,这样可以将rehash过程的时间消耗分散到redis的各种操作中。防止数据过多时,Redis线程被长时间占用无法执行其他的业务,影响用户的使用,这是一种空间换时间的方法。
在Redis rehash的过程中还存在一个通过empty_visits对出现的空数据次数进行判断的流程,该流程是防止数据链表过长,影响操作时间的。在通过key获取下标时,理想情况下数据是平均分散在数组的各个下标下的,如果出现循环次数empty_visits=n*10次空数据,那么一个链表长度很可能是理想情况下的11倍长。这种情况下后续的操作如果放在一起执行可能会大大增加执行时长。所以退出执行,交给下一次rehash。
3:add时rehash的启动时机
上面讲了dict rehash的过程,那么这个过程会在什么时候启动,却没有表现出来。那么在什么时候启动呢?在redis通过key调用_dictKeyIndex函数获取集合下标时。
在获取下标之前_dictKeyIndex函数,会先调用_dictExpandIfNeeded函数判断是否需要启动rehash,如果需要启动rehash,那么_dictExpandIfNeeded函数会创建一个新的空集合赋值给新集合。并设置rehashidx为0,启动rehash。该函数的源码如下:
static int _dictExpandIfNeeded(dict *d)
{
/* Incremental rehashing already in progress. Return. */
/* 如果已经启动rehash,那么直接返回 */
if (dictIsRehashing(d)) return DICT_OK;
/* 如果旧集合的数组长度为0(集合刚初始化时),那么通过默认长度来执行dictExpand函数 */
if (DICTHT_SIZE(d->ht_size_exp[0]) == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
if (!dictTypeExpandAllowed(d))
return DICT_OK;
/* 如果允许扩容,并且元素数量大于等于集合长度或者不允许扩容,但是元素数量是集合长度的dict_force_resize_ratio倍及以上时,调用dictExpand函数进行扩容 */
if ((dict_can_resize == DICT_RESIZE_ENABLE &&
d->ht_used[0] >= DICTHT_SIZE(d->ht_size_exp[0])) ||
(dict_can_resize != DICT_RESIZE_FORBID &&
d->ht_used[0] / DICTHT_SIZE(d->ht_size_exp[0]) > dict_force_resize_ratio))
{
return dictExpand(d, d->ht_used[0] + 1);
}
return DICT_OK;
}
int dictExpand(dict *d, unsigned long size) {
return _dictExpand(d, size, NULL);
}
int _dictExpand(dict *d, unsigned long size, int* malloc_failed)
{
if (malloc_failed) *malloc_failed = 0;
/* 如果已经开启了rehash或者旧集合的元素数量已经大于了语句扩容之后最小的长度时,直接退出方法,返回DICT_ERR */
if (dictIsRehashing(d) || d->ht_used[0] > size)
return DICT_ERR;
/* 新集合的信息 */
dictEntry **new_ht_table;
unsigned long new_ht_used;
/* _dictNextExp函数是通过size获取合适的一个数值,数值结果为大于等于size并且最接近size的2的n次幂 */
signed char new_ht_size_exp = _dictNextExp(size);
/* Detect overflows */
size_t newsize = 1ul<<new_ht_size_exp;
if (newsize < size || newsize * sizeof(dictEntry*) < newsize)
return DICT_ERR;
/* 判断是否和旧表的长度相等,如果相等则直接退出,不再执行后续操作 */
if (new_ht_size_exp == d->ht_size_exp[0]) return DICT_ERR;
/* 设置新的集合数组,并将数组中所有的数据设置为null */
if (malloc_failed) {
new_ht_table = ztrycalloc(newsize*sizeof(dictEntry*));
*malloc_failed = new_ht_table == NULL;
if (*malloc_failed)
return DICT_ERR;
} else
new_ht_table = zcalloc(newsize*sizeof(dictEntry*));
/* 新集合的数据量 */
new_ht_used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
/* 如果旧集合为null则将新集合赋值给旧集合,不启动rehash并退出 */
if (d->ht_table[0] == NULL) {
d->ht_size_exp[0] = new_ht_size_exp;
d->ht_used[0] = new_ht_used;
d->ht_table[0] = new_ht_table;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
/* 如果旧集合不为null则将新创建的集合赋值给新集合,启动rehash并退出 */
d->ht_size_exp[1] = new_ht_size_exp;
d->ht_used[1] = new_ht_used;
d->ht_table[1] = new_ht_table;
d->rehashidx = 0;
return DICT_OK;
}
static signed char _dictNextExp(unsigned long size)
{
unsigned char e = DICT_HT_INITIAL_EXP;
if (size >= LONG_MAX) return (8*sizeof(long)-1);
while(1) {
if (((unsigned long)1<<e) >= size)
return e;
e++;
}
}
在上面的源代码中_dictExpandIfNeeded和_dictExpand函数是启动rehash的核心。
它们的执行过程分为两类:
一类为:如果dict是新建状态旧集合未初始化时,创建一个长度为DICT_HT_INITIAL_SIZE的key-value元素数组,交给旧集合,并结束函数。
另一类为:当允许扩容,并且元素数量已经等于或大于集合长度时或者不允许扩容时元素数量已经是集合长度的5倍或更高时,进行扩容。具体的扩容操作为:通过旧集合长度加1,获取一个大于并且最接近该值的2的n次幂(最大值为LONG_MAX)值。然后创建一个该长度的新的集合,并将该集合内的元素默认设置为null。将新创建的集合交给新集合,并且给新集合内的其他数据设置默认值,并开启rehash。