Redis 字典
字典
字典(dictionary),又名映射(map)或关联数组(associative array), [http://en.wikipedia.org/wiki/Associative_array]是一种抽象数据结构,由一集键值对(key-value pairs)组成,各个键值对的键各不相同,程序可以添加新的键值对到字典中,或者基于键进行查找、更新或删除等操作。
本章先对字典在 Redis 中的应用进行介绍,接着讲解字典的具体实现方式,以及这个字典实现要解决的问题,最后,以对字典迭代器的介绍作为本章的结束。
字典的应用
字典在 Redis 中的应用广泛,使用频率可以说和 SDS 以及双端链表不相上下,基本上各个功能模块都有用到字典的地方。
其中,字典的主要用途有以下两个:
- 实现数据库键空间(key space);
- 用作 Hash 类型键的底层实现之一;
以下两个小节分别介绍这两种用途。
实现数据库键空间
Redis 是一个键值对数据库,数据库中的键值对由字典保存:每个数据库都有一个对应的字典,这个字典被称之为键空间(key space)。
当用户添加一个键值对到数据库时(不论键值对是什么类型),程序就将该键值对添加到键空间;当用户从数据库中删除键值对时,程序就会将这个键值对从键空间中删除;等等。
举个例子,执行 FLUSHDB [http://redis.readthedocs.org/en/latest/server/flushdb.html#flushdb] 可以清空键空间里的所有键值对数据:
redis> FLUSHDB
OK
执行 DBSIZE [http://redis.readthedocs.org/en/latest/server/dbsize.html#dbsize] 则返回键空间里现有的键值对:
redis> DBSIZE
(integer) 0
还可以用 SET [http://redis.readthedocs.org/en/latest/string/set.html#set] 设置一个字符串键到键空间,并用 GET [http://redis.readthedocs.org/en/latest/string/get.html#get] 从键空间中取出该字符串键的值:
redis> SET number 10086
OK
redis> GET number
"10086"
redis> DBSIZE
(integer) 1
后面的《数据库》一章会对键空间以及数据库的实现作详细的介绍,届时将看到,大部分针对数据库的命令,比如 DBSIZE [http://redis.readthedocs.org/en/latest/server/dbsize.html#dbsize] 、 FLUSHDB [http://redis.readthedocs.org/en/latest/server/flushdb.html#flushdb] 、 RANDOMKEY [http://redis.readthedocs.org/en/latest/key/randomkey.html#randomkey] ,等等,都是构建于对字典的操作之上的;而那些创建、更新、删除和查找键值对的命令,也无一例外地需要在键空间上进行操作。
用作 Hash 类型键的底层实现之一
Redis 的 Hash 类型键使用以下两种数据结构作为底层实现:
- 字典;
- 压缩列表;
因为压缩列表比字典更节省内存,所以程序在创建新 Hash 键时,默认使用压缩列表作为底层实现,当有需要时,程序才会将底层实现从压缩列表转换到字典。
当用户操作一个 Hash 键时,键值在底层就可能是一个哈希表:
redis> HSET book name "The design and implementation of Redis"
(integer) 1
redis> HSET book type "source code analysis"
(integer) 1
redis> HSET book release-date "2013.3.8"
(integer) 1
redis> HGETALL book
1) "name"
2) "The design and implementation of Redis"
3) "type"
4) "source code analysis"
5) "release-date"
6) "2013.3.8"
《哈希表》章节给出了关于哈希类型键的更多信息,并介绍了压缩列表和字典之间的转换条件。
介绍完了字典的用途,现在让我们来看看字典数据结构的定义。
字典的实现
实现字典的方法有很多种:
- 最简单的就是使用链表或数组,但是这种方式只适用于元素个数不多的情况下;
- 要兼顾高效和简单性,可以使用哈希表;
- 如果追求更为稳定的性能特征,并希望高效地实现排序操作的话,则可使用更为复杂的平衡树;
在众多可能的实现中,Redis 选择了高效、实现简单的哈希表,作为字典的底层实现。
dict.h/dict
给出了这个字典的定义:
/*
* 字典
*
* 每个字典使用两个哈希表,用于实现渐进式 rehash
*/
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录 rehash 进度的标志,值为 -1 表示 rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} dict;
以下是用于处理 dict
类型的 API ,它们的作用及相应的算法复杂度:
操作 | 函数 | 算法复杂度 |
---|---|---|
创建一个新字典 | dictCreate |
(O(1)) |
添加新键值对到字典 | dictAdd |
(O(1)) |
添加或更新给定键的值 | dictReplace |
(O(1)) |
在字典中查找给定键所在的节点 | dictFind |
(O(1)) |
在字典中查找给定键的值 | dictFetchValue |
(O(1)) |
从字典中随机返回一个节点 | dictGetRandomKey |
(O(1)) |
根据给定键,删除字典中的键值对 | dictDelete |
(O(1)) |
清空并释放字典 | dictRelease |
(O(N)) |
清空并重置(但不释放)字典 | dictEmpty |
(O(N)) |
缩小字典 | dictResize |
(O(N)) |
扩大字典 | dictExpand |
(O(N)) |
对字典进行给定步数的 rehash | dictRehash |
(O(N)) |
在给定毫秒内,对字典进行rehash | dictRehashMilliseconds |
(O(N)) |
注意 dict
类型使用了两个指针,分别指向两个哈希表。
其中,0 号哈希表(ht[0]
)是字典主要使用的哈希表,而 1 号哈希表(ht[1]
)则只有在程序对 0 号哈希表进行 rehash 时才使用。
接下来两个小节将对哈希表的实现,以及哈希表所使用的哈希算法进行介绍。
哈希表实现
字典所使用的哈希表实现由 dict.h/dictht
类型定义:
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组的大小
unsigned long size;
// 指针数组的长度掩码,用于计算索引值
unsigned long sizemask;
// 哈希表现有的节点数量
unsigned long used;
} dictht;
table
属性是个数组,数组的每个元素都是个指向 dictEntry
结构的指针。
每个 dictEntry
都保存着一个键值对,以及一个指向另一个 dictEntry
结构的指针:
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 链往后继节点
struct dictEntry *next;
} dictEntry;
next
属性指向另一个 dictEntry
结构,多个 dictEntry
可以通过 next
指针串连成链表,从这里可以看出,dictht
使用链地址法来处理键碰撞 [http://en.wikipedia.org/wiki/Hash_table#Separate_chaining]:当多个不同的键拥有相同的哈希值时,哈希表用一个链表将这些键连接起来。
下图展示了一个由 dictht
和数个 dictEntry
组成的哈希表例子:
dictht |
如果再加上之前列出的 dict
类型,那么整个字典结构可以表示如下:
ht[2] | rehashidx: -1 | iterators: 0", fillcolor = "#A8E270"]; ht0 [label="dictht |
在上图的字典示例中,字典虽然创建了两个哈希表,但正在使用的只有 0 号哈希表,这说明字典未进行 rehash 状态。
哈希算法
Redis 目前使用两种不同的哈希算法:
- MurmurHash2 32 bit 算法:这种算法的分布率和速度都非常好, 具体信息请参考 MurmurHash 的主页: http://code.google.com/p/smhasher/ 。
- 基于 djb 算法实现的一个大小写无关散列算法:具体信息请参考 http://www.cse.yorku.ca/~oz/hash.html 。
使用哪种算法取决于具体应用所处理的数据:
- 命令表以及 Lua 脚本缓存都用到了算法 2 。
- 算法 1 的应用则更加广泛:数据库、集群、哈希键、阻塞操作等功能都用到了这个算法。
创建新字典
dictCreate
函数创建并返回一个新字典:
dict *d = dictCreate(&hash_type, NULL);
d
的值可以用图片表示如下:
ht[2] | rehashidx | iterators", fillcolor = "#A8E270"]; ht0 [label="dictht |
新创建的两个哈希表都没有为 table
属性分配任何空间:
ht[0]->table
的空间分配将在第一次往字典添加键值对时进行;ht[1]->table
的空间分配将在 rehash 开始时进行;
添加键值对到字典
根据字典所处的状态,将给定的键值对添加到字典可能会引起一系列复杂的操作:
- 如果字典为未初始化(即字典的 0 号哈希表的
table
属性为空),则程序需要对 0 号哈希表进行初始化; - 如果在插入时发生了键碰撞,则程序需要处理碰撞;
- 如果插入新元素,使得字典满足了 rehash 条件,则需要启动相应的 rehash 程序;
当程序处理完以上三种情况之后,新的键值对才会被真正地添加到字典上。
整个添加流程可以用下图表示:
key_exists_or_not; return_null_if_key_exists [label="返回 NULL ,\n表示添加失败"]; key_exists_or_not -> return_null_if_key_exists [label="是"]; dict_empty_or_not [label="ht[0]\n 未分配任何空间?", shape=diamond, fillcolor = "#95BBE3"]; key_exists_or_not -> dict_empty_or_not [label="否"]; init_hash_table_one [label="初始化 ht[0]"]; dict_empty_or_not -> init_hash_table_one [label="是"]; init_hash_table_one -> need_rehash_or_not; need_rehash_or_not [label="需要 rehash ?", shape=diamond, fillcolor = "#95BBE3"]; dict_empty_or_not -> need_rehash_or_not [label="否"]; begin_incremental_rehash [label="开始渐进式 rehash "]; need_rehash_or_not -> begin_incremental_rehash [label="需要,\n并且 rehash 未进行"]; begin_incremental_rehash -> rehashing_or_not; rehashing_or_not [label="rehash\n 正在进行中?", shape=diamond, fillcolor = "#95BBE3"]; need_rehash_or_not -> rehashing_or_not [label="不需要,\n或者 rehash 正在进行"]; is_rehashing [label="选择 ht[1] 作为新键值对的添加目标"]; not_rehashing [label="选择 ht[0] 作为新键值对的添加目标"]; rehashing_or_not -> is_rehashing [label="是"]; rehashing_or_not -> not_rehashing [label="否"]; calc_hash_code_and_index_by_key [label="根据给定键,计算出哈希值,以及索引值"]; is_rehashing -> calc_hash_code_and_index_by_key; not_rehashing -> calc_hash_code_and_index_by_key; create_entry_and_assoc_key_and_value [label="创建新 dictEntry ,并保存给定键值对"]; calc_hash_code_and_index_by_key -> create_entry_and_assoc_key_and_value; add_entry_to_hashtable [label="根据索引值,将新节点添加到目标哈希表"]; create_entry_and_assoc_key_and_value -> add_entry_to_hashtable;}" />
在接下来的三节中,我们将分别看到,添加操作如何在以下三种情况中执行:
- 字典为空;
- 添加新键值对时发生碰撞处理;
- 添加新键值对时触发了 rehash 操作;
添加新元素到空白字典
当第一次往空字典里添加键值对时,程序会根据 dict.h/DICT_HT_INITIAL_SIZE
里指定的大小为d->ht[0]->table
分配空间(在目前的版本中, DICT_HT_INITIAL_SIZE
的值为 4
)。
以下是字典空白时的样子:
ht[2] | rehashidx | iterators", fillcolor = "#A8E270"]; ht0 [label="dictht |
以下是往空白字典添加了第一个键值对之后的样子:
ht[2] | rehashidx | iterators", fillcolor = "#A8E270"]; ht0 [label="dictht |
添加新键值对时发生碰撞处理
在哈希表实现中,当两个不同的键拥有相同的哈希值时,称这两个键发生碰撞(collision),而哈希表实现必须想办法对碰撞进行处理。
字典哈希表所使用的碰撞解决方法被称之为链地址法 [http://en.wikipedia.org/wiki/Hash_table#Separate_chaining]:这种方法使用链表将多个哈希值相同的节点串连在一起,从而解决冲突问题。
假设现在有一个带有三个节点的哈希表,如下图:
0 | 1 | 2 | 3 ", fillcolor = "#F2F2F2"]; pair_1 [label="dictEntry |{key1 | value1 |next}", fillcolor = "#FADCAD"]; pair_2 [label="dictEntry |{key2 | value2 |next}", fillcolor = "#FADCAD"]; pair_3 [label="dictEntry |{key3 | value3 |next}", fillcolor = "#FADCAD"]; null0 [label="NULL", shape=plaintext]; null1 [label="NULL", shape=plaintext]; null2 [label="NULL", shape=plaintext]; null3 [label="NULL", shape=plaintext]; // lines bucket:table0 -> pair_1:head; pair_1:next -> null0; bucket:table1 -> null1; bucket:table2 -> pair_2:head; pair_2:next -> null2; bucket:table3 -> pair_3:head; pair_3:next -> null3; // label label = "添加碰撞节点之前";}" />
对于一个新的键值对 key4
和 value4
,如果 key4
的哈希值和 key1
的哈希值相同,那么它们将在哈希表的 0
号索引上发生碰撞。
通过将 key4-value4
和 key1-value1
两个键值对用链表连接起来,就可以解决碰撞的问题:
0 | 1 | 2 | 3 ", fillcolor = "#F2F2F2"]; pair_1 [label="dictEntry |{key1 | value1 |next}", fillcolor = "#FADCAD"]; pair_2 [label="dictEntry |{key2 | value2 |next}", fillcolor = "#FADCAD"]; pair_3 [label="dictEntry |{key3 | value3 |next}", fillcolor = "#FADCAD"]; pair_4 [label="dictEntry |{key4 | value4 |next}", fillcolor = "#FFC1C1"]; null0 [label="NULL", shape=plaintext]; null1 [label="NULL", shape=plaintext]; null2 [label="NULL", shape=plaintext]; null3 [label="NULL", shape=plaintext]; // lines bucket:table0 -> pair_4:head; pair_4:next -> pair_1:head; pair_1:next -> null0; bucket:table1 -> null1; bucket:table2 -> pair_2:head; pair_2:next -> null2; bucket:table3 -> pair_3:head; pair_3:next -> null3; // label label = "添加碰撞节点之后";}" />
添加新键值对时触发了 rehash 操作
对于使用链地址法来解决碰撞问题的哈希表 dictht
来说,哈希表的性能取决于大小(size
属性)与保存节点数量(used
属性)之间的比率:
- 哈希表的大小与节点数量,比率在 1:1 时,哈希表的性能最好;
- 如果节点数量比哈希表的大小要大很多的话,那么哈希表就会退化成多个链表,哈希表本身的性能优势便不复存在;
举个例子,下面这个哈希表,平均每次失败查找只需要访问 1 个节点(非空节点访问 2 次,空节点访问 1 次):