序言
字典,又称为符号表,是一种用于保存键值对的抽象数据结构。在字典中,一个键可以和一个值进行关联,这些关联的键和值就称为键值对。字典经常作为一个数据结构内置在很多编程语言中,但 Redis 所使用的 C 语言并没有这种数据结构,所以 Redis 构建了自己的字典实现。
1. 字典的实现
Redis 的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每一个哈希表节点就保存了字典中的一个键值对。接下来的三个小节将分别介绍 Redis 的哈希表、哈希表节点以及字典的实现
1.1 哈希表
Redis 字典所使用的哈希表有 dictht 结构定义:
typedef struct dictht {
// 哈希表数据
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size-1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht
table 属性是一个数组,数组中的每个元素都是一个指向 dictEntry 结构的指针,每个 dictEntry 结构保存了一个键值对。size 属性记录了哈希表的大小,即 table 表的大小,而 used 属性则记录了哈希表目前已有节点的数量。sizemask 属性的值总是等于 size-1,这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。下图展示一个大小为4的空哈希表:
1.2 哈希表节点
哈希表节点是使用 dictEntry 结构表示,每个 dictEntry 结构都保存着一个键值对:
typedef struct dictEntry {
// 键
void key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
- key 属性保存着键值对中的键,而 v 属性则保存键值对中的值,其中键值对的值可以是一个指针,或者一个 uint64_t 整数,又或者是一个 int64_t 整数。
- next 属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决哈希冲突的问题。
1.3 字典
Redis中的字段由 dict 结构表示:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引
// 当 rehash 不在进行时,值为-1
int trehashidx;
} dict;
type 属性和 privdata 属性是针对不同数据类型的键值对,为创建多态字典而设置的:
- type 属性是一个指向 dictType 结构的指针,每个 dictType 结构保存了一簇用于操作特性类型键值对的函数,Redis 会为用途不同的字典设置类型不同的特定函数。
- privdata 属性则保存了需要传给那些类型特地给函数的可选参数
typedef struct dictType{
//计算哈希值的函数
unsigned int (hashFunction)(const void *key);
//复制键的函数
void *(*keyDup)(void *privdata,const void *key);
//复制值的函数
void *(*valDup)(void *privdata,const void *obj);
//对比键的函数
int (-keycompare)(void *privdata,const void *keyl,const void *key2);
//销毁键的函数
void (*keyDestructor)(void *privdata,void *key);
//销毁值的函数
void (*valDestructor)(void *privdata,void *obj);
} dictType;
ht 属性是一个包含两个项的数组,数组中的每个项都是一个 dictht 的哈希表,一般的情况喜爱,字典只使用 ht[0] 哈希表,ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
除了 ht[1] 之外,另一个和 rehash 有关的属性就是 rehashidx,它记录了 rehash 目前的进度,如果目前没有在进行 rehash ,那么它的值为-1。下图即为一个没有进行 rehash 的字典:
2. 哈希算法
当要将一个新的键值对添加到字典时,程序需要先根据键值对的键计算出哈希值和索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面,类似于 HashMap 添加元素的操作。
3. 哈希冲突
当有两个或以上的数量的键被分配到了哈希表数组同一索引上面时,则称这些键发生了冲突。
Redis 使用的是链地址法来解决键冲突,每个哈希表节点都有一个 next 指针,多个哈希表节点可以使用 next 指针来构成一个单向链表,被分配到同一索引上面的多个节点可以使用链表链接起来,这就解决了键冲突的问题。
因为 dictEntry 节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,Redis 实现者总是将新节点添加到链表的表头为止(复杂度为O(1)),排在其他已有节点的前面。
4. rehash
随着操作的不断执行,哈希表所保存的节点会逐渐的增多或者减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表所保存的键值对太多或者太少时,程序需要对哈希表的大小进行相应扩展或收缩。
扩展和搜索的操作都是通过 rehash 过程来执行的,Redis 对子带你的哈希表执行 rehash 的步骤如下:
- 为字典的 ht[1] 哈希表分配空间,具体分配多大的空间取决于要执行的操作,以及 ht[0] 当前包含的键值对数量(即 ht[0].used 属性的值)。
- 如果执行的是扩展操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2 的倍数(比如ht[0].used 大小为4,乘以2则为8,而8刚好是2的倍数,所以 ht[1] 则为 8)
- 如果执行的是是收缩操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2 的倍数(比如比如ht[0].used 大小为4,则 ht[1] 的大小也为4)
- 将保存到 ht[0] 中所有键值对 rehash 到 ht[1] 上: rehash指的是重新计算键的哈希值和索引值,然后将键值对放置在 ht[1] 哈希表的指定位置上。
- 当 ht[0] 包含的所有键值对都迁移到 ht[1] 之后(ht[0] 变为空表),释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空白哈希表,为下一次 rehash 做准备。
举例子说明,假设要对以下字典进行扩容操作,那么程序将执行以下步骤:
- ht[0].used 当前为4,4 * 2 = 8,而8刚好是第一个大于等于4的2的倍数,所以程序会将 ht[1] 哈希表大小设置为8,下图则是 ht[1] 分配空间后字典的样子:
2.将 ht[0] 上所包含的四个键值对都 rehash 到 ht[1] 上,如下图所示:
3.释放 ht[0] ,并将 ht[1 ]设置为 ht[0] ,然后为 ht[1] 分配一个空白哈希表,如 下图所示。至此,对哈希表的扩展操作执行完毕,程序成功将哈希表的大小从原来的4 改为了现在的8。
4.1 哈希表的扩展和收缩
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执工扩展操作:
- 眼务器目前没有在执行BGS4YE命令成署BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
- 服务器霜前正在热行BGSAVE命令或者BGREWRITEAOE命令,并且哈希表的负载因子大于等于5。
其中哈希表的负载因子可以通过公式:
# 负黄因子 = 哈希表已保存节点数量 / 哈希表大小
load factor ht[0].used ht[0].size
计算得出。
例如,对于一个大小为4,包含4个健值对的哈希表来说,这个哈希表的负载因子为:load factor = 4 / 4 = 1
又例如,对于一个大小为512,包含256个键值对的哈希表来说,这个哈希表的负因子为: load factor = 256 / 512 = 0.5
根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-wt)技术来优化子进程的使用效率,所以在子进程存在期间、服务器会提高执行扩展操作 所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
另一方面,当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作
5. 渐进式 rehash
上一节说过,扩展或收缩哈希表需要将 ht[o] 里面的所有键值对rehash到 ht[1] 里面,但是,这个 rehash 动作并不是一次性、集中式地完成的,而是分多次、渐进式地完 成的。 这样做的原因在于,如果 ht[0] 里只保存着四个键值对,那么服务器可以在瞬间就将 这些键值对全部 rehash 到 ht[1] ;但是,如果哈希表里保存的键值对数量不是四个,而是 四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部 rehash 到 ht[1] 的话, 庞大的计算量可能会导致服务器在一段时间内停止服务。 因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将 ht[O] 里面的所有 键值对全部 rehash 到 ht[1] ,而是分多次、渐进式地将 ht[O] 里面的键值对慢慢地rehash 到 ht[1]。
渐进式 rehash 的好处在它采取分而治之的方式,将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。
5.1 渐进式 rehash 执行期间的哈希表操作
因为在进行渐进式 rehash 的过程中,字典会同时使用 ht[0] 和ht[1] 两个哈希表,所以在渐进式ehash进行期间字典的删除、查找、更新等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在 ht[0] 里面进 行查找,如果没找到的话,就会继续到 ht[1] 里面进行查找,诸如此类。 另外,在渐进式rehash执期间 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操步,这一措施保证了 ht[0] 包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。
6. Redis 字典常用命令
| 命令 | 格式 | 描述 | 示例 |
|---|---|---|---|
| HSET | HSET key field value | 设置单个字段 - 值对。若哈希表不存在则创建它 | HSET user:1 name "John" |
| HGET | HGET key field | 获取单个字段的值 | HGET user:1 name |
| HMSET | HMSET key field1 value1 field2 value2... | 批量设置多个字段 - 值对 | HMSET user:2 name "Alice" age 30 |
| HMGET | HMGET key field1 field2... | 获取多个字段的值,返回结果顺序与请求字段顺序一致 | HMGET user:2 name age |
| HGETALL | HGETALL key | 获取哈希表中所有字段 - 值对,返回格式为field1, value1, field2, value2,... | HGETALL user:1 |
| HDEL | HDEL key field1 [field2...] | 删除一个或多个字段 | HDEL user:1 name |
| HLEN | HLEN key | 获取哈希表中字段的数量 | HLEN user:1 |
| HKEYS | HKEYS key | 获取哈希表中所有字段名 | HKEYS user:1 |
| HVALS | HVALS key | 获取哈希表中所有值 | HVALS user:1 |
| HEXISTS | HEXISTS key field | 检查哈希表中是否存在指定字段 | HEXISTS user:1 name |
| HINCRBY | HINCRBY key field increment | 将哈希表中指定字段的整数值增加指定的增量 | HINCRBY user:1 age 5(假设age是整数类型) |
| HINCRBYFLOAT | HINCRBYFLOAT key field increment | 将哈希表中指定字段的浮点数值增加指定的增量 | HINCRBYFLOAT user:1 score 2.5(假设score是浮点类型) |