Redis实现-字典

Redis实现 - 字典

一、字典在Redis的使用

1.字典简述

字典又称为映射 – map,是一种用于保存键值对(K - V)的抽象数据结构。

在map中,一个键(K)对应一个值(V),这对关联的键和值称为键值对。

字典中的键都是唯一的。因此,我们可以通过键来改变值,或者删除整个键值对。

2.在Redis中的使用

字典在Redis中的应用非常广泛,比如,Redis的数据库就是使用字典来作为底层实现的。

数据库的增删改查操作也是构建在对字典之上的。

1
2
3
4
127.0.0.1:6379> set name jiang
OK
127.0.0.1:6379> get name
"jiang"

字典也是哈希键的底层实现。

1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> hset person name jiang
(integer) 1
127.0.0.1:6379> hset person age 20
(integer) 1
127.0.0.1:6379> keys person
1) "person"
127.0.0.1:6379> hget person age
"20"
127.0.0.1:6379> hdel person age
(integer) 1
127.0.0.1:6379> type person
hash

二、字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表有多个哈希表节点,一个节点对应一个键值对。

1、字典

字典的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* 字典
*/
typedef struct dict {

// 类型特定函数
dictType *type;

// 私有数据
void *privdata;

// 哈希表
dictht ht[2];

// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;

Type和private是针对不同类型的键值对,为创建多态字典而设置的。ht是一个哈希表数组,字典只使用ht[0],ht[1]哈希表只会在rehash时使用。rehashidx记录了rehash的进度,如果目前没有在进行rehash,那么它的值为-1。

  • Type

    这是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数。

    Redis会为用途不同的字典设置不同的类型特定函数。

  • private

    保存了需要传给那些类型特定函数的可选函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
* 字典类型特定函数
*/
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、哈希表节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* 哈希表节点
*/
typedef struct dictEntry {

// 键
void *key;

// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;

// 指向下个哈希表节点,形成链表
struct dictEntry *next;

} dictEntry;

每个dictEntry结构都保存着一个键值对。

Key和V属性分别是一个键值对的键和值。键是一个指针,而值既可以是一个指针,也可以是uint64_tint64_t的整数。

next是指向另一个哈希表节点的指针,解决键冲突的问题。

3、哈希表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct dictht {

// 哈希表数组
dictEntry **table;

// 哈希表大小
unsigned long size;

// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;

// 该哈希表已有节点的数量
unsigned long used;

} dictht;

table是一个数组,数组中的每个元素都是一个指向dictEntry的指针。

size记录了哈希表的大小,即table数组的大小

used属性则记录了哈希表目前已有的节点的数量

sizemask属性的值总是等于size - 1,这个属性和哈希值一起决定一个键应该被放到table数组的具体位置上。

字典结构

三、哈希算法

哈希算法是根据键值对的键计算出哈希数组索引值。

将一个键值对添加到字典中,先根据键计算出哈希值和索引值,然后将键值对放到指定的索引中。

1
2
3
4
5
6
7
8
# Redis计算哈希值和索引值的方法:

1. 使用字典设置的哈希函数,计算key的哈希值
hash = dict->type->hashFunction(key)

2. 使用哈希表的sizemask属性和哈希值,计算出索引值
根据情况不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dict->ht[x].sizemask;

四、解决键冲突

键冲突:当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面。

解决:使用链地址法,使用next指针将冲突的键连接起来。

具体细节:头插法,将新的节点添加到链表的表头位置。

五、rehash

1、rehash

随着添加节点的操作不断执行,存储的键值对不断增加。

我们知道,从链表中查询一个节点的时间复杂度时o(1),键值对不断增加,那么一个链表中的节点就越多,时间消耗就越多。

因此,我们会将哈希表的长度扩大,将一个链表的结点重新散列在各个数组结点中。

扩展和收缩哈希表数组的工作可以通过执行rehash(重新散列)操作来完成。

1
2
3
4
5
6
7
8
9
10
11
# rehash的步骤
1. 为ht[1]哈希表分配空间
如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].usedX2 的2的n次幂
比如:此时h[0].used=4,4X2=8,而8正好是2的3次幂。因此ht[1]的大小设置为8
如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次幂
比如:此时h[0].used=3,那么ht[1]=4

2. 将保存在ht[0]中的所有键值对rehash到ht[1]上面
rehash指的就是重新计算键的哈希值和索引值。
3. 当ht[0]中的所有键值对都迁移到ht[1]之后
ht[0]会变成空表,此时将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表

2、哈希表的扩展和收缩:

扩展和收缩都需要一个指标:负载因子。

哈希表的负载因此的计算方式:

