Redis的rehash机制

2024-09-08 13:48
文章标签 redis 机制 rehash

本文主要是介绍Redis的rehash机制,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

在Redis中,键值对(Key-Value Pair)存储方式是由字典(Dict)保存的,而字典底层是通过哈希表来实现的。通过哈希表中的节点保存字典中的键值对。我们知道当HashMap中由于Hash冲突(负载因子)超过某个阈值时,出于链表性能的考虑,会进行Resize的操作。Redis也一样。

在redis的具体实现中,使用了一种叫做渐进式哈希(rehashing)的机制来提高字典的缩放效率,避免 rehash 对服务器性能造成影响,渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。

字典结构

哈希表节点

typedef struct dictEntry {void *key;                //键union {void *val;            //值uint64_t u64;int64_t s64;double d;} v;struct dictEntry *next; //指向下一个节点,形成链表
} dictEntry;

从哈希表节点结构中,可以看出,在redis中解决hash冲突的方式为采用链地址法。key和v分别用于保存键值对的键和值

哈希表

/* This is our hash table structure. Every dictionary has two of this as we* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {dictEntry **table;unsigned long size;unsigned long sizemask;unsigned long used;
} dictht;
  • table:哈希表数组,数组的每个项是dictEntry链表的头结点指针
  • size:哈希表大小;在redis的实现中,size也是触发扩容的阈值
  • sizemask:哈希表大小掩码,用于计算索引值;总是等于 size-1 ;
  • used:哈希表中保存的节点的数量

字典

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;
  • type 属性是一个指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。
  • 而 privdata 属性则保存了需要传给那些类型特定函数的可选参数。
  • dictht ht[2]:在字典内部,维护了两张哈希表。 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
  • rehashidx:和 rehash 有关的属性,它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。

rehash检查

随着操作的不断执行, 哈希表保存的键值对会逐渐地增多或者减少, 为了让哈希表的负载因子(load factor)维持在一个合理的范围之内, 当哈希表保存的键值对数量太多或者太少时, 程序需要对哈希表的大小进行相应的扩展或者收缩。

扩容

redis中,每次插入键值对时,都会检查是否需要扩容。如果满足扩容条件,则进行扩容。
在向redis中添加键时都会依次调用dictAddRaw –> _dictKeyIndex –> _dictExpandIfNeeded函数,在_dictExpandIfNeeded函数中会判断是否需要扩容

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{/* Incremental rehashing already in progress. Return. */// 如果正在进行渐进式扩容,则返回OKif (dictIsRehashing(d)) return DICT_OK;/* If the hash table is empty expand it to the initial size. */// 如果哈希表ht[0]的大小为0,则初始化字典if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);/* If we reached the 1:1 ratio, and we are allowed to resize the hash* table (global setting) or we should avoid it but the ratio between* elements/buckets is over the "safe" threshold, we resize doubling* the number of buckets. *//** 如果哈希表ht[0]中保存的key个数与哈希表大小的比例已经达到1:1,即保存的节点数已经大于哈希表大小* 且redis服务当前允许执行rehash,或者保存的节点数与哈希表大小的比例超过了安全阈值(默认值为5)* 则将哈希表大小扩容为原来的两倍*/if (d->ht[0].used >= d->ht[0].size &&(dict_can_resize ||d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)){return dictExpand(d, d->ht[0].used*2);}return DICT_OK;
}

从上面代码和注释可以看到,如果没有进行初始化或者满足扩容条件则对字典进行扩容。

先来看看字典初始化,在redis中字典中的hash表也是采用延迟初始化策略,在创建字典的时候并没有为哈希表分配内存,只有当第一次插入数据时,才真正分配内存。看看字典创建函数dictCreate。

/* Create a new hash table */
dict *dictCreate(dictType *type,void *privDataPtr)
{dict *d = zmalloc(sizeof(*d));_dictInit(d,type,privDataPtr);return d;
}/* Initialize the hash table */
int _dictInit(dict *d, dictType *type,void *privDataPtr)
{_dictReset(&d->ht[0]);_dictReset(&d->ht[1]);d->type = type;d->privdata = privDataPtr;d->rehashidx = -1;d->iterators = 0;return DICT_OK;
}static void _dictReset(dictht *ht)
{ht->table = NULL;ht->size = 0;ht->sizemask = 0;ht->used = 0;
}

