从redis源码看数据结构(三)哈希表
卑微笔者大三,在准备明年的春招,下边是自己的一些复习的笔记,可能有不对的地方,还请社区的大佬能及时指教
字典相对于数组,链表来说,是一种较高层次的数据结构,像我们的汉语字典一样,可以通过拼音或偏旁唯一确定一个汉字,在程序里我们管每一个映射关系叫做一个键值对,很多个键值对放在一起就构成了我们的字典结构。
有很多高级的字典结构实现,例如我们 Java 中的 HashMap 底层实现,根据键的 Hash 值均匀的将键值对分散到数组中,并在遇到哈希冲突时,冲突的键值对通过单向链表串联,并在链表结构超过八个节点裂变成红黑树。
那么 redis 中是怎么实现的呢?我们一起来看一看。
一,redis底层hash字典定义
redis中的hash字典是使用拉链法的哈希表
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 链往后继节点(拉链法)
struct dictEntry *next;
} dictEntry;
其本质也是数组加上单链表的形式,数组用来存放key,当遇到哈希冲突不同key映射到数组同一位置时,采取拉链法放于数组同一位置来解决冲突,与hashmap很类似
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组的大小
unsigned long size;
// 指针数组的长度掩码,用于计算索引值
unsigned long sizemask;
// 哈希表现有的节点数量
unsigned long used;
} dictht;
上面就是字典的底层实现----哈希表,其实就是两张哈希表
/*
* 字典
*
* 每个字典使用两个哈希表,用于实现渐进式 rehash
*/
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2个)
dictht ht[2];
// 记录 rehash 进度的标志,值为-1 表示 rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} dict;
使用两张哈希表作为字典的实现是为了后续字典的扩展rehash用的
- ht[0]:就是平时用来存放普通键值对的哈希表,经常使用字典中的这个
- ht[2]:是在进行字典扩张时rehash才启用
二,字典的相关操作
1.初始化字典
在redis中字典中的hash表也是采用延迟初始化策略,在创建字典的时候并没有为哈希表分配内存,只有当第一次插入数据时,才真正分配内存。看看字典创建函数dictCreate。
/*
* 创建一个新字典
*
* T = O(1)
*/
dict *dictCreate(dictType *type,
void *privDataPtr)
{
// 分配空间
dict *d = zmalloc(sizeof(*d));
// 初始化字典
_dictInit(d,type,privDataPtr);
return d;
}
/*
* 初始化字典
*
* T = O(1)
*/
int _dictInit(dict *d, dictType *type,
void *privDataPtr)
{
// 初始化 ht[0]
_dictReset(&d->ht[0]);
// 初始化 ht[1]
_dictReset(&d->ht[1]);
// 初始化字典属性
d->type = type;
d->privdata = privDataPtr;
d->rehashidx = -1;
d->iterators = 0;
return DICT_OK;
}
2.rehash
刚刚在上边提到字典中的ht[1]只有在rehash时才启用,那么什么是rehash呢?
rehash是指当hash表扩容或者缩容后的各个key哈希值的重新调整
rehash过程:
1.渐进式rehash初始化
渐进式rehash初始化其实就只是创建了一个新的哈希表,并没有把原来哈希表中的key进行rehash并且放到这个新表中
/* Expand or create the hash table */
/*
* 渐进式rehash初始化
* 创建一个新哈希表,并视情况,进行以下动作之一:
*
* 1) 如果字典里的 ht[0] 为空,将新哈希表赋值给它
* 2) 如果字典里的 ht[0] 不为空,那么将新哈希表赋值给 ht[1] ,并打开 rehash 标识
*
* T = O(N)
*/
int dictExpand(dict *d, unsigned long size)
{
dictht n; /* the new hash table */
// 计算哈希表的真实大小
// O(N)
unsigned long realsize = _dictNextPower(size);
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
//如果当前正在进行rehash或者新哈希表的大小小于现已使用
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
// 创建并初始化新哈希表
// O(N)
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. */
// 如果 ht[0] 为空,那么这就是一次创建新哈希表行为
// 将新哈希表设置为 ht[0] ,然后返回
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
// 如果 ht[0] 不为空,那么这就是一次扩展字典的行为
// 将新哈希表设置为 ht[1] ,并打开 rehash 标识
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
2.将rehash的key放入新的哈希表
在redis的实现中,没有集中的将原有的key重新rehash到新的槽中,而是分解到各个命令的执行中,以及周期函数中 ,比如在redis的每一个增删改查函数中都会判断字典中哈希表是否正在进行渐进式rehash,即(rehashidx != -1),如果是,则帮忙执行一次将rehash后的key放在新的哈希表中这一过程
比如下边的添加键值对的操作
// 判断是否在进行渐进式的初始化rehash,如果是,则尝试渐进式地 rehash 一个元素
if (dictIsRehashing(d)) _dictRehashStep(d);
详细看下边:
插入键值对
/*
* 添加给定 key-value 对到字典
*
* T = O(1)
*/
int dictAdd(dict *d, void *key, void *val)
{
// 添加 key 到哈希表,返回包含该 key 的节点
dictEntry *entry = dictAddRaw(d,key);
// 添加失败?
if (!entry) return DICT_ERR;
// 设置节点的值
dictSetVal(d, entry, val);
//添加成功
return DICT_OK;
}
实际添加函数:
/*
* 添加 key 到字典的底层实现,完成之后返回新节点。
*
* 如果 key 已经存在,返回 NULL 。
*
* T = O(1)
*/
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;
// 判断是否在进行渐进式的初始化rehash,如果是,则尝试渐进式地 rehash 一个元素
if (dictIsRehashing(d)) _dictRehashStep(d);
// 查找可容纳新元素的索引位置
// 如果元素已存在, index 为 -1
if ((index = _dictKeyIndex(d, key)) == -1)
return NULL;//key存在直接返回null
/* Allocate the memory and store the new entry */
// 决定该把新元素放在哪个哈希表,如果实在正在进行渐进式rehash初始化,则新元素放在ht[1],否则则放在ht[0]
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
// 为新元素分配节点空间
entry = zmalloc(sizeof(*entry));
// 新节点的后继指针指向旧的表头节点
entry->next = ht->table[index];
// 设置新节点为表头
ht->table[index] = entry;
// 更新已有节点数量
ht->used++;
/* Set the hash entry fields. */
// 关联起节点和 key
dictSetKey(d, entry, key);
// 返回新节点
return entry;
}
rehash过程
其实上边添加键值对执行rehash是通过调用dictRehash方法执行的,这个函数式一个渐进式执行N步的rehash,n是rehash的次数
/*
* 执行 N 步渐进式 rehash 。
*
* 如果执行之后哈希表还有元素需要 rehash ,那么返回 1 。
* 如果哈希表里面所有元素已经迁移完毕,那么返回 0 。
*
* 每步 rehash 都会移动哈希表数组内某个索引上的整个链表节点,
* 所以从 ht[0] 迁移到 ht[1] 的 key 可能不止一个。
*
* T = O(N)
*/
int dictRehash(dict *d, int n) {
//判断当前有没有在进行rehash初始化。没有则返回0
if (!dictIsRehashing(d)) return 0;
while(n--) {
dictEntry *de, *nextde;
// 如果 ht[0] 已经为空,那么迁移完毕
// 用 ht[1] 代替原来的 ht[0]
if (d->ht[0].used == 0) {
// 释放 ht[0] 的哈希表数组
zfree(d->ht[0].table);
// 将 ht[0] 指向 ht[1]
d->ht[0] = d->ht[1];
// 清空 ht[1] 的指针
_dictReset(&d->ht[1]);
// 关闭 rehash 标识
d->rehashidx = -1;
// 通知调用者, rehash 完毕
return 0;
}
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned)d->rehashidx);
// 从当前正在rehash初始化的索引开始,移动到数组中首个不为 NULL 链表的索引上
while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
// 指向链表头
de = d->ht[0].table[d->rehashidx];
// 将链表内的所有元素从 ht[0] 迁移到 ht[1]
// 因为桶内的元素通常只有一个,或者不多于某个特定比率
// 所以可以将这个操作看作 O(1)
while(de) {
unsigned int h;
nextde = de->next;
/* Get the index in the new hash table */
// 计算元素在 ht[1] 的哈希值
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
// 添加节点到 ht[1] ,调整指针
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
// 更新计数器
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
// 设置指针为 NULL ,方便下次 rehash 时跳过
d->ht[0].table[d->rehashidx] = NULL;
// 前进至下一索引
d->rehashidx++;
}
// 通知调用者,还有元素等待 rehash
return 1;
}
执行完后,原来ht[0].table[rehashidx]下的链表节点全部转移到了ht[1]
rehash小结
在redis中,扩展或收缩哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面, 但是, 这个 rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的。为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0] 里面的所有键值对全部 rehash 到 ht[1] , 而是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1] 。
以下是哈希表渐进式 rehash 的详细步骤:
-
为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
-
在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
-
在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
-
随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。