zoukankan      html  css  js  c++  java
  • redis源码学习之dict

    参考《Redis 设计与实现》 (基于redis3.0.0) 作者:黄健宏
    学习redis3.2.13

    toc

    介绍

    字典是一种存储键值对的抽象数据结构。其键与值相互关联,在字典中,通过键可以找到相应的值。
    字典的实现方式是多种多样,可以是数组、也可以是哈希表、或者也可以是树。C++中的有序字典map与无序字典unordered_map就分别使用了红黑树与哈希表来实现。
    redis实现的字典使用的是哈希表的方式。

    字典的结构

    键值对节点 dictEntry

    typedef struct dictEntry {
        void *key;
        union {
            void *val;
            uint64_t u64;
            int64_t s64;
            double d;
        } v;
        //处理键冲突时使用,指向下个节点的地址
        struct dictEntry *next;
    } dictEntry;
    • 为了支持存储多种类型的value,同时节省空间,redis选用了联合来表示值。
    • redis在处理键的冲突时,采用了链地址法,使用一个指针来记录的下个冲突节点的位置。

    哈希表结构 dictht

    typedef struct dictht {
        //存放dictEntry *的数组(里面的元素也叫bucket)
        dictEntry **table;
        //数组的大小,必须是2的N次方(初始情况为0)
        unsigned long size;
        //用于数组下标计算的掩码,总是等于size - 1
        unsigned long sizemask;
        //当前dictht 中已有节点的总和
        unsigned long used;
    } dictht;
    • 值得注意的是size和used没有任何关系,size是数组的大小,而used是dictht中已有的键值对节点数量,包括数组中使用的节点,以及发生冲突后由链表连接起来的节点
    • 这里size必须是2的N次方, 且sizemask等于size - 1,是为了配合hash计算数组的下标,redis通过位操作来提高了性能
    //下标的计算方式
    index = hash % size
    // 当size为2的N次方时,
    hash % size == hash & (size - 1) <====> hash & (sizemask)

    字典结构 dict

    typedef struct dictType {
        //哈希函数的函数指针
        unsigned int (*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;
        //私有数据,创建字典时传入,可配合结构dictType中的函数使用
        void *privdata;
        //两个哈希表,正常使用表0,rehash时才会用到表1
        dictht ht[2];
        //渐进式rehash所需,表示rehash进行到dictEntry *数组的哪个位置,-1表示没有在rehash(或是rehash完成)
        long rehashidx; /* rehashing not in progress if rehashidx == -1 */
        //dict当前存在的迭代器的个数
        int iterators; /* number of iterators currently running */
    } dict;

    未发生rehash、键冲突时,字典的示例(还是取书中的例子):

    hash与rehash

    键冲突的原因与处理

    1. hash算法具有冲突必然性: 导致即使两个键是不同的,得出的哈希值也可能是一样的。哈希冲突必然性见鸽巢原理
    2. 已有节点多于dictEntry *大小:即使计算出的hash值不一样,对数组长度取余后得到的下标就会重复,必然会有键冲突。

    redis处理冲突的方法是链地址法。即是使用一个链表来存储该键所有冲突的节点。 为了能快速存入键值对,redisi直接将新的键值对插入链表头部。
    但是,随着节点的增多,链表会越来越长,严重影响字典性能。需要一定的方法去处理这个问题。

    rehash

    rehash过程概览

    1. rehas之前会根据已有的节点个数和dictEntry *数组的大小综合判断需要扩容还是缩容:
    • 当向字典添加节点时,会判断是否符合扩容条件
    • redis后台定时判断是否符合缩容条件
    1. 满足条件则分配一个足够的空间给ht[1],此时字典同时拥有两个dictEntry *数组
    2. 将字典中的rehashidx置0,表明开始rehash,将要迁移ht[0]数组中0位置的元素
      4.重算ht[0]中数组0位置元素里的全部节点在ht[1]的下标,并根据下标将节点放入ht[1],并更新rehashidx以便下次rehash
    3. 迁移完ht[0]中的全部节点,释放ht[0]中的数组,并用ht[1]替换ht[0],最后重置ht[1],并将rehashidx设为-1表示rehash结束

    redis在迁移dictEntry *数组时,并不是一次全部迁移完成的。而是一部分一部分迁移:

    • 查找、添加、更新、删除内部进行的是一步迁移,一次只迁移dictEntry *数组中一个元素/bucket(会迁移完其上的链表里的全部节点)
    static void _dictRehashStep(dict *d) {
        if (d->iterators == 0) dictRehash(d,1);    //当不存在安全迭代器时才触发一步迁移
    }
    • redis后台定时通过函数dictRehashMilliseconds迁移,这种迁移方式里,迁移1毫秒,并在1毫秒内一次试图迁移100个元素/bucket(会迁移完其上的链表里的全部节点)
    //dict.c
    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;
    }
    //server.c
    int incrementallyRehash(int dbid) {
    ...
    dictRehashMilliseconds(server.db[dbid].dict,1);
    dictRehashMilliseconds(server.db[dbid].expires,1);
    ...    
    }

    扩容、缩容

    为什么首先检查扩容、缩容条件

    扩容检查
    前面说到了redis字典有键冲突,字典中节点越多,重复的概率越大,链表也就可能越长。
    需要一个更大的数组,使得对现有节点重新计算hash并取余后,能尽量落到数组的空槽里,使时间复杂度从O(N)变为最初的O(1)。
    缩容检查
    由于节点数量一直在随着程序运行进行着动态增减,只有扩容没有缩容的话,势必会造成不必要的内存浪费。所以,需要对字典的空间进行缩调。

    容量计算

    为了使用位操作取余,容量(除数)的为2的N次方

    static unsigned long _dictNextPower(unsigned long size)
    {
        unsigned long i = DICT_HT_INITIAL_SIZE;    //#define DICT_HT_INITIAL_SIZE     4
    
        if (size >= LONG_MAX) return LONG_MAX + 1LU;
        while(1) {
            if (i >= size)
                return i;
            i *= 2;
        }
    }

    何时扩容、缩容

    扩容条件

    • 添加节点时,dictEntry *数组为空(字典刚创建)
    • ht[0]已有节点数大于数组大小,同时,开启了允许resize标志或已有节点数与数组大小之商大于5,即负载因子大于5
    static int _dictExpandIfNeeded(dict *d)
    {
        /* Incremental rehashing already in progress. Return. */
        //dict处于渐进式rehash状态不用扩容,是因为进行渐进式rehash的前置条件是扩容完成
        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);
    
        /* 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. */
        if (d->ht[0].used >= d->ht[0].size &&
            (dict_can_resize ||                                                            //static int dict_can_resize = 1;
             d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))    //static unsigned int dict_force_resize_ratio = 5;
        {
            return dictExpand(d, d->ht[0].used*2);    //指定已有节点的2倍扩容,ht[0].used*2的2的N次方扩容
        }
        return DICT_OK;
    }

    resize标志由server.c中updateDictResizePolicy控制

    /* This function is called once a background process of some kind terminates,
     * as we want to avoid resizing the hash tables when there is a child in order
     * to play well with copy-on-write (otherwise when a resize happens lots of
     * memory pages are copied). The goal of this function is to update the ability
     * for dict.c to resize the hash tables accordingly to the fact we have o not
     * running childs. */
    void updateDictResizePolicy(void) {
        if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
            dictEnableResize();
        else
            dictDisableResize();
    }

    结合注释与代码可以知道,由于redis想利用好写时复制,所以,当后台进程开始生成/重写RDB/AOF文件或结束生成/重写RDB/AOF文件,会调用此函数来关闭/开启字典的扩容。
    总结下来,也可以这样理解:
    服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 1
    服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 5
    缩容条件
    已有节点数与数组大小之商大于10%,即负载因子小于0.1时发生缩容

    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));    //#define HASHTABLE_MIN_FILL        10      /* Minimal hash table fill 10% */
    }

    缩容判断函数由redis定期调用

    rehash前奏:准备ht[1]

    ht[1]中数组空间的准备、以及rehash开启的标志 通过dictExpand来处理

    #define dictIsRehashing(d) ((d)->rehashidx != -1)
    ...
    int dictExpand(dict *d, unsigned long size)
    {
        dictht n; /* the new hash table */
        unsigned long realsize = _dictNextPower(size);    //从4开始找大于等于size的最小2的N次方做为新大小
    
        /* the size is invalid if it is smaller than the number of
         * elements already inside the hash table */
        //字典正在rehash时,ht[0]与ht[1]都有存在节点的可能,后面的赋值操作可能导致节点丢失,不允许扩容
        if (dictIsRehashing(d) || d->ht[0].used > size)
            return DICT_ERR;
    
        /* 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;    //ht[1]已准备好,可以从ht[0]的d->rehashidx处的bucket移动到ht[1]
        return DICT_OK;
    }

    如果是新创建的字典,dictEntry *数组是不会有任何容量的,扩容函数也是根据该数组是否为空,来确定是处理新字典还是准备rehash

    rehashing

    字典的rehash操作由dictRehash实现,此函数执行N步渐进式rehash,N决定了函数一次处理几个bucket(dictEntry *数组中的元素)

    int dictRehash(dict *d, int n) {
        //一次rehash只会访问最多10个空桶便会返回,empty_visits用于记录已访问空桶个数
        int empty_visits = n*10; /* Max number of empty buckets to visit. */
        //没有准备准备好ht[1]不能rehash
        if (!dictIsRehashing(d)) return 0;
        //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++;
                if (--empty_visits == 0) return 1;    //一次最多访问10个空桶
            }
            de = d->ht[0].table[d->rehashidx];
            /* Move all the keys in this bucket from the old to the new hash HT */
            while(de) {
                unsigned int h;
    
                nextde = de->next;
                /* Get the index in the new hash table */
                h = dictHashKey(d, de->key) & d->ht[1].sizemask;
                //将节点放入新哈希表数组table的h位置(如果形成了链表为头插)
                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的位置
        }
    
        /* 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;    //rehash完成
            return 0;
        }
    
        /* More to rehash... */
        return 1;
    }

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

    因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0] 和 ht[1] 两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。
    另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。

    迭代器

    dict中的迭代器有两种,一种是安全迭代器,另一种是非安全迭代器

    • 安全迭代器存在时,dict的查找、添加、更新、删除操作不会进行rehash,这避免了rehash造成的迭代顺序混乱。安全迭代器存在时,可以对dict进行增加、更新、查找、删除操作
    • 非安全迭代器存在时,只能对字典进行迭代,如果对字典进行了修改,会导迭代器的指纹前后不一致而触发断言

    迭代器的结构

    typedef struct dictIterator {
        //被迭代的字典
        dict *d;                   
        //bucket的位置(dictEntry *数组的下标)    
        long index;             
        //table ht的下标(创建迭代器前可能已经处于rehash状态,所以两个ht都需要遍历)   
        //safe表明当前迭代器的种类(安全或非安全)
        int table, safe;
        //当前迭代的节点 与 将迭代的节点
        //在迭代器游走函数dictNext中,当前的节点entry会被返回给用户,并可能被用户删除,保留nextEntry避免指针丢失
        dictEntry *entry, *nextEntry;    
        /* unsafe iterator fingerprint for misuse detection. */
        long long fingerprint;    //非安全迭代器使用的,用于验证的指纹
    } dictIterator;

    迭代器的获取与释放

    获取

    dictIterator *dictGetIterator(dict *d)
    {
        dictIterator *iter = zmalloc(sizeof(*iter));
    
        iter->d = d;
        iter->table = 0;
        iter->index = -1;
        iter->safe = 0;
        iter->entry = NULL;
        iter->nextEntry = NULL;
        return iter;
    }
    
    dictIterator *dictGetSafeIterator(dict *d) {
        dictIterator *i = dictGetIterator(d);
    
        i->safe = 1;
        return i;
    }

    释放

    void dictReleaseIterator(dictIterator *iter)
    {
        if (!(iter->index == -1 && iter->table == 0)) {
            if (iter->safe)
                iter->d->iterators--;
            else
                assert(iter->fingerprint == dictFingerprint(iter->d));    //迭代完成后,释放迭代器时校验指纹
        }
        zfree(iter);

    迭代器的游走方式

    dictEntry *dictNext(dictIterator *iter)
    {
        while (1) {
            //iter是一个全新迭代器或已迭代完bucket中的一个链表
            if (iter->entry == NULL) {
                //指向正在迭代的ht
                dictht *ht = &iter->d->ht[iter->table];
                //iter是个全新迭代器,是安全迭代器,增加被迭代dict上迭代器数量,否则字典计算指纹
                if (iter->index == -1 && iter->table == 0) {
                    if (iter->safe)
                        iter->d->iterators++;
                    else
                        iter->fingerprint = dictFingerprint(iter->d);
                }
                //当前bucket中的链表迭代完了,应该迭代下一个bucket,所以增加index,指向下一个bucket
                //若是新迭代器,则应该迭代首个bucket了,也需增加index,使其指向首个bucket
                iter->index++;
                //即将被迭代的bucket的下标大于当前ht的下标,分情况讨论
                if (iter->index >= (long) ht->size) {
                    //1、正在rehash时会有两个ht,当前迭代完的是ht[0],需要再迭代下ht[1]
                    if (dictIsRehashing(iter->d) && iter->table == 0) {
                        iter->table++;
                        iter->index = 0;
                        ht = &iter->d->ht[1];
                    } else {
                        //2、不在rehash状态,已经完成了迭代
                        break;    
                    }
                }
                //迭代到下个bucket(或首个bucket)内的链表头部
                iter->entry = ht->table[iter->index];
            } else {
                //在某个bucket中的链表内迭代
                iter->entry = iter->nextEntry;
            }
            //没有到链表位部,先记录下个节点的位置,再返回迭代到的节点,因为返回的迭代器可能被用户删除
            if (iter->entry) {
                /* We need to save the 'next' here, the iterator user
                 * may delete the entry we are returning. */
                iter->nextEntry = iter->entry->next;
                return iter->entry;
            }
        }
        return NULL;
    }

    使用迭代器遍历

    while((de = dictNext(di)) != NULL) {
        //   doSomethingWith(de);    
    }

    后记

    • 由于受当前哈希表空间的限制,节点数量增加到多于哈希表空间时,必定会发生键冲突,链地址法可以解决键冲突
    • 链地址法虽然可以解决键冲突,同时也增加了时间复杂度,需要通过rehash来处理这个问题
    • rehash也可以避免空间的浪费
    • 渐进式rehash可以避免一次性迁移太多节点而造成的的阻塞
    • 定时rehash可以避免字典同时拥有两个哈希表太久造成的性能损失
    • 合适的时候使用位运算会得到更好的性能
    • 通过linux系统的COW机制来在两个进程间共享内存时,避免修改太多内存,可减少内存复制量,从而更好的使用COW




    原创不易,转载请注明出处,谢谢
  • 相关阅读:
    springboot中,使用redisTemplate操作redis
    【转】手把手教你 Mockito 的使用
    【转】Java JUnit 单元测试小结
    【转】如何使用MAT分析内存泄漏
    free命令中的buffer和cached的比较(转)
    【转】java comparator 升序、降序、倒序从源码角度理解
    打印日志的10个建议
    hadoop控制map个数(转)
    Java工具库:
    Scala工具库
  • 原文地址:https://www.cnblogs.com/Keeping-Fit/p/14157290.html
Copyright © 2011-2022 走看看