Redis源码(三)——字典


一、字典(dictionary)的定义与实现


  在使用Redis中,我们会经常用到哈希(hash)对象。哈希对象中存在key、field和value,每一个field都对应一个value。我曾经使用哈希对象来缓存不同平台上的书籍数据——平台id为key,书的id为field,需要的数据为value。哈希对象的底层就是通过字典来实现的。


  src下的dict.h和dict.c中定义了字典:

/*
 * 字典
 */
typedef struct dict {
 
    // 类型特定函数
    // 私有数据
   dictType *type;
 
    dicththt[2];
    void*privdata;
 
    // 哈希表
 
    // 当 rehash 不在进行时,值为 -1
    //rehash 索引
    intrehashidx; /* rehashing not in progress if rehashidx == -1 */
 
    // 目前正在运行的安全迭代器的数量
} dict;
    intiterators; /* number of iterators currently running */

dict结构体中有一个type属性是指向dictType结构体的指针,dictType中保存了一系列操作特定类型键值对的函数:

/*
 * 字典类型特定函数
 */
typedef struct dictType {
 
    // 计算哈希值的函数
   unsigned int (*hashFunction)(const void *key);
 
    // 复制键的函数
    // 复制值的函数
    void*(*keyDup)(void *privdata, const void *key);
 
    int(*keyCompare)(void *privdata, const void *key1, const void *key2);
    void*(*valDup)(void *privdata, const void *obj);
 
    // 对比键的函数
 
    // 销毁键的函数
    void(*valDestructor)(void *privdata, void *obj);
    void(*keyDestructor)(void *privdata, void *key);
   
    // 销毁值的函数
 
} dictType;

ht[2]是大小为2的dictht数组,dictht结构是哈希表,一般字典只使用ht[0]的哈希表,只有在rehash时会用到ht[1]:

/*
 * 哈希表
 *
 * 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
 */
typedef struct dictht {
   
 
    // 哈希表数组
   dictEntry **table;
    // 哈希表大小掩码,用于计算索引值
    // 哈希表大小
   unsigned long size;
   
 
    // 总是等于 size - 1
   unsigned long sizemask;
} dictht;
    // 该哈希表已有节点的数量
   unsigned long used;

哈希表dictht中最重要的属性是table数组,数组中的元素是指向dictEntry结构体的指针,dictEntry是哈希表的节点,保存着键值对(key-value);size属性表示哈希表的大小,即table数组的长度;used表示哈希表现存的节点数目;sizemask用于在对key进行hash时使用:

/*
 * 哈希表节点
 */
typedef struct dictEntry {
   
    // 键
    void*key;
 
       void *val;
    // 值
    union {
       int64_t s64;
       uint64_t u64;
    } v;
 
    structdictEntry *next;
    // 指向下个哈希表节点,形成链表
 
} dictEntry;

哈希表节点中除了存放着key以及value,还有指向下个节点的指针,为什么要让哈希节点形成链表呢?接触过数据结构的同学应该知道,哈希表的存储是通过hash算法来完成的,那么有时就会出现hash碰撞(即两个key的hash值一直,那么两个key就会存放在哈希表中同一位置,如果不做特殊处理,只有一个key能存在,这即是hash碰撞),最典型解决hash碰撞的方法是链表法——即将关键字为同一值的这些key链接在一条链表中,且后面加入链表的节点会放在链表开头,那么通过迭代链表就可以获取那些关键字为同一值的节点。

 

 

二、字典内部的重点逻辑

 

   1. 哈希算法


    将key加入到字典时,需要对key值进行hash计算,然后将得到的hash值与哈希表中的sizemask(之所以要用数组size-1,是因为索引是从0开始的)属性进行“按位与”得到最终的索引值,然后将此key及对应的value放入对应索引的数组中:

// 计算给定键的哈希值
#definedictHashKey(d, key) (d)->type->hashFunction(key)
 
/* Getthe index in the new hash table */
h =dictHashKey(d, de->key) & d->ht[x].sizemask;
// 计算哈希表的哈希值,以及节点插入的索引位置,x可以是0或者1

