Redis 源码学习记录:散列 (dict)

散列

Redis 源码版本:Redis-6.0.9,本篇文章的代码均在 dict.h / dict.c 文件中。

散列类型可以存储一组无需的键值对,他特别适用于存储一个对象数据。

字典

  • Redis 通常使用字典结构体存储用户散列数据。
  • 字典是 Redis 的重要数据结构。除了散列类型,Redis 数据库也使用了字典结构。
  • Redis 使用 Hash 表实现字典结构。分析 Hash 表,我们需要重点关注下面的问题:
    1. 使用什么 Hash 算法。
    2. Hash 冲突如何解决。
    3. Hash 表如何扩容。

定义

字典中键值对的定义如下:

typedef struct dictEntry {
    void *key; // 键
    union
    {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;                    // 值
    struct dictEntry *next; // 下一个键值对的指针
} dictEntry;

C 语言 union 关键字用于声明联合体,联合体的所有属性共用同一空间,同一时间只能存储其中一个属性值。也就是说,dictEntry.v 可以存放 valu64s64d 中的一个属性值。使用 sizeof 计算联合体的大小,结果不会小于联合体中的最大成员属性大小。


字典中哈希表的定义如下:

typedef struct dictht {
    dictEntry **table;      // 哈希表数组,负责存储哈希表中的数据。table 是一个指针数组,每个元素是一个指向 dictEntry 的指针
    unsigned long size;     // Hash 表数组长度
    unsigned long sizemask; // 哈希表大小的掩码值,通常为 size - 1,用于计算哈希值在哈希表中的索引位置。通过 sizemask 与哈希值进行按位与操作,可以快速计算出索引位置:index = hash & sizemask。这种计算方式比直接取模运算效率更高。
    unsigned long used; // 记录存储键值对的数量,在判断 Hash 表的扩容缩容时有很大作用
} dictht;

dictht 的结构如下图所示:

img


字典的定义如下:

typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);
    void *(*keyDup)(void *privdata, const void *key);
    void *(*valDup)(void *privdata, const void *obj);
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    void (*keyDestructor)(void *privdata, void *key);
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

typedef struct dict {
    dictType *type; // 指定操作数据的函数指针
    void *privdata; // 用户自定义数据指针。作用:传递给 dictType 中的函数,便于实现定制化操作。通常用于在操作过程中传递额外的上下文信息。
    dictht ht[2]; // 两个 dictht 结构体数组,分别表示哈希表的当前表和临时表。作用:在进行 rehash 操作(哈希表扩展或收缩)时,ht[0] 表示原始表,ht[1] 表示临时表。rehash 完成后,临时表将替代原始表。
    long rehashidx; // rehash 进度索引。作用:如果 rehash 正在进行,该值表示当前正在 rehash 的索引位置。如果值为 -1,则表示没有进行 rehash 操作。
    unsigned long iterators; // 当前正在运行的迭代器数量。作用:记录当前有多少个迭代器正在遍历该哈希表,以确保在 rehash 或修改哈希表时能够安全地处理迭代器。
} dict;

通过 dictType 指定操作数据的函数指针,字典就可以存放不同类型的数据啦。但在一个字典中,键和值之间可以是不同的类型,但键必须类型相同,值也必须类型相同。

Redis 为不同的字典定义了不同的 dictType,如数据库使用的 server.c / dbDictType,散列类型使用的 server.c/setDictType 等。

dictAddRaw

  • 函数功能:向哈希表中添加一个 key。也可以在哈希表中查找一个 key

  • 参数:

    • dict *d:哈希表所在的字典。
    • void *key:需要添加的 key
    • dictEntry **existing:输出型参数,如果参数 key 在哈希表中已经存在,则会添加失败。该参数可以将这个已经存在的 key 所属的 dictEntry 返回。
  • 返回值:

    • NULL:哈希表中已经存在该 key,添加失败。
    • NULL:添加成功,返回该 key 所在的 dictEntry 的首地址。
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;

    // [1](见注解1)
    if (dictIsRehashing(d)) _dictRehashStep(d); // 如果处于 rehash 状态,尝试进行一次单步 rehash

    /* Get the index of the new element, or -1 if
     * the element already exists. */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL; // 如果待插入的 key 在哈希表中已经存在,则直接返回 NULL

    // 走到这里说明待插入的 key 在哈希表中不存在,并且通过上一个代码块的 if 语句,我们已经计算出了该 key 应当插入到哈希表的哪个位置
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; // 根据哈希表是否处于 rehash 状态确定新节点应该插入到哪一个 Hash 表。如果处于 rehash 状态,肯定是插入到 d->ht[1] 因为该状态下 d->ht[0] 中的数据要全部迁移到 d->ht[1] 嘛。如果还往 d->ht[0] 中插入,是在不知道你怎么想的
    entry = zmalloc(sizeof(*entry)); // 开辟一个 dictEntry 节点
    entry->next = ht->table[index]; // 头插节点
    ht->table[index] = entry; // 覆盖哈希表中索引位置的指针
    ht->used++; // 更新哈希表中键值对的数量

    // 将 key 设置到 dictEntry 中
    dictSetKey(d, entry, key);
    return entry;
}
  1. 为什么需要在字典插入函数中进行 rehash 操作呢?
    • 什么是 rehash?在 Redis 中,rehash 是将数据从旧的哈希表迁移到新的哈希表的过程。由于哈希表的大小是动态调整的,特别是在插入和删除元素时,哈希表可能会增长或缩小。当哈希表增长或缩小时,所有的元素需要从旧的哈希表重新哈希并搬移到新的哈希表中。这就是 rehash 过程。
    • 因为 Redis 是单线程的,如果在一个操作内将原哈希表中的数据 全部都迁移到新的哈希表,那么就可能引起线程长期阻塞。rehash 过程可能会非常耗时,尤其是当哈希表中有大量元素的时候。为了避免在单次操作中耗费过多时间,Redis 采取了增量 rehash (incremental rehashing)的策略,即每次操作时,只执行 rehash 过程的一小部分。这样做的好处是将 rehash 的开销分摊到多次操作中,从而避免了单次操作耗时过长的问题。
    • 这么做有什么好处?这样做的目的在于将 rehash 操作平滑地分摊到多个操作中,以避免单次操作的卡顿,确保 Redis 的高性能和低延迟。
    • 哪些操作可能会进行一次单步 rehashdictAddRawdictGenericDeletedictFinddictGetRandomKeydictGetSomeKeys 等函数都会调用 dictRehashStep 函数,从而逐步将数据迁移到新的 Hash 表中。

