Redis源码剖析之字典(dict)

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
简介: dict中的hashtable在出现hash冲突时采用的是开链方式,如果有多个entry落在同一个bucket中,那么他们就会串成一个单链表存储。

Dict在redis中是最为核心的一个数据结构,因为它承载了redis里的所有数据,你可以简单粗暴的认为redis就是一个大的dict,里面存储的所有的key-value。


redis中dict的本质其实就是一个hashtable,所以它也需要考虑所有hashtable所有的问题,如何组织K-V、如何处理hash冲突、扩容策略及扩容方式……。实际上Redis中hashtable的实现方式就是普通的hashtable,但Redis创新的引入了渐进式hash以减小hashtable扩容是对性能带来的影响,接下来我们就来看看redis中hashtable的具体实现。


Redis中Dict的实现

dict的定义在dict.h中,其各个字段及其含义如下:


typedef struct dict {
    dictType *type;  // dictType结构的指针,封装了很多数据操作的函数指针,使得dict能处理任意数据类型(类似面向对象语言的interface,可以重载其方法)
    void *privdata;  // 一个私有数据指针(privdata),由调用者在创建dict的时候传进来。
    dictht ht[2];  // 两个hashtable,ht[0]为主,ht[1]在渐进式hash的过程中才会用到。  
    long rehashidx; /* 增量hash过程过程中记录rehash执行到第几个bucket了,当rehashidx == -1表示没有在做rehash */
    unsigned long iterators; /* 正在运行的迭代器数量 */
} dict;


重点介绍下dictType *type字段(个人感觉命名为type不太合适),其作用就是为了让dict支持各种数据类型,因为不同的数据类型需要对应不同的操作函数,比如计算hashcode 字符串和整数的计算方式就不一样, 所以dictType通过函数指针的方式,将不同数据类型的操作都封装起来。从面相对象的角度来看,可以把dictType当成dict中各种数据类型相关操作的interface,各个数据类型只需要实现其对应的数据操作就行。 dictType中封装了以下几个函数指针。


typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);  // 对key生成hash值 
    void *(*keyDup)(void *privdata, const void *key); // 对key进行拷贝 
    void *(*valDup)(void *privdata, const void *obj);  // 对val进行拷贝
    int (*keyCompare)(void *privdata, const void *key1, const void *key2); // 两个key的对比函数
    void (*keyDestructor)(void *privdata, void *key); // key的销毁
    void (*valDestructor)(void *privdata, void *obj); // val的销毁 
} dictType;


dict中还有另外一个重要的字段dictht ht[2],dictht其实就是hashtable,但这里为什么是ht[2]? 这就不得不提到redis dict的渐进式hash,dict的hashtable的扩容不是一次性完成的,它是先建立一个大的新的hashtable存放在ht[1]中,然后逐渐把ht[0]的数据迁移到ht[1]中,rehashidx就是ht[0]中数据迁移的进度,渐进式hash的过程会在后文中详解。


这里我们来看下dictht的定义:


typedef struct dictht {
    dictEntry **table;  // hashtable中的连续空间 
    unsigned long size; // table的大小 
    unsigned long sizemask;  // hashcode的掩码  
    unsigned long used; // 已存储的数据个数
} dictht;


其中dictEntry就是对dict中每对key-value的封装,除了具体的key-value,其还包含一些其他信息,具体如下:


typedef struct dictEntry {
    void *key;
    union {   // dictEntry在不同用途时存储不同的数据 
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // hash冲突时开链,单链表的next指针 
} dictEntry;


dict中的hashtable在出现hash冲突时采用的是开链方式,如果有多个entry落在同一个bucket中,那么他们就会串成一个单链表存储。


如果我们将dict在内存中的存储绘制出来,会是下图这个样子。


5718473f158ba5bc742c05cf04d76e61_watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3hpbmRvbw==,size_16,color_FFFFFF,t_70#center#pic_center.png

扩容

在看dict几个核心API实现之前,我们先来看下dict的扩容,也就是redis的渐进式hash。 何为渐进式hash?redis为什么采用渐进式hash?渐进式hash又是如何实现的?


要回答这些问题,我们先来考虑下hashtable扩容的过程。如果熟悉java的同学可能知道,java中hashmap的扩容是在数据元素达到某个阈值后,新建一个更大的空间,一次性把旧数据搬过去,搬完之后再继续后续的操作。如果数据量过大的话,HashMap扩容是非常耗时的,所有有些编程规范推荐new HashMap时最好指定其容量,防止出现自动扩容。


但是redis在新建dict的时候,没法知道数据量大小,如果直接采用java hashmap的扩容方式,因为redis是单线程的,势必在扩容过程中啥都干不了,阻塞掉后面的请求,最终影响到整个redis的性能。如何解决? 其实也很简单,就是化整为零,将一次大的扩容操作拆分成多次小的步骤,一步步来减少扩容对其他操作的影响,其具体实现如下:


上文中我们已经看到了在dict的定义中有个dictht ht[2],dict在扩容过程中会有两个hashtable分别存储在ht[0]和ht[1]中,其中ht[0]是旧的hashtable,ht[1]是新的更大的hashtable。

/* 检查是否dict需要扩容 */
static int _dictExpandIfNeeded(dict *d)
{
    /* 已经在渐进式hash的流程中了,直接返回 */
    if (dictIsRehashing(d)) return DICT_OK;
    /* If the hash table is empty expand it to the initial size. */
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
    /* 当配置了可扩容时,容量负载达到100%就扩容。配置不可扩容时,负载达到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在每次查找某个key的索引下标时都会检查是否需要对ht[0]做扩容,如果配置的是可以扩容 那么当hashtable使用率超过100%(uesed/size)就触发扩容,否则使用率操作500%时强制扩容。执行扩容的代码如下:


/* dict的创建和扩容 */ 
int dictExpand(dict *d, unsigned long size)
{
    /* 如果size比hashtable中的元素个数还小,那size就是无效的,直接返回error */
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;
    dictht n; /* 新的hashtable */
    // 扩容时新table容量是大于当前size的最小2的幂次方,但有上限 
    unsigned long realsize = _dictNextPower(size);
    // 如果新容量和旧容量一致,没有必要继续执行了,返回err
    if (realsize == d->ht[0].size) return DICT_ERR;
    /* 新建一个容量更大的hashtable */
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
    // 如果是dict初始化的情况,直接把新建的hashtable赋值给ht[0]就行 
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }
    // 非初始化的情况,将新表赋值给ht[1], 然后标记rehashidx 0
    d->ht[1] = n;
    d->rehashidx = 0; // rehashidx表示当前rehash到ht[0]的下标位置
    return DICT_OK;
}


这里dictExpand只是创建了新的空间,将rehashidx标记为0(rehashidx==-1表示不在rehash的过程中),并未对ht[0]中的数据迁移到ht[1]中。数据迁移的逻辑都在_dictRehashStep()中。 _dictRehashStep()是只迁移一个bucket,它在dict的查找、插入、删除的过程中都会被调到,每次调用至少迁移一个bucket。 而dictRehash()是_dictRehashStep()的具体实现,代码如下:


/* redis渐进式hash,采用分批的方式,逐渐将ht[0]依下标转移到ht[2],避免了hashtable扩容时大量

* 数据迁移导致的性能问题
 * 参数n是指这次rehash只做n个bucket */
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* 最大空bucket数量,如果遇到empty_visits个空bucket,直接结束当前rehash的过程 */
    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; // 如果遇到了empty_visits个空的bucket,直接结束 
        }
        // 遍历当前bucket中的链表,直接将其移动到新的hashtable中  
        de = d->ht[0].table[d->rehashidx];
        /* 把所有的key从旧的hash桶移到新的hash桶中 */
        while(de) {
            uint64_t h;
            nextde = de->next;
            /* 获取到key在新hashtable中的下标 */
            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++;
    }
    /* 检测是否已对全表做完了rehash */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);  // 释放旧ht所占用的内存空间  
        d->ht[0] = d->ht[1];  // ht[0]始终是在用ht,ht[1]始终是新ht,ht0全迁移到ht1后会交换下  
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;   
        return 0;  // 如果全表hash完,返回0
    }
    /* 还需要继续做hash返回1 */
    return 1;
}


