redis 数据结构基础 (三) hash表

hash表是最常见的数据结构,有趣的是,虽然hash函数被如此多的地方所使用,然而hash函数的原理却是一个不折不扣的数学问题。

http://blog.csdn.net/youyiyang/article/details/48522979 是一篇介绍hash函数很好的文章,非常值得一读。本文仅仅涉及hash函数的实现和应用。

hash函数本质上是一种单向函数映射,即y  = f(x)的计算,当x确定时,可以得出唯一的y值,然而,当y确定时,却不一定能得到x值,就算你知道f(x)的形式(就是hash算法)也不可以(比如5次方程)

通常情况下,hash函数可以将任意的数据映射成固定的

在工程应用中,hash函数最常见的有以下运用场景:

1、数据校验: 有点类似于CRC32冗余校验,比如传输文件后,可以通过hash算法来计算出文件的hash值,再和文件的发送方公布的hash值进行对比

2、单向性运用: 最常见的场合是密码存储,服务器端存储用户的密码,实际上存储的是用户密码的hash值,这样,就算破解得到了服务器方的密码库,也无法反推出用户的明文密码。

3、搜索:比如C++11所提供的新的unordered_map,在处理查找算法时,据称效率比原有的map类速度提高了5倍,不过相比较map而言,其劣势在于每个key 不支持 operator < 运算,存储的数据是无序的,不能进行范围查找。

显然, hash函数在redis中主要用的是第三种用途,比较坑的是,redis中的hash相关的文件有2个,都叫做dict.c & dict.h, 里面的很多接口也长的比较像,后来才明白,一个用作客户端,一个用作服务器端

redis中的hash函数不是一个,而是由多个组成的,我们看一个最简单的:

1 static unsigned int dictGenHashFunction(const unsigned char *buf, int len) {
2     unsigned int hash = 5381;
3 
4     while (len--)
5         hash = ((hash << 5) + hash) + (*buf++); /* hash * 33 + c */
6     return hash;
7 }

上述算法又被称为djb hash算法,这个算法有以下特点:

1、只涉及位移运算和加法运算,时间复杂度同len成正比,执行速度那是杠杠的

2、对字符串的效果非常好,(不要问我为什么是33,我猜测是个经验值),6个字符以内的小写字母组成的字符串可以做到完全不冲突

3、从算法中我们也可以得出,有hash值反推原始字符串是不可能的

掌握了以上的hash函数,基本上,就没多大问题了。不过,redis中的hash函数可不止这一个,还有一个,就是MurmurHash64A函数,这个就是大名鼎鼎的MurmurHash函数,这个函数是由Austin Appleby创建的,这位大神后来也去了Google,目前Redis中使用的版本是MurmurHash2的64位版本,而MurmurHash函数目前已经发展到了V3,据说效率比v2要高一些。遗憾的是,仅有32bit和128bit两个版本。

下面给出MurmurHash64的源代码,算法具体是什么原理,我也不清楚

 1 uint64_t MurmurHash64A (const void * key, int len, unsigned int seed) {
 2     const uint64_t m = 0xc6a4a7935bd1e995;
 3     const int r = 47;
 4     uint64_t h = seed ^ (len * m);
 5     const uint8_t *data = (const uint8_t *)key;
 6     const uint8_t *end = data + (len-(len&7));
 7 
 8     while(data != end) {
 9         uint64_t k;
10 
11 #if (BYTE_ORDER == LITTLE_ENDIAN)
12     #ifdef USE_ALIGNED_ACCESS
13     memcpy(&k,data,sizeof(uint64_t));
14     #else
15         k = *((uint64_t*)data);
16     #endif
17 #else
18         k = (uint64_t) data[0];
19         k |= (uint64_t) data[1] << 8;
20         k |= (uint64_t) data[2] << 16;
21         k |= (uint64_t) data[3] << 24;
22         k |= (uint64_t) data[4] << 32;
23         k |= (uint64_t) data[5] << 40;
24         k |= (uint64_t) data[6] << 48;
25         k |= (uint64_t) data[7] << 56;
26 #endif
27 
28         k *= m;
29         k ^= k >> r;
30         k *= m;
31         h ^= k;
32         h *= m;
33         data += 8;
34     }
35 
36     switch(len & 7) {
37     case 7: h ^= (uint64_t)data[6] << 48;
38     case 6: h ^= (uint64_t)data[5] << 40;
39     case 5: h ^= (uint64_t)data[4] << 32;
40     case 4: h ^= (uint64_t)data[3] << 24;
41     case 3: h ^= (uint64_t)data[2] << 16;
42     case 2: h ^= (uint64_t)data[1] << 8;
43     case 1: h ^= (uint64_t)data[0];
44             h *= m;
45     };
46 
47     h ^= h >> r;
48     h *= m;
49     h ^= h >> r;
50     return h;
51 }
MurmurHash64A

