Redis中遍歷大數據量的key:keys與scan命令


 

keys命令

keys * 、keys id:* 分別是查詢全部的key以及查詢前綴為id:的key。

缺點:

1、沒有 offset、limit 參數,一次返回所有滿足條件的 key。

2.keys算法是遍歷算法,復雜度是O(n),也就是數據越多,時間復雜度越高。

3.數據量達到幾百萬,keys這個指令就會導致 Redis 服務卡頓,因為 Redis 是單線程程序,順序執行所有指令,其它指令必須等到當前的 keys 指令執行完了才可以繼續。

 

scan命令

那我們如何去遍歷大數據量呢?我們可以采用redis的另一個命令scan。我們看一下scan的特點

  • 復雜度雖然也是 O(n),但是它是通過游標分步進行的,不會阻塞線程

  • 提供 count 參數,不是結果數量,是redis單次遍歷字典槽位數量(約等於)

  • 同 keys 一樣,它也提供模式匹配功能;

  • 服務器不需要為游標保存狀態,游標的唯一狀態就是 scan 返回給客戶端的游標整數;

  • 返回的結果可能會有重復,需要客戶端去重復,這點非常重要;

  • 單次返回的結果是空的並不意味着遍歷結束,而要看返回的游標值是否為零

scan命令格式

SCAN cursor [MATCH pattern] [COUNT count]

命令解釋:scan 游標 MATCH <返回和給定模式相匹配的元素> count 每次迭代所返回的元素數量

SCAN命令是增量的循環,每次調用只會返回一小部分的元素。所以不會讓redis假死。

SCAN命令返回的是一個游標,從0開始遍歷,到0結束遍歷。

redis > scan 0 match user_token* count 5 
 1) "6"
 2) 1) "user_token:1000"
 2) "user_token:1001"
 3) "user_token:1010"
 4) "user_token:2300"
 5) "user_token:1389"

從0開始遍歷,返回了游標6,又返回了數據,繼續scan遍歷,就要從6開始

redis > scan 6 match user_token* count 5 
 1) "10"
 2) 1) "user_token:3100"
 2) "user_token:1201"
 3) "user_token:1410"
 4) "user_token:5300"
 5) "user_token:3389"


Redis的結構

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

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

SCAN的遍歷順序

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

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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函數中對游標進行了如下處理

 
1
2
3
v = rev(v);
v++;
v = rev(v);
 

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 字典 */
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操作的源碼。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* 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命令在需要同時掃描新表和舊表,將結果返回客戶端。


refer:

如何正確訪問Redis中的海量數據?服務才不會掛掉!

Redis中scan命令的深入講解








免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM