Redis 內存優化在 vivo 的探索與實踐

作者:互聯網服務器團隊 - Tang Wenjian

一、 背景

使用過 Redis 的同學應該都知道,它基於鍵值對 (key-value) 的內存數據庫,所有數據存放在內存中,內存在 Redis 中扮演一個核心角色,所有的操作都是圍繞它進行。

我們在實際維護過程中經常會被問到如下問題,比如數據怎麼存儲在 Redis 裏面能節約成本、提升性能?Redis 內存告警是什麼原因導致?

本文主要是通過分析 Redis 內存結構、介紹內存優化手段,同時結合生產案例,幫助大家在優化內存使用,快速定位 Redis 相關內存異常問題。

二、 Redis 內存管理

本章詳細介紹 Redis 是怎麼管理各內存結構的,然後主要介紹幾個佔用內存可能比較多的內存結構。首先我們看下 Redis 的內存模型。

內存模型如圖:

圖片

【used_memory】:Redis 內存佔用中最主要的部分,Redis 分配器分配的內存總量(單位是 KB)(在編譯時指定編譯器,默認是 jemalloc),主要包含自身內存 (字典、元數據)、對象內存、緩存,lua 內存。

【自身內存】:自身維護的一些數據字典及元數據,一般佔用內存很低。

【對象內存】:所有對象都是 Key-Value 型,Key 對象都是字符串,Value 對象則包括 5 種類(String,List,Hash,Set,Zset),5.0 還支持 stream 類型。

【緩存】:客戶端緩衝區 (普通 + 主從複製 + pubsub) 以及 aof 緩衝區。

【Lua 內存】:主要是存儲加載的 Lua 腳本,內存使用量和加載的 Lua 腳本數量有關。

【used_memory_rss】:Redis 主進程佔據操作系統的內存(單位是 KB),是從操作系統角度得到的值,如 top、ps 等命令。

【內存碎片】:如果對數據的更改頻繁,可能導致 redis 釋放的空間在物理內存中並沒有釋放,但 redis 又無法有效利用,這就形成了內存碎片。

【運行內存】:運行時消耗的內存,一般佔用內存較低,在 10M 內。

【子進程內存】:主要是在持久化的時候,aof rewrite 或者 rdb 產生的子進程消耗的內存,一般也是比較小。

2.1 對象內存

對象內存存儲 Redis 所有的 key-value 型數據類型,key 對象都是 string 類型,value 對象主要有五種數據類型 String、List、Hash、Set、Zset,不同類型的對象通過對應的編碼各種封裝,對外定義爲 RedisObject 結構體,RedisObject 都是由字典(Dict)保存的,而字典底層是通過哈希表來實現的。通過哈希表中的節點保存字典中的鍵值對,結構如下:

圖片

(來源:書籍《Redis 設計與實現》)

爲了達到極大的提高 Redis 的靈活性和效率,Redis 根據不同的使用場景來對一個對象設置不同的編碼,從而優化某一場景下的效率。 

各類對象選擇編碼的規則如下:

string (字符串)

list (列表)

set (集合)

hash (hash 列表)

zset(有序集合)

2.2 緩衝內存

2.2 1 客戶端緩存

客戶端緩衝指的是所有接入 Redis 服務的 TCP 連接的輸入輸出緩衝。有普通客戶端緩衝、主從複製緩衝、訂閱緩衝,這些都由對應的參數緩衝控制大小(輸入緩衝無參數控制,最大空間爲 1G),若達到設定的最大值,客戶端將斷開。

【client-output-buffer-limit】: 限制客戶端輸出緩存的大小,後面接客戶端種類 (normal、slave、pubsub) 及限制大小,默認是 0,不做限制,如果做了限制,達到閾值之後,會斷開鏈接,釋放內存。

【repl-backlog-size】:默認是 1M,backlog 是一個主從複製的緩衝區,是一個環形 buffer, 假設達到設置的閾值,不存在溢出的問題, 會循環覆蓋,比如 slave 中斷過程中同步數據沒有被覆蓋,執行增量同步就可以。backlog 設置的越大,slave 可以失連的時間就越長,受參數 maxmemory 限制,正常不要設置太大。

2.2 2 AOF 緩衝

當我們開啓了 AOF 的時候,先將客戶端傳來的命令存放在 AOF 緩衝區,再去根據具體的策略(always、everysec、no)去寫入磁盤中的 AOF 文件中,同時記錄刷盤時間。

 AOF 緩衝沒法限制,也不需要限制,因爲主線程每次進行 AOF 會對比上次刷盤成功的時間;如果超過 2s,則主線程阻塞直到 fsync 同步完成,主線程被阻塞的時候,aof_delayed_fsync 狀態變量記錄會增加。因此 AOF 緩存只會存幾秒時間的數據,消耗內存比較小。

