Redis BigKey介紹

一、什麼是bigkey

在Redis中,一個字符串最大512MB,一個二級數據結構(例如hash、list、set、zset)可以存儲大約40億個(2^32-1)個元素,但實際上中如果下面兩種情況,我就會認爲它是bigkey。

  • 字符串類型:它的big體現在單個value值很大,一般認爲超過10KB就是bigkey。
  • 非字符串類型:哈希、列表、集合、有序集合,它們的big體現在元素個數太多。

二、危害

bigkey可以說就是Redis的老鼠屎,具體表現在:

1.內存空間不均勻

這樣會不利於集羣對內存的統一管理,存在丟失數據的隱患。

2.超時阻塞

由於Redis單線程的特性,操作bigkey的通常比較耗時,也就意味着阻塞Redis可能性越大,這樣會造成客戶端阻塞或者引起故障切換,它們通常出現在慢查詢中。

例如,在Redis發現了這樣的key,你就等着DBA找你吧。


 
  1.  
    127.0.0.1:6379> hlen big:hash(integer)
  2.  
    2000000127.0.0.1:6379> hgetall big:hash
  3.  
    1) "a"
  4.  
    2) "1"

3.網絡擁塞

bigkey也就意味着每次獲取要產生的網絡流量較大,假設一個bigkey爲1MB,客戶端每秒訪問量爲1000,那麼每秒產生1000MB的流量,對於普通的千兆網卡(按照字節算是128MB/s)的服務器來說簡直是滅頂之災,而且一般服務器會採用單機多實例的方式來部署,也就是說一個bigkey可能會對其他實例造成影響,其後果不堪設想。

4.過期刪除

有個bigkey,它安分守己(只執行簡單的命令,例如hget、lpop、zscore等),但它設置了過期時間,當它過期後,會被刪除,如果沒有使用Redis 4.0的過期異步刪除(lazyfree-lazy-expire yes),就會存在阻塞Redis的可能性,而且這個過期刪除不會從主節點的慢查詢發現(因爲這個刪除不是客戶端產生的,是內部循環事件,可以從latency命令中獲取或者從slave節點慢查詢發現)。

5.遷移困難

當需要對bigkey進行遷移(例如Redis cluster的遷移slot),實際上是通過migrate命令來完成的,migrate實際上是通過dump + restore + del三個命令組合成原子命令完成,如果是bigkey,可能會使遷移失敗,而且較慢的migrate會阻塞Redis。

三、怎麼產生的?

一般來說,bigkey的產生都是由於程序設計不當,或者對於數據規模預料不清楚造成的,來看幾個:

(1) 社交類:粉絲列表,如果某些明星或者大v不精心設計下,必是bigkey。

(2) 統計類:例如按天存儲某項功能或者網站的用戶集合,除非沒幾個人用,否則必是bigkey。

(3) 緩存類:將數據從數據庫load出來序列化放到Redis裏,這個方式非常常用,但有兩個地方需要注意:

  • 第一,是不是有必要把所有字段都緩存
  • 第二,有沒有相關關聯的數據

例如遇到過一個例子,該同學將某明星一個專輯下所有視頻信息都緩存一個巨大的json中,造成這個json達到6MB,後來這個明星發了一個官宣

四、如何發現

1. redis-cli --bigkeys

redis-cli提供了--bigkeys來查找bigkey,例如下面就是一次執行結果:


 
  1.  
    -------- summary -------
  2.  
    Biggest string found 'user:1' has 5 bytes
  3.  
    Biggest list found 'taskflow:175448' has 97478 items
  4.  
    Biggest set found 'redisServerSelect:set:11597' has 49 members
  5.  
    Biggest hash found 'loginUser:t:20180905' has 863 fields
  6.  
    Biggest zset found 'hotkey:scan:instance:zset' has 3431 members
  7.  
    40 strings with 200 bytes (00.00% of keys, avg size 5.00)
  8.  
    2747619 lists with 14680289 items (99.86% of keys, avg size 5.34)
  9.  
    2855 sets with 10305 members (00.10% of keys, avg size 3.61)
  10.  
    13 hashs with 2433 fields (00.00% of keys, avg size 187.15)
  11.  
    830 zsets with 14098 members (00.03% of keys, avg size 16.99)

