Redis 記憶體使用優化與儲存

發表於2016-10-09

Redis 常用資料型別

Redis 最為常用的資料型別主要有以下五種:

• String

• Hash

• List

• Set

• Sorted set

在具體描述這幾種資料型別之前,我們先通過一張圖瞭解下 Redis 內部記憶體管理中是如何描述這些不同資料型別的:

201610091

首先 Redis 內部使用一個 redisObject 物件來表示所有的 key 和 value,redisObject 最主要的資訊如上圖所示:type  代表一個 value 物件具體是何種資料型別,encoding 是不同資料型別在 redis 內部的儲存方式,比如:type=string 代表 value 儲存的是一個普通字串,那麼對應的 encoding 可以是 raw 或者是 int,如果是 int 則代表實際 redis 內部是按數值型類儲存和表示這個字串的,當然前提是這個字串本身可以用數值表示,比如:”123″ “456”這樣的字串。

這裡需要特殊說明一下 vm 欄位,只有開啟了 Redis 的虛擬記憶體功能,此欄位才會真正的分配記憶體,該功能預設是關閉狀態的,該功能會在後面具體描述。通過上圖我們可以發現 Redis 使用 redisObject 來表示所有的 key/value 資料是比較浪費記憶體的,當然這些記憶體管理成本的付出主要也是為了給 Redis 不同資料型別提供一個統一的管理介面,實際作者也提供了多種方法幫助我們儘量節省記憶體使用,我們隨後會具體討論。

下面我們先來逐一的分析下這五種資料型別的使用和內部實現方式:

String

常用命令:

set,get,decr,incr,mget 等。

應用場景:

String 是最常用的一種資料型別,普通的 key/value 儲存都可以歸為此類,這裡就不所做解釋了。

實現方式:

String 在 redis 內部儲存預設就是一個字串,被 redisObject 所引用,當遇到 incr,decr 等操作時會轉成數值型進行計算,此時 redisObject 的 encoding 欄位為int。

Hash

常用命令:

hget,hset,hgetall 等。

應用場景:

我們簡單舉個例項來描述下 Hash 的應用場景,比如我們要儲存一個使用者資訊物件資料,包含以下資訊:

使用者 ID 為查詢的 key,儲存的 value 使用者物件包含姓名,年齡,生日等資訊,如果用普通的 key/value 結構來儲存,主要有以下2種儲存方式:

201610092

第一種方式將使用者 ID 作為查詢 key,把其他資訊封裝成一個物件以序列化的方式儲存,這種方式的缺點是,增加了序列化/反序列化的開銷,並且在需要修改其中一項資訊時,需要把整個物件取回,並且修改操作需要對併發進行保護,引入CAS等複雜問題。

201610093

第二種方法是這個使用者資訊物件有多少成員就存成多少個 key-value 對兒,用使用者 ID +對應屬性的名稱作為唯一標識來取得對應屬性的值,雖然省去了序列化開銷和併發問題,但是使用者 ID 為重複儲存,如果存在大量這樣的資料,記憶體浪費還是非常可觀的。

那麼 Redis 提供的 Hash 很好的解決了這個問題,Redis 的 Hash 實際是內部儲存的 Value 為一個 HashMap,並提供了直接存取這個 Map 成員的介面,如下圖:

201610094

也就是說,Key 仍然是使用者 ID,value 是一個 Map,這個 Map 的 key 是成員的屬性名,value 是屬性值,這樣對資料的修改和存取都可以直接通過其內部 Map 的 Key(Redis 裡稱內部 Map 的 key 為 field),也就是通過 key(使用者 ID) + field(屬性標籤)就可以操作對應屬性資料了,既不需要重複儲存資料,也不會帶來序列化和併發修改控制的問題。很好的解決了問題。

