一、字典(dictionary)的定义与实现
在使用Redis中,我们会经常用到哈希(hash)对象。哈希对象中存在key、field和value,每一个field都对应一个value。我曾经使用哈希对象来缓存不同平台上的书籍数据——平台id为key,书的id为field,需要的数据为value。哈希对象的底层就是通过字典来实现的。
src下的dict.h和dict.c中定义了字典:
/*
* 字典
*/
typedef struct dict {
// 类型特定函数
// 私有数据
dictType *type;
dicththt[2];
void*privdata;
// 哈希表
// 当 rehash 不在进行时,值为 -1
//rehash 索引
intrehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
} dict;
intiterators; /* number of iterators currently running */
dict结构体中有一个type属性是指向dictType结构体的指针,dictType中保存了一系列操作特定类型键值对的函数:
/*
* 字典类型特定函数
*/
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
// 复制值的函数
void*(*keyDup)(void *privdata, const void *key);
int(*keyCompare)(void *privdata, const void *key1, const void *key2);
void*(*valDup)(void *privdata, const void *obj);
// 对比键的函数
// 销毁键的函数
void(*valDestructor)(void *privdata, void *obj);
void(*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
} dictType;
ht[2]是大小为2的dictht数组,dictht结构是哈希表,一般字典只使用ht[0]的哈希表,只有在rehash时会用到ht[1]:
/*
* 哈希表
*
* 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小掩码,用于计算索引值
// 哈希表大小
unsigned long size;
// 总是等于 size - 1
unsigned long sizemask;
} dictht;
// 该哈希表已有节点的数量
unsigned long used;
哈希表dictht中最重要的属性是table数组,数组中的元素是指向dictEntry结构体的指针,dictEntry是哈希表的节点,保存着键值对(key-value);size属性表示哈希表的大小,即table数组的长度;used表示哈希表现存的节点数目;sizemask用于在对key进行hash时使用:
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void*key;
void *val;
// 值
union {
int64_t s64;
uint64_t u64;
} v;
structdictEntry *next;
// 指向下个哈希表节点,形成链表
} dictEntry;
哈希表节点中除了存放着key以及value,还有指向下个节点的指针,为什么要让哈希节点形成链表呢?接触过数据结构的同学应该知道,哈希表的存储是通过hash算法来完成的,那么有时就会出现hash碰撞(即两个key的hash值一直,那么两个key就会存放在哈希表中同一位置,如果不做特殊处理,只有一个key能存在,这即是hash碰撞),最典型解决hash碰撞的方法是链表法——即将关键字为同一值的这些key链接在一条链表中,且后面加入链表的节点会放在链表开头,那么通过迭代链表就可以获取那些关键字为同一值的节点。
二、字典内部的重点逻辑
1. 哈希算法
将key加入到字典时,需要对key值进行hash计算,然后将得到的hash值与哈希表中的sizemask(之所以要用数组size-1,是因为索引是从0开始的)属性进行“按位与”得到最终的索引值,然后将此key及对应的value放入对应索引的数组中:
// 计算给定键的哈希值
#definedictHashKey(d, key) (d)->type->hashFunction(key)
/* Getthe index in the new hash table */
h =dictHashKey(d, de->key) & d->ht[x].sizemask;
// 计算哈希表的哈希值,以及节点插入的索引位置,x可以是0或者1
Redis底层默认使用MurmurHash2算法来计算哈希值:
/* MurmurHash2, by Austin Appleby
* Note -This code makes a few assumptions about how your machine behaves -
* 1. Wecan read a 4-byte value from any address without crashing
* 1. Itwill not work incrementally.
* 2.sizeof(int) == 4
*
* And ithas a few limitations -
*
* machines.
* 2. Itwill not produce the same results on little-endian and big-endian
*/
They're not really 'magic', they just happen to work well. */
unsigned int dictGenHashFunction(const void *key,int len) {
/* 'm'and 'r' are mixing constants generated offline.
/*Initialize the hash to a 'random' value */
uint32_t seed = dict_hash_function_seed;
constuint32_t m = 0x5bd1e995;
constint r = 24;
uint32_t h = seed ^ len;
uint32_t k = *(uint32_t*)data;
/* Mix4 bytes at a time into the hash */
constunsigned char *data = (const unsigned char *)key;
while(len >= 4) {
k*= m;
k^= k >> r;
case 3:h ^= data[2] << 16;
k*= m;
h*= m;
h^= k;
data += 4;
len-= 4;
}
/*Handle the last few bytes of the input array */
switch(len) {
h ^= h>> 13;
case 2:h ^= data[1] << 8;
case 1:h ^= data[0]; h *= m;
};
/* Do afew final mixes of the hash to ensure the last few
*bytes are well-incorporated. */
h *= m;
}
h ^= h>> 15;
return(unsigned int)h;
2. rehash(再散列)
随着使用,哈希表中的键值对会增多或减少,为了保证空间及性能,在需要时需要对哈希表进行扩展或收缩:
/* Expand or create the hash table */
/*
*
* 创建一个新的哈希表,并根据字典的情况,选择以下其中一个动作来进行:
* 2) 如果字典的 0 号哈希表非空,那么将新哈希表设置为 1 号哈希表,
* 1) 如果字典的 0 号哈希表为空,那么将新哈希表设置为 0 号哈希表
* size 参数不够大,或者 rehash 已经在进行时,返回 DICT_ERR 。
* 并打开字典的 rehash 标识,使得程序可以开始对字典进行 rehash
*
*
int dictExpand(dict *d, unsigned long size)
* 成功创建 0 号哈希表,或者 1 号哈希表时,返回 DICT_OK 。
*
* T = O(N)
*/
{
// 新哈希表
unsigned long realsize =_dictNextPower(size);
dictht n; /* the new hash table*/
// 根据 size 参数,计算哈希表的大小
// T = O(1)
// 不能在字典正在 rehash 时进行
/* the size is invalid if it issmaller than the number of
* elements already inside thehash table */
// size 的值也不能小于 0 号哈希表的当前已使用节点
/* Allocate the new hash tableand initialize all pointers to NULL */
if (dictIsRehashing(d) ||d->ht[0].used > size)
return DICT_ERR;
// 为哈希表分配空间,并将所有指针指向 NULL
n.size = realsize;
n.sizemask = realsize-1;
// T = O(N)
* we just set the first hashtable so that it can accept keys. */
n.table =zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
/* Is this the firstinitialization? If so it's not really a rehashing
// 如果 0 号哈希表为空,那么这是一次初始化:
// 程序将新哈希表赋给 0 号哈希表的指针,然后字典就可以开始处理键值对了。
// 并将字典的 rehash 标识打开,让程序可以开始对字典进行 rehash
if (d->ht[0].table == NULL){
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash tablefor incremental rehashing */
// 如果 0 号哈希表非空,那么这是一次 rehash :
// 程序将新哈希表设置为 1 号哈希表,
d->ht[1] = n;
return DICT_OK;
d->rehashidx = 0;
return DICT_OK;
/* 顺带一提,上面的代码可以重构成以下形式:
if (d->ht[0].table == NULL){
// 初始化
d->ht[0] = n;
} else {
// rehash
d->ht[1] = n;
d->rehashidx = 0;
}
*/
}
上述是根据传入的size创建一个相应大小的新的hash表。计算新hash表的大小是通过dictNextPower函数得到的,逻辑是计算第一个大于等于size的2次幂的数。
/* Our hash table capability is a power of two */
/*
* 计算第一个大于等于 size 的 2 的 N 次方,用作哈希表的值
*
static unsigned long _dictNextPower(unsigned longsize)
* T = O(1)
*/
{
if(size >= LONG_MAX) return LONG_MAX;
unsigned long i = DICT_HT_INITIAL_SIZE;
while(1) {
}
if(i >= size)
return i;
i*= 2;
}
在dictExpand函数中,如果字典的0号哈希表为空,那么将新哈希表设置为0号哈希表,那么此次操作其实就是一次初始化;否则,将新哈希表设置为1号哈希表,这个过程就是一次rehash。当在rehash时,d->rehashidx= 0,rehashidx的值为0,代表此时可以进行rehash。rehash的目的是当第一个哈希表不满足我们的需求(空间太少或太空),此时就需要新建立一个哈希表,并将现在哈希表的数据重新映射到新的哈希表中,具体的步骤如下:
1) 首先是为新的哈希表分配空间,分配策略已经在上面讲述了
2) 将ht[0]中的所有数据再次计算哈希值和索引(这个过程即再散列,rehash)后放入新的哈希表中
3) 当数据全部完成迁移后,那么释放掉ht[0],并将ht[1]设置成ht[0],并让ht[1]成为一个空的哈希表
这里有一个问题——当源哈希表中数据比较少时,我们可以一次性迁移完,那么当数据很多时,如1亿,10亿呢?这么大的数据量如果要一次性迁移完,那么整个服务都会出现卡顿。为了解决这个问题,Redis使用了渐进式的rehash策略——rehash是多次、渐进地完成的:
/* Performs N steps of incremental rehashing. Returns 1 if there arestill
* keys to move from the old to thenew hash table, otherwise 0 is returned.
*
* 执行 N 步渐进式 rehash 。
*
* 返回 1 表示仍有键需要从 0 号哈希表移动到 1 号哈希表,
* Note that a rehashing stepconsists in moving a bucket (that may have more
* 返回 0 则表示所有键都已经迁移完毕。
*
* 注意,每步 rehash 都是以一个哈希表索引(桶)作为单位的,
* than one key as we use chaining)from the old to the new hash table.
*
* 一个桶里可能会有多个节点,
// 只可以在 rehash 进行中时执行
* 被 rehash 的桶里的所有节点都会被移动到新哈希表。
*
* T = O(N)
*/
int dictRehash(dict *d, int n) {
/* Check if we alreadyrehashed the whole table... */
if (!dictIsRehashing(d)) return0;
// 进行 N 步迁移
// T = O(N)
while(n--) {
dictEntry *de, *nextde;
zfree(d->ht[0].table);
// 如果 0 号哈希表为空,那么表示 rehash 执行完毕
// T = O(1)
if (d->ht[0].used == 0){
// 释放 0 号哈希表
// 将原来的 1 号哈希表设置为新的 0 号哈希表
// 返回 0 ,向调用者表示 rehash 已经完成
d->ht[0] = d->ht[1];
// 重置旧的 1 号哈希表
_dictReset(&d->ht[1]);
// 关闭 rehash 标识
d->rehashidx = -1;
return 0;
}
assert(d->ht[0].size> (unsigned)d->rehashidx);
/* Note that rehashidxcan't overflow as we are sure there are more
* elements becauseht[0].used != 0 */
// 确保 rehashidx 没有越界
// 略过数组中为空的索引,找到下一个非空索引
while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
// 保存下个节点的指针
// 指向该索引的链表表头节点
de =d->ht[0].table[d->rehashidx];
/* Move all the keys inthis bucket from the old to the new hash HT */
// 将链表中的所有节点迁移到新哈希表
// T = O(1)
while(de) {
unsigned int h;
nextde = de->next;
d->ht[0].used--;
/* Get the index in thenew 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[1].used++;
}
// 继续处理下个节点
de = nextde;
}
// 将刚迁移完的哈希表索引的指针设为空
d->ht[0].table[d->rehashidx] = NULL;
// 更新 rehash 索引
d->rehashidx++;
}
return 1;
当rehashidx的值设为1时,代表rehash正式开始,然后在rehash期间对字典执行的增删查更新操作时,会将ht[0]哈希表中rehashidx索引处的数据经过rehash后放入ht[1]中,然后rehashidx的值自增;当ht[0]中所有的数据都rehash至ht[1],rehashidx的值被设为-1,rehash操作已完成。
/* This function performs just a step ofrehashing, and only if there are
* no safeiterators bound to our hash table. When we have iterators in the
* middleof a rehashing we can't mess with the two hash tables otherwise
* 在字典不存在安全迭代器的情况下,对字典进行单步 rehash 。
* someelement can be missed or duplicated.
*
*
* 字典有安全迭代器的情况下不能进行 rehash,
* 因为两种不同的迭代和修改操作可能会弄乱字典。
*
*dictionary so that the hash table automatically migrates from H1 to H2
* Thisfunction is called by common lookup or update operations in the
* while itis actively used.
*
* 这个函数被多个通用的查找、更新操作调用,
* 它可以让字典在被使用的同时进行 rehash。
*
* T = O(1)
*/
}
static void _dictRehashStep(dict *d) {
if(d->iterators == 0) dictRehash(d,1);
上面是在字典中查找对应key的节点的函数,其中有一行代码是这样的:if(dictIsRehashing(d)) _dictRehashStep(d);这即是渐进式rehash的应用,这个操作让rehash期间,对字典的查找操作时顺带进行rehash。_dictRehashStep(d)中调用了dictRehash函数,实现了单步的rehash,dictRehashStep函数如下:
/* This function performs just a step ofrehashing, and only if there are
* no safeiterators bound to our hash table. When we have iterators in the
* middleof a rehashing we can't mess with the two hash tables otherwise
* 在字典不存在安全迭代器的情况下,对字典进行单步 rehash 。
* someelement can be missed or duplicated.
*
*
* 字典有安全迭代器的情况下不能进行 rehash,
* 因为两种不同的迭代和修改操作可能会弄乱字典。
*
*dictionary so that the hash table automatically migrates from H1 to H2
* Thisfunction is called by common lookup or update operations in the
* while itis actively used.
*
* 这个函数被多个通用的查找、更新操作调用,
* 它可以让字典在被使用的同时进行 rehash。
*
* T = O(1)
*/
}
static void _dictRehashStep(dict *d) {
if(d->iterators == 0) dictRehash(d,1);
三、总结
看完字典的代码,不仅为Redis作者的思路叹服!我们知道C语言是单线程的,那么通过渐进式hash这种多次hash操作就比较好的平衡了rehash时的效率问题。