可以看到--bigkeys給出了每種數據結構的top 1 bigkey,同時給出了每種數據類型的鍵值個數以及平均大小。

bigkeys對問題的排查非常方便,但是在使用它時候也有幾點需要注意:

  • 建議在從節點執行,因爲--bigkeys也是通過scan完成的。
  • 建議在節點本機執行,這樣可以減少網絡開銷。
  • 如果沒有從節點,可以使用--i參數,例如(--i 0.1 代表100毫秒執行一次)
  • --bigkeys只能計算每種數據結構的top1,如果有些數據結構非常多的bigkey,也搞不定,畢竟不是自己寫的東西嘛
  • debug object

再來看一個場景:

你好,麻煩幫我查一下Redis裏大於10KB的所有key

您好,幫忙查一下Redis中長度大於5000的hash key

是不是發現用--bigkeys不行了(當然如果改源碼也不是太難),但有沒有更快捷的方法,Redis提供了debug object ${key}命令獲取鍵值的相關信息:


 
  1.  
    127.0.0.1:6379> hlen big:hash
  2.  
    (integer) 5000000
  3.  
    127.0.0.1:6379> debug object big:hash
  4.  
    Value at:0x7fda95b0cb20 refcount:1 encoding:hashtable serializedlength:87777785 lru:9625559 lru_seconds_idle:2
  5.  
    (1.08s)

其中serializedlength表示key對應的value序列化之後的字節數,當然如果是字符串類型,完全看可以執行strlen,例如:


 
  1.  
    127.0.0.1:6379> strlen key
  2.  
    (integer) 947394

這樣你就可以用scan + debug object的方式遍歷Redis所有的鍵值,找到你需要閾值的數據了。

但是在使用debug object時候一定要注意以下幾點:

  • debug object bigkey本身可能就會比較慢,它本身就會存在阻塞Redis的可能
  • 建議在從節點執行
  • 建議在節點本地執行
  • 如果不關係具體字節數,完全可以使用scan + strlen|hlen|llen|scard|zcard替代,他們都是o(1)

3. memory usage

上面的debug object可能會比較危險、而且不太準確(序列化後的長度),有沒有更準確的呢?Redis 4.0開始提供memory usage命令可以計算每個鍵值的字節數(自身、以及相關指針開銷,具體的細節可查閱相關文章),例如下面是一次執行結果:


 
  1.  
    127.0.0.1:6379> memory usage big:hash
  2.  
    (integer) 318663444

下面我們來對比就可以看出來,當前系統就一個key,總內存消耗是400MB左右,memory usage相比debug object還是要精確一些的。


 
  1.  
    127.0.0.1:6379> dbsize
  2.  
    (integer) 1
  3.  
    127.0.0.1:6379> hlen big:hash
  4.  
    (integer) 5000000
  5.  
    #約300MB
  6.  
    127.0.0.1:6379> memory usage big:hash
  7.  
    (integer) 318663444
  8.  
    #約85MB
  9.  
    127.0.0.1:6379> debug object big:hash
  10.  
    Value at:0x7fda95b0cb20 refcount:1 encoding:hashtable serializedlength:87777785 lru:9625814 lru_seconds_idle:9
  11.  
    (1.06s)
  12.  
    127.0.0.1:6379> info memory
  13.  
    # Memory
  14.  
    used_memory_human:402.16M

如果你使用Redis 4.0+,你就可以用scan + memory usage(pipeline)了,而且很好的一點是,memory不會執行很慢,當然依然是建議從節點 + 本地 。

4. 客戶端