這裡同時需要注意,Redis 提供了介面(hgetall)可以直接取到全部的屬性資料,但是如果內部 Map 的成員很多,那麼涉及到遍歷整個內部 Map 的操作,由於 Redis 單執行緒模型的緣故,這個遍歷操作可能會比較耗時,而另其它客戶端的請求完全不響應,這點需要格外注意。

實現方式:

上面已經說到 Redis Hash 對應 Value 內部實際就是一個 HashMap,實際這裡會有2種不同實現,這個 Hash 的成員比較少時 Redis 為了節省記憶體會採用類似一維陣列的方式來緊湊儲存,而不會採用真正的 HashMap 結構,對應的 value redisObject 的 encoding 為 zipmap,當成員數量增大時會自動轉成真正的 HashMap,此時 encoding 為 ht。

List

常用命令:

lpush,rpush,lpop,rpop,lrange等。

應用場景:

Redis list 的應用場景非常多,也是 Redis 最重要的資料結構之一,比如 twitter 的關注列表,粉絲列表等都可以用 Redis 的 list 結構來實現,比較好理解,這裡不再重複。

實現方式:

Redis list 的實現為一個雙向連結串列,即可以支援反向查詢和遍歷,更方便操作,不過帶來了部分額外的記憶體開銷,Redis 內部的很多實現,包括髮送緩衝佇列等也都是用的這個資料結構。

Set

常用命令:

sadd,spop,smembers,sunion 等。

應用場景:

Redis set 對外提供的功能與 list 類似是一個列表的功能,特殊之處在於 set 是可以自動排重的,當你需要儲存一個列表資料,又不希望出現重複資料時,set 是一個很好的選擇,並且 set 提供了判斷某個成員是否在一個 set 集合內的重要介面,這個也是 list 所不能提供的。

實現方式:

set 的內部實現是一個 value 永遠為 null 的 HashMap,實際就是通過計算 hash 的方式來快速排重的,這也是 set 能提供判斷一個成員是否在集合內的原因。

Sorted set

常用命令:

zadd,zrange,zrem,zcard等

使用場景:

Redis sorted set 的使用場景與 set 類似,區別是 set 不是自動有序的,而 sorted set 可以通過使用者額外提供一個優先順序(score)的引數來為成員排序,並且是插入有序的,即自動排序。當你需要一個有序的並且不重複的集合列表,那麼可以選擇 sorted set 資料結構,比如 twitter 的 public timeline 可以以發表時間作為 score 來儲存,這樣獲取時就是自動按時間排好序的。

實現方式:

Redis sorted set 的內部使用 HashMap 和跳躍表(SkipList)來保證資料的儲存和有序,HashMap 裡放的是成員到 score 的對映,而跳躍表裡存放的是所有的成員,排序依據是 HashMap 裡存的 score,使用跳躍表的結構可以獲得比較高的查詢效率,並且在實現上比較簡單。

常用記憶體優化手段與引數

通過我們上面的一些實現上的分析可以看出 redis 實際上的記憶體管理成本非常高,即佔用了過多的記憶體,作者對這點也非常清楚,所以提供了一系列的引數和手段來控制和節省記憶體,我們分別來討論下。

首先最重要的一點是不要開啟 Redis 的 VM 選項,即虛擬記憶體功能,這個本來是作為 Redis 儲存超出實體記憶體資料的一種資料在記憶體與磁碟換入換出的一個持久化策略,但是其記憶體管理成本也非常的高,並且我們後續會分析此種持久化策略並不成熟,所以要關閉 VM 功能,請檢查你的 redis.conf 檔案中 vm-enabled 為 no。

其次最好設定下 redis.conf 中的 maxmemory 選項,該選項是告訴 Redis 當使用了多少實體記憶體後就開始拒絕後續的寫入請求,該引數能很好的保護好你的 Redis 不會因為使用了過多的實體記憶體而導致 swap,最終嚴重影響效能甚至崩潰。