dictIsRehashing

宏功能:判断是否正在进行 rehash 操作。

#define dictIsRehashing(d) ((d)->rehashidx != -1)

_dictRehashStep

  • 函数功能:进行一次单步 rehash

  • 参数:

    • dict* d:需要进行一次单步 rehashdict 首地址。
  • 返回值:无。

static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1); // 只有原哈希表中没有迭代器正在遍历该哈希表,才会进行 rehash 操作。如果有迭代器正在遍历原哈希表,还去进行 rehash 很有可能会导致迭代器失效,引发不可预期的后果。
}

dictRehash

  • 函数功能:进行 n 次单步 rehash 操作。

  • 参数:

    • dict *d:一个 dict 的首地址。
    • n:惊醒多少次单步 rehash 操作。
  • 返回值:

    • 0:当前的 dict 不处于 rehash 状态或者执行完该函数之后,rehash 完毕,退出 rehash 状态。
    • 1:还需要继续进行 rehash 操作。
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; // 从 rehashidx 允许出现的空索引位置的数量
    
    // 如果没有在进行 rehash 操作,直接返回 0 
    if (!dictIsRehashing(d)) return 0;

    // rehash 操作必须确保原哈希表中还有键值对嘛,至于 n 就是进行单步  rehash 的次数嘛
    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++; // 更新 rehashidx,rehashidx 在原 hash 表中的索引加 1 
            if (--empty_visits == 0) return 1; // 出现空索引,empty_visits 需要减一
        }
        de = d->ht[0].table[d->rehashidx]; // 走到这说明找到了原哈希表中的一个非空索引
        
        // 将该索引下的全部节点移到新的哈希表中
        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; // 更新索引位置的 dictEntry 指针
            d->ht[0].used--; // 更新原哈希表中的键值对数量
            d->ht[1].used++; // 更新新哈希表中的键值对数量
            de = nextde; // 继续对下一个节点进行处理
        }
        d->ht[0].table[d->rehashidx] = NULL; // 原哈希表中的一个索引处理完成之后需要置为 NULL
        d->rehashidx++; // 更新原哈希表的 rehashidx 
    }

    /* Check if we already rehashed the whole table... */
    if (d->ht[0].used == 0) { // 如果原哈希表中的键值对数量为 0,说明 rehash 操作已经完成啦
        zfree(d->ht[0].table); // 释放掉原哈希表
        d->ht[0] = d->ht[1]; // 让 ht[0] 指向 rehash 完成的哈希表
        _dictReset(&d->ht[1]); // 重置 ht[1] 的相关结构体成员
        d->rehashidx = -1; // 更新 dict 的 rehashidx
        return 0; // rehash 完毕
    }

    /* More to rehash... */
    return 1;
}

dictHashKey

宏功能:根据键计算其哈希值。

这是调用了 dictType.hashFunction 函数计算键的 Hash 值。

#define dictHashKey(d, key) (d)->type->hashFunction(key)

Redis 中字典基本都使用 SipHash 算法。该算法能有效防止 Hash 碰撞,并提供不错的性能。

Redis 4.0 之前使用的 Hash 算法是 MurmurHash。即使输入的键是有规律的,该算法计算的结果依然有很好的离散性,并且计算速度非常快。Redis 4.0 开始更换为 SipHash 算法,应该是出于安全的考虑。

_dictReset

  • 函数功能:重置一个 dictht 的成员。
  • 参数:
    • dictht *ht:需要重置的 dictht 的首地址。
  • 返回值:无。
static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

_dictKeyIndex

  • 函数功能:计算一个键在哈希表中的索引。
  • 参数:
    • dict *d:哈希表所在的 dict 首地址。
    • const void *key:需要查找的键。
    • uint64_t hashkey 经过哈希算法得到的哈希值。
    • dictEntry **existing:输出型参数,如果查找的键在哈希表中已经存在,则通过该参数将这个键对应的 dictEntry 返回。
  • 返回值:
    • -1:表示参数 key 在哈希表中已经存在。
    • 其他值:参数 key 在哈希表中的索引。
static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{
    unsigned long idx, table;
    dictEntry *he;
    if (existing) *existing = NULL; // 置为 NULL 防止上一次的结果影响这次函数计算的结果

    // 判断哈希表是否需要进行扩容,如果需要的话,如果扩容失败直接返回 -1, 大概率是不可能在这里进行返回的
    if (_dictExpandIfNeeded(d) == DICT_ERR) 
        return -1;
    
    // 可能需要对两张表进行查找,因为如果原来的哈希表如果正处于 rehash 状态的话,原哈希表的一部分数据是在 新的哈希表中
    for (table = 0; table <= 1; table++) {
        idx = hash & d->ht[table].sizemask; // hash 值在哈希表中的索引
        /* Search if this slot does not already contain the given key */
        he = d->ht[table].table[idx]; // 找到哈希表中该索引下的第一个 dictEntry 节点
        while(he) { // 遍历这个链表 (哈希桶)
            // [1](见注解1)
            if (key==he->key || dictCompareKeys(d, key, he->key)) {
                // 如果参数的 key 已经在哈希表中存在了,通过输出型参数将这个存在的 dictEntry 节点返回出去
                if (existing) *existing = he;
                return -1;
            }
            he = he->next; // 继续比较下一个节点
        }
        if (!dictIsRehashing(d)) break; // 如果当前的哈希表没有处于 rehash 的状态说明另一个哈希表中是没有数据的,直接结束循环就行啦!
    }
    return idx;
}
  1. Redis 中,使用 dict 存储散列元素,键(key)通常是使用 SDS (Simple Dynamic Strings) 来表示的,并且 Redis 在内部维护了一个 redisObject 共享池。redisObject 共享池是一个优化措施,它会缓存一些常用的 redisObject 对象,特别是在一些常见的操作中,如命令解析、客户端传输等。这些常用的 redisObject 对象会被缓存在共享池中,以减少内存分配和提高性能。

    因此,当在 Redis 的字典中进行键的比较时,如果两个键的 SDS 对象的指针相同(即它们指向了共享池中的同一个 SDS 对象),那么这两个键是相同的。在这种情况下,直接使用 == 操作符进行指针比较是一种快速有效的方法,因为它只需比较两个指针是否相等,而不需要比较 SDS 对象的内容。(并没有 SDS 共享池哈,实际上是 redisObject 共享池,但是呢,redisObjectptr 指针可以指向一个 SDS 嘛,因此勉强说有 SDS 共享池也是没有啥大问题的,你觉得呢?)

    这种优化适用于那些频繁使用的 SDS 对象,尤其是在 Redis 内部的核心逻辑中,这些 SDS 对象可能被重复使用多次。通过使用 SDS 共享池和直接指针比较,Redis 可以在某种程度上提高比较操作的效率,并减少不必要的字符串内容比较的开销。

    需要注意的是,这种优化只适用于那些频繁使用的 SDS 对象,对于不在共享池中的 SDS 对象,仍然需要比较其内容来确定是否相等。因此,在字典中比较键时,Redis 会根据具体情况选择合适的比较方式,以提高性能。

    key==he->key 比较失败的时候,就会执行 dictCompareKeys 这个函数,使用 dict.type.keyCompare 进行比较。

    key==he->key 这个判断可以提供一些性能上的优化,因为它是一个非常快速的指针比较操作。而调用

    dictCompareKeys 可能会涉及更复杂的逻辑,具体取决于键的类型和比较函数的实现。因此,保留

    key==he->key 这个判断可以在某些情况下提高性能。

_dictExpandIfNeeded

  • 函数功能:判断指定的 dict 中的哈希表是否需要进行 resize。如果需要 resize直接进行 resize哈!
  • 参数:
    • dict *d:指定需要进行判断的 dict
  • 返回值:
    • DICT_ERRresize失败。
    • DICT_OKresize成功。
      • 当前哈希表正在进行 rehash
      • 当前哈希表需要 rehash
#define DICT_OK 0
#define DICT_ERR 1
#define DICT_HT_INITIAL_SIZE     4
static int dict_can_resize = 1;
static unsigned int dict_force_resize_ratio = 5;

static int _dictExpandIfNeeded(dict *d)
{
    // 如果正在进行 rehash 直接返回
    if (dictIsRehashing(d)) return DICT_OK;

    // 如果哈希表是空的,则扩容到哈希表的初始大小
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    // [1](见注解1)
    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;
}
  1. 哈希表的扩容操作需要满足两个条件:

    • d->ht[0].used >= d->ht[0].sizeHash 表存储的键值对数量大于或者等于 Hash 表数组的长度。
    • 开启了 dict_can_resize 或者负载因子大于 dict_force_resize_ratio

    d->ht[0].used/d->ht[0].size,即 Hash 表存储的键值对数量除以 Hash 表数组的长度,称之为负载因子。dict_can_resize 默认开启,即负载因子等于 1 就扩容。负载因子等于 1 可能出现比较高的 Hash 冲突率,但这样可以提高 Hash 表的内存使用率。dict_can_resize 关闭时,必须等到负载因子大于

    dict_force_resize_ratio 时才强制扩容。用户不能通过配置关闭 dict_can_resize

    以下是一些可能会修改 dict_can_resize 的场景:

    1. 持久化操作:在执行 RDB 保存或 AOF 重写等持久化操作期间,可能希望暂时禁用扩容以减少内存使用和避免性能抖动。
    2. 批量数据导入:在大量数据导入期间,可能希望禁用扩容以加快导入速度,然后在导入完成后手动调整字典大小。
    3. 内存限制:在接近内存使用上限时,可能希望禁用扩容以避免超出内存限制。

dictExpand

  • 函数功能:对 dict 中的哈希表进行 resize (说是 resize 的准备也不错的,增量式的 resize 嘛)
  • 参数:
    • dict *d:要进行 resizedict
    • unsigned long sizeresize 之后的大小。这不一定是真的 resize 之后的哈希表大小。因为 Redis 要求哈希表的大小是 2 的整数次幂嘛!
  • 返回值:
    • DICT_ERRresize 失败。
      • 当前 dict 的哈希表正在进行 rehash 操作。
      • 指定的参数 size 太小。
    • DICT_OK:扩容成功。
      • 原哈希表不存在,该函数的功能就可以理解为创建哈希表啦。
      • 原哈希表存在,进行 resize
int dictExpand(dict *d, unsigned long size)
{
    // 如果当前的 dict 正在进行 rehash 操作,或者键值对的数量大于 resize 之后的目标数组大小,直接返回
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    dictht n; // 新的哈希表
    unsigned long realsize = _dictNextPower(size); // 获取哈希表的真实大小,因为参数的 size 并不一定满足 Redis 对哈希表大小的要求,resize 之后数组的大小需要进行手动计算

    // resize 之后的 size 不能与原来哈希表的 size 相等,否则直接返回,因为这并没有什么意义嘛
    if (realsize == d->ht[0].size) return DICT_ERR;

    /* Allocate the new hash table and initialize all pointers to NULL */
    n.size = realsize; // 初始化新的哈希表的 size 字段 (Hash 表数组的长度)
    n.sizemask = realsize-1; // 设置 Hash 表的 sizemask 字段,固定为 size 字段 - 1
    n.table = zcalloc(realsize*sizeof(dictEntry*)); // 开辟哈希表数组,并且初始化为 NULL,calloc 函数的功能嘛
    n.used = 0; // 设置新的哈希表的 used 字段,现在还没有任何键值对嘛,设置为 0 就行啦

    // 如果说原来的哈希表就是 NULL 说明就不是执行扩容操作啦,就是创建一个大小为 realsize 的哈希表啦
    if (d->ht[0].table == NULL) {
        d->ht[0] = n; // 结构体是直接允许赋值的,只是可能会带来效率上的消耗吧,其实效率问题不好说
        return DICT_OK; 
    } 

    // 增量式 resize 要提前准备一个哈希表来接收数据,就是 dictht->ht[1] 嘛
    d->ht[1] = n;
    d->rehashidx = 0; // 数据迁移的起始下标
    return DICT_OK;
}

_dictNextPower

  • 函数功能:获取大于参数 size 的下一个可能的哈希表的大小。
  • 参数:
    • unsigned long size:指定计算的 起点。
  • 返回值:返回计算得到的大于等于 size 的哈希表的 size
#define DICT_HT_INITIAL_SIZE     4

/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
    unsigned long i = DICT_HT_INITIAL_SIZE; // 初始化 i 为哈希表的初始大小

    if (size >= LONG_MAX) return LONG_MAX + 1LU; // 这个 1LU 表示常量 1 他的类型是 unsigned long哈
    while(1) {
        if (i >= size) // 大于等于 size 的时候就返回
            return i;
        // [1](见注解1)
        i *= 2; // Redis 要求哈希表的大小是 2 的整数次幂
    }
}
  1. 为什么要将 size 调整为 2 的整数次幂呢?为了能够使用位运算来计算哈希值在哈希表中的索引。
    • 在哈希表中,通过对键的哈希值进行模运算来确定键在哈希表中的索引位置,而模运算可以转化为位运算,即 hash & (size-1)。这种转化只有在哈希表的大小是 2 的整数次幂的情况下才成立,因为 (size-1) 的二进制表示形式是若干个连续的 1,这样做位与运算时可以更高效地确定哈希值在哈希表中的索引位置。
    • 如果哈希表的大小不是 2 的整数次幂,那么 (size-1) 的二进制表示形式就不再是若干个连续的 1,这样就无法通过位与运算来高效地计算哈希值在哈希表中的索引位置,而需要使用更为复杂的模运算。这会增加计算的时间复杂度,并且降低哈希表的性能。

dictCompareKeys

宏功能:比较哈希表中的两个键是否相等。

// 判断 dict.dictType.keyCompare 这个函数指针有没有初始化,如果初始化了,那么就调用这个函数来比较两个键否之直接使用 == 进行比较
#define dictCompareKeys(d, key1, key2) \
    (((d)->type->keyCompare) ? \
        (d)->type->keyCompare((d)->privdata, key1, key2) : \
        (key1) == (key2))

dictSetKey

宏功能:将 key 设置到指定的 dictEntry 中。

#define dictSetKey(d, entry, _key_) do { \
    if ((d)->type->keyDup) \ // 如果 (d)->type->keyDup 这个函数指针已经初始化了,那么调用该函数进行 key 的复制
        (entry)->key = (d)->type->keyDup((d)->privdata, _key_); \
    else \
        (entry)->key = (_key_); \ // 如果该函数指针没有初始化,那么直接使用 = 进行复制
} while(0)

dictAdd

  • 函数功能:向哈希表中添加一个键值对。
  • 参数:
    • dict *d:哈希表所属的 dict
    • void *key:待插入的键。
    • void *val:待插入的值。
  • 返回值:
    • DICT_ERR:键已存在,添加失败。
    • DICT_OK:添加成功。
int dictAdd(dict *d, void *key, void *val)
{
    dictEntry *entry = dictAddRaw(d,key,NULL); // 先向哈希表中添加一个键,通过接受返回值确定添加的结果

    if (!entry) return DICT_ERR; // entry == NULL 说明这个键在哈希表中已经存在了,添加失败,返回 DICT_ERR
    // 走到这里说明键已经添加成功啦
    dictSetVal(d, entry, val); // 将值设置到 dictEntry 中,添加值
    return DICT_OK;
}

dictSetVal

宏功能:将 val 设置到指定的 dictEntry 中。

#define dictSetVal(d, entry, _val_) do { \
    if ((d)->type->valDup) \ // 如果 (d)->type->valDup 这个函数指针已经初始化了,那么调用该函数进行 val 的复制
        (entry)->v.val = (d)->type->valDup((d)->privdata, _val_); \
    else \
        (entry)->v.val = (_val_); \ // 如果该函数指针没有初始化,那么直接使用 = 进行复制
} while(0)

dictReplace

  • 函数功能:替换或者插入键值对。
  • 参数:
  • 返回值:
int dictReplace(dict *d, void *key, void *val)
{
    dictEntry *entry, *existing, auxentry;

    /* Try to add the element. If the key
     * does not exists dictAdd will succeed. */
    entry = dictAddRaw(d,key,&existing); // 先尝试向哈希表中添加一个键,通过接受返回值确定添加的结果
    if (entry) { // 如果添加成功了
        dictSetVal(d, entry, val); // 直接设置 val 就行啦
        return 1;
    }

    /* Set the new value and free the old one. Note that it is important
     * to do that in this order, as the value may just be exactly the same
     * as the previous one. In this context, think to reference counting,
     * you want to increment (set), and then decrement (free), and not the
     * reverse. */
    // 走到这里说明哈希表中已经存在这个键啦
    auxentry = *existing; // 保存这个存在的键所在的 dictEntry 浅拷贝,保存了旧的 val 地址
    dictSetVal(d, existing, val); // 将 val 设置到这个 dictEntry 中
    dictFreeVal(d, &auxentry); // 释放点旧的 val 空间
    // [1](见注解1)
    return 0;
}
  1. 如果先释放旧值,然后再设置新值,那么有可能在释放旧值的过程中,新值的内存被错误地释放掉,从而导致潜在的内存错误。

    引用计数和内存管理的潜在问题

    Redis 中,值对象(value)通常是通过引用计数进行内存管理的。这意味着每次使用一个对象时,会增加其引用计数,而每次不再使用时,会减少其引用计数。当引用计数降为零时,对象的内存才会被释放。

    错误释放新值的可能性

    1. 旧值和新值是同一个对象
      • 如果 val 是一个引用计数对象,并且此时 existing 指向的旧值恰好与 val 是同一个对象(例如同一个字符串或其他共享对象),先释放旧值会导致其引用计数减少。
      • 如果此时引用计数减少到零,该对象就会被释放。
      • 之后,再尝试设置新值 val 时,由于 val 已经被释放,会导致访问无效内存,进而引发未定义行为或程序崩溃。
    2. 引用计数的安全性
      • 在设置新值之前,如果旧值与新值是同一个对象,立即减少引用计数可能会错误地认为对象不再被使用,从而释放它。
      • 为了避免这种情况,正确的顺序应该是先增加引用计数(通过设置新值),然后再减少引用计数(通过释放旧值)。这样可以确保在整个过程中对象不会被错误地释放。

dictFreeVal

  • 宏功能:释放一个 dictEntryval 的空间。
#define dictFreeVal(d, entry) \
    if ((d)->type->valDestructor) \
        (d)->type->valDestructor((d)->privdata, (entry)->v.val) // 只有初始化了 valDestructor 这个函数指针,才会调用该函数来释放 val 的空间

dictDelete

  • 函数功能:删除哈希表中的一个键值对。
  • 参数:
    • dict *ht:哈希表所属的 dict
    • const void *key:待删除的键。
  • 返回值:
/* Remove an element, returning DICT_OK on success or DICT_ERR if the
 * element was not found. */
int dictDelete(dict *ht, const void *key) {
    return dictGenericDelete(ht,key,0) ? DICT_OK : DICT_ERR; // 根据 dictGenericDelete 函数的返回值,判断 Delete 操作是否成功
}

dictGenericDelete

  • 函数功能:在哈希表中查找并删除一个 dictEntry
  • 参数:
    • dict *d:哈希表所属的 dict
    • const void *key:待删除的键。
    • nofree:在哈希表中存在待删除的键的前提下,决定删除该 dictEntry 之后是否释放其空间。引入 nofree 参数的主要目的是提供灵活性,让调用者能够控制在删除字典节点时是否立即释放节点所占用的内存。这种设计考虑了性能优化、内存管理、并发安全和特定业务逻辑需求等多方面因素。在具体使用时,需要根据实际需求和场景来决定是否使用 nofree 参数。
  • 返回值:
    • NULL:待删除的键在哈希表中不存在,删除失败。
    • NULL:待删除的键在哈希表中存在。无论是否释放这个 dictEntry 返回值都一定不为 NULL
/* Search and remove an element. This is an helper function for
 * dictDelete() and dictUnlink(), please check the top comment
 * of those functions. */
static dictEntry *dictGenericDelete(dict *d, const void *key, int nofree) {
    uint64_t h, idx;
    dictEntry *he, *prevHe;
    int table;

    // 如果两个哈希表中的键值对数量均为 0,那么没有键值对会被删除,直接返回 NULL
    if (d->ht[0].used == 0 && d->ht[1].used == 0) return NULL;

    // 如果当前哈希表处于 rehash 状态,那么执行一次单步的 rehash 操作
    if (dictIsRehashing(d)) _dictRehashStep(d);
    
    // 根据键计算其哈希值
    h = dictHashKey(d, key);

    // 当前的哈希表可能处于 rehash 状态,该状态下键值对数据可能存在与 dict 下的两个哈希表中,因此可能需要遍历两个哈希表
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask; // 计算这个哈希值在哈希表中的索引
        he = d->ht[table].table[idx]; // 找到该索引下的链表(哈希桶)的第一个节点
        prevHe = NULL;
        while(he) { // 遍历这个链表(哈希桶)
            if (key==he->key || dictCompareKeys(d, key, he->key)) { // 如果这个键存在
                /* Unlink the element from the list */
                if (prevHe) // prevHe 不为 NULL 说明待删除的 dictEntry 不是链表(哈希桶)的头结点
                    prevHe->next = he->next; // 直接进行节点的链接就行啦
                else // prevHe 为 NULL 说明待删除的 dictEntry 是链表(哈希桶)的头结点
                    d->ht[table].table[idx] = he->next; // 更新该索引下的头结点地址
                if (!nofree) { // 如果 nofree == 0 即需要释放掉 dictEntry
                    dictFreeKey(d, he); // 释放键
                    dictFreeVal(d, he); // 释放值
                    zfree(he); // 释放 dictEntry
                }
                d->ht[table].used--; // 哈希表中的键值对数量减 1
                // [1](见注解1)
                return he; // 因为哈希表中不可能出先两个相同的键,那么一旦找到了待删除的键值对,删除之后就可以直接返回了
            }
            prevHe = he; // 更新 prevHe
            he = he->next; // 更新 he 
        }
        if (!dictIsRehashing(d)) break; // 如果当前的哈希表不处于 rehash 状态,那么另一个哈希表一定没有数据退出循环
    }
    return NULL; // 返回 NULL 代表删除成功
}
  1. dictGenericDelete 函数中,如果 nofree 为 0,则会释放字典项中的键和值,并且释放 dictEntry 本身的内存。然而,在这种情况下,函数仍然返回已经被释放的 dictEntry 指针。这会导致返回一个无效的指针,这种情况下访问返回的指针将导致未定义行为。

    Redis 中,dictGenericDelete 函数的设计可能有以下几个原因:

    1. 一致的接口:函数总是返回 dictEntry 指针,无论是否释放内存。这使得调用者在处理结果时有一个一致的接口,虽然需要调用者小心处理 nofree 为 0 的情况。

    2. 特定使用场景:在 Redis 的使用场景中,调用 dictGenericDelete 时,如果 nofree 为 0,调用者需要特别注意不要使用返回的指针。这可能是由 Redis 开发者内部约定的,并且通过代码审查和测试来确保正确使用。

    3. 性能考虑:Redis 是一个高性能的内存数据库,设计中非常注重性能。在一些性能关键的代码路径中,避免额外的判断和复杂的逻辑可以提高性能。通过约定来管理内存和指针可能是权衡性能和代码安全性的一种选择。

      dictGenericDelete 函数只是作为 dictDeletedictUnlink 的子函数,只要在这两个函数中正确使用

      dictGenericDelete 函数的返回值,就不会出现任何问题啦!

dictFind

  • 函数功能:查找键值对。
  • 参数:
    • dict* d:哈希表所属的 dict
    • const void *key:查找的键。
  • 返回值:
    • NULL:哈希表中不存在该键。
    • NULL:找到的键所在的 dictEntry
dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    uint64_t h, idx, table;

    if (dictSize(d) == 0) return NULL; // dict 是空的,找啥呢?直接返回
    if (dictIsRehashing(d)) _dictRehashStep(d); // 如果当前的哈希表处于 rehash 状态,那么执行一次单步 rehash 的操作
    h = dictHashKey(d, key); // 键的哈希值
    
    // 遍历两个哈希表
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask; // 在哈希表中的索引值
        he = d->ht[table].table[idx]; // 该索引下的头结点
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key)) // 如果找到了直接返回
                return he; // 返回找到的 dictEntry
            he = he->next; // 下一个节点
        }
        if (!dictIsRehashing(d)) return NULL; // 不处于 rehash 状态直接跳出循环
    }
    return NULL; // 遍历完哈希表都没找到
}

dictSize

  • 宏功能:计算 dict 中哈希表的键值对数量。
#define dictSize(d) ((d)->ht[0].used+(d)->ht[1].used)

dictResize

  • 函数功能:哈希表的缩容。
  • 参数:
    • dict* d:哈希表所属的 dict
  • 返回值:
    • DICT_ERR:缩容失败。
    • DICT_OK:缩容成功。
#define DICT_HT_INITIAL_SIZE     4

int dictResize(dict *d)
{
    unsigned long minimal;

    // dict_can_resize 详见 _dictExpandIfNeeded 函数的注解
    // 如果哈希表不允许 resize 或者 当前哈希表已经在进行 rehash 操作了,直接返回 DICT_ERR
    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    minimal = d->ht[0].used; // 记录 ht[0] 中键值对的数量
    if (minimal < DICT_HT_INITIAL_SIZE) // 缩容之后的最小值不能小于 4 
        minimal = DICT_HT_INITIAL_SIZE;
    return dictExpand(d, minimal); // 尝试缩容哈,目标容量:minimal
}

