Redis源码阅读笔记(三)dict字典结构

- dict字典简介

Redis中的dict字典简单来说就是key-value键值对数据,存储 方式是通过哈希表来实现的,哈希表是通过桶bucket来实现的,实现步骤是:首先规定一个哈希表大小size,然后将key通过哈希函数h=hash(key)得到的哈希值h通过h&(size-1)映射到哈希表索引中去,当两个h值映射成一个索引值时通过链表解决冲突。每次从表头插入数据。

dict字典实现主要有三个部分:

  • 哈希表节点结构:包含了一个键指针void *key和一个联合体表示的值数据,以及用于形成链表的指向下一个节点的指针next
  • 字典类型特定函数结构体:存放了用于处理键值的是实际函数指针,包括计算哈希值,复制键值,销毁键值,对比键内容
  • 哈希表结构:包含了一个哈希表节点数组(二维指针的形式),哈希表大小,哈希表掩码(用于计算索引)以及该哈希表已有的节点数目
  • 字典结构:字典结构是由两个哈希表实现的,其中除了两个哈希表还包括类型特定函数结构体指针,一个私有数据(目前不知道有何用),标志是否正在rehash的标志位以及安全迭代器数量。
  • 迭代器结构:用于迭代指定的字典,包括当前指向的哈希表及其索引,是否为安全迭代器的标志,具体节点指针,下一节点指针(因为有链表存在,所以索引和节点指针不能等同),当前字典状态的一个指纹。