load_factor(负载因子) = ht[0].used / ht[0].size

  • 扩展时机

    1
    2
    3
    # 以下条件满足任意一个即可扩展:
    1. redis服务器目前没有执行BGSAVE或者BGREWRITEAOF命令,且负载因子大于等于1
    2. redis服务器正在执行BGSAVE或者BGREWRITEAOF命令,且负载因子大于等于5

    根据BGSAVE和BGREWRITEAOF命令是否执行,负载因子并不相同。

    这是因为在执行这个命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,尽可能避免在子进程存在期间进行哈希扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。

  • 收缩

    负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

3、渐进式rehash

rehash的操作并不是一次性、集中式的完成的,而是多次、渐进式地完成的。

因为如果redis中的键值对太多,一次性的完成rehash可能会造成长时间系统的停滞。

1
2
3
4
5
# rehash的详细步骤
1. 为ht[1]分配空间
2. 字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作开始
3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时程序除了执行这些操作,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]
4. ht[0]中的键值对全部rehash至ht[1]之后,将rehashidx属性设为-1

渐进式rehash的好处:

将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上。

避免了集中式rehash而带来的庞大计算量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* 执行 N 步渐进式 rehash
*
* 返回 1 表示仍有键需要从 0 号哈希表移动到 1 号哈希表,
* 返回 0 则表示所有键都已经迁移完毕。
*
* 注意,每步 rehash 都是以一个哈希表索引(桶)作为单位的,
* 一个桶里可能会有多个节点,
* 被 rehash 的桶里的所有节点都会被移动到新哈希表。
*
*/
int dictRehash(dict *d, int n) {

// 只可以在 rehash 进行中时执行
if (!dictIsRehashing(d)) return 0;

// 进行 N 步迁移
// T = O(N)
while(n--) {
dictEntry *de, *nextde;

/* Check if we already rehashed the whole table... */
// 如果 0 号哈希表为空,那么表示 rehash 执行完毕
// T = O(1)
if (d->ht[0].used == 0) {
// 释放 0 号哈希表
zfree(d->ht[0].table);
// 将原来的 1 号哈希表设置为新的 0 号哈希表
d->ht[0] = d->ht[1];
// 重置旧的 1 号哈希表
_dictReset(&d->ht[1]);
// 关闭 rehash 标识
d->rehashidx = -1;
// 返回 0 ,向调用者表示 rehash 已经完成
return 0;
}

/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
// 确保 rehashidx 没有越界
assert(d->ht[0].size > (unsigned)d->rehashidx);

// 略过数组中为空的索引,找到下一个非空索引
while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;

// 指向该索引的链表表头节点
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
// 将链表中的所有节点迁移到新哈希表
// T = O(1)
while(de) {
unsigned int 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;
// 更新 rehash 索引
d->rehashidx++;
}

return 1;
}

4、rehash执行期间的哈希表操作

在rehash执行期间,字典会同时使用ht[0]和ht[1]两个哈希表。

因此,在rehash执行期间,字典的删除、查找、更新等操作会在两个哈希表上进行。

比如:如果执行查找操作,会先在ht[0]中查找,然后在ht[1]中查找。保存操作在ht[1]中执行。

六、源码

dict.c和dict.h

rehash相关的属性

1
2
3
4
// 指示字典是否启用 rehash 的标识
static int dict_can_resize = 1;
// 强制 rehash 的比率
static unsigned int dict_force_resize_ratio = 5;

重置(或初始化)给定哈希表的各项属性值

1
2
3
4
5
6
7
static void _dictReset(dictht *ht)
{
ht->table = NULL;
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}

初始化哈希表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
}

创建一个新的字典

1
2
3
4
5
6
dict *dictCreate(dictType *type,void *privDataPtr)
{
dict *d = zmalloc(sizeof(*d));
_dictInit(d,type,privDataPtr);
return d;
}

将键插入到字典

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
* 尝试将键插入到字典中
*
* 如果键已经在字典存在,那么返回 NULL
*
* 如果键不存在,那么程序创建新的哈希节点,
* 将节点和键关联,并插入到字典,然后返回节点本身。
*/
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;

// 如果条件允许的话,进行单步 rehash
// T = O(1)
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;
}

返回字典中包含键 key 的节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/*
* 返回字典中包含键 key 的节点
*
* 找到返回节点,找不到返回 NULL
*
* T = O(1)
*/
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;
}

