二) redis的数据存储方式hash的实现方式

参考
https://segmentfault.com/a/1190000016981700
进阶的Redis之哈希分片原理与集群实战

https://blog.csdn.net/valada/article/details/81639673?utm_source=blogxgwz8
美团redis集群技术分享

1. Redis KV存储的实现
Redis作为K-V键值对存储数据库,其键值对存储方式是通过字典Dict保存的,而字典底层是通过哈希表(dictht)来实现的,通过哈希表中的节点保存字典中的键值对。类似于Java中的HashMap,将key通过哈希函数映射到哈希表节点位置。
(1)Redis 哈希表dictht结构体
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表的大小
unsigned long sizemask; // 哈希表大小掩码
unsigned long used; // 哈希表现有节点的数量
} dictht;

例如下图所示一个大小为4的空哈希表(redis默认初始化值为4)
在这里插入图片描述

(2) redis 哈希桶dictEntry
Redis哈希表dictht中的table数组存储的是哈希桶结构(dictEntry),里面就是redis的键值对。类似于java的HashMap,Redis的dictEntry也是通过链表(next指针)方式来解决hash冲突。
/* 哈希桶 */
typedef struct dictEntry {
void *key; // 键定义
// 值定义
union {
void *val; // 自定义类型
uint64_t u64; // 无符号整形
int64_t s64; // 有符号整形
double d; // 浮点型
} v;
struct dictEntry *next; //指向下一个哈希表节点
} dictEntry;

如下所示,dictEntry就类似于HashMap中的链表节点;而dictht则类似于HashMap。dictEntry作为一个节点,它持有key和value,以及next节点。
在这里插入图片描述

(3)redis字典
前面我们提到键值对是通过redis字典Dict来存储的。Dict的存储结构如下
/* 字典结构定义 */
typedef struct dict {
dictType *type; // 字典类型
void *privdata; // 私有数据
dictht ht[2]; // 哈希表[两个]
long rehashidx; // 记录rehash 进度的标志,值为-1表示rehash未进行
int iterators; // 当前正在迭代的迭代器数
} dict;

注意redis的字典里面有两个dictht,也就是哈希表。其中一个表是用来存储键值对信息,如前面部分所述;而第二个表则是为了字典的扩展rehash而用的。另外,还有一个标志位是用来记录rehash的进度。
在这里插入图片描述

总结一下,
在集群模式下,一个redis实例对应一个RedisDB(db0);而一个redisDB则对应一个Dict字典;一个Dict则对应2个哈希表Dictht,其中正常情况下只用到ht[0],在rehash时还会使用到ht[1]。
注意,redis实例是整体上作为一个字典来使用的(db0);一个实例对应多个dbx一般不推荐使用。故一个redis实例的信息就存储到一个完整的hash表中。

2. redis添加一个新键值对的过程
在这里插入图片描述
源码:
/* 添加新键值对 */
int dictAdd(dict *d, void *key, void *val){
dictEntry *entry = dictAddRaw(d,key); // 添加新键
if (!entry) return DICT_ERR; // 如果键存在,则返回失败
dictSetVal(d, entry, val); // 键不存在,则设置节点值
return DICT_OK;
}

/* 将Key插入哈希表 */
dictEntry *dictAddRaw(dict *d, void *key){
int index;
dictEntry *entry;
dictht *ht;

if (dictIsRehashing(d)) _dictRehashStep(d);  // 如果哈希表在rehashing,则执行单步rehash

/* 调用_dictKeyIndex() 检查键是否存在,如果存在则返回NULL */
if ((index = _dictKeyIndex(d, key)) == -1)
    return NULL;

//如果当前是在rehash中,就直接插入到新的rehash的表中的了
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
entry = zmalloc(sizeof(*entry)); // 为新增的节点分配内存
entry->next = ht->table[index]; // 将节点插入链表表头
ht->table[index] = entry; // 更新节点和桶信息
ht->used++; // 更新ht的used

/* 设置新节点的键 */
dictSetKey(d, entry, key);
return entry;

}

/* 计算存储Key的bucket的位置 */

static int _dictKeyIndex(dict *d, const void *key) {
unsigned int h, idx, table;
dictEntry *he;

/* 检查是否需要扩展哈希表,不足则扩展 */
if (_dictExpandIfNeeded(d) == DICT_ERR) 
    return -1;

/* 计算Key的哈希值 */(先从ht[0]找,找不到的话分两种情况:1)此时不在rehash中就直接返回,2}在rehash中再去ht[1]上找)
h = dictHashKey(d, key);
for (table = 0; table <= 1; table++) {
    idx = h & d->ht[table].sizemask;  //计算Key的bucket位置
    /* 检查节点上是否存在新增的Key */
    he = d->ht[table].table[idx];
    /* 在节点链表检查 */
    while(he) {
        if (key==he->key || dictCompareKeys(d, key, he->key))
            return -1;
        he = he->next;
    }
    if (!dictIsRehashing(d)) break;  // 扫完ht[0]后,如果哈希表不在rehashing,则无需再扫ht[1]
}
return idx;

}

备注: Redis 通过 dictCreate() 创建词典,在初始化中,table 指针为 Null,所以两个哈希表 ht[0].table 和 ht[1].table 都未真正分配内存空间。只有在 dictExpand() 字典扩展时才给 table 分配指向 dictEntry 的内存。

当 Redis 触发 Resize 后,就会动态分配一块内存,最终由 ht[1].table 指向,动态分配的内存大小为:realsize*sizeof(dictEntry*),table 指向 dictEntry* 的一个指针,大小为 8bytes(64 位OS),即 ht[1].table 需分配的内存大小为:8*2*2^n (n 大于等于 2)。

3. redis rehash过程
在hashmap中,由于hash冲突导致负载因子超出某个阈值时,处于链表性能的考虑,会进行resize过程。Redis也一样,它是通过dictExpand()实现。

    /* 根据相关触发条件扩展字典 */

static int _dictExpandIfNeeded(dict d){
if (dictIsRehashing(d)) return DICT_OK; // 如果正在进行Rehash,则直接返回
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); // 如果ht[0]字典为空,则创建并初始化ht[0]
/
(ht[0].used/ht[0].size)>=1前提下,当满足dict_can_resize=1或ht[0].used/t[0].size>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); // 扩展字典为原来的2倍
}
return DICT_OK;
}

大概流程如下:
如果dict正在hash(rehashidx作为状态位来标记的),就直接返回;
如果当前dict是空的,那么久初始化ht[0],这和hashMap一致都是创建的时候没有初始化,第一次放入设备时再初始化;
扩容条件:在ht[0]这个哈希表used数目>= size的前提下,下面两个条件都可以导致扩容:dict表可以扩容; used数目是size的5倍以上
此时,会按照ht[0]的used数量扩展为2倍

具体的扩容函数dictExpand如下,其中d为原哈希表,size是期待的扩容值,实际上会取第一个大于等于size的2的n次方作为真正的扩容值
int dictExpand(dict *d, unsigned long size){
dictht n; // 新哈希表
unsigned long realsize = _dictNextPower(size); // 计算扩展或缩放新哈希表的大小,这里也是类似于hashmap的取第一个大于等于size的2的n次方

/* 如果正在rehash或者新哈希表的大小小于现已使用(比如某个表刚刚扩容后了),则返回error */
if (dictIsRehashing(d) || d->ht[0].used > size)
    return DICT_ERR; 
/* 如果计算出哈希表size与现哈希表大小一样,也返回error */
if (realsize == d->ht[0].size) return DICT_ERR;

/* 初始化新哈希表 */
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));  // 为table指向dictEntry 分配内存
n.used = 0;

/* 如果ht[0] 为空,则初始化ht[0]为当前键值对的哈希表。这个应该是第一次初始化吧 */
if (d->ht[0].table == NULL) {
    d->ht[0] = n;
    return DICT_OK;
}

/* 如果ht[0]不为空,则初始化ht[1]为当前键值对的哈希表,并开启渐进式rehash模式 */
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;

}

d->rehashidx = 0; rehashidx是是否在rehash的标志位,为-1代表未rehash;为1代表正在hash中。

注意,在每次将key插入到哈希表中时,会调用_dictKeyIndex方法计算存储key的bucket桶的位置,在该方法中,还会调用_dictExpandIfNeeded方法检查是否需要扩展哈希表。然后再插入新的节点。(hashmap是先插入,再扩容)

