3、认识redis的hash

数据结构

Hash 由数组链表两种数据结构组成,数组里面每个元素就是一个槽位,一个槽位下面挂了一个链表。写入 field-value 数据的时候,会计算 field 的 hash 值,然后对数据长度进行取模,找到对应的槽位,把 field-value 插入到这个链表里面。
在这里插入图片描述

1、Redis 中的 hash 数据结构底层实现

在 Redis 里面,Hash 表底层依赖的数据结构其实有两种:第一种是前面介绍的 ziplist,Redis 7.0 之后呢,就是 listpack,总之就是一块连续的内存空间;另一种是我们今天这一节要介绍的 dict 结构,它就是哈希表的结构。

哈希表底层数据少、键值对都比较短的时候用 listpack 存,数据量大了之后就用 dict 存。Redis 会根据元素数量键值对大小在这两种结构之间自动切换。

2、切换规则

Redis 中有两个参数控制 hash 是否使用 listpack:

  • hash-max-ziplist-entries 👉 控制 hash 中元素数量(默认512)。
  • hash-max-ziplist-value 👉 控制单个entry值的最大长度(字节数, 默认64字节)。

在 Redis 7.x 中,ziplist 被完全替换为 listpack,因此参数名称改为:

hash-max-listpack-entries

hash-max-listpack-value

只要某个元素的长度大于64字节, 或者hash中的数据个数大于512个, 都会从listpack转为dict。

3、 listpack 与 dict 的区别

特性listpackdict
存储方式连续内存存储(紧凑存储)哈希表(散列表)
性能读取效率低,插入效率高(适合小哈希表)查询效率高(O(1) 时间复杂度)
内存占用占用内存小,存储紧凑内存占用相对较大
数据量适合存储少量、小体积数据适合存储大量数据
扩容机制无扩容机制渐进式 rehash

4、数据存储实例(ziplist和dict)

hash中存储age 21和name jack, 上面zlbytes开头的是ziplist, 下面是dict
在这里插入图片描述

关于ziplist和listpack, 可以看这篇文章 认识redis的list

dict

在 Redis 里面,哈希表对应的是 dict 这个结构体, 也就是字典结构

struct dict {
    // 当前dict实例使用的一些特殊函数集合,通过这些函数可以改变当前dict的行为
    dictType *type;
    // 真正存储数据的hashtable,其中一个是在rehash的时候使用,实现渐进式的rehash
    dictEntry **ht_table[2];
    // 每个哈希table里面存了多少个元素
    unsigned long ht_used[2];
    // 渐进式rehash现在处理的哈希槽索引值
    long rehashidx; 
    // 用来暂停渐进式rehash的开关
    int16_t pauserehash;
    // 记录两个哈希table的长度,实际是是记录2的n次方中的 n 这个值
    signed char ht_size_exp[2]; 
};

这里关键的是ht_table这个数组, 这个数组里面放了两个 dictEntry 的二级指针, 也就是指向指针的指针

如下图,这两个二级指针,分别指向了一个哈希表或者说是哈希 table(dictEntry*)。再展开看每个哈希 table 的结构(dictEntry),这个二级指针实际上指向的是一个 dictEntry 指针的数组,就是图中绿底的数组,里面每个元素都是 dictEntry 指针;然后这些 dictEntry 指针,指向了一个 dictEntry 列表,就是图中紫色底的列表。我用虚线框出来的这个部分,与上文介绍的哈希表结构非常类似了。
在这里插入图片描述

也就是dictEntry**二级指针指向指针数组dictEntry*, 该数组中的元素都是指针, 然后每个指针指向一个dictEntry单链表

  • ht_table: dictEntry结构具体存了hash结构中的数据
  • *type: dictType类型, 定义了一批函数指针的集合, 这些指针指向的函数决定了 dict 实例的一些关键行为; dictType 是 Redis 中字典操作的抽象层,Redis 中的字典(dict)通过引用特定的 dictType 来执行具体的操作。这种设计方式使得 Redis 字典可以适配不同的存储需求
  • ht_used: 每个哈希table(ht_table)里面存了多少个元素(实际存储数量)
  • rehashidx: 渐进式rehash现在处理的哈希槽索引值, -1表示没有rehash
  • pauserehash: 类似锁的功能,锁重入一次,pauserehash 就会加一次 1;锁退出一次,就减一次 1。那这个字段用来锁什么的呢?是用来锁渐进式 rehash,锁上了,渐进式 rehash 就停了
  • ht_size_exp: 记录两个哈希table的长度,实际是是记录2的n次方中的 n 这个值(申请的长度)

dictEntry

dictEntry它表示字典中的一个节点, 也就是存hash数据的节点。结构如下