可以看出,rehash就是分批次把ht[0]中的数据搬到ht[1]中,这样将原有的一个大操作拆分为很多个小操作逐步进行,避免了redis发生dict扩容是瞬时不可用的情况,缺点是在redis扩容过程中会占用俩份存储空间,而且占用时间会比较长。


核心API

插入

/* 向dict中添加元素 */
int dictAdd(dict *d, void *key, void *val)
{
    dictEntry *entry = dictAddRaw(d,key,NULL);  
    // 
    if (!entry) return DICT_ERR;  
    dictSetVal(d, entry, val);
    return DICT_OK;
}
/* 添加和查找的底层实现:  
 * 这个函数只会返回key对应的entry,并不会设置key对应的value,而是把设值权交给调用者。 
 * 
 * 这个函数也作为一个API直接暴露给用户调用,主要是为了在dict中存储非指针类的数据,比如
 * entry = dictAddRaw(dict,mykey,NULL);
 * if (entry != NULL) dictSetSignedIntegerVal(entry,1000);
 *
 * 返回值:
 * 如果key已经存在于dict中了,直接返回null,并把已经存在的entry指针放到&existing里。否则
 * 为key新建一个entry并返回其指针。 
*/
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;
    if (dictIsRehashing(d)) _dictRehashStep(d);
    /* 获取到新元素的下标,如果返回-1标识该元素已经存在于dict中了,直接返回null */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;
    /* 否则就给新元素分配内存,并将其插入到链表的头部(一般新插入的数据被访问的频次会更高)*/
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    entry = zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry;
    ht->used++;
    /* 如果是新建的entry,需要把key填进去 */
    dictSetKey(d, entry, key);
    return entry;
}


插入过程也比较简单,就是先定位bucket的下标,然后插入到单链表的头节点,注意这里也需要考虑到rehash的情况,如果是在rehash过程中,新数据一定是插入到ht[1]中的。


查找

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);
    h = dictHashKey(d, key);
    // 查找的过程中,可能正在rehash中,所以新老两个hashtable都需要查 
    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;
            he = he->next;
        }
        // 如果ht[0]中没找到,且不再rehas中,就不需要继续找了ht[1]了。 
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}


查找的过程比较简单,就是用hashcode做定位,然后遍历单链表。但这里需要考虑到如果是在rehash过程中,可能需要查找ht[2]中的两个hashtable。


删除

/* 查找并删除一个元素,是dictDelete()和dictUnlink()的辅助函数。*/
static dictEntry *dictGenericDelete(dict *d, const void *key, int nofree) {
    uint64_t h, idx;
    dictEntry *he, *prevHe;
    int table;
    if (d->ht[0].used == 0 && d->ht[1].used == 0) return NULL;
    if (dictIsRehashing(d)) _dictRehashStep(d);
    h = dictHashKey(d, key);
    // 这里也是需要考虑到rehash的情况,ht[0]和ht[1]中的数据都要删除掉 
    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掉元素 */
                if (prevHe)
                    prevHe->next = he->next;
                else
                    d->ht[table].table[idx] = he->next;
                // 如果nofree是0,需要释放k和v对应的内存空间 
                if (!nofree) {
                    dictFreeKey(d, he);
                    dictFreeVal(d, he);
                    zfree(he);
                }
                d->ht[table].used--;
                return he;
            }
            prevHe = he;
            he = he->next;
        }
        if (!dictIsRehashing(d)) break;
    }
    return NULL; /* 没找到key对应的数据 */
}


其它API

其他的API实现都比较简单,我在dict.c源码中做了大量的注释,有兴趣可以自行阅读下,我这里仅列举并说明下其大致的功能。


