本篇文章介绍字典(Map/Dict)类型在Redis中的实现。上来第一个问题依旧是为什么Redis要自己实现字典类型?原因和链表类似,C语言没有提供字典类型。
字典类型对于Redis而言非常重要,Redis被称为KV数据库,因此其数据库在底层就是通过字典实现的。我们在Redis中插入一条键值对后,这个键值对就是保存在代表数据库的字典中。此外字典还是哈希键的底层实现之一。
1. 字典的实现
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值,总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
我们知道字典的底层实现就是哈希表,在Redis中对应的数据结构为dictht。table属性类型为保存指向dictEntry的指针的数组,size表示哈希表的大小,sizemask表示哈希表掩码,总是等于size - 1,used表示哈希表中已有节点的数量。
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
键值对在Redis通过dictEntry数据结构表示。key属性指向键,v属性存储或指向值,由于Redis中解决哈希冲突是通过链地址法,因此哈希值相同的键值对通过链表保存,next指针用于指向链表中的下一个键值对节点。
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
字典在Redis中通过dict数据结构表示。type和privadata属性是针对不同类型的键值对,为创建多态字段而设计的。ht[2]属性用于执行哈希表,至于为什么需要维护两个哈希表指针以及rehashidx的用途在后面介绍渐进式ReHash时进行讲解。iterators表示安全迭代器的数量。
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*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. 哈希算法
Redis计算在哈希表中索引的算法如下:
// 计算哈希值
hash = dict->type->hashFunction(k0);
// 计算在哈希表中的索引
idx = hash & dict->ht[0].sizemask;
3. ReHash
3.1 负载因子
负载因子的计算公式如下:
// 节点数 / 哈希表长度
load_factor = ht[0].used / ht[0].size;
3.2 哈希表的扩展与收缩
- 没有执行
BGSAVE或BGREWRITEAOF命令时,如果load_factor >= 1,哈希表大小扩展为第一个大于等于ht[0].used * 2的2^n。 - 正在执行
BGSAVE或BGREWRITEAOF命令时,如果load_factor >= 5,哈希表大小扩展为第一个大于等于ht[0].used * 2的2^n。。 - 如果
load_factor < 0.1,哈希表大小收缩为第一个大于等于ht[0].used的2^n。
3.3 ReHash
前面提高dict结构中维护这两个指向dictht的指针,ht[0]用于存储节点,ht[1]指向空哈希表。当哈希表进行扩展或收缩时,ht[1]会指向一个新分配的指定大小的哈希表,然后将ht[0]指向的哈希表中的所有节点重新计算哈希值和索引值将其存放到ht[1]指向的哈希表中,然后释放ht[0]的空间,然后ht[0]指向重新哈希的哈希表,ht[1]指向空哈希表,为下一次ReHash做准备。
3.4 渐进式ReHash
当哈希表中存放的节点数量过大时,如果一次性将所有的键值对全部ReHash到ht[1]的话,庞大的计算量会让服务器无法对外提供服务。为了避免ReHash对服务器性能的影响,服务器采用分多次、渐进式地将ht[0]里面的键值对慢慢地ReHash到ht[1]。
步骤如下:
- 为
ht[1]分配空间,让字典同时拥有ht[0]和ht[1]两个哈希表。 - 设置
rehashidx为0表示ReHash工作正式开始。 - 在
ReHash期间,每次操作都会附带将操作的数据从ht[0]ReHash到ht[1],rehashidx + 1。 - 当
ht[0]中的所有节点ReHash到ht[1]后,rehashidx = -1。
3.5 ReHash执行期间的哈希表操作
期间删除、查询和更新操作会在两个哈希表上进行。而插入操作只在ht[1]上进行。
4. API实现
4.1 创建新字典
dict *dictCreate(dictType *type,
void *privDataPtr)
{
// 分配空间
dict *d = zmalloc(sizeof(*d));
// 字典初始化
_dictInit(d,type,privDataPtr);
return d;
}
int _dictInit(dict *d, dictType *type,
void *privDataPtr)
{
// 初始化两个哈希表的各项属性值,但暂时还不分配内存给哈希表数组
_dictReset(&d->ht[0]);
_dictReset(&d->ht[1]);
// 设置类型特定函数
d->type = type;
// 设置私有数据
d->privdata = privDataPtr;
// 设置哈希表 rehash 状态
d->rehashidx = -1;
// 设置字典的安全迭代器数量
d->iterators = 0;
return DICT_OK;
}
static void _dictReset(dictht *ht)
{
ht->table = NULL;
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}
新建字典时需要传递字典类型和私有数据指针作为入参,新建的dict的ht[0]和ht[1]都空哈希表,rehasdhidx设置-1,表示未进行ReHash。
4.2 将给定的键值对添加到字典中
int dictAdd(dict *d, void *key, void *val)
{
// 尝试添加键到字典,并返回包含了这个键的新哈希节点
dictEntry *entry = dictAddRaw(d,key);
// 键已存在,添加失败
if (!entry) return DICT_ERR;
// 键不存在,设置节点的值
dictSetVal(d, entry, val);
// 添加成功
return DICT_OK;
}
// 尝试将键值添加到哈希表中,如果成功,返回对应的节点指针,反之返回NULL
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;
// 如果条件允许的话,进行单步 rehash,即将当前键值计算得到的哈希值对应哈希表中桶中所有的节点进行ReHash
if (dictIsRehashing(d)) _dictRehashStep(d);
/* Get the index of the new element, or -1 if
* the element already exists. */
// 计算键在哈希表中的索引值
// 如果值为 -1 ,那么表示键已经存在
// T = O(N)
if ((index = _dictKeyIndex(d, key)) == -1)
return NULL;
// T = O(1)
/* Allocate the memory and store the new entry */
// 如果字典正在 rehash ,那么将新键添加到 1 号哈希表
// 否则,将新键添加到 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. */
// 设置新节点的键
// T = O(1)
dictSetKey(d, entry, key);
return entry;
}
// 查看字典是否正在 rehash
#define dictIsRehashing(ht) ((ht)->rehashidx != -1)
/*
* 在字典不存在安全迭代器的情况下,对字典进行单步 rehash 。
*
* 字典有安全迭代器的情况下不能进行 rehash ,因为两种不同的迭代和修改操作可能会弄乱字典。
*
* 这个函数被多个通用的查找、更新操作调用,它可以让字典在被使用的同时进行 rehash 。
*/
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
步骤总结如下:
- 如果当前在正在
ReHash,将Key值对应的哈希表中的桶的所有节点ReHash到ht[1]。 - 判断该键值是否已经存在,如果存在返回
NULL。 - 如果正在
ReHash将新建的节点添加到ht[1]中,反之添加到ht[0]中。 - 将
Value值设置到对应的节点对象中。
4.3 更新键值
int dictReplace(dict *d, void *key, void *val)
{
dictEntry *entry, auxentry;
/* Try to add the element. If the key
* does not exists dictAdd will suceed. */
// 尝试直接将键值对添加到字典
// 如果键 key 不存在的话,添加会成功
// T = O(N)
if (dictAdd(d, key, val) == DICT_OK)
return 1;
/* It already exists, get the entry */
// 运行到这里,说明键 key 已经存在,那么找出包含这个 key 的节点
entry = dictFind(d, key);
// 先保存原有的值的指针
auxentry = *entry;
// 然后设置新的值
// T = O(1)
dictSetVal(d, entry, val);
// 然后释放旧值
// T = O(1)
dictFreeVal(d, &auxentry);
return 0;
}
替换指定的键对应的值,如果键不存在,则添加。
4.4 查询
dictEntry *dictFind(dict *d, const void *key)
{
dictEntry *he;
unsigned int h, idx, table;
// 字典(的哈希表)为空
if (d->ht[0].size == 0) return NULL; /* We don't have a table at all */
// 如果条件允许的话,进行单步 rehash
if (dictIsRehashing(d)) _dictRehashStep(d);
// 计算键的哈希值
h = dictHashKey(d, key);
// 在字典的哈希表中查找这个键
// T = O(1)
for (table = 0; table <= 1; table++) {
// 计算索引值
idx = h & d->ht[table].sizemask;
// 遍历给定索引上的链表的所有节点,查找 key
he = d->ht[table].table[idx];
// T = O(1)
while(he) {
if (dictCompareKeys(d, key, he->key))
return he;
he = he->next;
}
// 如果程序遍历完 0 号哈希表,仍然没找到指定的键的节点
// 那么程序会检查字典是否在进行 rehash ,
// 然后才决定是直接返回 NULL ,还是继续查找 1 号哈希表
if (!dictIsRehashing(d)) return NULL;
}
// 进行到这里时,说明两个哈希表都没找到
return NULL;
}
4.5 获取指定键的值
void *dictFetchValue(dict *d, const void *key) {
dictEntry *he;
// T = O(1)
he = dictFind(d,key);
return he ? dictGetVal(he) : NULL;
}
4.6 删除给定的键值
int dictDelete(dict *ht, const void *key) {
return dictGenericDelete(ht,key,0);
}
static int dictGenericDelete(dict *d, const void *key, int nofree)
{
unsigned int h, idx;
dictEntry *he, *prevHe;
int table;
// 字典(的哈希表)为空
if (d->ht[0].size == 0) return DICT_ERR; /* d->ht[0].table is NULL */
// 进行单步 rehash ,T = O(1)
if (dictIsRehashing(d)) _dictRehashStep(d);
// 计算哈希值
h = dictHashKey(d, key);
// 遍历哈希表
// T = O(1)
for (table = 0; table <= 1; table++) {
// 计算索引值
idx = h & d->ht[table].sizemask;
// 指向该索引上的链表
he = d->ht[table].table[idx];
prevHe = NULL;
// 遍历链表上的所有节点
// T = O(1)
while(he) {
if (dictCompareKeys(d, key, he->key)) {
// 超找目标节点
/* Unlink the element from the list */
// 从链表中删除
if (prevHe)
prevHe->next = he->next;
else
d->ht[table].table[idx] = he->next;
// 释放调用键和值的释放函数?
if (!nofree) {
dictFreeKey(d, he);
dictFreeVal(d, he);
}
// 释放节点本身
zfree(he);
// 更新已使用节点数量
d->ht[table].used--;
// 返回已找到信号
return DICT_OK;
}
prevHe = he;
he = he->next;
}
// 如果执行到这里,说明在 0 号哈希表中找不到给定键
// 那么根据字典是否正在进行 rehash ,决定要不要查找 1 号哈希表
if (!dictIsRehashing(d)) break;
}
// 没找到
return DICT_ERR; /* not found */
}
4.7 释放给定的字典
void dictRelease(dict *d)
{
// 删除并清空两个哈希表
_dictClear(d,&d->ht[0],NULL);
_dictClear(d,&d->ht[1],NULL);
// 释放节点结构
zfree(d);
}
5. 最后
关于Redis字典还有一些知识点没有提到,就是关于Scan的实现,这部分内容比较复杂,后面会在单独在一篇文章中讨论。