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

語言: CN / TW / HK

作者:網際網路伺服器團隊- 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 (字串)

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

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

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

我們再往下分析,可能是客戶端的記憶體佔用異常比較大,檢視例項 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該連線,記憶體釋放,然後為了避免防止後面還產生異常,和業務方

  • 【int:】 (整數且數字長度小於20,直接記錄在ptr*裡面)

  • 【embstr】: (連續分配的記憶體(字串長度小於等於44位元組的字串))

  • 【raw】: 動態字串(大於44個位元組的字串,同時字元長度小於 512M(512M是字串的大小限制))

溝通設定普通客戶端快取限制,因為最大記憶體是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的一些內部執行機制和記憶體管理方式去深入發現是否還有可能哪些方面導致異常或者效能下降。

參考資料