已经说过,redis整个数据结构是一个hash。在运行中,有两个hashtable,但是只有一个是在使用中的,另一个是备用处理rehash之用。
首先说明下redis对于sds(也就是redis中key的数据类型)的hash算法为:
while (len--) hash = ((hash << 5) + hash) + (*buf++); /* hash * 33 + c */ return hash; }
是很典型的字符串hash方式,不详述。下面的hash处理是重点:
h = dictHashKey(d, key); for (table = 0; table <= 1; table++) { idx = h & d->ht[table].sizemask; he = d->ht[table].table[idx]; while(he) { if (dictCompareKeys(d, key, he->key)) return he; he = he->next; } if (!dictIsRehashing(d)) return NULL; }
这里提到了一个重要的变量:sizemask。在其他文章已经提到过(http://askdba.alibaba-inc.com/libary/control/getArticle.do?articleId=30561),redis的table(也就是用来存储所有键值的hashtable),大小永远是2的幂次方。而sizemask则是tableSize - 1. 也就是说,sizemask的二进制表现就是一串1。所以,上述的算法就是:获得hash之后用tableSize取余。这点也是和我们的预期一样的。
说完hash的机制,然后说下rehash的触发机制,也就是rehash的临界点。
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); }
首先dictExpand的第二个参数明确显示每次table大小的扩容都是以2的倍数为大小的。其次,显示的触发条件有两个(逻辑与关系)
-
key的总数大于tableSize
-
slot的平均key数大于临界值
在这两个条件下,触发扩容然后rehash移动键值。至于rehash的具体算法,在《REDIS介绍》已经介绍过,详细代码也不用贴。本文侧重于rehash的运行机制。
在其他文章也说过,redis的事件机制是单线程的。而rehash操作是timeEvent中的一个步骤(包含在serverCron中的databasesCron任务)。那么问题来了,当redis的键值非常大的时候,比如说1G(redis运行内存1G是很正常的事情),那么也就意味着一次rehash操作就得耗费秒级别的时间,由于是单线程的,就势必会阻塞了连接处理操作,对外表现就是服务拒绝。所以,redis是不可能也不允许全量rehash的,取而代之的是,redis采用了时间分片操作。
在实际操作中,redis采用了毫秒分片,也就是说,在每个serverCron里面,如果需要或者正在rehash,那就分2ms进行rehash,那么这个2ms是怎么分配的呢:
int incrementallyRehash(int dbid) { /* Keys dictionary */ if (dictIsRehashing(server.db[dbid].dict)) { dictRehashMilliseconds(server.db[dbid].dict,1); return 1; /* already used our millisecond for this loop... */ } /* Expires */ if (dictIsRehashing(server.db[dbid].expires)) { dictRehashMilliseconds(server.db[dbid].expires,1); return 1; /* already used our millisecond for this loop... */ } return 0; }
可以看出,redis对于持久KV分配1ms,对Expire KV分配1ms,从中可以看出,redis将持久KV和Expired KV进行分开存储。这个细节这里不详述。考虑到serverCron的频率一般在10-100,也就是说,一般情况下,rehas对于吞吐率的影响在1% - 10%(吞吐率:对外服务时间/运行时间)。考虑到用户传输层的额外开销,这点影响是忽略不计的。
回到正题,redis是如何控制着1ms的呢
while(dictRehash(d,100)) { rehashes += 100; if (timeInMilliseconds()-start > ms) break; }
也就是说,每次调用dictRehash操作,然后检查时间,看是否超时。这里就有个问题,如果一次性调用dictRehash超过1ms怎么办?
在《REDIS介绍》提到过,redis的rehash迁移是按照slot来做的,也就是table中的每一行。上文中的dictRehash(d,100)中的100就是每次rehash的slot数。也就是说,redis默认每次rehash迁移100个slot所用的时间少于1ms。由于在slot中没有特定的变量来标记rehash的进度,所以redis的rehash最小粒度是slot。考虑到slot中的KV是个链表,多个KV分配到多个slot和分配到单个slot的rehash是无明显差别的(反正都是内存的随机访问),而redis的值是放在堆中的,在table中只存有指向这个值的内存指针。也就是说,redis在rehash的时候,仅仅是移动整个KV的数据结构也就是dictEntry的结构指针。所以,这个效率是非常高的。换句话说,value哪怕是200M的数据,移动的时候也和1K的数据没有半毛钱差别。可以得出结论:除了rehash的时候计算hash值之外,其余都是小变量的copy操作,时间损耗非常小而且非常稳定。
换言之,以下的代码就是唯一的造成单位rehash操作时间不稳定的原因:
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
或者说,key的长度会直接正向影响rehash性能。至于有多影响,我觉得仁者见仁智者见智了。
当然,在rehash中还有很多细节问题你:比如在两次rehash中插入了大量的KV,或者删除了大量的KV。这些细节读者可以自己读源码去发掘。在此不表。
最后,redis的cluster支持中对于KEY的多node分配,也是采用差不多的机制,不过其中有一点比较麻烦的是:如果KEY在本机的slot中未找到,应该去哪台机器上找?毕竟在rehash中,在table1中找不到,那就可以在table2中找,两个table都找不到那就肯定没有。cluster的实现则是redis又一个亮点。下篇文章再详述。
From:鱼藏