2.3 內存碎片

程序出現內存碎片是個很常見的問題,Redis 的默認分配器是 jemalloc ,它的策略是按照一系列固定的大小劃分內存空間,例如 8 字節、16 字節、32 字節、…, 4KB、8KB 等。當程序申請的內存最接近某個固定值時,jemalloc 會給它分配比它大一點的固定大小的空間,所以會產生一些碎片,另外在刪除數據的時候,釋放的內存不會立刻返回給操作系統,但 redis 自己又無法有效利用,就形成碎片。

內存碎片不會被統計在 used_memory 中,內存碎片比率在 redis info 裏面記錄了一個動態值 mem_fragmentation_ratio,該值是 used_memory_rss / used_memory 的比值, mem_fragmentation_ratio 越接近 1,碎片率越低,正常值在 1~1.5 內,超過了說明碎片很多。

2.4 子進程內存

前面提到子進程主要是爲了生成 RDB 和 AOF rewrite 產生的子進程,也會佔用一定的內存,但是在這個過程中寫操作不頻繁的情況下內存佔用較少,寫操作很頻繁會導致佔用內存較多。

三、Redis 內存優化

內存優化的對象主要是對象內存、客戶端緩衝、內存碎片、子進程內存等幾個方面,因爲這幾個內存消耗比較大或者有的時候不穩定,我們優化內存的方向分爲如:減少內存使用、提高性能、減少內存異常發生。

3.1 對象內存優化

對象內存的優化可以降低內存使用率,提高性能,優化點主要針對不同對象不同編碼的選擇上做優化。

在優化前,我們可以瞭解下如下的一些知識點

(1)首先是字符串類型的 3 種編碼,int 編碼除了自身 object 無需分配內存,object 的指針不需要指向其他內存空間,無論是從性能還是內存使用都是最優的,embstr 是會分配一塊連續的內存空間,但是假設這個 value 有任何變化,那麼 value 對象會變成 raw 編碼,而且是不可逆的。

(2)ziplist 存儲 list 時每個元素會作爲一個 entry; 存儲 hash 時 key 和 value 會作爲相鄰的兩個 entry; 存儲 zset 時 member 和 score 會作爲相鄰的兩個 entry,當不滿足上述條件時,ziplist 會升級爲 linkedlist, hashtable 或 skiplist 編碼。

(3)在任何情況下大內存的編碼都不會降級爲 ziplist。

(4)linkedlist 、hashtable 便於進行增刪改操作但是內存佔用較大。

(5)ziplist 內存佔用較少,但是因爲每次修改都可能觸發 realloc 和 memcopy, 可能導致連鎖更新 (數據可能需要挪動)。因此修改操作的效率較低,在 ziplist 的條目很多時這個問題更加突出。

(6)由於目前大部分 redis 運行的版本都是在 3.2 以上,所以 List 類型的編碼都是 quicklist, 它是 ziplist 組成的雙向鏈表 linkedlist ,它的每個節點都是一個 ziplist,考慮了綜合平衡空間碎片和讀寫性能兩個維度所以使用了個新編碼 quicklist,quicklist 有個比較重要的參數 list-max-ziplist-size,當它取正數的時候,正數表示限制每個節點 ziplist 中的 entry 數量,如果是負數則只能爲 - 1~-5,限制 ziplist 大小,從 - 1~-5 的限制分別爲 4kb、8kb、16kb、32kb、64kb,默認是 - 2,也就是限制不超過 8kb。

(7)【rehash】: redis 存儲底層很多是 hashtable,客戶端可以根據 key 計算的 hash 值找到對應的對象,但是當數據量越來越大的時候,可能就會存在多個 key 計算的 hash 值相同,這個時候這些相同的 hash 值就會以鏈表的形式存放,如果這個鏈表過大,那麼遍歷的時候性能就會下降,所以 Redis 定義了一個閾值 (負載因子 loader_factor = 哈希表中鍵值對數量 / 哈希表長度),會觸發漸進式的 rehash,過程是新建一個更大的新 hashtable,然後把數據逐步移動到新 hashtable 中。

(8)【bigkey】:bigkey 一般指的是 value 的值佔用內存空間很大,但是這個大小其實沒有一個固定的標準,我們自己定義超過 10M 就可以稱之爲 bigkey。

優化建議:

(1)key 儘量控制在 44 個字節數內,走 embstr 編碼,embstr 比 raw 編碼減少一次內存分配,同時因爲是連續內存存儲,性能會更好。

(2)多個 string 類型可以合併成小段 hash 類型去維護,小的 hash 類型走 ziplist 是有很好的壓縮效果,節約內存。

(3)非 string 的類型的 value 對象的元素個數儘量不要太多,避免產生大 key。

(4)在 value 的元素較多且頻繁變動,不要使用 ziplist 編碼,因爲 ziplist 是連續的內存分配,對頻繁更新的對象並不友好,性能損耗反而大。

(5)hash 類型對象包含的元素不要太多,避免在 rehash 的時候消耗過多內存。

(6)儘量不要修改 ziplist 限制的參數值,因爲 ziplist 編碼雖然可以對內存有很好的壓縮,但是如果元素太多使用 ziplist 的話,性能可能會有所下降。

3.2 客戶端緩衝優化

客戶端緩存是很多內存異常增長的罪魁禍首,大部分都是普通客戶端輸出緩衝區異常增長導致,我們先了解下執行命令的過程,客戶端發送一個或者通過 piplie 發送一組請求命令給服務端,然後等待服務端的響應,一般客戶端使用阻塞模式來等待服務端響應,數據在被客戶端讀取前,數據是存放在客戶端緩存區,命令執行的簡易流程圖如下:

圖片

圖片

異常增長原因可能如下幾種:

  1. 客戶端訪問大 key 導致客戶端輸出緩存異常增長。

  2. 客戶端使用 monitor 命令訪問 Redis,monitor 命令會把所有訪問 redis 的命令持續存放到輸出緩衝區,導致輸出緩衝區異常增長。

  3. 客戶端爲了加快訪問效率,使用 pipline 封裝了大量命令,導致返回的結果集異常大(pipline 的特性是等所有命令全部執行完才返回,返回前都是暫存在輸出緩存區)。

  4. 從節點應用數據較慢,導致輸出主從複製輸出緩存有很多數據積壓,最後導致緩衝區異常增長。

異常表現

  1. 在 Redis 的 info 命令返回的結果裏面,client 部分 client_recent_max_output_buffer 的值很大。

  2. 在執行 client list 命令返回的結果集裏面,omem 不爲 0 且很大,omem 代表該客戶端的輸出代表緩存使用的字節數。

  3. 在集羣中,可能少部分 used_memory 在監控顯示存在異常增長,因爲不管是 monitor 或者 pipeline 都是針對單個實例的下發的命令。

優化建議

  1. 應用不要設計大 key, 大 key 儘量拆分。

  2. 服務端的普通客戶端輸出緩存區通過參數設置,因爲內存告警的閾值大部分是使用率 80% 開始,實際建議參數可以設置爲實例內存的 5%~15% 左右,最好不要超過 20%,避免 OOM。

  3. 非特殊情況下避免使用 monitor 命令或者 rename 該命令。

  4. 在使用 pipline 的時候,pipeline 不能封裝過多的命令,特別是一些返回結果集較多的命令更應該少封裝。

  5. 主從複製輸出緩衝區大小設置參考: 緩衝區大小 =(主庫寫入命令速度 * 操作大小 - 主從庫間網絡傳輸命令速度 * 操作大小)* 2。

3.3  碎片優化

碎片優化可以降低內存使用率,提高訪問效率,在 4.0 以下版本,我們只能使用重啓恢復,重啓加載 rdb 或者重啓通過高可用主從切換實現數據的重新加載可以減少碎片,在 4.0 以上版本,Redis 提供了自動和手動的碎片整理功能,原理大致是把數據拷貝到新的內存空間,然後把老的空間釋放掉,這個是有一定的性能損耗的。

【a. redis 手動整理碎片】:執行 memory purge 命令即可。

【b.redis 自動整理碎片】:通過如下幾個參數控制

3.4 子進程內存優化

前面談到 AOF rewrite 和 RDB 生成動作會產生子進程,正常在兩個動作執行的過程中,Redis 寫操作沒有那麼頻繁的情況下 fork 出來的子進程是不會消耗很多內存的,這個主要是因爲 Redis 子進程使用了 Linux 的 copy on write 機制,簡稱 COW。

COW 的核心是在 fork 出子進程後,與父進程共享內存空間,只有在父進程發生寫操作修改內存數據時,纔會真正去分配內存空間,並複製內存數據。

