redis底层数据结构
Redis底层数据结构——SDS
Redis底层数据结构——IntSet
文章目录
一、Dict介绍
redis 中的 dict 是一个哈希表,用来存储键值类型的数据。redis的数据类型中用到dict的有:
- Set:key用来存储元素,value均为null。
- Zset
- Hash:默认采用跳表,当跳表中的元素数量超过hash-max-ziplist-entries(512) 且 任意entry大小超过了hash-max-ziplist-value(64) 时,采用dict。可通过
config get hash-max-ziplist-entries
和config get hash-max-ziplist-value
来查看这两个值。
二、Dict源码分析
1. dict结构体
// dict.h
struct dict {
// dictType定义了字典操作类型,包括键值的复制、释放、比较和哈希函数
dictType *type;
// hash表,为了在rehashing过程中同时使用旧表和新表
dictEntry **ht_table[2];
/* 记录两个哈希表中已使用的桶数量,也就是键值对数量
* ht_used[0]表示主哈希表中存储的键值对数量
* ht_used[1]表示rehash过程中临时哈希表中存储的键值对数量 */
unsigned long ht_used[2];
/* rehashing进度的索引
* 如果rehashidx等于-1,表示没有进行rehash
* 如果rehashidx大于等于0,表示正在rehash,且该索引前面的键值对都已经迁移到新的哈希表*/
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
/* Keep small vars at end for optimal (minimal) struct padding */
unsigned pauserehash : 15; /* If >0 rehashing is paused */
// 标记是否使用存储的键
unsigned useStoredKeyApi : 1; /* See comment of storedHashFunction above */
// 有符号数组,表示ht_table的大小指数,ht_table的实际大小是1 << ht_size_exp,也就是预分配的键值对数量
signed char ht_size_exp[2]; /* exponent of size. (size = 1<<exp) */
// 用于控制是否暂停自动调整大小,如果大于0,则自动调整大小被暂停
int16_t pauseAutoResize; /* If >0 automatic resizing is disallowed (<0 indicates coding error) */
void *metadata[];
};
这里还是得提一下ht_used和ht_size_exp,redis每次给dict分配的键值对数量都是2的指数幂,最少为4个,这个指数幂就是ht_size_exp。ht_used是dict实际使用的键值对数量。
2. dictType结构体
// dict.h
typedef struct dictEntry dictEntry; /* opaque */
typedef struct dict dict;
typedef struct dictType {
/* Callbacks */
uint64_t (*hashFunction)(const void *key); // 哈希函数,用于生成键的哈希值。
void *(*keyDup)(dict *d, const void *key); // 复制键的回调函数。当需要深拷贝键时使用,返回新分配的键的副本。
void *(*valDup)(dict *d, const void *obj); // 复制值的回调函数,与 keyDup 类似,用于深拷贝值。
int (*keyCompare)(dict *d, const void *key1, const void *key2);// 键比较函数,用于判断两个键是否相等。
void (*keyDestructor)(dict *d, void *key); // 键销毁函数,在字典删除键时调用,用于释放键占用的内存或执行相关清理操作。
void (*valDestructor)(dict *d, void *obj); // 值销毁函数,与 keyDestructor 类似,用于清理值。
int (*resizeAllowed)(size_t moreMem, double usedRatio); // 判断是否允许调整字典大小(扩容或缩容)。根据字典大小和负载因子动态调整。
/* Invoked at the start of dict initialization/rehashing (old and new ht are already created) */
void (*rehashingStarted)(dict *d); // 在初始化或 rehashing 开始时调用的回调函数,用于执行一些额外操作。
/* Invoked at the end of dict initialization/rehashing of all the entries from old to new ht. Both ht still exists
* and are cleaned up after this callback. */
void (*rehashingCompleted)(dict *d); // rehashing 完成时调用的回调函数。
/* Allow a dict to carry extra caller-defined metadata. The
* extra memory is initialized to 0 when a dict is allocated. */
size_t (*dictMetadataBytes)(dict *d); // 返回字典元数据所需的字节数,用于动态分配额外内存以存储元信息。
/* Data */
void *userdata;
/* Flags */
/* 如果设置了“no_value”标志,则表示不使用值,即字典是一个Set。
* 当设置此标志时,无法访问字典项的值,也无法使用“dictSetKey()”函数。
* Entry元数据也不能使用。*/
unsigned int no_value:1;
/* 如果 no_value 等于 1 并且所有键的低位位(LSB)等于 1,将 keys_are_odd 设置为 1 可以启用另一个优化:
* 存储一个没有分配 dictEntry 的键 */
unsigned int keys_are_odd:1;
/* TODO: Add a 'keys_are_even' flag and use a similar optimization if that
* flag is set. */
uint64_t (*storedHashFunction)(const void *key); // 为存储的键提供专门的哈希函数。用于某些场景下,存储键和查找键的类型不同的情况(例如存储结构体、查找字符串)。
int (*storedKeyCompare)(dict *d, const void *key1, const void *key2); // 为存储的键提供专门的比较函数,用于与 storedHashFunction 类似的场景。
/* Optional callback called when the dict is destroyed. */
void (*onDictRelease)(dict *d); // 字典销毁时调用的回调函数,用于释放资源或执行清理任务。
} dictType;
dicttype主要有以下作用:
- 灵活操作键值:可以通过自定义哈希函数、比较函数和复制/销毁函数,使字典支持任意类型的键值对。
- 集合支持(no_value 标志):当字典仅存储键(例如Set)时,使用 no_value 标志禁用值相关的操作,从而节省内存和提升效率。
- 存储键和查找键分离:storedHashFunction 和 storedKeyCompare 提供了存储键和查找键分离的机制。适用于复杂场景,例如存储结构体键但用字符串查找。
- 动态扩展:通过 dictMetadataBytes 支持元数据扩展。可通过用户数据字段(userdata)添加额外功能。
3. dictEntry结构体
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. */
};
dict中维护了两个哈希表ht_table,这个ht_table是个二维数组,其中存储的每个元素都是dictEntry指针。
从dictEntry中的union可知,redis的哈希表的value类型可以是任意类型指针、无符号整数、有符号整数以及双精度小数。如果要存放字符串,这里的指针类型就为sds *。
next指向下一个dictEntry结构体,用来处理哈希冲突。
整个dict的内存布局如下:
4. dict扩容
当dict中元素数量满足以下两种情况时,dict发生扩容:
- 如果dict_can_resize被设置为DICT_RESIZE_ENABLE,也就是可以调整哈希表大小,且哈希表中的已存储的键值对数量 超过了 哈希表预分配的数量,则扩容;
- 如果dict_can_resize没有被设置为DICT_RESIZE_FORBID,也就是可以调整哈希表大小或避免调整哈希表大小(DICT_RESIZE_FORBID) 且 哈希表中的已存储的键值对数量 超过了 哈希表预分配的数量的四倍,则扩容。PS:这种情况一般发生在需要一次性插入大量的键值对时。
// dict.c
int dictExpandIfNeeded(dict *d) {
/* Incremental rehashing already in progress. Return. */
// 如果正在执行rehash,则直接返回
if (dictIsRehashing(d)) return DICT_OK;
/* If the hash table is empty expand it to the initial size. */
// 如果哈希表的大小为0,则将哈希表扩展到初始大小:4
if (DICTHT_SIZE(d->ht_size_exp[0]) == 0) {
dictExpand(d, DICT_HT_INITIAL_SIZE);
return DICT_OK;
}
/* 以下两种情况会进行扩容:
* 1. 字典可以resize标记被置为DICT_RESIZE_ENABLE 且 该dict已存储的键值对数量超过已分配的键值对数量
* 2. 字典可以resize标记没有被置为DICT_RESIZE_FORBID,也就是处于DICT_RESIZE_AVOID 或 DICT_RESIZE_ENABLE 且
* 该dict已存储的键值对数量 超过了 4倍的已分配的键值对数量
* */
if ((dict_can_resize == DICT_RESIZE_ENABLE &&
d->ht_used[0] >= DICTHT_SIZE(d->ht_size_exp[0])) ||
(dict_can_resize != DICT_RESIZE_FORBID &&
d->ht_used[0] >= dict_force_resize_ratio * DICTHT_SIZE(d->ht_size_exp[0])))
{
//每新增一个键值对,就需要判断一下是否需要重新分配内存大小
if (dictTypeResizeAllowed(d, d->ht_used[0] + 1))
dictExpand(d, d->ht_used[0] + 1);
return DICT_OK;
}
return DICT_ERR;
}
关于dict_can_resize的解释如下,英语好的可自行翻译:
/* Using dictSetResizeEnabled() we make possible to disable
* resizing and rehashing of the hash table as needed. This is very important
* for Redis, as we use copy-on-write and don't want to move too much memory
* around when there is a child performing saving operations.
*
* Note that even when dict_can_resize is set to DICT_RESIZE_AVOID, not all
* resizes are prevented:
* - A hash table is still allowed to expand if the ratio between the number
* of elements and the buckets >= dict_force_resize_ratio.
* - A hash table is still allowed to shrink if the ratio between the number
* of elements and the buckets <= 1 / (HASHTABLE_MIN_FILL * dict_force_resize_ratio). */
static dictResizeEnable dict_can_resize = DICT_RESIZE_ENABLE;
static unsigned int dict_force_resize_ratio = 4;
为什么需要控制哈希表的 resize?
- 性能影响:哈希表的扩容或缩容涉及重新分配内存和迁移现有的键值对(rehashing)。这会对性能产生显著影响。
特别是在执行 保存(saving)操作 时(如 RDB 快照或 AOF 持久化),Redis 使用 写时复制(Copy-On-Write, COW),数据迁移会增加内存页面的写操作,显著增加内存消耗。 - 行为控制:为了减少在这些情况下对性能和内存的影响,redis 允许通过 dictSetResizeEnabled() 接口动态控制哈希表的 resize 行为。
dict_can_resize 是一个全局变量,控制哈希表是否允许 resize,它有以下几种可能的值:
- DICT_RESIZE_FORBID(完全禁止)。
- DICT_RESIZE_AVOID(尽量避免)。默认情况下,尽量避免扩展和缩减,但在以下条件下仍允许:
- 扩展:当元素数与桶数的比例(load factor)大于等于 dict_force_resize_ratio(默认值为 4)。这里的桶数就是键值对的数量。
- 缩减:当 load factor 小于等于 1 / (HASHTABLE_MIN_FILL * dict_force_resize_ratio)(默认值为 1 / 32,因为 HASHTABLE_MIN_FILL = 8)。也就是哈希表中实际使用的键值对数量连预分配的数量的32分之一都不到。
- DICT_RESIZE_ENABLE(允许调整大小).
5. dict缩容
与dict扩容类似,当dict中元素数量满足以下两种情况时,dict发生缩容:
- 如果dict_can_resize被设置为DICT_RESIZE_ENABLE,也就是可以调整哈希表大小,且哈希表中的已存储的键值对数量 不超过 哈希表预分配的数量的八分之一,则缩容;
- 如果dict_can_resize没有被设置为DICT_RESIZE_FORBID,也就是可以调整哈希表大小或避免调整哈希表大小(DICT_RESIZE_FORBID) 且 哈希表中的已存储的键值对数量 不超过 哈希表预分配的数量的三十二分之一,则缩容。
// dict.h
int dictShrinkIfNeeded(dict *d) {
/* Incremental rehashing already in progress. Return. */
if (dictIsRehashing(d)) return DICT_OK;
/* If the size of hash table is DICT_HT_INITIAL_SIZE, don't shrink it. */
if (DICTHT_SIZE(d->ht_size_exp[0]) <= DICT_HT_INITIAL_SIZE) return DICT_OK;
/* If we reached below 1:8 elements/buckets ratio, and we are allowed to resize
* the hash table (global setting) or we should avoid it but the ratio is below 1:32,
* we'll trigger a resize of the hash table. */
/* 以下两种情况会进行缩容:
* 1. 字典可以resize标记被置为DICT_RESIZE_ENABLE 且 该dict已存储的键值对数量小于已分配的键值对数量的8分之一
* 2. 字典可以resize标记没有被置为DICT_RESIZE_FORBID,也就是处于DICT_RESIZE_AVOID 或 DICT_RESIZE_ENABLE 且
* 该dict已存储的键值对数量 小于 已分配的键值对数量的32分之一。*/
if ((dict_can_resize == DICT_RESIZE_ENABLE &&
d->ht_used[0] * HASHTABLE_MIN_FILL <= DICTHT_SIZE(d->ht_size_exp[0])) ||
(dict_can_resize != DICT_RESIZE_FORBID &&
d->ht_used[0] * HASHTABLE_MIN_FILL * dict_force_resize_ratio <= DICTHT_SIZE(d->ht_size_exp[0])))
{
if (dictTypeResizeAllowed(d, d->ht_used[0]))
dictShrink(d, d->ht_used[0]);
return DICT_OK;
}
return DICT_ERR;
}
6. dict调整大小
功能:不管是扩容还是缩容,dict用到的调整大小的函数都是_dictResize,该函数实现方式是创建一个新的哈希表,并将旧的哈希表中的所有元素重新哈希到新表中,以适应字典大小的变化 。
参数:
- d:dict
- size:如果是扩容,size等于dict已存储的键值对数量 + 1;如果是缩容,size就等于dict已存储的键值对大小。
- malloc_failed:标记是否由于内存分配失败导致 resize 无法完成。
int _dictResize(dict *d, unsigned long size, int* malloc_failed)
{
if (malloc_failed) *malloc_failed = 0;
/* We can't rehash twice if rehashing is ongoing. */
assert(!dictIsRehashing(d));
/* the new hash table */
dictEntry **new_ht_table;
unsigned long new_ht_used;
// 找到第一个大于等于size的2^n,也就是新的哈希表大小指数。举例说明:
// 如果当前哈希表存储了12个键值对,那么第一个大于等于size的2的幂就是16,因此new_ht_size_exp = 4
signed char new_ht_size_exp = _dictNextExp(size);
/* Detect overflows */
// 哈希表的新大小
size_t newsize = DICTHT_SIZE(new_ht_size_exp);
/* 如果新的哈希表大小 < 原来哈希表的大小 或 扩容后的哈希表大小超过了size_t类型能够表示的最大值,则调整大小失败。
* 解释:当newsize * sizeof(dictEntry*)超过size_t类型能表示的最大值时,会发生溢出,由于size_t是无符号的,
* 溢出会导致导致结果绕回到size_t能表示的最小值,这样乘积就小于newsize了*/
if (newsize < size || newsize * sizeof(dictEntry*) < newsize)
return DICT_ERR;
/* Rehashing to the same table size is not useful. */
// 如果新旧哈希表的大小相同,则不需要调整大小
if (new_ht_size_exp == d->ht_size_exp[0]) return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
if (malloc_failed) {
new_ht_table = ztrycalloc(newsize*sizeof(dictEntry*));
*malloc_failed = new_ht_table == NULL;
if (*malloc_failed)
return DICT_ERR;
} else
//给新哈希表分配内存
new_ht_table = zcalloc(newsize*sizeof(dictEntry*));
// 初始化新的哈希表
new_ht_used = 0;
/* Prepare a second hash table for incremental rehashing.
* We do this even for the first initialization, so that we can trigger the
* rehashingStarted more conveniently, we will clean it up right after. */
d->ht_size_exp[1] = new_ht_size_exp;
d->ht_used[1] = new_ht_used;
d->ht_table[1] = new_ht_table;
d->rehashidx = 0;
if (d->type->rehashingStarted) d->type->rehashingStarted(d);
/* Is this the first initialization or is the first hash table empty? If so
* it's not really a rehashing, we can just set the first hash table so that
* it can accept keys. */
/* 判断是否是首次初始化(ht_table[0] == NULL)或当前第一个哈希表为空(ht_used[0] == 0)。
* 如果是首次初始化或当前哈希表为空,则直接设置哈希表的第一个表(ht_table[0]),而无需进行rehash*/
if (d->ht_table[0] == NULL || d->ht_used[0] == 0) {
if (d->type->rehashingCompleted) d->type->rehashingCompleted(d);
if (d->ht_table[0]) zfree(d->ht_table[0]);
d->ht_size_exp[0] = new_ht_size_exp;
d->ht_used[0] = new_ht_used;
d->ht_table[0] = new_ht_table;
_dictReset(d, 1);
d->rehashidx = -1;
return DICT_OK;
}
return DICT_OK;
}
7. rehash
哈希表的大小通常是 2 的幂,因此,redis在计算桶的索引时,使用dictht_size_mask来限制哈希值的范围。通过与dictht_size_mask进行位与(&)运算,redis能确保桶索引始终处于合法的范围内,即小于哈希表的大小。
当dict发生扩容和缩容时,ht_table的大小会发生改变,而dictht_size_mask的值等于size - 1,此时key的哈希值会失效,因此需要进行再哈希(rehash)操作。
其步骤为:
- 计算新hash表的realsize,值取决于当前要做的是扩容还是缩容:
- 如果是扩容,则新size为第一个大于等于dict->ht_used[0]+1的2^n
- 如果是缩容,则新size为第一个大于等于dict->ht_used[0]的2^n,且不得小于4
- 按照新的realSize申请内存空间,创建ht_table,并赋值给dict->ht_table[1]
- 设置dict->rehashidx=0,标示开始rehash
- 将dict->ht_table[0]中的每一个dictEntry都rehash到dict.ht_table[1]
- 将dict.ht_table[1]赋值给dict.ht[0],给dict.ht_table[1]初始化为空哈希表,释放原来的dict.ht_table[0]的内存
当dict中有数百万的entry时,如果一次性完成上面的操作,极有可能导致主线程阻塞。因此dict的rehash是分多次、渐进式地完成。
渐进式 rehash:
从上面第3步开始,实际上做的是:
- 每次执行增、删、改、查时,都检查一下dict.rehashidx是否大于-1,如果是,则将d->ht_table[0][d->rehashidx]位置处的entry链表rehash到dict.ht[1],然后将rehashidx++,直到dict.ht_table[0]的所有数据都rehash到dict.ht_table[1]。
- 将dict.ht_table[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
- 将rehashidx赋值为-1,代表rehash结束
- 在rehash的过程中,新增操作,直接将数据写入到ht_table[1],查、删、改则会在dict.ht_table[0]和dict.ht_table[1]依次查找并执行。这样可以确保ht_table[0]的数据只减不增,随着rehash最终为空。
// dict.c
// n:每次要迁移的键值对个数
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
unsigned long s0 = DICTHT_SIZE(d->ht_size_exp[0]);
unsigned long s1 = DICTHT_SIZE(d->ht_size_exp[1]);
if (dict_can_resize == DICT_RESIZE_FORBID || !dictIsRehashing(d)) return 0;
/* If dict_can_resize is DICT_RESIZE_AVOID, we want to avoid rehashing.
* - If expanding, the threshold is dict_force_resize_ratio which is 4.
* - If shrinking, the threshold is 1 / (HASHTABLE_MIN_FILL * dict_force_resize_ratio) which is 1/32. */
if (dict_can_resize == DICT_RESIZE_AVOID &&
((s1 > s0 && s1 < dict_force_resize_ratio * s0) || //对应扩容情况
(s1 < s0 && s0 < HASHTABLE_MIN_FILL * dict_force_resize_ratio * s1))) //对应缩容情况
{
return 0;
}
while(n-- && d->ht_used[0] != 0) {
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(DICTHT_SIZE(d->ht_size_exp[0]) > (unsigned long)d->rehashidx);
while(d->ht_table[0][d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
/* Move all the keys in this bucket from the old to the new hash HT */
rehashEntriesInBucketAtIndex(d, d->rehashidx);
d->rehashidx++;
}
return !dictCheckRehashingCompleted(d);
}
8. 时间复杂度
增:平均时间复杂度为O(1)。考虑最坏情况,也就是发生扩容,此时需要将原来的数据从ht_table[0]拷贝到ht_table[1],这个过程的时间复杂度为O(N)。
删:平均时间复杂度为O(1)。考虑最坏情况,哈希表中存放的键全部发生了哈希冲突,此时哈希表退化至链表,时间复杂度为O(N)。
改:平均时间复杂度为O(1)。考虑最坏情况,哈希表中存放的键全部发生了哈希冲突,此时哈希表退化至链表,时间复杂度为O(N)。
查:平均时间复杂度为O(1)。考虑最坏情况,哈希表中存放的键全部发生了哈希冲突,此时哈希表退化至链表,时间复杂度为O(N)。
三、一些函数和自定义宏
1. 自定义宏
// dict.h
#define DICTHT_SIZE(exp) ((exp) == -1 ? 0 : (unsigned long)1<<(exp)) // 计算dict键值对数量,2^exp个
#define DICTHT_SIZE_MASK(exp) ((exp) == -1 ? 0 : (DICTHT_SIZE(exp))-1) //掩码,值为dict键值对数量-1