天天看點

Redis 記憶體優化在 vivo 的探索與實踐

 作者:vivo 網際網路伺服器團隊- Tang Wenjian

一、 背景

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

我們在實際維護過程中經常會被問到如下問題,比如資料怎麼存儲在 Redis 裡面能節約成本、提升性能?Redis記憶體告警是什麼原因導緻?

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

二、 Redis 記憶體管理

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

記憶體模型如圖:

Redis 記憶體優化在 vivo 的探索與實踐

【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 記憶體優化在 vivo 的探索與實踐

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

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

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

string (字元串)

  • 【int】:(整數且數字長度小于20,直接記錄在ptr*裡面)
  • 【embstr】: (連續配置設定的記憶體(字元串長度小于等于44位元組的字元串))
  • 【raw】: 動态字元串(大于44個位元組的字元串,同時字元長度小于 512M(512M是字元串的大小限制))

list (清單)

  • 【ziplist】:(元素個數小于hash-max-ziplist-entries配置(預設512個),同時所有值都小于hash-max-ziplist-value配置(預設64個位元組))
  • 【linkedlist】:(當清單類型無法滿足ziplist的條件時,Redis會使用linkedlist作為清單的内部實作)
  • 【quicklist】:(Redis 3.2 版本引入了 quicklist 作為 list 的底層實作,不再使用 linkedlist 和 ziplist 實作)

set (集合)

  • 【intset 】:(元素都是整數且元素個數小于set-max-intset-entries配置(預設512個))
  • 【hashtable】:(集合類型無法滿足intset的條件時就會使用hashtable)

hash (hash清單)

  • 【ziplist】:(元素個數小于hash-max-ziplist-entries配置(預設512個),同時任意一個value的長度都小于hash-max-ziplist-value配置(預設64個位元組))
  • 【hashtable】:(hash類型無法滿足intset的條件時就會使用hashtable

zset(有序集合)

  • 【ziplist】:(元素個數小于zset-max-ziplist-entries配置(預設128個)同時每個元素的value小于zset-max-ziplist-value配置(預設64個位元組))
  • 【skiplist】:(當ziplist條件不滿足時,有序集合會使用skiplist作為内部實作)

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發送一組請求指令給服務端,然後等待服務端的響應,一般用戶端使用阻塞模式來等待服務端響應,資料在被用戶端讀取前,資料是存放在用戶端緩存區,指令執行的簡易流程圖如下:

Redis 記憶體優化在 vivo 的探索與實踐
Redis 記憶體優化在 vivo 的探索與實踐

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

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自動整理碎片】:通過如下幾個參數控制

  • 【activedefrag yes 】:啟用自動碎片清理開關
  • 【active-defrag-ignore-bytes 100mb】:記憶體碎片空間達到多少才開啟碎片整理
  • 【active-defrag-threshold-lower 10】:碎片率達到百分之多少才開啟碎片整理
  • 【active-defrag-threshold-upper 100 】:記憶體碎片率超過多少,則盡最大努力整理(占用最大資源去做碎片整理)
  • 【active-defrag-cycle-min 25 】:記憶體自動整理占用資源最小百分比
  • 【active-defrag-cycle-max 75】:記憶體自動整理占用資源最大百分比

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的數量并沒有異常增長,說明沒有寫入大批量資料導緻。

Redis 記憶體優化在 vivo 的探索與實踐

我們再往下分析,可能是用戶端的記憶體占用異常比較大,檢視執行個體 info 裡面的用戶端相關名額,觀察發現output\_list的增長曲線和used\_memory一緻,可以判定是用戶端的輸出緩沖異常導緻。

Redis 記憶體優化在 vivo 的探索與實踐

接下來我們再去通過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. 主從複制出現延遲也記憶體增長的那個時間段。
Redis 記憶體優化在 vivo 的探索與實踐
Redis 記憶體優化在 vivo 的探索與實踐
Redis 記憶體優化在 vivo 的探索與實踐

處理過程:

首先想到的應該是保持主從節點最大記憶體一緻,但是因為主機記憶體使用率比較高暫時沒法擴容,因為想到的是從節點可能什麼原因阻塞,是以和業務方溝通是重新開機下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設計與實作》