typedef struct dictEntry {
    void *key; // 键值对中的Key,实际上指向一个sds实例
    union { // 用来存储键值对中的value值,因为是一个union,所以下面四个字段
            // 同时只会有一个有值
        void *val;     // 当value是一个非数字类型的值时,使用该指针
        uint64_t u64; // 当value值是一个无符号整数时,使用u64字段进行存储
        int64_t s64;  // 当value值是一个有符号整数时,使用s64字段进行存储
        double d;     // 当value值是一个浮点数时,使用d字段进行存储
    } v;
    struct dictEntry *next; // 指向下一个节点的指针
    void *metadata[];  // 额外的空间,这个跟Redis Cluster相关,后面再说
} dictEntry;

  • key: 指针存储的是键值对中的 Key,其实就是指向了一个 sds 实例
  • v 字段表示的是键值对的 Value 值,它有点特殊,是一个 union,也就是联合体。联合体里面有 4 个字段,但是只有一个字段能有值。
    • 如果 Value 值是一个非数字的类型,就用 val 这个指针来存;
    • 如果 Value 是一个无符号数字 u64字段存。
    • 如果 Value 是一个有符号数字,就是用 s64字段存。
    • 如果 Value 是一个浮点数,就是用d 字段存。
  • next 字段是指向下一个节点的指针。每个哈希槽里面存储的都是一个链表,每个链表节点都是通过这个 next 指针连接下一个节点的。
  • 至于 metadata 字段,是和 Redis Cluster 相关的一个优化,柔性数组,也不占空间。

dictType

dict 结构体里面还有一个 type 指针,它指向了一个 dictType 对象。看名字就知道,它表示的就是 dict 的类型

首先,来看 dictType 这个结构体,这里面就是一批函数指针的集合,这些指针指向的函数决定了 dict 实例的一些关键行为。dictType 结构体的定义如下:

typedef struct dictType {
    // hashFunction函数用来计算key的hash值
    uint64_t (*hashFunction)(const void *key);
    // keyDup和valDup分别负责对key和value进行复制
    void *(*keyDup)(dict *d, const void *key);
    void *(*valDup)(dict *d, const void *obj);
    // 用来比较两个key是否相同
    int (*keyCompare)(dict *d, const void *key1, const void *key2);
    // keyDestructor和valDestructor分别负责销毁key和value 
    void (*keyDestructor)(dict *d, void *key);
    void (*valDestructor)(dict *d, void *obj);
    // 用来检查当前dict是否需要扩容 
    int (*expandAllowed)(size_t moreMem, double usedRatio);
    // 用来计算metadata那个柔性数组的长度,用来检查
    size_t (*dictEntryMetadataBytes)(dict *d);
} dictType;

  • hashFunction: 指向的这个函数是用来计算 key 的 hash 值
  • keyDup: 深拷贝key的函数
  • valDup: 深拷贝value的函数
  • keyCompare: 用来比较两个 Key 是否相等
  • keyDestructor: 释放key的函数
  • valDestructor: 释放value的函数
  • expandAllowed: 这个函数用来检查这个哈希表能不能扩容

这里我们选取 dbDictType、hashDictType 和 setDictType 这三个比较典型、比较重点的实现进行分析。

dbDictType

Redis 本身是一个 KV 数据库,其实 Redis DB 也就是一个大的哈希表,只不过 Value 可能是 String、List、哈希表这种复杂结构而已。没错,dbDictType 就是 Redis 存全部键值对的哈希表使用的 dictType 实现:

dictType dbDictType = { 
    dictSdsHash,   // dictSdsHash函数底层就是使用siphash算法 
    NULL,   // keyDup和valDup两个指针为NULL,表示不会对键值对进行复制 
    NULL,  
    dictSdsKeyCompare, // key按照字符串方式进行比较 
    dictSdsDestructor, // key按照字符串方式进行销毁 
    // Redis中每个value值都是robj类型,所以value按照robj类型进行销毁 
    dictObjectDestructor,  
    dictExpandAllowed // 通过dictExpandAllowed函数决定是否扩容 
}; 

  • dictSdsHash: 哈希函数, 对 Redis 对象计算哈希值

  • keyDup 和 valDup 两个指针都是 NULL 值,那就是说,不对 key 和 value 进行深拷贝,传进什么实例就用什么实例了

  • dictSdsKeyCompare: 比较 Redis 对象是否相等; 先比较sds的长度, 再比较sds中每个字节是否相等

  • dictSdsDestructor: 就是释放 sds类型的key

  • dictObjectDestructor: 销毁 value对象; 它有点不一样; Redis 里面的 Value 可能是字符串,也可能是 List 或者哈希表,那我们调用哪种对象的销毁函数呢?Redis 在这些数据类型外面,又包了一层叫 robj 的结构体(全称就是 redis Object),它里面有个指针,指向了真正的字符串、List 之类的数据类型;另外,redis Object 里面还有个引用计数器,要是引用数掉到 1 了,就释放这个对象

    // 它释放 value 的逻辑就是减 redis Object 的引用计数器,我们点进去看,现在是 1 了,只有一个引用了,还要再减,就是 0 了,没人引用了,就根据里面存的具体类型,调释放函数回收内存,字符串调 sdsfree,List 调用 quicklistRelease,一个个节点释放掉。
    void dictObjectDestructor(dict *d, void *val){
        if (val == NULL) return; 
        decrRefCount(val); // 引用次数减1,减到0就会释放
    }
    
    void decrRefCount(robj *o) {
        if (o->refcount == 1) { // 无无人引用的时候,会调用value对应的free函数进行释放
            switch(o->type) {
            case OBJ_STRING: freeStringObject(o); break;
            case OBJ_LIST: freeListObject(o); break;
            case OBJ_SET: freeSetObject(o); break;
            case OBJ_ZSET: freeZsetObject(o); break;
            case OBJ_HASH: freeHashObject(o); break;
            case OBJ_MODULE: freeModuleObject(o); break;
            case OBJ_STREAM: freeStreamObject(o); break;
            default: serverPanic("Unknown object type"); break;
            }
            zfree(o);
        } else {
            if (o->refcount <= 0) serverPanic("decrRefCount against refcount <= 0");
            if (o->refcount != OBJ_SHARED_REFCOUNT) o->refcount--;
        }
    }
    
    
  • dictExpandAllowed: 扩容函数; 它的扩容逻辑就是看使用率是不是超过了 1.618,然后看看要是再扩容的话,是不是到了内存上限值,到了上限,肯定是不能扩容了。检查都通过 ,就会返回 1,允许扩容。

    int dictExpandAllowed(size_t moreMem, double usedRatio) {
        if (usedRatio <= 1.618) {
            return !overMaxmemoryAfterAlloc(moreMem); // 扩容
        } else {
            return 1;
        }
    }
    
    

hashDictType

hashDictType 是 Redis 里面 Hash 这种数据类型使用的 dictType 实现,具体定义如下所示。它里面的 hash 函数也是用的 siphash 算法,在写入键值对的时候不会进行复制,它使用的 Key 比较函数,也是 sds 字符串的比较。Redis Hash 结构中,键值对都只能是字符串,所以销毁 Key 和 Value 的函数,都是销毁 sds 字符串。

dictType hashDictType = {
    dictSdsHash,                // dictSdsHash函数底层就是使用siphash算法 
    NULL,                       // keyDup和valDup两个指针为NULL,表示不会对键值对进行复制
    NULL,                       
    dictSdsKeyCompare,          // 按照sds来比较Key
    dictSdsDestructor,          // 按照sds来销毁Key
    dictSdsDestructor,          // 按照sds来销毁Key
    NULL                        // 默认允许扩容
};

setDictType

它是 Set 这个数据类型对应的 dictType 实现。这里你可以先回忆一下 Java 里面 HashSet 的实现,底层其实是一个 HashMap,Key 用来存储 Set 的元素,Value 里面存一个固定的对象。Redis 里面也是类似的实现,Set 也是依赖 dict 实现的,Set 底层的 dict 中,Value 部分为空,也就无需进行比较、销毁等操作

结构如下

dictType setDictType = {
    dictSdsHash,   // dictSdsHash函数底层就是使用siphash算法 
    NULL,          // keyDup和valDup两个指针为NULL,表示不会对键值对进行复制
    NULL,
    // set集合中只能存放字符串的key,所以比较函数和销毁函数按照字符串方式进行处理
    dictSdsKeyCompare, 
    dictSdsDestructor, 
    NULL            // set集合是没有使用到value的,所以不需要对应的销毁函数
                    // 未指定expandAllowed函数,默认支持扩容
};

最后,看一下 setDictType 和 hashDictType 里面的扩容函数,都是 NULL,NULL 的意思就是允许扩容,不做任何限制。

扩容和渐进式 Rehash

1、上面介绍过dict结构中有个属性dictEntry **ht_table[2], 它有两个hash表, 最开始的时候, 我们只用 ht_table[0] 这个哈希表,读写只在这一个哈希表上面,数据写着写着,这个哈希表容量不够了,触发扩容了,这个时候就需要创建一个更大的哈希表,这个更大的哈希表就会存到 ht_table[1] 里面,然后把 ht_table[0] 里面的数据都迁移到这个 ht_table[1] 里面,扩容就完完成了。最后,再改一下指针,ht_table[0] 指向这个扩容后的哈希表,原来小的哈希表回收掉,ht_table[1] 重新改成 NULL,为下次扩容做准备。

