一、 背景
使用過 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發送一組請求命令給伺服端,然後等待伺服端的響應,一般客戶端使用阻塞模式來等待伺服端響應,數據在被客戶端讀取前,數據是存放在客戶端緩存區,命令執行的簡易流程圖如下:
異常增長 原因 可能如下幾種:
客戶端存取大key 導致客戶端輸出緩存異常增長。
客戶端使用monitor命令存取Redis,monitor命令會把所有存取redis的命令持續存放到輸出緩沖區,導致輸出緩沖區異常增長。
客戶端為了加快存取效率,使用pipline封裝了大量命令,導致返回的結果集異常大(pipline的特性是等所有命令全部執行完才返回,返回前都是暫存在輸出緩存區)。
從節點套用數據較慢,導致輸出主從復制輸出緩存有很多數據積壓,最後導致緩沖區異常增長。
異常 表現 :
在Redis的info命令返回的結果裏面,client部份client_recent_max_output_buffer的值很大。
在執行client list命令返回的結果集裏面,omem不為0且很大,omem代表該客戶端的輸出代表緩存使用的字節數。
在集群中,可能少部份used_memory在監控顯示存在異常增長,因為不管是monitor或者pipeline都是針對單個例項的下發的命令。
最佳化 建議 :
套用不要設計大key,大key盡量拆分。
伺服端的普通客戶端輸出緩存區透過參數設定,因為記憶體告警的閾值大部份是使用率80%開始,實際建議參數可以設定為例項記憶體的5%~15%左右,最好不要超過20%,避免OOM。
非特殊情況下避免使用monitor命令或者rename該命令。
在使用pipline的時候,pipeline不能封裝過多的命令,特別是一些返回結果集較多的命令更應該少封裝。
主從復制輸出緩沖區大小設定參考: 緩沖區大小=(主庫寫入命令速度 * 操作大小 - 主從庫間網路傳輸命令速度 * 操作大小)* 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個節點。所以檢視集群對應資訊以及監控指標發現如下有用資訊:
3個從節點對應的主節點記憶體沒有變化,從節點的記憶體是逐步增長的。
發現集群整體ops比較低,說明業務變化並不大,沒有發現有效命令突增。
主從節點的最大記憶體不一致,主節點是6G,從節點是5G,這個是導致災難告警的重要原因。
在出問題前,主節點比從節點的記憶體大概多出1.3G,後面從節點used_memory逐步增長到超過主節點記憶體,但是rss記憶體是最後保持了一樣。
主從復制出現延遲也記憶體增長的那個時間段。
處理過程:
首先想到的應該是保持主從節點最大記憶體一致,但是因為主機記憶體使用率比較高暫時沒法擴容,因為想到的是從節點可能什麽原因阻塞,所以和業務方溝通是重新開機下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的一些內部執行機制和記憶體管理方式去深入發現是否還有可能哪些方面導致異常或者效能下降。
參考資料
書籍【Redis設計與實作】
如喜歡本文,請點選右上角,把文章分享到朋友圈
如有想了解學習的技術點,請留言給若飛安排分享
因公眾號更改推播規則,請點「在看」並加「星標」 第一時間獲取精彩技術分享
·END·
相關閱讀:
作者:Tang Wenjian
來源:vivo互聯網技術
版權申明:內容來源網路,僅供學習研究,版權歸原創者所有。如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝!
架構師
我們都是架構師!
關註 架構師(JiaGouX),添加「星標」
獲取每天技術幹貨,一起成為牛逼架構師
技術群請 加若飛: 1321113940 進架構師群
投稿、合作、版權等信箱: [email protected]