从上面的创建过程可以看出,ht[0].table为NULL,且ht[0].size为0,直到第一次插入数据时,才调用dictExpand函数初始化

我们再看看dict_can_resize字段,该字段在dictEnableResize和dictDisableResize函数中分别赋值1和0,在updateDictResizePolicy函数中会调用者两个函数。

void updateDictResizePolicy(void) {if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)dictEnableResize();elsedictDisableResize();
}void dictEnableResize(void) {dict_can_resize = 1;
}void dictDisableResize(void) {dict_can_resize = 0;
}

而在redis中每次开始执行aof文件重写或者开始生成新的RDB文件或者执行aof重写/生成RDB的子进程结束时,都会调用updateDictResizePolicy函数,所以从该函数中,也可以看出来,如果当前没有子进程在执行aof文件重写或者生成RDB文件,则运行进行字典扩容;否则禁止字典扩容。

综上,字典扩容需要同时满足如下两个条件:

  1. 哈希表中保存的key数量超过了哈希表的大小(可以看出size既是哈希表大小,同时也是扩容阈值)
  2. 当前没有子进程在执行aof文件重写或者生成RDB文件;或者保存的节点数与哈希表大小的比例超过了安全阈值(默认值为5)

也可以如下理解:
当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩展操作:
服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 1
服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 5

缩容

当哈希表的负载因子小于 0.1 时, 程序自动开始对哈希表执行收缩操作。
在周期函数serverCron中,调用databasesCron函数,该函数中会调用tryResizeHashTables函数检查用于保存键值对的redis数据库字典是否需要缩容。如果需要则调用dictResize进行缩容,dictResize函数中也是调用dictExpand函数

看看databasesCron中相关部分

if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {/* We use global counters so if we stop the computation at a given* DB we'll be able to start from the successive in the next* cron loop iteration. */static unsigned int resize_db = 0;static unsigned int rehash_db = 0;int dbs_per_call = CRON_DBS_PER_CALL;int j;/* Don't test more DBs than we have. */if (dbs_per_call > server.dbnum) dbs_per_call = server.dbnum;/* Resize */for (j = 0; j < dbs_per_call; j++) {tryResizeHashTables(resize_db % server.dbnum);resize_db++;}
}

可以看到要检查是否需要缩容的前提也是当前没有子进程执行aof重写或者生成RDB文件

/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL* we resize the hash table to save memory */
void tryResizeHashTables(int dbid) {if (htNeedsResize(server.db[dbid].dict))dictResize(server.db[dbid].dict);if (htNeedsResize(server.db[dbid].expires))dictResize(server.db[dbid].expires);
}/* Hash table parameters */
#define HASHTABLE_MIN_FILL        10      /* Minimal hash table fill 10% */
int htNeedsResize(dict *dict) {long long size, used;size = dictSlots(dict);used = dictSize(dict);return (size > DICT_HT_INITIAL_SIZE &&(used*100/size < HASHTABLE_MIN_FILL));
}/* Resize the table to the minimal size that contains all the elements,* but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{int minimal;if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;minimal = d->ht[0].used;if (minimal < DICT_HT_INITIAL_SIZE)minimal = DICT_HT_INITIAL_SIZE;return dictExpand(d, minimal);
}

从htNeedsResize函数中可以看到,当哈希表保存的key数量与哈希表的大小的比例小于10%时需要缩容。最小容量为4

从dictResize函数中可以看到缩容时,缩容后的哈希表大小为当前哈希表中key的数量,当然经过dictExpand函数中_dictNextPower函数计算后,缩容后的大小为第一个大于等于当前key数量的2的n次方。最小容量为4。同样从dictResize函数中可以看到,如果当前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,则不进行缩容(有篇文章中提到缩容时没有考虑bgsave,该说法是错误的)

渐进式rehash

渐进式rehash初始化

从上面可以看到,不管是扩容还是缩容,最终都是调用dictExpand函数来完成。看看dictExpand函数实现

/* Expand or create the hash table */
int dictExpand(dict *d, unsigned long size)
{/* the size is invalid if it is smaller than the number of* elements already inside the hash table */if (dictIsRehashing(d) || d->ht[0].used > size)return DICT_ERR;//计算新的哈希表大小,使得新的哈希表大小为一个2的n次方;大于等于size的第一个2的n次方dictht n; /* the new hash table */unsigned long realsize = _dictNextPower(size);/* Rehashing to the same table size is not useful. */if (realsize == d->ht[0].size) return DICT_ERR;/* Allocate the new hash table and initialize all pointers to NULL */n.size = realsize;n.sizemask = realsize-1;n.table = zcalloc(realsize*sizeof(dictEntry*));n.used = 0;/* Is this the first initialization? If so it's not really a rehashing* we just set the first hash table so that it can accept keys. */// 并非扩容,而是第一次初始化;前面说了,第一次初始化也是通过该函数完成if (d->ht[0].table == NULL) {d->ht[0] = n;return DICT_OK;}/* Prepare a second hash table for incremental rehashing */// 为渐进式扩容作准备,下面两个赋值非常重要d->ht[1] = n;d->rehashidx = 0;return DICT_OK;
}