注意,在rehash过程中,新的hash表是存到ht[1]中的

4)rehash中的渐进式rehash

/* Performs N steps of incremental rehashing. Returns 1 if there are still

  • keys to move from the old to the new hash table, otherwise 0 is returned.

  • Note that a rehashing step consists in moving a bucket (that may have more

  • than one key as we use chaining) from the old to the new hash table, however

  • since part of the hash table may be composed of empty spaces, it is not

  • guaranteed that this function will rehash even a single bucket, since it

  • will visit at max N*10 empty buckets in total, otherwise the amount of

  • work it does would be unbound and the function may block for a long time. /
    int dictRehash(dict d, int n) {
    int empty_visits = n
    10; /
    Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
    dictEntry *de, *nextde;

     /* Note that rehashidx can't overflow as we are sure there are more
      * elements because ht[0].used != 0 */
     assert(d->ht[0].size > (unsigned long)d->rehashidx);
     while(d->ht[0].table[d->rehashidx] == NULL) {
         d->rehashidx++;
         if (--empty_visits == 0) return 1;
     }
     de = d->ht[0].table[d->rehashidx];
     /* Move all the keys in this bucket from the old to the new hash HT */
     while(de) {
         uint64_t h;
    
         nextde = de->next;
         /* Get the index in the new 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[0].used--;
         d->ht[1].used++;
         de = nextde;
     }
     d->ht[0].table[d->rehashidx] = NULL;
     d->rehashidx++;
    

    }

    /* Check if we already rehashed the whole table… */
    if (d->ht[0].used == 0) {
    zfree(d->ht[0].table);
    d->ht[0] = d->ht[1];
    _dictReset(&d->ht[1]);
    d->rehashidx = -1;
    return 0;
    }

    /* More to rehash… */
    return 1;
    }

Rehash是以bucket为单位进行渐进式数据迁移的,每一次只完成一个桶的迁移(retrun 1),然后继续下一个桶,一直到迁移掉每一个桶(return 0)。一个桶就是哈希表数组的一个entry链。
深扒一下这个函数的具体实现。

判断dict是否正在rehashing,只有是,才能继续往下进行,否则已经结束哈希过程,直接返回。
接着是分n步进行的渐进式哈希主体部分(n由函数参数传入),在while的条件里面加入对.used旧表中剩余元素数目的观察,增加安全性。
一个runtime的断言保证一下渐进式哈希的索引没有越界。
接下来一个小while是为了跳过空桶,同时更新剩余可以访问的空桶数,empty_visits这个变量的作用之前已经说过了。
**现在我们来到了当前的bucket,在下一个while(de)中把其中的所有元素都迁移到ht[1]中,**索引值是辅助了哈希表的大小掩码计算出来的,可以保证不会越界。同时更新了两张表的当前元素数目。
每一步rehash结束,都要增加索引值,并且把旧表中已经迁移完毕的bucket置为空指针。
最后判断一下旧表是否全部迁移完毕,若是,则回收空间,重置旧表,重置渐进式哈希的索引,否则用返回值告诉调用方,dict内仍然有数据未迁移。

**渐进式哈希的精髓在于:数据的迁移不是一次性完成的,而是可以通过dictRehash()这个函数分步规划的,并且调用方可以及时知道是否需要继续进行渐进式哈希操作。**如果dict数据结构中存储了海量的数据,那么一次性迁移势必带来redis性能的下降,别忘了redis是单线程模型,在实时性要求高的场景下这可能是致命的。而渐进式哈希则将这种代价可控地分摊了,调用方可以在dict做插入,删除,更新的时候执行dictRehash(),最小化数据迁移的代价。

在迁移的过程中,数据在新表还是旧表中并不是一个很急迫的需求,因为迁移的过程中并不会丢数据(程序中是先去ht[0]中找,如果此时处于迁移中还要继续去ht[1]中找)。
所以我们有时候在程序中可以看到一个所谓的单步rehash,就是执行一次操作,而不是全部迁移。

https://blog.csdn.net/cqk0100/article/details/80400811
最后是从《Redis设计与实现》中copy来的图解,可以帮助大家更形象地理解整个incremental rehash的过程:
在这里插入图片描述
在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/xiaohesdu/article/details/87906120