Redis——hash

前言

Hash 表是一种 key-value 类型的数据结构,它可以使用 O(1) 的时间复杂度查询数据。传入指定的 key,可以查询到指定的 value,使用范围非常广泛。

但是因为容量是确定的,会出现哈希冲突的现象。Redis 使用拉链法解决哈希冲突。

在数据越来越多的时候,原来的容量已经不能满足数据的存储,Redis 在达成某个条件后会触发扩容,扩容的方式是使用两个 Hash 表进行渐进式扩容。

插入元素

Hash 表底层通常会采用数组作为数据结构,当插入元素的时候,会先计算元素的 hash 值,然后使用 hash 值对数组长度进行求模,得到的结果就是元素需要插入到数组的下标。

image.png

结构

struct dict {
    // hash表类型,不同类型有不同的比较函数
    dictType *type;
    
    // 2 个 hash 表,
    dictEntry **ht_table[2];
    
    // 2 个哈希 table 里面各自存了多少个元素
    unsigned long ht_used[2];
    
    // 渐进式 rehash 现在处理的哈希槽索引值
    long rehashidx;
    
    // 用来暂停渐进式rehash的开关
    int16_t pauserehash;
    
    // 记录两个哈希table的长度,实际是是记录2的n次方中的 n 这个值
    signed char ht_size_exp[2]; 
};

image.png

  1. type,它是 hash 表的类型,本身也是一个结构体,里面定义了很多函数接口,类似于 Java 中的接口。需要传递接口的实现类。
  2. ht_table,它是一个数组类型,并且大小为 2,是因为在 rehash 的时候需要使用 2 个 hash 表。
  3. ht_used,表示 2 个 hash 表中各自有多少个元素
  4. rehashidx,记录 rehash 正在处理的 hash 槽,因为渐进式 rehash 是将迁移分配到每一个操作中,所以需要记录当前处理到哪一个槽了。
  5. pauserehash,是否暂停 rehash
  6. ht_size_exp,表示两个 hash 表的容量

在 hash 表中每一个元素的结构是这样的:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;     /* Next entry in the same hash bucket. */
    void *metadata[];           /* An arbitrary number of bytes (starting at a
                                 * pointer-aligned address) of size as returned
                                 * by dictType's dictEntryMetadataBytes(). */
} dictEntry;

在 hash 表的每一个元素都会包含三个部分:key,value,指向下一个元素的指针。指向下一个节点的指针是为了解决 hash 冲突。

value 是由一个联合体定义的,表示它可以是里面的任意一种类型。它可以是一个指针、一个表示 64 位无符号的整数、一个表示 16 位的整数,一个 double 值。这样定义的好处是,如果是整数类型,可以直接将值嵌入到 entry 中,节省内存,因为不需要使用指针去记录地址了。

哈希冲突

在插入函数那个段落中,可以知道每一个元素都会使用 key 计算 hash 值,然后再求模得到可以插入的数组坐标。但是 hash 值是有限的、数组容量是有限的,而数据是无限的,那么就会造成两种情况:

  1. 不同的数据存在一样的 hash 值
  2. 不一样的 hash 值求模得到的坐标相同

这两种情况都会将不同的元素都插入到数组的同一个坐标下,这是不允许出现的情况。

为了解决这个问题,redis 使用拉链法。拉链法就是在每一个元素中都记录一个指针,这个指针会指向下一个坐标相同的元素。当一个元素计算得到的坐标下已经有元素时,就会将数组中的元素的指针指向新的元素。从而得到下面这种情况

image.png

当需要查看 key 为 jshd 的元素时,首先会通过计算找到坐标 1,然后比较 key 值,发现 a 不等于 jshd,那么就沿着链表继续往下比较 key 值。直到找到或者 NULL 为止。

所以,hash 表的底层是数组和链表的组合。

我们可以试想这么一种极端情况,所有的元素都在同一个坐标下,那么 hash 表就会退化成链表,我们执行查询操作时,时间复杂度就会从 O(1) 退化为 O(n)。

为了解决这个问题,我们就需要进行扩容。

扩容

Redis 会在两种情况下进行扩容;

  • 当元素数量 / 容量 >= 1,并且没有执行 RDB 快照和 AOF 重写时,会进行扩容
  • 当元素数量 / 容量 > 5,那么将会直接进行扩容。

还记得之前介绍的,在一个 hash 结构中,会有两个 hash 表吗?正常情况下,只有一个 hash 表1 是正常使用的,另外一个表2 并不会分配空间。

但是当需要扩容的时候,会执行以下步骤:

  1. 给表 2 分配内存空间,通常是表 1 的两倍
  2. 然后将表 1 的数据逐渐迁移到表 2 中,迁移完成后,将表 1 的内存空间释放
  3. 切换表 2 为正常使用的表。

如果表 1 的元素数量很少,那么迁移的过程是非常快的,但是如果表 1 的元素数量很多,那么进行迁移的过程就会阻塞客户端的请求,此时 Redis 对外表现就是无法处理请求。这是无法接受的情况

渐进式 rehash

在扩容的时候,会将表 1 的数据进行迁移,但是如果表 1 的数据量很大,就会对 Redis 服务造成影响。因此 Redis 使用了一种叫做渐进式 rehash 的方式来进行迁移

渐进式 rehash 并不会一次性将全部数据进行迁移,而是在每一次对 hash 表的访问中,都迁移几个元素。在某一个时刻,表 1 的数据会全部迁移到表 2 中。

这种方式将迁移工作分配到了每一次的操作中,大大降低了一次操作的耗时。

在迁移期间,两个表的数据都是正常使用。在执行删除、更新、查询操作,会在两个表中都进行。比如查询一个元素,会现在表 1 中进行查询,查询不到就去表 2 中继续查询。

但是对于插入操作,只会将新的元素插入到表 2 中。这样就保证了表 1 中元素只会减少不会增加,从而可以在某个时间全部将表 1 的元素迁移完成。

如果插入的够快,会不会出现表 1 的数据还没有迁移完成,表 2 又要进行扩容呢?

Redis 检测到正在进行 rehash 时,是不会再次进行扩容的。

猜你喜欢

转载自juejin.im/post/7245936314850459706