另外 Redis 為不同資料型別分別提供了一組引數來控制記憶體使用,我們在前面詳細分析過 Redis Hash 是 value 內部為一個 HashMap,如果該 Map 的成員數比較少,則會採用類似一維線性的緊湊格式來儲存該 Map,即省去了大量指標的記憶體開銷,這個引數控制對應在 redis.conf 配置檔案中下面2項:

含義是當 value 這個 Map 內部不超過多少個成員時會採用線性緊湊格式儲存,預設是64,即 value 內部有64個以下的成員就是使用線性緊湊儲存,超過該值自動轉成真正的 HashMap。

hash-max-zipmap-value 含義是當 value 這個 Map 內部的每個成員值長度不超過多少位元組就會採用線性緊湊儲存來節省空間。

以上2個條件任意一個條件超過設定值都會轉換成真正的 HashMap,也就不會再節省記憶體了,那麼這個值是不是設定的越大越好呢,答案當然是否定的,HashMap 的優勢就是查詢和操作的時間複雜度都是 O(1) 的,而放棄 Hash 採用一維儲存則是 O(n) 的時間複雜度,如果成員數量很少,則影響不大,否則會嚴重影響效能,所以要權衡好這個值的設定,總體上還是最根本的時間成本和空間成本上的權衡。

同樣類似的引數還有:

說明:list 資料型別多少節點以下會採用去指標的緊湊儲存格式。

說明:list 資料型別節點值大小小於多少位元組會採用緊湊儲存格式。

說明:set 資料型別內部資料如果全部是數值型,且包含多少節點以下會採用緊湊格式儲存。

最後想說的是 Redis 內部實現沒有對記憶體分配方面做過多的優化,在一定程度上會存在記憶體碎片,不過大多數情況下這個不會成為 Redis 的效能瓶 頸,不過如果在 Redis 內部儲存的大部分資料是數值型的話,Redis 內部採用了一個 shared integer 的方式來省去分配記憶體的開銷,即在系統啟動時先分配一個從 1~n 那麼多個數值物件放在一個池子中,如果儲存的資料恰好是這個數值範圍內的資料,則直接從池子裡取出該物件,並且通過引用計數的方式來共享,這樣在系統儲存了大量數值下,也能一定程度上節省記憶體並且提高效能,這個引數值 n 的設定需要修改原始碼中的一行巨集定義 REDIS_SHARED_INTEGERS,該值 預設是 10000,可以根據自己的需要進行修改,修改後重新編譯就可以了。

Redis 的持久化機制

Redis 由於支援非常豐富的記憶體資料結構型別,如何把這些複雜的記憶體組織方式持久化到磁碟上是一個難題,所以 Redis 的持久化方式與傳統資料庫的方式有比較多的差別,Redis 一共支援四種持久化方式,分別是:

– 定時快照方式(snapshot)

– 基於語句追加檔案的方式(aof)

– 虛擬記憶體(vm)

– Diskstore 方式

在設計思路上,前兩種是基於全部資料都在記憶體中,即小資料量下提供磁碟落地功能,而後兩種方式則是作者在嘗試儲存資料超過實體記憶體時,即大資料量的資料儲存,截止到本文,後兩種持久化方式仍然是在實驗階段,並且 vm 方式基本已經被作者放棄,所以實際能在生產環境用的只有前兩種,換句話說 Redis 目前還只能作為小資料量儲存(全部資料能夠載入在記憶體中),海量資料儲存方面並不是 Redis 所擅長的領域。下面分別介紹下這幾種持久化方式:

定時快照方式(snapshot):

該持久化方式實際是在 Redis 內部一個定時器事件,每隔固定時間去檢查當前資料發生的改變次數與時間是否滿足配置的持久化觸發的條件,如果滿足則通過作業系統 fork 呼叫來建立出一個子程式,這個子程式預設會與父程式共享相同的地址空間,這時就可以通過子程式來遍歷整個記憶體來進行儲存操作,而主程式則仍然可以提供服務,當有寫入時由作業系統按照記憶體頁(page)為單位來進行 copy-on-write 保證父子程式之間不會互相影響。

