1

baiyan

命令语法

命令含义:查找并返回所有符合给定模式 pattern 的 key
命令格式:

KEYS pattern

命令实战:

127.0.0.1:6379> keys *
1) "kkk"
2) "key1"

返回值: 根据pattern匹配后的所有键的集合

源码分析

keys命令对应的处理函数是keysCommand():

void keysCommand(client *c) {
    dictIterator *di; 
    dictEntry *de;
    sds pattern = c->argv[1]->ptr; // 获取我们输入的pattern
    int plen = sdslen(pattern), allkeys;
    unsigned long numkeys = 0;
    void *replylen = addDeferredMultiBulkLength(c); 

    di = dictGetSafeIterator(c->db->dict); // 初始化一个安全迭代器
    allkeys = (pattern[0] == '*' && pattern[1] == '\0'); // 判断是否返回全部keys的集合
    while((de = dictNext(di)) != NULL) { // 遍历整个键空间字典
        sds key = dictGetKey(de); // 获取key值
        robj *keyobj;

        // 如果是返回全体键的集合,或者当前键与我们给定的pattern匹配,那么添加到返回列表
        if (allkeys || stringmatchlen(pattern,plen,key,sdslen(key),0)) {
            keyobj = createStringObject(key,sdslen(key));
            if (!keyIsExpired(c->db,keyobj)) { // 筛选出没有过期的键
                addReplyBulk(c,keyobj); // 添加到返回列表
                numkeys++; // 返回键的数量++
            }
            decrRefCount(keyobj); 
        }
    }
    dictReleaseIterator(di); // 释放安全迭代器
    setDeferredMultiBulkLength(c,replylen,numkeys); // 设置返回值的长度
}

由于我们使用了keys *命令,需要返回所有键的集合。我们首先观察这段代码,它会使用一个安全迭代器,来遍历整个键空间字典。在遍历的同时,筛选出那些匹配我们pattern以及非过期的键,然后返回给客户端。由于其遍历的时间复杂度是和字典的大小成正比的,这样就会导致一个问题,当键非常多的时候,这个键空间字典可能会非常大,我们一口气使用keys把字典从上到下遍历一遍,会消耗非常多的时间。由于redis是单进程的应用,长时间执行keys命令会阻塞redis进程,造成redis服务对外的不可用状态。所以,很多公司都会禁止开发者使用keys命令,这可能导致redis服务长时间不可用。参考案例:Redis的KEYS命令引起RDS数据库雪崩,RDS发生两次宕机,造成几百万的资金损失
那么可能大家会问了,我如果换上其他范围比较小的pattern去替换之前的*,不就可以避免一次性去遍历全部的键空间了吗?但是我们看上面的源码,由于它是在遍历到每一个key的时候,都会去判断当前key是否与传入的pattern所匹配,所以,并不是我们想象中的,只遍历我们传入的pattern的键空间元素集合,而需要遍历完整的键空间集合,在遍历的同时筛选出符合条件的key值。其实遍历的初始范围并没有缩小,其时间复杂度仍然为O(N),N为键空间字典的大小。

扩展

安全迭代器与非安全迭代器

在keys命令的遍历过程中,涉及到了安全迭代器的概念。与之相对的,还有非安全迭代器。那么,迭代器是如何工作的,安全与非安全的区别有是什么呢?我们首先来看迭代器的存储结构:

typedef struct dictIterator {
    dict *d; // 指向所要遍历的字典
    long index; // 哈希表中bucket的索引位置
    int table, safe; // table索引(参考dict结构只能为0或1),以及迭代器是否安全的标记
    dictEntry *entry, *nextEntry; // 存储当前entry和下一个entry
    long long fingerprint; // 指纹,只在非安全迭代的情况下做校验
} dictIterator;

为了让大家能够看明白index和table字段的作用,我们又要贴上dict的结构了:

