【redis源码阅读】dict

本文详细解析了Redis3.0中dict数据结构,包括其渐进式扩容机制、哈希表设计、dictAddRaw和dictGenericDelete操作,以及dictScan的遍历逻辑。强调了单线程环境下如何优化耗时操作,如rehash和遍历性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

基于带中文注释的redis3.0源码阅读,github地址:https://github.com/huangz1990/redis-3.0-annotated。redis的基础数据结构dict的源码位于src/dict.c。

数据结构

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;

redis的字典由两个dictht(哈希表)组成,两个哈希表是为了字典的渐进式扩容,这个机制会在下面介绍。
再来看看哈希表的结构

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

redis的哈希表又由dictEntry组成,每个dictEntry就是一个键值对。哈希表解决冲突的方法是链表法。

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

渐进式扩容

关于字典,需要先介绍其渐进式扩容机制和两个哈希表的作用,因为其他操作都与这两个哈希表有关,能更好地理解其他操作。

由于redis是一个单线程的程序,所以希望在字典扩容的时候不要占用太多时间,因此使用渐进式扩容的方法。渐进式扩容就是,一次只迁移一个entry的数据。
redis扩容步骤
1、获取新哈希表大小,大小一般是2的n次方,给新哈希表分配内存
2、把rehashidx设置为0表示正在进行rehash,rehashidx表示下一个需要迁移数据的地方
3、到这里后,redis对字典的增删改查操作都会调用一次单步rehash(dictRehash函数)

int dictRehash(dict *d, int n) {
    // 只可以在 rehash 进行中时执行
    if (!dictIsRehashing(d)) return 0;
    // 进行 N 步迁移
    // T = O(N)
    while(n--) {
        dictEntry *de, *nextde;
        // 如果 0 号哈希表为空,那么表示 rehash 执行完毕
        // T = O(1)
        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指向该索引的链表表头节点
        de = d->ht[0].table[d->rehashidx];
        // 将链表中的所有节点迁移到新哈希表
        // T = O(1)
        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;
}

该函数的作用是把字典d中的ht[0]中的数据迁移到ht[1]去,n是迁移的步数(entry数)
虽然该函数有一个while(n–)的循环,但实际上调用的时候n都是等于1,也就是只迁移一个entry的数据。

接下来看看,redis的其他操作
增加的操作在dictEntry *dictAddRaw(dict *d, void *key)函数中,该函数给定字典d和键key,获取字典中的dictEntry(插入数据的位置)。

dictEntry *dictAddRaw(dict *d, void *key)
{
    int index;
    dictEntry *entry;
    dictht *ht;
    // 如果条件允许的话,进行单步 rehash
    // T = O(1)
    if (dictIsRehashing(d)) _dictRehashStep(d);
    // 计算键在哈希表中的索引值
    // 如果值为 -1 ,那么表示键已经存在
    // T = O(N)
    if ((index = _dictKeyIndex(d, key)) == -1)
        return NULL;
    // T = O(1)
    // 如果字典正在 rehash ,那么将新键添加到 1 号哈希表
    // 否则,将新键添加到 0 号哈希表
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    // 为新节点分配空间
    entry = zmalloc(sizeof(*entry));
    // 将新节点插入到链表表头
    entry->next = ht->table[index];
    ht->table[index] = entry;
    // 更新哈希表已使用节点数量
    ht->used++;
    // 设置新节点的键
    // T = O(1)
    dictSetKey(d, entry, key);
    return entry;
}
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

可以看到,在插入操作之前,调用了一次_dictRehashStep函数,进行一次数据迁移。在进行rehash的时候把新的数据插入到ht[1]

查找和删除

在查找和删除时,如果字典正在进行rehash,则会到两个hashtable里面去找。下面是删除的函数,查找的也类似,就不上了。

static int dictGenericDelete(dict *d, const void *key, int nofree)
{
    unsigned int h, idx;
    dictEntry *he, *prevHe;
    int table;
    // 字典(的哈希表)为空
    if (d->ht[0].size == 0) return DICT_ERR; /* d->ht[0].table is NULL */
    // 进行单步 rehash ,T = O(1)
    if (dictIsRehashing(d)) _dictRehashStep(d);
    // 计算哈希值
    h = dictHashKey(d, key);
    // 遍历哈希表
    // T = O(1)
    // 这里遍历了ht[0]和ht[1]
    for (table = 0; table <= 1; table++) {
        // 计算索引值 
        idx = h & d->ht[table].sizemask;
        // 指向该索引上的链表
        he = d->ht[table].table[idx];
        prevHe = NULL;
        // 遍历链表上的所有节点
        // T = O(1)
        while(he) {
            if (dictCompareKeys(d, key, he->key)) {
                // 超找目标节点
                // 从链表中删除
                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 DICT_OK;
            }
            prevHe = he;
            he = he->next;
        }
        // 如果执行到这里,说明在 0 号哈希表中找不到给定键
        // 那么根据字典是否正在进行 rehash ,决定要不要查找 1 号哈希表
        if (!dictIsRehashing(d)) break;
    }
    // 没找到
    return DICT_ERR; /* not found */
}

dictScan

在dict中还有一个dictScan函数,是用于遍历dict中的所有元素。由于redis是单线程,所以为了避免长时间的操作,dictScan使用了游标的方式实现。游标从0开始,每次遍历都会返回下一个游标,一直遍历到游标为0则整个dict遍历完毕。
dictScan这个函数,看起来只在redis的scan命令中使用到。而scan命令是用于查询redis中所有的key的,和key类似,不过key会一次返回所有key,若存在很多key,那么时间会花费得更多。

dictScan的实现比较复杂,这里先用表格展示游标的变化过程
假设hashtable大小为16(1<<4),二进制表示(10000),哈希值掩码(1111),游标的变化过程为

初始值0000
第一次遍历返回值1000
第二次0100
第三次1100
第四次0010
第五次1010
最后一次1111
结束0000

游标的变化过程是从左边开始递增的(正常二进制递增从右边开始,而dictScan的游标则是反过来的)

在rehash的情况下,会同时遍历两个hasttable。假设新hashtable大小为64(1<<6),二进制表示(1000000),掩码(111111),
dictScan的游标也是按照大小为16来遍历的,不过会在遍历每一个游标时,对ht[1]额外遍历所有高位的游标。
举个例子,假设正在遍历游标0100,那么会在遍历的过程中同时遍历ht[1]所有xx0100,即010100,100100,110100,高位的遍历顺序就是正常的二进制递增顺序(从右开始递增)。
接下来对着代码看一遍

unsigned long dictScan(dict *d,
                       unsigned long v,
                       dictScanFunction *fn,
                       void *privdata)
{
    dictht *t0, *t1;
    const dictEntry *de;
    unsigned long m0, m1;
    // v就是我们所说的游标,初始值为0,fn是回调函数,用于执行一些逻辑
    // 跳过空字典
    if (dictSize(d) == 0) return 0;
    // 迭代只有一个哈希表的字典
    if (!dictIsRehashing(d)) {
        // 指向哈希表
        t0 = &(d->ht[0]);
        // 记录 mask
        m0 = t0->sizemask;
        // 指向哈希桶
        de = t0->table[v & m0];
        // 遍历桶中的所有节点
        while (de) {
            fn(privdata, de);
            de = de->next;
        }
    // 迭代有两个哈希表的字典
    } else {
        // 指向两个哈希表
        t0 = &d->ht[0];
        t1 = &d->ht[1];
        // 确保 t0 比 t1 要小
        if (t0->size > t1->size) {
            t0 = &d->ht[1];
            t1 = &d->ht[0];
        }
        // 记录掩码,结合上面的例子,m0=1111,m1=111111(二进制)
        m0 = t0->sizemask;
        m1 = t1->sizemask;
        // 指向桶,并迭代桶中的所有节点
        de = t0->table[v & m0];
        while (de) {
            fn(privdata, de);
            de = de->next;
        }
        do {
            // 指向桶,并迭代桶中的所有节点
            de = t1->table[v & m1];
            while (de) {
                fn(privdata, de);
                de = de->next;
            }
            // 这里是rehash时遍历某个游标的同时遍历该游标的高位
            // 该逻辑的变化过程对应上面例子的xx0100的变化过程
            v = (((v | m0) + 1) & ~m0) | (v & m0);
            // m0^m1=001111,v&(m0^m1)==0表示xx0100从00 0100到01 0100... 00 0100
            // 即v&(m0^m1)==0时表示当前游标的高位已经遍历完了
        } while (v & (m0 ^ m1));
    }
    // 这里是游标变化的逻辑
    v |= ~m0;
    // rev是二进制反转函数,输入0001会返回1000,0011返回1100。。。
    v = rev(v);
    // 先反转再加一实现了二进制从左边开始递增
    v++;
    v = rev(v);
    return v;
}

关于游标的变化方式
代码中也给了解释,是为了防止扩容是漏遍历某些数据,同时该遍历方法也有可能会出现重复遍历的情况。
举个例子,假设当前hashtable大小为16(1<<4),当前游标为1100,那么已遍历游标为0000,1000,0100
1、假设在遍历了0100游标后,字典缩容了,大小变成了8(1<<3),那么在游标为0100,1100的数据会迁移到100
2、遍历1100游标时会遍历ht[0]的0100,1100和ht[1]的100,在ht[0]的0100的数据会被重复遍历
3、如果游标按正常的递增方式遍历,那么已遍历的游标就是0000,0001,0010,0011,0100,…,1011。1110还没遍历,如果在遍历之前,1110的数据被迁移到0110,那么将有数据被漏掉(0110已经遍历过)
3.1、而redis这种游标的变化方式可以避免缩容时的这个问题(我脑补了一下,感觉扩容应该两种变化方式都没有问题,可以自己模拟一下)

总结一下就是dictScan可以遍历一开始就在dict中(开始遍历后插入的数据不算,那这样开始后删掉的数据应该也拿不到)的所有数据,可能会有重复(缩容情况),但不会遗漏。

总结

由于redis是单线程程序,里面很多的耗时操作都采用了分步执行(渐进式扩容,dictScan)的方法,这种设计值得借鉴一下。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值