dict *dictCreate(dictType *type, void *privDataPtr);  // 创建dict 
int dictExpand(dict *d, unsigned long size);  // 扩缩容
int dictAdd(dict *d, void *key, void *val);  // 添加k-v
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing); // 添加的key对应的dictEntry 
dictEntry *dictAddOrFind(dict *d, void *key); // 添加或者查找 
int dictReplace(dict *d, void *key, void *val); // 替换key对应的value,如果没有就添加新的k-v
int dictDelete(dict *d, const void *key);  // 删除某个key对应的数据 
dictEntry *dictUnlink(dict *ht, const void *key); // 卸载某个key对应的entry 
void dictFreeUnlinkedEntry(dict *d, dictEntry *he); // 卸载并清除key对应的entry
void dictRelease(dict *d);  // 释放整个dict 
dictEntry * dictFind(dict *d, const void *key);  // 数据查找
void *dictFetchValue(dict *d, const void *key);  // 获取key对应的value
int dictResize(dict *d);  // 重设dict的大小,主要是缩容用的
/************    迭代器相关     *********** */
dictIterator *dictGetIterator(dict *d);  
dictIterator *dictGetSafeIterator(dict *d);
dictEntry *dictNext(dictIterator *iter);
void dictReleaseIterator(dictIterator *iter);
/************    迭代器相关     *********** */
dictEntry *dictGetRandomKey(dict *d);  // 随机返回一个entry 
dictEntry *dictGetFairRandomKey(dict *d);   // 随机返回一个entry,但返回每个entry的概率会更均匀 
unsigned int dictGetSomeKeys(dict *d, dictEntry **des, unsigned int count); // 获取dict中的部分数据


其他的API见代码dict.c和dict.h.


本文是Redis源码剖析系列博文,同时也有与之对应的Redis中文注释版,有想深入学习Redis的同学,欢迎star和关注。

Redis中文注解版仓库:https://github.com/xindoo/Redis

Redis源码剖析专栏:https://zxs.io/s/1h

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
2月前
|
存储 NoSQL Java
【Redis系列】那有序集合为什么要同时使用字典和跳跃表
面试官问:那有序集合为什么要同时使用字典和跳跃表来实现?我:这个设计主要是考虑了性能因素。1. 如果单纯使用字典,查询的效率很高是O(1),但执行类似ZRANGE、ZRNK时,排序性能低。每次排序需要在内存上对字典进行排序一次,同时消耗了额外的O(n)内存空间
32 1
【Redis系列】那有序集合为什么要同时使用字典和跳跃表
|
2月前
|
存储 缓存 NoSQL
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)(一)
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)
40 0
|
2月前
|
存储 NoSQL 算法
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)(二)
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)
52 0
|
7天前
|
存储 NoSQL Java
Redis入门到通关之数据结构解析-Dict
Redis入门到通关之数据结构解析-Dict
14 2
|
14天前
|
人工智能 前端开发 Java
Java语言开发的AI智慧导诊系统源码springboot+redis 3D互联网智导诊系统源码
智慧导诊解决盲目就诊问题,减轻分诊工作压力。降低挂错号比例,优化就诊流程,有效提高线上线下医疗机构接诊效率。可通过人体画像选择症状部位,了解对应病症信息和推荐就医科室。
162 10
|
2月前
|
存储 机器学习/深度学习 NoSQL
作者推荐 |【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(链表)(二)
作者推荐 |【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(链表)
23 0
|
2月前
|
存储 缓存 NoSQL
作者推荐 |【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(链表)(一)
作者推荐 |【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(链表)
29 0
|
2月前
|
存储 NoSQL 网络协议
读懂Redis源码,我总结了这7点心得
读懂Redis源码,我总结了这7点心得
|
4月前
|
缓存 NoSQL 关系型数据库
Redis 7.0 源码调试环境搭建与源码导读技巧
Redis 7.0 源码调试环境搭建与源码导读技巧
56 0
|
4月前
|
NoSQL 算法 Redis
redis7.0源码阅读(五):跳表(skiplist)
redis7.0源码阅读(五):跳表(skiplist)
62 1