其中的table字段只能为0或1。0是正常状态下会使用的哈希表,1是rehash过程中需要用到的过渡哈希表。而index就是每个哈希表中01234567这个索引了。迭代器中的safe字段就是用来区分迭代器类型是安全还是非安全的。所谓安全就是指在遍历的过程中,对字典的操作不会影响遍历的结果;而非安全的迭代器可能会由于rehash等操作,导致其遍历结果会有所误差,但是它的性能更好。

怎么做才会安全

在redis中,安全迭代器通过直接禁止rehash操作,来让迭代器变得安全。那么,为什么禁止rehash操作就安全了呢?我们都知道,rehash操作是渐进式的。每执行一个命令,才会做一个rehash。rehash操作会同时使用字典的两个table。我们考虑这样一种情况:假设迭代器当前正在遍历第一个table,此时进度已经到了索引index为3的位置,而某一个元素还没有进行rehash,我们已经遍历过了这个元素。那么rehash和遍历同时进行,假设rehash完毕,这个元素到了第二个table的index为33的位置上。而目前迭代器的进度仅仅到了第二个table的index为13的位置,还没有遍历到index为33的位置上。那么如果继续遍历,由于这个元素已经在第一个table中遍历过一次,那么现在会被不可避免地遍历第二次。也就是说,由于rehash导致同一个元素被遍历了两次,这就是为什么rehash会影响迭代器的遍历结果。为了解决以上问题,redis通过在安全迭代器运行期间禁止rehash操作,来保证迭代器是安全的。那么究竟redis是如何判断当前是否有安全迭代器在运行,进而来禁止rehash操作的呢?我们首先回顾一下dict的结构:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2]; // 两个table的指针
    long rehashidx;  // rehash标志,如果是-1则没有在rehash
    unsigned long iterators; // 当前运行的安全迭代器的数量
} dict;

我们看到,字典结构中的iterators字段用来描述安全迭代器的数量。如果有一个安全迭代器在运行,那么这个字段就会++。这样,在迭代的过程中,字典会变得相对稳定,避免了一个元素被遍历多次的问题。如果当前有一个安全迭代器在运行,iterator字段必然不会为0。当这个字段为0的时候,才能进行rehash操作:

static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

其实,除了安全迭代器这种简单粗暴地禁止rehash操作之外,redis还提供了SCAN这种更高级的遍历方式。它通过一种更为复杂以及巧妙的算法,来保证了即使在rehash过程中,也能保证遍历的结果不重不漏。这就保证了rehash操作以及遍历操作能够并发执行,同时也避免了keys在遍历当键空间很大的时候超高的时间复杂度会导致redis阻塞的问题,大大提高了效率。

安全迭代器一定安全吗

那么继续思考,仅仅不进行rehash操作就能够保证迭代器是安全的了吗?由于redis是单进程的应用,所以我们在执行keys命令的时候,会阻塞其他所有命令的执行。所以,在迭代器进行遍历的时候,我们外部是无法通过执行命令,来对键空间字典进行增删改操作的。但是redis内部的一些时间事件会有修改字典的可能性。比如:每隔一段时间扫描某个键是否已经过期,过期了则把它从键空间中删除。这一点,我认为即使是安全迭代器,也是无法避免可能在遍历期间对字典进行操作的的。比如在遍历期间,redis某个时间事件把还没有遍历到的元素删除了,那么后续迭代器再去继续遍历,就无法遍历到这个元素了。那么如何解决这个问题呢?除非redis内部根本不在遍历期间触发事件并执行处理函数,否则这些操作所导致遍历结果的细微误差,redis是无法避免的。

迭代器遍历的过程

抛开上面这些细节,我们接下来看一下具体的遍历逻辑。首先我们需要初始化安全迭代器:

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

如果是安全迭代器,除了需要初始化以上字段之外,还需要将safe字段设置为1:

dictIterator *dictGetSafeIterator(dict *d) {
    dictIterator *i = dictGetIterator(d); // 调用上面的方法初始化其余字段

    i->safe = 1; // 初始化safe字段
    return i;
}

