×

Redis Scan算法设计思想

96
68号小喇叭
2018.06.10 01:16* 字数 1613
网图侵删.jpg

想要返回redis当前数据库中的所有key应该怎么办?用keys命令?在key非常多的情况下,该命令会导致单线程redis服务器执行时间过长,后续命令得不到响应,同时对内存也会造成一定的压力,严重降低redis服务的可用性

为此redis 2.8.0及以上版本提供了多个scan相关命令,用以针对不同数据结构(如数据库、集合、哈希、有序集合)提供相关遍历功能

SCAN 命令及其相关的 SSCAN 命令、 HSCAN 命令和 ZSCAN 命令都用于增量地迭代(incrementally iterate)一集元素(a collection of elements)

  • SCAN 命令用于迭代当前数据库中的数据库键
  • SSCAN 命令用于迭代集合键中的元素
  • HSCAN 命令用于迭代哈希键中的键值对
  • ZSCAN 命令用于迭代有序集合中的元素(包括元素成员和元素分值)

SCAN
  • 命令格式:SCAN cursor [MATCH pattern] [COUNT count]
  • SCAN 命令是一个基于游标的迭代器(cursor based iterator): SCAN 命令每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程
  • SCAN 命令的游标参数被设置为 0 时, 服务器将开始一次新的迭代, 而当服务器向用户返回值为 0 的游标时, 表示迭代已结束
  • SSCAN / HSCAN /ZSCAN 与SCAN命令除命令格式有细微不同以及在非哈希表实现下的遍历方式不同外,其他均类似,不再赘述,具体请点击链接查询
  • 保证:从完整遍历开始直到完整遍历结束期间, 一直存在于数据集内的所有元素都会被完整遍历返回
  • 缺点:
    1)同一个元素可能会被返回多次,在rehash 缩小后遍历或者rehash缩小过程中遍历可能发生此情况(个人理解
    2)如果一个元素是在迭代过程中被添加到数据集的, 又或者是在迭代过程中从数据集中被删除的, 那么这个元素可能会被返回, 也可能不会, 这是不确定的

注:以上内容摘自http://redisdoc.com/key/scan.html


对于SCAN命令和底层采用了哈希表实现的集合、哈希、有序集合,遍历时采用了同样的scan算法(都会调用dictScan函数),dictScan函数短小精悍,正是本文尝试解释的核心,如下

unsigned long dictScan(dict *d,//待遍历哈希表
                       unsigned long v,//cursor值,此次遍历位置,初始为0
                       dictScanFunction *fn,//单个条目遍历函数,根据条目类型,copy条目对象,以便加入到返回对象中
                       dictScanBucketFunction* bucketfn,//null
                       void *privdata)//返回对象
{
    dictht *t0, *t1;
    const dictEntry *de, *next;
    unsigned long m0, m1;

    if (dictSize(d) == 0) return 0;//如果dict为空,直接返回

    if (!dictIsRehashing(d)) {//如果此刻哈希表没有在rehashing,只有ht[0]有数据
        t0 = &(d->ht[0]);//将ht[0]作为遍历表
        m0 = t0->sizemask;//遍历表的sizemask,即以遍历表的size为底取模,如表大小为8,则m0为111

        /* 遍历cursor所在位置的所有条目 */
        if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);//这行if条件为false,不会执行
        de = t0->table[v & m0];
        while (de) {//遍历当前cursor位置的所有条目,即hash key取模hash table大小相同的所有条目
            next = de->next;
            fn(privdata, de);
            de = next;
        }

        /* 作用是将v也就是cursor的高位置为1,低位不变,如v为001,则改为61个1再加001 */
        v |= ~m0;

        /* 将cursor高位0变成1或者(连续高位1都变成0且第一个0变为1) */
        v = rev(v);//将cursor做二进制逆序,也就是变成100+61个1
        v++;//末位加1,也就是101+61个0
        v = rev(v);//将cursor做二进制逆序,也就是61个0+101

    } else {//哈希表正在rehashing
        t0 = &d->ht[0];
        t1 = &d->ht[1];

        /* 确保t0小t1大 */
        if (t0->size > t1->size) {
            t0 = &d->ht[1];
            t1 = &d->ht[0];
        }

        m0 = t0->sizemask;//t0表sizemask,如表大小为8,则m0为7,即0111
        m1 = t1->sizemask;//t1表sizemask,如表大小为64,则m1为63,即00111111

        /* 将cursor位置的所有条目都添加进去 */
        if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);//不执行
        de = t0->table[v & m0];
        while (de) {//将t0的所有条目都加进去
            next = de->next;
            fn(privdata, de);
            de = next;
        }

        /* 遍历小表cursor位置可能会rehash到大表的所有条目,  
         *如cursor为1,小表大小为8,大表大小为64,则0、8、16、24、32、40、48、56等位置的条目都会被添加返回  
         */
        do {
            /* 添加大表v位置的所有元素,注意v位置跟着while循环不断变化 */
            if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);//不执行
            de = t1->table[v & m1];
            while (de) {//添加v位置的所有条目
                next = de->next;
                fn(privdata, de);
                de = next;
            }

            /* 作用同上,只不过换成了大表的元素,也就是小表cursor位置可能扩展到大表的所有位置*/
            v |= ~m1;
            v = rev(v);
            v++;
            v = rev(v);

            /* 如上举例,m0为3位1,m1为6位1,二者做异或,也就是将二者不同的高位置为1,  
             *其他前后的61位均为0,然后遍历v在二者不同高位的所有可能,  
             *当v重新回到0时,跳出while循环 ,也就是将m0可能rehash到的m1位置的条目全部返回  
             */
        } while (v & (m0 ^ m1));
    }

    return v;
}