这个算法由以下的特点:

1、涉及到了位移,乘法、异或三种运算,

2、提供了一个种子数,显然,种子数对hash值有重大的影响

3、高性能、低碰撞, 据google公司的实际测验的结果表明,在10位以上的字符串hash过程中,效率比djb算法更高。(所以说,效率这个东西,一定要测试才来的准,没有性能测试的软件都是~~~)

2011年,Google公司受到murmurhash算法的启发,又发明了cityhash算法,据称cityhash算法效率较murmur算法效率更高,利用了CPU的一些指令提高了算法的效率。

city算法更加复杂,而且不是redis所采用的hash算法,此处就此略过。

此外,redis还有一个hash函数,

下面再来看redis中,dict(字典)的源代码:

在redis中,服务器端的代码dict.c和客户端的dict.c的代码逻辑差不了太多,dict的代码复杂度明显要高一些,下面我们来看相关代码:

redis中,字典中存储每个键值对的数据结构叫做dictEntry:

 1 typedef struct dictEntry {
 2     void *key;
 3     union {
 4         void *val;
 5         uint64_t u64;
 6         int64_t s64;
 7         double d;
 8     } v;
 9     struct dictEntry *next;
10 } dictEntry;

各个成员的意思很明白,其中,next是为了出现碰撞冲突而设定的,目的是将所有hash'值相同的键值映射到一个单向链表上去,同时,新增一个节点时,会放在该节点的首部

有了键值对的数据结构之后,我们再来看字典数据结构:

1 typedef struct dictht {
2     dictEntry **table;
3     unsigned long size;
4     unsigned long sizemask;
5     unsigned long used;
6 } dictht;

 其中,table是一个可以理解为一个数组,数组中的每一个元素是一个链表。

 size是dictht中存储的键值对的数量

下面2个变量比较麻烦,往下看之前,请先深呼吸.

sizemask = 4*(2n)-1, 我们可以很容易的想象出,sizemask的二进制值的形式为(111111...111),采用这个值,是在计算完hash值之后,需要将hash值映射到数组的某个链表上,

该链表在数组中的索引,就是由hash&sizemask来确定的,不难得出,table数组的元素数量(其实就是hash算法中桶的数量)就是sizemask+1, 这里采用&运算,想必是因为&运算效率比取模运算的效率要来的高。

 1 static int _dictKeyIndex(dict *d, const void *key, unsigned int hash, dictEntry **existing)
 2 {
 3     unsigned int idx, table;
 4     dictEntry *he;
 5     if (existing) *existing = NULL;
 6 
 7     /* Expand the hash table if needed */
 8     if (_dictExpandIfNeeded(d) == DICT_ERR)
 9         return -1;
10     for (table = 0; table <= 1; table++) {
11         idx = hash & d->ht[table].sizemask;
12         /* Search if this slot does not already contain the given key */
13         he = d->ht[table].table[idx];
14         while(he) {
15             if (key==he->key || dictCompareKeys(d, key, he->key)) {
16                 if (existing) *existing = he;
17                 return -1;
18             }
19             he = he->next;
20         }
21         if (!dictIsRehashing(d)) break;
22     }
23     return idx;
24 }

第13行就是获取索引的代码, 从上面的代码中,我们可以看出,在一个dict中字典其实有2个,其中,90%以上的情况下,我们只会用到第一个,第二个hash表是在对dict中的元素进行重新布局时才会用到。

hash字典为何需要重新布局,什么情况下会重新布局?

字典中的元素一般情况下,数量是会逐渐变化的,因此,table数组的size,会随着 元素的数量的变化而变化,我们来看下面的代码

 1 /* Expand or create the hash table */
 2 int dictExpand(dict *d, unsigned long size)
 3 {
 4     dictht n; /* the new hash table */
 5     unsigned long realsize = _dictNextPower(size); ①
 6 
 7     /* the size is invalid if it is smaller than the number of
 8      * elements already inside the hash table */
 9     if (dictIsRehashing(d) || d->ht[0].used > size)
10         return DICT_ERR;
11 
12     /* Rehashing to the same table size is not useful. */
13     if (realsize == d->ht[0].size) return DICT_ERR;
14 
15     /* Allocate the new hash table and initialize all pointers to NULL */
16     n.size = realsize; ②
17     n.sizemask = realsize-1;
18     n.table = zcalloc(realsize*sizeof(dictEntry*));
19     n.used = 0;
20 
21     /* Is this the first initialization? If so it's not really a rehashing
22      * we just set the first hash table so that it can accept keys. */
23     if (d->ht[0].table == NULL) {
24         d->ht[0] = n;
25         return DICT_OK;
26     }
27 
28     /* Prepare a second hash table for incremental rehashing */
29     d->ht[1] = n; ③
30     d->rehashidx = 0;
31     return DICT_OK;
32 }

