一、原理
1.1 数组+链表的底层数据结构
字典是用hash实现的,通过数组+链表解决hash冲突。通过hash函数计算出key的index后,在对应的链表上进行数据的增删查。
1.2 渐进式rehash
rehash含义:当dict中的数据越来越多时,数组中的链表会越来越长,dict的增删改效率越来越低,因此需要对数组进行扩容,这就是rehash。
渐进式rehash的含义:当数组扩容时,需要进行一次数据迁移。在触发扩容时,并不执行数据迁移,而是在后续的每次增删改等操作中,迁移一部分数据。
为什么渐进式rehash:当dict很大时,扩容涉及的数据非常的多,如果一次迁移完会导致服务器卡顿。
二、数据结构定义
2.1 dict
- dictType是hash相关的处理函数,C的多态实现,在运行时确定需要调用的函数。
- privdata,在dictType中的处理函数里会使用该数据。
- ht[2]。实际的数据存储,非rehash情况下,使用ht[0]存储数据,rehash过程中,ht[0]存老的数据,ht[1]存新的数据。
- rehashidx。rehash的进度,-1表示不在rehash,其它值表示当前rehash的进度,数据迁移是遍历ht[0]把数据迁移到ht[1]中,rehashidx记录了当前遍历ht[0]的下标。
- iterators表示指向该dict的安全迭代器的数量。
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;
2.2 dictType
dict绑定的处理函数,主要有hash函数,key比较函数,key-value的拷贝和析构函数。
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;
2.3 dictht
实际存储key-value的结构。
typedef struct dictht {
dictEntry **table; // 存储数据的数组
unsigned long size; // 数组大小
unsigned long sizemask; // size为2^n sizemask则为size - 1
unsigned long used; // 哈希表中k-v的数量
} dictht;
2.4 dictEntry
字典的元存储数据结构,k-v类型,包含存储的键值对和数组中对应的链表的下一个元素。
typedef struct dictEntry {
void *key; // key
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // val
struct dictEntry *next; // 指向下一个元素
} dictEntry;
2.5 dictIterator
dict的迭代器,迭代的过程可以从上面的dict结构大致可以看出。2层嵌套循环,外层遍历数组,内层遍历数组元素对应的链表。
typedef struct dictIterator {
dict *d; // 迭代器对应的字典
long index; // 遍历的hash表对应的数组下标
int table, safe; // table对应dict->ht[2]的下标 如果是正在rehash这2个表中都会有数据
// safe=1表示该迭代器可以在迭代中间进行增删改等操作 反之则不行
dictEntry *entry, *nextEntry; // entry表示当前迭代器指向的元素 nextEntry表示
/* unsafe iterator fingerprint for misuse detection. */
long long fingerprint; // 对于unsafe的迭代器 遍历前根据dict的值计算一个fingerprint
// 当迭代完成后 比较当前的fingerprint和迭代前的值是否相同
// 不同则说明在迭代过程中有增删操作 迭代器使用不合法
} dictIterator;
三、疑问和答案
3.1 key-value的存储格式
- key为void*类型,只有起始地址。类型和长度由上层业务控制。可能类型有是int,long,double,sds等类型。
- value可以是int64, uint64, double等内置类型,也可以是void*。需要业务去解析内存的类型和长度。
3.2 扩容判断算法
static unsigned int dict_force_resize_ratio = 5;
#define DICT_HT_INITIAL_SIZE 4
static int _dictExpandIfNeeded(dict *d)
{
if (dictIsRehashing(d)) return DICT_OK; // 如果已在rehash中 不再触发
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); // 初始化大小为4
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;
}
1.正常情况,hash表的的数据量大于数组长度时扩容。
2.特殊情况,hash表的数据量大于数组长度的5倍时,强制扩容。
3.3 扩容的容量计算
- hash表的初始大小为4。
- 每次扩容时,大小翻倍。
3.4 何时数据迁移
涉及的相关代码较多,这里不贴源码了,数据迁移调用函数_dictRehashStep。通过在源码中搜索该函数的调用可以发现,在对dict进行增删查改时,会触发该函数。
3.5 如何数据迁移
数据迁移比较简单,每次最多找10个空的链表或者处理一个有数据的链表。
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
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;
assert(d->ht[0].size > (unsigned long)d->rehashidx);
while(d->ht[0].table[d->rehashidx] == NULL) { // 递增rehashidx找非空的链表 但是最多找 n*10个 找不到则返回
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
while(de) { // 找到数组中非空的链表后 遍历链表将所有数据都进行迁移
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++;
}
// 如果已经迁移完 设置标志位并且启用dict->ht[0] 弃用dict->ht[1]
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;
}
3.6 dict的释放
释放的实现也比较简单,不看源码也可以猜到无非是遍历整个dict释放所有元素,最后释放dict自身。相关函数dictRelease,有兴趣可以看源码。
3.7 dict落地的序列化
这个在序列化模块找答案,查看dict.h/c中的所有代码后,未找到序列化相关的函数。
3.8 rehash过程中再次触发扩容
- 有可能rehash过程中再次触发扩容。
- 上面源码中可以看到,触发二次扩容会直接返回。
3.9 迭代器遍历实现
- 上面已经提到了,迭代器分为安全和非安全2种。区别在于迭代过程中是否能进行增删改。
- 非安全迭代会在开始前根据dict的数据生成一个fingerprint,在结束后再次生成fingerprint,比较2个值是否相同即可判断中间是否有增删改。
- rehash过程中,2个哈希表都需要遍历。
dictEntry *dictNext(dictIterator *iter)
{
while (1) { // 只有遍历完或者找到下一个非空的元素才退出循环
if (iter->entry == NULL) {
dictht *ht = &iter->d->ht[iter->table];
if (iter->index == -1 && iter->table == 0) { // 迭代开始的特殊处理
if (iter->safe)
iter->d->iterators++; // 表示dict当前有效的迭代器+1
else
iter->fingerprint = dictFingerprint(iter->d); // 非安全模式的迭代器
}
iter->index++;
if (iter->index >= (long) ht->size) { // 如果当前的hash表已遍历完
if (dictIsRehashing(iter->d) && iter->table == 0) { // 判断是否在rehash中 并且当前是ht[0] 是则遍历ht[1]
iter->table++;
iter->index = 0;
ht = &iter->d->ht[1];
} else {
break; // 遍历完成
}
}
iter->entry = ht->table[iter->index];
} else {
iter->entry = iter->nextEntry;
}
if (iter->entry) { // 找到非空的元素
/* We need to save the 'next' here, the iterator user
* may delete the entry we are returning. */
// 上面注释解释了为什么要保存nextEntry的原因 当entry被删除时 nextEntry可以记录遍历的进度
iter->nextEntry = iter->entry->next;
return iter->entry;
}
}
return NULL;
}
3.10 rehash中的迭代器遍历
从上面源码可知,rehash过程的遍历和普通的遍历的差别仅在于遍历完dict->ht[0]后需要遍历dict->ht[1]。
3.11 迭代过程中元素的增删
通过上面的源码可以对安全模式下迭代器遍历时有字典有数据的增删的情况讨论。
- 增加且增加到nextEntry之前,新增的数据无法被遍历到。
- 增加且增加到nextEntry之后,新增的数据在后续过程中会被遍历到。
- 删除且删除nextEntry之前的数据包括entry,不会对遍历产生影响。
- 删除nextEntry。会导致野指针的访问,后续迭代访问nextEntry时访问的是野指针。
- 删除nextEntry之后的数据。不会对遍历产生影响。
总结
- 渐进式rehash是个非常常见的情况,go中的map使用的也是渐进式rehash。对于做服务器的而言,应该把这个作为常识,一个操作的复杂度超过一定阈值时,必须考虑分多次操作,不然有可能会引起服务器的卡顿。
- 非安全迭代器的校验。非安全迭代器在迭代前后会各生成一个fingerprint,前后对比就能判断中间是否有数据修改。这个想法很好,但是实现的方式有待商榷,源码如下:
long long dictFingerprint(dict *d) {
long long integers[6], hash = 0;
int j;
integers[0] = (long) d->ht[0].table;
integers[1] = d->ht[0].size;
integers[2] = d->ht[0].used;
integers[3] = (long) d->ht[1].table;
integers[4] = d->ht[1].size;
integers[5] = d->ht[1].used;
for (j = 0; j < 6; j++) {
hash += integers[j];
/* For the hashing step we use Tomas Wang's 64 bit integer hash. */
hash = (~hash) + (hash << 21); // hash = (hash << 21) - hash - 1;
hash = hash ^ (hash >> 24);
hash = (hash + (hash << 3)) + (hash << 8); // hash * 265
hash = hash ^ (hash >> 14);
hash = (hash + (hash << 2)) + (hash << 4); // hash * 21
hash = hash ^ (hash >> 28);
hash = hash + (hash << 31);
}
return hash;
}
从上面的源码可以看到,无论它使用的hash算法多好,只要size和used前后一致,算出来的结果就是一样的,也就意味着只要增加和删除的操作是成对的,不管操作的是否是同一个元素,最后dict被修改了也无法通过fingerprint看出来。