可以看到该函数计算一个新的哈希表大小,满足2的n次方,为什么要满足2的n次方?因为哈希表掩码sizemask为size-1,当size满足2的n次方时,计算每个key的索引值时只需要用key的hash值与掩码sizemask进行位与操作,替代求余操作,计算更快

然后分配了一个新的哈希表,为该哈希表分配了新的大小的内存。最后将该哈希表赋值给字典的ht[1],然后将rehashidx赋值为0,打开渐进式rehash标志。同时该值也标志渐进式rehash当前已经进行到了哪个hash槽

从该函数中,我们并没有看到真正执行哈希表rehash的相关操作,只是分配了一个新的哈希表就结束了。我们知道哈希表rehash需要遍历原有的整个哈希表,对原有的所有key进行重新hash,存放到新的哈希槽

在redis的实现中,没有集中的将原有的key重新rehash到新的槽中,而是分解到各个命令的执行中,以及周期函数中

操作辅助rehash

在redis中每一个增删改查命令中都会判断数据库字典中的哈希表是否正在进行渐进式rehash,如果是则帮助执行一次

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{long index;dictEntry *entry;dictht *ht;if (dictIsRehashing(d)) _dictRehashStep(d);......
}

类似的在dictFind、dictGenericDelete、dictGetRandomKey、dictGetSomeKeys等函数中都有以下语句判断是否正在进行渐进式rehash

if (dictIsRehashing(d)) _dictRehashStep(d);
//dictIsRehashing(d)定义如下,rehashidx不等于-1即表示正在进行渐进式rehash
#define dictIsRehashing(d) ((d)->rehashidx != -1)

_dictRehashStep函数的定义如下:

/** 此函数仅执行一步hash表的重散列,并且仅当没有安全迭代器绑定到哈希表时。* 当我们在重新散列中有迭代器时,我们不能混淆打乱两个散列表的数据,否则某些元素可能被遗漏或重复遍历。** 该函数被在字典中查找或更新等普通操作调用,以致字典中的数据能自动的从哈系表1迁移到哈系表2*/
static void _dictRehashStep(dict *d) {if (d->iterators == 0) dictRehash(d,1);
}

定时辅助rehash

虽然redis实现了在读写操作时,辅助服务器进行渐进式rehash操作,但是如果服务器比较空闲,redis数据库将很长时间内都一直使用两个哈希表。所以在redis周期函数中,如果发现有字典正在进行渐进式rehash操作,则会花费1毫秒的时间,帮助一起进行渐进式rehash操作

在databasesCron函数中,实现如下

/* Rehash */
if (server.activerehashing) {for (j = 0; j < dbs_per_call; j++) {int work_done = incrementallyRehash(rehash_db);if (work_done) {/* If the function did some work, stop here, we'll do* more at the next cron loop. */break;} else {/* If this db didn't need rehash, we'll try the next one. */rehash_db++;rehash_db %= server.dbnum;}}
}

