Redis源码学习–数据结构之dict(1)
dict是什么?
dict
是redis的一个重要的底层数据结构,究其原型就是一个键值存储的哈希表,它是Redis的性能的重要保障,下面就来看看这个东西到底和我们自己实现的哈希表有什么不一样吧。
dict的结构设计
dict是的结构就是如图上的样子,先来介绍下字段的含义
- 先看最右边的dict_entry,一个entry即为一个哈希表
键值对
元素,下文简称哈希元素,而每一个键都是void*的,因此该哈希表支持各种数据类型为键。而next则无需多说,一看就知道这个哈希表是使用链地址法
实现的。 - 然后看中间的dict_ht,这就是一个哈希表的结构,开始的参数dict_entry**,参数名字是table,是一个动态开辟的哈希元素指针数组,size和used分别是数组实际大小和已经使用的大小自然不必多说。大家肯定有疑惑size_mask是个什么鬼,这其实就是size-1,因为dict_ht的大小
must be the power of 2
(一定是2的整数次幂),所以如果使用最后的哈希值m对size取模(一般的哈希函数的做法)的话,可以将m % size
除法计算改为m & size_mask
位运算。 - 最后看dict,这就是最终的数据结构。第一个参数有点疑惑
dict_type
,字面意思不是dict类型吗?一个哈希表还有什么类型?其实不然,每个哈希表中都有一个哈希函数。还有由于键值都是void*类型的,可能是一个复杂的结构体,所以创建和销毁建值可能是需要接口的,而不是直接想要操作就能直接操作的。所以一个dict_type
中包含了一个哈希函数,键值的创建、复制和销毁函数,dict_type
这个参数有点类似于其他语言中的接口:interface
。private涉及客户数据(client data),和实现dict无关,后续篇章写到client时再说。而之后可以看到有两张哈希表,称为0号哈希表
和1号哈希表
,简称0号和1号。0号是一直使用着的,1号是在rehash的时候为了不影响效率才拿来使用的。rehashidx不在rehash的时候为-1,在rehash的时候标识正在rehash到的数组下标号。iterators表示安全迭代器的数量,因此还有不安全迭代器,安全迭代器和不安全迭代器的区别就是前者可以使用dictAdd,dictFind等接口对整个dict进行迭代之外的修改操作,而不安全迭代器只能使用dictNext这么一个接口进行迭代操作。
dict的哈希函数
一起来看看redis的几个默认的哈希函数。
key为int时的哈希函数
/* Thomas Wang's 32 bit Mix Function */
unsigned int dictIntHashFunction(unsigned int key) {
key += ~(key << 15);
key ^= (key >> 10);
key += (key << 3);
key ^= (key >> 6);
key += ~(key << 11);
key ^= (key >> 16);
return key;
}
一般情况下的哈希函数
static uint32_t dict_hash_function_seed = 5381;
unsigned int dictGenHashFunction(const void *key, int len) {
/* 'm' and 'r' are mixing constants generated offline.
They're not really 'magic', they just happen to work well. */
uint32_t seed = dict_hash_function_seed;
const uint32_t m = 0x5bd1e995;
const int r = 24;
/* Initialize the hash to a 'random' value */
uint32_t h = seed ^ len;
/* Mix 4 bytes at a time into the hash */
const unsigned char *data = (const unsigned char *)key;
while (len >= 4) {
uint32_t k = *(uint32_t*)data;
k *= m;
k ^= k >> r;
k *= m;
h *= m;
h ^= k;
data += 4;
len -= 4;
}
/* Handle the last few bytes of the input array */
switch (len) {
case 3: h ^= data[2] << 16;
case 2: h ^= data[1] << 8;
case 1: h ^= data[0]; h *= m;
};
/* Do a few final mixes of the hash to ensure the last few
* bytes are well-incorporated. */
h ^= h >> 13;
h *= m;
h ^= h >> 15;
return (unsigned int)h;
}
一个忽略大小写的字符串哈希函数(大小写相同时哈希值相同)
/* And a case insensitive hash function (based on djb hash) */
unsigned int dictGenCaseHashFunction(const unsigned char *buf, int len) {
unsigned int hash = (unsigned int)dict_hash_function_seed;
while (len--)
hash = ((hash << 5) + hash) + (tolower(*buf++)); /* hash * 33 + c */
return hash;
}
上述哈希函数
最复杂的就是中间的通用的哈希函数了,初始哈希值是一个质数种子和长度混合
(mix)之后的结果,然后每次取四个字节,然后将这四个字节通过乘和异或,混合后结果混合到之前的哈希值中。在最后还有一个简单的编程小技巧,就是使用没有break语句的switch-case处理最后的小于四个字节的情况。这样来回混合的目的就是为了让最后生成的结果产生远远超出哈希值存储范围的溢出,并且足够混乱,以此能减少哈希冲突,但是只要key值相同,再混乱的哈希函数生成的哈希值都是一样的,这样整个哈希表的效率得到了尽可能的提升。当然这些哈希函数并不只有这一点好处,还有种子的选取(一般是质数),左右移位数的选择,这些涉及到数学问题,感兴趣的读者可以自行查看相关资料,研究一下这样做的原因。所以,有了这些函数,我们以后在设计哈希函数时就有思路(可copy)了。
dict的rehash是怎么做的?
有数据结构基础的读者都知道,所谓哈希表,其实就是一个一维数组,和普通数组使用起来不同的地方仅仅是使用哈希值做索引。而redis的哈希表就是动态数组,而为了保证整个哈希表的效率,需要根据填入数据的多少和本身数组的长度的比例进行动态增容,以此保证产生的冲突不会太多,保证效率。因此,rehash
(重新哈希,重新放置元素)是必须的。而我们一般的rehash策略都是这样的,先开辟一段新的更大的空间,然后将原来的哈希表中的元素重新放置到新的开辟的空间,释放原来的空间,这样,新的空间就成为了新的哈希表。但是,这样的处理方式有一段不必要的时间,想想这个场景,如果我想要插入一个键值对,发现哈希表刚好达到了增容的阈值,这个时候我们只能阻塞在那里等待增容也就是rehash完成的时候,才能插入成功。而作为使用者,等待rehash的时间就是多余的,痛苦的,尤其是当键值对基数过大的时候,所以我们来看看redis是怎么做的。
何时触发增容
static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{//这个函数是用来根据已经计算好的哈希值获取数组索引的,同时也会判断是否哈希表中已有一个相同的key
/***只看触发rehash的部分****/
/*
...
*/
if (_dictExpandIfNeeded(d) == DICT_ERR)
return -1;
/*
...
*/
}
static int _dictExpandIfNeeded(dict *d)
{
if (dictIsRehashing(d)) return DICT_OK;
/* 如果是第一次调用该函数就初始化哈希表 */
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
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;
}
int dictExpand(dict *d, unsigned long size)
{
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
dictht n; /* the new hash table */
unsigned long realsize = _dictNextPower(size);
/* Rehashing to the same table size is not useful. */
if (realsize == d->ht[0].size) return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.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. */
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
在每次使用哈希值求key对应下标时(其实就是CRUD)都会检查是否达到增容的阈值,dictExpandIfNeed中的条件判断就是阈值,可以看到其实是两个条件满足任意一个就会触发。
- 主动扩容,存储元素个数>=数组长度,且调用者没有关闭dict_can_resize这个开关。使用主动扩容就是为了不至于触发第二个条件,保证查找效率。
- 强制扩容,存储元素个数/数组长度>=5的时候,强制扩容。因为此时数组上的每个dictentry指针平均都有五个元素,可能有非常多的哈希冲突存在于哈希表中,再不扩容就会影响查找的效率。
rehash步骤
- 在需要rehash时,先调用dictExpand(上文中有粘贴)
- 申请一块更大的空间,并赋值给1号hash表
- 同时将rehashidx置为0,开始从第一个位置进行rehash操作
- 然后进行Lazy-Rehash,就是在dict进行CRUD操作时,如果发现0号hash表的rehashidx不为0时就调用_dictRehashStep,从0号hash表中搬一个bucket到1号hash表
/* Performs N steps of incremental rehashing. Returns 1 if there are still
* keys to move from the old to the new hash table, otherwise 0 is returned.
*
* Note that a rehashing step consists in moving a bucket (that may have more
* than one key as we use chaining) from the old to the new hash table, however
* since part of the hash table may be composed of empty spaces, it is not
* guaranteed that this function will rehash even a single bucket, since it
* will visit at max N*10 empty buckets in total, otherwise the amount of
* work it does would be unbound and the function may block for a long time. */
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
// 往前找了10倍于n的数量,都找到的是空的bucket,则也要返回
assert(d->ht[0].size > (unsigned long)d->rehashidx);
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
while(de) {
// 这里需要注意的是,不能一次把一个bucket一次性的全部搬到1号hash表
// 因为在0号表中存在hash冲突的key,在1号表中不一定存在hash冲突(1号表和0号表size不同)
uint64_t h;
nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* Check if we already rehashed the whole table... */
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
return 1;
}
long long timeInMilliseconds(void) {
struct timeval tv;
gettimeofday(&tv,NULL);
return (((long long)tv.tv_sec)*1000)+(tv.tv_usec/1000);
}
/* Rehash in ms+"delta" milliseconds. The value of "delta" is larger
* than 0, and is smaller than 1 in most cases. The exact upper bound
* depends on the running time of dictRehash(d,100).*/
// 定时rehash,在给定的豪秒数内进行rehash,会在给定的毫秒内阻塞hash表的使用
// 一次搬100个bucket,直到超过时间上限
// 调用时机未知,内部没有调用,应该是在其他地方调用的,后面再补
int dictRehashMilliseconds(dict *d, int ms) {
if (d->pauserehash > 0) return 0;
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 hashing has
* not been paused for 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->pauserehash == 0) dictRehash(d,1);
}
工欲善其事,必先利其器
为了方便描述该结构和快速画以后的博客中的图,我花了很多时间使用graphviz来绘制这些图。而这个工具的使用远远偏离了我这篇文章的中心,因此这里如果大家感兴趣的话,我给大家发几个学习链接大家尝试着自己画画,让你们在写博客的时候也能有好看的图给别人看,最主要的是可以快速将你所想的变成图片,正如官方广告所说所思即所得(逼格满满对不对)?
针对这条我觉得有必要更新一下,两年前我尚且可以忍受这个写代码画图的软件,直到使用了其他的靠点、拖就能完成画图制作的时候,我觉得真香(比如draw.io)