深入理解Redis的scan命令

熟悉Redis的人都知道,它是單線程的。因此在使用一些時間復雜度為O(N)的命令時要非常謹慎。可能一不小心就會阻塞進程,導致Redis出現卡頓。

有時,我們需要針對符合條件的一部分命令進行操作,比如刪除以test_開頭的key。那么怎么獲取到這些key呢?在Redis2.8版本之前,我們可以使用keys命令按照正則匹配得到我們需要的key。但是這個命令有兩個缺點:

  1. 沒有limit,我們只能一次性獲取所有符合條件的key,如果結果有上百萬條,那么等待你的就是“無窮無盡”的字符串輸出。
  2. keys命令是遍歷算法,時間復雜度是O(N)。如我們剛才所說,這個命令非常容易導致Redis服務卡頓。因此,我們要盡量避免在生產環境使用該命令。

在滿足需求和存在造成Redis卡頓之間究竟要如何選擇呢?面對這個兩難的抉擇,Redis在2.8版本給我們提供了解決辦法——scan命令。

相比于keys命令,scan命令有兩個比較明顯的優勢:

  1. scan命令的時間復雜度雖然也是O(N),但它是分次進行的,不會阻塞線程。
  2. scan命令提供了limit參數,可以控制每次返回結果的最大條數。

這兩個優勢就幫助我們解決了上面的難題,不過scan命令也并不是完美的,它返回的結果有可能重復,因此需要客戶端去重。至于為什么會重復,相信你看完本文之后就會有答案了。

關于scan命令的基本用法,可以參看Redis命令詳解:Keys一文中關于SCAN命令的介紹。

今天我們主要從底層的結構和源碼的角度來討論scan是如何工作的。

Redis的結構

Redis使用了Hash表作為底層實現,原因不外乎高效且實現簡單。說到Hash表,很多Java程序員第一反應就是HashMap。沒錯,Redis底層key的存儲結構就是類似于HashMap那樣數組+鏈表的結構。其中第一維的數組大小為2n(n>=0)。每次擴容數組長度擴大一倍。

scan命令就是對這個一維數組進行遍歷。每次返回的游標值也都是這個數組的索引。limit參數表示遍歷多少個數組的元素,將這些元素下掛接的符合條件的結果都返回。因為每個元素下掛接的鏈表大小不同,所以每次返回的結果數量也就不同。

SCAN的遍歷順序

關于scan命令的遍歷順序,我們可以用一個小栗子來具體看一下。

127.0.0.1:6379> keys *
1) "db_number"
2) "key1"
3) "myKey"
127.0.0.1:6379> scan 0 MATCH * COUNT 1
1) "2"
2) 1) "db_number"
127.0.0.1:6379> scan 2 MATCH * COUNT 1
1) "1"
2) 1) "myKey"
127.0.0.1:6379> scan 1 MATCH * COUNT 1
1) "3"
2) 1) "key1"
127.0.0.1:6379> scan 3 MATCH * COUNT 1
1) "0"
2) (empty list or set)

我們的Redis中有3個key,我們每次只遍歷一個一維數組中的元素。如上所示,SCAN命令的遍歷順序是

0->2->1->3

這個順序看起來有些奇怪。我們把它轉換成二進制就好理解一些了。

00->10->01->11

我們發現每次這個序列是高位加1的。普通二進制的加法,是從右往左相加、進位。而這個序列是從左往右相加、進位的。這一點我們在redis的源碼中也得到印證。

在dict.c文件的dictScan函數中對游標進行了如下處理

v = rev(v);
v++;
v = rev(v);

意思是,將游標倒置,加一后,再倒置,也就是我們所說的“高位加1”的操作。

這里大家可能會有疑問了,為什么要使用這樣的順序進行遍歷,而不是用正常的0、1、2……這樣的順序呢,這是因為需要考慮遍歷時發生字典擴容與縮容的情況(不得不佩服開發者考慮問題的全面性)。

我們來看一下在SCAN遍歷過程中,發生擴容時,遍歷會如何進行。加入我們原始的數組有4個元素,也就是索引有兩位,這時需要把它擴充成3位,并進行rehash。

rehash

原來掛接在xx下的所有元素被分配到0xx和1xx下。在上圖中,當我們即將遍歷10時,dict進行了rehash,這時,scan命令會從010開始遍歷,而000和100(原00下掛接的元素)不會再被重復遍歷。

再來看看縮容的情況。假設dict從3位縮容到2位,當即將遍歷110時,dict發生了縮容,這時scan會遍歷10。這時010下掛接的元素會被重復遍歷,但010之前的元素都不會被重復遍歷了。所以,縮容時還是可能會有些重復元素出現的。

Redis的rehash

rehash是一個比較復雜的過程,為了不阻塞Redis的進程,它采用了一種漸進式的rehash的機制。

/* 字典 */
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的字典結構中,有兩個hash表,一個新表,一個舊表。在rehash的過程中,redis將舊表中的元素逐步遷移到新表中,接下來我們看一下dict的rehash操作的源碼。

/* Performs N steps of incremental rehashing. Returns 1 if there are still
 * keys to move from the old to the new hash table, otherwise 0 is returned.
 *
 * Note that a rehashing step consists in moving a bucket (that may have more
 * than one key as we use chaining) from the old to the new hash table, however
 * since part of the hash table may be composed of empty spaces, it is not
 * guaranteed that this function will rehash even a single bucket, since it
 * will visit at max N*10 empty buckets in total, otherwise the amount of
 * work it does would be unbound and the function may block for a long time. */
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht[0].table[d->rehashidx];
        /* Move all the keys in this bucket from the old to the new hash HT */
        while(de) {
            uint64_t h;

            nextde = de->next;
            /* Get the index in the new hash table */
            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;
        d->rehashidx++;
    }

    /* Check if we already rehashed the whole table... */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }

    /* More to rehash... */
    return 1;
}

通過注釋我們就能了解到,rehash的過程是以bucket為基本單位進行遷移的。所謂的bucket其實就是我們前面所提到的一維數組的元素。每次遷移一個列表。下面來解釋一下這段代碼。

  • 首先判斷一下是否在進行rehash,如果是,則繼續進行;否則直接返回。
  • 接著就是分n步開始進行漸進式rehash。同時還判斷是否還有剩余元素,以保證安全性。
  • 在進行rehash之前,首先判斷要遷移的bucket是否越界。
  • 然后跳過空的bucket,這里有一個empty_visits變量,表示最大可訪問的空bucket的數量,這一變量主要是為了保證不過多的阻塞Redis。
  • 接下來就是元素的遷移,將當前bucket的全部元素進行rehash,并且更新兩張表中元素的數量。
  • 每次遷移完一個bucket,需要將舊表中的bucket指向NULL。
  • 最后判斷一下是否全部遷移完成,如果是,則收回空間,重置rehash索引,否則告訴調用方,仍有數據未遷移。

由于Redis使用的是漸進式rehash機制,因此,scan命令在需要同時掃描新表和舊表,將結果返回客戶端。


歡迎關注我的個人公眾號:代碼潔癖患者


wechat-qcode.jpg
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內容