前提是配置了activerehashing,允许服务器在周期函数中辅助进行渐进式rehash,该参数默认值是1

/* Our hash table implementation performs rehashing incrementally while* we write/read from the hash table. Still if the server is idle, the hash* table will use two tables for a long time. So we try to use 1 millisecond* of CPU time at every call of this function to perform some rehahsing.** The function returns 1 if some rehashing was performed, otherwise 0* is returned. */
int incrementallyRehash(int dbid) {/* Keys dictionary */if (dictIsRehashing(server.db[dbid].dict)) {dictRehashMilliseconds(server.db[dbid].dict,1);return 1; /* already used our millisecond for this loop... */}/* Expires */if (dictIsRehashing(server.db[dbid].expires)) {dictRehashMilliseconds(server.db[dbid].expires,1);return 1; /* already used our millisecond for this loop... */}return 0;
}/* Rehash for an amount of time between ms milliseconds and ms+1 milliseconds */
int dictRehashMilliseconds(dict *d, int ms) {long long start = timeInMilliseconds();int rehashes = 0;while(dictRehash(d,100)) {rehashes += 100;if (timeInMilliseconds()-start > ms) break;}return rehashes;
}

渐进式rehash实现

从上面可以看到,不管是在操作中辅助rehash执行,还是在周期函数中辅助执行,最终都是调用dictRehash函数

/* Performs N steps of incremental rehashing. Returns 1 if there are still* keys to move from the old to the new hash table, otherwise 0 is returned.** Note that a rehashing step consists in moving a bucket (that may have more* than one key as we use chaining) from the old to the new hash table, however* since part of the hash table may be composed of empty spaces, it is not* guaranteed that this function will rehash even a single bucket, since it* will visit at max N*10 empty buckets in total, otherwise the amount of* work it does would be unbound and the function may block for a long time. */
int dictRehash(dict *d, int n) {int empty_visits = n*10; /* Max number of empty buckets to visit. */if (!dictIsRehashing(d)) return 0;while(n-- && d->ht[0].used != 0) {dictEntry *de, *nextde;/* Note that rehashidx can't overflow as we are sure there are more* elements because ht[0].used != 0 */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++;}/* Check if we already rehashed the whole table... */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;}/* More to rehash... */return 1;
}

渐进式rehash小结

在redis中,扩展或收缩哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面, 但是, 这个 rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的。为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0] 里面的所有键值对全部 rehash 到 ht[1] , 而是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1] 。

以下是哈希表渐进式 rehash 的详细步骤:

  1. 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
  2. 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
  3. 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
  4. 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。

渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。

渐进式 rehash 执行期间的哈希表操作

因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0] 和 ht[1] 两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。

另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。

渐进式rehash带来的问题

渐进式rehash避免了redis阻塞,可以说非常完美,但是由于在rehash时,需要分配一个新的hash表,在rehash期间,同时有两个hash表在使用,会使得redis内存使用量瞬间突增,在Redis 满容状态下由于Rehash会导致大量Key驱逐。

这篇关于Redis的rehash机制的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1148281

相关文章

JVM 的类初始化机制

前言 当你在 Java 程序中new对象时,有没有考虑过 JVM 是如何把静态的字节码(byte code)转化为运行时对象的呢,这个问题看似简单,但清楚的同学相信也不会太多,这篇文章首先介绍 JVM 类初始化的机制,然后给出几个易出错的实例来分析,帮助大家更好理解这个知识点。 JVM 将字节码转化为运行时对象分为三个阶段,分别是:loading 、Linking、initialization

零基础学习Redis(10) -- zset类型命令使用

zset是有序集合,内部除了存储元素外,还会存储一个score,存储在zset中的元素会按照score的大小升序排列,不同元素的score可以重复,score相同的元素会按照元素的字典序排列。 1. zset常用命令 1.1 zadd  zadd key [NX | XX] [GT | LT]   [CH] [INCR] score member [score member ...]

Java ArrayList扩容机制 (源码解读)