回到开始的keys命令,它调用的就是dictGetSafeIterator()函数来初始化一个安全迭代器。接下来,keys命令会循环调用dictNext()方法对所有键空间字典中的元素做遍历:

dictEntry *dictNext(dictIterator *iter)
{
    while (1) {
    
        // 进入这个if的两种情况:
        // 1. 这是迭代器第一次运行
        // 2. 当前索引链表中的节点已经迭代完
        if (iter->entry == NULL) {

            // 指向被遍历的哈希表,默认为第一个哈希表
            dictht *ht = &iter->d->ht[iter->table];

            // 仅仅第一次遍历时执行(index初始化值为-1)
            if (iter->index == -1 && iter->table == 0) {
            
                // 如果是安全迭代器(safe == 1),那么更新iterators计数器
                if (iter->safe)
                    iter->d->iterators++;
                // 如果是不安全迭代器,那么计算指纹
                else
                    iter->fingerprint = dictFingerprint(iter->d);
            }
            
            // 更新索引,继续遍历下一个bucket上的元素
            iter->index++;

            // 如果迭代器的当前索引大于当前被迭代的哈希表的大小
            // 那么说明这个哈希表已经迭代完毕
            if (iter->index >= (signed) ht->size) {
                // 如果正在进行rehash操作,说明第二个哈希表也正在使用中
                // 那么继续对第二个哈希表进行遍历
                if (dictIsRehashing(iter->d) && iter->table == 0) {
                    iter->table++;
                    iter->index = 0;
                    ht = &iter->d->ht[1];
                // 如果没有rehash,则不需要遍历第二个哈希表
                } else {
                    break;
                }
            }

            // 如果进行到这里,说明这个哈希表并未遍历完成
            // 更新节点指针,指向下个索引链表的表头节点(index已经++过了)
            iter->entry = ht->table[iter->index];
        } else {
            // 执行到这里,说明正在遍历某个bucket上的链表(为了解决冲突会在一个bucket后面挂接多个dictEntry,组成一个链表)
            iter->entry = iter->nextEntry;
        }

        // 如果当前节点不为空,那么记录下该节点的下个节点的指针(即next)
        // 因为安全迭代器在运行的时候,可能会将迭代器返回的当前节点删除,这样就找不到next指针了
        if (iter->entry) {
            iter->nextEntry = iter->entry->next;
            return iter->entry;
        }
    }

    // 遍历完成
    return NULL;
}

具体的遍历过程已以注释的形式给出了。代码中又有一个新的概念:fingerprint指纹,下面我们讨论一下指纹的概念。

指纹的作用

在dictNext()遍历函数中,有这样一段代码:

if (iter->safe) { // 如果是安全迭代器(safe == 1),那么更新iterators计数器
     iter->d->iterators++;
} else { // 如果是不安全迭代器,那么计算指纹
     iter->fingerprint = dictFingerprint(iter->d);
}

我们看到,当迭代器是非安全的情况下,它会验证一个指纹。顾名思义,非安全的意思就是在遍历的时候可以进行rehash操作,这样就会导致遍历结果可能出现重复等问题。为了正确地识别这种问题,redis采用了指纹机制,即在遍历之前采集一次指纹,在遍历完成之后再次采集指纹。如果两次指纹比对一致,就说明遍历结果没有因为rehash操作的影响而改变。那么具体如何去验证指纹呢?验证指纹的本质其实就是判断字典是否因为rehash操作发生了变化:

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

我们看到,指纹验证就是基于字典的table、size、used等字段来进行的。如果这几个字段发生了改变,就代表rehash操作正在执行或已执行完毕。一旦有rehash操作在执行,那么有可能就会导致遍历结果受到影响。所以,非安全迭代器的指纹验证能够很好地发现rehash操作对遍历结果产生影响的可能性。


NoSay
449 声望544 粉丝