但是有一點需要注意,不要開啓操作系統的大頁 THP(Transparent Huge Pages),開啓 THP 機制後,本來頁的大小由 4KB 變爲 2MB 了。它雖然可以加快 fork 完成的速度 (因爲要拷貝的頁的數量減少),但是會導致 copy-on-write 複製內存頁的單位從 4KB 增大爲 2MB,如果父進程有大量寫命令,會加重內存拷貝量,從而造成過度內存消耗。

四、內存優化案例

4.1 緩衝區異常優化案例

線上業務 Redis 集羣出現內存告警,內存使用率增長很快達到 100%,值班人員先進行了緊急擴容,同時反饋至業務羣是否有大量新數據寫入,業務反饋並無大量新數據寫入,且同時擴容後的內存還在漲,很快又要觸發告警了,業務 DBA 去查監控看看具體原因。

首先我們看 used_memory 增長只是集羣的少數幾個實例,同時內存異常的實例的 key 的數量並沒有異常增長,說明沒有寫入大批量數據導致。

圖片

我們再往下分析,可能是客戶端的內存佔用異常比較大,查看實例 info 裏面的客戶端相關指標,觀察發現 output_list 的增長曲線和 used_memory 一致,可以判定是客戶端的輸出緩衝異常導致。

圖片

接下來我們再去通過 client list 查看是什麼客戶端導致 output 增長,客戶端在執行什麼命令,同時去分析是否訪問大 key。

執行 client list |grep -i  omem=0  發現如下:

id=12593807 addr=192.168.101.1:52086 fd=10767 name=  age=15301 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0  qbuf-free=32768  obl=16173  oll=341101  omem=5259227504  events=rw  cmd=get

說明下相關的幾個重點的字段的含義:

【id】:就是客戶端的唯一標識,經常用於我們 kill 客戶端用到 id;

【addr】:客戶端信息;

【obl】:固定緩衝區大小 (字節),默認是 16K;

【oll】:動態緩衝區大小(對象個數),客戶端如果每條命令的響應結果超過 16k 或者固定緩衝區寫滿了會寫動態緩衝區;

【omem】: 指緩衝區的總字節數;

【cmd】: 最近一次的操作命令。

可以看到緩衝區內存佔用很大,最近的操作命令也是 get,所以我們先看看是否大 key 導致 (我們是直接分析 RDB 發現並沒有大 key),但是發現並沒有大 key,而且 get 對應的肯定是 string 類型,string 類型的 value 最大是 512M,所以單個 key 也不太可能產生這麼大的緩存,所以斷定是客戶端緩存了多個 key。

這個時候爲了儘快恢復,和業務溝通臨時 kill 該連接,內存釋放,然後爲了避免防止後面還產生異常,和業務方

溝通設置普通客戶端緩存限制,因爲最大內存是 25G,我們把緩存設置了 2G-4G, 動態設置參數如下:

config set client-output-buffer-limit normal 

4096mb 2048mb 120

因爲參數限制也只是針對單個 client 的輸出緩衝這麼大,所以還需要檢查客戶端使用使用 pipline 這種管道命令或者類似實現了封裝大批量命令導致結果統一返回之前被阻塞,後面確定確實會有這個操作,業務層就需要去逐步優化,不然我們限制了輸出緩衝,達到了上限,會話會被 kill, 所以業務不改的話還是會有拋錯。

業務方反饋用的是 C++ 語言 brpc 自帶的 Redis 客戶端,第一次直接搜索沒有 pipline 的關鍵字,但是現象又指向使用的管道,所以繼續仔細看了下代碼,發現其內部是實現了 pipline 類似的功能,也是會對多個命令進行封裝去請求 redis,然後統一返回結果,客戶端 GitHub 鏈接如下:

https://github.com/apache/incubator-brpc/blob/master/docs/cn/redis_client.md

總結: 

pipline 在 Redis 客戶端中使用的挺多的,因爲確實可以提供訪問效率,但是使用不當反而會影響訪問,應該控制好訪問,生產環境也儘量加這些內存限制,避免部分客戶端的異常訪問影響全局使用。

4.2 從節點內存異常增長案例

線上 Redis 集羣出現內存使用率超過 95% 的災難告警,但是該集羣是有 190 個節點的集羣觸發異常內存告警的只有 3 個節點。所以查看集羣對應信息以及監控指標發現如下有用信息:

  1. 3 個從節點對應的主節點內存沒有變化,從節點的內存是逐步增長的。

  2. 發現集羣整體 ops 比較低,說明業務變化並不大,沒有發現有效命令突增。

  3. 主從節點的最大內存不一致,主節點是 6G, 從節點是 5G,這個是導致災難告警的重要原因。

  4. 在出問題前,主節點比從節點的內存大概多出 1.3G,後面從節點 used_memory 逐步增長到超過主節點內存, 但是 rss 內存是最後保持了一樣。

  5. 主從複製出現延遲也內存增長的那個時間段。