上述代码中,①处计算出大于等于size的最小的2的N次方值 ②处设置字典的基本参数 ③处建立临时字典,临时字典是扩张后的字典,即d->ht[1], 由标记位rehashidx来表示其有效性(rehashidx值为-1时表示其无效)

注意在redis中, rehash的过程不是一蹴而就的,而是渐进式的,如果一次性的将太多的键值rehash,占用CPU过多,会对其性能造成影响

那么如何进行rehash呢,代码在server.c中  的incrementallyRehash函数中,我们忽略其代码实现,仅仅看一下注释:

/Our hash table implementation performs rehashing incrementally while we write/read from the hash table. Still if the server is idle, the hash table will use two tables for a long time. So we try to use 1 millisecond
of CPU time at every call of this function to perform some rehahsing.
简单来说,就是利用CPU的空余时间来做搬运工的工作,而不是把所有的活都放在一起,一次搬运完毕。
incrementallyRehash函数内部调用了dictRehashMilliseconds函数,该函数才是真正的执行搬家操作,该函数会自己计算自己已经运行了多长时间,不会占用CPU过多的时间
 1 int dictRehashMilliseconds(dict *d, int ms) {
 2     long long start = timeInMilliseconds();
 3     int rehashes = 0;
 4 
 5     while(dictRehash(d,100)) {
 6         rehashes += 100;
 7         if (timeInMilliseconds()-start > ms) break;
 8     }
 9     return rehashes;
10 }

incrementallyRehash函数在后台任务中被调用,可以参阅databasesCron函数,包括rdb存储的一些操作也会在该函数中运行

在redis的readme.md文件中由如下描述:

1. `serverCron()` is called periodically (according to `server.hz` frequency), and performs tasks that must be performed from time to time, like checking for timedout clients.
其中,databasesCron函数即被serverCron函数调用,其中,server.hz的值在redis.conf文件中可以被配置,默认值一般是10.
redis中触发hash重新布局的代码见如下函数 :
 1 /* Expand the hash table if needed */
 2 static int _dictExpandIfNeeded(dict *d)
 3 {
 4     /* Incremental rehashing already in progress. Return. */
 5     if (dictIsRehashing(d)) return DICT_OK;
 6 
 7     /* If the hash table is empty expand it to the initial size. */
 8     if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
 9 
10     /* If we reached the 1:1 ratio, and we are allowed to resize the hash
11      * table (global setting) or we should avoid it but the ratio between
12      * elements/buckets is over the "safe" threshold, we resize doubling
13      * the number of buckets. */
14     if (d->ht[0].used >= d->ht[0].size &&
15         (dict_can_resize ||
16          d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
17     {
18         return dictExpand(d, d->ht[0].used*2);
19     }
20     return DICT_OK;
21 }

上述代码中的第14-16,确定了rehash的条件:

条件是根据负载因子来的, 负载因子由used/size来确定,负载因子的值是5时会强制rehash,负载因子>=1的时候,此时若是没有rdb或者aof子进程,则可以进行rehash

 除了在后台偷偷的搬运键值对之外,redis同时,也在读取、修改、添加,删除某个键的时候,顺便进行搬迁。我们以添加操作为例,顺便引出对hash表进行操作的基本流程:

 1 dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
 2 {
 3     int index;
 4     dictEntry *entry;
 5     dictht *ht;
 6 
 7     if (dictIsRehashing(d)) _dictRehashStep(d);
 8 
 9     /* Get the index of the new element, or -1 if
10      * the element already exists. */
11     if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
12         return NULL;
13 
14     /* Allocate the memory and store the new entry.
15      * Insert the element in top, with the assumption that in a database
16      * system it is more likely that recently added entries are accessed
17      * more frequently. */
18     ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
19     entry = zmalloc(sizeof(*entry));
20     entry->next = ht->table[index];
21     ht->table[index] = entry;
22     ht->used++;
23 
24     /* Set the hash entry fields. */
25     dictSetKey(d, entry, key);
26     return entry;
27 }

以上代码,第7行移动了一个键值对,而后判定是否在rehashing中,如果在rehashing中,那么直接将新元素插入到ht[1]中去,否则插入到ht[0]中去

其它类似函数,如Delete、Find等操作,限于篇幅,不解释

本文基本上解释清楚了hash表的工作原理,但是,hash表作为redis中最重要的数据结构,还有其它值得一读的地方,这就是dictScan函数,下一篇就来分析它

 

 

 

 

 

posted on 2017-09-07 14:14  鹤鸣_Huang  阅读(291)  评论(0编辑  收藏  举报

导航