43-源码 10:跋山涉水 —— 深入字典遍历
课程
1
开篇:授人以鱼不若授人以渔 —— Redis 可以用来做什么?
学习时长: 5分21秒
2
基础:万丈高楼平地起 —— Redis 基础数据结构
上次学到
学习时长: 16分14秒
3
应用 1:千帆竞发 —— 分布式锁
学习时长: 7分47秒
4
应用 2:缓兵之计 —— 延时队列
学习时长: 8分9秒
5
应用 3:节衣缩食 —— 位图
学习时长: 8分52秒
6
应用 4:四两拨千斤 —— HyperLogLog
学习时长: 14分17秒
7
应用 5:层峦叠嶂 —— 布隆过滤器
学习时长: 17分54秒
8
应用 6:断尾求生 —— 简单限流
学习时长: 4分37秒
9
应用 7:一毛不拔 —— 漏斗限流
学习时长: 7分22秒
10
应用 8:近水楼台 —— GeoHash
学习时长: 7分52秒
11
应用 9:大海捞针 —— Scan
学习时长: 8分42秒
12
原理 1:鞭辟入里 —— 线程 IO 模型
学习时长: 4分1秒
13
原理 2:交头接耳 —— 通信协议
学习时长: 3分34秒
14
原理 3:未雨绸缪 —— 持久化
学习时长: 5分27秒
15
原理 4:雷厉风行 —— 管道
学习时长: 3分51秒
16
原理 5:同舟共济 —— 事务
学习时长: 6分36秒
17
原理 6:小道消息 —— PubSub
学习时长: 7分7秒
18
原理 7:开源节流 —— 小对象压缩
学习时长: 7分14秒
19
原理 8:有备无患 —— 主从同步
学习时长: 4分9秒
20
集群 1:李代桃僵 —— Sentinel
学习时长: 3分52秒
21
集群 2:分而治之 —— Codis
学习时长: 7分28秒
22
集群 3:众志成城 —— Cluster
学习时长: 8分38秒
23
拓展 1:耳听八方 —— Stream
学习时长: 13分40秒
24
拓展 2:无所不知 —— Info 指令
学习时长: 4分4秒
25
拓展 3:拾遗补漏 —— 再谈分布式锁
学习时长: 2分18秒
26
拓展 4:朝生暮死 —— 过期策略
学习时长: 2分21秒
27
拓展 5:优胜劣汰 —— LRU
学习时长: 4分34秒
28
拓展 6:平波缓进 —— 懒惰删除
学习时长: 2分13秒
29
拓展 7:妙手仁心 —— 优雅地使用 Jedis
学习时长: 6分35秒
30
拓展 8:居安思危 —— 保护 Redis
学习时长: 2分19秒
31
拓展 9:隔墙有耳 —— Redis 安全通信
学习时长: 6分34秒
32
拓展 10:法力无边 —— Redis Lua 脚本执行原理
学习时长: 9分24秒
33
拓展 11:短小精悍 —— 命令行工具的妙用
学习时长: 9分21秒
34
源码 1:丝分缕析 —— 探索「字符串」内部
学习时长: 5分20秒
35
源码 2:循序渐进 —— 探索「字典」内部
学习时长: 7分24秒
36
源码 3:挨肩迭背 —— 探索「压缩列表」内部
学习时长: 10分42秒
37
源码 4:风驰电掣 —— 探索「快速列表」内部
学习时长: 3分49秒
38
源码 5:凌波微步 —— 探索「跳跃列表」内部
学习时长: 9分57秒
39
源码 6:破旧立新 —— 探索「紧凑列表」内部
学习时长: 2分42秒
40
源码 7:金枝玉叶 —— 探索「基数树」内部
学习时长: 5分36秒
41
源码 8:精益求精 —— LFU vs LRU
学习时长: 8分4秒
42
源码 9:如履薄冰 —— 懒惰删除的巨大牺牲
学习时长: 9分53秒
43
源码 10:跋山涉水 —— 深入字典遍历
学习时长: 9分24秒
44
源码 11:见缝插针 —— 探索 HyperLogLog 内部
学习时长: 13分3秒
45
尾声:百尺竿头 —— 继续深造指南
学习时长: 2分32秒
juejin_logo copyCreated with Sketch.

源码 10:跋山涉水 —— 深入字典遍历

Redis 字典的遍历过程逻辑比较复杂,互联网上对这一块的分析讲解非常少。我也花了不少时间对源码的细节进行了整理,将我个人对字典遍历逻辑的理解呈现给各位读者。也许读者们对字典的遍历过程有比我更好的理解,还请不吝指教。

一边遍历一边修改

我们知道 Redis 对象树的主干是一个字典,如果对象很多,这个主干字典也会很大。当我们使用 keys 命令搜寻指定模式的 key 时,它会遍历整个主干字典。值得注意的是,在遍历的过程中,如果满足模式匹配条件的 key 被找到了,还需要判断 key 指向的对象是否已经过期。如果过期了就需要从主干字典中将该 key 删除。

