Redis底层数据结构——字典

·  阅读 318
Redis底层数据结构——字典

在字典中,一个键 key 和一个值 value 进行关联,通过这样的关系,称他们为键值对。而字典的内部数据结构基本是由多个键值对组成的。 Redsi 使用 C 语言作为实现,但 C 语言不像其他大多语言把字典的数据结构内置,所以 Redis 实现了自己的专属字典结构。

字典应用场景

字典基本在 Redis 中是应用最广泛的,比如 Redis 的数据库就使用了字典作为了自己的底层实现,我们平时对数据库的增删改查都是建立在对字典的操作之上的。 还有 Redis 经常使用的数据类型:Hash , Zset 也都使用了字典作为在自己的底层数据结构实现之一。 以及 Redis 中带有过期时间的 key 集合也是使用了字典作为底层数据结构的实现。

字典结构内容

一个没有正在 rehash 中的普通状态的字典结构:

具体结构代码:

typedef struct dict {    

    // 类型特定函数    
    dictType *type;   
    
    // 私有数据    
    void *privdata;   
    
    // 哈希表    
    dictht ht[2];   
    
    // rehash 索引    
    // 当 rehash 不在进行时,值为 -1    
    int rehashidx; 
    
    // 目前正在运行的安全迭代器的数量   
    int iterators; 
    
} dict;
复制代码

图中最左边的就是代表 dict 字典结构的状况,type 属性和 privdata 属性设计的目的是针对不同类型的键值对,都可以保存在字典中,创造字典的多态性。

type 属性是一个指针,指向了 dictType 结构,每个 dictType 结构包含了多个操作特定类型键值对的函数,这样是为了能够为不同的字典设置不同的类型特定函数。

privadate 属性保存了需要传给上述特定类型函数的可选参数。

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;
复制代码

ht 属性是一个2个长度的数组,数组里面的每一个是一个 dictht 哈希表,一般在不 rehash 的情况下,字典只使用 ht [0] 哈希表,只有 rehash 的时候,ht [1] 哈希表才会对 ht [0] 哈希表 rehash ,两张表同时使用。

当然,只有 rehash 的时候, rehashidx 才会使用,它会记录 rehash 的进度,如果没有发生 rehash , 那么 rehashidx 的值是为 -1 的。

哈希表结构内容

上述提到的 ht 属性,里面包含两个哈希表,每个哈希表的结构是具体下面样子的:

哈希表中的 table 属性是一个数组,数组中的每个元素都是一个指向 dictEntry 结构的指针,每个 dictEntry 结构保存着一个键值对。

size 属性保存哈希表 table 数组的大小,也是哈希表的大小。比如图片中 table属性指向有4个 dictEntry 结构,所以 size 值为4。

used 属性保存了哈希表已有键值对的数量,图片中有两个键值对,所以 used 值为2。

sizemask 属性的值总是等于 size - 1,这个属性和哈希值一起计算出一个键应该被放到 table 数组中的哪个索引上。

哈希表结构具体代码:

typedef struct dictht {     

    // 哈希表数组    
    dictEntry **table;   

    // 哈希表大小    
    unsigned long size;       

    // 哈希表大小掩码,用于计算索引值    
    // 总是等于 size - 1   
    unsigned long sizemask;    

    // 该哈希表已有键值对的数量    
    unsigned long used;

} dictht;

复制代码

哈希表节点结构内容

上文提到的 dictEntry 节点即为哈希表节点,每个哈希表节点都保存了一个键值对。

dictEntry 结构具体代码:

typedef struct dictEntry {      

    // 键   
    void *key;   

    // 值    
    union {        
        void *val;        
        uint64_t u64;       
        int64_t s64;    
    } v;   

    // 指向下个哈希表节点,形成链表    
    struct dictEntry *next;

} dictEntry;
复制代码

dictEntry 结构中的 key 属性保存了键值对中的键,而 v 属性保存了键值对中的值,代码中表示了 v 属性可以是一个指针,也可以是一个 uint64_t 整数,也更可以是一个 int64_t 整数。

next 属性是一个指向另一个哈希表节点的指针,这个指针能将多个哈希值相同的键值对连接在一起,形成一个链表,用来解决键冲突的问题。

比如下图中的键 key1 与键 key2 通过哈希算法计算出来的哈希值与 sizemask 进行位与运算后都等于2,都将把他们放到数组的2号槽位上,此时发生了冲突,所以通过 next 指针将两者连接起来,以此解决哈希冲突。