结论:初始长度为10,若所需长度小于1.5倍原长度,则按照1.5倍扩容。若不够用则按照所需长度扩容。 一. 明确类内部重要变量含义         1:数组默认长度         2:这是一个共享的空数组实例,用于明确创建长度为0时的ArrayList ,比如通过 new ArrayList<>(0),ArrayList 内部的数组 elementData 会指向这个 EMPTY_EL

【编程底层思考】垃圾收集机制,GC算法,垃圾收集器类型概述

Java的垃圾收集(Garbage Collection,GC)机制是Java语言的一大特色,它负责自动管理内存的回收,释放不再使用的对象所占用的内存。以下是对Java垃圾收集机制的详细介绍: 一、垃圾收集机制概述: 对象存活判断:垃圾收集器定期检查堆内存中的对象,判断哪些对象是“垃圾”,即不再被任何引用链直接或间接引用的对象。内存回收:将判断为垃圾的对象占用的内存进行回收,以便重新使用。

【Tools】大模型中的自注意力机制

摇来摇去摇碎点点的金黄 伸手牵来一片梦的霞光 南方的小巷推开多情的门窗 年轻和我们歌唱 摇来摇去摇着温柔的阳光 轻轻托起一件梦的衣裳 古老的都市每天都改变模样                      🎵 方芳《摇太阳》 自注意力机制(Self-Attention)是一种在Transformer等大模型中经常使用的注意力机制。该机制通过对输入序列中的每个元素计算与其他元素之间的相似性,

如何通俗理解注意力机制?

1、注意力机制(Attention Mechanism)是机器学习和深度学习中一种模拟人类注意力的方法,用于提高模型在处理大量信息时的效率和效果。通俗地理解,它就像是在一堆信息中找到最重要的部分,把注意力集中在这些关键点上,从而更好地完成任务。以下是几个简单的比喻来帮助理解注意力机制: 2、寻找重点:想象一下,你在阅读一篇文章的时候,有些段落特别重要,你会特别注意这些段落,反复阅读,而对其他部分

【Tools】大模型中的注意力机制

摇来摇去摇碎点点的金黄 伸手牵来一片梦的霞光 南方的小巷推开多情的门窗 年轻和我们歌唱 摇来摇去摇着温柔的阳光 轻轻托起一件梦的衣裳 古老的都市每天都改变模样                      🎵 方芳《摇太阳》 在大模型中,注意力机制是一种重要的技术,它被广泛应用于自然语言处理领域,特别是在机器翻译和语言模型中。 注意力机制的基本思想是通过计算输入序列中各个位置的权重,以确

Redis中使用布隆过滤器解决缓存穿透问题

一、缓存穿透(失效)问题 缓存穿透是指查询一个一定不存在的数据,由于缓存中没有命中,会去数据库中查询,而数据库中也没有该数据,并且每次查询都不会命中缓存,从而每次请求都直接打到了数据库上,这会给数据库带来巨大压力。 二、布隆过滤器原理 布隆过滤器(Bloom Filter)是一种空间效率很高的随机数据结构,它利用多个不同的哈希函数将一个元素映射到一个位数组中的多个位置,并将这些位置的值置

FreeRTOS内部机制学习03(事件组内部机制)

文章目录 事件组使用的场景事件组的核心以及Set事件API做的事情事件组的特殊之处事件组为什么不关闭中断xEventGroupSetBitsFromISR内部是怎么做的? 事件组使用的场景 学校组织秋游,组长在等待: 张三:我到了 李四:我到了 王五:我到了 组长说:好,大家都到齐了,出发! 秋游回来第二天就要提交一篇心得报告,组长在焦急等待:张三、李四、王五谁先写好就交谁的

UVM:callback机制的意义和用法

1. 作用         Callback机制在UVM验证平台,最大用处就是为了提高验证平台的可重用性。在不创建复杂的OOP层次结构前提下,针对组件中的某些行为,在其之前后之后,内置一些函数,增加或者修改UVM组件的操作,增加新的功能,从而实现一个环境多个用例。此外还可以通过Callback机制构建异常的测试用例。 2. 使用步骤         (1)在UVM组件中内嵌callback函