Redis(二):字典实现

字典

字典:一种用于保存键值对的抽象数据结构

Redis所使用的C语言没有字典结构,所以Redis构建了自己的字典实现

set msg2 haha

在数据库中创建了两个SDS对象(msg2与haha),这两个对象分别为键和值,共同组成一个键值对,这个键值对就是保存在Redis自己构建的字典中的。

字典除了用来表示数据库时(像是一个数据库一样存储数据,数据即键值对),字典还是哈希键的底层实现之一,当一个哈希键(这里的哈希键其实指的是数据库的5大基本数据类型里面的哈希)包含的键值对比较多时,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

字典的实现

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

哈希表

Redis字典所使用的哈希表由dict.h/dicht结构定义(跟SDS一样也是一个结构体)

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

下面简单介绍以下这几个对象

  • 哈希表的底层数组table,存放的是哈希表结点,即键值对(下面会说)
  • size属性记录了哈希表的底层数组大小,即table数组的大小
  • used属性记录了哈希表目前已经有的结点数量
  • sizemask属性的值总是等于size-1,这个属性和哈希值一起决定插入进来的键值对会被放入数组里面的哪个位置。
哈希表结点

哈希表结点使用dictEntry结构表示(也是一个结构体),每个dictEntry结构都保存着一个键值对

typedef dictEntry(
    //键
	void *key;
    //值
    union{
        //指针类型
        void *val;
        //无符号整数类型 64位
        unit64_t u64;
        //有符号整数类型 64位
        int64_t s64;
    } v;
    //指向下一个哈希表的结点,形成链表
    struct dictEntry *next;
)dictEntry

key属性就是键值对里面的键值,而v属性,也是一个联合体,里面保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个无符号64位整数和有符号64位整数。

next属性是一个指针,指向下一个哈希表结点,这让就形成了一个链表,这个叫做链地址法,用来解决哈希冲突问题。

模型大概像下面这样
在这里插入图片描述

字典

现在看看字典的实现(哈希表只是底层实现,还有其他属性)

Redis中的字典由dict.h/dict结构表示

typedef struct dict(
	//类型特定函数
    dictType *type;
    
    //私有数据
    void *privdata;
    
    //哈希表数组
    dictht ht[2]
    
    //rehash索引
    //当rehash不存在时,值为-1
	int rehashidx;
)
  • type和pridata是针对不同类型的键值对,为创建多态字典设置的。

    • type属性是一个指向dictType结构体的指针,dictType结构保存了一簇用于操作特定类型键值对的函数(也就是不同类型的键值对会对应不同的操作函数),Redis会为用途不同的字典设置不同的类型特定函数。
    • pridata属性则保存了需要传给那些类型特定函数的可选参数(也就是type里面的那些操作函数所需要对应类型的参数)
      • dictType如下所示
    typedef struct dictType(
    	//计算哈希值的函数
        unsigned int(*hashFunction)...
        
        //复制键的函数
        void *(*keyDup)...;
        
        //复制值的函数
        int (*valDup)....;
        
        //对比键的函数
        int (*keyCompare) ...;
        
        //销毁键的函数
        void (*keyDestruction)...
        
        //销毁值的函数
        void (*valDestructor)...
    )
    

    ​ 里面总共有5个方法,计算哈希值、复制键、复制值、对比键(没有对比值)、销毁键、销毁值

  • ht是一个哈希表数组,ht[2]代表里面存储了两个哈希表,在一般情况下,只会使用ht[0]里面的哈希表,而ht[1]里面的哈希表只会在ht[0]哈希表进行rehash操作时才会使用。

  • 除了ht[1]跟rehash相关外,还有一个rehashidx属性,它记录了rehash目前的进度,如果目前没有进行rehash的话,它的值为-1。

哈希算法

当要将一个新的键值对添加到字典中时,程序需要先根据键值计算出哈希值,然后根据哈希值计算出索引值,然后再根据索引值,将包含键值对的哈希表结点放到哈希表数组的指定索引上面

计算哈希值,其实就是调用字典里面的type指向的dictType结构体,里面有个方法就是计算哈希值的。

过程如下

#使用字典设置的哈希函数,计算键key的哈希值
hash = dict -> type -> hashFunction(key);

#使用哈希表的sizemask属性和哈希值来计算出索引值
#根据情况的不同,哈希表可以选择ht[0]或者ht[1]
index = hash & dict->ht[x].sizemask;

sizemask是记录了哈希表底层数组的最大索引,使用与运算,那么就可以避免不超过这个最大索引值(顶多相等),所以Index是肯定不会超过底层数组的最大索引值的。

关于哈希值计算方法,Redis采用的是MurmurHash2算法来计算键的哈希值

这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且这种算法的速度也是挺快的。

解决键的冲突

键的冲突是指,当有两个以上的键,通过哈希算法,然后计算出索引值相等话,叶菊是这两个键值对放在了同一个底层数组索引上。

