Redis Scan演算法設計思想

68號小喇叭發表於2019-03-01

網圖侵刪.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號小喇叭

相關文章