一 字典的简介以及在Redis中的应用
字典(dictionary), 又名映射(map)或关联数组(associative array), 是一种抽象数据结构, 由一集键值对(key-value pairs)组成, 各个键值对的键各不相同, 程序可以添加新的键值对到字典中, 或者基于键进行查找、更新或删除等操作。字典在Redis应用很广泛,和SDS、双向链表一样使用频率很高。
其中, 字典的主要用途有以下两个:
1)实现数据库键空间(key space);
2)用作 Hash 类型键的底层实现之一;
以下两个小节分别介绍这两种用途。
二 实现数据库键空间
Redis 是一个键值对数据库, 数据库中的键值对由字典保存: 每个数据库都有一个对应的字典, 这个字典被称之为键空间(key space)。
当用户添加一个键值对到数据库时(不论键值对是什么类型), 程序就将该键值对添加到键空间; 当用户从数据库中删除键值对时, 程序就会将这个键值对从键空间中删除; 等等。
三 用作 Hash 类型键的底层实现之一
Redis 的 Hash 类型键使用以下两种数据结构作为底层实现:
- 字典;
- 压缩列表;
因为压缩列表比字典更节省内存, 所以程序在创建新 Hash 键时, 默认使用压缩列表作为底层实现, 当有需要时, 程序才会将底层实现从压缩列表转换到字典。
当用户操作一个 Hash 键时, 键值在底层就可能是一个哈希表:
Redis的底层使用哈希表来实现的。其一系列操作复杂度如下:
操作 | 函数 | 算法复杂度 |
---|---|---|
创建一个新字典 | 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) |
其中, 0 号哈希表(ht[0]
)是字典主要使用的哈希表, 而 1 号哈希表(ht[1]
)则只有在程序对 0 号哈希表进行 rehash 时才使用。
哈希I定义参考源码:dict.h
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
/*
* 字典类型特定函数,本质都是回调函数
*/
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;
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
/*
* 哈希表
*
* 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
/*
* 字典
*/
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表,用两个哈希表实现渐进式rehash
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
next
属性指向另一个 dictEntry
结构, 多个 dictEntry
可以通过 next
指针串连成链表, 从这里可以看出, dictht
使用链地址法来处理键碰撞: 当多个不同的键拥有相同的哈希值时,哈希表用一个链表将这些键连接起来。
下图展示了一个由 dictht
和数个 dictEntry
组成的哈希表例子:
如果再加上之前列出的 dict
类型,那么整个字典结构可以表示如下:
在上图的字典示例中, 字典虽然创建了两个哈希表, 但正在使用的只有 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 的应用则更加广泛:数据库、集群、哈希键、阻塞操作等功能都用到了这个算法
六 添加键值对到字典
根据字典所处的状态, 将给定的键值对添加到字典可能会引起一系列复杂的操作:
- 如果字典为未初始化(即字典的 0 号哈希表的
table
属性为空),则程序需要对 0 号哈希表进行初始化; - 如果在插入时发生了键碰撞,则程序需要处理碰撞;
- 如果插入新元素,使得字典满足了 rehash 条件,则需要启动相应的 rehash 程序;
当程序处理完以上三种情况之后,新的键值对才会被真正地添加到字典上。
整个添加流程可以用下图表示:
在接下来的三节中, 我们将分别看到,添加操作如何在以下三种情况中执行:
- 字典为空;
- 添加新键值对时发生碰撞处理;
- 添加新键值对时触发了 rehash 操作
七 添加新元素到空白字典
当第一次往空字典里添加键值对时, 程序会根据 dict.h/DICT_HT_INITIAL_SIZE
里指定的大小为 d->ht[0]->table
分配空间 (在目前的版本中, DICT_HT_INITIAL_SIZE
的值为 4
)。
以下是字典空白时的样子:
以下是往空白字典添加了第一个键值对之后的样子:
八 添加新键值对时发生碰撞处理
在哈希表实现中, 当两个不同的键拥有相同的哈希值时, 称这两个键发生碰撞(collision), 而哈希表实现必须想办法对碰撞进行处理。
字典哈希表所使用的碰撞解决方法被称之为链地址法: 这种方法使用链表将多个哈希值相同的节点串连在一起, 从而解决冲突问题。
假设现在有一个带有三个节点的哈希表,如下图:
对于一个新的键值对 key4
和 value4
, 如果 key4
的哈希值和 key1
的哈希值相同, 那么它们将在哈希表的 0
号索引上发生碰撞。
通过将 key4-value4
和 key1-value1
两个键值对用链表连接起来, 就可以解决碰撞的问题:
九 添加新键值对时触发了 rehash 操作
对于使用链地址法来解决碰撞问题的哈希表 dictht
来说, 哈希表的性能取决于大小(size
属性)与保存节点数量(used
属性)之间的比率:
- 哈希表的大小与节点数量,比率在 1:1 时,哈希表的性能最好;
- 如果节点数量比哈希表的大小要大很多的话,那么哈希表就会退化成多个链表,哈希表本身的性能优势便不复存在;
举个例子, 下面这个哈希表, 平均每次失败查找只需要访问 1 个节点(非空节点访问 2 次,空节点访问 1 次):
而下面这个哈希表, 平均每次失败查找需要访问 5 个节点:
为了在字典的键值对不断增多的情况下保持良好的性能, 字典需要对所使用的哈希表(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]->table
中的所有键值对迁移到ht[1]->table
; - 将原有
ht[0]
的数据清空,并将ht[1]
替换为新的ht[0]
;
经过以上步骤之后, 程序就在不改变原有键值对数据的基础上, 增大了哈希表的大小。
作为例子, 以下四个小节展示了一次对哈希表进行 rehash 的完整过程。
1. 开始 rehash
这个阶段有两个事情要做:
- 设置字典的
rehashidx
为0
,标识着 rehash 的开始; - 为
ht[1]->table
分配空间,大小至少为ht[0]->used
的两倍;
这时的字典是这个样子:
2. Rehash 进行中
在这个阶段, ht[0]->table
的节点会被逐渐迁移到 ht[1]->table
, 因为 rehash 是分多次进行的(细节在下一节解释), 字典的 rehashidx
变量会记录 rehash 进行到 ht[0]
的哪个索引位置上。
以下是 rehashidx
值为 2
时,字典的样子:
注意除了节点的移动外, 字典的 rehashidx
、 ht[0]->used
和 ht[1]->used
三个属性也产生了变化。
3. 节点迁移完毕
到了这个阶段,所有的节点都已经从 ht[0]
迁移到 ht[1]
了:
4. Rehash 完毕
在 rehash 的最后阶段,程序会执行以下工作:
- 释放
ht[0]
的空间; - 用
ht[1]
来代替ht[0]
,使原来的ht[1]
成为新的ht[0]
; - 创建一个新的空哈希表,并将它设置为
ht[1]
; - 将字典的
rehashidx
属性设置为-1
,标识 rehash 已停止;
以下是字典 rehash 完毕之后的样子:
对比字典 rehash 前后, 新的 ht[0]
空间更大, 并且字典原有的键值对也没有被修改或者删除。
十一 Rehash 执行过程
在上一节,我们了解了字典的 rehash 过程, 需要特别指出的是, rehash 程序并不是在激活之后,就马上执行直到完成的, 而是分多次、渐进式地完成的。
假设这样一个场景:在一个有很多键值对的字典里, 某个用户在添加新键值对时触发了 rehash 过程, 如果这个 rehash 过程必须将所有键值对迁移完毕之后才将结果返回给用户, 这样的处理方式将是非常不友好的。
另一方面, 要求服务器必须阻塞直到 rehash 完成, 这对于 Redis 服务器本身也是不能接受的。
为了解决这个问题, Redis 使用了渐进式(incremental)的 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)。