这个算法非常精妙,看了挺久才明白点意思,如有不当之处,欢迎拍砖

哈希表有多种状态,遍历时有可能处于

  • 哈希扩展后
  • 收缩后
  • 正在rehashing(扩展or收缩)中

这就使得scan算法面临的情况很复杂,怎样遍历完所有元素(遍历过程中没有发生变化的元素保证遍历完)且尽可能少的返回重复元素是个难题
三种状态具体的遍历流程图示推演发个传送门:Redis Scan迭代器遍历操作原理(二)–dictScan反向二进制迭代器 (网上搜的,流程很长,慎点,但是有一些不错的图)

具体算法流程也可参见上面的源码注释


算法思想(把收缩看成反向的扩张)

1、假设hash表大小从N扩张为2^M x N(哈希表大小只可能为2的幂数,N也为2的幂数),那么原先hash表的i元素可能被分布到i + j x N where j <- [0, 2^M-1]位置,如N为4,M为3,则i(原先为1)可能被分散到1、1+1x4、1+2x4...、1+7x4位置,注意这些位置,它们后面的log(N)位是相同的,也就是前面的M位不同,如

  • 00001
  • 00101
  • 01001
  • ...
  • 11101

如果在扩展后遍历的过程中能将后面两位相同都为01的位置都忽略,也就是只要后面N位相同的遍历完了,意味着前面M位的所有可能性也都列举完了,即总是先把前面的可能性穷举完,再穷举后面的位,那么扩展后的slot(如1对应的1、5、9...、29)就不必重新再重新遍历一遍了,收缩是类似的,只不过收缩后的位置可能包含原哈希表高位尚未穷举完的可能性,需要再次遍历

2、怎么先遍历高位的可能性,dictScan给出了反向二进制迭代算法(总是先将最高位置取反,穷举高位的可能性,依次向低位推进,这种变换方式确保了所有元素都会被遍历到):

  • 将第一个遇到的高位0对应的位置置1(即变换前后二者拥有最多的从右向左连续相同低位,也就是模相同的范围最大),在该规则下,32大小哈希表,00001遍历后的下一个位置是10001,如果下次遍历10001时哈希表收缩成16大小,则会重新遍历0001位置(10001与16取模),00001和10001都收缩到了该位置,这种情况下元素可能重复返回;如果32扩展为64,则00001扩张为000001/100001两个位置,由于高位穷举的原则,则后续这些位置不会再次处理,降低了元素重复返回的概率
  • 或将前面的连续1置为0,第一个0置为1,如10001下一个是01001,即开始穷举下一个高位的可能性

3、rehashing这种情况,需要在遍历完小表cursor位置后将小表cursor位置可能rehash到的大表所有位置全部遍历一遍,然后再返回遍历元素和下一小表遍历位置

欢迎关注我的微信公众号
68号小喇叭
后端之路
Web note ad 1