[redis] redis系列三:字典

redis中的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

数据结构

哈希表节点结构

哈希表节点结构定义如下:

typedef struct dictEntry {
	// 键
    void *key;
    // 值是一个union,即值可以是一个指针,或者一个unit64_t整数,或者int64_t整数
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

哈希表结构

哈希表结构定义如下:

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

如下图所示,两个键值对(k1, v1)和(k0, v0),经过哈希运算后都落在了table[2]里:
哈希表示例
通过dictEntry的next指针,将用key计算后索引值相同的(k1, v1)和(k0, v0)连接在一起。

字典结构

字典结构定义如下:

// 定义了一簇用于操作特定类型键值对的函数
typedef struct dictType {
	// 哈希函数,计算给定key的hash值
    uint64_t (*hashFunction)(const void *key);
    // 复制key的函数
    void *(*keyDup)(void *privdata, const void *key);
    // 复制value的函数
    void *(*valDup)(void *privdata, const void *obj);
    // 对比key1和key2的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    // 销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
    // 销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

typedef struct dict {
    dictType *type;
    // 作为参数传递给type中函数
    void *privdata;
    // 哈希表,ht[1]在进行rehash操作时会使用到
    dictht ht[2];
    // rehash的进度(当前rehash进行到了ht[0]数组的第几个元素),若当前没有进行rehash,则为-1
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

hash算法

假设我们要保存键值对(key, value)到dict中,需要进行如下步骤。

  1. 首先计算hash值:
    hash = dict->type->hashFunction(key);
    
  2. 接着用步骤1得到的hash值和dictht中的sizemask计算出索引值,根据情况不同,ht[x]可以是ht[0]或者ht[1]:
    index = hash & dict->ht[x].sizemask;
    
  3. 最后,用key和value构造dictEntry字典节点结构插入到ht[x]->table[index]链表的头部。

如下图所示,(k2, v2)和(k1, v1)发生冲突后(计算出的index一样),通过next指针将两者连接起来:
解决hash冲突
到目前为止一个完整的字典结构如下图所示:
一个完整的字典结构

rehash操作

在理解rehash操作之前我们先来理解下什么是负载因子,负载因子的计算方式如下:

负载因子 = 哈希表已保存的节点数量 / 哈希表的大小;
load_factor = ht[0].used / ht[0].size;

当对哈希表进行修改操作时,哈希表保存的键值对会逐渐增加或减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或太少时,程序需要对哈希表的大小进行相应的扩展或收缩,即对哈希表进行rehash操作。

Redis对字典的哈希表执行rehash操作需要经过如下步骤:

  1. 为字典的ht[1]哈希表分配空间,ht[1]的大小取决于要执行的操作以及ht[0]当前包含的键值对数量(即ht[0].used属性的值):如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used * 2的2的n次方,如果执行的收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次方;
  2. 将保存在ht[0]上的所有键值对rehash到ht[1]:rehash是指重新计算键的hash值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上;
  3. 当ht[0]表包含的所有键值对都迁移到ht[1]后(ht[0]为空),释放ht[0],将ht[1]设置为ht[0],并在ht[1]上新建一个空白哈希表,为下一次rehash做准备;

上面所说的都是如何对哈希表进行rehash操作,但何时会进行rehash操作呢? 当以下任一条件满足时redis都会执行扩展操作:

  1. 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且hash表的负载因子大于等于1;
  2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5;

其实也很好理解,当redis服务器正在执行BGSAVE或者BGREWRITEAOF命令时,会创建子进程,并且使用写时复制技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能的避免在子进程存在期间进行hash表扩展操作。

当hash表的负载因子小于0.1时,程序自动对hash表进行收缩操作。

渐进式hash

扩展或收缩hash表需要需要将ht[0]里的所有键值对rehash到ht[1]中,但是这个rehash动作并不是一次性、集中式的完成的,而是分多次、渐进式的完成的。

哈希表渐进式的rehash的步骤如下:

  1. 计算前面所述计算ht[1]的大小,并为ht[1]分配相应的空间,让字典同时拥有ht[0]和ht[1]两个哈希表;
  2. 在字典中维护一个索引计数器变量rehashidx,并将他的值设为0,表示rehash工作正式开始;
  3. 在rehash进行期间,每次对字典进行添加、删除、查找或更新操作时,程序除了执行指定的操作之外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]上,当rehash工作完成后,程序将rehashidx的值增1;
  4. 随着字典的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash到ht[1]上,这时程序将rehashidx属性的值设为-1,表示rehash操作已经完成;

渐进式rehash采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,避免了集中式rehash带来的庞大计算量从而阻塞对客户端的相应。

在渐进式rehash操作期间,对字典的删除、查找、更新等操作会在两个哈希表(ht[0]和ht[1])上进行。例如查找一个键的话会先在ht[0]中查找,然后在ht[1]中查找。新添加到字典中的键值对一律会被保存到ht[1]里,ht[0]则不再进行任何添加操作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值