Redis 学习 基础数据结构篇 之 dict
[TOC]
基于 Redis 6.2.1
dict(字典)
字典, 又称符号表(symbol table)、关联数组(associative array)或者映射(map), 是一种用于保存键值对(key-value pair)的抽象数据结构。
在字典中,一个键(key)和一个值(value)关联,关联上的键和值被称为键值对
很多语言都提供了字典的实现,如C++ STL中的map,python中的dict,Go中的map等等。C语言中并为提供字典实现,因此Redis自行实现了字典。
Redis中很多地方用到了字典,Redis的数据库,HASH类型等。
dict的实现
src/dict.h 中定义了字典
1 | // hashtable 哈希表结构 |
table 属性是一个数组, 数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对。
size 属性记录了哈希表的大小, 也即是 table 数组的大小, 而 used 属性则记录了哈希表目前已有节点(键值对)的数量。
sizemask 属性的值总是等于 size - 1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。
采用的hash算法在src/siphash.c 中, 采用的hash算法 是 SipHash 1-2 本文并不展开介绍SipHash 1-2原理。
1 | // 哈希表节点, 也就是一个键值对 |
key 属性保存着键值对中的键, 而 v 属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数。
next 属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。
举个例子, 图 4-2 就展示了如何通过 next 指针, 将两个索引值相同的键 k1 和 k0 连接在一起。
1 | // 字典 结构 |
type 属性和 privdata 属性是针对不同类型的键值对, 为创建多态字典而设置的:
type属性是一个指向dictType结构的指针, 每个dictType结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。而
privdata属性则保存了需要传给那些类型特定函数的可选参数。
1 | typedef struct dictType { |
ht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
除了 ht[1] 之外, 另一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。
下图是一个完整的字典结构(普通状态下,没有进行rehash):

hash 算法
TODO 感觉没啥好说的了。
hash冲突解决(链地址法)
TODO 这个感觉也没啥好说的,太基础了。
rehash 和 渐进式 rehash
rehash
随着频繁的增删操作, 哈希表可能不够用了或者空间使用率变低了,这时候就需要进行扩容或缩容,也就是Redis的rehash过程。
rehash过程很好理解,当哈希表需要扩容或缩容的时候,改变哈希表的大小,对所有元素重新 hash 后放到新的位置。
以下图为例, 当字典使用率过低时,会进行缩容。按照used个数确定合适的容量生成一个新的。这时候将旧的哈希表(h[0])中的数据重新hash放到新的哈希表中(h[1])。

同理当容量不足的时候,字典会进行扩容。容量大小会翻一倍(乘2)。也是一样生成一个新的。把旧的哈希表(h[0])中的数据重新hash放到新的哈希表中(h[1])。

⚠️注意, 如上图所示,当给一个容量为4的哈希表添加键值对,然后再删一个,然后再添加一个。。。这样重复的删除再添加。 会一直rehash下去,每次都要进行大量的内存操作和数据结构的维护处理,想想Redis本身单进程的,这得慢成什么样子。 所以 Redis 在处理扩容缩容时有这样的策略:
一般情况下 当used/size >= 1 时, 进行扩容。 容量缩到大于 used 的第一个大小。
正在进行高负载命令时(行 BGSAVE 命令或BGREWRITEAOF 命令), 当used/size > 5时才进行扩容,容量缩到大于 used 的第一个大小。
当used/size <= 1/10 时进行缩容。容量缩到大于 used 的第一个大小。
扩容策略源码:
1 | /* Expand the hash table if needed */ |
渐进式rehash
有了上面rehash的,字典可以应对容量的变化, 但如果字典已经存储了十万,百万,甚至千万量级的键值对,一次rehash的过程会持续很久,这个时候因为Redis单进程处理会阻塞很久,业务服务就会卡住。
因此Redis实现了渐进式的rehash,将rehash的操作分散到字典多次的增删改查操作中。每一次只操作少量的哈希表的槽位,将整体rehash操作均摊。
从字典的结构中可以看到它有两个哈希表。正常情况下直接使用的ht[0], 渐进式的rehash过程就是逐步将键值对迁移到ht[1]中。在rehash的过程中进行操作时字典会先从ht[0]找,如果找不到则在ht[1]中找。 如下面dictFind函数:
1 | dictEntry *dictFind(dict *d, const void *key) |
渐进式rehash主要时限制了两个,
最多可迁移的槽的个数
n。最多可遍历的空槽的个数
n*10
如果进行了这么多的遍历或rehash操作,函数则退出,如果rehash已经结束返回0,没有结束返回1。
最后看下rehash的完整函数吧:
1 | /* Performs N steps of incremental rehashing. Returns 1 if there are still |
dict重要API
| 函数 | 作用 | 时间复杂度 |
|---|---|---|
dictCreate | 创建一个新的字典。 | ![]() |
dictAdd | 将给定的键值对添加到字典里面。 | ![]() |
dictReplace | 将给定的键值对添加到字典里面, 如果键已经存在于字典,那么用新值取代原有的值。 | ![]() |
dictFetchValue | 返回给定键的值。 | ![]() |
dictGetRandomKey | 从字典中随机返回一个键值对。 | ![]() |
dictDelete | 从字典中删除给定键所对应的键值对。 | ![]() |
dictRelease | 释放给定字典,以及字典中包含的所有键值对。 | , N 为字典包含的键值对数量。 |
总结
字典中最重要的实现是 rehash 及其渐进式的方式。