void keysCommand(client *c) {
    dictIterator *di; // 迭代器
    dictEntry *de; // 迭代器当前的entry
    sds pattern = c->argv[1]->ptr; // keys的匹配模式参数
    int plen = sdslen(pattern);
    int allkeys; // 是否要获取所有key,用于keys *这样的指令
    unsigned long numkeys = 0;
    void *replylen = addDeferredMultiBulkLength(c);

    // why safe? 
    di = dictGetSafeIterator(c->db->dict);
    allkeys = (pattern[0] == '*' && pattern[1] == '\0');
    while((de = dictNext(di)) != NULL) {
        sds key = dictGetKey(de);
        robj *keyobj;

        if (allkeys || stringmatchlen(pattern,plen,key,sdslen(key),0)) {
            keyobj = createStringObject(key,sdslen(key));
            // 判断是否过期,过期了要删除元素
            if (expireIfNeeded(c->db,keyobj) == 0) {
                addReplyBulk(c,keyobj);
                numkeys++;
            }
            decrRefCount(keyobj);
        }
    }
    dictReleaseIterator(di);
    setDeferredMultiBulkLength(c,replylen,numkeys);
}

那么,你是否想到了其中的困难之处,在遍历字典的时候还需要修改字典,会不会出现指针安全问题?

重复遍历

字典在扩容的时候要进行渐进式迁移,会存在新旧两个 hashtable。遍历需要对这两个 hashtable 依次进行,先遍历完旧的 hashtable,再继续遍历新的 hashtable。如果在遍历的过程中进行了 rehashStep,将已经遍历过的旧的 hashtable 的元素迁移到了新的 hashtable 中,那么遍历会不会出现元素的重复?这也是遍历需要考虑的疑难之处,下面我们来看看 Redis 是如何解决这个问题的。

迭代器的结构

Redis 为字典的遍历提供了 2 种迭代器,一种是安全迭代器,另一种是不安全迭代器。

typedef struct dictIterator {
    dict *d; // 目标字典对象
    long index; // 当前遍历的槽位置,初始化为-1
    int table; // ht[0] or ht[1]
    int safe; // 这个属性非常关键,它表示迭代器是否安全
    dictEntry *entry; // 迭代器当前指向的对象
    dictEntry *nextEntry; // 迭代器下一个指向的对象
    long long fingerprint; // 迭代器指纹,放置迭代过程中字典被修改
} dictIterator;

// 获取非安全迭代器,只读迭代器,允许rehashStep
dictIterator *dictGetIterator(dict *d)
{
    dictIterator *iter = zmalloc(sizeof(*iter));

    iter->d = d;
    iter->table = 0;
    iter->index = -1;
    iter->safe = 0;
    iter->entry = NULL;
    iter->nextEntry = NULL;
    return iter;
}

// 获取安全迭代器,允许触发过期处理,禁止rehashStep
dictIterator *dictGetSafeIterator(dict *d) {
    dictIterator *i = dictGetIterator(d);

    i->safe = 1;
    return i;
}

迭代器的「安全」指的是在遍历过程中可以对字典进行查找和修改,不用感到担心,因为查找和修改会触发过期判断,会删除内部元素。「安全」的另一层意思是迭代过程中不会出现元素重复,为了保证不重复,就会禁止 rehashStep。

而「不安全」的迭代器是指遍历过程中字典是只读的,你不可以修改,你只能调用 dictNext 对字典进行持续遍历,不得调用任何可能触发过期判断的函数。不过好处是不影响 rehash,代价就是遍历的元素可能会出现重复。

安全迭代器在刚开始遍历时,会给字典打上一个标记,有了这个标记,rehashStep 就不会执行,遍历时元素就不会出现重复。

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx;
    // 这个就是标记,它表示当前加在字典上的安全迭代器的数量
    unsigned long iterators;
} dict;

// 如果存在安全的迭代器,就禁止rehash
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

迭代过程

安全的迭代器在遍历过程中允许删除元素,意味着字典第一维数组下面挂接的链表中的元素可能会被摘走,元素的 next 指针就会发生变动,这是否会影响迭代过程呢?下面我们仔细研究一下迭代函数的代码逻辑。