上面三種方式都有一個問題,就是馬後炮,如果想很實時的找到bigkey,一方面你可以試試修改Redis源碼,還有一種方式就是可以修改客戶端,以jedis爲例,可以在關鍵的出入口加上對應的檢測機制,例如以Jedis的獲取結果爲例子:


 
  1.  
    protected Object readProtocolWithCheckingBroken() {
  2.  
    Object o = null;
  3.  
    try {
  4.  
    o = Protocol.read(inputStream); return o;
  5.  
    }catch(JedisConnectionException exc) {
  6.  
    UsefulDataCollector.collectException(exc, getHostPort(), System.currentTimeMillis()); broken = true;
  7.  
    throw exc;
  8.  
    }finally {
  9.  
    if(o != null) {
  10.  
    if(o instanceof byte[]) {
  11.  
    byte[] bytes = (byte[]) o;
  12.  
    if (bytes.length > threshold) {
  13.  
    // 做很多事情,例如用ELK完成收集和展示
  14.  
    }
  15.  
    }
  16.  
    }
  17.  
    }
  18.  
    }

5. 監控報警

bigkey的大操作,通常會引起客戶端輸入或者輸出緩衝區的異常,Redis提供了info clients裏面包含的客戶端輸入緩衝區的字節數以及輸出緩衝區的隊列長度,可以重點關注下:

如果想知道具體的客戶端,可以使用client list命令來查找


 
  1.  
    redis-cli client list
  2.  
    id=3 addr=127.0.0.1:58500 fd=8 name= age=3978 idle=25 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=26263554 events=r cmd=hgetall

6. 改源碼

這個其實也是能做的,但是各方面成本比較高,對於一般公司來說不適用。

建議的最佳實踐:

  • Redis端與客戶端相結合:--bigkeys臨時用、scan長期做排除隱患(儘可能本地化)、客戶端實時監控。
  • 監控報警要跟上
  • debug object儘量少用
  • 所有數據平臺化
  • 要和開發同學強調bigkey的危害

五、如何刪除

如果發現了bigkey,而且確認是垃圾是不是直接del就可以了,來看一組數據:

可以看到對於string類型,刪除速度還是可以接受的。但對於二級數據結構,隨着元素個數的增長以及每個元素字節數的增大,刪除速度會越來越慢,存在阻塞Redis的隱患。所以在刪除它們時候建議採用漸進式的方式來完成:hscan、ltrim、sscan、zscan。

如果你使用Redis 4.0+,一條異步刪除unlink就解決,就可以忽略下面內容。

1. 字符串

一般來說,對於string類型使用del命令不會產生阻塞。

del bigkey

2. hash

使用hscan命令,每次獲取部分(例如100個)field-value,在利用hdel刪除每個field(爲了快速可以使用pipeline)。


 
  1.  
    public void delBigHash(String bigKey) {
  2.  
    Jedis jedis = new Jedis("127.0.0.1", 6379);
  3.  
    // 遊標
  4.  
    String cursor = "0";
  5.  
    while(true) {
  6.  
    ScanResult<Map.Entry<String, String>> scanResult = jedis.hscan(bigKey, cursor, new ScanParams().count(100));
  7.  
    // 每次掃描後獲取新的遊標
  8.  
    cursor = scanResult.getStringCursor(); // 獲取掃描結果
  9.  
    List<Entry<String, String>> list = scanResult.getResult(); if(list == null || list.size() == 0) {
  10.  
    continue; } String[] fields = getFieldsFrom(list); // 刪除多個field
  11.  
    jedis.hdel(bigKey, fields); // 遊標爲0時停止
  12.  
    if(cursor.equals("0")) {
  13.  
    break;
  14.  
    } } // 最終刪除key
  15.  
    jedis.del(bigKey);
  16.  
    }
  17.  
    /**
  18.  
    * 獲取field數組 */
  19.  
    private String[] getFieldsFrom(List<Entry<String, String>> list) {
  20.  
    List<String> fields = new ArrayList<String>();
  21.  
    for (Entry<String, String> entry : list) {
  22.  
    fields.add(entry.getKey());
  23.  
    }
  24.  
    return fields.toArray(new String[fields.size()]);
  25.  
    }

3. list

