一. 概述
首先,一个字典需要实现什么功能呢 ?
一个键值对来记录数据, 能够插入数据、修改数据、删除数据, 通过键key 能够极可能快速的查找数据。
Redis数据库的底层实现就是字典, 例如, 当我们在redis客户端的命令行上输入一个最简单的命令:redis > SET name "mercury" ,
它就在数据库字典里生成了一个条目(键值对),key 是 值为name的字符串对象, value 是值为mercury 的字符串对象。
除此之外,当hash对象的编码是hashtable 时, 它的实现方式也是字典。
二. 字典结构
hash表查找元素的时间复杂度为O(1), 所以Redis采用hash表来作为字典的底层实现。
hash表解决冲突有两种方法:开放地址法,拉链法。 redis的字典采用拉链法来解决冲突。
为了速度考虑,redis 总是将新节点添加到链表表头位置, 例如, 下图, 先插入k0, 再插入k1, k0和k1被hash 到一个桶中, k1 会被插入到链表的表头。
hash 表结构
让我们先看下节点的代码:
typedef struct dictEntry {
void *key;
union{
void *val;
uint64_t u64;
int64_t s64;
} v;
struct dictEntry *next;
} dictEntry;
节点由三部分组成: 键key, 值v,指向同一个桶(我们把dictEntry 数组的每个位置叫做一个桶,一个桶存一条拉链)内的后继节点的指针,v 可以是一个指针,或者一个 uint64t整数, 或者是一个int64_t 整数。
hash 表的定义如下:
typedef struct dictht {
// hash 表数组
dictEntry ** table;
// hash表的大小
unsigned long size;
unsigned long sizemask;
// 已有节点的数量
unsigned long used;
}
注意到dictht 结构中有一个sizemask ,在直觉上 我们一般用 key 的hash 值% hash表的size 来决定key 存放的位置index , 但redis 有个更巧妙的做法,用key计算出来的hash 值同sizemask 做& 运算。CPU做位运算要快于+-*%,工业级数据结构几乎都会用类似的操作来最大程度上提高运行速度。计算索引值的代码如下:
hash = dict->type->hashFunction(key);
index = hash & dict->type->ht[x].sizemask;
上面的dict,type 和 ht 是什么在下面会提到。
字典结构:
typedef struct dict {
// 类型特定函数
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
其中 type 和 privdata 是为了创建多态字典而设置的。
dictType 封装了一簇对于key和value 的各种操作的函数指针。 private 保存了传进这些函数的可选参数。
typedef struct dictType {
//哈希计算方法,返回整形变量
uint64_t (*hashFunction)(const void *key);
//复制key函数
void *(*keyDup)(void *privdata, const void *key);
void *(*valDup)(void *privdata, const void *obj);
//key值比较方法
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
//key的析构函数
void (*keyDestructor)(void *privdata, void *key);
void (*valDestructor)(void *privdata, void *obj);
} dictType;
三. 字典扩容
hash 表有一个特性, 随着元素越来越多, 新插入一个元素发生hash冲突的次数就会越来越多, 查找一个元素的速度也会越来越慢。当hash表保存的键值对数量太多或太少,redis 就会对hash 表进行扩容和缩容。 扩容和缩容的操作可以通过执行rehash(重新散列)操作来完成。 rehash的原理很简单, 创建一个新的更大或更小的数组, 重新计算index位置,把元素都拷贝过去, 然后释放原来的数组。
dict 中有两个 dictht 结构, 就是用来执行扩容和缩容操作的。假设redis要对ht[0]进行扩容,可以采取如下操作:
- 为ht[1] 分配空间,ht[1] 的大小为第一个大于等于 ht[0].used*2 的 2的n次方 。
- 将ht[0] 中的元素rehash 到ht[1] 。
- 释放ht[0] 。
但这样存在一个问题, 假设当客户端发过来一个add 命令, 刚好触发扩容操作, 如果一次性将h[0] 里面的所有键值对全部rehash 到ht[1]里, 它的耗时会非常长。 所以redis 采取渐进式的rehash 方案,在dict 中,用rehashidx来表示rehash的进度, 其详细步骤如下:
- 为ht[1] 分配空间
- 将rehashidx 初始化为0 ,代表rehash 工作正式开始。
- 每次字典进行删除、查找、更新操作时, 会同时在两个hash表上进行(先查找ht[0], 如果没找到,再去查找ht[1])。 进行添加操作时,会直接添加到ht[1]。
- 在进行每次增删改查操作时, 会同时把ht[0] 在rehashidx 索引上的所有键值对都rehash到ht[1]上, 完成后 rehashidx 加1.
- 当ht[0] 所有元素都被复制到ht[1], 设置rehashidx 的值为-1 。
- 回收 ht[0]。
参考资料:
- 《Redis设计与实现》
- Redis 源码注释 :https://github.com/huangz1990/redis-3.0-annotated/tree/unstable/src