dictEntry *dictNext(dictIterator *iter)
{
    while (1) {
        if (iter->entry == NULL) {
            // 遍历一个新槽位下面的链表,数组的index往前移动了
            dictht *ht = &iter->d->ht[iter->table];
            if (iter->index == -1 && iter->table == 0) {
                // 第一次遍历,刚刚进入遍历过程
                // 也就是ht[0]数组的第一个元素下面的链表
                if (iter->safe) {
                  // 给字典打安全标记,禁止字典进行rehash
                  iter->d->iterators++;
                } else {
                  // 记录迭代器指纹,就好比字典的md5值
                  // 如果遍历过程中字典有任何变动,指纹就会改变
                  iter->fingerprint = dictFingerprint(iter->d);
                }      
            }
            iter->index++; // index=0,正式进入第一个槽位
            if (iter->index >= (long) ht->size) {
                // 最后一个槽位都遍历完了
                if (dictIsRehashing(iter->d) && iter->table == 0) {
                    // 如果处于rehash中,那就继续遍历第二个 hashtable
                    iter->table++;
                    iter->index = 0;
                    ht = &iter->d->ht[1];
                } else {
                    // 结束遍历
                    break;
                }
            }
            // 将当前遍历的元素记录到迭代器中
            iter->entry = ht->table[iter->index];
        } else {
            // 直接将下一个元素记录为本次迭代的元素
            iter->entry = iter->nextEntry;
        }
        if (iter->entry) {
            // 将下一个元素也记录到迭代器中,这点非常关键
            // 防止安全迭代过程中当前元素被过期删除后,找不到下一个需要遍历的元素
            
            // 试想如果后面发生了rehash,当前遍历的链表被打散了,会发生什么
            // 这里要使劲发挥自己的想象力来理解
            // 旧的链表将一分为二,打散后重新挂接到新数组的两个槽位下
            // 结果就是会导致当前链表上的元素会重复遍历
            
            // 如果rehash的链表是index前面的链表,那么这部分链表也会被重复遍历
            iter->nextEntry = iter->entry->next;
            return iter->entry;
        }
    }
    return NULL;
}

// 遍历完成后要释放迭代器,安全迭代器需要去掉字典的禁止rehash的标记
// 非安全迭代器还需要检查指纹,如果有变动,服务器就会奔溃(failfast)
void dictReleaseIterator(dictIterator *iter)
{
    if (!(iter->index == -1 && iter->table == 0)) {
        if (iter->safe)
            iter->d->iterators--; // 去掉禁止rehash的标记
        else
            assert(iter->fingerprint == dictFingerprint(iter->d));
    }
    zfree(iter);
}

// 计算字典的指纹,就是将字典的关键字段进行按位糅合到一起
// 这样只要有任意的结构变动,指纹都会发生变化
// 如果只是某个元素的value被修改了,指纹不会发生变动
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;

    for (j = 0; j < 6; j++) {
        hash += integers[j];
        hash = (~hash) + (hash << 21);
        hash = hash ^ (hash >> 24);
        hash = (hash + (hash << 3)) + (hash << 8);
        hash = hash ^ (hash >> 14);
        hash = (hash + (hash << 2)) + (hash << 4);
        hash = hash ^ (hash >> 28);
        hash = hash + (hash << 31);
    }
    return hash;
}

值得注意的是在字典扩容时进行 rehash,将旧数组中的链表迁移到新的数组中。某个具体槽位下的链表只可能会迁移到新数组的两个槽位中。

hash mod 2^n = k
hash mod 2^(n+1) = k or k+2^n

迭代器的选择

除了 keys 指令使用了安全迭代器,因为结果不允许重复。那还有其它的地方使用了安全迭代器么,什么情况下遍历适合使用非安全迭代器呢?

简单一点说,那就是如果遍历过程中不允许出现重复,那就使用 SafeIterator,比如下面的两种情况

  1. bgaofrewrite 需要遍历所有对象转换称操作指令进行持久化,绝对不允许出现重复
  2. bgsave 也需要遍历所有对象来持久化,同样不允许出现重复

如果遍历过程中需要处理元素过期,需要对字典进行修改,那也必须使用 SafeIterator,因为非安全的迭代器是只读的。

其它情况下,也就是允许遍历过程中出现个别元素重复,不需要对字典进行结构性修改的情况下一律使用非安全迭代器。

思考

请继续思考 rehash 对非安全遍历过程的影响,会重复哪些元素,重复的元素会非常多么还是只是少量重复?

留言
Ctrl + Enter
全部评论(10)
BinK_1783的头像
删除
终究会成为正在成为的人
点赞
回复
奥利奥根本扭不开的头像
删除
”遍历所有对象转换称操作指令进行持久化“ 称写错了,应该是成呐
点赞
回复
只会狗刨的头像
删除
后端
非安全迭代器也不会遍历重复。但是不能写,rehash变化后也应该是报错。
点赞
回复
只会狗刨的头像
删除
后端
非safe迭代器,也会用指纹判断其安全性,可以控制不rehash,但是也无法进行增删改。
点赞
回复
只会狗刨的头像
删除
后端
这是比较简单的keys。但是让我明白了几个之前一直没搞懂的点~赞一个吧。scan会比较复杂
点赞
回复
James爱学习52834的头像
删除
这章难懂,标记一下慢慢啃
1
回复
大米粒酱52973的头像
删除
老钱,scan命令之所以会重复是不是因为使用非安全迭代器?
点赞
2
删除
我的理解 scan命令通过游标索引,扩容和缩容时,采用高位加1进位,可能会产生当前游标位置的一个重复
点赞
回复
删除
scan的原理是dictscan,用高位进位法避免重复
点赞
回复
波波本尊54116的头像
删除
快看完了,过来打个卡,为老钱打call
点赞
回复