Redis面面觀

ITPUB社群發表於2022-11-29

為什麼要有快取

對於有狀態的服務而言,資料庫往往會成為系統的瓶頸所在。在使用者活躍的高峰期,或者由於PUSH、活動等引發的請求突增,都會給後端的資料庫造成巨大的壓力。

由儲存系統的特性我們知道,從記憶體讀一個資料,比從一般的磁碟讀要快10000倍左右,基於這樣的原因,資料庫本身也會有一定的記憶體cache。但是當熱資料集比較大的時候,本地cache會頻繁淘汰,此時會觸發大量磁碟IO,效能急劇下降,往往也會伴隨有大量的慢日誌。另外,有些資料是需要透過複雜的查詢或計算後得到且又不會頻繁變化的。

雖說資料庫可以透過讀寫分離來擴充套件讀的能力,但存在增加slave例項的成本、主從延遲導致資料不一致等問題。子曾經曰過,“電腦科學領域的任何問題可以透過增加一箇中間層來解決”,於是我們考慮在系統中再增加一個cache層。

需要快取的場景

  1. 使用者個人資料
  2. 關注、粉絲、好友列表
  3. 關注數、粉絲數、好友數
  4. 使用者之間的關係
  5. 使用者瀏覽過的TopN...

Redis是什麼

Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker. It supports data structures such as strings, hashes, lists, sets, sorted sets with range queries, bitmaps, hyperloglogs, geospatial indexes with radius queries and streams. Redis has built-in replication, Lua scripting, LRU eviction, transactions and different levels of on-disk persistence, and provides high availability via Redis Sentinel and automatic partitioning with Redis Cluster.

如果你認為Redis是一個key value store, 那可能會用它來代替MySQL;如果認為它是一個可以持久化的cache, 可能只是用它儲存一些頻繁訪問的臨時資料。Redis是REmote DIctionary Server的縮寫,在Redis在官方網站的的副標題是A persistent key-value database with built-in net interface written in ANSI-C for Posix systems,這個定義偏向key value store。還有一些看法則認為Redis是一個memory database,因為它的高效能都是基於記憶體操作的基礎。另外一些人則認為Redis是一個data structure server,因為Redis支援複雜的資料特性,比如List, Set等。對Redis的作用的不同解讀決定了你對Redis的使用方式。

網際網路資料目前基本使用兩種方式來儲存,關聯式資料庫或者key value。但是這些網際網路業務本身並不屬於這兩種資料型別,比如使用者在社會化平臺中的關係,它是一個list,如果要用關聯式資料庫儲存就需要轉換成一種多行記錄的形式,這種形式存在很多冗餘資料,每一行需要儲存一些重複資訊。如果用key value儲存則修改和刪除比較麻煩,需要將全部資料讀出再寫入。Redis在記憶體中設計了各種資料型別,讓業務能夠高速原子的訪問這些資料結構,並且不需要關心持久儲存的問題,從架構上解決了前面兩種儲存需要走一些彎路的問題。

簡單概括起來Redis有這樣幾個核心特性:

  • 純記憶體資料儲存,並且支援多種持久化
  • 支援豐富的資料結構,比如string,hash,list,set,sorted set,bitmap,hyperloglog, geospatial index等
  • 支援複製、Lua指令碼、LRU淘汰、事務
  • 基於Sentinel實現高可用
  • Cluster模式支援自動分割槽

Redis效能

Redis雖然是單程式單執行緒模型,但是讀寫效能非常優異,單機可支援10wQPS,原因主要有以下幾點:

  • 純記憶體操作,避免了與磁碟的互動
  • 用hash table作為鍵空間,查詢任意的key只需O(1)
  • 單執行緒,天生的佇列模式,避免了因多執行緒競爭而導致的上下文切換和搶鎖的開銷
  • 事件機制,Redis伺服器將所有處理的任務分為兩類事件,一類是採用I/O多路複用處理客戶端請求的網路事件;一類是處理定時任務的時間事件,包括更新統計資訊、清理過期鍵、持久化、主從同步等;

當然這種單執行緒事件機制也是有缺陷的,由於所有的事件都是序列執行,一旦某個事件比較重就會阻塞其它事件,從而導致整個系統的吞吐率下降。比如某個客戶端執行了一個比較重的lua函式、或者使用了諸如keys*、zrange(0,-1)、hgetall等全集合掃描的操作,又或者刪除的過期鍵是個big key,又或者使用了較多記憶體的redis例項進行bgsave時,都會導致伺服器一定程度的阻塞,一般伴隨會有相應的慢日誌。所以我們在實際使用redis的過程中,必須要給每一次的操作分配合理的時間片。

Redis持久化

對於記憶體型資料庫,比如redis和memcache,如果資料狀態不落盤,一旦伺服器程式退出,那麼這些資料狀態也就會全部消失不見。資料狀態的重建需要從後端資料庫回源,這會給後端資料庫造成非常大的壓力,最壞的情況可能會把資料庫壓垮,導致服務不可用。

為了解決這個問題,Redis提供了RDB和AOF兩種持久化方式。前者會生成一份記憶體快照--RDB檔案,該檔案是經過壓縮的二進位制格式,記錄的是鍵值對資料;後者則是以Redis的命令請求協議格式來儲存,記錄的是命令操作;

  • RDB的特點,檔案體積小,載入速度快;但因為是對整個例項的記憶體生成快照,所以操作比較重,一般持久化的間隔不宜太快,所以儲存的資料相對比較舊一些;
  • AOF的特點,檔案體積較大(可以用AOF重寫進行覆蓋);所有的寫操作會追加到AOF緩衝區,持久化的行為可配置,分為三種,always(每次刷盤)、everysec(非同步執行緒每隔1秒刷一次)和no(只寫到page cache,交給作業系統來刷盤);相對來說,AOF檔案資料儲存的比較新一些,所以如果開啟了AOF,那麼Redis伺服器恢復的時候會優先載入AOF檔案。

由於RDB SAVE和AOF重寫會阻塞主執行緒,所以都支援BG模式執行,至於持久化的具體實現這裡就不展開討論了。

Redis豐富的資料型別

比較巧妙的是,Redis並沒有使用固定的資料結構來儲存各種型別的資料,而是建立了一套物件系統,對於同一個物件,可以對應一個或多個不同的底層資料結構(或者叫做編碼方式),某些特定的編碼方式在時空間的效率上有所最佳化,透過執行"Object Encoding"可以查詢當前編碼方式。

  • String 字串物件,最大可支援512MB,memcache最大隻支援1MB。編碼可以是int,raw或者embstr,int對應整型資料,可方便計數,embstr用來儲存長度小於等於39位元組的字串值,採用連續的空間進行儲存,更好利用快取優勢;字串物件常用來進行計數,或者快取序列化的物件;
  • List 列表物件,編碼可以是ziplist或linkedlist,ziplist是為了節約記憶體而開發的,是一個經過特殊編碼的連續記憶體塊組成的順序結構,當列表物件元素的個數較少以及元素的長度較短時會採用這種方式;列表物件一般可用來實現訊息佇列;
  • Hash 雜湊物件,編碼可以是ziplist或hashtable,在Redis的實現裡,採用的鏈式衝突來解決衝突問題,並且為了維護hash表的負載因子在一個合理的範圍,會執行漸進式rehash;雜湊物件一般用於儲存某個物件的屬性資料,便於選擇性查詢,這個效率要比粗暴的序列化和反序列化要高很多,比如使用者的個人資料;另一個用法,則是利用ziplist編碼方式實現壓縮儲存,節省記憶體;
  • Set 集合物件,編碼可以是intset或hashtable,當集合的元素不多且都是整數時,Redis就會使用整數集intset,底層是一個以有序、無重複的方式進行排列的陣列,能有效的節約記憶體;這個物件一般用於去重,比如派獎;
  • Sorted Set 有序集合物件,編碼可以是ziplist或者skiplist,跳躍表skiplist是一種查詢效率可媲美平衡樹的資料結構,平均O(logN),最壞O(N),而且實現更加簡單;其實,Redis用了skiplist和hashtable兩種資料結構來實現zset,一方面hashtable能實現O(1)的查詢,另一方面skiplist實現了有序,可支援範圍查詢;有集合序物件用的就比較廣泛了,比如排行榜(只要是排序相關的列表都可以)、延遲任務佇列等。

Redis的高可用

Redis的高可用,主要透過主從複製機制以及Sentinel叢集來實現。

  • 主從複製 分為兩個階段,首先,當從伺服器發起SYNC命令後,主伺服器會生成最新的RDB檔案傳送給從伺服器,並使用一個緩衝區來記錄從此刻開始主伺服器執行的所有寫命令;待RDB檔案傳輸完之後,再將該緩衝區的資料再傳送給從伺服器,這樣就完成了複製。舊的Redis版本有個缺陷是,如果在第二個階段發生失敗,需要從第一個階段重新開始同步,而這個階段的操作會消耗大量的CPU、記憶體和磁碟I/O以及網路頻寬資源,太過耗費資源。所以從2.8版本開始,實現了部分重同步,透過主從伺服器各維護一個複製偏移量來實現。
  • Sentinel 由一個或多個Sentinel例項組成的哨兵系統,可以監視任意多個主從伺服器,並完成Failover的操作。Sentinal其實是一個執行在特殊模式下的Redis伺服器,執行期間,會與各伺服器建立網路連線,以檢測伺服器的狀態;同時會與其它Sentinel伺服器建立連線,完成資訊交換,比如發現某個主伺服器心跳異常時,會互相詢問心跳結果,當超過一定數量時即可判定為客觀下線;一旦主伺服器被判定為客觀下線狀態,那麼Sentinel叢集會透過raft協議選舉,選出一個Leader來執行Failover。
  • Failover 一般來說,會先選出優先順序最高的從伺服器,然後再從中選出複製偏移量最大的例項,作為新的主伺服器;最後將其它從和舊的主都切換為新主的從。

當從伺服器有2個或者多個時,Redis的主從架構可以有兩種形式。一種是,所有的從伺服器直接掛在主伺服器上,這種模式的優點是,所有從伺服器複製的延遲相對較低,而缺點在於加大了主伺服器的複製壓力;另一種形式,是採用級聯的方式,S1從M複製,S2從S1複製,以此類推,這種模式的優點是,將主伺服器的複製壓力分攤到多個伺服器上,而缺點在於越處於級聯下游的從例項,複製延遲就越大。

從主從複製模式可以看出,Redis的資料只能保證最終一致,不能保證強一致性。

Redis的擴充套件性

讀擴充套件,基於主從架構,可以很好的平行擴充套件讀的能力。寫擴充套件,主要受限於主伺服器的硬體資源的限制,一是單個例項記憶體容量受限,二是一個例項只使用到CPU一個核。下面討論基於多套主從架構Redis例項的叢集實現,目前主要有以下幾種方案:

  • 客戶端分片 實現方案,業務程式透過對key進行hash來分片,用Sentinel做failover。優點:運維簡單,每個例項獨立部署;可使用lua指令碼,業務程式執行的key均hash到同一個分片即可;缺點:一旦重新分片,由於資料無法自動遷移,部分資料需要回源;
  • Redis叢集 是官方提供的分散式資料庫方案,透過分片實現資料共享,並提供複製和failover。按照16384個槽位進行分片,且例項之間共享分片檢視。優點:當發生重新分片時,資料可以自動遷移;缺點:客戶端需要升級到支援叢集協議的版本;客戶端需要感知分片例項,最壞的情況,每個key需要一次重定向;不支援lua指令碼;不支援pipeline;
  • Codis 是由豌豆莢團隊開源的一款分散式元件,它將分散式的邏輯從Redis叢集剝離出來,交由幾個元件來完成,與資料的讀寫解耦。Codis proxy負責分片和聚合,dashboard作為管理後臺,zookeeper做配置管理,Sentinel做failover。優點:底層透明,客戶端相容性好;重新分片時,資料可自動遷移;支援pipeline;支援lua指令碼,業務程式保證執行的key均hash到同一個分片即可;缺點:運維較為複雜;引入了中間層;

Redis使用誤區

  1. 鍵過大

Redis的key是string型別,最大可以是512MB,那麼實際中是不是也可以這樣用呢?答案是否定的,redis將key儲存在一個全域性的hashtable,如果key過大,一是佔用過多的記憶體,二是計算hash和字串比較都會更耗時;一般建議key的大小不超過2kB。

  1. Big key

或者說是big value,這會導致刪除key的操作比較耗時,會阻塞主執行緒。比如有些同學喜歡用集合類的物件,動輒上百萬的元素。對於這類超大集合,一般有兩種最佳化方案,一是採取分片的方式,將每個集合分片控制在較小的範圍內,比如小於1000個元素;二是起一個非同步任務,對集合中的元素分批進行老化。

  1. 全集合掃描

比如在業務程式碼使用了keys*,hgetall,zrange(0, -1)等返回集合中所有元素,這些都屬於阻塞操作,一般考慮用scan,hscan等迭代操作代替。

  1. 單個例項記憶體過大

記憶體過大有什麼問題呢?上文中在講到持久化的時候其實有說到,無論是生成RDB檔案,還是AOF重寫,都是要對整個例項的記憶體資料進行掃描,非常消耗CPU和磁碟資源;當使用Backgroud方式建立子程式時也會涉及到記憶體空間的複製,即便使用了COW機制,也會佔用相當的記憶體開銷。另外,在主從複製的第一階段,save、傳輸和載入RDB檔案的開銷,也會隨著RDB檔案的變大而變大。當單個例項達到瓶頸時,更好的解決方案應該是採用叢集方案。

  1. 大量key同時過期

redis刪除過期鍵採用了惰性刪除和定期刪除相結合的策略,惰性刪除則是在每次GET/SET操作時去刪,定期刪除,則是在時間事件中,從整個key空間隨機取樣,直到過期鍵比率小於25%,如果同時有大量key過期的話,極可能導致主執行緒阻塞。一般可以透過做雜湊來最佳化處理。

Redis不可能比Memcache快?

很多開發者都認為Redis不可能比Memcached快,Memcached完全基於記憶體,而Redis具有持久化儲存特性,即使是非同步的,Redis也不可能比Memcached快。但是測試結果基本是Redis佔絕對優勢,主要原因有兩個。

  • Libevent。和Memcached不同,Redis並沒有選擇libevent。Libevent為了迎合通用性造成程式碼龐大(目前Redis程式碼還不到libevent的1/3)及犧牲了在特定平臺的不少效能。Redis用libevent中兩個檔案修改實現了自己的epoll event loop。業界不少開發者也建議Redis使用另外一個libevent高效能替代libev,但是作者還是堅持Redis應該小巧並去依賴的思路。
  • CAS問題。CAS是Memcached中比較方便的一種防止競爭修改資源的方法。CAS實現需要為每個cache key設定一個隱藏的cas token,cas相當value版本號,每次set會token需要遞增,因此帶來CPU和記憶體的雙重開銷,雖然這些開銷很小,但是到單機10G+ cache以及QPS上萬之後這些開銷就會給雙方帶來一些細微效能差別。

單臺Redis的存放資料是否必須比實體記憶體小

Redis的資料全部放在記憶體帶來了高速的效能,但是也帶來一些不合理之處。比如一箇中型網站有100萬註冊使用者,如果這些資料要用Redis來儲存,記憶體的容量必須能夠容納這100萬使用者。但是業務實際情況是100萬使用者只有5萬活躍使用者,1周來訪問過1次的也只有15萬使用者,因此全部100萬使用者的資料都放在記憶體有不合理之處,RAM需要為冷資料買單。

這跟作業系統非常相似,作業系統所有應用訪問的資料都在記憶體,但是如果實體記憶體容納不下新的資料,作業系統會智慧將部分長期沒有訪問的資料交換到磁碟,為新的應用留出空間。現代作業系統給應用提供的並不是實體記憶體,而是虛擬記憶體(Virtual Memory)的概念。

基於相同的考慮,Redis 2.0也增加了VM特性。讓Redis資料容量突破了實體記憶體的限制。並實現了資料冷熱分離。

Redis的VM依照之前的epoll實現思路依舊是自己實現。但是OS也可以自動幫程式實現冷熱資料分離,Redis只需要OS申請一塊大記憶體,OS會自動將熱資料放入實體記憶體,冷資料交換到硬碟。作者antirez在解釋為什麼要自己實現VM中提到兩個原因。

  • OS的VM換入換出是基於Page概念,比如OS VM 1個Page是4K, 4K中只要還有一個元素即使只有1個位元組被訪問,這個頁也不會被SWAP, 換入也同樣道理,讀到一個位元組可能會換入4K無用的記憶體。而Redis自己實現則可以達到控制換入的粒度。
  • 訪問作業系統SWAP記憶體區域時block程式,也是導致Redis要自己實現VM原因之一。

總結

要想成功使用一種產品,我們需要深入瞭解它的特性。Redis效能突出,如果能夠熟練的駕馭,對其他技術產品的分析使用也會更有體會。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024420/viewspace-2925693/,如需轉載,請註明出處,否則將追究法律責任。

相關文章