該持久化的主要缺點是定時快照只是代表一段時間內的記憶體映像,所以系統重啟會丟失上次快照與重啟之間所有的資料。

基於語句追加方式(aof

aof 方式實際類似 mysql 基於語句的 binlog 方式,即每條會使 Redis 記憶體資料發生改變的命令都會追加到一個 log 檔案中,也就是說這個 log 檔案就是 Redis 的持久化資料。

aof 的方式的主要缺點是追加 log 檔案可能導致體積過大,當系統重啟恢復資料時如果是 aof 的方式則載入資料會非常慢,幾十G的資料可能需要幾小時才能載入完,當然這個耗時並不是因為磁碟檔案讀取速度慢,而是由於讀取的所有命令都要在記憶體中執行一遍。另外由於每條命令都要寫 log,所以使用 aof 的方式,Redis 的讀寫效能也會有所下降。

虛擬記憶體方式:

虛擬記憶體方式是 Redis 來進行使用者空間的資料換入換出的一個策略,此種方式在實現的效果上比較差,主要問題是程式碼複雜,重啟慢,複製慢等等,目前已經被作者放棄。

diskstore 方式:

diskstore 方式是作者放棄了虛擬記憶體方式後選擇的一種新的實現方式,也就是傳統的 B-tree 的方式,目前仍在實驗階段,後續是否可用我們可以拭目以待。

Redis 持久化磁碟 IO 方式及其帶來的問題

有 Redis 線上運維經驗的人會發現 Redis 在實體記憶體使用比較多,但還沒有超過實際實體記憶體總容量時就會發生不穩定甚至崩潰的問題,有人認為是基於快照方式持久化的 fork 系統呼叫造成記憶體佔用加倍而導致的,這種觀點是不準確的,因為 fork 呼叫的 copy-on-write 機制是基於作業系統頁這個單位的,也就是隻有有寫入的髒頁會被複制,但是一般你的系統不會在短時間內所有的頁都發生了寫入而導致複製,那麼是什麼原因導致 Redis 崩潰的呢?

答案是 Redis 的持久化使用了 Buffer IO 造成的,所謂 Buffer IO 是指 Redis 對持久化檔案的寫入和讀取操作都會使用實體記憶體的 Page Cache,而大多數資料庫系統會使用 Direct IO 來繞過這層 Page Cache 並自行維護一個資料的 Cache,而當 Redis 的持久化檔案過大(尤其是快照檔案),並對其進行讀寫時,磁碟檔案中的資料都會被載入到物理內 存中作為作業系統對該檔案的一層 Cache,而這層 Cache 的資料與 Redis 記憶體中管理的資料實際是重複儲存的,雖然核心在實體記憶體緊張時會做 Page Cache 的剔除工作,但核心很可能認為某塊 Page Cache 更重要,而讓你的程式開始 Swap,這時你的系統就會開始出現不穩定或者崩潰了。我們的經驗是當你的 Redis 實體記憶體使用超過記憶體總容量的3/5時就會開始比較危險了。

下圖是 Redis 在讀取或者寫入快照檔案 dump.rdb 後的記憶體資料圖:

201610095

總結:

1. 根據業務需要選擇合適的資料型別,併為不同的應用場景設定相應的緊湊儲存引數。

2. 當業務場景不需要資料持久化時,關閉所有的持久化方式可以獲得最佳的效能以及最大的記憶體使用量。

3. 如果需要使用持久化,根據是否可以容忍重啟丟失部分資料在快照方式與語句追加方式之間選擇其一,不要使用虛擬記憶體以及 diskstore 方式。

4. 不要讓你的 Redis 所在機器實體記憶體使用超過實際記憶體總量的3/5。

相關文章