直擊Redis持久化磁盤IO痛點,讓存儲不再有負擔!

NoSQL Redis 數據結構 Twitter 中國統計網 中國統計網 2017-09-27

直擊Redis持久化磁盤IO痛點,讓存儲不再有負擔!

作者介紹

張鬆然,京東商城POP平臺系統架構師。豐富的構建高性能高可用大規模分佈式系統的研發、架構經驗。2013年加入京東,專注於商家開放平臺API網關、消息推送、交易服務等解決方案。

Redis 常用數據類型

Redis 最為常用的數據類型主要有以下五種:

  • String

  • Hash

  • List

  • Set

  • Sorted set

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

直擊Redis持久化磁盤IO痛點,讓存儲不再有負擔!

首先 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種存儲方式:

直擊Redis持久化磁盤IO痛點,讓存儲不再有負擔!

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

直擊Redis持久化磁盤IO痛點,讓存儲不再有負擔!

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

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

直擊Redis持久化磁盤IO痛點,讓存儲不再有負擔!

也就是說,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項:

hash-max-zipmap-entries 64

hash-max-zipmap-value 512

hash-max-zipmap-entries

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

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

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

同樣類似的參數還有:

list-max-ziplist-entries 512

說明:list 數據類型多少節點以下會採用去指針的緊湊存儲格式。

list-max-ziplist-value 64

說明:list 數據類型節點值大小小於多少字節會採用緊湊存儲格式。

set-max-intset-entries 512

注: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 後的內存數據圖:

直擊Redis持久化磁盤IO痛點,讓存儲不再有負擔!

總結

  1. 根據業務需要選擇合適的數據類型,併為不同的應用場景設置相應的緊湊存儲參數。

  2. 當業務場景不需要數據持久化時,關閉所有的持久化方式可以獲得最佳的性能以及最大的內存使用量。

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

  4. 不要讓你的 Redis 所在機器物理內存使用超過實際內存總量的3/5。

End.

運行人員:中國統計網小編(微信號:itongjilove)

微博ID:中國統計網

中國統計網,是國內最早的大數據學習網站,公眾號:中國統計網

http://www.itongji.cn

相關推薦

推薦中...