深入理解Redis—Dict字典

2,785 阅读9分钟

写在前

本系列文章已被收录到专栏中,如果各位看官有兴趣,可以移步深入理解Redis专栏

简介

​ Redis的字典是通过Hash函数来实现的,对于Hash,相信大部分看官都不陌生,在Java中最典型的就是HashMap,那么他的基本原理我就不做过多的介绍了。但是在Redis中,由于Redis是单线程的,在做扩容、缩容、迭代等等的情况下会做特殊处理,这一点是跟我们Java中的HashMap不同的地方。

数据结构

Redis中的字典分为几个部分

1. Hash表

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

Hash表的结构如图所示:

1.1

  • **size:**存储hash表的长度,初始是4,由#define DICT_HT_INITIAL_SIZE 4控制。
  • **table:**指针数组,这里Redis并没有把entry数组内嵌到Hash表中,而是存放的entry数组的地址。
  • **sizemask:**用于将哈希值映射到table的位置索引,每个key先经过hashFunction计算得到一个哈希值,然后计算hashcode & sizemask得到在table上的位置。相当于计算取余hashcode % size 。(Redis为了高性能对这个计算做了优化,位运算比取余快的多
  • **used:**存储entry数组中有多少元素。

2. hash表节点

具体实现是dictEntry,相关的结构体如下:

typedef struct dictEntry {
    void *key;	/*存储键*/
    union {
        void *val;		// 可以存放任何类型
        uint64_t u64;
        int64_t s64;	
        double d;
    } v;		
    struct dictEntry *next;		// 拉链法解决hash冲突
} dictEntry;
  • **v:**是一个联合体,如果内容是uint64、int64、double类型的时候,可以直接存入,比如说我们用字典存放键的过期时间的时候,使用的就是s64,如果是存放一些复杂类型,就可以使用*val
  • **next:**使用拉链法解决hash冲突,插入的方式是头插法
  • **key:**存储键的地址


3. 字典

Redis对Hash表外部做了一层封装,在扩容、迭代的时候会用到:

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:**一个指向dictType结构的指针。它使得dict的key和value能够存储任何类型的数据。
  • **privdata:**一个私有数据指针,由调用者在创建dict的时候传进来,配合type字段指向的函数一起使用。
  • **ht[2]:**一个字典中存放两个hash表,平常使用的是ht[0]上边的,只有扩容缩容的时候才会使用到另一个
  • **rehashidx:**正如注释所说,当值为-1的时候,表示没有进行rehash,否则,该值用来表示Hash表ht[0]执行rehash到了哪个元素,并记录该元素的数组下标值。
  • **iterators:**用来记录当前运行的安全迭代器数,当不为0的时候表示有安全迭代器正在执行,这时候就会暂停rehash操作。

特别要注意的是:对于type字段来说,他存在的意义主要是为了区分Redis字典的使用场景,我们来看他的结构体:

typedef struct dictType {
    uint64_t (*hashFunction)(const void *key); // 该字典对应的Hash函数
    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;

这些方法都是对键、值的操作函数。Redis字典在Redis整个架构中起到了举足轻重的作用,比如说在Redis哨兵模式中,其实就是用字典来存储管理所有的Master节点和Slaver节点的。每一个场景,字典的功能都不一样,所以说抽取出了这个type,让他们实现各自的操作。有点类似于抽象函数。

完整的字典结构

以上是完整的字典结构,各位看官可以细细品一品结构的细节。


初始化

说完了数据结构,我们来看看字典的初始化,其实很简单,无非就是分配内存,初始化一个空的字典。

/* 创建一个新的字典 */
dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));

    _dictInit(d,type,privDataPtr);
    return d;
}

/* 初始化字典 */
int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]); // 初始化hash表
    _dictReset(&d->ht[1]); // 初始化hash表
    d->type = type;
    d->privdata = privDataPtr;
    d->rehashidx = -1;
    d->iterators = 0;
    return DICT_OK;
}

/* Reset a hash table already initialized with ht_init().
 * NOTE: This function should only be called by ht_destroy(). */
static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

唯一要注意的是,创建成功后的字典,dictht是空的。


添加和扩容

​ Redis字典的添加大致操作跟hashmap的差不太多,都是先查找键是否存在,存在就修改,否则添加。我们这里主要聊一聊他的扩容机制。

Rehash

Redis中HashTable的负载因子计算:load_factor = used / ht[0].size

而他的扩容条件跟hashmap不同的地方是redis的load_factor == 1就有可能会扩容。

缩容的条件是load_factor == 0.1,即当usedht[0].size的10%的时候就有可能会缩容。

扩容对应的代码是:

