技術抽絲剝繭|為什麼 Redis 內部使用不同編碼?

資料庫工作筆記發表於2023-11-13

來源:滴滴技術

個週末的晚上突然收到一波耗時上升報警,仔細一看報警訊息,原來是出現了慢查請求導致叢集耗時大幅上升,此時業務同學也收到上游服務受影響報警。在處理問題過程中,運維同學發現 Redis 叢集中只有部分例項出現 cpu 利用率上升,慢查日誌也集中在這幾個例項,而上游業務此時沒有上線或是業務模型變化。因為是少量熱 key 訪問導致部分 Redis 例項負載高,執行限流對業務有損,執行擴容也無法達到快速止損的目標,幸好業務側有提前制定的降級預案,快速溝通後採用了業務側降級方案進行了止損。

案例回顧

雖然故障透過業務側降級預案及時止損, 但作為 Redis 服務提供方,進行復盤弄清問題根因,制定預案是必須要做的工作,這樣才能避免問題再次發生或是發生時更快止損。透過了解業務場景,發現是因為城市A大雨,排隊訂單上升3倍。咦?這個情景有點熟悉,以前也是這個城市在大雨時出過這個問題,這也是為什麼業務側會有一個降級預案存在。瞭解背景後,接下來就要接受業務同學們的靈魂拷問了。

  • 為什麼昨天同一時間段,該叢集 QPS 更高但是沒有出現類似問題?

  • 這個叢集儲存了多個城市的資料,為什麼只有城市A這個城市下大雨會出問題,而別的城市沒有問題,難道是別的城市不下大雨?或是大家不用排隊功能?

 

答案顯然不是,一定還有技術層面的問題沒有搞明白,接下來帶大家回顧當時我們的排查問題思路。

 

首先從觀察故障期間的一些現象入手,我們發現如下幾個現象:

  1. 當時出現了5個熱 key,都屬於同一個城市A,該城市因為當天晚上大雨導致排隊訂單量超過平時3倍;

  2. 這5個熱 key 都是 hash 資料結構,裡面儲存的每個 key 有400多個元素;

  3. 同樣的業務邏輯,另外一個城市B,也是5個 hash 表,QPS 比城市A這個城市高,key 中的元素數量也比城市A要高,但是城市B反而並沒有出現耗時高的問題;

  4. 在出現問題的時刻,這5個熱 key 上大部分命令都是 HEXISTS(作用是檢視某個 field 是否在 hash 表中),此命令是O(1)複雜度。

 

此時腦中靈光一現,在檢視熱 key 的 meta 資訊時,發現城市A的這5個 key 使用的內部編碼是 ziplist,此時再對比一下城市B的5個 key,發現使用的 hashtable 編碼,這個發現讓我們似乎看到解決問題的曙光,這是目前發現的城市A和城市B最大的不同之處,後續的分析也證明了這個方向是正確的。

雜湊(hash)物件的兩種內部編碼對比

為什麼 Redis 的 hash 資料結構會採用兩種不同的編碼方式,不同編碼方式又會帶來什麼樣的不同後果呢?


Redis 為什麼內部使用不同編碼?

Redis 使用物件來表示資料庫中的鍵和值, 每次當我們在 Redis 的資料庫中新建立一個鍵值對時, 我們至少會建立兩個物件, 一個物件用作鍵值對的鍵(鍵物件), 另一個物件用作鍵值對的值(值物件)。例如:SET mykey "HelloWorld"這個命令會建立兩個 Redis 物件,一個字串物件"mykey"做 key,一個字串物件"HelloWorld"作為 value。Redis 物件的結構體如下:










