redis源码分析与思考(二)——链表与字典

 链表

  关于链表,我们并不陌生,在数据结构里早已接触过,所以在这只是简单的描述下Redis中链表的一些特征。在Redis中,使用链表作为列表键的底层实现之一,而且在发布与订阅、慢查询等功能上也用到了链表。

  链表的定义与实现是在adlist.h与adlist.c两个文件中,先看Redis中链表的节点与链表本身的定义:

//链表节点
typedef struct listNode {
    // 前置节点
    struct listNode *prev;
    // 后置节点
    struct listNode *next;
    // 节点的值,任意的类型
    void *value;
} listNode;

//链表
typedef struct list {
    // 表头节点
    listNode *head;
    // 表尾节点
    listNode *tail;
    // 节点值复制函数
    void *(*dup)(void *ptr);
    // 节点值释放函数
    void (*free)(void *ptr);
    // 节点值对比函数
    int (*match)(void *ptr, void *key);
    // 链表所包含的节点数量
    unsigned long len;
} list;

  不仅如此,在Redis中,还特殊的定义了一个迭代器,专门用来迭代,记录迭代的方向与当前迭代的节点:

typedef struct listIter {
    // 当前迭代到的节点
    listNode *next;
    // 迭代的方向
    int direction;
} listIter;

  Redis中链表的主要特性主要如下:

  1. 多态:这是Redis链表中最重要的特点,在链表定义里使用函数指针来定义复制、释放与比较函数,将具体实现交予其它模块,类似于在这定义了三个接口,而且大量使用void指针,使其可以指向任何的数据类型,这也是Redis整体的特征。
  2. 尽可能少的时间花销:从上述代码不难看出,Redis为了减少在时间上的开销,自带了链表长度的计数器len,以及表头与表位指针,从而使得获取长度、头结点与尾节点的时间复杂度为O(1),而且在插入一个新的节点时,Redis会使用前插法使得插入节点的时间复杂度也为O(1)。

 字典

  字典,又被称作映射(map),许多语言中都内置了这种数据结构,而在Redis中,字典可谓是重中之重,因为Redis本身就是一个基于map的数据库,基本上所有的操作都是建立在map的操作上的,也就是说Redis中的键值操作就是map的操作。

  而在Redis中,为了节省内存的浪费与时间复杂度而设计出来的字典有着如下的特点:

  1. 采用了MurmurHash2算法来计算键的哈希值,通过这个算法,可使得键值对在哈希表中有着一个不错的均匀的分布,而不是集中在一块浪费内存。
  2. 采用了链地址法来解决键冲突,即解决因为键的哈希值一样而被分配到同一个地址的哈希表节点。
  3. 为防止哈希表的负载不均衡,即某个哈希节点含有的键值对很多,而其它哈希节点有着较少的键值对,从而引发的资源分配不均以及时间复杂度增加问题等,Redis会在某个时候rehash哈希表,即重新分配哈希表内存空间,而rehash是渐进式的进行的。
  4. 迭代哈希节点采用了reverse binary iteration算法,该算法使得即便在哈希表扩张以及缩小的同时,也能遍历到所有的数据。

 字典结构

  字典的结构有着几大组成模块,先从最小的哈希节点看起:

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下个哈希表节点
    struct dictEntry *next;
} dictEntry;

  其中,值可以联合中的unit_t的整数,又或者是一个int64_t的整数,又或者是一个指向任意类型的一个指针。

  哈希表的结构如下:

typedef struct dictht {
    // 哈希表的数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,等于 size - 1
    unsigned long sizemask;
    // 该哈希表已用节点的数量
    unsigned long used;
} dictht;

  哈希表中包含着哈希表的数组,这个可以看做是二维数组的table是用来解决键冲突的,而sizemask这个哈希表大小的掩码是用来在后面计算哈希节点的索引值的,这个索引值决定了刚创建此哈希节点时插入哈希表中的位置,计算的方式如下:

hash = dictHashKey(d, key);
idx = hash & d->ht[table].sizemask;

   通过键的哈希值与哈希表大小的掩码取与操作获取索引值。事实证明,这样能往往做到一个不错的分配,而具体的原因与hash的计算放在以后再讲。

  字典的特定函数:

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;

  字典的组成:

typedef struct dict {
    // 特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash的索引,当 rehash 不在进行时,值为 -1
    int rehashidx; 
    // 目前正在运行的安全迭代器的数量
    int iterators; /* number of iterators currently running */
} dict;

  之所以有着一个哈希数组,而且数组的大小为2,是因为ht[0]用来存贮数据,而ht[1]用来rehash用的。当然,还有字典的特殊迭代器:

typedef struct dictIterator {      
    // 被迭代的字典
    dict *d;
    // table :正在被迭代的哈希表号码,值可以是 0 或 1 。
    // index :迭代器当前所指向的哈希表索引位置。
    // safe :标识这个迭代器是否安全,如果是安全迭代器,这safe设置为1,可以调用增删改的操作
    //如果是不安全的,则只能调用dicNext方法,也就是防止迭代器的乱用而导致数据的混乱
    int table, index, safe;
    // entry :当前迭代到的节点的指针
    // nextEntry :当前迭代节点的下一个节点
    //             因为在安全迭代器运作时, entry 所指向的节点可能会被修改,
    //             所以需要一个额外的指针来保存下一节点的位置,
    //             从而防止指针丢失
    dictEntry *entry, *nextEntry;
    //用于误用检测的不安全迭代器指纹
    long long fingerprint; 
} dictIterator;

  关于迭代器与遍历,在字典中有着许多问题需要被解决,所以我将它放在后一篇博客专门讲解。比如:在rehash的途中如何保证每一个哈希节点被遍历到?因为在rehash的时候,哈希节点的地址,以及指向它的指针等都会变,如何保证每一个节点都被遍历到就成了一个很大的问题。有人会问:等到rehash结束后再遍历不行吗?要知道的是,Redis是一个数据库,数据库的要求是低延迟与高并发性的,rehsah本身就是一个重量级的耗时操作,等到rehash结束无疑是一个很槽糕的做法。而在Redis解决遍历的问题采用了reverse binary iteration算法,这无疑值得我们去深究它。

  在讲解字典如何创建、删除、rehash操作前,先来了解几个比较重要的参数:

//初始化字典默认字典的大小
#define DICT_HT_INITIAL_SIZE     4
// 操作成功
#define DICT_OK 0
// 操作失败(或出错)
#define DICT_ERR 1
//是否开启rehash操作,默认不开启
static int dict_can_resize = 1;
// 强制 rehash 的比率
static unsigned int dict_force_resize_ratio = 5;

 

 创建

  在dict.c的文件下(ps:字典的代码位于dict.h与dict.c文件下)分为了三个区域,一个是private prototypes区域,即私有函数的位置,一个是计算哈希值的函数,另外的就是字典api的实现了。

  因为创建字典代码较简单,所以直接贴出源代码:

//创建一个新的字典
dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));
    _dictInit(d,type,privDataPtr);
    return d;
}
//重置一个字典
static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}
//初始化字典
int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    // 初始化两个哈希表的各项属性值
    // 但暂时还不分配内存给哈希表数组
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);
    // 设置类型特定函数
    d->type = type;
    // 设置私有数据
    d->privdata = privDataPtr;
    // 设置哈希表 rehash 状态,默认不进行rehash
    d->rehashidx = -1;
    // 设置字典的安全迭代器数量
    d->iterators = 0;
    return DICT_OK;
}

  创建新的哈希表:

int dictExpand(dict *d, unsigned long size)
{
    // 新哈希表
    dictht n; 
    // 根据 size 参数,计算哈希表的大小
    unsigned long realsize = _dictNextPower(size);
    // 不能在字典正在 rehash 时进行,size 的值也不能小于 0 号哈希表的当前已使用节点
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;
    // 为哈希表分配空间,初始化
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
    // 如果 0 号哈希表为空,那么这是一次初始化:
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }
    // 如果 0 号哈希表非空,那么这是一次 rehash :
    // 程序将新哈希表设置为 1 号哈希表,
    // 并将字典的 rehash 标识打开,让程序可以开始对字典进行 rehash
    d->ht[1] = n;
    d->rehashidx = 0;
    return DICT_OK;
}

  在创建新的哈希表时,就默认有了两种策略,将新的哈希表赋值给ht[0],亦或者赋值给ht[0],打开rehash操作。而分配新的哈希表的内存大小采用如下的策略:

//计算新的哈希表的大小
static unsigned long _dictNextPower(unsigned long size)
{
  //默认初始值为DICT_HT_INITIAL_SIZE
    unsigned long i = DICT_HT_INITIAL_SIZE;
  //如果传入的大小超过最大整数,返回最大整数
  //否则找到第一个大于size的size乘以2的n次方的数
    if (size >= LONG_MAX) return LONG_MAX;
    while(1) {
        if (i >= size)
            return i;
        i *= 2;
    }
}

  这样就动态的减少了不必要的内存浪费。而关于删除与修改操作代码比较简单,在此就不在阐述了,基本的策略是当rehash操作被打开时,就在ht[0]与ht[1]两张哈希表中遍历,否则只在ht[0]中遍历。而新增哈希节点在rehash进行时,只会增加到ht[1]上,这样保证了ht[0]哈希表的只减不增。而且,Redis中字典还提供了随机的返回哈希节点的实现。

       遍历

dictEntry *dictNext(dictIterator *iter)
{
    while (1) {
        // 进入这个循环有两种可能:
        // 1) 这是迭代器第一次运行
        // 2) 当前索引链表中的节点已经迭代完(NULL 为链表的表尾)
        if (iter->entry == NULL) {
            // 指向被迭代的哈希表
            dictht *ht = &iter->d->ht[iter->table];
            // 初次迭代时执行
            if (iter->index == -1 && iter->table == 0) {
                // 如果是安全迭代器,那么更新安全迭代器计数器
                if (iter->safe)
                    iter->d->iterators++;
                // 如果是不安全迭代器,那么计算指纹
                else
                    iter->fingerprint = dictFingerprint(iter->d);
            }
            // 更新索引
            iter->index++;
            // 如果迭代器的当前索引大于当前被迭代的哈希表的大小
            // 那么说明这个哈希表已经迭代完毕
            if (iter->index >= (signed) ht->size) {
                // 如果正在 rehash 的话,那么说明 1 号哈希表也正在使用中
                // 那么继续对 1 号哈希表进行迭代
                if (dictIsRehashing(iter->d) && iter->table == 0) {
                    iter->table++;
                    iter->index = 0;
                    ht = &iter->d->ht[1];
                // 如果没有 rehash ,那么说明迭代已经完成
                } else {
                    break;
                }
            }
            // 如果进行到这里,说明这个哈希表并未迭代完
            // 更新节点指针,指向下个索引链表的表头节点
            iter->entry = ht->table[iter->index];
        } else {
            // 执行到这里,说明程序正在迭代某个链表
            // 将节点指针指向链表的下个节点
            iter->entry = iter->nextEntry;
        }
        // 如果当前节点不为空,那么也记录下该节点的下个节点
        // 因为安全迭代器有可能会将迭代器返回的当前节点删除
        if (iter->entry) {
            iter->nextEntry = iter->entry->next;
            return iter->entry;
        }
    }
    // 迭代完毕
    return NULL;
}

  rehash

  上文讲到,rehash操作是一个耗时的操作,如果处理不当就会造成阻塞,酿成严重的后果。所以redis采用的策略是渐进式rehash,也就是将rehash操作不一次性完成,而是分时分步进行多次完成。

  在谈论具体如何rehash之前,简述下在什么时候需要进行rehash操作。

  1. 没有进行BGSAVE以及BGREWRITEAOF命令时,且负载因子的值与1相差很近时,进行rehash操作。
  2. 在进行BGSAVE以及BGREWRITEAOF命令时,且负载因子大于的等于5,即强制进行rehash的常量时。

  如下代码所示:

//判断是么时候进行rehash操作
static int _dictExpandIfNeeded(dict *d)
{
    // 渐进式 rehash 已经在进行了,直接返回
    if (dictIsRehashing(d)) return DICT_OK;
    // 如果字典(的 0 号哈希表)为空,那么创建并返回初始化大小的 0 号哈希表
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
    // 以下两个条件之一为真时,对字典进行扩展
    // 1)字典已使用节点数和字典大小之间的比率接近 1:1
    //    并且 dict_can_resize 为真
    // 2)已使用节点数和字典大小之间的比率超过 dict_force_resize_ratio
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
    {
        // 新哈希表的大小至少是目前已使用节点数的两倍
        return dictExpand(d, d->ht[0].used*2);
    }

    return DICT_OK;
}

  负载因子的计算公式:ht[0].used / d->ht[0].size。没有进行BGSAVE以及BGREWRITEAOF命令时,即在没有进行AOF与RDB读写操作时,Redis整体上消耗CPU并不多,所以这时如果负载因子接近1的情况下,完全可以开启rehash操作。而如果已经在进行AOF以及RDB的读写操作时,此时服务器为了进行读写,会开启一个子线程而不影响服务器其它的操作,而此时如果轻易的开启另一个线程来进行rehash操作,会导致读写子线程受到影响较大。所以才把开启rehash操作的负载因子门槛设为了5,当负载因子大于等于5的时候,这时候字典本身操作所消耗的资源要大于开启另外一个线程所带来的消耗,所以在负载因子大于等于5时,强制开启rehash操作。

  rehash的流程

  首先,分时段进行,在给定时间内没完成强制进行退出rehash操作:

int dictRehashMilliseconds(dict *d, int ms) {
    //开始时间
    long long start = timeInMilliseconds();
    //记录完成到了哪一步
  int rehashes = 0;
    while(dictRehash(d,100)) {
        rehashes += 100;
        // 如果时间已过,跳出
        if (timeInMilliseconds()-start > ms) break;
    }

    return rehashes;
}

  其次,分批次进行,Redis中rehash操作是分批次进行的,假如在给定时间内没有完成rehash操作,Redis会记录完成到了哪一步,从而在CPU使用不密集以及其它操作不频繁的时候再次进行rehash操作,一直重复直到完成rehash为止。

  分批进行:

//每批次进行n步迁移
int dictRehash(dict *d, int n) {

    // 判断是否打开了rehash操作
    if (!dictIsRehashing(d)) return 0;
    while(n--) {
        dictEntry *de, *nextde;
        // 如果 0 号哈希表为空,那么表示 rehash 执行完毕
        if (d->ht[0].used == 0) {
            // 释放 0 号哈希表
            zfree(d->ht[0].table);
            // 将原来的 1 号哈希表设置为新的 0 号哈希表
            d->ht[0] = d->ht[1];
            // 重置旧的 1 号哈希表
            _dictReset(&d->ht[1]);
            // 关闭 rehash 标识
            d->rehashidx = -1;
            // 返回 0 ,向调用者表示 rehash 已经完成
            return 0;
        }
        // 确保 rehashidx 没有越界
        assert(d->ht[0].size > (unsigned)d->rehashidx);
        // 略过数组中为空的索引,找到下一个非空索引
        while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
        // 指向该索引的链表表头节点
        de = d->ht[0].table[d->rehashidx];
        // 将链表中的所有节点迁移到新哈希表
        while(de) {
            unsigned int h;
            // 保存下个节点的指针
            nextde = de->next;
            // 计算新哈希表的哈希值,以及节点插入的索引位置
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            // 插入节点到新哈希表
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            // 更新计数器
            d->ht[0].used--;
            d->ht[1].used++;
            // 继续处理下个节点
            de = nextde;
        }
        // 将刚迁移完的哈希表索引的指针设为空
        d->ht[0].table[d->rehashidx] = NULL;
        // 更新 rehash 索引
        d->rehashidx++;
    }

    return 1;
}

    当rehash彻底完成的时候,会将指向ht[0]的指针指向ht[1]的地址,指向ht[1]的指针指向NULL,保证ht[0]是存贮数据的位置。同样,为了节省空间,字典有着相应的缩小字典大小的判断:

int dictResize(dict *d)
{
    int minimal;
    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    // 计算节点数量
    minimal = d->ht[0].used;
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    // 调整字典的大小
    return dictExpand(d, minimal);
}

  对rehash操作的总结如下:

  1. 采用分而治之的思想,让得rehash这个耗时操作分时段、分批次进行,均衡了Redis整体上的消耗,从而使得rehash操作对其它操作的影响降到最少。
  2. rehash操作会根据当前的负载因子大小,自行决定扩充字典大小还是缩小字典大小,避免不必要的内存消耗。
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值