在平時線上 Redis 維護工作中,有時候需要從 Redis 實例成千上萬的 key 中找出特定前綴的 key 列表來手動處理數據,可能是修改它的值,也可能是刪除 key。這里就有一個問題,如何從海量的 key 中找出滿足特定前綴的 key 列表來?
Redis 提供了一個簡單暴力的指令 keys
用來列出所有滿足特定正則字符串規則的 key。
127.0.0.1:6379> set codehole1 a
OK
127.0.0.1:6379> set codehole2 b
OK
127.0.0.1:6379> set codehole3 c
OK
127.0.0.1:6379> set code1hole a
OK
127.0.0.1:6379> set code2hole b
OK
127.0.0.1:6379> set code3hole b
OK
127.0.0.1:6379> keys *
1) "codehole1"
2) "code3hole"
3) "codehole3"
4) "code2hole"
5) "codehole2"
6) "code1hole"
127.0.0.1:6379> keys codehole*
1) "codehole1"
2) "codehole3"
3) "codehole2"
127.0.0.1:6379> keys code*hole
1) "code3hole"
2) "code2hole"
3) "code1hole"
這個指令使用非常簡單,提供一個簡單的正則字符串即可,但是有很明顯的兩個缺點。
- 沒有 offset、limit 參數,一次性吐出所有滿足條件的 key,萬一實例中有幾百 w 個 key 滿足條件,當你看到滿屏的字符串刷的沒有盡頭時,你就知道難受了。
- keys 算法是遍歷算法,復雜度是 O(n),如果實例中有千萬級以上的 key,這個指令就會導致 Redis 服務卡頓,所有讀寫 Redis 的其它的指令都會被延后甚至會超時報錯,因為 Redis 是單線程程序,順序執行所有指令,其它指令必須等到當前的 keys 指令執行完了才可以繼續。
面對這兩個顯著的缺點該怎么辦呢?
Redis 為了解決這個問題,它在 2.8 版本中加入了大海撈針的指令——scan
。scan
相比 keys
具備有以下特點:
- 復雜度雖然也是 O(n),但是它是通過游標分步進行的,不會阻塞線程;
- 提供 limit 參數,可以控制每次返回結果的最大條數,limit 只是一個 hint,返回的結果可多可少;
- 同 keys 一樣,它也提供模式匹配功能;
- 服務器不需要為游標保存狀態,游標的唯一狀態就是 scan 返回給客戶端的游標整數;
- 返回的結果可能會有重復,需要客戶端去重復,這點非常重要;
- 遍歷的過程中如果有數據修改,改動后的數據能不能遍歷到是不確定的;
- 單次返回的結果是空的并不意味著遍歷結束,而要看返回的游標值是否為零;
scan 基礎使用
在使用之前,讓我們往 Redis 里插入 10000 條數據來進行測試
import redis
client = redis.StrictRedis()
for i in range(10000):
client.set("key%d" % i, i)
好,Redis 中現在有了 10000 條數據,接下來我們找出以 key99 開頭 key 列表。
scan 參數提供了三個參數,第一個是 cursor 整數值
,第二個是 key 的正則模式
,第三個是遍歷的 limit hint
。第一次遍歷時,cursor 值為 0,然后將返回結果中第一個整數值作為下一次遍歷的 cursor。一直遍歷到返回的 cursor 值為 0 時結束。
127.0.0.1:6379> scan 0 match key99* count 1000
1) "13976"
2) 1) "key9911"
2) "key9974"
3) "key9994"
4) "key9910"
5) "key9907"
6) "key9989"
7) "key9971"
8) "key99"
9) "key9966"
10) "key992"
11) "key9903"
12) "key9905"
127.0.0.1:6379> scan 13976 match key99* count 1000
1) "1996"
2) 1) "key9982"
2) "key9997"
3) "key9963"
4) "key996"
5) "key9912"
6) "key9999"
7) "key9921"
8) "key994"
9) "key9956"
10) "key9919"
127.0.0.1:6379> scan 1996 match key99* count 1000
1) "12594"
2) 1) "key9939"
2) "key9941"
3) "key9967"
4) "key9938"
5) "key9906"
6) "key999"
7) "key9909"
8) "key9933"
9) "key9992"
......
127.0.0.1:6379> scan 11687 match key99* count 1000
1) "0"
2) 1) "key9969"
2) "key998"
3) "key9986"
4) "key9968"
5) "key9965"
6) "key9990"
7) "key9915"
8) "key9928"
9) "key9908"
10) "key9929"
11) "key9944"
從上面的過程可以看到雖然提供的 limit 是 1000,但是返回的結果只有 10 個左右。因為這個 limit 不是限定返回結果的數量,而是限定服務器單次遍歷的字典槽位數量(約等于)。如果將 limit 設置為 10,你會發現返回結果是空的,但是游標值不為零,意味著遍歷還沒結束。
127.0.0.1:6379> scan 0 match key99* count 10
1) "3072"
2) (empty list or set)
字典的結構
在 Redis 中所有的 key 都存儲在一個很大的字典中,這個字典的結構和 Java 中的 HashMap 一樣,是一維數組 + 二維鏈表結構,第一維數組的大小總是 2^n(n>=0),擴容一次數組大小空間加倍,也就是 n++。
scan 指令返回的游標就是第一維數組的位置索引,我們將這個位置索引稱為槽 (slot)。如果不考慮字典的擴容縮容,直接按數組下標挨個遍歷就行了。limit 參數就表示需要遍歷的槽位數,之所以返回的結果可能多可能少,是因為不是所有的槽位上都會掛接鏈表,有些槽位可能是空的,還有些槽位上掛接的鏈表上的元素可能會有多個。每一次遍歷都會將 limit 數量的槽位上掛接的所有鏈表元素進行模式匹配過濾后,一次性返回給客戶端。
scan 遍歷順序
scan 的遍歷順序非常特別。它不是從第一維數組的第 0 位一直遍歷到末尾,而是采用了高位進位加法來遍歷。之所以使用這樣特殊的方式進行遍歷,是考慮到字典的擴容和縮容時避免槽位的遍歷重復和遺漏。
首先我們用動畫演示一下普通加法和高位進位加法的區別。
從動畫中可以看出高位進位法從左邊加,進位往右邊移動,同普通加法正好相反。但是最終它們都會遍歷所有的槽位并且沒有重復。
字典擴容
Java 中的 HashMap 有擴容的概念,當 loadFactor 達到閾值時,需要重新分配一個新的 2 倍大小的數組,然后將所有的元素全部 rehash 掛到新的數組下面。rehash 就是將元素的 hash 值對數組長度進行取模運算,因為長度變了,所以每個元素掛接的槽位可能也發生了變化。又因為數組的長度是 2^n 次方,所以取模運算等價于位與操作。
a mod 8 = a & (8-1) = a & 7
a mod 16 = a & (16-1) = a & 15
a mod 32 = a & (32-1) = a & 31
這里的 7, 15, 31 稱之為字典的 mask 值,mask 的作用就是保留 hash 值的低位,高位都被設置為 0。
接下來我們看看 rehash 前后元素槽位的變化。
假設當前的字典的數組長度由 8 位擴容到 16 位,那么 3 號槽位 011 將會被 rehash 到 3 號槽位和 11 號槽位,也就是說該槽位鏈表中大約有一半的元素還是 3 號槽位,其它的元素會放到 11 號槽位,11 這個數字的二進制是 1011,就是對 3 的二進制 011 增加了一個高位 1。
抽象一點說,假設開始槽位的二進制數是 xxx,那么該槽位中的元素將被 rehash 到 0xxx 和 1xxx(xxx+8) 中。 如果字典長度由 16 位擴容到 32 位,那么對于二進制槽位 xxxx 中的元素將被 rehash 到 0xxxx 和 1xxxx(xxxx+16) 中。
對比擴容縮容前后的遍歷順序
觀察這張圖,我們發現采用高位進位加法的遍歷順序,rehash 后的槽位在遍歷順序上是相鄰的。
假設當前要即將遍歷 110 這個位置 (橙色),那么擴容后,當前槽位上所有的元素對應的新槽位是 0110 和 1110(深綠色),也就是在槽位的二進制數增加一個高位 0 或 1。這時我們可以直接從 0110 這個槽位開始往后繼續遍歷,0110 槽位之前的所有槽位都是已經遍歷過的,這樣就可以避免擴容后對已經遍歷過的槽位進行重復遍歷。
再考慮縮容,假設當前即將遍歷 110 這個位置 (橙色),那么縮容后,當前槽位所有的元素對應的新槽位是 10(深綠色),也就是去掉槽位二進制最高位。這時我們可以直接從 10 這個槽位繼續往后遍歷,10 槽位之前的所有槽位都是已經遍歷過的,這樣就可以避免縮容的重復遍歷。不過縮容還是不太一樣,它會對圖中 010 這個槽位上的元素進行重復遍歷,因為縮融后 10 槽位的元素是 010 和 110 上掛接的元素的融合。
漸進式 rehash
Java 的 HashMap 在擴容時會一次性將舊數組下掛接的元素全部轉移到新數組下面。如果 HashMap 中元素特別多,線程就會出現卡頓現象。Redis 為了解決這個問題,它采用漸進式 rehash。
它會同時保留舊數組和新數組,然后在定時任務中以及后續對 hash 的指令操作中漸漸地將舊數組中掛接的元素遷移到新數組上。這意味著要操作處于 rehash 中的字典,需要同時訪問新舊兩個數組結構。如果在舊數組下面找不到元素,還需要去新數組下面去尋找。
scan 也需要考慮這個問題,對與 rehash 中的字典,它需要同時掃描新舊槽位,然后將結果融合后返回給客戶端。
更多的 scan 指令
scan 指令是一系列指令,除了可以遍歷所有的 key 之外,還可以對指定的容器集合進行遍歷。比如 zscan 遍歷 zset 集合元素,hscan 遍歷 hash 字典的元素、sscan 遍歷 set 集合的元素。
它們的原理同 scan 都會類似的,因為 hash 底層就是字典,set 也是一個特殊的 hash(所有的 value 指向同一個元素),zset 內部也使用了字典來存儲所有的元素內容,所以這里不再贅述。
大 key 掃描
有時候會因為業務人員使用不當,在 Redis 實例中會形成很大的對象,比如一個很大的 hash,一個很大的 zset 這都是經常出現的。這樣的對象對 Redis 的集群數據遷移帶來了很大的問題,因為在集群環境下,如果某個 key 太大,會數據導致遷移卡頓。另外在內存分配上,如果一個 key 太大,那么當它需要擴容時,會一次性申請更大的一塊內存,這也會導致卡頓。如果這個大 key 被刪除,內存會一次性回收,卡頓現象會再一次產生。
在平時的業務開發中,要盡量避免大 key 的產生。
如果你觀察到 Redis 的內存大起大落,這極有可能是因為大 key 導致的,這時候你就需要定位出具體是那個 key,進一步定位出具體的業務來源,然后再改進相關業務代碼設計。
那如何定位大 key 呢?
為了避免對線上 Redis 帶來卡頓,這就要用到 scan 指令,對于掃描出來的每一個 key,使用 type 指令獲得 key 的類型,然后使用相應數據結構的 size 或者 len 方法來得到它的大小,對于每一種類型,保留大小的前 N 名作為掃描結果展示出來。
上面這樣的過程需要編寫腳本,比較繁瑣,不過 Redis 官方已經在 redis-cli 指令中提供了這樣的掃描功能,我們可以直接拿來即用。
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys
如果你擔心這個指令會大幅抬升 Redis 的 ops 導致線上報警,還可以增加一個休眠參數。
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1
上面這個指令每隔 100 條 scan 指令就會休眠 0.1s,ops 就不會劇烈抬升,但是掃描的時間會變長。