typedef struct redisObject {// 型別unsigned type:4;// 編碼unsigned encoding:4;// 指向底層實現資料結構的指標void *ptr;// ...} robj;

技術抽絲剝繭|為什麼 Redis 內部使用不同編碼?     

一個 Redis 物件可以有多個型別(type), 如字串、列表、雜湊、集合、有序集合等,同時每種型別也可以設定為不同的編碼方式(encoding):int、 hash(ht)、zipmap、ziplist、intset、skiplist、embstr、quicklist、 stream。透過 encoding 屬性來設定物件所使用的編碼, 而不是為特定型別的物件關聯一種固定的編碼, 極大地提升了 Redis 的靈活性和效率, 使得 Redis 可以根據不同的使用場景來為一個物件設定不同的編碼, 從而最佳化物件在某一場景下的效率。


兩種編碼方式的資料結構

編碼方式1: ziplist


<zlbytes><zltail><zllen><entry>...<entry><zlend>

技術抽絲剝繭|為什麼 Redis 內部使用不同編碼?

編碼方式2: hashtable 編碼  

技術抽絲剝繭|為什麼 Redis 內部使用不同編碼?     

為什麼要使用 ziplist 編碼?

  • 在雜湊物件包含的元素比較少時, Redis 使用壓縮列表作為列表物件的底層實現,因為壓縮列表比雙端連結串列更節約記憶體,並且在元素數量較少時,在記憶體中以連續塊方式儲存的壓縮列表比起雙端連結串列可以更快被載入到 CPU 快取中;

  • 隨著列表物件包含的元素越來越多,使用壓縮列表來儲存元素效能就會變差,Redis 就會把這個列表物件在底層從壓縮列表編碼轉成 hashtable 編碼,hashtable 編碼是一個更適合儲存大量元素的雙端連結串列。


命令執行時對兩種編碼結構處理邏輯

當執行 hset/hmset 命令時,程式碼中會去檢查雜湊的物件編碼以及相關條件來判斷該採用哪種編碼方式,大致流程如下:

 

1、檢查 value 大小

先是在 hashTypeTryConversion() 中檢查,當編碼是 OBJ_ENCODING_ZIPLIST,並且寫入的 value > server.hash_max_ziplist_value(我們線上預設設定為64 Bytes),會執行轉碼函式 hashTypeConvert()

 

2、檢查 field 的個數

在 hashTypeSet() 中,如果編碼是 OBJ_ENCODING_ZIPLIST,並且元素個數 >server.hash_max_ziplist_entries(我們線上預設設定為512個),就會執行轉碼函式 hashTypeConvert()

 

3、hashTypeConvert() 函式實現

在 hashTypeConvert()中 會呼叫 hashTypeConvertZiplist() 函式,先建立一個字典 dict,然後逐個複製 ziplist 中的 field 和 value 並建立對應 Redis 物件後放入 dict 中,在 ziplist 中的資料全部複製並加入 dict 後,釋放原來的 ziplist,然後把 key 指向這個 dict,這樣這個雜湊物件的編碼就從 ziplist 轉換成了 hashtable。這個過程是阻塞式的,直到 hashTypeConvert 函式執行完畢,hset/hmset 命令才算完成。

原始碼如下:








































void hashTypeConvertZiplist(robj *o, int enc) {    serverAssert(o->encoding == OBJ_ENCODING_ZIPLIST);     if (enc == OBJ_ENCODING_ZIPLIST) {        /* Nothing to do... */     } else if (enc == OBJ_ENCODING_HT) {        hashTypeIterator *hi;        dict *dict;        int ret;         hi = hashTypeInitIterator(o);        dict = dictCreate(&hashDictType, NULL);         while (hashTypeNext(hi) != C_ERR) {            robj *field, *value;             field = hashTypeCurrentObject(hi, OBJ_HASH_KEY);            field = tryObjectEncoding(field);            value = hashTypeCurrentObject(hi, OBJ_HASH_VALUE);            value = tryObjectEncoding(value);            ret = dictAdd(dict, field, value);            if (ret != DICT_OK) {                serverLogHexDump(LL_WARNING,"ziplist with dup elements dump",                    o->ptr,ziplistBlobLen(o->ptr));                serverAssert(ret == DICT_OK);            }        }         hashTypeReleaseIterator(hi);        zfree(o->ptr);         o->encoding = OBJ_ENCODING_HT;        o->ptr = dict;     } else {        serverPanic("Unknown hash encoding");    }}



兩種編碼的時間複雜度對比

說完了一些背景知識,下面該進入真正的主題了,為什麼城市A這次熱 key 問題會造成業務影響,首先我們來看一下雜湊物件使用 ziplist 和 hashtable 兩種編碼不同命令的時間複雜度:

命令

ziplist 編碼實現方法

hashtable 編碼的實現方法

HSET

首先呼叫 ziplistPush 函式, 將鍵推入到壓縮列表的表尾, 然後再次呼叫用ziplistPush函式, 將值推入到壓縮列表的表尾。

時間複雜度:O(1)

呼叫 dictAdd 函式, 將新節點新增到字典裡面。

時間複雜度:O(1)

HGET

首先呼叫 ziplistFind 函式, 在壓縮列表中查詢指定鍵所對應的節點, 然後呼叫 ziplistNext 函式, 將指標移動到鍵節點旁邊的值節點, 最後返回值節點。

時間複雜度:O(N)

呼叫 dictFind 函式, 在字典中查詢給定鍵, 然後呼叫 dictGetVal 函式, 返回該鍵所對應的值。

時間複雜度:O(1)

HEXISTS

呼叫 ziplistFind 函式, 在壓縮列表中查詢指定鍵所對應的節點, 如果找到的話說明鍵值對存在, 沒找到的話就說明鍵值對不存在。

時間複雜度:O(N)

呼叫 dictFind 函式, 在字典中查詢給定鍵, 如果找到的話說明鍵值對存在, 沒找到的話就說明鍵值對不存在。

時間複雜度:O(1)

HDEL

呼叫 ziplistFind 函式, 在壓縮列表中查詢指定鍵所對應的節點, 然後將相應的鍵節點、 以及鍵節點旁邊的值節點都刪除掉。

時間複雜度:O(N)

呼叫 dictDelete 函式, 將指定鍵所對應的鍵值對從字典中刪除掉。

時間複雜度:O(1)

HLEN

呼叫 ziplistLen 函式, 取得壓縮列表包含節點的總數量, 將這個數量除以 2 , 得出的結果就是壓縮列表儲存的鍵值對的數量。

時間複雜度:O(1)

呼叫 dictSize 函式, 返回字典包含的鍵值對數量, 這個數量就是雜湊物件包含的鍵值對數量。

時間複雜度:O(1)

HGETALL

遍歷整個壓縮列表, 用 ziplistGet 函式返回所有鍵和值(都是節點)。

時間複雜度:O(N)

遍歷整個字典, 用 dictGetKey 函式返回字典的鍵, 用 dictGetVal 函式返回字典的值。

時間複雜度:O(N)

注:字典 dict 結構的操作,可以簡單認為是O(1),雖然如果 hash 有衝突時會多個 key 放到連結串列,相應的很多操作就需要遍歷這個連結串列,但是一般這個連結串列不會太大,跟整個 hash 表大小比較來說可以認為O(1)。

 

從上面的對比不難看出:HEXISTS 命令使用 hashtable 時間複雜度為O(1),而使用 ziplist 編碼為O(N),當出問題時,城市A儲存的 hash  表元素為400個(採用 ziplist 編碼),而城市B的 hash 表元素個數超過了512個(採用hashtable編碼),這就解釋了為什麼訪問城市A的 key 效能更差。 

理論聯絡實際之測試驗證

測試方案

講完了理論,接下來就需要用實際測試來證明上面的推論(這裡僅針對 HEXISTS 一個命令做效能測試說明)。


測試方法如下:

  • 在同一個 Redis 例項分別生成兩個 hash 物件:mykey001 和 mykey002,同樣都是500個 filed,每個 value=60B;

  • 當壓測 mykey001 時,hash-max-ziplist-entries 引數=512,編碼將會使用 ziplist,壓測 mykey002 時 hash-max-ziplist-entries 引數=256,因此編碼為 hashtable;

  • 每輪傳送相同的壓力,這裡測試時為2萬 QPS,命令為 HEXISTS。


測試資料:

  • 兩輪測試 QPS 是相同的     

技術抽絲剝繭|為什麼 Redis 內部使用不同編碼?

 

  •  P99耗時對比

技術抽絲剝繭|為什麼 Redis 內部使用不同編碼?

  • cpu 利用率對比

技術抽絲剝繭|為什麼 Redis 內部使用不同編碼?

  • 雜湊物件佔用記憶體對比

 編碼方式

500個元素

ziplist

4.3MB

hashtable

19.6MB


測試結論
  • hashtable 編碼時(HEXISTS命令)的P99耗時下降14%(70us -> 60us)

  • hashtable 編碼時(HEXISTS命令)Redis 的 cpu 利用率下降72%(50% vs 14%)

  • hashtable 編碼的記憶體佔用是 ziplist 的4.6倍


補充說明: 

  • 每個雜湊物件儲存500個 field,每個 field 長度8個 Byte, 每個 value 是一個整數型別的時間戳

  • 記憶體佔用透過檢視 info 中 used_memory_rss 指標統計的。這個指標其實從 /proc/$pid/stat 採集的,應該是最精確的記憶體佔用資料了。單個物件是透過 debug object 檢視,這個不太精確。

 

一個小插曲,之前測試時,使用 debug object 在獲取 key 的記憶體佔用量,忽略一個問題,這個命令是複用了 rdbSaveObject 這個函式,在這個函式中計算Redis 物件的長度時,如果系統引數 rdbcompression 設定為 yes,且長度超過20B就進行壓縮,就導致了 debug object 輸出的 serializedlength 值失真,在3.2.8版本上 ziplist 在 rdbSaveObject 函式中會進行壓縮,hashtable 不會做壓縮,因此 ziplist 編碼時產生的 rdb 檔案會小很多。在3.2.8之後的版本針對 hashtable 在 rdbSaveObject 函式中也做了壓縮,產生的 rdb 檔案大小相差就沒那麼大,這裡只針對該case跑的3.2.8版本做了測試。



























# 使用ziplist編碼redis-cli -p 5555 config set hash-max-ziplist-entries 512OKredis-cli -p 5555 config set rdbcompression yesOKredis-cli -p 5555 debug object mykey002Value at:0x7fdf72868d40 refcount:1 encoding:ziplist serializedlength:3692 lru:5959713 lru_seconds_idle:35redis-cli -p 5555 config set rdbcompression noOKredis-cli -p 5555 debug object mykey002Value at:0x7fdf72868d40 refcount:1 encoding:ziplist serializedlength:34585 lru:5959713 lru_seconds_idle:35

# 使用ht編碼redis-cli -p 5555 config set hash-max-ziplist-entries 256OKredis-cli -p 5555 hset mykey002 mykey000000000000000000000001500 8c1dfc543d72c0826fc0968276932c88af7ed1deebd74d9c95c129fe6371500(integer) 1redis-cli -p 5555 config set rdbcompression yesOKredis-cli -p 5555 debug object mykey002Value at:0x7fdf72868d40 refcount:1 encoding:hashtable serializedlength:33651 lru:5959748 lru_seconds_idle:0redis-cli -p 5555 config set rdbcompression noOKredis-cli -p 5555 debug object mykey002Value at:0x7fdf72868d40 refcount:1 encoding:hashtable serializedlength:33666 lru:5959748 lru_seconds_idle:


最佳化思路

經過上面的測試我們會發現 ziplist 在成本上會有優勢,但是對於某些原來是O(1)的操作會變成O(N),如果追求效能,對成本不關注,或是隻需對少數 Key 追求效能,可以從以下方面來進行最佳化:


1、Redis 服務端最佳化:

透過調整 hash-max-ziplist-entries 或是 hash-max-ziplist-value 配置項,從而控制雜湊物件的編碼方式,例如把 hash-max-ziplist-entries 從512改成256,那麼超過256個 field 就會自動轉換成 hashtable,但是要注意,如果你的雜湊物件大部分都是超過256個 field,這麼修改有可能會造成記憶體使用量大幅上升的情況,需要注意你的錢包。

 

2、業務側最佳化方式:

在服務端做引數調整雖然簡單,但是需要根據業務實際情況確定引數,且實際使用中元素數量會發生變化,調整引數沒有可操作的條件,且在 key 數量多的時候還會造成不必要的成本浪費。如果是隻有部分雜湊物件需要關注效能,可以考慮在業務層針對單個 key 做最佳化。

  • 滿足 hash-max-ziplist-value 條件

例如在雜湊物件中存入一個特殊 field,讓這個 field 的 value 超過64B,這樣這個 key 就自動變成 hashtable 編碼,但是需要業務能識別這個特殊的 field,避免出現 bug。

  • 滿足 hash-max-ziplist-entries 條件

很多業務的雜湊物件是經過一次拆分了,透過取模 hash 的方式拆分到多個雜湊物件,也可以透過將拆分的雜湊物件減少,從而達到滿足單個雜湊物件中 field 個數超過512個的條件。

 

3、業務使用建議:

  • 儘量避免熱 key 現象

我們使用的是 Redis 的 cluster 版本,如果把業務的大部分流量都集中在某個或某幾個 key 上,就無法充分發揮分散式叢集的作用,壓力都集中在個別的 Redis 例項上,從而出現熱點瓶頸。

  • 使用 list/hash/set/zset 等資料結構時要注意效能問題

需要根據自己的單個 key 中的 field 個數,value 大小,以及使用的命令等綜合考慮效能和成本,如果需要了解自己的 key 的實際編碼個數可以透過命令:object encoding 檢視。

總結

透過這次問題分析,我們可以看到 Redis 內部提供的不同編碼會帶來不同的效能和成本差別,建議大家在使用 Redis 時,也可以多瞭解自己的訪問場景,根據實際情況來做一些調優。同時也提醒我們,時刻保持對問題根因的探究精神,才可以使我們自己的技術和業務能力不斷提升。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70027826/viewspace-2994903/,如需轉載,請註明出處,否則將追究法律責任。

相關文章