字典,是一种用于保存键值对的抽象数据结构。因为 C 语言没有内置字典这种数据结构,所以 Redis 构建了本身的字典实现。数据库
在 Redis 中,就是使用字典来实现数据库底层的。对数据库的 CURD 操做也是构建在对字典的操做之上。数组
除了用来表示数据库以外,字典仍是哈希键的底层实现之一。当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis 就会适应字典做为哈希键的底层实现。服务器
Redis 的字典使用哈希表做为底层实现。一个哈希表里面能够有多个哈希表节点,而每一个哈希表节点就保存了字典中的一个键值对。数据结构
Redis 字典所使用的哈希表结构:函数
typedef struct dictht { dictEntry **table; // 哈希表数组 unsigned long size; // 哈希表大小 unsigned long sizemask; // 哈希表大小掩码,用来计算索引 unsigned long used; // 哈希表现有节点的数量 } dictht;
图 1 展现了一个大小为 4 的空哈希表。性能
哈希表节点使用 dictEntry 结构表示,每一个 dictEntry 结构中都保存着一个键值对:ui
typedef struct dictEntry { void *key; // 键 union { void *val; // 值类型之指针 uint64_t u64; // 值类型之无符号整型 int64_t s64; // 值类型之有符号整型 double d; // 值类型之浮点型 } v; // 值 struct dictEntry *next; // 指向下个哈希表节点,造成链表 } dictEntry;
图 2 展现了经过 next 指针,将两个索引相同的键 k1 和 k0 链接在一块儿的状况。3d
字典的结构:指针
typedef struct dict { dictType *type; // 类型特定函数 void *privdata; // 私有数据 dictht ht[2]; // 哈希表(两个) long rehashidx; // 记录 rehash 进度的标志。值为 -1 表示 rehash 未进行 int iterators; // 当前正在迭代的迭代器数 } dict;
dictType 的结构以下:
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;
type 属性和 privdata 属性是针对不一样类型的键值对,为建立多态字典而设置的。其中:
而 ht 属性是一个包含两个哈希表的数组。通常状况下,字典只使用 ht[0],只有在对 ht[0] 进行 rehash 时才会使用 ht[1]。
rehashidx 属性,它记录了 rehash 目前的进度,若是当前没有进行 rehash,它的值为 -1。至于什么是 rehash,别急,后面会详细说明。
图 3 是没有进行 rehash 的字典:
当在字典中添加一个新的键值对时,Redis 会先根据键值对的键计算出哈希值和索引值,而后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组指定的索引上。具体算法以下:
# 使用字典设置的哈希函数,计算 key 的哈希值 hash = dict->type->hashFunction(key); # 使用哈希表的 sizemask 属性和哈希值,计算出索引值 # 根据不一样状况,使用 ht[0] 或 ht[1] index = hash & dict[x].sizemask;
如图 4,若是把键值对 [k0, v0] 添加到字典中,插入顺序以下:
hash = dict-type->hashFunction(k0); index = hash & dict->ht[0].sizemask; # 8 & 3 = 0
计算得出,[k0, v0] 键值对应该被放在哈希表数组索引为 0 的位置上,如图 5:
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,咱们认为这些键发生了建冲突。
Redis 的哈希表使用链地址法来解决建冲突。每一个哈希表节点都有一个 next 指针,多个哈希表节点能够用 next 指针构成一个单向链表,被分配到同一个索引的多个节点用 next 指针连接成一个单向链表。
举个栗子,假设咱们要把 [k2, v2] 键值对添加到图 6 所示的哈希表中,而且计算得出 k2 的索引值为 2,和 k1 冲突,所以,这里就用 next 指针将 k2 和 k1 所在的节点链接起来,如图 7。
随着对字典的操做,哈希表报错的键值对会逐渐增多或者减小,为了让哈希表的负载因子维持在一个合理的范围以内,当哈希表报错的键值对数量太多或者太少时,程序须要对哈希表进行相应的扩容或收缩。这个扩容或收缩的过程,咱们称之为 rehash。
对于负载因子,能够经过如下公式计算得出:
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小 load_factor = ht[0].used / ht[0].size;
扩容
对于哈希表的扩容,源码以下:
if (d->ht[0].used >= d->ht[0].size && (dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)) { return dictExpand(d, d->ht[0].used*2); }
当如下条件被知足时,程序会自动开始对哈希表执行扩展操做:
收缩
哈希表的收缩,源码以下:
int htNeedsResize(dict *dict) { long long size, used; size = dictSlots(dict); // ht[2] 两个哈希表的大小之和 used = dictSize(dict); // ht[2] 两个哈希表已保存节点数量之和 # DICT_HT_INITIAL_SIZE 默认为 4,HASHTABLE_MIN_FILL 默认为 10。 return (size > DICT_HT_INITIAL_SIZE && (used*100/size < HASHTABLE_MIN_FILL)); } void tryResizeHashTables(int dbid) { if (htNeedsResize(server.db[dbid].dict)) dictResize(server.db[dbid].dict); if (htNeedsResize(server.db[dbid].expires)) dictResize(server.db[dbid].expires); }
当 ht[] 哈希表的大小之和大于 DICT_HT_INITIAL_SIZE(默认 4),且已保存节点数量与总大小之比小于 4,HASHTABLE_MIN_FILL(默认 10,也就是 10%),会对哈希表进行收缩操做。
扩容和收缩哈希表都是经过执行 rehash 操做来完成,哈希表执行 rehash 的步骤以下:
示例:
假设程序要对图 8 所示字典的 ht[0] 进行扩展操做,那么程序将执行如下步骤:
1)ht[0].used 当前的值为 4,那么 4*2 = 8,而 2^3 刚好是第一个大于等于 8 的,2 的 n 次方。因此程序会将 ht[1] 哈希表的大小设置为 8。图 9 是 ht[1] 在分配空间以后的字典。
2)将 ht[0] 包含的四个键值对都 rehash 到 ht[1],如图 10。
3)释放 ht[0],并将 ht[1] 设置为 ht[0],而后为 ht[1] 分配一个空白哈希表。如图 11:
至此,对哈希表的扩容操做执行完毕,程序成功将哈希表的大小从原来的 4 改成了 8。
对于 Redis 的 rehash 而言,并非一次性、集中式的完成,而是分屡次、渐进式地完成,因此也叫渐进式 rehash。
之因此采用渐进式的方式,其实也很好理解。当哈希表里保存了大量的键值对,要一次性的将全部键值对所有 rehash 到 ht[1] 里,极可能会致使服务器在一段时间内只能进行 rehash,不能对外提供服务。
所以,为了不 rehash 对服务器性能形成影响,Redis 分屡次、渐进式的将 ht[0] 里面的键值对 rehash 到 ht[1]。
渐进式 rehash 就用到了索引计数器变量 rehashidx,详细步骤以下:
渐进式 rehash 才有分而治之的方式,将 rehash 键值对所须要的计算工做均摊到对字典的 CURD 操做上,从而避免了集中式 rehash 带来的问题。
此外,字典在进行 rehash 时,删除、查找、更新等操做会在两个哈希表上进行。例如,在字典张查找一个键,程序会如今 ht[0] 里面进行查找,若是没找到,再去 ht[1] 上查找。
要注意的是,新增的键值对一概只保存在 ht[1] 里,不在对 ht[0] 进行任何添加操做,保证了 ht[0] 包含的键值对数量只减不增,随着 rehash 操做最终变成空表。
图 12 至 图 17 展现了一次完整的渐进式 rehash 过程:
1)未进行 rehash 的字典
2) rehash 索引 0 上的键值对
3)rehash 索引 1 上的键值对
4)rehash 索引 2 上的键值对
5)rehash 索引 3 上的键值对
6)rehash 执行完毕