Redis数据库笔记—— Hash(哈希)的扩容机制(rehash)
大家好,这里是Good Note,关注 公主号:Goodnote,专栏文章私信限时Free。详细介绍Hash(哈希)的扩容机制(rehash)、源码、以及扩容和缩容过程。
文章目录
- Redis 字典(dict)结构
- 源码
- 哈希表结构定义
- 渐进式哈希扩容(rehash)
- 渐进式哈希的优点
- 扩容机制:rehash
- 扩容条件
- 扩容过程
- 缩容机制:rehash
- 缩容条件
- 缩容过程
- 图示 rehash 操作
- 1. 开始 Rehash
- 2. Rehash 进行中
- 3. 节点迁移完毕
- 4. Rehash 完毕
- 字典的扩容和渐进式 rehash 实现
- Rehash 的影响与优化
- rehash执行的操作
- 查询操作
- 添加操作
- 总结
- 历史文章
- MySQL数据库
- Redis
Redis 的 Hash 类型是一个非常重要的数据结构,用于存储键值对的映射关系。它背后使用的是一种哈希表(hash table)结构,提供高效的 O(1) 时间复杂度进行插入、查找和删除操作。然而,当哈希表中的元素逐渐增多时,可能会触发哈希表的 扩容(rehash) 操作,以保证数据结构的高效性和性能。
Redis 字典(dict)结构
Redis 中的字典(dict
)是一个使用哈希表实现的高效数据结构,它包含以下基本元素:
- 键值对:每个元素是一个
key
和value
的配对,Redis 通过哈希表将key
映射到value
。 - 哈希表:采用链地址法的哈希表,桶(bucket)用于存储哈希值冲突的元素。
源码
哈希表结构定义
Redis 的字典(dict
)使用了哈希表(dictht
)作为其基本存储结构。每个字典(dict
)由两个哈希表组成,这两个哈希表用于实现 渐进式 rehash(渐进式扩容)。下面是哈希表相关的结构定义:
1. 单个节点:
- dictEntry:这是哈希表中的一个元素。每个元素包含一个键(
key
)和值(v
),并且每个节点指向下一个哈希表节点(形成链地址法)。
typedef struct dictEntry {
void *key; // 键
union {
void *val; // 值(可以是任意类型)
uint64_t u64; // 无符号64位整型值
int64_t s64; // 有符号64位整型值
} v;
dictEntry *next; // 指向下一个哈希表节点(链表形式)
} dictEntry;
2. 哈希表:
- dictht:这是哈希表本身的结构,包含哈希表的大小、掩码、已使用的节点数以及哈希表数组(
table
),数组中的每个项是dictEntry
链表的头节点。
typedef struct dictht {
dictEntry **table; // 哈希表数组,表的每个元素是一个链表的头节点
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,size - 1
unsigned long used; // 哈希表当前已使用的节点数
} dictht;
3. 字典结构体:
- dict:字典结构体,包含两个
dictht
(旧表和新表),用于实现渐进式哈希扩容,还包含指向dictType
(类型特定函数)的指针和当前正在执行的安全迭代器的数量。
typedef struct dict {
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 两张哈希表:ht[0] 是旧表,ht[1] 是新表
int rehashidx; // rehash 索引(标识当前扩容进度)
int iterators; // 当前正在运行的迭代器数量
} dict;
渐进式哈希扩容(rehash)
在 Redis 中,哈希表的扩容(rehash)采用渐进式的方法。这意味着哈希表的扩容不是一次性完成的,而是通过多次操作逐步迁移数据,避免阻塞。具体扩容的过程是基于桶(bucket)为单位进行的,每次迁移一个桶的数据,直到完成迁移。
核心函数:dictRehash
该函数实现了渐进式的哈希扩容。它接受一个参数 n
,表示希望执行的步骤数,每次执行一步操作。以下是该函数的主要步骤和逻辑:
int dictRehash(dict *d, int n) {
int empty_visits = n * 10; // 最多访问的空桶数
if (!dictIsRehashing(d)) return 0; // 如果没有进行 rehash,直接返回
while (n-- && d->ht[0].used != 0) { // 如果有数据需要迁移
dictEntry *de, *nextde;
assert(d->ht[0].size > (unsigned long)d->rehashidx); // 确保 rehashidx 没有越界
while (d->ht[0].table[d->rehashidx] == NULL) { // 跳过空桶
d->rehashidx++;
if (--empty_visits == 0) return 1; // 如果访问空桶次数超过限制,返回1,继续执行下一次
}
de = d->ht[0].table[d->rehashidx]; // 当前桶
// 将当前桶中的所有元素迁移到新哈希表
while (de) {
uint64_t h;
nextde = de->next; // 记录下一个节点
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++; // 更新迁移的索引
}
// 检查是否已完成迁移
if (d->ht[0].used == 0) {
zfree(d->ht[0].table); // 回收旧哈希表空间
d->ht[0] = d->ht[1]; // 旧表变为新表
_dictReset(&d->ht[1]); // 重置新表
d->rehashidx = -1; // 结束 rehash
return 0; // 返回0,表示迁移完成
}
// 还有更多的元素需要迁移
return 1;
}
函数逻辑分析:
-
判断是否正在进行 rehash:如果
rehashidx == -1
,则表示没有进行 rehash,因此直接返回0
。否则,进入扩容操作。 -
迁移数据:每次迁移一个桶的数据,直到迁移完当前
n
步的操作。每个桶可能包含多个哈希冲突的元素,因此会使用链地址法逐一迁移。 -
跳过空桶:在迁移过程中,
rehashidx
会跳过空桶。通过empty_visits
限制最多访问空桶的数量,防止迁移操作造成长时间阻塞。 -
迁移完成:当所有元素从旧哈希表迁移到新哈希表后,回收旧表的内存并重置新表。若迁移未完成,则返回
1
,表示还有数据需要迁移。
渐进式哈希的优点
-
性能优化:Redis 是单线程模型,如果在哈希表扩容时一次性迁移所有数据,可能会导致阻塞,影响性能。渐进式哈希通过逐步迁移数据,在后台处理扩容,避免了阻塞。
-
实时性:在大量数据迁移的过程中,Redis 仍然能够继续处理其他操作,不会因扩容而导致延迟或服务中断。
-
控制扩容步长:通过
dctRehash
函数中的n
参数,用户可以控制扩容的步长(桶的数量),避免扩容操作占用过多时间。
扩容机制:rehash
哈希表的负载因子是哈希表容量与存储的元素数量之间的比例。负载因子过大时,会导致哈希冲突增加,从而影响性能,因此需要进行扩容(rehash)操作。Redis 采用渐进式 rehash 机制,以平滑过渡的方式增加哈希表的容量,避免在 rehash 时导致性能的剧烈波动。
扩容条件
Redis 字典的扩容发生在以下条件下:
- 负载因子超过设定阈值时,触发扩容(rehash)。具体来说,当字典中的元素数量超过了字典容量的负载因子时,Redis 会自动扩容哈希表。
- 默认负载因子:Redis 默认的负载因子为 1,Redis 会将哈希表容量扩大为原来的 2 倍。
- 扩容是为了解决哈希冲突,提高哈希表的查找效率。
扩容过程
扩容是指当哈希表的负载因子过大时,Redis 会创建一个更大的哈希表,并将所有的元素重新映射到新的哈希表中。具体过程如下:
-
计算新大小:当哈希表的负载因子超过设定的阈值时,Redis 会将哈希表的容量扩大为原来大小的两倍。比如,如果当前字典大小为
N
,则新的字典大小会是2 * N
。 -
渐进式 rehash:为了避免在扩容时出现一次性大规模的性能抖动,Redis 采用了渐进式 rehash(Incremental Rehash)。在这种机制下,rehash 的过程并不是一次性完成的,而是分批进行的。具体来说,Redis 会在后续的操作中逐步完成哈希表从旧哈希表到新哈希表的元素迁移。
- 每次执行哈希表操作(如
SET
、GET
、DEL
等)时,Redis 会检查当前是否有需要迁移的元素。如果有迁移任务,它会将一部分元素(一个元素或者说是一个桶的元素)从旧表迁移到新表,并在之后的操作中继续迁移剩余的元素,直到迁移完毕。 - 这样可以避免一次性迁移所有元素导致的性能瓶颈,使得扩容过程对 Redis 的性能影响最小。
- 每次执行哈希表操作(如
-
完成 rehash 后:当所有元素迁移到新的哈希表后,Redis 将释放旧哈希表所占用的内存。
缩容机制:rehash
与扩容操作类似,当 Redis 中的哈希表负载因子过低时,Redis 会触发缩容(rehash) 操作,目的是为了节省内存并提高效率。缩容操作主要用于在键值对数量减少时,调整哈希表的大小,避免浪费内存。
缩容条件
Redis 字典的扩容发生在以下条件下:
- 当负载因子低于 0.1 时,Redis 会启动缩容操作。
- Redis 会将哈希表容量缩小为适应当前键值对数量的 2 的幂次方。
- 缩容的目的是节省内存空间,避免内存浪费。
如果现在哈希表中存储了 30 个键值对,2^5 = 32 (足够容纳 30 个键值对)。新的哈希表的大小会是 32。
缩容过程
缩容的过程与扩容类似,也是通过渐进式 rehash 来完成的,区别在于目标是减小哈希表的大小。
-
计算新大小:
- 当负载因子小于设定阈值时,Redis 会创建一个新的哈希表(
ht[1]
),其大小为当前哈希表的键值对数量所能容纳的最小 2 的幂次方。 - 如果
ht[0].used
(当前元素数量)为N
,则新的字典大小为不小于N
的 2 的幂次方。
- 当负载因子小于设定阈值时,Redis 会创建一个新的哈希表(
-
渐进式 rehash:
- 为了避免缩容时的性能瓶颈,Redis 会采用渐进式 rehash 机制,逐步将元素从旧的哈希表迁移到新的哈希表。
- 每当执行哈希表的操作时(如
SET
、GET
、DEL
等),Redis 会检查是否有待迁移的元素。如果有迁移任务,Redis 会将部分元素从旧哈希表(ht[0]
)迁移到新哈希表(ht[1]
)。 - 迁移操作会在每次操作中分批进行,直到所有元素都迁移完成,避免一次性迁移带来的性能压力。
-
完成 rehash 后:
- 当所有的元素迁移到新哈希表后,Redis 会释放旧哈希表所占用的内存。
- 此时,
ht[0]
被置为空表,ht[1]
成为新的哈希表。
图示 rehash 操作
来源
Redis 字典的 rehash 是一个用于扩展或收缩哈希表大小的过程。它涉及到重新分配空间,并逐步迁移键值对。以下是整个 rehash 过程的详细步骤,借助于例子,我们可以更清晰地理解 Redis 是如何管理字典的扩容和迁移的。
1. 开始 Rehash
在这一阶段,Redis 执行两个主要任务:
- 初始化
rehashidx
:rehashidx
被设置为0
,标识 rehash 过程的开始。 - 为
ht[1]
分配空间:新的哈希表ht[1]
会被分配至少是ht[0]
大小的两倍空间。
此时字典的状态如下:
- 旧哈希表 (
ht[0]
):存储原有键值对(例如 4 个键值对),并具有初步的哈希表大小(如大小 4)。 - 新哈希表 (
ht[1]
):开始创建,并分配空间(例如大小 8),但此时没有存储任何键值对。
2. Rehash 进行中
此时,rehashidx
的值被逐步增加,并表示 ht[0]
当前迁移到了哪个位置。逐步迁移的原因是 渐进式 rehash,即迁移过程不会一次性完成,而是分多次进行。
ht[0]
中的部分桶可能已经迁移,而ht[1]
中的部分桶已经开始填充新数据。- 以下是 rehashidx 值为 2 时,字典的样子:
3. 节点迁移完毕
当所有元素从 ht[0]
完全迁移到 ht[1]
后,ht[0]
变为空,且 ht[1]
中存储了所有键值对。此时,rehashidx
会被设置为 ht[1]
的最后一个桶位置,标志着迁移过程的完成。
4. Rehash 完毕
在 rehash 完成后,Redis 会:
- 释放
ht[0]
的空间:由于所有的键值对已经迁移到ht[1]
,ht[0]
中的空间可以被释放。 - 替换
ht[0]
和ht[1]
:ht[1]
被替换为新的ht[0]
,而ht[0]
的数据则会被丢弃(因为它已经变为空)。 - 更新字典的
rehashidx
:rehashidx
被设置为-1
,表示 rehash 完成,所有迁移工作已经结束。
此时,字典的哈希表已扩展,且容量已增加为 8
,可以容纳更多的键值对,且原有的键值对未发生任何变化。
字典的扩容和渐进式 rehash 实现
在 Redis 中,字典的扩容和渐进式 rehash 由以下几个函数实现:
dictExpand()
:用于扩容哈希表,当元素数量超过哈希表容量的阈值时,会调用该函数。dictResize()
:用于调整哈希表的大小,可以增加或减少容量。dictRehash()
:这是核心函数之一,用于将旧哈希表的元素迁移到新哈希表。迁移的过程是逐步进行的,不会阻塞客户端的请求。
Rehash 的影响与优化
- 性能优化:Redis 通过渐进式 rehash 技术避免了扩容时的性能瓶颈。渐进式 rehash 的引入使得 Redis 可以在高并发环境下继续处理客户端请求,极大地提升了扩容操作的效率。
- 内存优化:通过扩容和缩容,Redis 在保证性能的同时有效地使用内存。当负载因子较高时,扩容可以避免哈希冲突过多,保证查询性能;而当元素较少时,缩容可以释放内存。
- 内存浪费:尽管 Redis 尝试通过自动扩容和缩容来优化内存使用,但在一些极端情况下(比如字典大小变化较大时),可能会造成一定的内存浪费。
rehash执行的操作
查询操作
- 在进行哈希表查询(例如
GET
操作)时,Redis 会先检查 旧哈希表(ht[0]
)。 - 如果在 旧哈希表 中找到了匹配的元素,Redis 会返回该元素。
- 如果元素不在旧哈希表中,Redis 会继续查找 新哈希表(
ht[1]
),即使在进行 rehash 的过程中,新哈希表中的元素还在逐步迁移。
添加操作
在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。
总结
Redis 字典(dict
)的扩容机制是通过 渐进式 rehash 实现的,这使得在扩容时 Redis 可以保持高效的性能。每当字典达到一定负载因子时,Redis 会通过将哈希表的大小扩大一倍,并逐步将元素迁移到新表中,以保证系统稳定运行。同时,Redis 还支持缩容操作,以节省内存。渐进式 rehash 是 Redis 的一项关键优化,保证了 Redis 在高并发和大数据量情况下的高效性。
历史文章
MySQL数据库
- MySQL数据库笔记——数据库三范式
- MySQL数据库笔记——存储引擎(InnoDB、MyISAM、MEMORY、ARCHIVE)
- MySQL数据库笔记——常见的几种锁分类
- MySQL数据库笔记——索引介绍
- MySQL数据库笔记——事务介绍
- MySQL数据库笔记——索引结构之B+树
- MySQL数据库笔记——索引潜规则(回表查询、索引覆盖、索引下推)
- MySQL数据库笔记——索引潜规则(最左前缀原则)
- MySQL数据库笔记——常见慢查询优化方式
- MySQL数据库笔记——日志介绍
- MySQL数据库笔记——多版本并发控制MVCC
- MySQL数据库笔记——主从复制
Redis
- Redis数据库笔记——数据结构类型
- Redis数据库——Redis雪崩、穿透、击穿
- Redis数据库——内存淘汰机制
- Redis数据库笔记——内存分配器
- Redis数据库笔记——内存预分配