一 前言
dict 常见称之字典(dictionary)或映射(map) ,其元素以键值对形式存在。是 Redis 最重要、常用的数据结构,可以说 Redis 本质就是一个 dict。
Redis 是一个内存型数据库,在 server.h 不难发现这样的定义:
typedef struct redisDb {
dict *dict; /* 使用 dict 来存储键值对 */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
unsigned long expires_cursor; /* Cursor of the active expire cycle. */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
dict 是为了解决算法中的查找问题而出现的,在无冲突下理论上能达到 O(1) 查找效率。Dict 本质其实就是一个 hashtable,所以也要面临 hash冲突、扩容缩容等问题。让我们带着这些问题继续看本文的正文部分。
二 结构分析
2.1 dict
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; 默认为 -1。当在进行 rehash 时候改值会累增
unsigned long iterators; /* 正在运行的迭代器数量 */
} dict;
1. dictType *type
指向 dictType 结构的指针, 可以以 OOP 中 interface 的方式理解这个结构,它是一个封装了对不同数据类型的操作接口,以支持不同的数据类型。由继承的数据类型实现具体逻辑,这一块读者可以自行查阅。
2. void *privdata
私有数据指针(privdata),由调用者在创建 dict 的时候赋值。和 dictType 一起实现多态字典。
3. dictht ht[2]
代表有0、1两个哈希表,为了实现渐进式扩容而设计的机制。下文会解释。
4. long rehashidx
长整形数字,默认为 -1。当在进行 rehash 时候改值会累增。下文会解释。
2.2 dictht(dict hash table)
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
1. dictEntry **table
这就是我们常说的哈希桶(bucket),哈希表节点指针数组。
2. unsigned long size
指针数组的大小,redis 很多数据结构中经常出现这种空间换时间的设计。
3. unsigned long sizemask
指针数组的长度掩码,用于计算 hash 值。大小等于(size-1),每个 key 先经过 hashFunction 计算得到一个哈希值,然后计算(哈希值 & sizemask)得到在table上的位置。相当于计算取余(哈希值 % size)。
4. unsigned long used
已存储的数据个数,该值也用于参与计算扩容时新哈希表的大小。used / size = 装载因子,这个比值越大哈希值冲突概率越高。
2.3 dictEntry 哈希表节点
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
1. void *key,键。
2. union 结构体,值。
3. struct dictEntry *next,哈希冲突时开链(开链法(拉链法)),单链表的next指针。
2.4 小结
上面分节描述了 dict 的结构构成,下面用一张图来总结下整个结构。
三 操作API
这一小节只介绍创建字典和添加元素两个接口,其他的可以看 dict.h 头文件中实现。
3.1 创建字典
1. dictCreate 方法作为创建字段的入口,逻辑较为简单清晰。调用 zmalloc 申请内存以后然后进行初始化。
2.
dict *dictCreate(dictType *type, void *privDataPtr)
{
dict *d = zmalloc(sizeof(*d));
_dictInit(d,type,privDataPtr);
return d;
}
2. 初始化字典,两个哈希表都是在此处进行初始化。
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;
return DICT_OK;
}
static void _dictReset(dictht *ht)
{
ht->table = NULL;
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}
3.2. 新增操作
int dictAdd(dict *d, void *key, void *val)
{
dictEntry *entry = dictAddRaw(d,key,NULL);
if (!entry) return DICT_ERR;
dictSetVal(d, entry, val);
return DICT_OK;
}
// 可以通过设置 existing 控制 value 是否可被复写,dictReplace 接口有实现。
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
long index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d);
1. 获取新元素的索引,如果元素已经存在 return null
if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
return NULL;
2. dictIsRehashing 判断当前字典是否在进行 rehash 操作,也就是 dict 结构中 rehashidx 不为 -1 时。
若在进行 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++;
dictSetKey(d, entry, key);
return entry;
}
四 rehash
上文说过 Dict 本质其实就是一个 hashtable,所以也要面临 hash冲突、扩容缩容等问题。
redis 的哈希表冲突采用的是拉链法方式进行处理,若连表过长势必会影响操作速度。作为一个以速度著称的缓存中间件来说是不可接受的,所说需要将链表进行散列开了。
所以需要扩大哈希槽范围,再者当前哈希表大小不能容纳新的元素。这些情况都需要进行扩容操作。
在 Redis 中哈希表的扩容称之为 渐进式 扩容,意思就是扩容不是一蹴而就,而是循序渐进的。如果直接申请新的内存,将原有的数据直接拷贝过去这势必会需要较大的时间(对于缓存服务来说)。
int dictRehash(dict *d, int n) {
int empty_visits = n*10;
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
assert(d->ht[0].size > (unsigned long)d->rehashidx);
1. 设置进行 rehash 状态。
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
while(de) {
uint64_t h;
nextde = de->next;
2. 将 ht[0] 哈希表中下标为 rehashidx 的元素剪切到 ht[1] 哈希表。
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++;
}
// 完成迁移后,需要对下一次 rehash 做准备。
if (d->ht[0].used == 0) {
1. 释放 0 下标哈希表内存。
zfree(d->ht[0].table);
2. 将 0 下标哈希表指向下标 1 哈希表。
d->ht[0] = d->ht[1];
3. 销毁下标 1 哈希表。
_dictReset(&d->ht[1]);
4. 设置未 rehash 状态
d->rehashidx = -1;
return 0;
}
// 进行迁移中
return 1;
}
大致的流程如上,看到这里有同学会问:怎么确保完全将 ht[0] 哈希表元素全部复制到新的 ht[1] 哈希表中?继续看 rehash 的过程。
- 为ht[1]分配空间,大小计算方式见下面一段。
- 将 dict 结构体中 rehashidx 设为0,表示 rehash 正式开始。
- 在进行 rehash 期间,每次对该字典进行操作的同时会对 rehashidx 进行累加操作并将 ht[0] 中下标为 rehashidx 的元素剪切到 ht[1]。 当然了新增操作是直接写到 ht[1] 中,可以看上面 dictAdd 接口。
- rehash 完成后,将 rehashidx 重新置为 -1、切换 ht[0] 和 ht[1] 指向、重置 ht[1] 为下一次 rehash 准备。
rehash 是一个双向操作,除了扩容也可以缩容。新的哈希表大小计算方式:
- 扩容 ht[1] 大小为第一个大于等于 ht[0].used * 2 *2 ^{n} 的整数。
- 缩容 ht[1] 大小为第一个大于等于 ht[0].used * 2^{n} 的整数。
五 总结
本文介绍了 redis 中 dict 数据结构,介绍了实现原理和面临问题。当然还有部分细节没有提到,在本文最后做一个总结列出。
- 负载因子 = 哈希表当前保存节点数 / 哈希表大小。
- 进行 rehash 触发时机,1. 负载因子大于等于 1 时进行扩容操作(当前未执行 BGSAVE 或 BGREWRITEAOF);2. 负载因子大于等于 5 时强制进行扩容操作。
- 负载因子小于 0.1 时进行缩容操作。