Redis5设计与源码分析 (第5章 字典)

5.1 基本概念

Redis是K-V型数据库,整个数据库是用字典来存储的,对Redis数据库进行任何增、删、改、查操作,实际就是对字典中的数据进行增、删、改、查操作。

C数组,既可以存储海量数据,又可以根据下标以O(1)的时间复杂度取值

键值对中值的类型可为String、Hash、List、Set、SortedSet。

 

5.1.1 数组

图5-3 字典结构示意图(带数组结构)

"键值对中键的类型可以为字符串、整型、浮点型等",不能直接当成下标使用,此

时,需要对键做Hash处理。

 

5.1.2 Hash函数

"times 33"散列函数,其使用的核心算法是:"hash(i)=hash(i-1)*33+str[i]",

Redis服务端的Hash函数使用的是siphash算法,主要功能与客户端Hash函数类似,其优点是针对有规律的键计算出来的Hash值也具有强随机分布性,但算法较为复杂,

static unsigned int dictGenHashFunction(const unsigned char *buf, int len) {

unsigned int hash = 5381;

while (len--)

// 33 -> 100001 ,左移5位再加一个原数

  hash = ((hash << 5) + hash) + (*buf++); /* hash * 33 + c */

return hash;

}

入参是任意长度的字符串*buf;

 

图5-4 字典结构示意图(带容量限制)

①总容量——size字段;②已存入数据量——used字段。

Hash值(可能较大)数组容量取余,会得到一个永远小于数组容量大小的值,此时的值也就恰好可以当作数组下标来使用,我们把取余之后的值称为键在该字典中的索引值,即"索引值==数组下标值",

5.1.3 Hash冲突

图5-5 字典结构示意图(整体结构)

单链表 解决hash冲突

数组中的元素除了应把键值对中的"值"存储外,还应该存储"键"信息和一个next指针,next指针可以把冲突的键值对串成单链表 ;

当根据键去找值时,分如下几步。

第1步: 键通过Hash、取余等操作得到索引值,根据索引值找到对应元素。

第2步: 判断元素中键查找的键是否相等,相等则读取元素中的值返回,否则判断next指针是否有值,如存在值,则读取next指向元素,回到第2步继续执行,如不存在值,则代表此键在字典中不存在,返回NULL。

 

第3个特征的实现,即"键值对中值的类型可为String、Hash、List、Set、SortedSet",可以将数组元素中的val字段设置成指针,通过指针指向值所在任意内存

5.2 Redis字典的实现

Redis字典实现依赖的数据结构主要包含了三部分:字典、Hash表、Hash表节点。字典中嵌入了两个Hash表,Hash表中的table字段存放着Hash表节点,Hash表节点对应存储的是键值对。

 

1.Hash表

typedef struct dictht {

  dictEntry **table; /* 指针数组,用于存储键值对 */

  unsigned long size; /* table数组的大小 */

  unsigned long sizemask; /* 掩码 = size - 1 */

  unsigned long used; /*table数组已存元素个数,包含next单链表的 */

} dictht;

 

table : 数组中的元素指向的是dictEntry的结构体

dictEntry : 里面存有键值对

sizemask : 用来计算键的索引值,sizemask的值恒等于size–1。

 

索引值具体计算步骤如下 :

第1步: 人为设定Hash表的数组容量初始值为4,随着键值对存储量的增加,就需对Hash表扩容,新扩容的容量大小设定为当前容量大小的一倍,也就是说,Hash表的容量大小只能为4,8,16,32…。而sizemask掩码的值就只能为3,7,15,31…,对应的二进制为11,111,1111,11111…,因此掩码值的二进制肯定是每一位都为1。

第2步: 索引值=Hash&掩码值,对应Redis源码为:idx=hash&d->ht[table].sizemask,其计算结果等同Hash值与Hash表容量取余,而计算机的位运算要比取余运算快很多。

举例:

hash值=8 ( 二进制1000), size=4, sizemask=3 (二进制11) , 8 mod 4 =0 ;