Redis底层默认使用MurmurHash2算法来计算哈希值:

/* MurmurHash2, by Austin Appleby
 * Note -This code makes a few assumptions about how your machine behaves -
 * 1. Wecan read a 4-byte value from any address without crashing
 * 1. Itwill not work incrementally.
 * 2.sizeof(int) == 4
 *
 * And ithas a few limitations -
 *
 *    machines.
 * 2. Itwill not produce the same results on little-endian and big-endian
 */
    They're not really 'magic', they just happen to work well.  */
unsigned int dictGenHashFunction(const void *key,int len) {
    /* 'm'and 'r' are mixing constants generated offline.
    /*Initialize the hash to a 'random' value */
   uint32_t seed = dict_hash_function_seed;
    constuint32_t m = 0x5bd1e995;
    constint r = 24;
 
   uint32_t h = seed ^ len;
 
       uint32_t k = *(uint32_t*)data;
    /* Mix4 bytes at a time into the hash */
    constunsigned char *data = (const unsigned char *)key;
 
   while(len >= 4) {
 
        k*= m;
        k^= k >> r;
    case 3:h ^= data[2] << 16;
        k*= m;
 
        h*= m;
        h^= k;
 
       data += 4;
        len-= 4;
    }
 
    /*Handle the last few bytes of the input array */
   switch(len) {
    h ^= h>> 13;
    case 2:h ^= data[1] << 8;
    case 1:h ^= data[0]; h *= m;
    };
 
    /* Do afew final mixes of the hash to ensure the last few
     *bytes are well-incorporated. */
    h *= m;
}
    h ^= h>> 15;
 
    return(unsigned int)h;

    2. rehash(再散列)


随着使用,哈希表中的键值对会增多或减少,为了保证空间及性能,在需要时需要对哈希表进行扩展或收缩:

/* Expand or create the hash table */
/*
 *
 * 创建一个新的哈希表,并根据字典的情况,选择以下其中一个动作来进行:
 * 2) 如果字典的 0 号哈希表非空,那么将新哈希表设置为 1 号哈希表,
 * 1) 如果字典的 0 号哈希表为空,那么将新哈希表设置为 0 号哈希表
 * size 参数不够大,或者 rehash 已经在进行时,返回 DICT_ERR 。
 *    并打开字典的 rehash 标识,使得程序可以开始对字典进行 rehash
 *
 *
int dictExpand(dict *d, unsigned long size)
 * 成功创建 0 号哈希表,或者 1 号哈希表时,返回 DICT_OK 。
 *
 * T = O(N)
 */
{
    // 新哈希表
    unsigned long realsize =_dictNextPower(size);
    dictht n; /* the new hash table*/
 
    // 根据 size 参数,计算哈希表的大小
    // T = O(1)
 
    // 不能在字典正在 rehash 时进行
    /* the size is invalid if it issmaller than the number of
     * elements already inside thehash table */
    // size 的值也不能小于 0 号哈希表的当前已使用节点
    /* Allocate the new hash tableand initialize all pointers to NULL */
    if (dictIsRehashing(d) ||d->ht[0].used > size)
        return DICT_ERR;
 
    // 为哈希表分配空间,并将所有指针指向 NULL
    n.size = realsize;
    n.sizemask = realsize-1;
    // T = O(N)
     * we just set the first hashtable so that it can accept keys. */
    n.table =zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
 
    /* Is this the firstinitialization? If so it's not really a rehashing
    // 如果 0 号哈希表为空,那么这是一次初始化:
    // 程序将新哈希表赋给 0 号哈希表的指针,然后字典就可以开始处理键值对了。
    // 并将字典的 rehash 标识打开,让程序可以开始对字典进行 rehash
    if (d->ht[0].table == NULL){
        d->ht[0] = n;
        return DICT_OK;
    }
 
    /* Prepare a second hash tablefor incremental rehashing */
    // 如果 0 号哈希表非空,那么这是一次 rehash :
    // 程序将新哈希表设置为 1 号哈希表,
    d->ht[1] = n;
    return DICT_OK;
    d->rehashidx = 0;
    return DICT_OK;
 
    /* 顺带一提,上面的代码可以重构成以下形式:
   
    if (d->ht[0].table == NULL){
        // 初始化
        d->ht[0] = n;
    } else {
        // rehash
        d->ht[1] = n;
        d->rehashidx = 0;
    }
 
 
    */
}