2、我们把一个 Key 从小 ht_table 迁移到大 ht_table 的时候,需要重新计算 hash 值,然后再找槽位、再插入,这个过程也叫 Rehash 操作。rehash 过程对一个 Key 来说还好,但要是 Key 非常多了,耗时就起来了,这就会阻塞 Redis 主线程。

3、为了防止把整个线程阻塞住,Redis 就没有一把完成全部 Key 的 rehash,而是在每次访问这个 dict 的时候,rehash 一部分 Key,这样的话,就把 rehash 的耗时摊到了多次请求里面,不会出现长时间的阻塞,只是每次请求的耗时都分摊一点点可控的耗时。这个方案也就是我们说的渐进式 rehash

扩容逻辑

  1. 计算扩容之后的数组长度; 大于当前size,且是最小的2^n(和hashmap一样)
  2. 申请内存,申请好了之后,用 ht_table[1] 这个指针指向这块内存
  3. rehash标志位; 将 dict->rehashidx 标记为 0,表示开始渐进式 rehash。
  4. 渐进式 rehash 在增删改查的时候,都会被触发
  5. 每次访问 dict 时触发的渐进式 rehash 最多 rehash 一个槽位的数据。再就是 Redis 会定时触发 Redis DB 这个 dict 对象的 rehash,这个时候是一次 100 个槽位。
  6. rehash具体操作: 先重新计算 key 的 hash 值,然后确定它在 ht_table[1] 里面的槽位,然后头插法插入,同时还会把这个 Key 从 ht_table[0] 里面删掉。这样的话,这个 Key 就迁移好了。
  7. 如果ht_table[0] 里面的全部键值对是不是都迁移完了, 就把 ht_table[0] 指向这个迁移好的哈希表, ht_table[1] 的使命就结束了,会将其设置成 NULL
  8. 最后,最关键的一步,就是 rehashidx 设置成 -1,标志着整个 rehash 过程结束了,后面再增删改查的时候,就不用再渐进式 rehash 了

明白渐进式 rehash 之后,我们也就知道为啥 ht_table 数组的长度是 2 了。

简单总结一下:在没有发生扩容的时候,只有 ht_table[0] 正常使用,ht_table[1] 则指向 NULL;当发生扩容的时候,ht_table[1] 指向扩容后的大哈希表,每个请求都会尝试 rehash 一个槽中的 Key,或者 10 个空槽位。在 rehash 过程中,rehashidx 字段用于记录当前 rehash 的进度。

查找数据

在 rehash 的过程中要是查找某个 Key,是从两个 ht_table 都查一遍。

先走渐进式 rehash,然后计算 hash 值,在 ht_table[0] 里面找槽位,然后遍历槽位下面挂的链表,要是处于 rehash 的状态,就去 ht_table[1] 里面再找一遍

删除数据

  1. 先检查 dict 是不是要渐进式 rehash 一下,需要的话,就处理一个槽
  2. 先计算目标 key 的 hash 值, 找到目标 key 所在的槽位。遍历槽位中的链表,逐个比较 key,直到查找到目标节点。要是正处在渐进式 rehash 状态的话,两个 ht_table 都要扫。
  3. 找到之后,就把这个目标节点从链表中移除
  4. 最后如果需要就删除dictEntry节点
  5. 在删除数据的时候,可能会造成哈希表的缩容,缩容的逻辑和扩容的逻辑一模一样,除了 ht_table[1] 分配的哈希表比原来的小

总结

  1. dict使用key-value形式存储数据结构, hash使用的hashDictType只能存储字符串(sds结构)
  2. redis的hash集合在7.0版本之前value使用的是ziplist和dict存储数据,在7.0及以后就是listpack+dict结构
  3. 默认ziplist(listpack)单个元素大于64字节或者hash中元素大于512个会转为dict
  4. dict使用两个数组来完成存储和rehash动作, 采用头插法转移数据
  5. rehash采用的是渐进式, 每次增删改查会出发一个key的rehash, 定时rehash是100个槽位
  6. 查询数据时, 如果dict处于rehash状态, 会先在在 ht_table[0] 中查找, 如果没有则会在ht_table[1]中查找
  7. 删除数据时, 也会先查询一下数据处于哪个ht_table中, 找到了就删除
  8. Redis 采用渐进式 rehash策略,将数据迁移分散到后续的多次操作中完成, 每次对 dict 进行操作(如读、写、删除)时,会顺带迁移一部分数据,从而避免集中迁移导致的性能抖动, 也不会不会阻塞 Redis, 保证 Redis 在高并发场景下性能稳定
  9. set集合使用dict结构时, 使用的是setDictType, key是字符串类型(sds), value为空(和hashmap就挺像)

个人公众号: 行云代码

参考文章

说透 Redis 7

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

uncleqiao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值