Redis学习笔记&源码阅读--字典-操作

申明

  • 本文基于Redis源码5.0.8
  • 本文内容大量借鉴《Redis设计和实现》和《Redis5设计与源码分析》

概述

上文Redis学习笔记&源码阅读–字典-概念中我们已经介绍了字典的概念和源码层的结构,本节介绍对字典的各种基本操作。

基本操作

接下来结合源码看Redis是如何创建字典,以及对字典进行增删改查的。

创建字典

源码中创建字典的函数是dictCreate函数,源码如下:

/*创建一个新的字典*/
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]);
    _dictReset(&d->ht[1]);
    d->type = type;
    d->privdata = privDataPtr;
    d->rehashidx = -1;
    d->iterators = 0;
    return DICT_OK;
}

dictCreate函数执行的操作是先申请内存,然后再做初始化操作,zmalloc是Redis自己封装了malloc,初始化操作是对字典的各个字段赋值,可以看出除了由需要用户配置的type和privdata字段外,其他基本都是逻辑上的空值。初始化后,一个字典的内存占用情况如下所示:
在这里插入图片描述

元素查询

在字典中查询操作比我们想象中使用的要多,在概念篇中我们已经讨论了字典中键是唯一的,所以在执行字典插入新元素时都要提前查询元素判断键是否存在。
Redis中查询元素的函数是dictFind,源码如下:

dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    uint64_t h, idx, table;

    if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* 字典为空 */
    if (dictIsRehashing(d)) _dictRehashStep(d);
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;//Hash值和掩码进行位运算计算下标
        he = d->ht[table].table[idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key))
                return he;
            he = he->next;
        }
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}

查询前判空是常见的逻辑,然后字典当前如果是rehash状态就执行一次rehash操作,这个暂时不用深究,后面会介绍rehash的,只要有个印象,那就是字典的rehash是化整为零,将一个hash表(可能包含千万个元素)的rehash拆解到各个增删改查动作里。

接下来才是查询真正的逻辑开始,通过dictHashKey计算Hash值,遍历字典的两个Hash表,每次遍历查询元素的逻辑比较简单,使用sizemask计算下标,通过下标在table中找到对应链表(你该知道这里是链表,如果脑海中字典结构不清晰了,请回到上一篇回顾下其结构),代码中he就是一个单向链表,链表是通过next串起来的,遍历该链表比对键是否相等。逻辑到这里基本结束了,但是for循环中还有一个小尾巴,再次出现一个if判断字典是否是rehash了,为什么会出现这种情况呢?我想聪明的你应该知道原因,如果不知道的话,当我上一句没说,这里是因为如果字典不在rehash状态下,ht[1]实际是空的,没必要再次循环了。

元素插入

元素插入是两种层次的逻辑,第一层是逻辑上查询元素是需要先查询该元素是否存在,没错就是上面的dictFind函数,如果存在就是修改元素的值,不存在的话才是真正的元素插入;第二层就是底层真实做元素插入的函数。我们这里讨论的元素插入是第二层的概念,不要把第一层的逻辑带入进来。
先上菜,贴源码:

/* Add an element to the target hash table */
int dictAdd(dict *d, void *key, void *val)
{
    dictEntry *entry = dictAddRaw(d,key,NULL);

    if (!entry) return DICT_ERR;
    dictSetVal(d, entry, val);
    return DICT_OK;
}

从代码看插入的逻辑是比较简单的,dictAddRaw函数的返回结果有两种情况:

  1. 如果key找不到已经存在的entry,则新建一个entry设置key并返回;
  2. 如果key找到已经存在的,则返回null,第三个参数不为空则指向已经存在的entry;

从这个结果看,如果key存在,dictAdd则返回失败,也不会更新旧值,如果key不存在,dictSetVal就会设置entry的值,返回成功。

dictAdd函数的逻辑主要封装在dictAddRaw函数中,先看源码:

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;

    if (dictIsRehashing(d)) _dictRehashStep(d);

    /* Get the index of the new element, or -1 if
     * the element already exists. */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        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];
    entry = zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry;
    ht->used++;

    /* Set the hash entry fields. */
    dictSetKey(d, entry, key);
    return entry;
}

呃。。。。乍一看,代码也并不复杂,判断一下rehash,这个在dictFind中已经见过了,通过_dictKeyIndex判断key是否存在,接着就是创建并初始化一个entry,通过dictSetKey设置下key的值,返回entry。确实dictAddRaw的主要逻辑也就是这些,其实啊,之所以聊这个函数是为了引入一个问题,我们知道字典是有rehash操作的,它的触发是因为字典需要扩容或者缩容了,那么到底是哪里来判断一个字典是当前是否需要扩容或缩容呢?Redis中在插入元素时会判断字典当前是否需要做一次扩容操作,但是这些逻辑我们刚才在dictAddRaw中并没有看到啊?这些是封装在_dictKeyIndex函数中的,扩容的逻辑我们后面再说,元素插入这里就不展开那么多了。