Redis解决键冲突的方法是采用链地址法,也就是使用单向链表来解决键的冲突,比如插入A键值对时,放在了a索引上,然后插入B键值对时,计算出的索引值也是a,那么B键值对就会拼在A前面(Redis使用的是头插法,因为Redis中的链表没有尾指针,所以为了速度考虑,使用了头插法),这种插入的时间复杂度为 O ( 1 ) O(1) O(1)

Rehash

随着操作的不断执行,哈希表保存道德键值对会逐渐增多或者减少,为了让哈希表的负载因子维持在一个合理范围内,(Redis的负载因子与JAVA的负载因子不太相同,负载因子是哈希表结点数量/哈希表底层数组长度。也就是used/size),当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩(维持payload)

扩展和收缩哈希表的操作就需要及逆行Rehash(重新散列)操作来完成,Redis对字典的哈希表执行Rehash的步骤如下:

  1. 为字典的ht[1]哈希表分配空间时,空间的大小不仅取决于是扩展还是缩减,同时也受ht[0]哈希表当前包含的键值对,也就是used属性影响。
    • 如果执行的是扩展操作,那么ht[1]的大小就是为第一个等于大于ht[0].used*2的 2 n 2^n 2n。比如,ht[0].used=3,那么它的两倍就是6,需要从 2 n 2^n 2n中找到第一个大于等于6的数,这里的话,就是 2 3 = 8 2^3=8 23=8,索引ht[1]的大小就是8
    • 如果执行的是缩减操作,那么ht[1]的大小就是为第一个等于大于ht[0].used的 2 n 2^n 2n,还是那个栗子,ht[0].used=3,那么就需要从中找到第一个大于等于3的数,就是 2 2 = 4 2^2=4 22=4,那么索引ht[1]的大小就是4。
    • 注意的是,这里used是指哈希表中已经拥有的键值对数量,而不是底层数组的大小,而这里分配的是ht[1]哈希表的底层数组大小,肯定是要比键值对数量大的。
  2. 此时分配完ht[1]的空间后,需要将保存在ht[0]的所有键值对进行一次rehash,也就是对键重新计算哈希值,然后再计算索引值(由于这里ht[1]的长度已经改变,所以哈希值也是会改变的,生成的索引值会跟ht[0]不一样),然后再通过索引值存储键值对
  3. 当ht[0]里所有的键值对都迁移到ht[1]中之后,ht[0]会变成空表,并且被释放掉
  4. 此时将ht[1]设置为ht[0],ht[1]分配一个空的哈希表,方便下一次进行Rehash。
触发哈希表的扩展和收缩条件

当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  1. 当服务器目前没有执行BGSAVE命令或者BGREWRITEAOF命令时,并且哈希表的负载因子大于等于1
  2. 当服务器目前正在执行BGSAVE命令或则BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
    • 负载因子的计算方式为:负载因子 = 哈希表已保存结点数量/哈希表大小(used/size)

BGSAVE和BGREWRITEAOF都是持久化的操作,BG开头代表是另开一个进程去做,不影响当前进程。

这时因为在执行BGSAVE和BGREWRITEAOF命令时,Redis需要创建当前服务器进程的子进程去执行操作,而大多数的操作系统都采用写时复制技术来优化子进程的使用概率,所以在子进程存在期间,服务器会主动提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地借节约内存。

渐进式Rehash

前面提到的rehash动作,并不是一次性、集中式完成的,而是分多次、渐进式地完成的。

这是为了因为当键值对过多时,进行rehash动作需要一定的时间,那么redis在这一段时间里就会停止服务,降低服务器性能。

因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部复制到ht[1]中,而是分多次,渐进式地将ht[0]里面的键值对慢慢地转移到ht[1]中。

它的详细过程如下

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
  2. 在字典中维持一个索引计数器变量rehashidx(字典中的hash索引),并将它的值设为0(0代表ht[0]哈希表底层数组索引为0,进行rehash时,会对rehashidx的位置里面的所有结点进行rehash,这个位置完了之后,就自增,准备下一个位置),表示rehash工作正式开始
  3. 在rehash进行期间,如果发生了对字典进行增删查改,程序除了正常执行这些操作外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash动作完了之后,rehashidx就会自增一,准备下一个位置的rehash
  4. 最终,所有位置都被rehash,这时程序会将rehashidx改为-1,表示动作完成。
渐进式Rehash的好处

采取了分而治之的方法,将rehash的过程所需的工作量分摊到对字典进行CRUD的操作上,从而避免了集中式Rehasher而带来的庞大的计算量,和停止服务一段时间。

渐进式Rehash执行期间的哈希表操作

因为在进行渐进式Rehash过程中,ht[0]的一部分数据会被rehash到ht[1]中,所以在渐进式Rehash的过程中,进行删查改时,会在两个Hash表中进行,即现在ht[0]上找,如果找不到,再去ht[1]上去找。

对于增,一律增加到ht[1]中,不会对ht[0]进行任何的修改,这一措施可以保证,ht[0]包含的键值对数量只会减少,而不会增加,最终随着rehash操作的执行,最后变成空表。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值