htNeedsResize

  • 函数功能:判断哈希表是否需要缩容。
  • 参数:
    • dict* d:哈希表所属的 dict
  • 返回值:
    • 1:哈希表需要缩容。
    • 0:哈希表不需要缩容。
#define DICT_HT_INITIAL_SIZE     4
#define HASHTABLE_MIN_FILL        10 

int htNeedsResize(dict *dict) {
    long long size, used;

    size = dictSlots(dict); // dict 的两个哈希表数组的总长度
    used = dictSize(dict); // dict 的两个哈希表中存储的键值对的总数量
    // 这就是需要进行缩容的条件啦,原哈希表的长度至少为 4 并且 负载因子小于 0.1
    // [1](见注解1)
    return (size > DICT_HT_INITIAL_SIZE &&
            (used*100/size < HASHTABLE_MIN_FILL));
}
  1. 执行删除操作后,Redis 会检查字典是否需要缩容,当 Hash 表长度大于 4 且负载因子小于 0.1 时,会执行缩容操作,以节省内存。缩容实际上也是通过 dictExpand 函数完成的,只是函数的第二个参数 size 是缩容后的大小

dictSlots

  • 宏功能:计算 dict 下两个哈希表数组的总长度
#define dictSlots(d) ((d)->ht[0].size+(d)->ht[1].size)

编码

散列类型有 OBJ_ENCODING_HTOBJ_ ENCODING_ ZIPLIST 两种编码,分别使用 dictziplist 结构存储数 (redisObject.ptr 指向 dictziplist 结构)。Redis 会优先使用 ziplist 存储散列元素,使用一个 ziplist节点存储键,后驱节点存放值,查找时需要遍历 ziplist。 使用 dict 存储散列元素,字典的键一般是 sds 类型。

散列类型使用 OBJ_ENCODING_ZIPLIST 编码,需满足以下条件:

  1. 散列中所有键或值的长度小于或等于 server.hash_max_ziplist_value,该值可通过 hash-max-ziplist-value 配置项调整。
  2. 散列中键值对的数量小于 server.hash_max_ziplist_entries,该值可通过 hash-max-ziplist-entries 配置项调整。

img

总结

  • Redis 字典使用 SipHash 算法计算 Hash 值,并使用链表法处理 Hash 冲突。
  • Redis 字典使用增量式扩容方式, 在每次数据操作中都执行一次扩容单步操作,直到扩容完成。
  • 散列类型的编码格式可以为 OBJ_ ENCODING_HTOBJ_ENCODING_ ZIPLIST

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/643641.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

DNS服务的部署与配置(1)

一、DNS的定义 1、域名系统&#xff08;英文&#xff1a;Domain Name System&#xff0c;缩写&#xff1a;DNS&#xff09;是互联网的一项服务。 它作为将域名和IP地址相互映射的一个分布式数据库&#xff0c;能够使人更方便地访问互联网。 DNS使用UDP端口53。 当前&#xff0…

AUTOMATIC1111/stable-diffusion-webui/stable-diffusion-webui-v1.9.3

配置环境介绍 目前平台集成了 Stable Diffusion WebUI 的官方镜像&#xff0c;该镜像中整合如下资源&#xff1a; GpuMall智算云 | 省钱、好用、弹性。租GPU就上GpuMall,面向AI开发者的GPU云平台 Stable Diffusion WebUI版本&#xff1a;v1.9.3 Python版本&#xff1a;3.10.…

一维前缀和[模版]

题目链接 题目: 分析: 因为要求数组中连续区间的和, 可以使用前缀和算法注意:下标是从1开始算起的, 真正下标0的位置是0第一步: 预处理出来一个前缀和数组dp dp[i] 表示: 表示[1,i] 区间所有元素的和dp[i] dp[i-1] arr[i]例如示例一中: dp数组为{1,3,7}第二步: 使用前缀数…

02_前端三大件HTML

文章目录 HTML用于网页结构搭建1. 标签2. 客户端服务器交互流程3. 专业词汇4. html语法细节5. 安装VSCODE安装插件6. Live Server插件使用7. 标题&段落&换行&列表8. 超链接标签使用9. 图片10. 表格的写法11. 表单标签*(重点)12. 下拉框13. 页面布局标签14. 块元素和…

数理逻辑:1、预备知识

17.1 命题和联结词 ​ 命题&#xff1a;可以判定真假的陈述句。&#xff08;则悖论&#xff0c;祈使句&#xff0c;疑问句都不是命题&#xff09; ​ 原子命题&#xff1a;不能被分割为更小的命题的命题 例如&#xff1a; 2既是素数又是偶数 可以由$p: 2 是素数&#xff0c;…

基于移动多媒体信源与信道编码调研

前言 移动多媒体是指在移动通信环境下&#xff0c;通过无线网络传输的音频、视频、图像等多种媒体信息。移动多媒体的特点是数据量大、传输速率高、服务质量要求高&#xff0c;因此对信源编码和信道编码的性能提出了更高的要求。 本文对进3年的移动多媒体信源与信道编码的研究…

Docker 模块在宝塔中怎么使用

么是 Docker&#xff1f; Docker 是一个用于开发、发布和运行应用程序的开放平台。Docker 使您能够将应用程序与基础架构分离&#xff0c;以便您可以快速交付软件。使用 Docker&#xff0c;您可以像管理应用程序一样管理基础设施。通过利用 Docker 快速交付、测试和部署代码的方…

Django中model中的抽象类

Django中model中的抽象类 当我们在app中models.py文件中定义model表并执行python manage.py makemigrations和python manage.py migrate后&#xff0c;Django就会在数据库中创建表 但是我们也可以对其默认配置修改&#xff0c;定义model类但是不在数据库中创建 from django.…

ubuntu20.04 安装系统后-开机黑屏-nvidia显卡驱动没问题_thinkpad-intel-13700H

文章目录 硬件现象原因&解决 硬件 thinkpad p1 gen6笔记本&#xff0c; intel 13代cpu 13700H,nvidia rtx 2000 Ada laptop gpu 13700H应该是有集显的&#xff0c;但可能没装集显驱动or由于Bios设置的缘故&#xff0c;我的win任务管理器只能看到一个gpu(gpu0)&#xff1…

c++编程14——STL(3)list

欢迎来到博主的专栏&#xff1a;c编程 博主ID&#xff1a;代码小豪 文章目录 list成员类型构造、析构、与赋值iterator元素访问修改元素list的操作 list list的数据结构是一个链表&#xff0c;准确的说应该是一个双向链表。这是一个双向链表的节点结构&#xff1a; list的使用…

关于学习Go语言的并发编程

开始之前&#xff0c;介绍一下​最近很火的开源技术&#xff0c;低代码。 作为一种软件开发技术逐渐进入了人们的视角里&#xff0c;它利用自身独特的优势占领市场一角——让使用者可以通过可视化的方式&#xff0c;以更少的编码&#xff0c;更快速地构建和交付应用软件&#…

Capture One Studio for Mac:打造完美影像的利器

对于摄影师而言&#xff0c;每一次按下快门都是一次对完美影像的追求。而Capture One Studio for Mac正是这样一款能够帮助你实现这一追求的利器。 Capture One Studio for Mac v16.4.2.1中文直装版下载 首先&#xff0c;Capture One Studio for Mac拥有出色的图像处理能力。它…

java项目之人事系统源码(springboot+vue+mysql)

风定落花生&#xff0c;歌声逐流水&#xff0c;大家好我是风歌&#xff0c;混迹在java圈的辛苦码农。今天要和大家聊的是一款基于springboot的人事系统。项目源码以及部署相关请联系风歌&#xff0c;文末附上联系信息 。 项目简介&#xff1a; 基于vue的人事系统的主要使用者…

独享IP是原生IP吗?

原生IP&#xff1a; 原生IP是指由Internet服务提供商&#xff08;ISP&#xff09;直接分配给用户的IP地址&#xff0c;这些IP地址通常反映了用户的实际地理位置和网络连接。原生IP是用户在其所在地区或国家使用的真实IP地址&#xff0c;与用户的物理位置直接相关。在跨境电商中…

C++奇迹之旅:vector使用方法以及操作技巧

文章目录 &#x1f4dd;前言&#x1f320; 熟悉vector&#x1f309;使用vector &#x1f320;构造函数&#x1f309;vector遍历 &#x1f320;operator[]&#x1f309;迭代器 &#x1f320;Capacity容量操作&#x1f309; size()&#x1f309; capacity()&#x1f309;resize()…

【Android开发】Android请求出现网络请求失败,HTTP请求,安全网络通信与权限管理

额外权限 要有这个权限&#xff1a; <uses-permission android:name"android.permission.INTERNET" />HTTP安全考虑 从 Android 9&#xff08;API 级别 28&#xff09;开始&#xff0c;默认情况下不支持通过 HTTP 访问网络&#xff0c;而要求使用 HTTPS。这…

html 字体设置 (web端字体设置)

windows自带的字体是有版权的&#xff0c;包括微软雅黑&#xff08;方正&#xff09;、宋体&#xff08;中易&#xff09;、黑体&#xff08;中易&#xff09;等 版权算是个大坑&#xff0c;所谓为了避免版权问题&#xff0c;全部使用开源字体即可 我这里选择的是思源宋体&…

软考-下午题-试题二、三

主要是最后一问的不同解答 1、父图子图平衡 1、员工关系是否存在传递依赖&#xff1f;用100字以内的文字说明理由。2019 2、在职员关系模式中&#xff0c;假设每个职员有多名家属成员&#xff0c;那么职员关系模式存在什么问题&#xff1f; 应如何解决&#xff1f;2020 职员关系…

world machine学习笔记(3)

打开 可以打开场景设置&#xff0c;项目设置平铺构建设置 场景设置&#xff1a; 输出范围 设置中心点和范围 设置分辨率 项目设置&#xff1a; 设置地图颜色&#xff0c;单位&#xff0c;最高地形高度 点击这个图形进行预览设置 该按钮还有其他的功能 world machine基础流程…

【成都站线下会议|EI稳定检索|SPIE出版】第三届信号处理与通信安全国际学术会议(ICSPCS 2024)

【SPIE独立出版|确定ISSN、ISBN号&#xff01;&#xff01;】【成都站&#xff01;&#xff01;欢迎投稿参会】 第三届信号处理与通信安全国际学术会议&#xff08;ICSPCS 2024&#xff09; 2024 3rd International Conference on Signal Processing and Communication Secur…