圖片

圖片

圖片

處理過程:

首先想到的應該是保持主從節點最大內存一致,但是因爲主機內存使用率比較高暫時沒法擴容,因爲想到的是從節點可能什麼原因阻塞,所以和業務方溝通是重啓下 2 從節點緩解下,重啓後從節點內存釋放,降到發生問題前的水平,如上圖,後面主機空出了內存資源,所以優先把內存調整一致。

內存調整好了一週後,這 3 個從節點內存又告警了,因爲現在主從內存是一致的,所以觸發的是嚴重告警 (>85%),查看監控發現情況是和之前一樣,猜測這個是某些操作觸發的,所以還是決定問問業務方這 兩個時間段都有哪些操作,業務反饋這段時間就是在寫業務,那 2 個時間段都是在寫入,也看了寫 redis 的那段代碼,用了一個比較少見的命令 append,append 是對 string 類型的 value 進行追加。

這裏就得提下 string 類型在 Redis 裏面是怎麼分配內存的:string 類型都是都是 sds 存儲,當前分配的 sds 內存空間不足存儲且小於 1M 時候,Redis 會重新分配一個 2 倍之前內存大小的內存空間。

根據上面到知識點,所以可以大致可以解析上述一系列的問題,大概是當時做 append 操作,從節點需要分配空間從而發生內存膨脹,而主節點不需要分配空間,因爲內存重新分配設計 malloc 和 free 操作,所以當時有 lag 也是正常的。

Redis 的主從本身是一個邏輯複製,加載 RDB 的過程其實也是拿到 kv 不斷的寫入到從節點,所以主從到內存大小也經常存在不相同的情況,特別是這種 values 大小經常改變的場景,主從存儲的 kv 所用的空間很多可能是不一樣的。

爲了證明這一猜測,我們可以通過獲取一個 key(value 大小要比較大) 在主從節點佔用空間的大小,因爲是 4.0 以上版本,所以我們可以使用 memory USAGE 去獲取大小, 看看差異有多少,我們隨機找了幾個稍微大點的 key 去查看,發現在有些 key 從庫佔用空間是主庫的近 2 倍,有的差不多,有的也是 1 倍多,rdb 解析出來的這個 key 空間更小,說明從節點重啓後加載 rdb 進行存放是最小的,然後因爲某段時間大批量 key 操作,導致從節點的大批量的 key 分配的空間不足,需要擴容 1 倍空間,導致內存出現增長。

到這就分析的其實差不多了,因爲 append 的特性,爲了避免內存再次出現內存告警,決定把該集羣的內存進行擴容,控制內存使用率在 70% 以下 (避免可能發生的大量 key 使用內存翻倍的情況)。

最後還有 1 個問題:上面的 used_memory 爲什麼會比 memory_rss 的值還大呢?(swap 是關閉的)。

這是因爲 jemalloc 內存分配一開始其實分配的是虛擬內存,只有往分配的 page 頁裏面寫數據的時候纔會真正分配內存,memory_rss 是實際內存佔用,used_memory 其實是一個計數器,在 Redis 做內存的 malloc/free 的時候,對這個 used_memory 做加減法。

關於 used_memory 大於 memory_rss 的問題,redis 作者也做了回答:

https://github.com/redis/redis/issues/946#issuecomment-13599772

總結:

在知曉 Redis 內存分配原理的情況下,數據庫的內存異常問題進行分析會比較快速定位,另外可能某個問題看起來和業務沒什麼關聯,但是我們還是應該多和業務方溝通獲取一些線索排查問題,最後主從內存一定按照規範保持一致。

五、總結

Redis 在數據存儲、緩存都是做了很巧妙的設計和優化,我們在瞭解了它的內部結構、存儲方式之後,我們可以提前在 key 的設計上做優化。我們在遇到內存異常或者性能優化的時候,可以不再侷限於表面的一些分析如:資源消耗、命令的複雜度、key 的大小,還可以結合根據 Redis 的一些內部運行機制和內存管理方式去深入發現是否還有可能哪些方面導致異常或者性能下降。

參考資料

vivo 互聯網技術 分享 vivo 互聯網技術乾貨與沙龍活動,推薦最新行業動態與熱門會議。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UoeGleV7GtCsgiPWD3ZWxA