Redis並沒有提供lscan這樣的API來遍歷列表類型,但是提供了ltrim這樣的命令可以漸進式的刪除列表元素,直到把列表刪除。


 
  1.  
    public void delBigList(String bigKey) {
  2.  
    Jedis jedis = new Jedis("127.0.0.1", 6379);
  3.  
    long llen = jedis.llen(bigKey);
  4.  
    int counter = 0;
  5.  
    int left = 100;
  6.  
    while(counter < llen) {
  7.  
    // 每次從左側截掉100個
  8.  
    jedis.ltrim(bigKey, left, llen);
  9.  
    counter += left;
  10.  
    }
  11.  
    // 最終刪除key
  12.  
    jedis.del(bigKey);
  13.  
    }

4. set

使用sscan命令,每次獲取部分(例如100個)元素,在利用srem刪除每個元素。


 
  1.  
    public void delBigSet(String bigKey) {
  2.  
    Jedis jedis = new Jedis("127.0.0.1", 6379);
  3.  
    // 遊標
  4.  
    String cursor = "0";
  5.  
    while(true) {
  6.  
    ScanResult<String> scanResult = jedis.sscan(bigKey, cursor, new ScanParams().count(100));
  7.  
    // 每次掃描後獲取新的遊標
  8.  
    cursor = scanResult.getStringCursor(); // 獲取掃描結果
  9.  
    List<String> list = scanResult.getResult(); if(list == null || list.size() == 0) {
  10.  
    continue;
  11.  
    } jedis.srem(bigKey, list.toArray(new String[list.size()]));
  12.  
    // 遊標爲0時停止
  13.  
    if(cursor.equals("0")) {
  14.  
    break;
  15.  
    } } // 最終刪除key
  16.  
    jedis.del(bigKey);}

5. sorted set

使用zscan命令,每次獲取部分(例如100個)元素,在利用zremrangebyrank刪除元素。


 
  1.  
    public void delBigSortedSet(String bigKey) {
  2.  
    long startTime = System.currentTimeMillis(); Jedis jedis = new Jedis(HOST, PORT); // 遊標
  3.  
    String cursor = "0";
  4.  
    while(true) {
  5.  
    ScanResult<Tuple> scanResult = jedis.zscan(bigKey, cursor, new ScanParams().count(100));
  6.  
    // 每次掃描後獲取新的遊標
  7.  
    cursor = scanResult.getStringCursor(); // 獲取掃描結果
  8.  
    List<Tuple> list = scanResult.getResult(); if(list == null || list.size() == 0) {
  9.  
    continue; } String[] members = getMembers(list); jedis.zrem(bigKey, members); // 遊標爲0時停止
  10.  
    if(cursor.equals("0")) {
  11.  
    break;
  12.  
    } } // 最終刪除key
  13.  
    jedis.del(bigKey);
  14.  
    }
  15.  
    public void delBigSortedSet2(String bigKey) {
  16.  
    Jedis jedis = new Jedis(HOST, PORT);
  17.  
    long zcard = jedis.zcard(bigKey);
  18.  
    int counter = 0;
  19.  
    int incr = 100;
  20.  
    while(counter < zcard) {
  21.  
    jedis.zremrangeByRank(bigKey, 0, 100);
  22.  
    // 每次從左側截掉100個
  23.  
    counter += incr;
  24.  
    }
  25.  
    // 最終刪除key
  26.  
    jedis.del(bigKey);
  27.  
    }

六、如何優化

1.拆分

big list: list1、list2、...listN

big hash:可以做二次的hash,例如hash%100

日期類:key20190320、key20190321、key_20190322。

2.本地緩存

減少訪問redis次數,降低危害,但是要注意這裏有可能因此本地的一些開銷(例如使用堆外內存會涉及序列化,bigkey對序列化的開銷也不小)

7、總結:

由於開發人員對Redis的理解程度不同,在實際開發中出現bigkey在所難免,重要的能通過合理的檢測機制及時找到它們,進行處理。作爲開發人員應該在業務開發時不能將Redis簡單暴力的使用,應該在數據結構的選擇和設計上更加合理,例如出現了bigkey,要思考一下可不可以做一些優化(例如二級索引)儘量的讓這些bigkey消失在業務中,如果bigkey不可避免,也要思考一下要不要每次把所有元素都取出來(例如有時候僅僅需要hmget,而不是hgetall),刪除也是一樣,儘量使用優雅的方式來處理。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章