字典,是一种保存键值对(key-value)的数据结构。字典的键是唯一的,程序可以通过键来对值进行修改,或者根据键来删除整个键值对。
字典作为一种常用的数据结构,被很多高级编程语言内置,如PHP的array,python的dict。但Redis由C语言实现,并没有内置这种数据结构,因此Redis构建了自己的字典结构。Redis键值对形式的数据库就是由字典实现的。
Redis底层是有哈希表实现的,一个哈希表中可以有多个哈希节点,每个哈希节点保存了一组字典中的键值对。
字典定义
哈希节点
typedef struct dictEntry {
void *key; // 键
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // 值
struct dictEntry *next; // 指向下一个哈希节点,形成链表
} dictEntry;
key
保存着键值对中的键,v
保存着键值对中的值,其中值可以是一个指针,或者是一个uint64_t u64
型或者int64_t s64
型的整数或者double
型的浮点数。
next
指向另一个哈希节点,目的是将多个哈希值相同的键值对连接在一起,以此来解决哈希冲突(collision)问题。
哈希表
typedef struct dictht {
dictEntry **table; // 哈希节点数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值。大小为size-1
unsigned long used; // 已有节点数量
} dictht;
table
是一个数组,数组中每个元素都指向一个dictEntry
。
size
记录了哈希表的大小,也是table
数组的大小。
used
记录哈希表目前已有节点数量。
sizemask
的值总等于size-1
,这个属性和哈希值一起决定了一个键应该被放在哪个table
索引上。
下图展示一个哈希表,并且存储两个哈希值相同的key1
、key2
的哈希节点
字典
typedef struct dict {
dictType *type; // 一些字典操作函数
void *privdata; // 私有数据
dictht ht[2]; // 两个哈希表,一个用来存储数据,一个用来rehash
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
type
是一个指向dictType
的指针,保存了一些操作字典的函数。
typedef struct dictType {
uint64_t (*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;
privdata
保存了需要传递给上述函数的可选参数。
ht
包含两个数组,数组中的每个元素都是dictht
哈希表,一般情况下,字典只使用ht[0]
,ht[1]
只会在rehash
时用到。
rehashidx
记录rehash的进度。
iterators
是Redis字典自己实现的迭代器,用于遍历,该变量用于记录迭代器的运行数量。
字典初始化
/* 重置初始化的哈希表 */
static void _dictReset(dictht *ht)
{
ht->table = NULL;
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}
/* Create a new hash table */
dict *dictCreate(dictType *type,
void *privDataPtr)
{
dict *d = zmalloc(sizeof(*d)); // 分配内存
_dictInit(d,type,privDataPtr);
return d;
}
/* Initialize the hash table */
int _dictInit(dict *d, dictType *type,
void *privDataPtr)
{
_dictReset(&d->ht[0]); // 重置哈希表h[1]
_dictReset(&d->ht[1]); // 重置哈希表h[1]
d->type = type;
d->privdata = privDataPtr;
d->rehashidx = -1; // rehash标志位
d->iterators = 0;
return DICT_OK;
}
上面dictCreate
代码实现初始化字典,完成之后会得到一个dict
的指针。然后就可以想字典中插入键值对。
字典插入键值对
// 使用字段设置的哈希函数,计算key的哈希值
#define dictHashKey(d, key) (d)->type->hashFunction(key)
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
long index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d); // 如果rehash在执行,则执行rehash
/* Get the index of the new element, or -1 if
* the element already exists. */
if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1) // 获取当前key的hash值,如果存在直接返回NULL
return NULL;
/* Allocate the memory and store the new entry.
* Insert the element in top, with the assumption that in a database
* system it is more likely that recently added entries are accessed
* more frequently. */
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; // rehash正在执行,则插入到ht[1]中
entry = zmalloc(sizeof(*entry)); // 分配内存空间
entry->next = ht->table[index]; // 插入到index头部
ht->table[index] = entry;
ht->used++; // 已有节点数加一
/* Set the hash entry fields. */
dictSetKey(d, entry, key); // 设置key值
return entry;
}
当要将一个新的键值对添加到字典中,程序需要先根据键值对的键值计算出哈希值和索引值,然后再根据索引值,将包含新键值对的节点放到哈希表数组的指定索引上面。
获取hash index
static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{
unsigned long idx, table;
dictEntry *he;
if (existing) *existing = NULL;
/* Expand the hash table if needed */
if (_dictExpandIfNeeded(d) == DICT_ERR) // 在需要扩展dict
return -1;
for (table = 0; table <= 1; table++) { // 在两个hash表中查找是否存在相同key
idx = hash & d->ht[table].sizemask; // 使用哈希表的sizemask属性和哈希值,计算出索引值
/* Search if this slot does not already contain the given key */
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key)) { // 是否存在相同key的节点
if (existing) *existing = he;
return -1;
}
he = he->next;
}
if (!dictIsRehashing(d)) break; // 如果没有rehash,直接break,不需要在操作ht[1]
}
return idx;
}
返回可以使用给定key
的哈希条目填充的空闲槽的索引。如果key
已存在,则返回-1
并且可以填充可选的输出参数。函数主要完成3个工作:1、字典扩容(需要时进行);2、计算索引值;3、检查字典中是否存在相同的key
。
举例,如果现在有一个空的字典,我们需要将k0
,v0
键值对添加到字典中程序首先执行:
hash = d->type->hashFunction(k0);
计算出哈希值。
假设哈希值为8,程序继续执行:
idx = hash & d->ht[0].sizemask = 8 & 3 = 0;
计算出k0
的索引值为0
,将键值对k0
,v0
的节点放置到哈希表数组索引值为0
的位置上,如下图:
何时需要扩展字典
/* This is the initial size of every hash table */
#define DICT_HT_INITIAL_SIZE 4
/* Using dictEnableResize() / dictDisableResize() we make possible to
* enable/disable resizing of the hash table as needed. This is very important
* for Redis, as we use copy-on-write and don't want to move too much memory
* around when there is a child performing saving operations.
*
* Note that even when dict_can_resize is set to 0, not all resizes are
* prevented: a hash table is still allowed to grow if the ratio between
* the number of elements and the buckets > dict_force_resize_ratio. */
static int dict_can_resize = 1;
static unsigned int dict_force_resize_ratio = 5;
void dictEnableResize(void) {
dict_can_resize = 1;
}
void dictDisableResize(void) {
dict_can_resize = 0;
}
static int _dictExpandIfNeeded(dict *d)
{
/* Incremental rehashing already in progress. Return. */
if (dictIsRehashing(d)) return DICT_OK; // 如果已经在执行rehash,直接返回
/* If the hash table is empty expand it to the initial size. */
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); // 哈希表大小为0,扩展为初始默认大小
/* If we reached the 1:1 ratio, and we are allowed to resize the hash
* table (global setting) or we should avoid it but the ratio between
* elements/buckets is over the "safe" threshold, we resize doubling
* the number of buckets. */
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)) // 当userd大于size并且 字典处于可以rehash或者负载5时进行扩展
{
return dictExpand(d, d->ht[0].used*2); // used*2
}
return DICT_OK;
}
DICT_HT_INITIAL_SIZE
为每个哈希表的初始大小,
我们可以根据需要使用dictEnableResize()
/ dictDisableResize()
,对哈希表的大小是否可调整做enable
/disable
操作。
rehash
随着操作不断执行,哈希表保存的键值对会增加或者减少,为了让哈希表的负载因子(load_factor)保持在一个合理的范围。需要对哈希表进行扩展或者收缩。
扩展或者收缩有rehash(重新散列)来实现。rehash操作步骤如下:
- 为字典的ht[1]哈希表分配空间,ht[1]分配空间的大小取决于所需要执行的操作,以及ht[0]当前所包含的键值对数量。
- 如果是执行扩展操作,那么ht[1]的大小为第一个大于等于
ht[0].used
*2的 2 n 2^n 2n(2的n次幂) - 如果是收缩操作,那么
ht[1]
的大小为第一个大于等于ht[0].used
的 2 n 2^n 2n
- 如果是执行扩展操作,那么ht[1]的大小为第一个大于等于
- 将保存的
ht[0]
中所有键值对rehash到ht[1]
上面:重新计算哈希值和索引值,然后将键值对放置到ht[1]
哈希表制定的位置。 - 当
ht[0]
中所有的键值对都迁移到了ht[1]
之后(此时ht[0]
为空),释放ht[0]
,将ht[1]
设置为ht[0]
,并在ht[1]
新创建一个空的哈希表,为下一次rehash准备。
字典rehash代码:
int dictRehash(dict *d, int n) {
// 最多读n*10个空,防止rehash长时间阻塞
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0; // 正在执行rehash,直接返回
while(n-- && d->ht[0].used != 0) { // n为需要rehash节点个数
dictEntry *de, *nextde;
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned long)d->rehashidx); // 保证rehashidx不能溢出
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
while(de) { // 处理处于相同索引的哈希节点
uint64_t h;
nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask; // 重新计算哈希值
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de; // 放置到ht[1]哈希表
d->ht[0].used--; // ht[0]哈希表拥有节点数减一
d->ht[1].used++; // ht[1]哈希表拥有节点数减一
de = nextde; // 下一个节点
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* Check if we already rehashed the whole table... */
if (d->ht[0].used == 0) { // 整个哈希表都已经被rehash
zfree(d->ht[0].table); // 释放ht[0]哈希节点数组
d->ht[0] = d->ht[1]; // 将ht[1]置为hht[0]
_dictReset(&d->ht[1]); // 释放ht[1]
d->rehashidx = -1; // 重置rehash标志位
return 0;
}
/* More to rehash... */
return 1;
}
扩展字典
/* Expand or create the hash table */
int dictExpand(dict *d, unsigned long size)
{
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
if (dictIsRehashing(d) || d->ht[0].used > size) // 扩展的size比已有的节点数量小时不被允许的
return DICT_ERR;
dictht n; /* the new hash table */
unsigned long realsize = _dictNextPower(size); // 根据size获取真实扩展的大小,第一个大于size的2的n次幂
/* Rehashing to the same table size is not useful. */
if (realsize == d->ht[0].size) return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
unsigned long i = DICT_HT_INITIAL_SIZE;
if (size >= LONG_MAX) return LONG_MAX + 1LU;
while(1) {
if (i >= size) // 返回第一个比size大的2的n次幂
return i;
i *= 2;
}
}
当一下条件任一被满足时,程序会自动对字典进行扩展:
2. 服务器目前没有执行BGSAVE
或者BGWRITEAOF
命令,并且哈希的负载因子大于等于1。
3. 服务器目前正在执行BGSAVE
或者BGWRITEAOF
命令,并且哈希的负载印在大于等于5。
其中,负载因子公式为:
# 负载因子 = 哈希表以保存节点个数 / 哈希表大小
load_factor = ht[0].used / ht[0].size
根据BGSAVE
或BGWRITEAOF
是都在执行,选择不同的负载因子,是因为在执行GBSAVE
或者BGWRITEAOF
过程中,Redis需要创建子进程,通常操作系统都采用写时复制(copy-on-write)来优化子进程的使用效率,在子进程存在期间,服务器提高执行扩展操作的负载因子,从而尽可能的避免在子进程期间对字典进行扩展。避免不必要的写入操作,节省内存。
字典收缩
当负载因子小于0.1时会对字典size进行收缩,收缩操作并不是在dict的底层函数定义调用的,而是由具体应用时决定、对应的htNeedsResize函数也是在server.c中。
src/server.c
/* Hash table parameters */
#define HASHTABLE_MIN_FILL 10 /* Minimal hash table fill 10% */
int htNeedsResize(dict *dict) {
long long size, used;
size = dictSlots(dict);
used = dictSize(dict);
return (size > DICT_HT_INITIAL_SIZE &&
(used*100/size < HASHTABLE_MIN_FILL)); //负载因子小于0.1
}
/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL
* we resize the hash table to save memory */
void tryResizeHashTables(int dbid) {
if (htNeedsResize(server.db[dbid].dict))
dictResize(server.db[dbid].dict);
if (htNeedsResize(server.db[dbid].expires))
dictResize(server.db[dbid].expires);
}
渐进式rehash
上面说到,哈希表的扩展与收缩是将ht[0]
的键值对rehash到ht[1]
上,但是这个动作不是一次性、集中性的完成的,而是分多次,渐进式完成。这是因为,一般情况下,哈希表中的数据量是比较多的,如果是一次性将数据从ht[0]
转移到ht[1]
上,庞大的计算量可能会使服务器在一段时间内停止服务。
渐进式rehash步骤如下:
- 为
ht[1]
分配空间,是字典同时具有ht[0]
和ht[1]
两个哈希表。 - 在字典中维持一个
rehashidx
所以计数器变量,将它置为0,表示rehash开始。 - 在rehash期间,如果对字典进行添加、删除、修改和查找操作时,程序会将
ht[0]
哈希表上rehashidx
索引上的键值对rehash到ht[1]
上,rehash完成后,rehashidx自增一。 - 当
ht[0]
上的节点全部转移到ht[1]
上,rehashidx
设置为-1,表示rehash已经完成。
一下rehash的两种方式,都是渐进式rehash的体现:
dictRehashMilliseconds
:按照ms计时的rehash操作,是databasesCron
中针对redis的DB进行rehash。databasesCron
是redis的时间事件之一,每隔一段时间就会执行。
/* Rehash for an amount of time between ms milliseconds and ms+1 milliseconds */
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
while(dictRehash(d,100)) { // 每次rehash100个键值对
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
_dictRehashStep
:一步的rehash操作,该函数在dict的增删改查操作中都会被调用。例如在上面dictAddRaw
函数代码中if (dictIsRehashing(d)) _dictRehashStep(d);
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}