Redis基础数据结构——字典
redis的字典hash相当于Java里面的HashMap,实现结构上也与Java的HashMap一样,都是“数组+链表”的二维结构。
不同的是,redis的字典的值只能是字符串,并且它们rehash的方式也不一样,相较于Java中HashMap的一次性rehash,redis中是渐进式rehash。
hash结构用来存储用户信息时,与字符串需要一次性全部序列化整个对象不同,hash可以对用户结构中的每个字段单独存储,这样当我们需要获取用户信息时可以进行部分获取。
1.redis字典的基本操作
hset key field value #给字典key加入field-value键值对,若field已存在,则更新field对应的value
hget key field #获取字典key中field对应的value值
hgetall key #获取字典中所有键值对
hlen key #获取字典的元素数
hmset key field value [field value …] #批量创建
若是value为数值,也可以进行计数
hincrby key field increment #字典key中field对应的数值value增加increment
2.redis字典的内部实现
字典是redis服务器中出现最频繁的复合型数据结构,除了hash结构的数据会用到字典外,整个redis数据库的所有key和value也组成了一个全局字典,还有待过期时间的key集合也是一个字典。zset集合中存储value和score值的映射关系也是字典结构。
struct RedisDb {
dict* dict; //全局字典
dict* expires; //过期字典
...
}
struct zset {
dict *dict; //存放value和score的映射关系
zskiplist *zsl;
}
既然字典在redis中这么重要,那么搞懂它的内部实现就尤为重要。
字典结构内部包含两个hashtable,通常情况下只有一个hashtable是有值的,但是在字典扩容缩容时,需要分配新的hashtable,然后进行渐进式搬迁,这时候两个hashtable存储的分别是旧的hashtable和新的hashtable。ht[0]为旧的hashtable,ht[1]为新的hashtable。等到搬迁结束之后,旧的hashtable被删除,新的hashtable取而代之。
struct dict {
...
dictht ht[2];
}
hashtable的结构和Java的HashMap几乎是一样的,都是通过分桶的方式解决hash冲突。第一维是数组,第二维是链表,数组中存储的是第二维链表的第一个元素的指针,如图所示。
struct dictEntry {
void* key;
void* val;
dictEntry* next;
}
struct dictht {
dictEntry** table;
long size; //第一维数组的长度
long used; //hash表中的元素个数
...
}
3.渐进式rehash
redis的hash相较于Java的HashMap来说还有一点不同就是rehash方式不同,为渐进式rehash,那么为什么要采用这种方式呢?
因为,大字典的扩容时比较耗时的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个O(n)级别的操作,作为单线程的redis很难承受这样耗时的过程,因而redis使用渐进式rehash,虽然会慢一些,但是最终肯定是可以搬迁完的。
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing){
long index;
dictEntry *entry;
dictht *ht;
//这里进行小步搬迁
if(dictIsRehashing(d))
_dictRehashStep(d);
/*获取新元素的索引,如果该元素已经存在了,则索引为-1
*如果新元素已经存在,则返回空指针 */
if(
(index = _dictKeyIndex(d, key, dictHashKey(d, key), existing))
== -1)
return NUll;
/*分配内存并存储新的entry
*将元素插入尾部
*系统优先访问最近添加的entry */
//如果字典处于搬迁过程中,要将新的元素挂接到新的数组下面
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
entry = zmalloc(sizeof(*entry));
entry->next = ht->table[index];
ht->table[index] = entry;
ht->used++;
/*设置字典输入字段 */
dictSetKey(d, entry, key);
return entry;
}
字典搬迁在当前字典的后续指令中,比如来自客户端的hset、hdel等命令之后,这样稍显被动了些,除此之外,redis还会在定时任务中对字典进行主动搬迁。
//服务器定时任务
void databaseCron() {
...
if(server.activerehashing) {
for(j = 0; j < dbs_per_call; j++) {
int word_done = incrementallyRehash(rehash_db);
if(work_done) {
/*如果函数做了一些工作,请到此为止,
*将在下一个cron循环中做更多的工作*/
break;
} else {
/*如果这个db不需要rehash,
*将尝试rehash下一个
rehash_db++;
rehash_db %= server.dbnum;
}
}
}
}
4.字典的查找过程
要想对字典进行插入,修改,删除操作都必须先要查找到元素,才可以进行数据结构的修改。因而,搞清楚hash的查找过程是很有必要的。
hashtable的元素是在第二维的链表上,所以首先要做的是确定目标key处在哪个链表上,然后再顺序遍历这个链表找到目标key。
func get(key) {
//hash函数计算得出目标元素在数组中的位置,也就是处于哪个链表上
let index = hash_func(key) % size;
let entry = table[index];
//遍历链表,找目标key
while(entry != NULL){
//查找成功,返回目标元素
if(entry.key == target){
return entry.value;
}
entry = entry.next;
}
}
5.扩容和缩容
(1)扩容条件
//如果需要,对hash表进行扩容
static int _dictExpandIfNeeded(dict *d) {
//若是字典正在进行rehash,则返回,此时选择不扩容
if(dictIsRehashing(d))
return DICT_OK;
//若是hash表是空的,将其扩容至初始大小
if(d->ht[0].size == 0)
return dictExpand(d, DICT_HT_INITIAL_SIZE);
/*如果元素数量大于等于桶的数量,
*且至少满足以下条件之一:
*1.该字典是可以进行扩容的
*2.元素数量/桶数量的比值大于安全阈值,默认为5
*此时要进行扩容,扩容为此时元素数量的两倍大小
*/
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);
}
return DICT_OK;
}
(2)缩容条件
当hash表因为元素逐渐被删除变得越来越稀疏时,redis会对hash表进行缩容来减少hash表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的10%,缩容不会考虑字典是否正在rehash,也就是说,字典在进行渐进式rehash时可以缩容。
为什么缩容时不考虑是否正在rehash呢?个人认为是本来就要将原hashtable中的元素全部迁移至新的hashtable中,最后将旧的hashtable删除,内存释放。此时如果进行缩容的话,对于后面继续rehash以及最后的释放内存并不影响,甚至会更方便这些操作,相当于将一个气球中的气逐渐放了,而不是一次性给它扎破。
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));
}
6.深入字典的遍历过程
Redis对象树的主干是一个字典,如果对象很多的话,主干字典也会非常大。当使用keys命令搜寻指定模式的key时,它会遍历整个主干字典,在遍历的过程中,如果满足匹配条件的key被找到了,还需要判断key指向的对象是否已经过期,若是过期了就需要从主干字典中将该key删除。
迭代器
void keysCommand(client *c){
dictIterator *di; //迭代器
dictEntry *de; //迭代器当前的entry
sds pattern = c->argv[1]->ptr; //keys的匹配模式参数
int plen = sdslen(pattern);
int allkeys; //是否要获取所有key
unsigned long numkeys = 0;
void *replylen = addDeferredMultiBulkLength(c);
di = dictGetSafeIterator(c->db->dict);
allkeys = (pattern[0] == '*' && pattern[1] == '\0');
while((de = dictNext(di)) != NULL){
sds key = dictGetKey(de);
robj *keyobj;
if(allkeys || stringmatchlen(pattern, plen, key, sdslen(key), 0)){
keyobj = createStringObject(key, sdslen(key));
//判断是否过期,过期了要删除元素
if(expireIfNeeded(c->db, keyobj) == 0){
addReplyBulk(c, keyobj);
numkeys++;
}
decrRefCount(keyobj);
}
}
dictReleaseIterator(di);
setDeferredMultiBulkLength(c, replylen, numkeys);
}
前面提到过,字典在扩容时要进行渐进式迁移,同时存在新旧两个hashtable。遍历需要对这两个hashtable依次进行,先遍历完旧的hashtable,再继续遍历新的hashtable。如果在遍历的过程中进行了rehashStep,将已经遍历过的旧的hashtable的元素迁移到了新的hashtable中,这时就存在一个问题,遍历是不是会出现元素的重复。
Redis为字典的遍历提供了两种迭代器,一种是安全迭代器,一种是不安全迭代器。
typedef struct dictIterator{
dict *d; //目标字典对象
long index; //当前遍历的槽位置,初始化为-1
int table; //ht[0]或ht[1]
int safe; //表示迭代器是否安全
dictEntry *entry; //迭代器当前指向的对象
dictEntry *nextEntry; //迭代器下一个指向的对象
long long fingerprint; //迭代器指纹,放置迭代过程中字典被修改
}dictIterator;
//获取非安全迭代器,只读迭代器,允许rehashStep
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;
}
//获取安全迭代器,允许触发过期处理,禁止rehashStep
dictIterator *dictGetSafeIterator(dict *d) {
dictIterator *i = dictGetIterator(d);
i->safe = 1;
return i;
}
安全的迭代器可以在遍历过程中对字典进行查找和修改,由于查找和修改会触发过期判断,会删除内部元素。为了保证元素不重复,会禁止rehashStep。
安全迭代器在刚开始遍历时,会给字典打上一个标记,有了这个标记之后,rehashStep就不会执行,遍历时就不会出现元素重复。
typedef struct dict{
dictType *type;
void *privdata;
dictht ht[2];
long rehashindex; //标记,表示当前加在字典上的安全迭代器的数量
unsigned long iterators;
}dict;
//如果存在安全的迭代器,就禁止rehash
static void _dictRehashStep(dict *d){
if(d->iterators == 0)
dictRehash(d, l);
}
不安全的迭代器是指,在遍历过程中,字典是只读的,不可以修改,只能调用dictNext对字典进行持续遍历,不得调用任何可能触发过期判断的函数。这样不影响rehash,但是遍历的元素可能会重复。
如果遍历过程中不允许出现重复,或者遍历过程中需要处理元素过期,需要对字典进行修改,那就使用安全迭代器。
其他情况下,允许出现个别元素重复,一般都使用非安全迭代器。
迭代过程
dictEntry *dictNext(dictIterator *iter){
while(1){
if(iter->entry == NULL){
//遍历一个新槽位下面的链表,数组的index往前移动了
dictht *ht = &iter->d->ht[iter->table];
if(iter->index == -1 && iter->table == 0){
//第一次遍历,刚刚进入遍历过程
//就是ht[0]数组的第一个元素下面的链表
if(iter->safe){
//给字典打安全标记,禁止字典进行rehash
iter->d->iterators++;
}else{
//记录迭代器指纹
//如果遍历过程中字典有任何变动,指纹就会改变
iter->fingerprint = dictFingerprint(iter->d);
}
}
iter->index++;
if(iter->index >= (long)ht->size){
//最后一个槽位都遍历完了
if(dictIsRehashing(iter->d) && iter->table == 0){
//如果处于rehash中,那就继续遍历第二个hashtable
iter->table++;
iter->index = 0;
ht = &iter->d->ht[1];
}else{
//结束遍历
break;
}
}
//将当前遍历的元素记录到迭代器中
iter->entry = ht->table[iter->index];
}else{
//直接将下一个元素记录为本次迭代的元素
iter->entry = iter->nextEntry;
}
if(iter->entry){
//将下一个元素也记录到迭代器中
//防止安全迭代过程中当前元素被过期删除后,找不到下一个需要遍历的元素
//如果后面发生了rehash,当前遍历的链表打散了
//旧的链表将被一分为二,打散后重新挂接到新数组的两个槽位下
//结果就是会导致当前链表上的元素会重复遍历
//如果rehash的链表是index前面的链表,那么这部分链表也会被重复遍历
iter->nextEntry = iter->entry->next;
return iter->entry;
}
}
return NULL;
}
//遍历完成后要释放迭代器,安全迭代器需要去掉字典的禁止rehash的标记
//非安全迭代器还需要检查指纹,如果有变动,服务器就会崩溃
void dictReleaseIterator(dictIterator *iter){
if(!(iter->index == -1 && iter->table == 0)){
if(iter->safe)
iter->d->iterators--; //去掉禁止rehash的标记
else
assert(iter->fingerprint == dictFingerprint(iter->d));
}
zfree(iter);
}
//计算字典的指纹,也就是将字典的关键字按为糅合到一起
//这样只要有任意的结构变动,指纹都会发生变化
//如果只是某个元素的value被修改了,指纹不会发生变动
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;
for(j = 0; j < 6; j++){
hash += integers[j];
hash = (~hash) + (hash << 21);
hash = hash ^ (hash >> 24);
hash = (hash + (hash << 3) + (hash << 8));
hash = hash ^ (hash >> 14);
hash = (hash + (hash << 2)) + (hash << 4);
hash = hash ^ (hash >> 28);
hash = hash + (hash << 31);
}
return hash;
}