Redis的rehash机制与触发条件
Redis哈希表结构概述
在深入探讨Redis的rehash机制之前,我们首先要了解Redis中哈希表的基本结构。Redis使用一种称为字典(dict)的数据结构来实现哈希表。在Redis的源码中,dict
结构体定义如下:
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
其中,ht
是一个包含两个哈希表的数组,ht[0]
用于正常情况下存储数据,ht[1]
则主要在rehash过程中发挥作用。rehashidx
字段用于记录当前rehash的进度,如果其值为 -1,表示当前没有进行rehash操作。
dictht
结构体定义了哈希表的具体结构:
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
table
是一个指针数组,每个元素都是一个指向dictEntry
结构体的指针。size
表示哈希表的大小,sizemask
用于计算哈希值对应的数组索引(通常为size - 1
),used
表示哈希表中已使用的槽位数量。
dictEntry
结构体则存储了实际的键值对数据:
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
key
是键,v
是值的联合体,可以存储不同类型的数据,next
用于解决哈希冲突,形成链表结构。
常规哈希表操作
在正常情况下,Redis对哈希表的操作主要包括插入、查找和删除。
插入操作
当执行插入操作时,Redis首先计算键的哈希值,然后通过h = dictHashFunction(key) & d->ht[x].sizemask
计算出对应的数组索引,其中d
是dict
结构体指针,x
通常为0(表示ht[0]
)。如果该索引位置已经有数据,就通过链表的方式将新的dictEntry
插入到链表头部。例如,下面是简化的插入代码逻辑:
int dictAdd(dict *d, void *key, void *val) {
long index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d);
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
index = _dictKeyIndex(d, key);
if (index == -1) return DICT_ERR;
entry = zmalloc(sizeof(*entry));
entry->next = ht->table[index];
ht->table[index] = entry;
dictSetKey(d, entry, key);
dictSetVal(d, entry, val);
ht->used++;
return DICT_OK;
}
查找操作
查找操作同样先计算键的哈希值,找到对应的数组索引,然后遍历链表查找目标键值对。如果找到了,就返回对应的值。示例代码逻辑如下:
dictEntry *dictFind(dict *d, const void *key) {
dictEntry *he;
unsigned int h, idx, table;
if (d->ht[0].used == 0) return NULL;
if (dictIsRehashing(d)) _dictRehashStep(d);
h = dictHashFunction(key);
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
while (he) {
if (dictCompareKeys(d, key, he->key)) return he;
he = he->next;
}
if (!dictIsRehashing(d)) break;
}
return NULL;
}
删除操作
删除操作也是类似的流程,先找到要删除的键值对所在的链表节点,然后将其从链表中移除,并释放相关内存。例如:
int dictDelete(dict *d, const void *key) {
dictEntry *he, *prevHe;
unsigned int h, idx;
int table;
if (d->ht[0].used == 0) return DICT_ERR;
if (dictIsRehashing(d)) _dictRehashStep(d);
for (table = 0; table <= 1; table++) {
idx = dictHashFunction(key) & d->ht[table].sizemask;
he = d->ht[table].table[idx];
prevHe = NULL;
while (he) {
if (dictCompareKeys(d, key, he->key)) {
if (prevHe) prevHe->next = he->next;
else d->ht[table].table[idx] = he->next;
dictFreeKey(d, he);
dictFreeVal(d, he);
zfree(he);
d->ht[table].used--;
return DICT_OK;
}
prevHe = he;
he = he->next;
}
if (!dictIsRehashing(d)) break;
}
return DICT_ERR;
}
Redis的rehash机制
随着数据的不断插入和删除,哈希表的负载因子(used / size
)会发生变化。当负载因子过高或过低时,为了保持哈希表的性能,Redis需要对哈希表进行重新哈希(rehash)操作。
渐进式rehash
Redis采用渐进式rehash的方式,而不是一次性完成整个哈希表的重新构建。这是因为如果哈希表中数据量非常大,一次性重新哈希可能会导致Redis在一段时间内无法处理其他请求,影响性能。
在渐进式rehash过程中,Redis会逐步将ht[0]
中的数据迁移到ht[1]
。具体步骤如下:
- 初始化阶段:当决定进行rehash时,Redis会分配一个大小合适的
ht[1]
。如果是扩展操作,ht[1]
的大小通常是ht[0]
的两倍;如果是收缩操作,ht[1]
的大小通常是ht[0]
的一半。同时,将rehashidx
设置为0。 - 迁移阶段:在每次哈希表操作(插入、查找、删除等)时,Redis会检查
rehashidx
是否大于 -1,如果是,则执行一次迁移步骤。每次迁移步骤会将ht[0]
中rehashidx
索引位置的所有键值对迁移到ht[1]
。迁移完成后,rehashidx
加1。 - 完成阶段:当
rehashidx
等于ht[0].size
时,表示ht[0]
中的所有数据都已迁移到ht[1]
,此时将ht[0]
指向ht[1]
,释放ht[0]
的内存,并将rehashidx
设置为 -1,表示rehash操作完成。
下面是_dictRehashStep
函数的简化代码,它执行一次迁移步骤:
int _dictRehashStep(dict *d) {
if (!dictIsRehashing(d)) return 0;
while (d->ht[0].used && d->rehashidx < d->ht[0].size) {
dictEntry *he, *nextHe;
he = d->ht[0].table[d->rehashidx];
if (he == NULL) {
d->rehashidx++;
continue;
}
while (he) {
nextHe = he->next;
unsigned int h = dictHashFunction(he->key) & d->ht[1].sizemask;
he->next = d->ht[1].table[h];
d->ht[1].table[h] = he;
d->ht[0].used--;
d->ht[1].used++;
he = nextHe;
}
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;
return 1;
}
return 0;
}
rehash触发条件
Redis的rehash操作主要由以下两种情况触发:
扩展触发条件
- 负载因子大于等于1且
ht[0].used
大于dict_force_resize_ratio
:当哈希表的负载因子(used / size
)大于等于1,并且ht[0].used
大于dict_force_resize_ratio
(默认为512)时,会触发扩展rehash。这意味着哈希表中的元素数量已经达到或超过了哈希表的大小,且元素数量超过了一定阈值,为了避免哈希冲突加剧,需要扩展哈希表的大小。 dict_can_resize
为1且负载因子大于等于dict_force_resize_ratio
:如果dict_can_resize
标志位为1(表示允许重新调整大小),并且负载因子大于等于dict_force_resize_ratio
,也会触发扩展rehash。这是一种更宽松的触发条件,只要负载因子达到一定程度且允许调整大小,就会进行扩展。
在dictExpandIfNeeded
函数中可以看到相关的触发逻辑:
int dictExpandIfNeeded(dict *d) {
/* Incremental rehashing already in progress. Return. */
if (dictIsRehashing(d)) return DICT_OK;
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used >= dict_force_resize_ratio))
{
return dictExpand(d, d->ht[0].used * 2);
}
return DICT_OK;
}
收缩触发条件
当负载因子小于0.1时,会触发收缩rehash。这表示哈希表中的元素数量相对哈希表大小过少,占用了过多的内存空间,通过收缩哈希表可以减少内存占用。在dictRehashIfNeeded
函数中可以看到收缩触发的逻辑:
void dictRehashIfNeeded(dict *d) {
if (!dictIsRehashing(d) && (d->ht[0].used == 0 ||
(double)d->ht[0].used / d->ht[0].size < dict_force_resize_ratio))
{
int ht_size = d->ht[0].used ? d->ht[0].used : 1;
if (ht_size < dict_force_resize_ratio) ht_size = dict_force_resize_ratio;
dictExpand(d, ht_size);
}
}
代码示例
为了更好地理解Redis的rehash机制,我们可以通过一个简单的Python代码示例来模拟类似的渐进式rehash过程。假设我们有一个简单的哈希表,使用列表来模拟哈希表的数组部分,使用字典来模拟链表结构存储键值对。
class HashTable:
def __init__(self, initial_size=4):
self.size = initial_size
self.sizemask = initial_size - 1
self.used = 0
self.table = [None] * self.size
def hash_function(self, key):
return hash(key) & self.sizemask
def add(self, key, value):
index = self.hash_function(key)
if self.table[index] is None:
self.table[index] = {key: value}
else:
self.table[index][key] = value
self.used += 1
if self.used / self.size >= 1:
self.rehash()
def get(self, key):
index = self.hash_function(key)
if self.table[index] is not None:
return self.table[index].get(key)
return None
def rehash(self):
new_size = self.size * 2
new_sizemask = new_size - 1
new_table = [None] * new_size
for bucket in self.table:
if bucket is not None:
for key, value in bucket.items():
new_index = hash(key) & new_sizemask
if new_table[new_index] is None:
new_table[new_index] = {key: value}
else:
new_table[new_index][key] = value
self.size = new_size
self.sizemask = new_sizemask
self.table = new_table
# 测试代码
ht = HashTable()
ht.add('key1', 'value1')
ht.add('key2', 'value2')
ht.add('key3', 'value3')
ht.add('key4', 'value4')
print(ht.get('key1')) # 输出: value1
在这个示例中,HashTable
类模拟了一个简单的哈希表。add
方法在插入元素时会检查负载因子,如果负载因子大于等于1,则调用rehash
方法进行扩展。rehash
方法会创建一个新的更大的哈希表,并将旧表中的所有元素重新插入到新表中。
rehash机制对性能的影响
优点
- 避免性能骤降:渐进式rehash避免了一次性重新哈希带来的性能问题。在数据量较大时,一次性重新哈希可能会导致Redis在较长时间内无法处理其他请求,而渐进式rehash将重新哈希的工作分散到多次操作中,减少了对系统性能的影响。
- 优化内存使用:通过扩展和收缩哈希表,Redis可以根据实际数据量调整哈希表的大小,从而优化内存使用。在数据量增长时,及时扩展哈希表可以减少哈希冲突,提高查找效率;在数据量减少时,收缩哈希表可以释放多余的内存空间。
缺点
- 增加操作复杂度:由于渐进式rehash的存在,每次哈希表操作都需要额外检查
rehashidx
并可能执行一次迁移步骤,这增加了操作的复杂度。虽然每次迁移步骤的开销较小,但在高并发场景下,这些额外的检查和迁移操作可能会对性能产生一定的影响。 - 内存占用增加:在rehash过程中,需要同时维护
ht[0]
和ht[1]
两个哈希表,这会导致在一段时间内内存占用增加。虽然最终会释放ht[0]
的内存,但在rehash期间,系统的内存压力会有所上升。
实际应用中的考虑
- 数据量预估:在设计使用Redis哈希表的应用时,尽量对数据量有一个合理的预估。如果预计数据量会有较大的增长,提前设置合适的初始哈希表大小可以减少rehash的频率。
- 性能监控:通过监控Redis的哈希表负载因子等指标,可以及时了解哈希表的状态。例如,可以使用Redis的INFO命令获取哈希表相关的统计信息,如
used_memory
、hash_table_size
、hash_table_used
等,根据这些指标来调整应用的策略。 - 高并发场景:在高并发场景下,渐进式rehash可能会对性能产生一定影响。可以考虑在业务低峰期手动触发rehash操作,以减少对正常业务的影响。同时,合理调整
dict_can_resize
和dict_force_resize_ratio
等参数,以平衡性能和内存使用。
总结rehash机制要点
- 哈希表结构基础:Redis的哈希表基于
dict
结构体,包含两个dictht
哈希表用于rehash过程。dictEntry
结构体存储键值对,通过链表解决哈希冲突。 - 渐进式rehash过程:分为初始化、迁移和完成阶段,每次操作逐步迁移部分数据,避免一次性操作带来的性能问题。
- 触发条件:扩展触发条件包括负载因子大于等于1且
ht[0].used
大于阈值,或dict_can_resize
为1且负载因子大于等于dict_force_resize_ratio
;收缩触发条件是负载因子小于0.1。 - 性能影响:优点是避免性能骤降和优化内存使用,缺点是增加操作复杂度和在rehash期间增加内存占用。
- 实际应用考虑:合理预估数据量、监控性能指标、在高并发场景下合理安排rehash操作。
通过深入理解Redis的rehash机制及其触发条件,开发者可以更好地优化使用Redis哈希表的应用,提高系统的性能和稳定性。无论是在小型应用还是大规模分布式系统中,对rehash机制的掌握都有助于充分发挥Redis的优势。在实际开发中,结合具体的业务场景和性能需求,灵活运用rehash相关的知识,可以为应用的高效运行提供有力保障。同时,通过代码示例的实践和性能分析,能够更直观地感受到rehash机制在不同情况下的表现,从而做出更合理的决策。无论是从内存管理的角度,还是从高并发处理的角度,深入理解rehash机制都是Redis开发者必不可少的技能之一。