字典实现

字典在 Redis 中的应用相当广泛,如 Redis 的数据库、Hash 类型等的底层实现都用到了字典。
Redis 的字典使用了哈希表,其中可以包含多个哈希表节点,每个节点就保存了字典中的一个键值对。这两者的结构定义分别如下:

typedef struct dictht{
dictEntry **table; // 哈希表节点数组
unsigned long size; // 哈希表节点数组大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,总是等于 size-1
unsigned long used; // 该哈希表已有节点的数量
}dictht;

typedef struct dictEntry{
void *key; // 键
union{ // 值
void *val;
uint64_t u64;
int64_t s64;
}v;
struct dictEntry *next; // 指向下一个哈希表节点,形成链表
}dictEntry;

从 dictEntry 结构的定义可以看出,键值对中的值可以是一个指针,或者是一个 uint64_t 类型的整数,又或者是一个 int64_t 类型的整数。另外,其中的 next 属性是指向另一个具有相同哈希值的节点的指针,用以避免键冲突。
哈希表的定义给出后,接下来再看看 Redis 中的字典的结构表示。

typedef struct dict{
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 哈希表
int rehashidx; // 渐进式 rehash 索引,当没进行 rehash 时,值为 -1
}dict;

typedef struct dictType{
unsigned int (*hashFunction)(const void *key); // 计算哈希值的函数
void *(*keyDup)(void *privdata, const void *key); // 复制键的函数
void *(*valDup)(void *privdata, const void *obj); // 复制值的函数
void (*keyDestructor)(void *privdata, void *key); // 销毁键的函数
void (*valDestructor)(void *privdata, void *obj); // 销毁值的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2); // 对比值的函数
}dictType;

dict 结构中的 type 属性是一个指向 dictType 结构的指针,该结构保存了一组用于操作特定类型键值对的函数,以便 Redis 为不同用途的字典设置不同的类型特定函数。privdata 属性中则保存了需要传给那些类型特定函数的可选参数。这两个属性主要是为创建多态字典,针对不同类型的键值对而设置的。ht 属性是一个包含两个 dictht 哈希表的数组。一般情况下只使用 ht[0] 哈希表,ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash(即重新对 ht[0] 哈希表中的节点进行哈希值计算) 时使用。属性 rehashidx 也与 rehash 有关,它记录了 rehash 目前的进度。如果目前没有进行 rehash,它的值就为 -1。
下图是一个没有进行 rehash 时的普通状态下的字典的示例。
[img]http://dl2.iteye.com/upload/attachment/0130/4901/119cb498-3cc2-3136-aedd-81e51c3c65fe.png[/img]
当要添加一个新的键值对到字典中时,Redis 会先根据键值对的键计算出哈希值和索引值,然后将该包含该键值对的哈希表节点放到哈希数组的指定索引上面。计算方式大致如下:
hash = dict->type->hashFunction(key); # 使用字典设置的哈希函数计算哈希值
index = hash & dict->ht[x].sizemask; # 根据情况,ht[x] 可以是 ht[0] 或 ht[1]
在键发生冲突时,Redis 使用了链地址法来处理,即利用哈希表节点结构 dictEntry 中 的 next 指针来指向具有相同索引的节点。不过为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂的为 O(1)),排在其他已有节点的前面。
接下来再来说说字典的 rehash。
由于哈希表中保存的键值对会随着操作的不断执行而逐渐地增多或减少,所以为了让哈希表的负载因子(load factor)维持在一个合理的范围内,Redis 需要在必要的时刻对哈希表的大小进行相应的扩展或收缩。这可以通过执行 rehash 操作来完成。大致步骤如下:
1、根据要执行的操作以及 ht[0] 当前已包含的键值对的数量为字典的 ht[1] 哈希表分配空间:
a、如果执行的是扩展操作,则 ht[1] 的大小将为第一个大于等于 ht[0].used * 2 并且是 2 的 n 次幂的数。
b、如果执行的是收缩操作,则 ht[1] 的大小将是第一个大于等于 ht[0].used 并且是 2 的 n 次幂的数。
2、将 ht[0] 中的所有键值对 rehash 到 ht[1] 上面。
3、迁移完 ht[0] 中的键值对后,释放 ht[0],再将 ht[1] 设置为 ht[0],并新建一个空白哈希表作为 ht[1],以便为下一次 rehash 做准备。
那么 Redis 如何知道在什么时候对哈希表进行扩展或收缩呢?
总的来说,当程序满足以下任意一个条件时,Redis 将会自动开始进行扩展操作:
1、服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1。
2、服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5。
其中,哈希表的负载因子通过下面公式计算:
load_factor = ht[0].used / ht[0].size; # 即:哈希表已有节点数 / 哈希表大小
另一方面,当哈希表的负载因子小于 0.1 时,程序会自动开始对哈希表执行收缩操作。
另外需要指出的是,在对哈希表进行扩展或收缩时,将 ht[0] 中的键值对 rehash 到 ht[1] 中的这个动作并非是一次性、集中式地完成的,而是分多次、渐进式地完成的。这样做的原因是为了避免在 ht[0] 中的键值对较多时,如果一次性将这些键值对全部迁移到 ht[1] 的话,可能会导致服务器在一段时间内停止对外服务。
渐进式 rehash 的详细步骤如下:
1、为 ht[1] 分配空间。
2、将 dict 结构中的索引计数器字段 rehashidx 的值设置为 0,表示 rehash 开始。
3、在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,除了执行指定的操作,程序还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],然后将 rehashidx 的值增 1。在这个过程中,字典的删除、查找和更新操作会依次在 ht[0] 和 ht[1] 中查找对应的键,而新添加到字典的键值对会一律保存到 ht[1] 中,以保证 ht[0] 中包含的键值对数量只减不增,能随着 rehash 操作的进行而最终变成空表。
4、随着字典操作的不断执行,ht[0] 的所有键值对会在某个时刻被 rehash 到 ht[1] 上,这时再将 rehashidx 属性的值设为 -1,表示 rehash 操作已完成。

参考书籍:《Redis 设计与实现》第四章——字典。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值