随机返回字典中任意一个节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/*
* 随机返回字典中任意一个节点。
*
* 可用于实现随机化算法。
*
* 如果字典为空,返回 NULL 。
*
* T = O(N)
*/
dictEntry *dictGetRandomKey(dict *d)
{
dictEntry *he, *orighe;
unsigned int h;
int listlen, listele;

// 字典为空
if (dictSize(d) == 0) return NULL;

// 进行单步 rehash
if (dictIsRehashing(d)) _dictRehashStep(d);

// 如果正在 rehash ,那么将 1 号哈希表也作为随机查找的目标
if (dictIsRehashing(d)) {
// T = O(N)
do {
h = random() % (d->ht[0].size+d->ht[1].size);
he = (h >= d->ht[0].size) ? d->ht[1].table[h - d->ht[0].size] :
d->ht[0].table[h];
} while(he == NULL);
// 否则,只从 0 号哈希表中查找节点
} else {
// T = O(N)
do {
h = random() & d->ht[0].sizemask;
he = d->ht[0].table[h];
} while(he == NULL);
}

/* Now we found a non empty bucket, but it is a linked
* list and we need to get a random element from the list.
* The only sane way to do so is counting the elements and
* select a random index. */
// 目前 he 已经指向一个非空的节点链表
// 程序将从这个链表随机返回一个节点
listlen = 0;
orighe = he;
// 计算节点数量, T = O(1)
while(he) {
he = he->next;
listlen++;
}
// 取模,得出随机节点的索引
listele = random() % listlen;
he = orighe;
// 按索引查找节点
// T = O(1)
while(listele--) he = he->next;

// 返回随机节点
return he;
}

查找并删除包含给定键的节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
/* Search and remove an element */
/*
* 查找并删除包含给定键的节点
*
* 参数 nofree 决定是否调用键和值的释放函数
* 0 表示调用,1 表示不调用
*
* 找到并成功删除返回 DICT_OK ,没找到则返回 DICT_ERR
*
* T = O(1)
*/
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 */
}

对字典进行扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static int _dictExpandIfNeeded(dict *d)
{
/* Incremental rehashing already in progress. Return. */
// 渐进式 rehash 已经在进行了,直接返回
if (dictIsRehashing(d)) return DICT_OK;

/* If the hash table is empty expand it to the initial size. */
// 如果字典(的 0 号哈希表)为空,那么创建并返回初始化大小的 0 号哈希表
// T = O(1)
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

/* If we reached the 1:1 ratio, and we are allowed to resize the hash
* table (global setting) or we should avoid it but the ratio between
* elements/buckets is over the "safe" threshold, we resize doubling
* the number of buckets. */
// 一下两个条件之一为真时,对字典进行扩展
// 1)字典已使用节点数和字典大小之间的比率接近 1:1
// 并且 dict_can_resize 为真
// 2)已使用节点数和字典大小之间的比率超过 dict_force_resize_ratio
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
// 新哈希表的大小至少是目前已使用节点数的两倍
// T = O(N)
return dictExpand(d, d->ht[0].used*2);
}

return DICT_OK;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
* 创建一个新的哈希表,并根据字典的情况,选择以下其中一个动作来进行:
*
* 1) 如果字典的 0 号哈希表为空,那么将新哈希表设置为 0 号哈希表
* 2) 如果字典的 0 号哈希表非空,那么将新哈希表设置为 1 号哈希表,
* 并打开字典的 rehash 标识,使得程序可以开始对字典进行 rehash
*
* size 参数不够大,或者 rehash 已经在进行时,返回 DICT_ERR 。
*
* 成功创建 0 号哈希表,或者 1 号哈希表时,返回 DICT_OK 。
*
* T = O(N)
*/
int dictExpand(dict *d, unsigned long size)
{
// 新哈希表
dictht n; /* the new hash table */

// 根据 size 参数,计算哈希表的大小
// T = O(1)
unsigned long realsize = _dictNextPower(size);

/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
// 不能在字典正在 rehash 时进行
// size 的值也不能小于 0 号哈希表的当前已使用节点
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;

/* Allocate the new hash table and initialize all pointers to NULL */
// 为哈希表分配空间,并将所有指针指向 NULL
n.size = realsize;
n.sizemask = realsize-1;
// T = O(N)
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. */
// 如果 0 号哈希表为空,那么这是一次初始化:
// 程序将新哈希表赋给 0 号哈希表的指针,然后字典就可以开始处理键值对了。
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}

/* Prepare a second hash table for incremental rehashing */
// 如果 0 号哈希表非空,那么这是一次 rehash :
// 程序将新哈希表设置为 1 号哈希表,
// 并将字典的 rehash 标识打开,让程序可以开始对字典进行 rehash
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}

Redis实现-字典
https://johnjoyjzw.github.io/2021/09/09/Redis实现-字典/
Author
JiangZW
Posted on
September 9, 2021
Licensed under