二进制计算方式: 索引值=1000 & 11 =0000 -> 0 (十进制)

 

2.Hash表节点

typedef struct dictEntry {

void *key; //存储键

union {

  void *val; //db.dict中的val

  uint64_t u64;

  int64_t s64; //db.expires中存储过期时间

  double d;

} v; //值,是个联合体

struct dictEntry *next; //Hash冲突时,指向冲突的元素,形成单链表

} dictEntry;

 

图5-7 两个元素冲突后(结构示意图)

3.字典

字典的数据结构其主要作用是对散列表再进行一层封装。

typedef struct dict {

dictType *type; /* 该字典对应的特定操作函数 */

void *privdata; /* 该字典依赖的数据*/

dictht ht[2]; /* Hash表,键值对存储在此 */

long rehashidx; /*rehash标识。默认值为-1,代表没进行rehash操作; */

unsigned long iterators; /* 当前运行的迭代器数 */

} dict;

 

 

type字段

指向dictType结构体:,为了实现各种形态的字典而抽象出来的一组操作函数

typedef struct dictType {

uint64_t (*hashFunction)(const void *key); /* 该字典对应的Hash */

void *(*keyDup)(void *privdata, const void *key); /**键对应的复制函数 */

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;

 

·privdata字段

私有数据,配合type字段指向的函数一起使用。

·ht字段

是个大小为2的数组,该数组存储的元素类型为dictht,虽然有两个元素,但一般情况下只会使用ht[0],只有当该字典扩容、缩容需要进行rehash时,才会用到ht[1]。

·rehashidx字段

用来标记该字典是否在进行rehash,没进行rehash时,值为-1,否则,该值用来表示Hash表ht[0]执行rehash到了哪个元素,并记录该元素的数组下标值。

·iterators字段

用来记录当前运行的安全迭代器数,当有安全迭代器绑定到该字典时,会暂停rehash操作。Redis很多场景下都会用到迭代器,例如:执行keys命令会创建一个安全迭代器,此时iterators会加1,命令执行完毕则减1,而执行sort命令时会创建普通迭代器,该字段不会改变,关于迭代器的介绍详见5.4.1节。

结构图(重要)

图5-8 Redis字典结构示意图

5.3 基本操作

5.3.1 字典初始化

redis-server启动中,整个数据库会先初始化一个空的字典用于存储整个数据库的键值对。

dict.h # dictCreate :

/* Create a new hash table */

dict *dictCreate(dictType *type,

void *privDataPtr)

{

  dict *d = zmalloc(sizeof(*d)); // 96字节 , 申请空间

  _dictInit(d,type,privDataPtr); // 结构体初始化值 , 调用_dictInit函数

  return d;

}

/* Initialize the hash table 字典的各个字段赋予初始值 */

int _dictInit(dict *d, dictType *type,

void *privDataPtr)

{

_dictReset(&d->ht[0]);

_dictReset(&d->ht[1]);

d->type = type;

d->privdata = privDataPtr;

d->rehashidx = -1;

d->iterators = 0;

return DICT_OK;

}

 

图5-9 Redis空字典内存占用示意图

5.3.2 添加元素

void setKey(redisDb *db, robj *key, robj *val);

主要流程:

第1步: 调用dictFind函数,查询键是否存在,是则调用dbOverwrite函数修改键值对,否则调用dbAdd函数添加元素。

第2步: dbAdd最终调用dict.h文件中的dictAdd函数插入键值对。

/* Add an element to the target hash table */

int dictAdd(dict *d, void *key, void *val) //调用前会查找key存在与否,不存在则调用dictAdd

{

dictEntry *entry = dictAddRaw(d,key,NULL); // 添加键,字典中键已存在则返回NULL,否则添加

if (!entry) return DICT_ERR; //键存在则返回错误

dictSetVal(d, entry, val); // 设置值

return DICT_OK;

}

 

dictAddRaw函数 :

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)

{

long index;

dictEntry *entry; //hash表节点

dictht *ht; //Hash

//该字典是否在进行rehash操作中,是则执行一次rehash

if (dictIsRehashing(d)) _dictRehashStep(d);

/* 查找键,找到则直接返回-1, 并把老节点存入existing字段,否则把新节点的索引值返回。如果遇到Hash表容量不足,则进行扩容return NULL*/

if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)

return NULL;

 

/*分配内存并存储新条目。 将元素插入顶部,并假设 LRU */

ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; //是否进行rehash操作中,是则插入至散列

//申请新节点内存,插入散列表中,给新节点存入键信息

entry = zmalloc(sizeof(*entry));

entry->next = ht->table[index];

ht->table[index] = entry;

ht->used++;

 

/* Set the hash entry fields. */

dictSetKey(d, entry, key);

return entry;

}

函数作用: 添加键与查找键, 添加成功返回新节点,查找成功返回NULL并把老节点存入existing字段。

核心调用是_dictKeyIndex函数, 作用是得到键的索引值 ;主要有这么两步:

dictHashKey(d,key) //第1步:调用该字典的Hash函数得到键的Hash值

idx = hash & d->ht[table].sizemask; //第2步:用键的Hash值与字典掩码取与,得到索引值

 

图5-10 字典添加一个元素后(结构示意图)

 

 

字典扩容

/* Expand or create the hash table */

int dictExpand(dict *d, unsigned long size)

{

/* 如果 size小于哈希表中已经存在的*个元素,则该大小无效 */

if (dictIsRehashing(d) || d->ht[0].used > size)

return DICT_ERR;

 

dictht n; /* the new hash table */

//重新计算扩容后的值,必须为2的N次方幂

unsigned long realsize = _dictNextPower(size);

 

/* Rehashing to the same table size is not useful. */

if (realsize == d->ht[0].size) return DICT_ERR;

 

/* Allocate the new hash table and initialize all pointers to NULL */

n.size = realsize;

n.sizemask = realsize-1;

n.table = zcalloc(realsize*sizeof(dictEntry*));

n.used = 0;

 

/* Is this the first initialization? If so it's not really a rehashing

* we just set the first hash table so that it can accept keys. */

if (d->ht[0].table == NULL) {

  d->ht[0] = n;

  return DICT_OK;

}

d->ht[1] = n; //扩容后的新内存放入ht[1]中

d->rehashidx = 0; //非默认的-1,表示需进行rehash

return DICT_OK;

}

 

扩容主要流程为:

①申请一块新内存,初次申请时默认容量大小为4个dictEntry;非初次申请时,申请内存的大小则为当前Hash表容量的一倍。

②把新申请的内存地址赋值给ht[1],并把字典的rehashidx标识由-1改为0,表示之后需要进行rehash操作。

 

图5-11 扩容后结构示意图

扩容后,字典容量掩码值会发生改变,索引值就会发生改变,从而导致根据键查找不到值的情况。

解决这个问题的方法是,新扩容的内存放到一个全新的Hash表中(ht[1]),并给字典打上在进行rehash操作中的标识(即rehashidx!=-1)。此后,新添加的键值对都往新的Hash表中存储;而修改、删除、查找操作需要在ht[0]、ht[1]中进行检查,然后再决定去对哪个Hash表操作。除此之外,还需要把老Hash表(ht[0])中的数据重新计算索引值后全部迁移插入到新的Hash表(ht[1])中,此迁移过程称作rehash,我们下面讲解rehash的实现。

 

2.渐进式rehash

rehash除了扩容时会触发,缩容时也会触发。Redis整个rehash的实现,主要分为如下几步完成。

  1. 给Hash表ht[1]申请足够的空间;扩容时空间大小为当前容量*2,即d->ht[0].used*2;

    当使用量不到总空间10%时,则进行缩容。缩容时空间大小则为能恰好包含d->ht[0].used个节点的2^N次方幂整数,并把字典中字段rehashidx标识为0。

2)进行rehash操作调用的是dictRehash函数,重新计算ht[0]中每个键的Hash值与索引值(重新计算就叫rehash),依次添加到新的Hash表ht[1],并把老Hash表中该键值对删除。把字典中字段rehashidx字段修改为Hash表ht[0]中正在进行rehash操作节点的索引值。

3)rehash操作后,清空ht[0],然后对调一下ht[1]与ht[0]的值,并把字典中rehashidx字段标识为-1。

 

Redis优化的思想

分而治之的思想进行rehash操作,大致的步骤如下。

执行插入、删除、查找、修改等操作前,都先判断当前字典rehash操作是否在进行中,进行中则调用dictRehashStep函数进行rehash操作(每次只对1个节点进行rehash操作,共执行1次)。

除这些操作之外,当服务空闲时,如果当前字典也需要进行rehsh操作,则会调用incrementallyRehash函数进行批量rehash操作(每次对100个节点进行rehash操作,共执行1毫秒)。在经历N次rehash操作后,整个ht[0]的数据都会迁移到ht[1]中,这样做的好处就把是本应集中处理的时间分散到了上百万、千万、亿次操作中,所以其耗时可忽略不计。

图5-12 渐进式rehash后结构示意图

5.3.3 查找元素

dictEntry *dictFind(dict *d, const void *key)

{

dictEntry *he;

uint64_t h, idx, table;

 

if (dictSize(d) == 0) return NULL; /* dict is empty */

if (dictIsRehashing(d)) _dictRehashStep(d);

h = dictHashKey(d, key); // 得到键的Hash值

for (table = 0; table <= 1; table++) { // 遍历查找Hash表 ht[0]与ht[1]

idx = h & d->ht[table].sizemask; // 根据Hash值获取到对应的索引值

he = d->ht[table].table[idx];

while(he) { // 如果存在值则遍历该值中的单链表

  if (key==he->key || dictCompareKeys(d, key, he->key)) // 找到与键相等的值,返回该节点

  return he;

  he = he->next;

}

if (!dictIsRehashing(d)) return NULL; // 如果未进行rehash操作,则只读取ht[0]

}

return NULL;

}

 

主要分如下几个步骤:

1)根据键调用Hash函数取得其Hash值。

2)根据Hash值取到索引值。

3)遍历字典的两个Hash表,读取索引对应的元素。

4)遍历该元素单链表,如找到了与自身键匹配的键,则返回该元素。

5)找不到则返回NULL。

 

5.3.4 修改元素

void dbOverwrite(redisDb *db, robj *key, robj *val) {

dictEntry *de = dictFind(db->dict,key->ptr); // 查找键存在与否,返回存在的节点

serverAssertWithInfo(NULL,key,de != NULL); // 不存在则中断执行

dictEntry auxentry = *de; //

robj *old = dictGetVal(de); // 获取老节点的val字段值

if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {

  val->lru = old->lru;

}

dictSetVal(db->dict, de, val); //给节点设置新的值

if (server.lazyfree_lazy_server_del) {

  freeObjAsync(old);

  dictSetVal(db->dict, &auxentry, NULL);

}

dictFreeVal(db->dict, &auxentry); // 释放节点中旧val内存

}

 

整个过程主要分如下几个步骤:

1)调用dictFind查找键是否存在;

2)不存在则中断执行;

3)修改节点键值对中的值为新值;

4)释放旧值内存。

 

5.3.5 删除元素

Server收到del命令后,最终删除键值对会执行dict.h文件中的dictDelete函数,其主要的执行过程为:

1)查找该键是否存在于该字典中;

2)存在则把该节点从单链表中剔除;

3)释放该节点对应键占用的内存、值占用的内存,以及本身占用的内存;

4)给对应的Hash表的used字典减1操作。

 

缩容

当字典中数据使用量不到总空间<10%时会进行缩容;

字典缩容的核心函数有两个:

void tryResizeHashTables(int dbid) {

if (htNeedsResize(server.db[dbid].dict)) //判断是否需要缩容:used/size<10%

dictResize(server.db[dbid].dict); //执行缩容操作

if (htNeedsResize(server.db[dbid].expires))

dictResize(server.db[dbid].expires);

}

//缩容函数

int dictResize(dict *d)

{

unsigned long minimal;

 

if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;

minimal = d->ht[0].used; //容量最小值为4

if (minimal < DICT_HT_INITIAL_SIZE)

minimal = DICT_HT_INITIAL_SIZE;

return dictExpand(d, minimal); //调用扩容函数,实质进行的是缩容

}

整个缩容的步骤大致为:

判断当前的容量是否达到最低阈值,即used/size<10%,达到了则调用dictResize函数进行缩容,缩容后的函数容量实质为used的最小2 N 整数。缩容操作和扩容操作实质差不多,最终调用的都是dictExpand函数,之后的操作与扩容一致

5.4 字典的遍历

遍历Redis整个数据库主要有两种方式:全遍历 (例如keys命令)、间断遍历 (hscan命令),这两种方式将在下面进行详细讲解。

全遍历:一次命令执行就遍历完整个数据库。

间断遍历:每次命令执行只取部分数据,分多次遍历。

5.4.1 迭代器遍历

基本数据结构如下:

/* 如果将safe设置为1,则这是一个安全的迭代器,这意味着,即使在进行迭代时,也可以针对字典调用 dictAdd,dictFind和其他函数。否则,它是不安全的迭代器,并且在迭代时仅应调用dictNext() */

typedef struct dictIterator {

dict *d; //迭代的字典

long index; //当前迭代到Hash表中哪个索引值

int table, safe; //table用于表示当前正在迭代的Hash表,即ht[0]与ht[1],safe用于表示当前创建的是否为安全迭代器

dictEntry *entry, *nextEntry; //当前节点,下一个节点

long long fingerprint; // 字典的指纹,未发生改变时,该值不变,发生改变时则值也随着改变

} dictIterator;

 

整个数据结构占用了48字节;

fingerprint字段是一个64位的整数,表示在给定时间内字典的状态。该字段的值为字典(dict结构体)中所有字段值组合在一起生成的Hash值,所以当字典中数据发生任何变化时,其值都会不同,

迭代相关的API函数,

主要为:

dictIterator *dictGetIterator(dict *d); /*初始化迭代器*/

dictIterator *dictGetSafeIterator(dict *d); /*初始化安全的迭代器*/

dictEntry *dictNext(dictIterator *iter); /*通过迭代器获取下一个节点*/

void dictReleaseIterator(dictIterator *iter); /*释放迭代器*/

 

我们把迭代器遍历数据分为两类:

1)普通迭代器,只遍历数据;

2)安全迭代器,遍历的同时删除数据。

1.普通迭代器

对迭代器中fingerprint字段的值作严格的校验,来保证迭代过程中字典结构不发生任何变化,确保读取出的数据不出现重复。

当Redis执行部分命令时会使用普通迭代器迭代字典数据,例如sort命令。sort命令主要作用是对给定列表、集合、有序集合的元素进行排序,如果给定的是有序集合,其成员名存储用的是字典,分值存储用的是跳跃表,则执行sort命令读取数据的时候会用到迭代器来遍历整个字典。

 

迭代过程主要分为如下几个步骤

1)调用dictGetIterator函数初始化一个普通迭代器,此时会把iter->safe值置为0,表示初始化的迭代器为普通迭代器,初始化后的结构示意图如图5-13所示。

图5-13 初始化后迭代器结构示意图

 

2)循环调用dictNext函数依次遍历字典中Hash表的节点,首次遍历时会通过dictFingerprint函数拿到当前字典的指纹值,此时结构示意图如图5-14所示。

图5-14 迭代一次后迭代器结构示意图

注意  entry与nextEntry两个指针分别指向Hash冲突后的两个父子节点。如果在安全模式下,删除了entry节点,nextEntry字段可以保证后续迭代数据不丢失。

 

3)当调用dictNext函数遍历完字典Hash表中节点数据后,释放迭代器时会继续调用dictFingerprint函数计算字典的指纹值,并与首次拿到的指纹值比较,不相等则输出异常"===ASSERTIONFAILED===",且退出程序执行。

普通迭代器通过步骤1、步骤3的指纹值对比,来限制整个迭代过程中只能进行迭代操作,即迭代过程中字典数据的修改、添加、删除、查找等操作都不能进行,只能调用dictNext函数迭代整个字典,否则就报异常,由此来保证迭代器取出数据的准确性。

 

注意  对字典进行修改、添加、删除、查找操作都会调用dictRehashStep函数,进行渐进式reahash操作,从而导致fingerprint值发生改变。

 

2.安全迭代器

安全迭代器和普通迭代器迭代数据原理类似,也是通过循环调用dictNext函数依次遍历字典中Hash表的节点。安全迭代器确保读取数据的准确性,不是通过限制字典的部分操作来实现的,而是通过限制rehash的进行来确保数据的准确性,因此迭代过程中可以对字典进行增删改查等操作。

对字典的增删改查操作会调用dictRehashStep函数进行渐进式rehash操作,那如何对rehash操作进行限制呢,我们一起看下dictRehashStep函数源码实现:

static void _dictRehashStep(dict *d) {

  if (d->iterators == 0) dictRehash(d,1); //字典正在运行迭代操作的安全迭代器个数

}

 

原理上很简单,如果当前字典有安全迭代器运行,则不进行渐进式rehash操作,rehash操作暂停,字典中数据就不会被重复遍历,由此确保了读取数据的准确性。

当Redis执行部分命令时会使用安全迭代器迭代字典数据,例如keys命令。keys命令主要作用是通过模式匹配,返回给定模式的所有key列表,遇到过期的键则会进行删除操作。Redis数据键值对都存储在字典中,因此keys命令会通过安全迭代器来遍历整个字典。

安全迭代器 迭代过程 主要步骤

第1步: 调用dictGetSafeIterator函数初始化一个安全迭代器,此时会把iter->safe值置为1,表示初始化的迭代器为安全迭代器,初始化完后的结构示意图与图5-12类似,safe字段置为1。

第2步: 循环调用dictNext函数依次遍历字典中Hash表的节点,首次遍历时会把字典中iterators字段进行加1操作,确保迭代过程中渐进式rehash操作会被中断执行。

第3步: 当调用dictNext函数遍历完字典Hash表中节点数据后,释放迭代器时会把字典中iterators字段进行减1操作,确保迭代后渐进式rehash操作能正常进行。

安全迭代器是通过步骤1、步骤3中对字典的iterators字段进行修改使得迭代过程中渐进式rehash操作被中断,由此来保证迭代器读取数据的准确性。

 

5.4.2 间断遍历

dictScan是"间断遍历"中的一种实现,主要在迭代字典中数据时使用,例如hscan命令迭代整个数据库中的key,以及zscan命令迭代有序集合所有成员与值时,都是通过dictScan函数来实现的字典遍历。dictScan遍历字典过程中是可以进行rehash操作的,通过算法来保证所有的数据能被遍历到。

unsigned long dictScan(dict *d, //变量d是当前迭代的字典

unsigned longv, //迭代开始的游标(即Hash表中数组索引)

dictScanFunction *fn, //每遍历一个节点则调用该函数处理

dictScanBucketFunction* bucketfn, //整理碎片时调用

void *privdata) //回调函数fn所需参数

 

执行hscan命令时外层调用dictScan函数示例:

long maxiterations = count*10;

//count为hscan命令传入的count值,代表获取数据个数。Hash表处于病态时(例如大部分的节点为空时)

do {

  cursor = dictScan(ht, cursor, scanCallback, NULL, privdata);

  //调用dictScan 函数迭代字典数据,cursor字段初始值为hscan命令传入值,代表迭代Hash数组的游标起点值

} while (cursor &&

  maxiterations-- &&

  listLength(keys) < (unsigned long)count);

 

dictScan函数间断遍历字典过程中会遇到如下3种情况。

1)从迭代开始到结束,散列表没有进行rehash操作。

2)从迭代开始到结束,散列表进行了扩容或缩容操作,且恰好为两次迭代间隔期间完成了rehash操作。

3)从迭代开始到结束,某次或某几次迭代时散列表正在进行rehash操作。

 

1.遍历过程中始终未遇到rehash操作

尽量不重复数据,统一采用了一种叫作reverse binary iteration的方法来进行间断数据迭代,接下来看下其主要源码实现,迭代的代码如下:

if (!dictIsRehashing(d)) {

  t0 = &(d->ht[0]);

  m0 = t0->sizemask;

 

/* Emit entries at cursor */

if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);

de = t0->table[v & m0]; // 避免缩容后游标超出Hash表最大值

while (de) { // 循环遍历当前节点的单链表

  next = de->next;

  fn(privdata, de); // 依次将节点中键值对存入privdata字段中的单链表

  de = next;

}

// 整个迭代过程强依赖游标值v变量,根据v找到当前需读取的Hash表元素,然后遍历该元素单链表上所有的键值对,依次执行fn函数指针执行的函数,对键值对进行读取操作。

//游标具体变更算法

/* Set unmasked bits so incrementing the reversed cursor

* operates on the masked bits */

v |= ~m0;

 

/* Increment the reverse cursor */

v = rev(v); //二进制逆转

v++;

v = rev(v); //二进制逆转

}

 

用 3个假设的例子,并结合游标变更算法来分别说明不同情况下迭代的顺序;

第1种假设:"假设Hash表大小为4,迭代从始至终未进行扩容缩容操作 ",

此时数组的掩码为m0=0x11,~m0=0x100,游标值的变化如表5-1所示。

表5-1 第1种假设游标值的变化

因此整个表遍历顺序为,0、2、1、3的顺序,恰好把所有的节点遍历完。

 

第2种假设:"假设Hash表大小为4,进行第3次迭代时,Hash表扩容到了8 ",

表为4时,数组的掩码为m0=0x11,~m0=0x100,游标值的变化顺序如表5-2所示。

进行第3次迭代时,表大小扩容到了8,数组的掩码m0=0x111,~m0=0x1000,接下来游标值的变化顺序如表5-3所示。

迭代只进行6次就完成了,顺序为0、2、1、5、3、7,扩容后少遍历了4、6,因为游标为0、2的数据在扩容前已经迭代完,而Hash表大小从4扩容至8,再经过rehash后,游标为0、2的数

据可能会分布在0|4、2|6上,因此扩容后的游标4、6不需要再迭代;

图5-15 扩容rehash后新老表数据分布对应图

 

第3种假设:"假设Hash表大小为8,迭代进行到了第5次时,Hash表缩容到了4 "

Hash表为8时,数组的掩码为m0=0x111, ~m0=0x1000,游标值的变化顺序如表5-4所示。

表5-4 第3种假设游标值的变化

进行第5次迭代时,Hash表大小缩容到了4,数组的掩码为m0=0x11,~m0=0x100,接下来游标值的变化顺序如表5-5所示。

跟第2种情况同理;

这里还有一种特殊的情况没说明,比如多次间断遍历的时候该字典缩容了两次,则可能造成遍历的数据出现重复,

 

2.遍历过程中遇到rehash操作

rehash操作中会同时并存两个Hash表: 扩容或缩容后的表ht[1], 老表ht[0],ht[0]的数据通过渐进式rehash会逐步迁移到ht[1]中,最终完成整个迁移过程。

遍历过程为:

先找到两个散列表中更小的表,先对小的Hash表遍历,然后对大的Hash表遍历;

/* to 是大表, t1是小表 */

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

if (t0->size > t1->size) {

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

}

// 掩码值

m0 = t0->sizemask; m1 = t1->sizemask;

/* Emit entries at cursor */

if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);

  de = t0->table[v & m0];

while (de) { /* 迭代第一张小Hash表*/

  next = de->next;

  fn(privdata, de);

  de = next;

}

do {         /* 迭代第2张大Hash表*/

  if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);

  de = t1->table[v & m1];

  while (de) {

  next = de->next;

  fn(privdata, de);

  de = next;

}

/*游标变更算法.*/

v |= ~m1; v = rev(v); v++; v = rev(v);

/* Continue while bits covered by mask difference is non-zero */

} while (v & (m0 ^ m1));

 

这套算法能满足这样的特点,主要是巧妙地利用了扩容及缩容正好为整数倍增长或减少的原理,根据这个特征,很容易就能推导出同一个节点的数据扩容/缩容后在新的Hash表中的分布位置,从而避免了重复遍历或漏遍历。

5.5 API列表

字典的所有API方法声明都在dict.h文件中,

具体每个API的作用如下:

dict *dictCreate(dictType *type, void *privDataPtr);//初始化字典

int dictExpand(dict *d, unsigned long size); //字典扩容

int dictAdd(dict *d, void *key, void *val); //添加键值对,已存在则不添加

//添加key,并返回新添加的key对应的节点。若已存在,则存入existing字段,并返回-1

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing);

dictEntry *dictAddOrFind(dict *d, void *key); //添加或查找key

int dictReplace(dict *d, void *key, void *val); //添加键值对,若存在则修改,否则添加

int dictDelete(dict *d, const void *key);//删除节点

dictEntry *dictUnlink(dict *ht, const void *key); //删除key,但不释放内存

void dictFreeUnlinkedEntry(dict *d, dictEntry *he);//释放dictUnlink函数删除key的内存

void dictRelease(dict *d);//释放字典

dictEntry * dictFind(dict *d, const void *key); //根据键查找元素

void *dictFetchValue(dict *d, const void *key); //根据键查找出值

int dictResize(dict *d); //将字典表的大小调整为包含所有元素的最小值,即收缩字典

dictIterator *dictGetIterator(dict *d);//初始化普通迭代器

dictIterator *dictGetSafeIterator(dict *d); // 初始化安全迭代器

dictEntry *dictNext(dictIterator *iter); //通过迭代器获取下一个节点

void dictReleaseIterator(dictIterator *iter); //释放迭代器

dictEntry *dictGetRandomKey(dict *d); //随机得到一个键

//随机得到几个键

unsigned int dictGetSomeKeys(dict *d, dictEntry **des, unsigned int count);

void dictGetStats(char *buf, size_t bufsize, dict *d); //读取字典的状态、使用情况等

uint64_t dictGenHashFunction(const void *key, int len);//hash函数-字母大小写敏感

//Hash函数,字母大小写不敏感

uint64_t dictGenCaseHashFunction(const unsigned char *buf, int len);

void dictEmpty(dict *d, void(callback)(void*));//清空一个字典

void dictEnableResize(void); //开启Resize

void dictDisableResize(void); //关闭Resize

int dictRehash(dict *d, int n); //渐进式rehash,n为进行几步

int dictRehashMilliseconds(dict *d, int ms); //持续性rehash,ms为持续多久

void dictSetHashFunctionSeed(uint8_t *seed); //设置新的散列种子

uint8_t *dictGetHashFunctionSeed(void); //获取当前散列种子值

unsigned long dictScan(dict *d, unsigned long v, dictScanFunction *fn, dictScanBucketFunction *bucketfn, void *privdata);//间断性的迭代字段数据

uint64_t dictGetHash(dict *d, const void *key); //得到键的Hash值

dictEntry **dictFindEntryRefByPtrAndHash(dict *d, const void *oldptr, uint64_t hash);//使用指针+hash值去查找元素

 

5.6 本章小结

字典的概念;

Redis数据库底层是如何存储数据。

请思考,在5.2.1节中介绍字典的基本实现中为什么Hash表数组中存放的是每个dictEntry的指针地址,而不是直接把dictEntry嵌入到Hash表数组中去?

posted @ 2020-10-28 19:34  将军上座  阅读(509)  评论(0)    收藏  举报