- dict字典具体实现 ``` typedef struct dictEntry { void *key; //键 union { //值 void *val; uint64_t u64; int64_t s64; double d; } v; struct dictEntry *next; // 指向下个哈希表节点,形成链表 } dictEntry;

typedef struct dictType {
unsigned int (*hashFunction)(const void *key); //计算哈希值的函数 返回一个值
void *(*keyDup)(void *privdata, const void key); //复制键的函数 返回一个void
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 dictht {
dictEntry **table; // 哈希表节点数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值 // 总是等于 size - 1
unsigned long used;// 该哈希表已有节点的数量
} dictht;

typedef struct dict {
dictType type; / 类型特定函数*/
void privdata; /私有数据/
dictht ht[2]; /
这不是指针!!就是两个表不需要自己释放,通过结构体析构*/
/哈希表ht[0]为当前使用的哈希表,ht[1]当该字典在rehash时则插入ht[1]/
long rehashidx; /* 当 rehash 不在进行时,值为 -1,在进行时值为进行到的位置 /
int iterators; /
目前正在运行的安全迭代器的数量*/
} dict;

typedef struct dictIterator {
dict *d; //被迭代的字典
long index; //迭代器当前指向的哈希表索引位置
int table, safe; //table为正在被迭代的哈希表号码0或者1
dictEntry *entry, *nextEntry; //当前的迭代器指针以及下一个节点指针
// 因为在安全迭代器运作时, entry 所指向的节点可能会被修改,
// 所以需要一个额外的指针来保存下一节点的位置,
// 从而防止指针丢失
long long fingerprint; //是当前字典状态的哈希值,用于非安全的迭代器
} dictIterator;

为什么要使用两个哈希表来实现一个字典?
为了渐进rehash,为了可以在对字典的操作过程中同时进行rehash,这样就避免了当字典哈希表过于拥挤时不至于耗费大量的时间进行一次性的rehash。

如何实现一边操作一边rehash的呢?
当在对字典进行操作时,例如进行插入操作,首先会进行判断,该字典是否处于rehash中,若没有在rehash就直接将数据插入ht[0],若正在rehash就执行一次单步rehash,然后将数据插入ht[1]。

rehash的激活条件呢?
每当往字典中添加元素时就会做一次判断,看字典是否需要扩展(当然如果正在rehash中那么就不需要了),激活条件为以下两个条件任意一个为真:
1.字典中哈希表已使用节点数和哈希表大小之间的比率接近1:1同时dict_can_resize为真;
2.已使用节点数和哈希表大小之间的比率超过dict_force_resize_ratio;


<h6>- dict字典的哈希策略

所谓[哈希策略](https://blog.csdn.net/jasper_xulei/article/details/18364313),就是哈希函数的选取,redis中使用的哈希算法总共有四种种,一种是用于给数字哈希的32 bit Mix Function,一种是用于生成字典指纹的类似于上面一种的64 bit Mix Function,还有两种是用于处理字符串,针对字符串得到哈希值的MurmurHash2算法和djb算法。哈希函数的选取遵循两个策略:可逆性和雪崩效应。

- 可逆性:一个好的哈希函数应该是可逆的。即,对于哈希函数输入值x和输出值y,如果存在f(x) = y,就一定存在g(y) = x。说白了,就是哈希函数可以将某一个值x转换成一个key,也可以把这个key还原回成x。通常**加减、异或、取非和相乘及其组合**都是可逆的。
- 雪崩效应:一个好的哈希函数应该容易造成雪崩效应。这里的雪崩效应是从比特位的角度出发的,它指的是,输入值1bit位的变化会造成输出值1/2的bit位发生变化。

/* Thomas Wang’s 32 bit Mix Function 哈希函数 可逆性与雪崩效应*/
unsigned int dictIntHashFunction(unsigned int key)
{
key += ~(key << 15);
key ^= (key >> 10);
key += (key << 3);
key ^= (key >> 6);
key += ~(key << 11);
key ^= (key >> 16);
return key;
}

//创建指纹函数,使用了64BITmixfunction来作为哈希函数,用于保证字典唯一性,当字典的哈希表发生变化时则该哈希值会变化
long long dictFingerprint(dict *d) {
long long integers[6], hash = 0;
int j;
integers[0] = (long) d->ht[0].table;
integers[1] = d->ht[0].size;
integers[2] = d->ht[0].used;
integers[3] = (long) d->ht[1].table;
integers[4] = d->ht[1].size;
integers[5] = d->ht[1].used;

/*将dict结构体中的几个状态放入到数组中,以便后面应用到64 bit MixFunctions中。
 *dict结构体其实就是一个hash表的实现,而这些状态其实就是第一、第二哈希表的表地址、表大小与
 *已用条目的数量
 *
 *利用64 bit Mix Functions,将这些状态信息混合到hash中,组成最后的指纹,如果这些状态中有一个
 *出现变化,可以通过一个算法逆推出该状态变化之前的值。例如,d->ht[0].size发生变化,则我们可
 *以通过hash和其他的几个状态,逆推出d->ht[0].size的最初值。
 * Result = hash(hash(hash(int1)+int2)+int3) ...
 *
 * This way the same set of integers in a different order will (likely) hash
 * to a different number. */
for (j = 0; j < 6; j++) {
    hash += integers[j];
    /* For the hashing step we use Tomas Wang's 64 bit integer hash. */
    hash = (~hash) + (hash << 21); // hash = (hash << 21) - hash - 1;
    hash = hash ^ (hash >> 24);
    hash = (hash + (hash << 3)) + (hash << 8); // hash * 265
    hash = hash ^ (hash >> 14);
    hash = (hash + (hash << 2)) + (hash << 4); // hash * 21
    hash = hash ^ (hash >> 28);
    hash = hash + (hash << 31);
}
return hash;

}

/* MurmurHash2, by Austin Appleby
*对于字符串形式的key,redis使用了MurmurHash2算法和djb算法:
*该算法的基本思想就是把key分成n组,每组4个字符,把这4个字符看成是一个uint_32,
*进行n次运算,得到一个h,然会在对h进行处理,得到一个相对离散的哈希结果。代码中
*出现了一些如dict_hash_function_seed、m和r的常数变量,别问我问什么选这些数,就
*像英文注释中写得那样,他们就是效果好
*
*这是针对字符串形式的键给出哈希函数,len为字符串长度应该是
*/
unsigned int dictGenHashFunction(const void key, int len) {
/
‘m’ and ‘r’ are mixing constants generated offline.
They’re not really ‘magic’, they just happen to work well. */
uint32_t seed = dict_hash_function_seed;
const uint32_t m = 0x5bd1e995;
const int r = 24;

/* Initialize the hash to a 'random' value 获取一个随机值*/
uint32_t h = seed ^ len;

/* Mix 4 bytes at a time into the hash 强转成1byte的无符号char指针*/
const unsigned char *data = (const unsigned char *)key;

while(len >= 4) {
    uint32_t k = *(uint32_t*)data;   //四比特四比特提取并进行操作

    k *= m;
    k ^= k >> r;
    k *= m;

    h *= m;
    h ^= k;

    data += 4;     //往后移4个比特
    len -= 4;
}

/* Handle the last few bytes of the input array  处理最后几个比比特*/
switch(len) {
case 3: h ^= data[2] << 16;
case 2: h ^= data[1] << 8;
case 1: h ^= data[0]; h *= m;
};

/* Do a few final mixes of the hash to ensure the last few
 * bytes are well-incorporated.再搞几次确保最后几个比特很好的融入 */
h ^= h >> 13;
h *= m;
h ^= h >> 15;

return (unsigned int)h;

}

//djb哈希算法很简单,也是处理字符串哈希的,只有几行代码,其功能与MurmurHash类似,都是将字符串转换为hash值
//实现了不区分大小写的散列函数
unsigned int dictGenCaseHashFunction(const unsigned char *buf, int len) {
unsigned int hash = (unsigned int)dict_hash_function_seed;

while (len--)
    hash = ((hash << 5) + hash) + (tolower(*buf++)); 
    /* 就是这个式子:hash * 33 + c  进行len遍*/
return hash;

}

<h6>- [dict字典的遍历](https://blog.csdn.net/breaksoftware/article/details/53509986)

-  **迭代器遍历:**
由于Redis字典库有rehash机制,而且是渐进式的,所以迭代器操作可能会通过其他特殊方式来实现,以保证能遍历到所有数据。但是阅读完源码发现,其实这个迭代器是个受限的迭代器,实现方法也很简单。

typedef struct dictIterator {
dict *d;
long index;
int table, safe;
dictEntry *entry, *nextEntry;
long long fingerprint;
} dictIterator;

成员变量d指向迭代器处理的字典。
index是dictht中table数组的下标。
table是dict结构中dictht数组的下标,即标识ht[0]还是ht[1]。
safe字段用于标识该迭代器是否为一个安全的迭代器。如果是,则可以在迭代过程中使用dictDelete、dictFind等方法;如果不是,则只能使用dictNext遍历方法。
entry和nextEntry分别指向当前的元素和下一个元素。
fingerprint是字典的指纹,使用了ht[0]和ht[1]的相关信息进行Hash运算,从而得到该字典的指纹。如果dictht的table、size和used任意一个有变化,则指纹将被改变。这也就意味着,扩容、锁容、rehash、新增元素和删除元素都会改变指纹(除了修改元素内容)

遍历迭代器的操作:
1.如果是初次迭代,则要查看是否是安全迭代器,如果是安全迭代器则让其对应的字典对象的iterators自增;如果不是则记录当前字典的指纹;
2.要遍历的时候,字典也可能已经处于rehash的中间状态,所以还要遍历ht[1]中的元素;
3.往往使用迭代器获得元素后,会让字典删除这个元素,这个时候就无法通过迭代器获取下一个元素了,于是设计了nextEntry来记录当前对象的下一个对象指针;
4. 遍历完成后,要释放迭代器。需要注意的是,如果是安全迭代器,就需要让其指向的字典的iterators自减以还原;如果不是,则需要检测前后字典的指纹是否一致(说明了非安全的迭代器不可以改变字典)
什么是安全迭代器?
源码中我们看到如果safe为1,则让字典iterators自增,这样dict字典库中的操作就不会触发rehash渐进,从而在一定程度上(消除rehash影响,但是无法阻止用户删除元素)保证了字典结构的稳定。如果不是安全迭代器,则只能使用dictNext方法遍历元素,而像获取元素值的dictFetchValue方法都不能调用。因为dictFetchValue底层会调_dictRehashStep让字典结构发生改变。

> 但是作者在源码说明中说安全迭代器在迭代过程中可以使用dictAdd方法,但是我觉得这个说法是错误的。因为dictAdd方法插入的元素可能在当前遍历的对象之前,这样就在之后的遍历中无法遍历到;也可能在当前遍历的对象之后,这样就在之后的遍历中可以遍历到。这样一种动作,两种可能结果的方式肯定是有问题的。我查了下该库在Redis中的应用,遍历操作不是为了获取值就是为了删除值,而没有增加元素的操作


- **高级遍历:**
 高级遍历允许ht[0]和ht[1]之间数据在迁移过程中进行遍历,通过相应的算法可以保证所有的元素都可以被遍历到。

unsigned long dictScan(dict *d, unsigned long v, dictScanFunction *fn, void *privdata)
{
dictht *t0, *t1;
const dictEntry *de;
unsigned long m0, m1;

if (dictSize(d) == 0) return 0;   //跳过空字典
//迭代只有一个ht0的字典,那就很简单了
if (!dictIsRehashing(d)) {
    t0 = &(d->ht[0]);      //当前迭代哈希表
    m0 = t0->sizemask;      //当前掩码

    /* Emit entries at cursor */
    de = t0->table[v & m0];   //获取当前迭代器v的一个索引值v&m0并且找到节点  ,如果是空的就是空的这个迭代器迭代的是索引而不是实际数据所以空的就是空的
    while (de) {              //后面会返回迭代器v的下一个值 就是高位+1向低位进位的
        fn(privdata, de);
        de = de->next;
    }

} else {
    t0 = &d->ht[0];
    t1 = &d->ht[1];

    /* Make sure t0 is the smaller and t1 is the bigger table */
    if (t0->size > t1->size) {
        t0 = &d->ht[1];
        t1 = &d->ht[0];
    }
	//t0指向小的那个哈希表,t1指向大的那个哈希表
    m0 = t0->sizemask;
    m1 = t1->sizemask;

    /* Emit entries at cursor */
    de = t0->table[v & m0];   //遍历小的那个
    while (de) {
        fn(privdata, de);
        de = de->next;
    }
	 //小的找过之后根据它扩展成的大的,去找大的索引,
	 //比如说v本身为它在8长度小表中索引为abc,
	 //找完之后再找32长度大表中索引11abc 01abc 10abc 00abc 
	 //这样才能确保可以遍历完大小表所有建
    do {
        /* Emit entries at cursor */
        de = t1->table[v & m1];
        while (de) {
            fn(privdata, de);
            de = de->next;
        }

        /* Increment bits not covered by the smaller mask */
        v = (((v | m0) + 1) & ~m0) | (v & m0);

        /* Continue while bits covered by mask difference is non-zero */
    } while (v & (m0 ^ m1));
}
//高级遍历中使用的返回下一次遍历的迭代器,允许数据在从ht0到ht1迁移过程中进行遍历

//它的方式是:反转 +1 再反转 等同于让高位+1然后向低位进位
v |= ~m0; //为了得到V的低m0位,其实就是v&mask的效果,的时候能够
//得到进位,如果高位全0那么翻转+1实际的低n位得不到计算

v = rev(v);
v++;
v = rev(v);
return v;

}

[dict.c中的dictScan函数](https://blog.csdn.net/gqtcgq/article/details/50533336),用来遍历字典,迭代其中的每个元素。该函数使用的算法非常精妙!!!所以必须记录一下。

遍历一个稳定的字典,当然不是什么难事,但Redis中的字典因为有rehash的过程,使字典可能扩展,也可能缩小。这就带来了问题,如果在两次遍历中间,字典的结构发生了变化(扩展或缩小),字典中的元素所在的位置相应的会发生变化,那如何保证字典中原有的元素都可以被遍历?又如何能尽可能少的重复迭代呢?

这就是该算法的精妙所在,使用该算法,可以做到下面两点:
1.开始遍历那一刻的所有元素,只要不被删除,肯定能被遍历到,不管字典扩展还是缩小;
2.该算法可能会返回重复元素,但是已经把返回重复元素的可能性降到了最低;

该算法使用了游标cursor来遍历字典,它表示本次要访问的bucket的索引。bucket中保存了一个链表,因此每次迭代都会把该bucket的链表中的所有元素都遍历一遍。

第一次迭代时,cursor置为0,dictScan函数的返回值作为下一个cursor再次调用dictScan,最终,dictScan函数返回0表示迭代结束。

首先看一下cursor的演变过程,也是该算法的核心所在。这里cursor的演变是采用了reverse binary iteration方法,也就是每次是向cursor的最高位加1,并向低位方向进位。

这样设计的原因就在于,字典中的哈希表有可能扩展,也有可能缩小。在字典不稳定的情况下,既要遍历到所有没被删除的元素,又要尽可能较少的重复遍历。

如果字典当前没有rehash,则比较简单,直接根据v找到需要迭代的bucket索引,针对该bucket中链表中的所有节点,调用用户提供的fn函数。

如果字典当前正在rehash,则需要先遍历较小的哈希表,然后是较大的哈希表。

首先使t0指向小表,t1指向大表;m0为小表的mask,m1为大表的mask。

根据v&m0,找到t0中需要迭代的bucket,然后迭代其中的每个节点即可。

接下来的代码稍显复杂,但是,本质上,就是t0中,索引为v&m0的bucket中的所有节点,再其扩展到t1中后,遍历其所有可能的bucket中的节点。语言不好描述,举个例子就明白了:若t0长度为8,则m0为111,v&m0就是保留v的低三位,假设为abc。若t1长度为32,则m1为11111,该过程就是:遍历完t0中索引为abc的bucket之后,接着遍历t1中,索引为00abc、01abc、10abc、11abc的bucket中的节点。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值