Redis 字典

字典

字典(dictionary),又名映射(map)或关联数组(associative array), [http://en.wikipedia.org/wiki/Associative_array]是一种抽象数据结构,由一集键值对(key-value pairs)组成,各个键值对的键各不相同,程序可以添加新的键值对到字典中,或者基于键进行查找、更新或删除等操作。

本章先对字典在 Redis 中的应用进行介绍,接着讲解字典的具体实现方式,以及这个字典实现要解决的问题,最后,以对字典迭代器的介绍作为本章的结束。

字典的应用

字典在 Redis 中的应用广泛,使用频率可以说和 SDS 以及双端链表不相上下,基本上各个功能模块都有用到字典的地方。

其中,字典的主要用途有以下两个:

  1. 实现数据库键空间(key space);
  2. 用作 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 类型键使用以下两种数据结构作为底层实现:

  1. 字典;
  2. 压缩列表

因为压缩列表比字典更节省内存,所以程序在创建新 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 组成的哈希表例子:

digraph hash_table_example {    // setting    rankdir = LR;    node[shape = record, style = filled];    edge [style = bold];    // nodes    ht1 [label=dictht |

如果再加上之前列出的 dict 类型,那么整个字典结构可以表示如下:

digraph hash_table_example {    // setting    rankdir = LR;    node[shape=record, style = filled];    edge [style = bold];    // nodes    dict [label= ht[2] | rehashidx: -1 | iterators: 0", fillcolor = "#A8E270"]; ht0 [label="dictht |

在上图的字典示例中,字典虽然创建了两个哈希表,但正在使用的只有 0 号哈希表,这说明字典未进行 rehash 状态。

哈希算法

Redis 目前使用两种不同的哈希算法:

  1. MurmurHash2 32 bit 算法:这种算法的分布率和速度都非常好, 具体信息请参考 MurmurHash 的主页: http://code.google.com/p/smhasher/
  2. 基于 djb 算法实现的一个大小写无关散列算法:具体信息请参考 http://www.cse.yorku.ca/~oz/hash.html

使用哪种算法取决于具体应用所处理的数据:

  • 命令表以及 Lua 脚本缓存都用到了算法 2 。
  • 算法 1 的应用则更加广泛:数据库、集群、哈希键、阻塞操作等功能都用到了这个算法。

创建新字典

dictCreate 函数创建并返回一个新字典:

dict *d = dictCreate(&hash_type, NULL);

d 的值可以用图片表示如下:

digraph empty_dict {    // setting    rankdir = LR;    node[shape = record, style = filled];    edge [style = bold];    // nodes    dict [label= ht[2] | rehashidx | iterators", fillcolor = "#A8E270"]; ht0 [label="dictht |

新创建的两个哈希表都没有为 table 属性分配任何空间:

  • ht[0]->table 的空间分配将在第一次往字典添加键值对时进行;
  • ht[1]->table 的空间分配将在 rehash 开始时进行;

添加键值对到字典

根据字典所处的状态,将给定的键值对添加到字典可能会引起一系列复杂的操作:

  • 如果字典为未初始化(即字典的 0 号哈希表的 table 属性为空),则程序需要对 0 号哈希表进行初始化;
  • 如果在插入时发生了键碰撞,则程序需要处理碰撞;
  • 如果插入新元素,使得字典满足了 rehash 条件,则需要启动相应的 rehash 程序;

当程序处理完以上三种情况之后,新的键值对才会被真正地添加到字典上。

整个添加流程可以用下图表示:

digraph dictAdd {    node[shape=plaintext, style = filled];    edge [style = bold];    //    start [label= 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;}" />

在接下来的三节中,我们将分别看到,添加操作如何在以下三种情况中执行:

  1. 字典为空;
  2. 添加新键值对时发生碰撞处理;
  3. 添加新键值对时触发了 rehash 操作;

添加新元素到空白字典

当第一次往空字典里添加键值对时,程序会根据 dict.h/DICT_HT_INITIAL_SIZE 里指定的大小为d->ht[0]->table 分配空间(在目前的版本中, DICT_HT_INITIAL_SIZE 的值为 4 )。

以下是字典空白时的样子:

digraph empty_dict {    // setting    rankdir = LR;    node[shape = record, style = filled];    edge [style = bold];    // nodes    dict [label= ht[2] | rehashidx | iterators", fillcolor = "#A8E270"]; ht0 [label="dictht |

以下是往空白字典添加了第一个键值对之后的样子:

digraph add_first_entry_to_empty_dict {    // setting    rankdir = LR;    node[shape=record, style = filled];    edge [style = bold];    // nodes    dict [label= ht[2] | rehashidx | iterators", fillcolor = "#A8E270"]; ht0 [label="dictht |

添加新键值对时发生碰撞处理

在哈希表实现中,当两个不同的键拥有相同的哈希值时,称这两个键发生碰撞(collision),而哈希表实现必须想办法对碰撞进行处理。

字典哈希表所使用的碰撞解决方法被称之为链地址法 [http://en.wikipedia.org/wiki/Hash_table#Separate_chaining]:这种方法使用链表将多个哈希值相同的节点串连在一起,从而解决冲突问题

假设现在有一个带有三个节点的哈希表,如下图:

digraph before_key_collision {    // setting    rankdir = LR;    node[shape=record, style = filled];    edge [style = bold];    // nodes    bucket [label= 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 = "添加碰撞节点之前";}" />

对于一个新的键值对 key4value4 ,如果 key4 的哈希值和 key1 的哈希值相同,那么它们将在哈希表的 0 号索引上发生碰撞。

通过将 key4-value4key1-value1 两个键值对用链表连接起来,就可以解决碰撞的问题:

digraph after_key_collision {    // setting    rankdir = LR;    node[shape=record, style = filled];    edge [style = bold];    // nodes    bucket [label= 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 次):

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号

意见反馈
返回顶部