/* 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)
        return DICT_ERR;

    dictht n; /* the new hash table */
    unsigned long realsize = _dictNextPower(size);  // 重新计算扩容后的值,必须为2的N次方幂,跟hashmap的原因一样

    /* 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*)); // 非初次申请时,为当前Hash表容量的一倍
    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;	// 扩容后的数组放到ht[1]中
    d->rehashidx = 0;	// 设置当前字典状态为扩容,-1是不扩容
    return DICT_OK;
}

扩容后,需要做数据迁移,这个过程就是将原来的元素的hashcode&新sizemask,到达新表中的位置。

渐进式Rehash

​ 众所周知,Redis是一个单线程执行的模式。当数据库中的键值对达到一个百万、千万级别的时候,我们如果使用普通的rehash流程就会显得很慢,尤其是会阻塞住整个服务,影响很大很大,而Redis这边提出了渐进式Rehash的概念,解决了这个问题。

扩容的简单过程:

​ 在我们执行增删改查操作的时候,都会先判断当前字典的状态是不是rehash,在进行中则会调用dictRehashStep这个方法进行rehash操作,这个操作只会对当前元素进行一次。除了这种情况外,当Redis服务器空闲时候,Redis也会自动执行批量Rehash,一次处理100个元素。最终全部Rehash完后,将ht[0]和ht[1]的值调换,并且修改rehashidx=-1,扩容结束。

​ 我们可以发现,如果有1000w个数据要rehash,我们一次只会操作一个元素,这样就将rehash的总时间分散到各个操作的时候,大大降低了因为扩容导致的损耗。


删除和缩容

删除的操作也比较简单,大致的流程是

  1. 查找该键是否存在于该字典中
  2. 存在就把这个节点从链表中删除,并释放内存
  3. 对应的hashTable的used字段-1
  4. 判断是否需要缩容

缩容

当删除一个节点后,hash表的使用量(used)不到总空间(size)的10%,就会进行缩容,缩容的大小为能够放下当前元素的最小二次幂。比如说,size = 12 , used = 6缩容后size = 8

对应的代码是:

void tryResizeHashTables(int dbid) {
    if (htNeedsResize(server.db[dbid].dict)) //判断是否需要缩容:used/size<10%
    	dictResize(server.db[dbid].dict); //执行缩容操作
}
int dictResize (dict *d){ //缩容函数
    int minimal;
    minimal = d->ht[0].used;
    if (minimal < DICT_HT_INITIAL_SIZE) //容量最小值为4
        minimal = DICT_HT_INITIAL_SIZE;
    return dictExpand (d, minimal); //调用扩容函数,实质进行的是缩容
}

字典遍历

对于字典结构来说,还有一个重要的就是字典的遍历,字典遍历也涉及到了各种情况下的遍历。

Redis下的遍历分两种:

  1. 全遍历:keys,一次性全读取出来
  2. 间断遍历:scan,每次只读取部分,分多次遍历

​ 对于字典来说,开发人员设计了一个迭代器遍历,熟悉Java的看官肯定也知道,hashmap的迭代器遍历的时候,如果对容器中的元素进行了修改,就会报错。Redis中也会出现这种问题,即多次遍历一个元素,或者少遍历了数据。

迭代器结构体

Redis中对字典的迭代器实现的结构体:

/* If safe is set to 1 this is a safe iterator, that means, you can call
 * dictAdd, dictFind, and other functions against the dictionary even while
 * iterating. Otherwise it is a non safe iterator, and only dictNext()
 * should be called while iterating. */
typedef struct dictIterator {
    dict *d; //迭代的字典
    int index; //当前迭代到Hash表中哪个索引值
    int table, safe; //table用于表示当前正在迭代的Hash表,即ht[0]与ht[1],safe用于表示当前创建的是否为安全迭代器
    dictEntry *entry, *nextEntry;//当前节点,下一个节点
    /* unsafe iterator fingerprint for misuse detection. */
    long long fingerprint;//字典的指纹,当字典未发生改变时,该值不变,发生改变时则值也随着改变
} dictIterator;

  • **d:**指向迭代的字典
  • **index:**代表当前读取到HashTable的索引值是啥
  • **table:**指向当前正在迭代的hashTable
  • **safe:**标志位,是否开启安全迭代
  • entry,nextEntry:指向当前迭代的节点和他的下一个节点,是为了防止当前节点删除后,下一个节点丢失
  • **fingerprint:**表示在给定时间内字典的状 态。在这里称其为字典的指纹,因为该字段的值为字典(dict结构体) 中所有字段值组合在一起生成的Hash值,所以当字典中数据发生任何变化时,其值都会不同。(方法是dict.c下的dictFingerprint函数)

全遍历

Redis中的迭代器分为两类:普通迭代器和安全迭代器。

普通迭代器

只遍历数据的迭代器

​ 普通迭代器迭代字典中数据时,会对迭代器中fingerprint字段的值作严格的校验,来保证迭代过程中字典结构不发生任何变化,确保读取 出的数据不出现重复。

主要步骤如下图:

安全迭代器

遍历数据的同时还能修改数据

​ 这类迭代器原理上很简单,如果当前字典有安全迭代器运行,则不进行渐进式 rehash操作,rehash操作暂停,字典中数据就不会被重复遍历,由此确 保了读取数据的准确性。

主要步骤如下:

  • 创建一个迭代器,设置safe=1
  • 循环迭代Hash表的节点,首次访问dictNext函数的时候会让Hash表的iderator++,确保迭代过程中rehash中断
  • 遍历完后,让iterator--,确保rehash能够正常进行

间断遍历

因为全遍历会导致Redis一定时间内不可用,所以Redis在后边的版本中增加了间断遍历操作,比如说scan

​ 间断遍历的思想是通过设置一个游标,每次遍历一部分数据,并返回新的游标值,整个遍历过程都是围绕这个游标值的改动进行,来保证所有的数据能被遍历到。

显然,rehash的以下几种情况会对游标遍历产生影响:

  • 间断遍历的整个过程都没有rehash,意味着只需要遍历ht[0]的每个元素即可。
  • 从迭代开始到结束,散列表进行了扩容或缩容操作,且恰好为 两次迭代间隔期间完成了rehash操作。
  • 从迭代开始到结束,某次或某几次迭代时散列表正在进行rehash 操作。

而对于第2、3种情况,redis采用了一种逆转二进制算法来保证游标遍历不重复也不遗漏,这套算法主要是利用了rehash正好为整数倍增长或者减少的原理。具体实现在dict.c下的dictScan函数中查看~


end~

参考资料