三、详解Redis字典

上一篇文章详细介绍了Redis的五种常用数据类型,每种数据类型在内存中的数据结构都会因情况而异。接下来,我会用几篇文章来详细解析这一系列数据结构,这篇文章我们先来解析一下Redis中用得最多的dict(字典)结构。

1. 字典在Redis中的应用

字典,是一种保存键值对的数据结构,相比采用其他数据结构来保存key-value,在字典中查找键值对的平均时间复杂度为O(1)。

经过上一篇文章的学习,我们可以知道:Redis中的hash、set、zset底层实现都包含字典,具体是怎么通过字典来实现这几种数据类型的,大家可以翻看上一章。除此之外,其实整个Redis数据库都是用字典来保存数据的,众所周知,Redis是一个key-value型的内存存储组件,这一点与字典的特性不谋而合。具体Redis如何在数据库层面使用字典,接下来我会写一篇文章带大家俯瞰Redis数据库,在这里就先不引申太多(卖个关子 😋 )。

2. Redis中字典的实现

Redis中的字典是使用dict结构来实现的,每个dict包含两个哈希表dictht,dictht[0]用于日常保存数据,dictht[1]只有在rehash时才会用到(哈希表在扩容或者缩容时需要rehash,将哈希表中的数据重新hash到不同的槽位上,使得元素分配更均匀)。每个哈希表都是由dictEntry节点组成的二维结构,一维是dictEntry数组,每个数组元素都由dictEntry链表构成,这就形成了二维结构。


Redis字典.png

接下来,我们对照着上图详细解析下字典中各个模块的属性。

dict

dict即Redis的字典,里面的每个属性都是在字典层面的。

  • type和privdata属性都是为了实现多态字典而设置的,只有实现了多态字典才能通过dict结构来保存string、hash、set等不同类型的数据。type指针指向dictType结构,每个dictType结构定义了一系列操作不同类型键值对的函数。而privdata则保存了需要传给特定类型函数的可选参数。
  • dictht数组保存了两个哈希表,第一个哈希表用于日常保存数据,另一个则在rehash时保存新哈希表使用。
  • rehashidx属性标记着当前字典是否正在rehash,如果没有在rehash则为-1。

dictht

dictht是哈希表,它直接连接着装载着dictEntry链表的数组。

  • table是哈希表数组,数组中的每个元素都是一个链表,因此形成了一个二维结构,字典中的数据都存储在这个二维结构里面。
  • size是哈希表数组的大小,不是直接指元素的个数。
  • sizemask是哈希表数组大小的掩码,取值为size-1。扩容后可通过hash(key) & sizemask算出数组下标。
    sizemask.png
  • used表示哈希表已有节点的数量,即dictEntry的总数。

dictEntry

dictEntry是哈希表的节点,每个key-value数据都保存在dictEntry里面。

  • key指针指向哈希表中的键。
  • v用于保存字典的值,它是一个union(联合体),联合体中的u64和s64可直接保存64位的整数值。如果值不是整数,则通过指针val来指向数据所在内存。
  • next指向下一个dictEntry节点,一系列dictEntry可通过next指针串成一个链表。

2. 字典中的插入与查询

要往字典里插入数据,必须先找到要插入的下标,计算下标常用的方法是对key的hash值取模,即hash(key) % size

当数组对应下标存在元素时,则产生了键冲突。Redis中解决键冲突的方法是链地址法,将插入数据的next指针指向数组中对应下标的元素,再将插入数据存入该下标。出于速度考虑Redis不是将新元素插入链尾,而是插入链头,这样做可省去遍历链表找到链尾节点的开销。


链地址法.png

查找key时,先通过key的hash值找到对应的下标,遍历该下标存储的链表,直到找到对应的key。理想情况下每个数组下标只有一个节点,只需定位数组的开销,时间复杂度为O(1)。

3. 哈希表的扩容与缩容

当哈希表中的元素太多或太少时,会进行扩容或者缩容。扩容是出于哈希表的查找效率考虑,当哈希表中的元素达到一定数量时,表中链的长度会随着哈希表元素的增长而增长,链表越长意味着查询效率也会越慢。缩容是为了节省内存空间,避免数组中太多空元素造成空间浪费。

Redis中的哈希表通过load_factor(负载因子)来决定是否扩容或者缩容,负载可由公式ht[0].used/ht[0].size算得,即为哈希表中元素个数与数组大小之比。哈希表中的元素越多,数组中的元素就越倾向于形成链表;反之则数组中空置的“坑位”也就越多。因此负载因子可作为哈希表扩缩容的指标。

扩容

扩容时需考虑服务器当前是否正在执行bgsave或者bgrewriteaof,若服务器当前正在bgsave或bgrewriteaof,应该尽量避免扩容,此时触发扩容的条件是load_factor为5;否则当load_factor为1时就开始扩容。之所以要这样做,是因为bgsave或bgrewriteaof命令会fork子进程来遍历内存,若内存无变更则与父进程共享内存页,修改内存数据时需将内存页拷贝一份出来进行修改(这就是传说中的写时复制),修改内存数据不但在拷贝内存页时会消耗CPU资源,而且会消耗更多的内存空间。而扩容时需要将旧哈希表元素rehash到新哈希表,从而造成内存数据变更,因此要尽量避免扩容。

为了方便rehash,扩容时数组容量会扩展到(第一个大于2倍哈希表元素个数)的2^n。这句话有点绕,举个例子:假如哈希表中有3个元素,扩容时先算出2倍元素个数为6,但是6不满足2^n, 因此要扩为8,因为2^2<6<2^3。通过这种扩容策略,新数组的大小为旧数组大小的2^n 倍,rehash时可以直接通过公式hash(key) & sizemask来算出下标。

扩容.png

缩容

缩容则不需要考虑bgsave或者bgrewriteaof,当负载因子为0.1时会触发缩容。缩容时也需要进行rehash操作,数组长度会缩减为(第一个大于哈希表元素个数)的2^n,新下标也可通过公式hash(key) & sizemask算出来。

缩容.png

4. 渐进式rehash

上面说到,哈希表在扩容和缩容时都需要rehash,由于Redis是单线程处理的,如果字典中的元素太多,一次性执行rehash操作会造成卡顿,因此Redis采用渐进式rehash的策略,在某个key被访问时对其进行迁移,如果某个key一直没有被访问,Redis会在定时任务中对其执行rehash。


渐进式rehash.png

上图简略展示了渐进式rehash的过程,除了两个哈希表ht[0]和ht[1]之间会发生节点转移外,还需要注意rehashidx的变化,该参数记录了rehash的进度,接下来我们详细讲述一下渐进式rehash的过程:

  1. 为ht[1]分配数组空间,大小为扩/缩容之后的空间。上图哈希表数组长度由2扩到4,因此ht[1]中节点数组的大小为4。
  2. 将rehashidx由-1置为0,标记着渐进式rehash的开始,同时也用来保存下次rehash的数组下标。
  3. 每次对key进行访问的时候,都将该节点从ht[0] rehash到ht[1],同时将下标为rehashidx的链表中的所有节点rehash到ht[1],然后将rehashidx加1。因此rehashidx既可以看出当前的rehash进度,又指代着下次rehash的下标。
  4. 最终ht[0]中的所有元素都rehash到了ht[1],此时将ht[0]指向ht[1]中的哈希表,再清空ht[1],然后将rehashidx置为-1,代表着rehash结束。

渐进式rehash对增、删、改、查的影响

在rehash的过程中,字典中同时存在两个哈希表,因此删、改、查操作可能需要对两个哈希表进行操作,当ht[0]中不存在元素时就访问ht[1],不过无论ht[0]中是否存在元素,最终元素都需要被rehash到ht[1]。往字典中插入元素时,rehash的过程中总会把新元素插入ht[1],这样做可以避免对新元素进行多余的rehash操作。

5. rehash与字典遍历

在Redis中,很多操作都需要遍历字典,如bgsave与bgrewriteaof操作、keys和scan指令。bgsave与bgrewriteaof是在子进程中执行的操作,在指令执行过程中如果Redis需要进行rehash,rehash操作会以页为单位将内存数据拷贝一份出来修改,因此并不会改变bgsave与bgrewriteaof需遍历的内存。keys指令在执行时与rehash操作共享同一份内存数据,但是由于keys指令用的是安全的迭代器,在指令执行过程中不允许rehash操作,所以执行keys指令并不会重复遍历数据。

安全的迭代器:在遍历哈希表时允许对哈希表中的数据进行修改,但是不允许rehhash操作。bgsave、bgrewriteaof和keys都是用安全的迭代器。
不安全的迭代器:在遍历的过程中哈希表只读,允许执行rehash操作。

scan指令与rehash

scan指令与keys指令的功能相似,都是用于查找字典中的key,支持模式匹配。但是keys指令一次性遍历返回所有符合条件的key,在数量大的情况下可能会造成卡顿,因此不建议使用。而scan指令则支持批次遍历,可以指定游标值(遍历的起始槽位)和单次遍历的槽位数量来执行,执行结果为游标值和符合条件的key,下次遍历时可指定上次返回的游标值继续遍历,遍历结束时游标值返回0。由于scan指令支持分批遍历,键小且少的情况下不会造成内存卡顿,因此要尽量避免大key的产生。

# 添加数据
127.0.0.1:6379> mset a a b b c c d d e e f f
OK

# 第一次遍历
127.0.0.1:6379> scan 0 match * count 2
1) "2"
2) 1) "a"
   2) "c"

# 第二次遍历
127.0.0.1:6379> scan 2 match * count 3
1) "5"
2) 1) "d"
   2) "b"
   3) "e"

# 第三次遍历
127.0.0.1:6379> scan 5 match * count 2
1) "0"
2) 1) "f"

由于使用scan指令遍历字典需分多次执行,即使使用安全的迭代器也无法保证在多次执行的过程中不会执行rehash操作,因此需要一种方法来尽量避免重复遍历,这种方法就是高位加法,高位加法每次执行加法运算时都是在高位相加。

高位加法.png

扩缩容下标关系.png

上图展示了哈希表扩容和缩容之后,元素迁移下标的对应关系。从左往右看,恰恰是采用高位加法的下标变化关系。总结来说就是:原来下标为 xxx的元素,扩容后会被rehash到槽位0xxx或者1xxx中;原来下标为 0xx的元素和下标为1xx的元素,缩容后会被rehash槽位xx中。

扩容时,如果当前遍历的下标为001,则可以保证不再遍历0001之前的槽位。如果使用普通的加法,则后续还会对槽位100001001100进行重复遍历。但是对于同一个槽位的元素而言,还是有可能造成重复遍历的:在rehash的过程中,能之直接被用户访问的还是原来的哈希表,scan遍历的时候会先遍历001,再遍历00011001,重复遍历的元素可在客户端去重。如果一次scan遍历完后,rehash过程也刚好完成,那么接下来的scan还是会再遍历1001槽位的。所以高位加法并不能完全避免元素重复遍历,而是尽可能地减少元素重复遍历。

对于缩容而言,如果在遍历旧哈希表槽位010时会同时遍历新哈希表的10槽位,在接下来遍历槽位110时如果当前还在rehash,也会遍历10槽位,因此可能对槽位10进行重复遍历;如果当前rehash过程已经完成了,ht[0]就会替换成ht[1]被客户端访问,槽位110也就不复存在了,所以如果当前scan遍历到槽位010,下次scan时可能就访问不到110槽位了,因此有可能漏遍历。

6、总结
最近工作太忙了,这篇文章断断续续写了一个星期,也许有一些瑕疵的地方,欢迎大家批评指正,接下来我来总结一下全文的知识点:

  1. Redis字典采用三个结构体实现:dict(字典)、dictht(哈希表)、dictEntry(哈希表节点),其中dictht有两个,只有ht[0]能被直接访问,ht[1]在rehash时被用来存储过度态的新哈希表。
  2. 在元素过多或者过少时,Redis都会执行rehash,过多或过少用负载因子load_factor来衡量,rehash采用渐进式rehash的方式。
  3. 由于字典会rehash,因此在遍历时有可能会重复遍历元素,因此bgsave、bgrewriteaof、keys指令采用安全的迭代器,在遍历过程中禁止rehash,由于scan是分批执行的,即使采用安全的迭代器也无法避免重复遍历,因此采用高位加法计算遍历槽位来避免尽量重复遍历。

读者如果有什么疑问或者建议可以在下方留言,我将会在看到的第一时间给予解答或者完善文章,提升自己也惠及他人

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容