Redis 数据结构之Dictionary 字典
字典(dictionary), 又名映射(map)或关联数组(associative array), 是一种抽象数据结构, 由一集键值对(key-value pairs)组成, 各个键值对的键各不相同, 程序可以添加新的键值对到字典中, 或者基于键进行查找、更新或删除等操作。
字典的主要用途有以下两个:
- 实现数据库键空间(key space)
- 用作 Hash 类型键的底层实现之一
实现数据库键空间
Redis 是一个键值对数据库, 数据库中的键值对由字典保存: 每个数据库都有一个对应的字典, 这个字典被称之为键空间(key space)。
当用户添加一个键值对到数据库时(不论键值对是什么类型), 程序就将该键值对添加到键空间; 当用户从数据库中删除键值对时, 程序就会将这个键值对从键空间中删除; 等等。
举个例子,执行 FLUSHDB 可以清空键空间里的所有键值对数据:
redis> FLUSHDB
OK
执行 DBSIZE 则返回键空间里现有的键值对:
redis> DBSIZE
(integer) 0
还可以用 SET 设置一个字符串键到键空间, 并用 GET 从键空间中取出该字符串键的值:
redis> SET number 10086
OK
redis> GET number
"10086"
redis> DBSIZE
(integer) 1
用作 Hash 类型键的底层实现之一
Redis 的 Hash 类型键使用以下两种数据结构作为底层实现:
- 字典
- 压缩列表
因为压缩列表比字典更节省内存, 所以程序在创建新 Hash 键时, 默认使用压缩列表作为底层实现, 当有需要时, 程序才会将底层实现从压缩列表转换到字典。
当用户操作一个 Hash 键时, 键值在底层就可能是一个哈希表。
字典的实现
实现字典的方法有很多种:
- 链表或数组,时间复杂度高
- 哈希表,兼顾高效和简单
- 平衡树,复杂和稳定高效
在众多可能的实现中, Redis 选择了高效、实现简单的哈希表,作为字典的底层实现。
/*
* 字典
*
* 每个字典使用两个哈希表,用于实现渐进式 rehash
*/
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录 rehash 进度的标志,值为 -1 表示 rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} dict;
注意 dict 类型使用了两个指针,分别指向两个哈希表。
其中, 0 号哈希表(ht[0])是字典主要使用的哈希表, 而 1 号哈希表(ht[1])则只有在程序对 0 号哈希表进行 rehash 时才使用。
API:
哈希表实现
/*
* 哈希表
*/
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 使用链地址法来处理键碰撞: 当多个不同的键拥有相同的哈希值时,哈希表用一个链表将这些键连接起来。
哈希算法
Redis 目前使用两种不同的哈希算法:
-
MurmurHash2 32 bit 算法:这种算法的分布率和速度都非常好, 具体信息请参考 MurmurHash 的主页: http://code.google.com/p/smhasher/ 。
-
基于 djb 算法实现的一个大小写无关散列算法:具体信息请参考 http://www.cse.yorku.ca/~oz/hash.html 。
使用哪种算法取决于具体应用所处理的数据:
- 命令表以及 Lua 脚本缓存都用到了算法 2 。
- 算法 1 的应用则更加广泛:数据库、集群、哈希键、阻塞操作等功能都用到了这个算法。
rehash
对于使用链地址法来解决碰撞问题的哈希表 dictht 来说, 哈希表的性能取决于大小(size属性)与保存节点数量(used属性)之间的比率:
- 哈希表的大小与节点数量,比率在 1:1 时,哈希表的性能最好;
- 如果节点数量比哈希表的大小要大很多的话,那么哈希表就会退化成多个链表,哈希表本身的性能优势便不复存在;
为了在字典的键值对不断增多的情况下保持良好的性能, 字典需要对所使用的哈希表(ht[0])进行 rehash 操作: 在不修改任何键值对的情况下,对哈希表进行扩容, 尽量将比率维持在 1:1 左右。
dictAdd 在每次向字典添加新键值对之前, 都会对哈希表 ht[0] 进行检查, 对于 ht[0] 的 size 和 used 属性, 如果它们之间的比率 ratio = used / size 满足以下任何一个条件的话,rehash 过程就会被**:
- 自然 rehash : ratio >= 1 ,且变量 dict_can_resize 为真。
- 强制 rehash : ratio 大于变量 dict_force_resize_ratio (目前版本中, dict_force_resize_ratio 的值为 5 )。
什么时候 dict_can_resize 会为假?
在前面介绍字典的应用时也说到过, 数据库就是字典, 数据库里的哈希类型键也是字典, 当 Redis 使用子进程对数据库执行后台持久化任务时(比如执行 BGSAVE 或 BGREWRITEAOF 时), 为了最大化地利用系统的 copy on write 机制, 程序会暂时将 dict_can_resize 设为假, 避免执行自然 rehash , 从而减少程序对内存的触碰(touch)。
当持久化任务完成之后, dict_can_resize 会重新被设为真。
另一方面, 当字典满足了强制 rehash 的条件时, 即使 dict_can_resize 不为真(有 BGSAVE 或 BGREWRITEAOF 正在执行), 这个字典一样会被 rehash 。
Rehash 执行过程
字典的 rehash 操作实际上就是执行以下任务:
- 创建一个比 ht[0]->table 更大的 ht[1]->table,大小至少为 ht[0]->used 的两倍;
- 将 ht[0]->table 中的所有键值对迁移到 ht[1]->table,ht[0]->table 的节点会被逐渐迁移到 ht[1]->table , 因为 rehash 是分多次进行的(细节在下一节解释), 字典的 rehashidx 变量会记录 rehash 进行到 ht[0] 的哪个索引位置上。 ;
- 将原有 ht[0] 的数据清空,并将 ht[1] 替换为新的 ht[0] ,创建一个新的空哈希表,并将它设置为 ht[1] ,将字典的 rehashidx 属性设置为 -1 ,标识 rehash 已停止;
渐进式 rehash
渐进式 rehash 主要由 _dictRehashStep 和 dictRehashMilliseconds 两个函数进行:
- _dictRehashStep 用于对数据库字典、以及哈希键的字典进行被动 rehash ;
- dictRehashMilliseconds 则由 Redis 服务器常规任务程序(server cron job)执行,用于对数据库字典进行主动 rehash ;
_dictRehashStep:
每次执行 _dictRehashStep , ht[0]->table 哈希表第一个不为空的索引上的所有节点就会全部迁移到 ht[1]->table 。
在 rehash 开始进行之后(d->rehashidx 不为 -1), 每次执行一次添加、查找、删除操作, _dictRehashStep 都会被执行一次:
因为字典会保持哈希表大小和节点数的比率在一个很小的范围内, 所以每个索引上的节点数量不会很多(从目前版本的 rehash 条件来看,平均只有一个,最多通常也不会超过五个), 所以在执行操作的同时,对单个索引上的节点进行迁移, 几乎不会对响应时间造成影响。
dictRehashMilliseconds:
dictRehashMilliseconds 可以在指定的毫秒数内, 对字典进行 rehash 。
当 Redis 的服务器常规任务执行时, dictRehashMilliseconds 会被执行, 在规定的时间内, 尽可能地对数据库字典中那些需要 rehash 的字典进行 rehash , 从而加速数据库字典的 rehash 进程(progress)。
其他措施:
在哈希表进行 rehash 时, 字典还会采取一些特别的措施, 确保 rehash 顺利、正确地进行:
因为在 rehash 时,字典会同时使用两个哈希表,所以在这期间的所有查找、删除等操作,除了在 ht[0] 上进行,还需要在 ht[1] 上进行。
在执行添加操作时,新的节点会直接添加到 ht[1] 而不是 ht[0] ,这样保证 ht[0] 的节点数量在整个 rehash 过程中都只减不增。
字典的收缩
上面关于 rehash 的章节描述了通过 rehash 对字典进行扩展(expand)的情况, 如果哈希表的可用节点数比已用节点数大很多的话, 那么也可以通过对哈希表进行 rehash 来收缩(shrink)字典。
收缩 rehash 和上面展示的扩展 rehash 的操作几乎一样,执行以下步骤:
- 创建一个比 ht[0]->table 小的 ht[1]->table ;
- 将 ht[0]->table 中的所有键值对迁移到 ht[1]->table ;
- 将原有 ht[0] 的数据清空,并将 ht[1] 替换为新的 ht[0] ;
字典的收缩规则由 redis.c/htNeedsResize 函数定义:
/*
* 检查字典的使用率是否低于系统允许的最小比率
*
* 是的话返回 1 ,否则返回 0 。
*/
int htNeedsResize(dict *dict) {
long long size, used;
// 哈希表大小
size = dictSlots(dict);
// 哈希表已用节点数量
used = dictSize(dict);
// 当哈希表的大小大于 DICT_HT_INITIAL_SIZE
// 并且字典的填充率低于 REDIS_HT_MINFILL 时
// 返回 1
return (size && used && size > DICT_HT_INITIAL_SIZE &&
(used*100/size < REDIS_HT_MINFILL));
}
在默认情况下, REDIS_HT_MINFILL 的值为 10 , 也即是说, 当字典的填充率低于 10% 时, 程序就可以对这个字典进行收缩操作了。
字典收缩和字典扩展的一个区别是:字典的扩展操作是自动触发的(不管是自动扩展还是强制扩展);而字典的收缩操作则是由程序手动执行。
因此, 使用字典的程序可以决定何时对字典进行收缩:
- 当字典用于实现哈希键的时候, 每次从字典中删除一个键值对, 程序就会执行一次 htNeedsResize 函数, 如果字典达到了收缩的标准, 程序将立即对字典进行收缩;
- 当字典用于实现数据库键空间(key space)的时候, 收缩的时机由 redis.c/tryResizeHashTables 函数决定
字典的迭代
字典带有自己的迭代器实现 —— 对字典进行迭代实际上就是对字典所使用的哈希表进行迭代:
- 迭代器首先迭代字典的第一个哈希表,然后,如果 rehash 正在进行的话,就继续对第二个哈希表进行迭代。
- 当迭代哈希表时,找到第一个不为空的索引,然后迭代这个索引上的所有节点。
- 当这个索引迭代完了,继续查找下一个不为空的索引,如此反覆,直到整个哈希表都迭代完为止。
字典的迭代器有两种:
- 安全迭代器:在迭代进行过程中,可以对字典进行修改。
- 不安全迭代器:在迭代进行过程中,不对字典进行修改。
以下是迭代器的数据结构定义:
/*
* 字典迭代器
*/
typedef struct dictIterator {
dict *d; // 正在迭代的字典
int table, // 正在迭代的哈希表的号码(0 或者 1)
index, // 正在迭代的哈希表数组的索引
safe; // 是否安全?
dictEntry *entry, // 当前哈希节点
*nextEntry; // 当前哈希节点的后继节点
} dictIterator;
API: