字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value)的抽象数据结构。

在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。

字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对,等等。

字典经常作为一种数据结构内置在很多高级编程语言里面,但Redis所使用的C语言并没有内置这种数据结构,因此Redis构建了自己的字典实现。

字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查操作也是对字典的操作之上的。

举个例子,当我们执行命令:

redis>SET msg "hello world"

OK

在数据库中创建一个键为“msg”,值为“hello world”的键值对时,这个键值对就是保存在代表数据库的字典里面的。

除了用来表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对重点额元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

举个例子,website是个包含10086个键值对的哈希键,这个哈希键的键都是一些数据库的名字,而键的值就是数据库的主页网址:

redis>HLEN websit

(integer)10086

redis>HGETALL website

1)"Redis"

2)"Redis.io"

3)"MariaDB"

4)"MariaDB.org"

5)MongoDB

6)"MongeDB.org"

......

website键的底层实现就是一个字典,字典中包含了10086个键值对,例如:

  • 键值对的键为“Redis”,值为“Redis.io”

除了用来实现数据库和哈希键之外,Redis的不少功能也用到了字典,在后续的章节中会不断地看到字典在Redis中的各种不同应用。


字典的实现


Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。


接下来分别介绍Redis的哈希表、哈希表节点以及字典的实现。


哈希表


Redis字典所使用的哈希表由dict.h/dictht结构定义:


/*
* 哈希表
*
* 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedef struct dictht {

// 哈希表数组
dictEntry **table;

// 哈希表大小
unsigned long size;

// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;

// 该哈希表已有节点的数量
unsigned long used;

} dictht;


table属性是一个数组,数组中的每个元素都是一个指向dict.h/dicEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,也即是table数组的大小,而used属性则记录了哈希表目前已有节点(键值对)的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。下图展示了一个大小为4的空哈希表:



redis设计与实现(三)字典_Redis



哈希表节点


哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:


/*
* 哈希表节点
*/
typedef struct dictEntry {

// 键
void *key;

// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;

// 指向下个哈希表节点,形成链表
struct dictEntry *next;

} dictEntry;


key属性保存着键值对中的键,而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个unit64_t整数,又或者是一个int64_t整数。


netx属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突(collision)的问题。


举个例子,下图就展示了如何通过next指针,将两个索引值相同的键K1和K0连接在一起。



redis设计与实现(三)字典_键值对_02



字典


Redis中的字典由dict.h/dict结构表示:


/*
* 字典
*/
typedef struct dict {

// 类型特定函数
dictType *type;

// 私有数据
void *privdata;

// 哈希表
dictht ht[2];

// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */

// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */

} 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 *key1, 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)的字典。



redis设计与实现(三)字典_Redis_03



哈希算法


当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。Redis计算哈希值和索引值的方法如下;


#使用字典设置的哈希函数,计算键key的哈希值


hash = dict->type->hashFunction(key)


#使用哈希表的sizemask属性和哈希值,计算出索引值


#使用情况不同,ht[x]可以是ht[0]或者ht[1]


index = hash& dict->ht[x].sizemask;



redis设计与实现(三)字典_数组_04



举个例子,对于图4-4所示的字典来说,如果我们要将一个键值对k0和v0添加到字典里面,那么程序会使用语句:


hash = dict->type->hashFunction(k0);


计算键K0的哈希值。


假设计算得出的哈希值为8,那么程序会继续使用语句:


index = hash&dict->ht[0].sizemask = 8&3 =0;



计算出键K0的索引值0,这表示包含键值对k0和v0的节点应该被放置到哈希表数组的索引0位置上,如下所示:



redis设计与实现(三)字典_键值对_05



解决键冲突


当有两个或以上数量的键被分配到了哈希数组的同一个索引上面我们称这些键发生了冲突。


redis中用了dictEntry的next属性解决键冲突,如下所示:



redis设计与实现(三)字典_键值对_06



rehash


随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。


扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:


1)为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值):


  • 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].的2倍
  • 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次幂。

2)将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash值的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。


3)当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],jiang ht【1】设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。



举个例子,假设程序要多下表的ht[0]进行扩展操作,那么程序将执行以下步骤:



redis设计与实现(三)字典_字典_07



1)ht[0].used当前的值为4,负载因子为1,图4-9展示了ht[1]在分配空间之后,字典的样子。



redis设计与实现(三)字典_Redis_08



2)将ht[0]包含的两个键值对都rehash到ht[1],如图4-10所示。



redis设计与实现(三)字典_键值对_09



3)释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,如图4-1·1所示。至此,哈希表的扩展操作执行完毕,程序成功即将哈希表的大小从原来的4改成了现在的8.



redis设计与实现(三)字典_redis_10