点击「京东金融技术说」可快速关注

「摘要」Redis中的哈希表,又称作为dict(字典)。Redis中只是用了几个简单的结构体和几种常见的哈希算法就实现了一个简单的类似高级语言中的map结构。本文是对Redis源码中哈希结构及其元素添加的一篇学习与探究的笔记,在此分享不足之处还请望大家多多指正。

一、保存键值对的结构体
/* 保存键值(key - value)对的结构体*/
typedefstructdictEntry {
// 关键字key定义
void *key;
// 值value定义,这里采用了联合体,根据union的特点,联合体只能存放一个被选中的成员
union {
void *val; // 自定义类型
uint64_t u64; // 无符号整形
int64_t s64; // 有符号整形
double d; // 浮点型
}v;
// 指向下一个键值对节点
structdictEntry *next;
} dictEntry;
二、定义字典操作的公共方法
/* 定义了字典操作的公共方法 */
typedefstructdictType {
/* hash方法,根据关键字计算哈希值 */
unsignedint(*hashFunction)(constvoid *key);
/* 复制key */
void *(*keyDup)(void *privdata, constvoid *key);
/* 复制value */
void *(*valDup)(void *privdata, constvoid *obj);
/* 关键字比较方法 */
int(*keyCompare)(void *privdata, constvoid *key1, constvoid *key2);
/* 销毁key */
void(*keyDestructor)(void *privdata, void *key);
/* 销毁value */
void(*valDestructor)(void *privdata, void *obj);
} dictType;
三、哈希表结构
/* 哈希表结构,每一个字典中有包含两个哈希表,其中ht[0] 正常使用,ht[1]在rehash时被使用,rehash 完成后,角色互换*/
typedefstructdictht {
// 散列数组。哈希表内部是基于数组,数组的元素是dictEntry*类型,指针数组。
dictEntry **table;
// 散列数组的长度
unsignedlong size;
// sizemask=size-1
unsignedlong sizemask;
// 散列数组中已经被使用的节点数量
unsignedlong used;
} dictht;
四、字典结构
/* 字典结构,对dictht结构包装 */
typedefstructdict {
// 字典类型
dictType *type;
// 私有数据指针
void *privdata;
/* 一个字典中有两个哈希表,通常情况下,所有的数据都是存在放dict的ht[0]中,ht[1]只在rehash的时候使用。dict进行rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中。最后将ht[1]赋值给ht[0],并清空ht[1];在正在rehash时,新添加的元素将直接被添加到ht[1]中,这样保证了,rehash操作时,ht[0]中的元素只少不多。 */
dictht ht[2];
// 数据动态迁移的位置,如果rehashidx ==-1说明当前没有执行rehash操作,rehashidx 为 n(0或任意正整数),说明当前ht[0].table[n]正在被rehash到ht[1].table[x](x为小于size的0或正整数)
long rehashidx;
// 当前正在使用的迭代器的数量
int iterators;
} dict;
这四个结构体之间的关系如下:


/* 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;
returnDICT_OK;
}
/* Reset a hash table already initializedwith ht_init().*/
/*重置哈希表,参数是dictht哈希表结构*/
staticvoid _dictReset(dictht *ht)
{ ht->table = NULL;
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}
从上述代码中可发现,初始化哈希表时,_dictReset函数并没有为两个哈希表申请空间,而仅仅做了赋空操作。

字典创建并初始化完成后,便可进行元素的添加操作,下面是元素添加的简要流程:

对上述流程做解析,以一窥其中的精妙:
一、首先,在进行真正添加元素前,以字典指针d为参,调用dictIsRehashing函数,进行字典是否正在Rehash判断。若正在ReHash,则调用_dictRehashStep函数,单步渐进式rehash操作,否则继续往下执行。
随着对哈希实体添加或删除操作的增加,哈希实体中存储的元素会逐步添加或减少,当被存储元素的数量符合特定条件时,程序将会对哈希表大小进行相应的扩容或收缩,以求将哈希实体所使用的内存控制在合理的范围内,此操作通过rehash重新散列完成。而单步渐进式rehash,就是指rehash操作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的,多次即是将rehash操作分布在增删改查中,且每次分别进行一个非空hash桶元素的再散列,最终完成哈希表的扩容,从而极大避免了像Java中HashMap 的rehash操作所带来的性能开销问题。
二、 对0号哈希表大小是否为零做判断。若大小为零,则按创建后未分配空间处理,即调用dictExpand(d, DICT_HT_INITIAL_SIZE)函数为哈希表分配空间,其中第二个参数为哈希表的为默认大小4,且哈希表分配的大小只能为2的整数次幂。若大小不为零,则对空间是否需要扩展进行判断,如下代码片段:
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);
}
从上述代码片段可了解,每次添加新元素时都需对哈希表ht[0]做扩展检查,只要满足如下条件,则需对空间做扩展。
d->ht[0].used >= d->ht[0].size ,存入哈希表中元素的数目大于等于哈希表本身的大小,即哈希表的负载因子大于等于1。
d->ht[0].used/d->ht[0].size,元素的数目与哈希表大小的比率大于指定的强制扩容比,即哈希表的负载因子大于dict_force_resize_ratio或可扩dict_can_resize设置为1,其中dict_force_resize_ratio默认值为5,dict_can_resize为相对的可扩展开关。
之所以用dict_can_resize||负载因子>5 作为扩容的条件之一,是因为BASAVE或BGREWRITEAOF命令正在执行时,系统需要创建当前服务器进程的子进程,为优化子进程的效率,就尽可能地避免子进程运行期间的哈希表扩容,避免不必要的内存写入操作,最大限度地节约内存,因此也就提高了执行扩容操作所需要的负载因子。
由上述可知,在满足上述两个条件之后,便可对空间进行扩容,且要扩容的大小至少为现已使用节点的两倍,扩容后的新哈希表的大小必为2的整数次幂,相关源码片段如下所示:
/* Our hash table capability is a powerof two */
staticPORT_ULONG _dictNextPower(PORT_ULONGsize)
{
PORT_ULONG i = DICT_HT_INITIAL_SIZE; //哈希表的初始大小,默认为4
//大小是否达上限,具体值与编译器的位数有关
if (size >= PORT_LONG_MAX) returnPORT_LONG_MAX;
while(1) {
if (i >= size)
return i;
i *= 2;
}
三、在空间没有问题之后,就需要对键key是否已存在做判断。如果不存在,则定位要插入的桶槽,否则添加失败,相应代码片段如下:
/* Compute the key hash value */
h = dictHashKey(d, key);//调用dictType中指定的hashFunction计算键的哈希值
for (table = 0; table <= 1; table++) { //遍历字典中的两个哈希表
idx = h & d->ht[table].sizemask;// 计算所在的哈希表槽
/* Search if this slot does not alreadycontain the given key */
he = d->ht[table].table[idx];//获取表槽对应的链表,即采用链地址法解决冲突
while(he) {
if (dictCompareKeys(d, key, he->key))//键比较
return -1;
he = he->next;
}
if (!dictIsRehashing(d)) break;//若正在rehash则继续遍历ht[1]哈希表,否则跳出
}
上述代码片段中dictHashKey函数调用dictType中指定的哈希函数计算键的哈希值,在redis源码中使用了三种不同算法来进行哈希值计算,分别是:
dictIntHashFunction哈希函数使用Thomas Wang’s 32 bit Mix哈希算法,对一个整型进行哈希值计算;
dictGenHashFunction哈希函数使用MurmurHash2哈希算法,对字符串做哈希值计算;
dictGenCaseHashFunction哈希函数则基于djb哈希算法实现了一个简单的且不区分大小写的算法,对字符串做哈希值计算。
不论使用哪个,一个好的哈希函数通常具备单向性、抗冲突性、雪崩效应等特性。所谓雪崩效应,从比特位的角度出发在哈希函数散列结果中,bit为0和为1的总数应该大致相等,输入中一个bit发生变化,散列结果中将有一半以上的bit被改变,即输入值1bit位的变化,至少会造成输出值1/2的bit位发生变化。在简单的运算中通常很容易引起雪崩效应,如下几个简单的例子:
加减运算
(1111)2 + (1)2 =(10000)2
(1000)2 - (1)2 = (111)2
位移运算
(00001111)2 << 2 = (00111100)2
乘除运算,本质就是位移与加减法的组合,故一定也具有雪崩效应,
取反、异或运算
~(1111)2 =(0000)2
(1101)2^ (1010)2=(0111)2
了解了以上哈希函数的特性,来看一下如何用MurmurHash2算法实现的?dictGenHashFunction哈希函数就较容易理解了,该函数的基本思想是将给定的key按每四个字符分组,每四个字符当做一个uint32_t整形进行处理,redis的该函数如下。
unsignedint dictGenHashFunction(constvoid *key, intlen) {
//哈希种子,在此处被设置为了5381
uint32_t seed =dict_hash_function_seed;
constuint32_t m = 0x5bd1e995;
constint r = 24;
/* Initialize the hash to a 'random' value */
uint32_t h = seed ^ len;
/* Mix 4 bytes at a time into the hash */
constunsignedchar *data = (constunsignedchar *)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 inputarray */
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 toensure the last few
* bytes are well-incorporated. */
h ^= h >> 13;
h *= m;
h ^= h >> 15;
return (unsignedint)h;
}
四、在计算出hash值之后,便要计算键key所在的哈希表槽,即采用idx = h & d->ht[table].sizemask,并以此来保证元素在哈希表中散列的比较均匀,其中sizemask=size-1,h& (size-1)运算等价于对size取模,也就是h%size,但是&比%具有更高的效率。
另外通过上述分析了解到,哈希数组的大小size一定是2的整数次幂,但是为什么一定是2的整数次幂呢?原因是这样的,size是2的整数次幂时,size-1定为奇数,那么奇数的二进制为的最后一位必是1,于是这样便能保证h&(size-1)的二进制的最后一位是0或1的概率皆为50%,即当h为奇数时,与的结果是奇数,当h为偶数时与的结果必为偶数,因此这样便能保证在底层哈希数组上散列的均匀性。反之如果size不一定是2的整数次幂,则如果再使用这种散列方法,则会出现如下问题,当size为奇数时,则size-1为偶数且对应二进制位的最后一位是0,那么h&(size-1)的对应二进制的最后一位定为0,即与的结果只能为偶数,因此不论h是什么样的值,其对应的键key最终都只会被散列到哈希数组以偶数为下标索引的槽上,这样便造成哈希数组的利用率只用50%了,剩余一半的空间便被浪费了。所以size一定是2的整数次幂,是为了在这种散列方法的前提下,降低哈希冲突的概率,使元素在哈希表中更加均匀的散列。
以上便是对Redis哈希表结构及其元素添加的源码学习与探究,后续还将对该结构的其他操作及其他数据结构的源码做深入的学习。知其然知其所以然,让Redis这一利器更好的为我们的应用服务。

京东金融技术说
▼▼▼
原创·实用·技术·专业
不只一技之长
我有N技在手
你看,我写,共成长!



本文深入探讨Redis中哈希表的实现原理与元素添加过程,包括哈希表结构、键值对存储方式、哈希算法及rehash机制。
2263

被折叠的 条评论
为什么被折叠?



