前言
Hash 表是一种 key-value 类型的数据结构,它可以使用 O(1) 的时间复杂度查询数据。传入指定的 key,可以查询到指定的 value,使用范围非常广泛。
但是因为容量是确定的,会出现哈希冲突的现象。Redis 使用拉链法解决哈希冲突。
在数据越来越多的时候,原来的容量已经不能满足数据的存储,Redis 在达成某个条件后会触发扩容,扩容的方式是使用两个 Hash 表进行渐进式扩容。
插入元素
Hash 表底层通常会采用数组作为数据结构,当插入元素的时候,会先计算元素的 hash 值,然后使用 hash 值对数组长度进行求模,得到的结果就是元素需要插入到数组的下标。
结构
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];
};
- type,它是 hash 表的类型,本身也是一个结构体,里面定义了很多函数接口,类似于 Java 中的接口。需要传递接口的实现类。
- ht_table,它是一个数组类型,并且大小为 2,是因为在 rehash 的时候需要使用 2 个 hash 表。
- ht_used,表示 2 个 hash 表中各自有多少个元素
- rehashidx,记录 rehash 正在处理的 hash 槽,因为渐进式 rehash 是将迁移分配到每一个操作中,所以需要记录当前处理到哪一个槽了。
- pauserehash,是否暂停 rehash
- 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 值是有限的、数组容量是有限的,而数据是无限的,那么就会造成两种情况:
- 不同的数据存在一样的 hash 值
- 不一样的 hash 值求模得到的坐标相同
这两种情况都会将不同的元素都插入到数组的同一个坐标下,这是不允许出现的情况。
为了解决这个问题,redis 使用拉链法。拉链法就是在每一个元素中都记录一个指针,这个指针会指向下一个坐标相同的元素。当一个元素计算得到的坐标下已经有元素时,就会将数组中的元素的指针指向新的元素。从而得到下面这种情况
当需要查看 key 为 jshd 的元素时,首先会通过计算找到坐标 1,然后比较 key 值,发现 a 不等于 jshd,那么就沿着链表继续往下比较 key 值。直到找到或者 NULL 为止。
所以,hash 表的底层是数组和链表的组合。
我们可以试想这么一种极端情况,所有的元素都在同一个坐标下,那么 hash 表就会退化成链表,我们执行查询操作时,时间复杂度就会从 O(1) 退化为 O(n)。
为了解决这个问题,我们就需要进行扩容。
扩容
Redis 会在两种情况下进行扩容;
- 当元素数量 / 容量 >= 1,并且没有执行 RDB 快照和 AOF 重写时,会进行扩容
- 当元素数量 / 容量 > 5,那么将会直接进行扩容。
还记得之前介绍的,在一个 hash 结构中,会有两个 hash 表吗?正常情况下,只有一个 hash 表1 是正常使用的,另外一个表2 并不会分配空间。
但是当需要扩容的时候,会执行以下步骤:
- 给表 2 分配内存空间,通常是表 1 的两倍
- 然后将表 1 的数据逐渐迁移到表 2 中,迁移完成后,将表 1 的内存空间释放
- 切换表 2 为正常使用的表。
如果表 1 的元素数量很少,那么迁移的过程是非常快的,但是如果表 1 的元素数量很多,那么进行迁移的过程就会阻塞客户端的请求,此时 Redis 对外表现就是无法处理请求。这是无法接受的情况
渐进式 rehash
在扩容的时候,会将表 1 的数据进行迁移,但是如果表 1 的数据量很大,就会对 Redis 服务造成影响。因此 Redis 使用了一种叫做渐进式 rehash 的方式来进行迁移 。
渐进式 rehash 并不会一次性将全部数据进行迁移,而是在每一次对 hash 表的访问中,都迁移几个元素。在某一个时刻,表 1 的数据会全部迁移到表 2 中。
这种方式将迁移工作分配到了每一次的操作中,大大降低了一次操作的耗时。
在迁移期间,两个表的数据都是正常使用。在执行删除、更新、查询操作,会在两个表中都进行。比如查询一个元素,会现在表 1 中进行查询,查询不到就去表 2 中继续查询。
但是对于插入操作,只会将新的元素插入到表 2 中。这样就保证了表 1 中元素只会减少不会增加,从而可以在某个时间全部将表 1 的元素迁移完成。
如果插入的够快,会不会出现表 1 的数据还没有迁移完成,表 2 又要进行扩容呢?
Redis 检测到正在进行 rehash 时,是不会再次进行扩容的。