赞
踩
哈希表是一种非常常用的数据结构,它具备 O(1) 时间复杂度的查找效率,常被用来索引数据。
哈希表在 Redis 中也承担着重要角色,例如:数据类型 Hash 底层就是用的哈希表实现的,数据库所有的键值对也是用一个全局哈希表来存储的。
Redis 是如何实现哈希表的呢?
哈希表(Hash Table),也称作散列表,是根据键(Key)直接访问在记忆体储存位置的数据结构。
哈希表由若干个键值对组成,键值对也被称作 Entry。
数组就是最简单的哈希表,我们可以把数组的每一项看作是一个桶 Bucket,数据写入时先对 Key 计算一个哈希值,然后对数组的长度取模得到结果下标,此时我们就可以定位到一个具体的 Bucket,然后把 Entry 写入该 Bucket 即可,查找的过程类似。
在实现哈希表时,需要考虑几个关键问题:
什么是哈希冲突?
数组的大小是有限的,也就是 Bucket 数量是有限的,但是往哈希表里写入的 Entry 理论上可以是无限的,把无限数量的 Entry 映射到有限的 Bucket 上,就一定会出现这样一种情况:两个不同的 Key 计算的结果下标相同,即映射到同一个 Bucket 上,这就是哈希冲突。
数组的容量设置多大?
数组就是最简单的哈希表,那数组的容量要如何设置呢?容量太小,发生哈希冲突的概率就会越大;容量太大,没用充分使用就会浪费内存空间。常见做法是:如果你能预先知道写入的数据量,那么就创建一个差不多容量数组;如果你无法预计数据量,就先创建一个较小的数组以节约内存,等到写入的数据量真的变大时再进行扩容操作。
怎么解决哈希冲突?
解决哈希冲突有很多种方式,例如:
rehash的策略
当数组容量过小,写入的数据过大,极大概率会发生哈希冲突。一旦发生哈希冲突,或多或少都会影响哈希表的查找性能,例如链地址法,哈希冲突后只能扫描链表,时间复杂度降到 O(N)。
因此,当 Entry 数量 和 数组容量的比值到达一定阈值时,就要考虑对哈希表进行扩容了,这个阈值也被称作负载因子。哈希表的扩容就是新申请一块更大的数组空间,然后把元素重新映射到新数组上,减少哈希冲突的概率。因为扩容的过程需要对所有 Key 重新执行一次哈希计算,所以扩容过程也被称作 rehash。
Redis 哈希表采用的是链式哈希的设计理念,通过链表来解决哈希冲突。Redis 哈希表对应的结构体是 dictht,二维数组 table 存储元素,size 记录了哈希表的容量,used 记录了元素的数量。
typedef struct dictht {
dictEntry **table; // 哈希表
unsigned long size; // 哈希表容量
unsigned long sizemask; // 容量掩码 size-1
unsigned long used; // 元素数量
} dictht;
哈希表中的键值对 Entry 对应的结构体是 dictEntry,next 指针指向下一个 Entry。
typedef struct dictEntry {
void *key;
union {
void *val; // 指向 Value 的指针
uint64_t u64; // 如果 Value 本身就是 整型或浮点型,直接存,无需指针,节省内存
int64_t s64;
double d;
} v;
struct dictEntry *next; // 链式哈希 指向下一个Entry
} dictEntry;
Redis 这里有一个小技巧,使用联合体(union)来定义 Value。正常键值对的 Value 可以是任意类型,使用指针 val 来指向它,但是如果 Value 本身就是整型或浮点型数值,就可以直接存储,无需再使用一个指针了。这种方式不仅节省内存,还少了一次寻址的开销。
Redis 是怎么实现 rehash 的呢?
首先我们要知道,Redis 读写DB只使用一个线程,也被称作主线程,主线程一旦发生阻塞,就无法处理其它请求了。Redis 作为一个高性能的键值对数据库,是无法容忍阻塞的。
偏偏 rehash 操作本身极有可能是个阻塞操作,首先 Redis 需要申请一块大的连续内存空间,然后把所有 Entry 做二次哈希操作,迁移到新的哈希表里,迁移的时长取决于哈希表中元素的数量。
为了避免 rehash 带来的阻塞问题,Redis 采用了渐进式 rehash 的做法。
所谓“渐进式 rehash”,意思是:不要一次迁移完所有 Entry,而是分批次,一点一点完成迁移。
为了实现渐进式 rehash,Redis 准备了两个哈希表 ht[0] 和 ht[1],在 dictht 外套了一层 dict 结构体:
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 两个哈希表 交替使用 rehash
long rehashidx; // rehash索引号 迁移的下标 -1代表没在rehash
int16_t pauserehash;
} dict;
rehash 的步骤:
什么时候会触发 rehash 呢?
判断是否需要 rehash 的方法是_dictExpandIfNeeded()
,满足任一条件即可:
dict_force_resize_ratio
倍大小,此时会立即 rehashstatic int _dictExpandIfNeeded(dict *d) { /* 正在rehash 直接返回 条件:rehashidx!=-1 */ if (dictIsRehashing(d)) return DICT_OK; /* 初始化 默认大小4 */ if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); if (!dictTypeExpandAllowed(d)) return DICT_OK; /* * 正常情况下 负载因子>=1 就rehash * 有RDB AOF子进程时,为了避免写时复制带来的影响,负载因子>=5才会rehash * 扩容后的大小:_dictNextPower(size) 大于等于元素大小的且最接近的的2的幂次方数 */ if ((dict_can_resize == DICT_RESIZE_ENABLE && d->ht[0].used >= d->ht[0].size) || (dict_can_resize != DICT_RESIZE_FORBID && d->ht[0].used / d->ht[0].size > dict_force_resize_ratio)) { return dictExpand(d, d->ht[0].used + 1); } return DICT_OK; }
dict_force_resize_ratio
的值默认为 5,当哈希表的元素数量超过了容量的 5 倍大小,说明哈希表的负载已经非常高了,此时会立即扩容。
哈希表是否被允许 rehash,Redis 定义了枚举值:
typedef enum {
DICT_RESIZE_ENABLE, // 启用rehash
DICT_RESIZE_AVOID, // 避免rehash 提高负载因子
DICT_RESIZE_FORBID, // 禁止rehash
} dictResizeEnable;
如果不进行 rehash 操作,带来的影响就是哈希表的性能会退化,为什么 Redis 还允许设置避免 rehash 呢?
我们先看看,哪些情况下 Redis 会设置避免 rehash 操作。
dict_can_resize
变量的设置方法是dictSetResizeEnabled()
:
void dictSetResizeEnabled(dictResizeEnable enable) {
dict_can_resize = enable;
}
设置的时机是updateDictResizePolicy()
:
void updateDictResizePolicy(void) {
if (server.in_fork_child != CHILD_TYPE_NONE)
dictSetResizeEnabled(DICT_RESIZE_FORBID);
else if (hasActiveChildProcess())// 有子进程在 rdb_dump aof_rewrite 时,尽量避免rehash
dictSetResizeEnabled(DICT_RESIZE_AVOID);
else
dictSetResizeEnabled(DICT_RESIZE_ENABLE);
}
我们发现,正常情况下,只要哈希表元素超过了容量大小就会触发 rehash,只有在子进程执行 rdb_dump 或 aof_rewrite 时才会设置尽量避免 rehash 操作,这又是为什么呢?
Redis 在做数据持久化和 AOF 文件重写时,因为这个操作相当耗时,为了避免阻塞主线程,Redis 会 fork 一个子进程异步处理。子进程和父进程共享同一块虚拟内存空间,所以子进程才能针对内存数据做全量持久化。但是为了节省资源,Linux 并不会真的拷贝一份内存,而是用到叫 “写时复制”(Copy On Write)技术,如果双方都不修改内存数据,就不会发生拷贝,此时基本没什么消耗。当任一方要修改数据时,因为内存页已经被 Linux 标记为 “写保护”所以该操作会报错,此时 Linux 会基于原始内存页复制一个新的内存页用于数据修改,而不至于影响到对方。大量的缺页报错会影响性能,所以在有 fork 子进程时,应该要尽量避免写数据,而 rehash 操作就是要一直写的,所以 Redis 在这种情况下才要尽量避免 rehash,手段就是调高负载因子,延迟 rehash 的时机。
哪些操作会触发 rehash 判断呢?通过源码发现有三个方法:
Redis rehash 的扩容策略是什么?
一句话总结:扩容后的大小必须大于等于元素数量且最小的2的幂次方数。
return dictExpand(d, d->ht[0].used + 1);
你可能好奇,为啥不直接 2 倍扩容呢?早期的版本确实是按照 2 倍容量扩容的,但是现在如果有 fork 子进程的话,Redis 会延迟扩容,就会导致元素大小远超过容量,此时就算 2 倍扩容也无济于事。
扩容方法是dictExpand()
,注意 size 并不是扩容后的大小,而是元素数量,Redis 会自动计算一个最接近的 2 的幂次方数作为新容量来扩容:
int dictExpand(dict *d, unsigned long size) {
return _dictExpand(d, size, NULL);
}
计算新容量的方法是_dictNextPower()
,它会从初始容量开始,一直乘以 2,直到超过元素大小。
static unsigned long _dictNextPower(unsigned long size)
{
unsigned long i = DICT_HT_INITIAL_SIZE;
if (size >= LONG_MAX) return LONG_MAX + 1LU;
while(1) {
if (i >= size)
return i;
i *= 2;
}
}
知道了扩容的策略,接下来看看 rehash 操作具体是如何实现的。
rehash 被拆分成两个函数:
默认情况下,Redis 每次只会迁移一个 Bucket,这样就保证了阻塞的时间很少。真正执行 rehash 操作的函数是dictRehash()
:
int dictRehash(dict *d, int n) { /* 访问的最大空桶数量 */ int empty_visits = n*10; unsigned long s0 = d->ht[0].size; unsigned long s1 = d->ht[1].size; if (dict_can_resize == DICT_RESIZE_FORBID || !dictIsRehashing(d)) return 0; if (dict_can_resize == DICT_RESIZE_AVOID && ((s1 > s0 && s1 / s0 < dict_force_resize_ratio) || (s1 < s0 && s0 / s1 < dict_force_resize_ratio))) { return 0; } // 循环迁移 while(n-- && d->ht[0].used != 0) { dictEntry *de, *nextde; assert(d->ht[0].size > (unsigned long)d->rehashidx); while(d->ht[0].table[d->rehashidx] == NULL) { d->rehashidx++; if (--empty_visits == 0) return 1; } de = d->ht[0].table[d->rehashidx]; /* Move all the keys in this bucket from the old to the new hash HT */ while(de) { uint64_t h; nextde = de->next; /* Get the index in the new hash table */ h = dictHashKey(d, de->key) & d->ht[1].sizemask; de->next = d->ht[1].table[h]; d->ht[1].table[h] = de; d->ht[0].used--; d->ht[1].used++; de = nextde; } d->ht[0].table[d->rehashidx] = NULL; d->rehashidx++; } /* * 如果已经迁移完了,释放ht[0],ht[0] 指向 ht[1] * 重置ht[1],重置rehashidx */ if (d->ht[0].used == 0) { zfree(d->ht[0].table); d->ht[0] = d->ht[1]; _dictReset(&d->ht[1]); d->rehashidx = -1; return 0; } return 1; }
Redis 会按照 rehashidx 顺序迁移 Bucket 里的数据,默认每次只会迁移 1 个 Bucket。如果访问的 Bucket 为空,则会继续扫描下一个,但是不排除极端情况下连续访问的 Bucket 都是空,此时为了不阻塞操作,Redis 不会一直扫描下去,最多扫描 10 个空桶,就会停止 rehash 操作。
如果把 ht[0] 数据都迁移完了,就会释放 ht[0],然后把 ht[1] 赋值给 ht[0],然后重置 ht[1] 和 rehashidx,以便下次 rehash。
为了避免全量 rehash 带来的阻塞问题,Redis 把 rehash 操作带来的影响分摊到多次执行,每次只迁移一个 Bucket,这样阻塞的时间就可以控制在可接受的范围内。那么哪些操作会触发 rehash 操作呢?通过源码发现有五个方法:
Redis 采用链式哈希的方式设计了哈希表,同时为了避免全量 rehash 操作带来的阻塞问题,Redis 使用渐进式 rehash,把数据的迁移操作分摊到多次执行,默认每次只迁移一个 Bucket 数据,这样阻塞的时间就可以控制在可接受的范围内。如果有子进程在执行 rdb_dump 或 aof_rewrite,考虑到主线程 rehash 会导致大量的写时复制,Redis 此时会尽量避免 rehash,延迟 rehash 的时机,但是如果哈希表负载过高,元素数量超过了容量的5倍大小,此时也不得不 rehash 了,否则哈希表的性能将会受到严重影响。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。