上述是根据传入的size创建一个相应大小的新的hash表。计算新hash表的大小是通过dictNextPower函数得到的,逻辑是计算第一个大于等于size的2次幂的数

/* Our hash table capability is a power of two */
/*
 * 计算第一个大于等于 size 的 2 的 N 次方,用作哈希表的值
 *
static unsigned long _dictNextPower(unsigned longsize)
 * T = O(1)
 */
{
    if(size >= LONG_MAX) return LONG_MAX;
   unsigned long i = DICT_HT_INITIAL_SIZE;
 
   while(1) {
    }
        if(i >= size)
           return i;
        i*= 2;
}

在dictExpand函数中,如果字典的0号哈希表为空,那么将新哈希表设置为0号哈希表,那么此次操作其实就是一次初始化;否则,将新哈希表设置为1号哈希表,这个过程就是一次rehash。当在rehash时,d->rehashidx= 0,rehashidx的值为0,代表此时可以进行rehash。rehash的目的是当第一个哈希表不满足我们的需求(空间太少或太空),此时就需要新建立一个哈希表,并将现在哈希表的数据重新映射到新的哈希表中,具体的步骤如下:


1) 首先是为新的哈希表分配空间,分配策略已经在上面讲述了


2) 将ht[0]中的所有数据再次计算哈希值和索引(这个过程即再散列,rehash)后放入新的哈希表中


3) 当数据全部完成迁移后,那么释放掉ht[0],并将ht[1]设置成ht[0],并让ht[1]成为一个空的哈希表


这里有一个问题——当源哈希表中数据比较少时,我们可以一次性迁移完,那么当数据很多时,如1亿,10亿呢?这么大的数据量如果要一次性迁移完,那么整个服务都会出现卡顿。为了解决这个问题,Redis使用了渐进式的rehash策略——rehash是多次、渐进地完成的

/* Performs N steps of incremental rehashing. Returns 1 if there arestill
 * keys to move from the old to thenew hash table, otherwise 0 is returned.
 *
 * 执行 N 步渐进式 rehash 。
 *
 * 返回 1 表示仍有键需要从 0 号哈希表移动到 1 号哈希表,
 * Note that a rehashing stepconsists in moving a bucket (that may have more
 * 返回 0 则表示所有键都已经迁移完毕。
 *
 * 注意,每步 rehash 都是以一个哈希表索引(桶)作为单位的,
 * than one key as we use chaining)from the old to the new hash table.
 *
 * 一个桶里可能会有多个节点,
    // 只可以在 rehash 进行中时执行
 * 被 rehash 的桶里的所有节点都会被移动到新哈希表。
 *
 * T = O(N)
 */
