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后端开发相关技术,可以关注公众号「 红橙呀 」。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Redis是一个开源的内存数据库,它使用了多种数据结构来存储不同类型的数据。下面是几种常见的Redis底层数据结构的详解: 1. 字符串(String):字符串是Redis中最基本的数据结构。它可以存储任意类型的数据,包括数字、文本等。字符串在Redis中以字节数组的形式存储,可以通过键访问和修改。 2. 列表(List):列表是一个有序的字符串集合,可以在列表的两端进行插入、删除和获取操作Redis使用双向链表来实现列表数据结构,它支持快速插入和删除操作。 3. 哈希(Hash):哈希是一种键值对的集合。在Redis中,哈希可以存储多个字段和对应的值,类似于关联数组或者字典。哈希在内部使用哈希表来实现,可以快速查找和修改字段值。 4. 集合(Set):集合是一组唯一且无序的字符串集合。Redis使用哈希表来实现集合数据结构,它支持添加、删除和判断元素是否存在等操作。 5. 有序集合(Sorted Set):有序集合是一组唯一且有序的字符串集合。在Redis中,每个元素都会关联一个分数,通过分数可以对元素进行排序。有序集合的实现使用了跳跃表和哈希表两种数据结构,它支持添加、删除、修改和范围查询等操作。 这些数据结构底层实现都是高效的,并且支持丰富的操作Redis数据结构灵活性较高,能够满足不同类型的数据存储需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值