哈希算法

当一个新的键值对将要添加到哈希表数组里面去的时候或者通过键去找对应值的时候,程序都需要执行哈希算法,获取键的对应索位,再进行相关的操作。

具体的哈希算法步骤:

1.通过字典的 type 属性里面的计算哈希值的函数,得到键的哈希值

hash = dict->type->hashFunction(key);
复制代码

2.根据哈希表的 sizemask 属性和哈希值进行位与运算,计算出索引值 ht[x] 可以是 ht[0] 或者ht[1]

idnex = hash & dict->hx[x].sizemask;
复制代码

解决哈希冲突

当两个键通过上面的哈希算法之后,计算出两个键的索引值一样,此时发生了哈希冲突,Redis 为了解决哈希冲突,采用了链表的形式,把每个哈希表节点设置上了一个 next 指针,通过 next 指针将冲突的键连接起来,形成一个单向链表。这样哈希表 table 数组上的每一个索引可以保存多个哈希表节点。

多个哈希表节点组成的单向链表因为没有指向链表表尾的指针,加上 Redis 这样的高性能架构思想,直接将下次需要新添加进链表的哈希表节点直接添加进链表的表头位置,直接位于链表的第一位,这样时间复杂度为 O(1), 大大提高了该数据结构的性能。

rehash

一个字典结构在实际生产过程中,可能随着业务发展,字典里面的哈希表保存的键值对可能会变多也会变少,为了能够让负载因子保持在一个合理的范围之内,即当哈希表保存的键值对数量太多或者太少,程序需要通过 rehash (重新散列)对哈希表大小进行相关的扩展或者收缩。

负载因子含义: 用于表示哈希冲突中元素填满的程序。 哈希冲突的机会越大,则查找的成本越高。反之,查找的成本越低,从而查找的时间越少。

这里一个负载因子等于当前已使用节点数量除上哈希表的大小:

load_factor = ht[0].used / ht[0].size
复制代码
rehash 步骤

1.先为字典的 ht [1] 哈希表分配内存空间,分配的空间大小由要执行的操作以及 ht [0] 当前保存的键值对数量 ( ht [0] .used 属性的值)

当哈希表的负载因子大于等于5的时候,且服务器正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令 或者

当哈希表的负载因子大于等于1的时候,且服务器没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令

执行扩展操作: ht [1] 的大小是第一个大于等于( ht [0].used * 2 )的 2的 n 次方幂

当哈希表的负载因子小于0.1的时候

执行收缩操作: ht [1] 的大小是第一个大于等于( ht [0].used )的 2的 n 次方幂

2.然后保存在 ht [0] 上的所有键值对都 rehash 到 ht [1] 上面,rehash 就是重新计算键的哈希值和索引,并且将键值对重新插入到 ht [1] 对应的索引上面。

3.当 ht [0] 上的所有键值对都迁移到 ht [1] 上面的时候,释放 ht [0] 的内存,之后将 ht [1] 更改为 ht [0] ,新建立一个空的 ht [1] 哈希表,为下次 rehash 操作做准备。

步骤就这样结束了~

注意点:

根据服务器有没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,扩展操作需要的负载因子的有所不同。

这样做是因为在执行 BGSAVE 命令或者 BGREWRITEAOF 命令 的时候,Redis 会创建当前服务器进程的子进程,且大多数操作系统采用写时复制( copy-on-write )技术来优化子进程的使用效果。假如在子进程存在的时候且扩展操作时负载因子比较低就执行 rehash 的话,内存会在原有子进程已经占用的很多情况下,会加剧内存的更多损耗。所以需要提高负载因子的值来尽可能的节约内存。

渐进式 rehash

上面说到的 rehash , 假如 ht[0] 哈希表中的所有键值对一次性都 rehash 到 ht[1] 哈希表,这样在生产过程中,要是多个字典,巨大量键值对都需要 rehash 的话,对服务器性能将是致命的打击,有可能宕机。所以 rehash 的时候需要渐进式。

渐进式 rehash 执行步骤:
  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 完成。

总结

通过对字典内部的结构内容以及对哈希表内容的相关操作,认识到了 redis 为了做到高性能的指标,通过对数据结构的设计、操作步骤上的优化等手段,来使 Redis 的字典结构更好的被应用。

参考:《 Redis设计与实现 》

更多Java后端开发相关技术,可以关注公众号「 红橙呀 」。

分类:
后端
标签: