字典相对于数组,链表来说,是一种较高层次的数据结构,像我们的汉语字典一样,可以通过拼音或偏旁唯一确定一个汉字,在程序里我们管每一个映射关系叫做一个键值对,很多个键值对放在一起就构成了我们的字典结构。

有很多高级的字典结构实现,例如我们 Java 中的 HashMap 底层实现,根据键的 Hash 值均匀的将键值对分散到数组中,并在遇到哈希冲突时,冲突的键值对通过单向链表串联,并在链表结构超过八个节点裂变成红黑树。

那么 redis 中是怎么实现的呢?我们一起来看一看。

一、字典结构定义

Redis 中的字典相关结构都定义在 dict.h 文件中,dict 表示一个字典结构:

typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx;
unsigned long iterators;
} dict;

其中,type 字段指向 dictType 结构,这个结构中定义几个多态方法,具体如下:

typedef struct dictType {
uint64_t (*hashFunction)(const void *key);
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;

hashFunction 哈希函数指针,当我们通过 set 命令往字典中存储数据时,会先用键值对的键作为参数传入哈希函数,得到一个较为散列均匀的值,然后才会实际的进行数据的存储。这里就会用到哈希函数,如果你需要为你的字典结构提供不同的散列方式,在初始化字典的时候为 dictType 中哈希函数进行一个实现就好。

keyDup 是一个键的复制函数,valDup是一个键值对的值的复制函数,keyCompare 是一个键的比较大小的函数,keyDestructor 销毁一个键,valDestructor 销毁一个键值对的值。都是一个多态的呈现,具体实现需要使用者自行提供。

接着看 dict 结构,privdata 指针存储了字典结构一些附属额外信息,ht 是一个 dictht 结构的数组,dictht 就是一个哈希表结构,我们等下看这个结构。rehashidx 字段用于 rehash 过程中记录正在转移的键。iterators 字段记录了当前字典正在进行中的迭代器,具体的再看。

dictht 就是我们的哈希表结构,

typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;

table 是一个指向 dictEntry 的二维数组,每个 dictEntry 其实就表述一个键值对,为什么是一个二维的结构呢?

其实正常情况下,我们的字典是这样保存数据的:

每个 dictEntry 内部会保存一个 key/value 的键值对,然后我们通过 table 指针可以遍历所有的键值对,但是如果某个键值对的键进行哈希之后并计算得到应该存储的位置被别的节点捷足先登了,也就是我们常说的哈希冲突了,怎么办?

redis 中的做法,甚至于大部分字典结构实现都是选择将冲突的节点串联成链表,于是字典结构就变成这样了。

同一条链表上的节点键的哈希值必定是相同的,也正是因为相同才会被串在一起,从逻辑上看,字典结构如上图所展示的那样,但抽象到我们的代码层,就是一个二维数组的结构,第一维放的就是节点指针的指针,第二维指向的就是指向我们键值对结构的指针,每一个 dictEntry 结构都会有一个 next 指针,在遇到哈希冲突的时候可以串联所有冲突节点。

除此之外,dictht 中的 size 属性用于描述整个哈希字典表最大可寻址大小,也就是二维数组中第一维度的最大长度,sizemask 属性始终等于 size-1 表述的是一种大小掩码的概念,用于确定节点最初在数组中的位置,used 记录了整张哈希表中已经存储的键值对节点数量。

其中,dict 字典结构中 ht 是一个只有两个元素的数组,正常情况下我们使用 ht[0] 字典表,ht[1] 用在我们渐进 rehash 过程中转移 ht[0] 中所有节点中。

最后,我们再来看这个 dictEntry 键值对结构:

typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;

key 是一个指向任意结构的指针,代表我们的 key 可以使用我们 redis 中任意对象类型,v 是一个 union 类型,它可以是一个指针,也可以是 uint64_t 或 int64_t 类型,也可以是一个 double 类型。根据实际使用中,value 的不同值,使用不同的字段属性。

next 指针指向另一个 dictEntry 结构,用于发生哈希冲突时,链接下一个键值对节点。

以上就是 redis 中字典结构主要结构类型,从里至外封装了三层,dict 描述一个字典,其中的 dictht 描述哈希表,其中的 dictEntry 描述键值对结构。迭代器回头我们单独说说。

二、渐进式 rehash 迁移数据

redis 的 rehash 和 Java 以及其他哈希的实现稍微可能有点不同,由于 redis 是单线程的,不需要写大量的并发语句来保证数据一致性,但是单线程处理也会导致一次 rehash 过程会非常缓慢,客户端阻塞太久。那么 redis 具体是怎么做的呢?

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;
}

rehashidx 的值默认为 -1,表示当前字典未处于 rehash 阶段,其他场合该字段的值等于当前正在转移桶的索引。

新版本的 dictRehash 需要多传一个参数 n,这个参数用于控制单次最多转移空桶数量。什么意思呢,具体我们看一张图:

有这么一个字典结构,其中索引值为 2 和 3 的两个桶是空的,也即里面没有放我们的键值对节点。正常情况下,一次 rehash 只会转移一个桶,但如果上一次转移了索引为 1 的那个桶,下一次来会遍历后面一个桶,如果继续为空就继续向后遍历,直到找到一个存储了我们节点的非空桶,极端情况下,如果字典表中只有最后一个桶有节点,那么一次的 rehash 就要遍历所有的桶,时间复杂度 O(n),这会导致客户端等待过长时间,所以新版本中额外传一个参数 n 用于控制最多遍历的空桶数。

相关代码段如下:

while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}

方法的尾部会进行一个校验,如果当前桶转移结束后,当前字典的 rehash 过程完全结束,那么修改 ht[0] 指针引用,让他指向新的字典表 ht[1],并设置 rehashidx 为 -1,标记整个字典 rehash 结束。

以上就是 redis 中 rehash 的全过程,还是比较简单的,那为什么说它是渐进式的呢,我们看一下添加和查询键值对的方法。

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
int index;
dictEntry *entry;
dictht *ht; if (dictIsRehashing(d)) _dictRehashStep(d); if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
return NULL;
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
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;
}

这就是我们调用 set 命令,底层为我们添加键值对的方法,函数的最开头逻辑就是调用 dictIsRehashing 方法判断当前的字典表是否处于 rehash 状态,也即判断 rehashidx 是否不等于 -1 了。_dictRehashStep 方法实现:

static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}

默认情况下,一次 rehash 过程,redis 允许最多 10 空桶的访问就要返回,不得逗留。值得注意的是,方法的后续逻辑会判断当前字典如果正在进行 rehash,那么新的键值对将不再向 ht[0] 中添加,而直接转而添加到 ht[1] 中

我们再看看查询键值对的 get 命令底层 API 调用,底层会调用 dictFind 方法。

dictEntry *dictFind(dict *d, const void *key)
{
dictEntry *he;
unsigned int h, idx, table; if (d->ht[0].used + d->ht[1].used == 0) return NULL;
if (dictIsRehashing(d)) _dictRehashStep(d);
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 (key==he->key || dictCompareKeys(d, key, he->key))
return he;
he = he->next;
}
if (!dictIsRehashing(d)) return NULL;
}
return NULL;
}

可以看到,同样也是有 dictIsRehashing 方法的判断,如果字典处于 rehash 状态,即需要去完成一个桶的转移,然后才能返回。值得注意的是,方法的中间逻辑是嵌套在一个 for 循环中的,供两次循环,第一次从 ht[0] 中搜索我们给定 key 的键值对,如果没有找到,第二次循环将从 ht[1] 中搜索我们要查询的键值对。

之所以说 redis 的 rehash 是渐进式的,就是因为即便它处于 rehash 状态下,所有节点的插入、查询甚至于删除都是不受影响的,直至整个 rehash 结束,redis 释放原先 ht[0] 占用无用内存。

ps:redis 中的字典实现相对于 Java 中的实现要简单不少,主要还是因为 redis 是单线程调用的,不需要使用额外的并发语句控制。

三、字典迭代器

迭代器是用于迭代遍历字典中所有的节点的一个工具,有两种,一种是安全迭代器,一种是不安全迭代器。安全迭代器就是指,你在迭代的过程中,允许你对字典结构进行修改,也即允许你添加、删除、修改字典中的键值对节点。不安全迭代器即不允许对字典中任何节点进行修改。

dictIterator 结构的定义如下:

typedef struct dictIterator {
dict *d;
long index;
int table, safe;
dictEntry *entry, *nextEntry;
/* unsafe iterator fingerprint for misuse detection. */
long long fingerprint;
} dictIterator;

字段 d 指向一个即将被迭代的字典结构,index 记录了当前迭代到字典中的桶索引,table 取值为 0 或 1,表示当前迭代的是字典中哪个哈希表,safe 标记当前迭代器是安全的或是不安全的。 entry 记录的是当前迭代的节点,nextEntry 的值等于 entry 的 next 指针,用于防止当前节点接受删除操作后续节点丢失情况。fingerprint 保存了 dictFingerprint 函数根据当前字典的基本信息计算的一个指纹信息,稍有一丁点变动,指纹信息就会发生变化,用于不安全迭代器检验。

安全迭代器获取方式:

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;
}

不安全迭代器获取方式:

dictIterator *dictGetSafeIterator(dict *d) {
dictIterator *i = dictGetIterator(d); i->safe = 1;
return i;
}

下面我们看看迭代器的核心方法,dictNext 用于获取字典中下一个节点。

dictEntry *dictNext(dictIterator *iter)
{
while (1) {
//如果迭代器初次工作,entry 必定为 null
if (iter->entry == NULL) {
//拿到迭代器 d 字段保存的字典
dictht *ht = &iter->d->ht[iter->table];
if (iter->index == -1 && iter->table == 0) {
if (iter->safe)
//给字典的 iterators 字段自增,禁止 rehash操作
iter->d->iterators++;
else
//计算并保存指纹信息
iter->fingerprint = dictFingerprint(iter->d);
}
//迭代器开始工作,指向 0 号桶
iter->index++;
//如果 index 大于等于 size,即最后一个桶迭代结束
if (iter->index >= (long) ht->size) {
if (dictIsRehashing(iter->d) && iter->table == 0) {
//当前字典结构正在 rehash 且 ht[0] 已经遍历结束
//继续遍历 ht[1]
iter->table++;
iter->index = 0;
ht = &iter->d->ht[1];
} else {
//否则表示迭代工作确实全部结束
break;
}
}
//根据 index 取出节点
iter->entry = ht->table[iter->index];
} else {
//如果 entry 不等于 null,尝试遍历它的后续节点
iter->entry = iter->nextEntry;
}
//到这里,迭代器已经拿到下一个节点了
if (iter->entry) {
//记录 nextEntry 节点的值
iter->nextEntry = iter->entry->next;
return iter->entry;
}
}
return NULL;
}

大部分逻辑都已经注释上了,整个方法是一个死循环,如果 entry 等于 null,要么是迭代器初次工作,要么是迭代到一个桶的最后节点处了。如果是后者,会进入 if 逻辑中,判断是否整个字典全部迭代结束,如果不是取下一个桶。

如果字典未处于 rehash 状态,自增 iterators 属性的操作会禁止后续节点操作触发 rehash,如果已经处于 rehash 过程了,也不慌,当前 ht[0] 迭代结束后,再去迭代早于迭代器工作前已经被转移到 ht[1] 的那些节点。因为如果你是安全迭代器的话,iterators 一自增之后,后续节点就不会触发 rehash 迁移节点,所以不会重复迭代数据。

迭代器迭代结束之后需要释放关闭释放迭代器,redis 中对应方法:

void dictReleaseIterator(dictIterator *iter)
{
if (!(iter->index == -1 && iter->table == 0)) {
if (iter->safe)
iter->d->iterators--;
else
assert(iter->fingerprint == dictFingerprint(iter->d));
}
zfree(iter);
}

如果是安全的迭代器,自减 iterators,不安全迭代器会重新计算指纹并与迭代器最开始工作时计算的指纹比较,并通过 assert 断言判断指纹是否一致,如果不一致则说明你在不安全的迭代器中执行了修改字典结构的方法,程序报错并退出。

以上就是 redis 字典中基础的两个安全与非安全迭代器用法及其原理,终究是不允许边 rehash 边遍历的,其实 redis 中还有一种高级遍历方式,大家叫它 scan 遍历,它允许边 rehash 边迭代,比较高级,我们后续会分析它的源码,敬请期待!


关注公众不迷路,一个爱分享的程序员。

公众号回复「1024」加作者微信一起探讨学习!

每篇文章用到的所有案例代码素材都会上传我个人 github

https://github.com/SingleYam/overview_java

欢迎来踩!

Redis 的底层数据结构(字典)的更多相关文章

  1. Redis(二)--- Redis的底层数据结构

    1.Redis的数据结构 Redis 的底层数据结构包含简单的动态字符串(SDS).链表.字典.压缩列表.整数集合等等:五大数据类型(数据对象)都是由一种或几种数结构构成. 在命令行中可以使用 OBJ ...

  2. Redis详解(四)------ redis的底层数据结构

    上一篇博客我们介绍了 redis的五大数据类型详细用法,但是在 Redis 中,这几种数据类型底层是由什么数据结构构造的呢?本篇博客我们就来详细介绍Redis中五大数据类型的底层实现. 1.演示数据类 ...

  3. Redis 的底层数据结构(对象)

    目前为止,我们介绍了 redis 中非常典型的五种数据结构,从 SDS 到 压缩列表,这都是 redis 最底层.最常用的数据结构,相信你也掌握的不错. 但 redis 实际存储键值对的时候,是基于对 ...

  4. Redis 详解 (四) redis的底层数据结构

    目录 1.演示数据类型的实现 2.简单动态字符串 3.链表 4.字典 5.跳跃表 6.整数集合 7.压缩列表 8.总结 上一篇博客我们介绍了 redis的五大数据类型详细用法,但是在 Redis 中, ...

  5. 深入理解Redis:底层数据结构

    简介 redis[1]是一个key-value存储系统.和Memcached类似,它支持存储的value类型相对更多,包括string(字符串).list(链表).set(集合).zset(sorte ...

  6. redis string底层数据结构sds

    redis的string没有采用c语言的字符串数组而采用自定义的数据结构SDS(simple dynamic string)设计 len 为字符串的实际长度  在redis中获取字符串的key长度的时 ...

  7. Redis 的底层数据结构(SDS和链表)

    Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库.缓存和消息中间件.可能几乎所有的线上项目都会使用到 Redis,无论你是做缓存.或是用作消息中间件,用起来很简单方便 ...

  8. Redis 的底层数据结构(跳跃表)

    字典相对于数组,链表来说,是一种较高层次的数据结构,像我们的汉语字典一样,可以通过拼音或偏旁唯一确定一个汉字,在程序里我们管每一个映射关系叫做一个键值对,很多个键值对放在一起就构成了我们的字典结构. ...

  9. Redis 的底层数据结构(整数集合)

    当一个集合中只包含整数,并且元素的个数不是很多的话,redis 会用整数集合作为底层存储,它的一个优点就是可以节省很多内存,虽然字典结构的效率很高,但是它的实现结构相对复杂并且会分配较多的内存空间. ...

随机推荐

  1. 神经网络优化算法:Dropout、梯度消失/爆炸、Adam优化算法,一篇就够了!

    1. 训练误差和泛化误差 机器学习模型在训练数据集和测试数据集上的表现.如果你改变过实验中的模型结构或者超参数,你也许发现了:当模型在训练数据集上更准确时,它在测试数据集上却不⼀定更准确.这是为什么呢 ...

  2. Zabbix数据库空间大小使用计算

    一.Zabbix的数据存储主要分类 1.历史数据 2.趋势数据 3.事件数据 二.每秒处理的数据量 顾名思义,例如,有3000个监控项(item),每60秒取一次值,即平均每秒有50(3000/60) ...

  3. .net core 实现 api网关 进行 api版本控制

    场景: 由一次大的项目改动引起的app端api不兼容问题,这时候就需要对api做版本控制了,权衡之后因为用户不多,选择了强更,没人想在已经写了8000行代码的单个svc文件中维护好几个版本的接口或者继 ...

  4. 天眼查sign 算法破解

    天眼查sign 算法破解 最近真的在sign算法破解上一去不复返 前几天看过了企查查的sign破解 今天再看看天眼查的sign算法破解,说的好(zhuang)点(bi)就是破解,不好的就是这是很简单的 ...

  5. python+jinja2实现接口数据批量生成工具

    在做接口测试的时候,我们经常会遇到一种情况就是要对接口的参数进行各种可能的校验,手动修改很麻烦,尤其是那些接口参数有几十个甚至更多的,有没有一种方法可以批量的对指定参数做生成处理呢. 答案是肯定的! ...

  6. 开发APP必须知道的API集合,来源http://www.cnblogs.com/wikiki/p/7232388.html

    笔记 OneNote - OneNote支持获取,复制,创建,更新,导入与导出笔记,支持为笔记添加多媒体内容,管理权限等.提供SDK和Demo. 为知笔记 - 为知笔记Windows客户端开放了大量的 ...

  7. d3.js制作蜂巢图表带动画效果

    以上是效果图,本图表使用d3.js v4制作.图表主要功能是在六边形格子中显示数据,点击底部图标可以切换指定格子高亮显示,图表可以随浏览器任意缩放. 1.图表的主体结构是由正六边形组成,使用d3生成六 ...

  8. StackOverflow 周报 - 这些高关注的问题你是否都会

    我从 Stack Overflow 上找的了一些高关注度且高赞的问题.这些问题可能平时我们遇不到,但既然是高关注的问题和高点赞的回答说明是被大家普遍认可的,如果我们提前学到了以后不管工作中还是面试中处 ...

  9. IDEA-Maven项目的jdk版本设置

    在 Intellij IDEA 中,我们需要设置 Settings 中的 Java Compiler 和 Project Structure 中的 Language Level 中的 jdk 版本为自 ...

  10. Delphi - 互斥对象下实现系统的单例模式

    使用CreateMutex函数创建互斥对象 利用Windows系统函数CreateMutex(),找出当前系统是否已经存在指定进程的实例,如果没有则创建一个互斥体. CreateMutex函数原型如下 ...