int dictRehash(dict *d, int n) {
 
        /* Check if we alreadyrehashed the whole table... */
    if (!dictIsRehashing(d)) return0;
 
    // 进行 N 步迁移
    // T = O(N)
    while(n--) {
        dictEntry *de, *nextde;
 
           zfree(d->ht[0].table);
        // 如果 0 号哈希表为空,那么表示 rehash 执行完毕
        // T = O(1)
        if (d->ht[0].used == 0){
            // 释放 0 号哈希表
            // 将原来的 1 号哈希表设置为新的 0 号哈希表
            // 返回 0 ,向调用者表示 rehash 已经完成
            d->ht[0] = d->ht[1];
            // 重置旧的 1 号哈希表
           _dictReset(&d->ht[1]);
            // 关闭 rehash 标识
            d->rehashidx = -1;
            return 0;
        }
 
        assert(d->ht[0].size> (unsigned)d->rehashidx);
        /* Note that rehashidxcan't overflow as we are sure there are more
         * elements becauseht[0].used != 0 */
        // 确保 rehashidx 没有越界
 
        // 略过数组中为空的索引,找到下一个非空索引
       while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
 
            // 保存下个节点的指针
        // 指向该索引的链表表头节点
        de =d->ht[0].table[d->rehashidx];
        /* Move all the keys inthis bucket from the old to the new hash HT */
        // 将链表中的所有节点迁移到新哈希表
        // T = O(1)
        while(de) {
            unsigned int h;
 
            nextde = de->next;
 
            d->ht[0].used--;
            /* Get the index in thenew hash table */
            // 计算新哈希表的哈希值,以及节点插入的索引位置
            h = dictHashKey(d,de->key) & d->ht[1].sizemask;
 
            // 插入节点到新哈希表
            de->next =d->ht[1].table[h];
            d->ht[1].table[h] =de;
 
            // 更新计数器
            d->ht[1].used++;
 
}
            // 继续处理下个节点
            de = nextde;
        }
        // 将刚迁移完的哈希表索引的指针设为空
       d->ht[0].table[d->rehashidx] = NULL;
        // 更新 rehash 索引
        d->rehashidx++;
    }
 
    return 1;

当rehashidx的值设为1时,代表rehash正式开始,然后在rehash期间对字典执行的增删查更新操作时,会将ht[0]哈希表中rehashidx索引处的数据经过rehash后放入ht[1]中,然后rehashidx的值自增;当ht[0]中所有的数据都rehash至ht[1],rehashidx的值被设为-1,rehash操作已完成

/* This function performs just a step ofrehashing, and only if there are
 * no safeiterators bound to our hash table. When we have iterators in the
 * middleof a rehashing we can't mess with the two hash tables otherwise
 * 在字典不存在安全迭代器的情况下,对字典进行单步 rehash 。
 * someelement can be missed or duplicated.
 *
 *
 * 字典有安全迭代器的情况下不能进行 rehash,
 * 因为两种不同的迭代和修改操作可能会弄乱字典。
 *
 *dictionary so that the hash table automatically migrates from H1 to H2
 * Thisfunction is called by common lookup or update operations in the
 * while itis actively used.
 *
 * 这个函数被多个通用的查找、更新操作调用,
 * 它可以让字典在被使用的同时进行 rehash。
 *
 * T = O(1)
 */
}
static void _dictRehashStep(dict *d) {
    if(d->iterators == 0) dictRehash(d,1);

上面是在字典中查找对应key的节点的函数,其中有一行代码是这样的:if(dictIsRehashing(d)) _dictRehashStep(d);这即是渐进式rehash的应用,这个操作让rehash期间,对字典的查找操作时顺带进行rehash。_dictRehashStep(d)中调用了dictRehash函数,实现了单步的rehash,dictRehashStep函数如下:

/* This function performs just a step ofrehashing, and only if there are
 * no safeiterators bound to our hash table. When we have iterators in the
 * middleof a rehashing we can't mess with the two hash tables otherwise
 * 在字典不存在安全迭代器的情况下,对字典进行单步 rehash 。
 * someelement can be missed or duplicated.
 *
 *
 * 字典有安全迭代器的情况下不能进行 rehash,
 * 因为两种不同的迭代和修改操作可能会弄乱字典。
 *
 *dictionary so that the hash table automatically migrates from H1 to H2
 * Thisfunction is called by common lookup or update operations in the
 * while itis actively used.
 *
 * 这个函数被多个通用的查找、更新操作调用,
 * 它可以让字典在被使用的同时进行 rehash。
 *
 * T = O(1)
 */
}
static void _dictRehashStep(dict *d) {
    if(d->iterators == 0) dictRehash(d,1);


三、总结

 

    看完字典的代码,不仅为Redis作者的思路叹服!我们知道C语言是单线程的,那么通过渐进式hash这种多次hash操作就比较好的平衡了rehash时的效率问题。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值