元素修改

其实啊,元素修改并没有什么新鲜内容,不信,你看下源码:

int dictReplace(dict *d, void *key, void *val)
{
    dictEntry *entry, *existing, auxentry;

    /* Try to add the element. If the key
     * does not exists dictAdd will succeed. */
    entry = dictAddRaw(d,key,&existing);
    if (entry) {
        dictSetVal(d, entry, val);
        return 1;
    }

    /* Set the new value and free the old one. Note that it is important
     * to do that in this order, as the value may just be exactly the same
     * as the previous one. In this context, think to reference counting,
     * you want to increment (set), and then decrement (free), and not the
     * reverse. */
    auxentry = *existing;
    dictSetVal(d, existing, val);
    dictFreeVal(d, &auxentry);
    return 0;
}

dictAddRaw、dictSetVal、dictSetVal这三个函数我们都见识过了,只有一个dictFreeVal我们没见过,但是看函数名字就知道它是啥意思了,这一节的内容真是寒酸。

元素删除

元素删除一样没啥讲头,来,我们先看下源码:

int dictDelete(dict *ht, const void *key) {
    return dictGenericDelete(ht,key,0) ? DICT_OK : DICT_ERR;
}

static dictEntry *dictGenericDelete(dict *d, const void *key, int nofree) {
    uint64_t h, idx;
    dictEntry *he, *prevHe;
    int table;

    if (d->ht[0].used == 0 && d->ht[1].used == 0) return NULL;

    if (dictIsRehashing(d)) _dictRehashStep(d);
    h = dictHashKey(d, key);

    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        prevHe = NULL;
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key)) {
                /* Unlink the element from the list */
                if (prevHe)
                    prevHe->next = he->next;
                else
                    d->ht[table].table[idx] = he->next;
                if (!nofree) {
                    dictFreeKey(d, he);
                    dictFreeVal(d, he);
                    zfree(he);
                }
                d->ht[table].used--;
                return he;
            }
            prevHe = he;
            he = he->next;
        }
        if (!dictIsRehashing(d)) break;
    }
    return NULL; /* not found */
}

你自己看看吧,有啥内容是新奇的,不要和我说链表的删除对应的那些指针操作你看不懂,看的懂是吧,那没有了,这一节结束。
呃。。。其实倒是有一点可以聊下的,我们在讨论元素插入的时候,实际上还看到了判断字典是否要扩容的,那对应到这里,元素删除,怎么没有元素缩容的操作啊,这个我倒是真没找到,反而在上层中找到了如果对字典进行删除操作后,会尝试判断字典是否需要缩容,这个我现在确实没搞清白。

字典扩缩容

前面已经提到字典的扩缩容了,是时候埋坑了,扩容和缩容使用底层函数都是一样的,只是在判断是否需要扩容和缩容时的逻辑不一样,我们先看下判断扩容的代码逻辑:

static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    if (dictIsRehashing(d)) return DICT_OK;

    /* 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);//初始大小4

    /* 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))
    {
        return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

如果说字典是空的,那么一定是要扩容了,在上一篇中我们讲到字典table的长度永远都是2^n,初始的大小是4。接着我们就是判断当前是否满足扩容的条件,

  • 当前是否希望延迟扩容,dict_can_resize并不是说dict能或者不能扩容,而是在一些场景下我们希望不要进行过多的内存操作,比如copy-on-write,这是一个软限制。
  • 如果当前保存的元素个数和table的size比值超过一定大小后,就强制进入扩容,当前值是5;

接下来,我们再看下缩容的代码逻辑:

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);
}

dictResize其实只是在缩容场景下对dictExpand的简单封装而已,而且判断是否缩容还不在dictResize中,是在使用dict的业务逻辑中,哈哈哈,我们看Redis中一些代码片段:

/* Always check if the dictionary needs a resize after a delete. */
if (htNeedsResize(o->ptr)) dictResize(o->ptr);

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));
}

感受到了吧。好了,接下来我们来揭开dictExpand的面纱吧:

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

    /* 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;
}

函数中第一个值得聊聊的点应该就是_dictNextPower函数了,再看下它的代码吧:

/* Hash表的容量值都是2^N */
static unsigned long _dictNextPower(unsigned long size)
{
	//起始值是4
    unsigned long i = DICT_HT_INITIAL_SIZE;
	
	//取值上限一般满足2^n-1
    if (size >= LONG_MAX) return LONG_MAX + 1LU;
    while(1) {
    	//如果i是第一个大小不小于size且满足2^N,则返回
        if (i >= size)
            return i;
        i *= 2;
    }
}

看我在代码加的注释我觉得你已经能理解它的作用了,那正文就不聊了,继续往下看,WTF ???哪里做扩容了???我们前面是不是已经说过了,Redis中字典的扩容实际是分解在千万次查询、插入、删除等操作中,这里的扩容实际只是将初始化ht[1],并将rehashidx置为idx0,告诉后面实际从table的idx为0处开始进行rehash操作。

字典遍历

Redis的遍历主要有两种方式:

  • 全遍历:一次命令执行就遍历完整个数据库,如keys命令;
  • 间断遍历:每次命令执行只取部分数据,份多次遍历,如hscan命令;

全遍历

Redis的全遍历主要是通过迭代器实现的,我们首先看下Redis中迭代器的定义:

typedef struct dictIterator {
    dict *d;//迭代器的字典
    long index;//当前迭代到Hash表的下标值
    int table;//当前正在迭代的Hash表,即ht[0]或者ht[1]
    int safe;//迭代器是否是安全迭代器
    dictEntry *entry, *nextEntry;//当前节点,下一个节点
    /* unsafe iterator fingerprint for misuse detection. */
    long long fingerprint;//字典的指纹,当字典不变时,指纹不变,当字典变化时,指纹发生变化
} dictIterator;

迭代器中各个字段的具体含义如下:

  • d:指向需要迭代的字典;
  • index:代表当前读取到Hash表中哪个索引值;
  • table:表示当前正在迭代的Hash表(即ht[0]与ht[1]中的0和1);
  • safe:表示当前创建的迭代器是否为安全模式;
  • entry:表示正在读取的节点数据;
  • nextEntry:表示entry节点中的next字段所指向的数据;
  • fingerprint:字典的指纹。
    fingerprint字段是一个64位的整数,表示在给定时间内字典的状态。在这里称其为字典的指纹,因为该字段的值为字典(dict结构体)中所有字段值组合在一起生成的Hash值,所以当字典中数据发生任何变化时,其值都会不同,源码如下所示:
long long dictFingerprint(dict *d) {
    long long integers[6], hash = 0;
    int j;

    integers[0] = (long) d->ht[0].table;
    integers[1] = d->ht[0].size;
    integers[2] = d->ht[0].used;
    integers[3] = (long) d->ht[1].table;
    integers[4] = d->ht[1].size;
    integers[5] = d->ht[1].used;

    /* We hash N integers by summing every successive integer with the integer
     * hashing of the previous sum. Basically:
     *
     * Result = hash(hash(hash(int1)+int2)+int3) ...
     *
     * This way the same set of integers in a different order will (likely) hash
     * to a different number. */
    for (j = 0; j < 6; j++) {
        hash += integers[j];
        /* For the hashing step we use Tomas Wang's 64 bit integer hash. */
        hash = (~hash) + (hash << 21); // hash = (hash << 21) - hash - 1;
        hash = hash ^ (hash >> 24);
        hash = (hash + (hash << 3)) + (hash << 8); // hash * 265
        hash = hash ^ (hash >> 14);
        hash = (hash + (hash << 2)) + (hash << 4); // hash * 21
        hash = hash ^ (hash >> 28);
        hash = hash + (hash << 31);
    }
    return hash;
}

指纹使用的Hash算法我们就不深入了解了,感兴趣可以自己百度常见的非加密Hash算法。
为了让迭代过程变得简单,Redis也提供了迭代相关的API函数,主要为:

dictIterator *dictGetIterator(dict *d)//初始化一个迭代器
dictIterator *dictGetSafeIterator(dict *d) //初始化一个安全的迭代器
dictEntry *dictNext(dictIterator *iter)//获取下一个元素
void dictReleaseIterator(dictIterator *iter)//销毁迭代器

这几个函数我相信你看下源码基本不用费力就能理解了,所以就不展开细说了,迭代器是分为两类的:

  • 普通迭代器,只遍历数据,遍历期间字典结构不能发生变化,否则报错;
  • 安全迭代器,遍历的同时会删除数据;
    安全迭代器和普通迭代器的区别在于安全迭代器迭代期间字典是不会进行rehash操作的,如何实现呢?我们知道到rehash是分布在字典的增删改查动作中的,是通过_dictRehashStep函数实现的,看下源码:
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

在进入rehash动作前先判断当前安全迭代器个数是否是0,明白了吧。好了,接下来我们看看重头戏间隔遍历。

间断遍历

间断遍历在我的另一篇博客中专门讲解了,转送门,这里就不再赘述了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值