`
huangz
  • 浏览: 322261 次
  • 性别: Icon_minigender_1
  • 来自: 广东-清远
社区版块
存档分类
最新评论

Redis 源码分析(1):字典和哈希表(dict.c 和 dict.h)

阅读更多

简介

 

哈希表是 redis 的核心结构之一,在 redis 的源码中, dict.c 和 dict.h 就定义了 redis 所使用的哈希结构,在这篇文章中,我们将对 dict.c 和 dict.h 进行注解和分析,籍此加深对 redis 的理解。

 

因为 dict.c 中使用的 separate chaining 哈希表实现可以在任何一本算法书上找到,因此,在本文中没有对查找和增删等操作做过多的着墨,而是将重点放到整个字典结构的运作流程,以及哈希表的渐进式 rehash 操作上。

 

 

数据结构概览


dict.h 中定义了被 dict.c 的程序所使用的几个数据结构,如 dict 、dictht 和 dictEntry 等,它们之间的关系可以用下图来描述(点击放大):


 

 

数据结构实现细节

 

上一节的大图给出了数据结构之间相互关系,现在,让我们将注意力集中到 dict 、 dictht 和 dictEntry 这三个核心数据结构上面。


dict 结构的定义如下:

 

/* 字典结构 */
typedef struct dict {
    dictType *type;     // 为哈希表中不同类型的值所使用的一族函数
    void *privdata;
    dictht ht[2];       // 每个字典使用两个哈希表
    int rehashidx;      // 指示 rehash 是否正在进行,如果不是则为 -1
    int iterators;      // 当前正在使用的 iterator 的数量
} dict;

 

代码的注释基本都说明相关属性的作用了,需要补充的一些是:


每个字典使用两个哈希表,是因为要实现渐增式 rehash ,redis 会逐个逐个地将 0 号哈希表的元素移动到 1 号哈希表,直到 0 号哈希表被清空为止,文章的后面会给出相关细节,不要心急!


另外, rehashidx 记录的实际上是 rehash 进行到的索引,比如如果 rehash 进行到第 10 个元素,那么 rehashidx 的值就为 9,以此类推,如果没有在进行 rehash ,rehashidx 的值就为 -1 。


接着来看看哈希表结构 —— dictht 结构,这个哈希表是一个 separate chaining hash table 实现,它通过将哈希值相同的元素放到一个链表中来解决冲突问题:

 

typedef struct dictht {
    dictEntry **table;      // 节点指针数组
    unsigned long size;     // 桶的数量
    unsigned long sizemask; // mask 码,用于地址索引计算
    unsigned long used;     // 已有节点数量
} dictht;
  

table 属性组成了一个数组,数组里带有节点指针,用作链表。


size 、 sizemask 和 used 这三个属性初看上去让人有点头晕,实际上,它们分别代表的是:


size :桶的数量,也即是, table 数组的大小。

sizemask :这个值通过 size - 1 计算出来,给定 key 的哈希值计算出来之后,就会和这个数值进行 & 操作,决定元素被放到 table 数组的那一个位置上。

used :这个值代表目前哈希表中元素的数量,也即是哈希表总共保存了多少 dictEntry 结构。


好的,最后,就是链表节点结构 —— dictEntry 了:

 

typedef struct dictEntry {
    void *key;              // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;                    // 值(可以有几种不同类型)
    struct dictEntry *next; // 指向下一个哈希节点(形成链表)
} dictEntry;
  

这个结构。。。阿,好吧,这个结构没啥要说的,赶紧进入下一部分吧。

 

 

字典创建流程

 

在初步解了几个核心数据结构之后,是时候可以来看看相关的函数怎么来使用这些数据结构了,让我们从最开始的创建字典开始,一步步研究字典(以及哈希表)的运作流程。


因为调用流程可以给我们一个高层次的观点来了解数据结构的运作流程,而不必陷入到代码细节中,因此,文章这里只给出程序调用流程的部分核心代码,如果你对代码的其他细节有兴趣,可以到我的 github 上去找注释版的代码,上面有完整的代码,而且我给大部分函数都加上了注释。


OK,说回来字典这边,创建新字典执行的调用链是: dictCreate -> _dictInit -> _dictReset


其中 dictCreate 函数为 dict 结构分配了空间,然后将新的 dict 传给 _dictInit 函数,让它初始化 dict 结构的相关属性,而 _dictInit 又调用 _dictReset ,对字典的 ht 属性(也即是两个哈希表)进行常量属性的设置。


注意, _dictReset 只是为字典所属的两个哈希表进行常量属性的设置(size、 sizemask 和 used),但并不为哈希表的链表数组分配内存:

 

static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

 

 

0 号哈希表的创建流程

 

我们知道,一个 dict 结构使用两个哈希表,也即是 d->ht[0] 和 d->ht[1] ,为了称呼方便,我们将他们分别叫做 0 号和 1 号哈希表。


从上一节可以知道, dictCreate 并不为哈希表的链表数组分配内存( d->ht[0]->table 和 d->ht[1]->table 都被设为 NULL),那么,什么时候哈希表的链表数组会被初始化呢?


答案是,当首次通过 dictAdd 向字典添加元素的时候, 0 号哈希表的链表数组会被初始化。


首次向字典增加元素将执行以下的调用序列: dictAdd -> dictAddRaw -> _dictKeyIndex -> dictExpandIfNeeded -> dictExpand


其中 dictAdd 是 dictAddRaw 的调用者, dictAddRaw 是添加元素这一工作的底层实现,而 dictAddRaw 为了计算新元素的 key 的地址索引,会调用 _dictKeyIndex :

 

dictEntry *dictAddRaw(dict *d, void *key)
{
    // 被省略的代码... 

    // 计算 key 的 index 值
    // 如果 key 已经存在,_dictKeyIndex 返回 -1 
    if ((index = _dictKeyIndex(d, key)) == -1)
        return NULL;

    // 被省略的代码... 
}
  

然后 _dictKeyIndex 会在计算 地址索引前,会先调用 _dictExpandIfNeeded 检查两个哈希表是否有空间容纳新元素:

 

static int _dictKeyIndex(dict *d, const void *key)
{
    // 被省略的代码...

    /* Expand the hashtable if needed */
    if (_dictExpandIfNeeded(d) == DICT_ERR)
        return -1;

    // 被省略的代码...
}

 

到 _dictExpandIfNeeded 这步,一些有趣的事情就开始发生了, _dictExpandIfNeeded 会检测到 0 号哈希表还没有分配任何空间,于是它调用 dictExpand ,传入 DICT_HT_INITIAL_SIZE 常量作为 0 号哈希表的初始大小(目前的版本 DICT_HT_INITIAL_SIZE = 4 ),为 0 号哈希表分配空间:

 

static int _dictExpandIfNeeded(dict *d)
{
    // 被忽略的代码...

    /* If the hash table is empty expand it to the intial size. */
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    // 被忽略的代码...
}

 

dictExpand 会创建一个分配了链表数组的新哈希表,然后进行判断,决定是该将新哈希表赋值给 0 号哈希表还是 1 号哈希表。


这里因为我们的 0 号哈希表的 size 还是 0 ,因此,这里会执行 if 语句的第一个 case ,将新哈希表赋值给 0 号哈希表:

 

int dictExpand(dict *d, unsigned long size)
{
    // 被省略的代码...

    // 计算哈希表的(真正)大小
    unsigned long realsize = _dictNextPower(size);

    // 创建新哈希表
    dictht n;
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));  // 分配链表数组
    n.used = 0;

    // 字典的 0 号哈希表是否已经初始化?
    // 如果没有的话,我们将新建哈希表作为字典的 0 号哈希表
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
    } else {
    // 否则,将新建哈希表作为字典的 1 号哈希表,并将它用于 rehash
        d->ht[1] = n;
        d->rehashidx = 0;
    }

    // 被省略的代码...
}

 

 

字典的扩展和 1 号哈希表的创建

 

在 0 号哈希表创建之后,我们就有了一个可以执各式各样操作(添加、删除、查找,诸如此类)的字典实例了。


但是这里还有一个问题: 这个最初创建的 0 号哈希表非常小(当前版本的 DICT_HT_INITIAL_SIZE = 4),它很快就会被元素填满,这时候, rehash 操作就会被激活。


(字典的)哈希表的完整 rehash 操作分为两步:


1) 首先就是创建一个比现有哈希表更大的新哈希表(expand)

2) 然后将旧哈希表的所有元素都迁移到新哈希表去(rehash)


当使用 dictAdd 对字典添加元素的时候, _dictExpandIfNeeded 会一直对 0 号哈希表的使用情况进行检查(还记得 dictAdd 的调用链吗?忘了的话翻到上一节去看看),当 rehash 条件被满足的时候,它就会调用 dictExpand 函数,对字典进行扩展:

 

 

static int _dictExpandIfNeeded(dict *d)
{
    // 被省略的代码...

    // 当 0 号哈希表的已用节点数大于等于它的桶数量,
    // 且以下两个条件的其中之一被满足时,执行 expand 操作:
    // 1) dict_can_resize 变量为真,正常 expand
    // 2) 已用节点数除以桶数量的比率超过变量 dict_force_resize_ratio ,强制 expand
    // (目前版本中 dict_force_resize_ratio = 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].size > d->ht[0].used) ?
                                    d->ht[0].size : d->ht[0].used)*2);
    }

    // 被省略的代码...
}
 

 

可以看到,当代码注释中所说的两种情况的其中一种被满足的时候, dictExpand 函数就会被调用, 0 号哈希表的桶数量和节点数量两个数值之间的较大者乘以 2 ,就会被作为第二个参数传入 dictExpand 函数。


这次调用 dictExpand 函数执行的是和之前创建 0 号哈希表时不同的路径 —— 这一次,程序执行的是 else case,它将新哈希表赋值给 1 号哈希表,并将字典的 rehashidx 属性从 -1 改为 0:

 

int dictExpand(dict *d, unsigned long size)
{
    // 被省略的代码...

    // 计算哈希表的(真正)大小
    unsigned long realsize = _dictNextPower(size);

    // 创建新哈希表
    dictht n;
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;

    // 字典的 0 号哈希表是否已经初始化?
    // 如果没有的话,我们将新建哈希表作为字典的 0 号哈希表
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
    } else {
    // 否则,将新建哈希表作为字典的 1 号哈希表,并将它用于 rehash
        d->ht[1] = n;
        d->rehashidx = 0;
    }

    // 被省略的代码...
}

 

 

渐增式 rehash 和平摊操作

 

在上一节的最后,我们看到,当 expand 执行完毕之后,字典同时使用两个哈希表,并且字典的 rehashidx 属性从 -1 被改为 0 ,这是一个重要的改动,它标志着 rehash 可以进行了。


但是 rehash 操作在那里?我们什么时候调用它?嗯。。。源码里的确有一个 dictRehash 函数,它可以将指定数量的元素从 0 号哈希表 rehash 到 1 号哈希表,但是,redis 并不是使用它一下子将 0 号哈希表的所有元素全都 rehash 到 1 号哈希表,因为这样集中式的 rehash 会引起大量的计算工作(想一想,假如现在需要 rehash 的不是 4 个元素,而是 4 十万个,4 千万个,4 亿呢?),进而影响整个系统的性能。


为了避免上面所说的问题, redis 采取了一种更平滑的 rehash 策略,redis 文档里称之为渐增式 rehash (incremental rehashing),它将 rehash 操作平摊到 dictAddRaw 、dictGetRandomKey 、dictFind 、dictGenericDelete 这些函数里面,每当上面这些函数被执行的时候(或者其他人调用它们时), _dictRehashStep 函数就会执行,将 1 个元素从 0 号哈希表 rehash 到 1 号哈希表,这样就避免了集中式的 rehash 。


以下是 dictFind 函数,它是其中一个平摊 rehash 操作的函数:

 

dictEntry *dictFind(dict *d, const void *key)
{
    // 被忽略的代码...

    // 检查字典(的哈希表)能否执行 rehash 操作
    // 如果可以的话,执行平摊 rehash 操作
    if (dictIsRehashing(d)) _dictRehashStep(d);

    // 被忽略的代码...
}
  

其中 dictIsRehashing 就是检查字典的 rehashidx 属性是否不为 -1 :

 

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

 

如果条件成立成立的话, _dictRehashStep 就会被执行,将一个元素从 0 号哈希表转移到 1 号哈希表:

 

static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}
  

(代码中的 iterators == 0 表示在 rehash 时不能有迭代器,因为迭代器可能会修改元素,所以不能在有迭代器的情况下进行 rehash 。)


就这样,如同愚公移山一般, 0 号哈希表的元素被逐个逐个地,从 0 号 rehash 到 1 号,最终整个 0 号哈希表被清空,这时 _dictRehashStep 再调用 dictRehash ,被清空的 0 号哈希表就会被删除,然后原来的  1 号哈希表成为新的 0 号哈希表(当有需要再次进行 rehash 的时候,这个循环就会再次开始):

 

int dictRehash(dict *d, int n) {
    // 被忽略的代码...

    while(n--) {
        dictEntry *de, *nextde;

        // 0 号哈希表的所有元素 rehash 完毕?
        if (d->ht[0].used == 0) {
            zfree(d->ht[0].table);  // 清空 0 号
            d->ht[0] = d->ht[1];   // 用原来的 1 号代替 0 号

            _dictReset(&d->ht[1]);  // 重置 1 号哈希表
            d->rehashidx = -1;      // 重置字典的 rehash flag

            return 0;
        }
    // 被忽略的代码...
    }

    // 被忽略的代码...
}
  

最后,还有之前一直都没有提到的一个确保 rehash 得以最终完成的重要条件是:当 rehashidx 不等于 -1 ,也即是 dictIsRehashing 为真时,所有新添加的元素都会直接被加到 1 号数据库,这样 0 号哈希表的大小就会只减不增,最终 rehash 总会有完成的一刻(假如新加入的元素还继续被放进 0 号哈希表,那么尽管平摊 rehash 一直在努力地进行,但说不定 rehash 还是永远也完成不了):

 

dictEntry *dictAddRaw(dict *d, void *key)
{
    // 被省略的代码...

    // 如果字典正在进行 rehash ,那么将新元素添加到 1 号哈希表,
    // 否则,使用 0 号哈希表
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];

    // 被省略的代码...
}

 

 

最后:哈希表的大小

 

我们知道哈希表最初的大小是由 DICT_HT_INITIAL_SIZE 决定的,而当 rehash 开始之后,根据给定的条件,哈希表的大小就会发生变动:

 

static int _dictExpandIfNeeded(dict *d)
{
    // 被省略的代码...

    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].size > d->ht[0].used) ?
                                    d->ht[0].size : d->ht[0].used)*2);
    }

    // 被省略的代码...
}

 

可以看到, d->ht[0].size 和 d->ht[0].used 两个数之间的较大者乘以 2 ,会作为 size 参数被传入 dictExpand 函数,但是,尽管如此,这个数值仍然还不是哈希表的最终大小,因为在 dictExpand 里面,_dictNextPower 函数会根据传入的 size 参数计算出真正的表大小:

 

int dictExpand(dict *d, unsigned long size)
{
    // 被省略的代码...

    // 计算哈希表的(真正)大小
    unsigned long realsize = _dictNextPower(size);

    // 创建新哈希表
    dictht n;
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;

    // 被省略的代码...
}

 

至于 _dictNextPower 函数,它不断计算 2 的乘幂,直到遇到大于等于 size 参数的乘幂,就返回这个乘幂作为哈希表的大小:

 

static unsigned long _dictNextPower(unsigned long size)
{
    unsigned long i = DICT_HT_INITIAL_SIZE;

    if (size >= LONG_MAX) return LONG_MAX;
    while(1) {
        if (i >= size)
            return i;
        i *= 2;
    }
}
  

虽然桶的元素个数 d->ht[0].size 刚开始是固定的(DICT_HT_INITIAL_SIZE),但是,因为我们没有办法预知 d->ht[0].used 的值,所以我们没有办法预知哈希表的大小,不过,我们可以确定以下两个关于哈希表大小的性质:


1) 哈希表的大小总是 2 的乘幂(也即是 2^N,此处 N 未知)

2)1 号哈希表的大小总比 0 号哈希表大

 

 

就这样了!

 

对 redis 的 dict.c 和 dict.h 的源码分析就到这了,本人一直认为,好的源码分析应该能在不贴太多代码的情况下把原理和流程讲明白,在这篇文章我已经尽力地将实践这个原则,不过因为是第一次写源码分析类的文章,而且才刚开始读 redis 的源码,难免有不如人意之处,希望朋友们不吝指出。


dict 的结构本身还是很简单的,唯一的遗憾是,dictht 作为字典的内部实现,本该被隐藏起来的,现在却完全暴露了出来(什么, _dictReset(&d->ht[1]),你是说真的?),如果能在模块化方面多做一点努力的话,代码会更容易看一些,当然这里也有历史遗留的因素在里面。


最后, 我为 redis 的源码分析项目专门建立了一个 github project ,上面有完整的源码文件,大部分加上了注释(目前只有 dict.c 和 dict.h),如果对代码的完整细节有兴趣,可以到上面去取:  https://github.com/huangz1990/reading_redis_source

 

 

 

  • 大小: 58.3 KB
分享到:
评论
1 楼 ppooooll 2012-03-19  
支持一下,同在看redis源码,希望楼主再接再厉!

相关推荐

    redis源码分析

    这些数据结构在源码中由不同的C结构体实现,例如`sds`用于表示字符串,`dict`表示哈希表,`list`表示列表等。这些高效的数据结构使得Redis能够快速处理数据操作。 二、Redis服务器 Redis服务器的核心是`server`...

    Redis源码C语言

    Redis的核心在于其高效的数据结构,如字符串(sds),哈希表(dict),链表(list),集合(set),有序集合(zset)。sds是Redis自定义的动态字符串,比C语言的原始字符串更安全、高效。dict是哈希表实现,支持...

    Redis dict

    Redis中的`dict`数据结构是其内部非常关键的一部分,它是一种高效的哈希表实现,用于存储键值对,是Redis数据库的基础。Redis的核心功能,如字符串、哈希、集合、有序集合等,都是基于`dict`来实现的。在本讨论中,...

    redis源码,

    `dict.c`和`dict.h`实现了字典数据结构,这是Redis中键值对存储的基础。字典使用哈希表作为底层实现,支持动态调整大小以适应数据量的变化。`server.c`中的`server.db`数组则包含了所有数据库,每个数据库都是一个...

    Redis源代码分析.pdf

    哈希表是Redis中用于存储键值对的数据结构,通过dict定义,提供哈希表的大小size和哈希表的元素数组table。 二、服务器模型 1. 事件处理(ae.h/ae.c) 事件处理是Redis的核心机制,用于处理客户端的连接和命令...

    redis源码日志

    - **位置**: Redis使用哈希表进行键值对存储。 - **实现**: 字典的具体实现方式,包括哈希冲突解决策略。 - **优化**: 扩展、重置哈希表的操作细节。 - **第8章:redis数据结构ziplist** - **概述**: 压缩双...

    redis-unstable_Redis数据库源码_

    这些数据结构在源码中都有对应的实现,例如` SDS (Simple Dynamic String)`用于字符串,`dict`用于哈希表等。 2. 持久化: Redis提供了两种主要的持久化方式:RDB(快照)和AOF(Append Only File)。RDB会在指定...

    源码redis2.6中文注释版

    2. 哈希表:用于存储键值对,采用开放寻址法解决冲突,源码在`dict.c`中。 3. 列表:支持双向链表,常用于实现消息队列,`list.h`和`list.c`提供了相关的实现。 4. 集合:无序不重复元素集合,`set.c`和`set.h`。 5....

    哈希表-浙大数据结构课件

    哈希表,又称为散列表,是数据结构中一种高效的数据存储方式,它通过特定的哈希函数将关键字(key)映射到一个固定大小的数组(也称为哈希表或槽位)中,以此实现快速查找、插入和删除操作。在浙大的这组经典数据...

    redis-in-action:Redis实战源代码

    源码中可以看到这些数据结构的实现细节,如 SDS(Simple Dynamic String)作为优化的字符串实现,ZipList和Quicklist作为压缩列表的不同版本,以及字典(dict)用于哈希表的实现。 2. 网络I/O:Redis使用单线程模型...

    Redis源码解析

    Redis的核心数据结构包括字符串(String)、哈希表(Hash)、列表(List)、集合(Set)和有序集合(Sorted Set)。这些数据结构的实现大多基于SDS(Simple Dynamic String)、ziplist、dict、skiplist等高效的数据...

    Redis-6.2源码调试

    研究`dict.c`,可以理解哈希表的实现和扩容策略;而深入`ae.c`,则可以掌握异步事件驱动模型。 总的来说,通过VSCode调试Redis 6.2源码,不仅可以增强你的编程技能,还能使你更深入地了解数据库系统的运作机制,...

    【Redis 系列】redis 学习十六,redis 字典(map) 及其核心编码结构.doc

    ### Redis中的字典(Map)及其核心编码结构 #### 一、引言 ...通过上述分析可以看出,Redis中的字典结构不仅高度灵活,而且能够根据实际需求定制各种操作,使得Redis能够在不同场景下高效地管理和检索数据。

    redis-4.0.14源码压缩包

    - **哈希表(Hashes)**:Redis使用ziplist和dict两种数据结构来存储哈希表。ziplist是内存优化的结构,适用于小对象;dict则用于大对象,使用开放寻址法解决冲突。 - **字符串(Strings)**:简单动态字符串(SDS...

    yoko-read-redis:is redis源码阅读,中文注释版

    最具基础性的数据结构,字典,基于哈希表实现 adlist.c [.h] 100% 关键基础数据结构,双向链表 sds.c [.h] 80% 脆弱的基础数据结构,xxx intset.c [.h] 95% 最具基础性的数据结构,整型集合,基于集成实现 ...

    Redis源代码分析

    #### 2.3 哈希表 (dict.h/dict.c) 哈希表是Redis中一种重要的数据结构,用于实现键值对存储。 - **基本数据结构**: 哈希表主要包括哈希表自身、哈希槽、哈希表节点等组成部分。 ```c typedef struct dict { ...

Global site tag (gtag.js) - Google Analytics