Java後端面試知識點彙總✈ 公衆号:知識淺談
Redis
- Redis6.0相關知識
- Redis有多快?
- Redis為什麼這麼快?
- 為什麼Redis是單線程?
- 為什麼使用單線程呢?
- Redis6.0 的多線程
- Redis的五種資料結構的底層實作原理
- Redis的兩層資料結構簡介
- redisObject:兩層資料結構的橋梁
- 第一層資料結構
- 第二層資料結構
- Redis的資料過期清除政策 與 記憶體淘汰政策
- Redis的資料過期清除政策
- Redis的緩存淘汰政策
- Redis中的LRU和LFU算法
- Redis的緩存雪崩、緩存擊穿、緩存穿透與緩存預熱、緩存降級
- 緩存雪崩
- 緩存擊穿
- 緩存穿透
- 緩存預熱
- 緩存降級
- Redis的事務機制
- Redis事務的相關指令
- Redis事務的原理
- Redis的持久化機制
- RDB機制
- AOF機制
- Redis4.0的混合持久化
- Redis主從複制原理
- 什麼是Redis主從複制
- 主從複制的原理
- 主從複制的其他問題
- Redis的高可用
- Redis哨兵機制原理
- 什麼是哨兵模式
- 哨兵模式的搭建
- 哨兵模式的工作原理
- Redis叢集原理詳解
- Redis叢集介紹
- Redis叢集的資料分布算法:哈希槽算法
- Redis叢集中節點的通信機制:goosip協定
- 叢集的擴容與收縮
- 叢集的故障檢測與故障轉恢複機制
- Redis叢集的搭建
- Redis叢集的運維
- Redis的分布式鎖
- 什麼是分布式鎖
- 基于資料庫的分布式鎖
- 基于Zookeeper的分布式鎖
- 基于redis的分布式鎖
- Redis緩存與資料庫雙寫一緻性
- 先更新資料庫,再更新緩存
- 先删緩存,再更新資料庫
- 先更新資料庫,在删除緩存
Redis6.0相關知識
Redis有多快?
Redis是基于記憶體運作的高性能 K-V 資料庫,官方提供的測試報告是單機可以支援約10w/s的QPS,每秒的請求.
Redis為什麼這麼快?
- 完全基于記憶體,資料存在記憶體中,絕大部分請求是純粹的記憶體操作,非常快速,跟傳統的磁盤檔案資料存儲相比,避免了通過磁盤IO讀取到記憶體這部分的開銷。
- 資料結構簡單,對資料操作也簡單。Redis中的資料結構是專門進行設計的,每種資料結構都有一種或多種資料結構來支援。Redis正是依賴這些靈活的資料結構,來提升讀取和寫入的性能。
- 采用單線程,省去了很多上下文切換的時間以及CPU消耗,不存在競争條件,不用去考慮各種鎖的問題,不存在加鎖釋放鎖操作,也不會出現死鎖而導緻的性能消耗。
-
使用基于IO多路複用機制的線程模型,可以處理并發的連結。
Redis 基于 Reactor 模式開發了自己的網絡事件處理器,這個處理器被稱為檔案事件處理器 file event handler。由于這個檔案事件處理器是單線程的,是以Redis才叫做單線程的模型,但是它采用IO多路複用機制同時監聽多個Socket,并根據Socket上的事件來選擇對應的事件處理器進行處理。檔案事件處理器的結構包含4個部分。
線程模型如下圖:
- 多個Socket
- IO多路複用程式
- 檔案事件分派器
- 事件處理器(指令請求處理器,指令回複處理器,連接配接應答處理器)
多個 Socket 可能會産生不同的操作,每個操作對應不同的檔案事件,但是IO多路複用程式會監聽多個Socket,将Socket産生的事件放入隊列中排隊,事件分派器每次從隊列中取出一個事件,把該事件交給對應的事件處理器進行處理。
Redis用戶端對服務端的每次調用都經曆了發送指令,執行指令,傳回結果三個過程。其中執行指令階段,由于Redis是單線程來處理指令的,所有每一條到達服務端的指令不會立刻執行,所有的指令都會進入一個隊列中,然後逐個被執行。并且多個用戶端發送的指令的執行順序是不确定的。但是可以确定的是不會有兩條指令被同時執行,不會産生并發問題,這就是Redis的單線程基本模型。
- 多路I/O複用模型是利用 select、poll、epoll 可以同時監察多個流的 I/O 事件的能力,在空閑的時候,會把目前線程阻塞掉,當有一個或多個流有 I/O 事件時,就從阻塞态中喚醒,然後程式就會輪詢一遍所有的流(epoll 是隻輪詢那些真正發出了事件的流),并且依次順序的處理就緒的流,這種做法就避免了大量的無用操作。
- 這裡“多路”指的是多個網絡連接配接,“複用”指的是複用同一個線程。采用多路 I/O 複用技術可以讓單個線程高效的處理多個用戶端的網絡IO連接配接請求(盡量減少網絡 IO 的時間消耗)
Redis直接自己建構VM機制,避免調用系統函數的時候浪費時間去移動和請求。
為什麼Redis是單線程?
這裡我們強調的單線程,指的是網絡請求子產品使用一個線程來處理,即一個線程處理所有網絡請求,其他子產品仍用了多個線程。
sadas
為什麼使用單線程呢?
因為CPU不是Redis的瓶頸,Redis的瓶頸最有可能是機器記憶體或者網絡帶寬。既然單線程容易實作,而且CPU不會成為瓶頸,那就順理成章地采用單線程的方案了。
但是,我們使用單線程的方式是無法發揮多核CPU 性能,不過我們可以通過在單機開多個Redis 執行個體來解決這個問題
Redis6.0 的多線程
-
Redis6.0 之前為什麼一直不使用多線程?
Redis使用單線程的可維護性高。多線程模型雖然在某些方面表現優異,但是它卻引入了程式執行順序的不确定性,帶來了并發讀寫的一系列問題,增加了系統複雜度、同時可能存線上程切換、甚至加鎖解鎖、死鎖造成的性能損耗。
-
Redis6.0 為什麼要引入多線程呢?
因為Redis的瓶頸不在記憶體,而是在網絡I/O子產品帶來CPU的耗時,是以Redis6.0的多線程是用來處理網絡I/O這部分,充分利用CPU資源,減少網絡I/O阻塞帶來的性能損耗。
-
Redis6.0 怎麼才能開啟多線程?
預設情況下Redis是關閉多線程的,可以在conf檔案進行配置開啟:
- io-threads-do-reads yes
- io-threads 線程數
- 官方建議的線程數設定:4核的機器建議設定為2或3個線程,8核的建議設定為6個線程,線程數一定要小于機器核數,盡量不超過8個。
-
多線程模式下,是否存線上程并發安全問題?
如圖,一次redis請求,要建立連接配接,然後擷取操作的指令,然後執行指令,最後将響應的結果寫到socket上。
在redis的多線程模式下,擷取、解析指令,以及輸出結果着兩個過程,可以配置成多線程執行的,因為它畢竟是我們定位到的主要耗時點,但是指令的執行,也就是記憶體操作,依然是單線程運作的。是以,Redis 的多線程部分隻是用來處理網絡資料的讀寫和協定解析,執行指令仍然是單線程順序執行,也就不存在并發安全問題。
也就是指令的擷取和結果的傳送時多線程操作的因為cpu 的延時主要在網絡傳輸上。
Redis的五種資料結構的底層實作原理
Redis的兩層資料結構簡介
redis的性能高的原因之一是它每種資料結構都是經過專門設計的,并都有一種或多種資料結構來支援,依賴這些靈活的資料結構,來提升讀取和寫入的性能。如果要了解redis的資料結構,可以從兩個不同的層面來讨論它:
(1):第一個層面,是從使用者的角度,這一層面也是Redis暴露給外部的調用接口
string,list,hash,set,sorted set
(2):第二個層面,是從内部實作的角度,屬于更底層的實作,比如:
dict,sds,ziplist,quicklist,skiplist,intset
本文的重點在于讨論第二個層面:
- Redis資料結構的内部實作
- 這兩個層面的資料結構之間的關系:Redis如何通過組合第二個層面的各種基礎資料結構來實作第一個層面的更高層的資料結構
在讨論任何一個系統的内部實作的時候,我們都要先明确它的設計原則,這樣我們才能更深刻地了解它為什麼會進行如此設計的真正意圖。在本文接下來的讨論中,我們主要關注以下幾點:
- 存儲效率。Redis是專用于存儲資料的,它對于計算機資源的主要消耗就在于記憶體,是以節省記憶體是它非常非常重要的一個方面。這意味着Redis一定是非常精細地考慮了壓縮資料、減少記憶體碎片等問題。
- 快速響應時間。與快速響應時間相對的,是高吞吐量。Redis是用于提供線上通路的,對于單個請求的響應時間要求很高,是以,快速響應時間是比高吞吐量更重要的目标。有時候,這兩個目标是沖突的。
- 單線程。Redis的性能瓶頸不在于CPU資源,而在于記憶體通路和網絡IO。而采用單線程的設計帶來的好處是,極大簡化了資料結構和算法的實作。相反,Redis通過異步IO和pipelining等機制來實作高速的并發通路。顯然,單線程的設計,對于單個請求的快速響應時間也提出了更高的要求。
redisObject:兩層資料結構的橋梁
- 什麼時redisObject:
- 從Redis的使用者的角度來看,一個Redis節點包含多個database(非cluster模式下預設是16個,cluster模式下隻能是1個),而一個database維護了從key space到object space的映射關系。這個映射關系的key是string類型,而value可以是多種資料類型,比如:string, list, hash、set、sorted set等。我們可以看到,key的類型固定是string,而value可能的類型是多個。
- 而從Redis内部實作的角度來看,database内的這個映射關系是用一個dict來維護的。dict的key固定用一種資料結構來表達就夠了,這就是動态字元串sds。而value則比較複雜,為了在同一個dict内能夠存儲不同類型的value,這就需要一個通用的資料結構,這個通用的資料結構就是robj,全名是redisObject
舉個例子:
如果value是一個list,那麼它的内部存儲結構是一個quicklist或者是一個ziplist
如果value是一個string,那麼它的内部存儲結構一般情況下是一個sds。但如果string類型的value的值是一個數字,那麼Redis内部還會把它轉成long型來存儲,進而減小記憶體使用。
是以,一個robj既能表示一個sds,也能表示一個quicklist,甚至還能表示一個long型。
- redis的資料結結構定義
#define LRU_BITS 24
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */
int refcount;
void *ptr;
} robj;
(1). 一個robj包含如下5個字段:
type: 對象的資料類型。占4個bit。可能的取值有5種:OBJ_STRING, OBJ_LIST, OBJ_SET, OBJ_ZSET, OBJ_HASH,分别對應Redis對外暴露的5種資料結構
encoding: 對象的内部表示方式(也可以稱為編碼)。占4個bit。可能的取值有10種,即前面代碼中的10個OBJ_ENCODING_XXX常量。
lru: 做LRU替換算法用,占24個bit。這個不是我們這裡讨論的重點,暫時忽略。
refcount: 引用計數。它允許robj對象在某些情況下被共享。
ptr: 資料指針。指向真正的資料。比如,一個代表string的robj,它的ptr可能指向一個sds結構;一個代表list的robj,它的ptr可能指向一個quicklist。
(2). encoding字段的說明
這裡特别需要仔細觀察的是encoding字段,對于同一個type,還可能對應不同的encoding,這說明同樣的一個資料類型,可能存在不同的内部表示方式。而不同的内部表示,在記憶體占用和查找性能上會有所不同。
當type = OBJ_STRING的時候,表示這個robj存儲的是一個string,這時encoding可以是下面3種中的一種:
OBJ_ENCODING_RAW: string采用原生的表示方式,即用sds來表示。
OBJ_ENCODING_INT: string采用數字的表示方式,實際上是一個long型。
OBJ_ENCODING_EMBSTR: string采用一種特殊的嵌入式的sds來表示。
當type = OBJ_HASH的時候,表示這個robj存儲的是一個hash,這時encoding可以是下面2種中的一種:
OBJ_ENCODING_HT: hash采用一個dict來表示
OBJ_ENCODING_ZIPLIST: hash采用一個ziplist來表示
(3)10種encoding的十種取值:
OBJ_ENCODING_RAW: 最原生的表示方式。其實隻有string類型才會用這個encoding值(表示成sds)。
OBJ_ENCODING_INT: 表示成數字。實際用long表示。
OBJ_ENCODING_HT: 表示成dict。
OBJ_ENCODING_ZIPMAP: 是個舊的表示方式,已不再用。在小于Redis 2.6的版本中才有。
OBJ_ENCODING_LINKEDLIST: 也是個舊的表示方式,已不再用。
OBJ_ENCODING_ZIPLIST: 表示成ziplist。
OBJ_ENCODING_INTSET: 表示成intset。用于set資料結構。
OBJ_ENCODING_SKIPLIST: 表示成skiplist。用于sorted set資料結構。
OBJ_ENCODING_EMBSTR: 表示成一種特殊的嵌入式的sds。
OBJ_ENCODING_QUICKLIST: 表示成quicklist。用于list資料結構。
- robj的作用:
- redisObject就是Redis對外暴露的第一層面的資料結構:string, list, hash, set, sorted set,而每一種資料結構的底層實作所對應的是哪些第二層面的資料結構(dict, sds, ziplist, quicklist, skiplist等),則通過不同的encoding來區分。可以說,robj是聯結兩個層面的資料結構的橋梁。
- 為多種資料類型提供一種統一的表示方式。
- 允許同一類型的資料采用不同的内部表示,進而在某些情況下盡量節省記憶體。
- 支援對象共享和引用計數。當對象被共享的時候,隻占用一份記憶體拷貝,進一步節省記憶體。
第一層資料結構
-
String:
String資料結構是最簡單的資料類型。一般用于複雜的計數功能的緩存:微網誌數,粉絲數等。
(1)底層實作方式:動态字元串sds 或者 long
String的内部存儲結構一般是sds(Simple Dynamic String,可以動态擴充記憶體),但是如果一個String類型的value的值是數字,那麼Redis内部會把它轉成long類型來存儲,進而減少記憶體的使用。
- 确切地說,String在Redis中是用一個robj來表示的。
- 用來表示String的robj可能編碼成3種内部表示:OBJ_ENCODING_RAW,OBJ_ENCODING_EMBSTR,OBJ_ENCODING_INT。其中前兩種編碼使用的是sds來存儲,最後一種OBJ_ENCODING_INT編碼直接把string存成了long型。
- 在對string進行incr, decr等操作的時候,如果它内部是OBJ_ENCODING_INT編碼,那麼可以直接進行加減操作;如果它内部是OBJ_ENCODING_RAW或OBJ_ENCODING_EMBSTR編碼,那麼Redis會先試圖把sds存儲的字元串轉成long型,如果能轉成功,再進行加減操作。
- 對一個内部表示成long型的string執行append, setbit, getrange這些指令,針對的仍然是string的值(即十進制表示的字元串),而不是針對内部表示的long型進行操作。比如字元串”32”,如果按照字元數組來解釋,它包含兩個字元,它們的ASCII碼分别是0x33和0x32。當我們執行指令setbit key 7 0的時候,相當于把字元0x33變成了0x32,這樣字元串的值就變成了”22”。而如果将字元串”32”按照内部的64位long型來解釋,那麼它是0x0000000000000020,在這個基礎上執行setbit位操作,結果就完全不對了。是以,在這些指令的實作中,會把long型先轉成字元串再進行相應的操作。
-
Hash
Hash特别适合用于存儲對象,因為一個對象的各個屬性,正好對應一個hash結構的各個field,可以友善地操作對象中的某個字段。
(1)底層實作方式:壓縮清單ziplist 或者 字典dict
當Hash中資料項比較少的情況下,Hash底層才用壓縮清單ziplist進行存儲資料,随着資料的增加,底層的ziplist就可能會轉成dict,具體配置如下:
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
- 當hash中的資料項(即filed-value對)的數目超過512時,也就是ziplist資料項超過1024的時候
- 當hash中插入的任意一個value的長度超過了64位元組的時候
當滿足上面兩個條件其中之一的時候,Redis就使用dict字典來實作hash。
Redis的hash之是以這樣設計,是因為當ziplist變得很大的時候,它有如下幾個缺點:
- 每次插入或修改引發的realloc操作會有更大的機率造成記憶體拷貝,進而降低性能。
- 一旦發生記憶體拷貝,記憶體拷貝的成本也相應增加,因為要拷貝更大的一塊資料。
- 當ziplist資料項過多的時候,在它上面查找指定的資料項就會性能變得很低,因為ziplist上的查找需要進行周遊。
總之,ziplist本來就設計為各個資料項挨在一起組成連續的記憶體空間,這種結構并不擅長做修改操作。一旦資料發生改動,就會引發記憶體realloc,可能導緻記憶體拷貝。
-
List
list 的實作為一個雙向連結清單,經常被用作隊列使用,支援在連結清單兩端進行push和pop操作,時間複雜度為O(1);同時也支援在連結清單中的任意位置的存取操作,但是都需要對list進行周遊,支援反向查找和周遊,時間複雜度為O(n)。
list是一個能維持資料項先後順序的清單(各個資料項的先後順序由插入位置決定),便于在表的兩端追加和删除資料,而對于中間位置的存取具有O(N)的時間複雜度。
list 的應用場景非常多,比如微網誌的關注清單,粉絲清單,消息清單等功能都可以用Redis的 list 結構來實作。可以利用lrange指令,做基于redis的分頁功能。
(1)Redis3.2之前的底層實作方式:壓縮清單ziplist 或者 雙向循環連結清單linkedlist
當list存儲的資料量比較少且同時滿足下面兩個條件時,list就使用ziplist存儲資料:
- list中儲存的每個元素的長度小于 64 位元組;
- 清單中資料個數少于512個。
當不能同時滿足上面兩個條件的時候,list就通過雙向循環連結清單linkedlist來實作了
(2)Redis3.2及之後的底層實作方式:quicklist
quicklist是一個雙向連結清單,而且是一個基于ziplist的雙向連結清單,quicklist的每個節點都是一個ziplist,結合了雙向連結清單和ziplist的優點
-
Set:
set是一個存放不重複值的無序集合,可以做全局去重的功能,提供了判斷某個元素是否在set集合内的功能,這個也是list所不能提供的。基于set可以實作交集、并集、差集的操作,計算共同喜好,全部的喜好,自己獨有的喜好等功能。
(1)底層實作方式:有序整數集合insert或者字典dict
當存儲的資料同時滿足下面這樣兩個條件的時候,Redis 就采用整數集合intset來實作set這種資料類型:
- 存儲的數都是整數
- 存儲的資料元素個數小于512個
當不能同時滿足這兩個條件的時候,Redis就是用dict來存儲集合中的資料。
-
Sorted Set:
Sorted set多了一個權重參數score,集合中的元素能夠按照score進行排列,可以做排行榜應用,取TOP N操作,另外,sorted set可以用來做延時任務,最後一個應用就是做範圍内内查找。
底層實作方式:壓縮清單ziplist 或者 zset
當存儲的資料同時滿足下邊的兩個條件的時候,Redis就使用壓縮清單ziplist實作sorted set
- 集合中每個資料的大小都要小于64位元組
- 元素個數要小于128個,也就是ziplist資料項小于256個
當不能同時滿足這兩個條件的時候,Redis就使用zset來實作sorted set,這個zset包含一個dict + 一個skiplist。dict用來查詢資料到分數(score)的對應關系,而skiplist用來根據分數查詢資料(可能是範圍查找)。
第二層資料結構
-
sds (Simple Dynamic String)
(1) 具有如下的顯著的特點
- 可動态擴充記憶體。sds表示的字元串其内容可以修改,也可以追加。在很多語言中字元串會分為mutable和immutable兩種,顯然sds屬于mutable類型的。
- 采用預配置設定備援空間的方式來減少記憶體的頻繁配置設定。内部為目前字元串實際配置設定的空間,一般要高于實際字元串的長度,當字元串的長度小于1M時,擴容都是擴一倍,如果超過1M,擴容是最多擴1M的空間,字元串的最大長度是512M
- 二進制安全(Binary Safe)。sds能存儲任意二進制資料,而不僅僅是可列印字元。
- 與傳統的C語言字元串類型相容。
(2) sds的資料結構
- sds是Binary Safe的,他可以存儲任意二進制資料,不能像c語言字元串那樣以字元’\0’來辨別字元串的結束,是以它必然有個長度字段。但這個長度字段在哪裡呢?實際上sds還包含一個header結構:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
sds一共有5種類型的header。之是以有5種,是為了能讓不同長度的字元串可以使用不同大小的header。這樣,短字元串就能使用較小的header,進而節省記憶體。
sds字元串的完整結構,由在記憶體位址上前後相鄰的兩部分組成:
- 一個header。通常包含字元串的長度(len)、最大容量(alloc)和flags。sdshdr5有所不同。
- 一個字元串數組,這個字元數組的長度等于最大容量+1,真正有效的字元串資料,其長度通常小于最大容量。在真正的字元串資料之後,是空餘未用的位元組(一般以位元組0填充),允許在不重新配置設定記憶體的前提下讓字元串資料向後做有限的擴充。在真正的字元串資料之後,還有一個NULL結束符,即ASCII碼為0的’\0’字元。這是為了和傳統C字元串相容。之是以字元數組的長度比最大容量多1個位元組,就是為了在字元串長度達到最大容量時仍然有1個位元組存放NULL結束符。
除了sdshdr5之外,其他4個header結構都包含三個字段:
- len: 表示字元串的真正長度(不包含NULL結束符在内)。
- alloc: 表示字元串的最大容量(不包含最後多餘的那個位元組)。
- flags: 總是占用一個位元組。其中的最低3個bit用來表示header的類型。header的類型共有5種,在sds.h中有常量定義。
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
sds字元串的header,其實隐藏在真正的字元串資料的前面(低位址方向)。這樣的一個定義,有如下幾個好處:
- header和資料相鄰,而不用分成兩塊記憶體空間來單獨配置設定。這有利于減少記憶體碎片,提高存儲效率(memory efficiency)。
- 雖然header有多個類型,但sds可以用統一的char *來表達。且它與傳統的C語言字元串保持類型相容。如果一個sds裡面存儲的是可列印字元串,那麼我們可以直接把它傳給C函數,比如使用strcmp比較字元串大小,或者使用printf進行列印。
(3) String robj的編碼與解碼過程:
OBJ_STRING類型的字元串對象的編碼和解碼過程在Redis裡非常重要,應用廣泛。
當我們執行Redis的set指令的時候,Redis首先将接收到的value值(string類型)表示成一個type = OBJ_STRING并且encoding = OBJ_ENCODING_RAW的robj對象,然後在存入内部存儲之前先執行一個編碼過程,試圖将它表示成另一種更節省記憶體的encoding方式。這一過程的核心代碼,是object.c中的tryObjectEncoding函數。
當我們需要擷取字元串的值,比如執行get指令的時候,我們需要執行與前面講的編碼過程相反的操作——解碼。這一解碼過程的核心代碼,是object.c中的getDecodedObject函數。
對于編碼的核心代碼tryObjectEncoding函數和解碼的核心代碼getDecodedObject函數的詳細說明,樣請讀者移步這篇文章:[http://zhangtielei.com/posts/blog-redis-robj.html](http://zhangtielei.com/posts/blog-redis-robj.html)
-
dict
dict是一個用于維護key-value映射關系的資料結構。Redis的一個database中所有key到value的映射,就是使用一個dict來維護的。不過,這隻是它在Redis中的一個用途而已,它在Redis中被使用的地方還有很多。比如,Redis的hash結構,當它的field較多時,便會采用dict來存儲。再比如,Redis配合使用dict和skiplist來共同維護一個sorted set。
在Redis中,dict本質上是為了解決算法中的查找問題,是一個基于哈希表的算法,在不要求資料有序存儲,且能保持較低的哈希值沖突機率的前提下,查詢的時間複雜度接近O(1)。它采用某個哈希函數從key計算得到在哈希表中的位置,采用拉鍊法解決沖突,并在裝載因子(load factor)超過預定值時自動擴充記憶體,引發重哈希(rehashing)。Redis的dict實作最顯著的一個特點,就在于它的重哈希。它采用了一種稱為增量式重哈希(incremental rehashing)的方法,在需要擴充記憶體時避免一次性對所有key進行重哈希,而是将重哈希操作分散到對于dict的各個增删改查的操作中去。這種方法能做到每次隻對一小部分key進行重哈希,而每次重哈希之間不影響dict的操作。dict之是以這樣設計,是為了避免重哈希期間單個請求的響應時間劇烈增加,這與前面提到的“快速響應時間”的設計原則是相符的。
當裝載因子大于 1 的時候,Redis 會觸發擴容,将散清單擴大為原來大小的 2 倍左右;當資料動态減少之後,為了節省記憶體,當裝載因子小于 0.1 的時候,Redis 就會觸發縮容,縮小為字典中資料個數的大約2 倍大小。
-
ziplist
ziplist是一個經過特殊編碼的雙向連結清單,可以用于存儲字元串或整數,其中整數是按真正的二進制表示進行編碼的,而不是編碼成字元串序列。它能以O(1)的時間複雜度在表的兩端提供
和push
pop
操作。
之是以ziplist,就是比list存儲空間更小,原因如下:
- 一個普通的雙向連結清單,連結清單中每一項都占用獨立的一塊記憶體,各項之間用位址指針(或引用)連接配接起來,但這種方式會帶來大量的記憶體碎片,而且位址指針也會占用額外的記憶體。而ziplist卻是使用一整塊連續的記憶體,将表中每一項存放在前後連續的位址空間内,類似于一個數組。
- 另外,ziplist為了在細節上節省記憶體,對于值的存儲采用了變長的編碼方式,大概意思是說,對于大的整數,就多用一些位元組來存儲,而對于小的整數,就少用一些位元組來存儲。
總的來說,ziplist使用一塊連續的記憶體空間來存儲資料,并采用可變長的編碼方式,支援不同類型和大小的資料的存儲,比較節省記憶體。而且資料存儲在一片連續的記憶體空間,讀取的效率也非常高。
-
quicklist
(1):什麼是quicklist
quicklist是一個雙向連結清單,而且是一個基于ziplist的雙向連結清單,quicklist的每個節點都是一個ziplist,比如,一個包含3個節點的quicklist,如果每個節點的ziplist又包含4個資料項,那麼對外表現上,這個list就總共包含12個資料項。
quicklist的結構為什麼這樣設計呢?總結起來,大概又是一個空間和時間的折中:
- 雙向連結清單便于在表的兩端進行push和pop的操作,但是它的記憶體開銷比較大。首先,它在每個節點上除了要儲存資料之外,還要額外儲存兩個指針;其次,雙向連結清單的各個節點是單獨的記憶體塊,位址不連續,節點多了容易産生記憶體碎片。
- ziplist由于是一整塊連續記憶體,是以存儲效率很高。但是,它不利于修改操作,每次資料變動都會引發一次記憶體的realloc。特别是當ziplist長度很長的時候,一次realloc可能會導緻大批量的資料拷貝,進一步降低性能。
于是,結合了雙向連結清單和ziplist的優點,quicklist就應運而生了。
(2)quicklist中每個ziplist長度的配置:
不過,這也帶來了一個新問題:到底一個quicklist節點包含多長的ziplist合适呢?比如,同樣是存儲12個資料項,既可以是一個quicklist包含3個節點,而每個節點的ziplist又包含4個資料項,也可以是一個quicklist包含6個節點,而每個節點的ziplist又包含2個資料項。
這又是一個需要找平衡點的難題。我們隻從存儲效率上分析一下:
- 每個quicklist節點上的ziplist越短,則記憶體碎片越多。記憶體碎片多了,有可能在記憶體中産生很多無法被利用的小碎片,進而降低存儲效率。這種情況的極端是每個quicklist節點上的ziplist隻包含一個資料項,這就蛻化成一個普通的雙向連結清單了。
- 每個quicklist節點上的ziplist越長,則為ziplist配置設定大塊連續記憶體空間的難度就越大。有可能出現記憶體裡有很多小塊的空閑空間(它們加起來很多),但卻找不到一塊足夠大的空閑空間配置設定給ziplist的情況。這同樣會降低存儲效率。這種情況的極端是整個quicklist隻有一個節點,所有的資料項都配置設定在這僅有的一個節點的ziplist裡面。這其實蛻化成一個ziplist了
可見,一個quicklist節點上的ziplist要保持一個合理的長度。那到底多長合理呢?這可能取決于具體應用場景。實際上,Redis提供了一個配置參數
list-max-ziplist-size
,就是為了讓使用者可以來根據自己的情況進行調整。
list-max-ziplist-size -2
這個參數可以取正值,也可以取負值。
當取正值的時候,表示按照資料項個數來限定每個quicklist節點上的ziplist長度。比如,當這個參數配置成5的時候,表示每個quicklist節點的ziplist最多包含5個資料項。
當取負值的時候,表示按照占用位元組數來限定每個quicklist節點上的ziplist長度。這時,它隻能取-1到-5這五個值,每個值含義如下:
-
intset
(1)什麼是intset
intset是一個由整數組成的有序集合,進而便于進行二分查找,用于快速地判斷一個元素是否屬于這個集合。它在記憶體配置設定上與ziplist有些類似,是連續的一整塊記憶體空間,而且對于大整數和小整數(按絕對值)采取了不同的編碼,盡量對記憶體的使用進行了優化。
(2)intset的資料結構
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
各個字段含義如下:
encoding: 資料編碼,表示intset中的每個資料元素用幾個位元組來存儲。它有三種可能的取值:
- INTSET_ENC_INT16表示每個元素用2個位元組存儲,INTSET_ENC_INT32表示每個元素用4個位元組存儲,INTSET_ENC_INT64表示每個元素用8個位元組存儲。是以,intset中存儲的整數最多隻能占用64bit。
- length: 表示intset中的元素個數。encoding和length兩個字段構成了intset的頭部(header)。
-
contents: 是一個柔性數組(flexible array member),表示intset的header後面緊跟着資料元素。這個數組的總長度(即總位元組數)等于encoding * length。柔性數組在Redis的很多資料結構的定義中都出現過(例如sds, quicklist, skiplist),用于表達一個偏移量。contents需要單獨為其配置設定空間,這部分記憶體不包含在intset結構當中。
其中需要注意的是,intset可能會随着資料的添加而改變它的資料編碼:
- 最開始,新建立的intset使用占記憶體最小的INTSET_ENC_INT16(值為2)作為資料編碼。
- 每添加一個新元素,則根據元素大小決定是否對資料編碼進行更新。
(3)intset與ziplist相比:
- ziplist可以存儲任意二進制串,而intset隻能存儲整數。
- ziplist是無序的,而intset是從小到大有序的。是以,在ziplist上查找隻能周遊,而在intset上可以進行二分查找,性能更高。
- ziplist可以對每個資料項進行不同的變長編碼(每個資料項前面都有資料長度字段len),而intset隻能整體使用一個統一的編碼(encoding)。
-
skiplist
(1)什麼是跳表
跳表是一種可以進行二分查找的有序連結清單,采用空間換時間的設計思路,跳表在原有的有序連結清單上面增加了多級索引(例如每兩個節點就提取一個節點到上一級),通過索引來實作快速查找。跳表是一種動态資料結構,支援快速的插入、删除、查找操作,時間複雜度都為O(logn),空間複雜度為 O(n)。跳表非常靈活,可以通過改變索引建構政策,有效平衡執行效率和記憶體消耗。
① 跳表的删除操作:除了要删除原始連結清單中的節點,還要删除索引中的節點。
② 插入元素後,索引的動态更新:不停的往跳表裡面插入資料,如果不更新索引,就有可能出現某兩個索引節點之間的資料非常多的情況,甚至退化成單連結清單。針對這種情況,我們在添加元素的時候,通過一個随機函數,同時選擇将這個資料插入到部分索引層。比如随機函數生成了值 K,那我們就将這個結點添加到第一級到第K級這K級的索引中
Redis的資料過期清除政策 與 記憶體淘汰政策
在使用Redis時,我們一般會為Redis的緩存空間設定一個大小,不會讓資料無限制地放入Redis緩存中。可以使用下面指令來設定緩存的大小,比如設定為4GB:
CONFIG SET maxmemory 4gb
既然 Redis 設定了緩存的容量大小,那緩存被寫滿就是不可避免的。當緩存被寫滿時,我們需要考慮下面兩個問題:決定淘汰哪些資料,如何處理那些被淘汰的資料。
Redis的資料過期清除政策
如果我們設定了Redis的key-value的過期時間,當緩存中的資料過期之後,Redis就需要将這些資料進行清除,釋放占用的記憶體空間。Redis中主要使用 定期删除 + 惰性删除 兩種資料過期清除政策。
-
過期政策:定期删除+惰性删除:
(1)定期删除:redis預設每隔100ms就随機抽取一些設定了過期時間的key,檢查其是否過期,如果有過期就删除。注意這裡是随機抽取的。為什麼要随機呢?你想一想假如 redis 存了幾十萬個 key ,每隔100ms就周遊所有的設定過期時間的 key 的話,就會給 CPU 帶來很大的負載。
為什麼不用定時删除政策呢?
定時删除,用一個定時器來負責監視key,過期則自動删除。雖然記憶體及時釋放,但是十分消耗CPU資源。在大并發請求下,CPU要将時間應用在處理請求,而不是删除key,是以沒有采用這一政策。
- (2) 惰性删除:定期删除可能導緻很多過期的key 到了時間并沒有被删除掉。這時就要使用到惰性删除。在你擷取某個key的時候,redis會檢查一下,這個key如果設定了過期時間并且過期了,是的話就删除。
-
定期删除+惰性删除存在的問題
如果某個key過期後,定期删除沒删除成功,然後也沒再次去請求key,也就是說惰性删除也沒生效。這時,如果大量過期的key堆積在記憶體中,redis的記憶體會越來越高,導緻redis的記憶體塊耗盡。那麼就應該采用記憶體淘汰機制。
Redis的緩存淘汰政策
Redis共提供了8中緩存淘汰政策,其中 volatile-lfu 和 allkeys-lfu 是Redis 4.0版本新增的。
- noeviction:不進行淘汰資料。一旦緩存被寫滿,再有寫請求進來,Redis就不再提供服務,而是直接傳回錯誤。Redis 用作緩存時,實際的資料集通常都是大于緩存容量的,總會有新的資料要寫入緩存,這個政策本身不淘汰資料,也就不會騰出新的緩存空間,我們不把它用在 Redis 緩存中。
- volatile-ttl:在設定了過期時間的鍵值對中,移除即将過期的鍵值對。
- volatile-random:在設定了過期時間的鍵值對中,随機移除某個鍵值對。
- volatile-lru: 在設定了過期時間的鍵值對中,移除最近最少使用的鍵值對。
- volatile-lfu:在設定了過期時間的鍵值對中,移除最近最不頻繁使用的鍵值對
- allkeys-random:在所有鍵值對中,随機移除某個key。
- allkeys-lru:在所有的鍵值對中,移除最近最少使用的鍵值對。
- allkeys-lfu:在所有的鍵值對中,移除最近最不頻繁使用的鍵值對
通常情況下推薦優先使用 allkeys-lru 政策。這樣可以充分利用 LRU 這一經典緩存算法的優勢,把最近最常通路的資料留在緩存中,提升應用的通路性能。
如果你的業務資料中有明顯的冷熱資料區分,建議使用allkeys-lru政策。
如果業務應用中的資料通路頻率相差不大,沒有明顯的冷熱資料區分,建議使用 allkeys-random 政策,随機選擇淘汰的資料就行。
如果沒有設定過期時間的鍵值對,那麼 volatile-lru,volatile-lfu,volatile-random 和 volatile-ttl 政策的行為, 和 noeviction 基本上一緻。
Redis中的LRU和LFU算法
1、LRU算法:
LRU 算法的全稱是 Least Recently Uses,按照最近最少使用的原則來篩選資料,最不常用的資料會被篩選出來。LRU 會把所有的資料組織成一個連結清單,連結清單的頭和尾分别表示 MRU 端和 LRU 端,分别代表最近最常使用的資料和最近最不常用的資料。我們看一個例子。
MRU最近最多使用的,LRU最近最少使用的。
如果有一個新資料 45 要被寫入緩存,但此時已經沒有緩存空間了,也就是連結清單沒有空餘位置了,那麼LRU 算法做兩件事:資料 45 是剛被通路的,是以它會被放到 MRU 端;算法把 LRU 端的資料 5 從緩存中删除,相應的連結清單中就沒有資料 5 的記錄了。LRU認為剛剛被通路的資料,肯定還會被再次通路,是以就把它放在 MRU 端;長久不通路的資料,肯定就不會再被通路了,是以就讓它逐漸後移到 LRU 端,在緩存滿時,就優先删除它。
LRU 算法在實際實作時,需要用連結清單管理所有的緩存資料,移除元素時直接從連結清單隊尾移除,增加時加到頭部就可以了,但這會帶來額外的空間開銷。而且,當有資料被通路時,需要在連結清單上把該資料移動到 MRU 端,如果有大量資料被通路,就會帶來很多連結清單移動操作,會很耗時,進而會降低 Redis 緩存性能。
是以,在 Redis 中,LRU 算法被做了簡化,以減輕資料淘汰對緩存性能的影響。具體來說:Redis 預設會記錄每個資料的最近一次通路的時間戳(由鍵值對資料結構 RedisObject 中的 lru 字段記錄)。然後,Redis 在決定淘汰的資料時,第一次會随機選出 N 個資料,把它們作為一個候選集合。接下來,Redis 會比較這 N 個資料的 lru 字段,把 lru 字段值最小的資料從緩存中淘汰出去。當需要再次淘汰資料時,Redis 需要挑選資料進入第一次淘汰時建立的候選集合。這裡的挑選标準是:能進入候選集合的資料的 lru 字段值必須小于候選集合中最小的 lru 值。當有新資料進入候選資料集後,如果候選資料集中的資料個數達到了 N 個,Redis 就把候選資料集中 lru 字段值最小的資料淘汰出去。這樣一來,Redis 緩存不用為所有的資料維護一個大連結清單,也不用在每次資料通路時都移動連結清單項,提升了緩存的性能。
Redis 提供了一個配置參數 maxmemory-samples,這個參數就是 Redis 選出的資料個數 N。例如,我們執行如下指令,可以讓 Redis 選出 100 個資料作為候選資料集:
CONFIG SET maxmemory-samples 100
RedisObject 的定義如下:(簡單了解為一個 key-value)
typedef struct redisObject {
unsigned type:4; //表示restobject對外展示的類型 String ,還是 List 還是Hash 等等
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
2、LFU算法
LFU是在Redis4.0後出現的,它的核心思想是根據key的最近被通路的頻率進行淘汰,很少被通路的優先被淘汰,被通路的多的則被留下來。LFU算法能更好的表示一個key被通路的熱度。假如你使用的是LRU算法,一個key很久沒有被通路到,隻剛剛是偶爾被通路了一次,那麼它就被認為是熱點資料,不會被淘汰,而有些key将來是很有可能被通路到的則被淘汰了。如果使用LFU算法則不會出現這種情況,因為使用一次并不會使一個key成為熱點資料。它的使用與LRU有所差別:
LFU (Least Frequently Used) :最近最不頻繁使用,跟使用的次數有關,淘汰使用次數最少的。
LRU (Least Recently Used):最近最少使用,跟使用的最後一次時間有關,淘汰最近使用時間離現在最久的。
LFU 實作比較複雜,需要考慮幾個問題:
- 如果實作為連結清單,當對象被通路時按通路次數移動到連結清單的某個有序位置可能是低效的,因為可能存在大量通路次數相同的 key,最差情況是O(n)
- 某些 key 通路次數可能非常之大,理論上可以無限大,但實際上我們并不需要精确的通路次數
- 通路次數特别大的 key 可能以後都不再通路了,但是因為通路次數大而一直占用着記憶體不被淘汰,需要一個方法來逐漸“驅除”(有點 LRU的意思),最簡單的就是逐漸衰減通路次數
本着能省則省的原則,Redis 隻用了 24bit (server.lruclock 也是24bit)來記錄上述的資訊,是的不是 24byte,連32位指針都放不下!
16bit : 上一次遞減時間 (解決第三個問題)
8bit : 通路次數 (解決第二個問題)
通路次數的計算如下:
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}
核心就是通路次數越大,通路次數被遞增的可能性越小,最大 255,可以在配置 redis.conf 中寫明通路多少次遞增多少。由于通路次數是有限的,是以第一個問題也被解決了,直接一個255數組或連結清單都可以。
16bit 部分儲存的是時間戳的後16位(分鐘),表示上一次遞減的時間,算法是這樣執行,随機采樣N個key,檢查遞減時間,如果距離現在超過 N 分鐘(可配置),則遞減或者減半(如果通路次數數值比較大)。
此外,由于新加入的 key 通路次數很可能比不被通路的老 key小,為了不被馬上淘汰,新key通路次數設為 5.
Redis的緩存雪崩、緩存擊穿、緩存穿透與緩存預熱、緩存降級
緩存雪崩
-
什麼是緩存雪崩
如果緩存在某一時刻出現大規模的key失效,那麼就會導緻大量的請求打在資料庫上面,導緻資料庫壓力巨大,如果在高并發的情況下,可能孫堅就會導緻資料庫當機。這時候如果運維馬上又重新開機資料庫,馬上又會有新的流量把資料庫打死。這就是緩存雪崩。
-
問題分析:
造成緩存雪崩的關鍵在于同一時間的大規模的key失效,為什麼會出現這個問題,主要有兩種可能:第一種是Redis當機,第二種可能就是采用了相同的過期時間。搞清楚原因之後,那麼有什麼解決方案呢?
-
解決方案
(1)事前
1. 均勻過期:設定不同的過期時間,讓緩存失效的時間盡量均勻,避免相同的過期時間導緻緩存雪崩,造成大量資料庫的通路。
2. 分級緩存:第一級緩存失效的基礎上,通路二級緩存,每一級緩存的失效時間都不同。
3. 熱點資料緩存永遠不過期。
永不過期實際包含兩層意思:
- 實體不過期,針對熱點key不設定過期時間
- 邏輯過期,把過期時間存在key對應的value裡,如果發現要過期了,通過一個背景的異步線程進行緩存的建構
-
4.保證Redis緩存的高可用,防止Redis當機導緻緩存雪崩的問題。可以使用 主從+ 哨兵,Redis叢集來避免 Redis 全盤崩潰的情況。
(2)事中:
- ① 互斥鎖:在緩存失效後,通過互斥鎖或者隊列來控制讀資料寫緩存的線程數量,比如某個key隻允許一個線程查詢資料和寫緩存,其他線程等待。這種方式會阻塞其他的線程,此時系統的吞吐量會下降
- 使用熔斷機制,限流降級。當流量達到一定的門檻值,直接傳回“系統擁擠”之類的提示,防止過多的請求打在資料庫上将資料庫擊垮,至少能保證一部分使用者是可以正常使用,其他使用者多重新整理幾次也能得到結果。
(3)事後
開啟Redis持久化機制,盡快恢複緩存資料,一旦重新開機,就能從磁盤上自動加載資料恢複記憶體中的資料。
緩存擊穿
-
什麼是緩存擊穿
緩存擊穿跟雪崩有點類似,緩存雪崩是大規模的key失效,而緩存擊穿是某個熱點的key失效,大并發集中對其進行請求,就會造成大量請求讀緩存沒讀到資料,進而導緻高并發通路資料庫,引起資料庫壓力劇增。這種現象就叫做緩存擊穿。
-
問題分析
關鍵在于某個熱點的key失效了,導緻大并發集中打在資料庫上。是以要從兩個方面解決,第一是否可以考慮熱點key不設定過期時間,第二是否可以考慮降低打在資料庫上的請求數量。
-
解決方案
(1)在緩存失效後,通過互斥鎖或者隊列來控制讀資料寫緩存的線程數量,比如某個key隻允許一個線程查詢資料和寫緩存,其他線程等待,這種方式會阻塞其他線程,此時的系統的吞吐量會下降。
(2)熱點資料緩存永遠不過期
永不過期實際包含兩層意思:
- 實體不過期,針對熱點key不設定過期時間
- 邏輯過期,把國旗的時間存儲再key對應的value裡,如果發現要過期了,通過一個背景異步線程進行緩存的建構。
緩存穿透
-
什麼是緩存穿透
緩存穿透是指使用者請求的資料在緩存中不存在即沒有命中,同時在資料庫中也不存在,導緻使用者每次請求該資料都要去資料庫中查詢一遍。如果有惡意攻擊者不斷請求系統中不存在的資料,會導緻短時間大量請求落在資料庫上,造成資料庫壓力過大,甚至導緻資料庫承受不住而當機崩潰。
-
問題分析
緩存穿透的關鍵在于Redis中查不到key值,它和緩存擊穿的差別就是在于傳進來的key在Redis中是不存在的,假如有黑客傳進大量的不存在的key,那麼大量的請求打在資料庫上是很緻命的問題,是以在日常開發中要對參數做好校驗,一些非法的參數,不可能存在的key就直接傳回錯誤提示。
-
解決辦法
(1)将無效的key存放在Redis中:
當出現Redis查不到資料,資料庫也查不到資料的情況,我們就把這個key儲存到Redis中,設定value=“null”,并設定其過期時間極短,後面再出現查詢這個key的請求的時候,直接傳回null,就不需要再查詢資料庫了。但這種處理方式是有問題的,假如傳進來的這個不存在的Key值每次都是随機的,那存進Redis也沒有意義。
(2)使用布隆過濾器
如果布隆過濾器判定某個 key 不存在布隆過濾器中,那麼就一定不存在,如果判定某個 key 存在,那麼很大可能是存在(存在一定的誤判率)。于是我們可以在緩存之前再加一個布隆過濾器,将資料庫中的所有key都存儲在布隆過濾器中,在查詢Redis前先去布隆過濾器查詢 key 是否存在,如果不存在就直接傳回,不讓其通路資料庫,進而避免了對底層存儲系統的查詢壓力。
如何選擇:針對一些惡意攻擊,攻擊帶過來的大量key是随機,那麼我們采用第一種方案就會緩存大量不存在key的資料。那麼這種方案就不合适了,我們可以先對使用布隆過濾器方案進行過濾掉這些key。是以,針對這種key異常多、請求重複率比較低的資料,優先使用第二種方案直接過濾掉。而對于空資料的key有限的,重複率比較高的,則可優先采用第一種方式進行緩存。
緩存預熱
- 什麼是緩存預熱
- 緩存預熱是指系統上線後,提前将相關的緩存資料加載到緩存系統。避免在使用者請求的時候,先查詢資料庫,然後再将資料緩存的問題,使用者直接查詢事先被預熱的緩存資料。
- 如果不進行預熱,那麼Redis初始狀态資料為空,系統上線初期,對于高并發的流量,都會通路到資料庫中, 對資料庫造成流量的壓力。
- 緩存預熱解決方案
- 資料量不大的時候,工程啟動的時候進行加載緩存動作;
- 資料量大的時候,設定一個定時任務腳本,進行緩存的重新整理;
- 資料量太大的時候,優先保證熱點資料進行提前加載到緩存。
緩存降級
緩存降級是指緩存失效或緩存伺服器挂掉的情況下,不去通路資料庫,直接傳回預設資料或通路服務的記憶體資料。降級一般是有損的操作,是以盡量減少降級對于業務的影響程度。
在項目實戰中通常會将部分熱點資料緩存到服務的記憶體中,這樣一旦緩存出現異常,可以直接使用服務的記憶體資料,進而避免資料庫遭受巨大壓力。
Redis的事務機制
Redis事務的相關指令
-
MULTI
用于标記事務塊的開啟。MULTI執行之後,Redis會将後續的指令逐個放到一個緩存隊列中,當EXEC指令被調用時,所有隊列中的指令才會被原子化執行。
-
EXEC
在一個事務中執行所有先前放入隊列的指令,然後恢複正常的連接配接狀态。當使用WATCH指令的時候,隻有當受監控的指令沒有被修改的時候,EXEC指令才會執行事務中的指令。
-
DISCARD
放棄事務,清除事務隊列中的指令,然後恢複正常的連接配接狀态。如果使用了UNWATCH指令,那麼DISCARD就會取消目前連接配接監控的所有鍵。
-
WATCH
當某個事物需要按照條件執行的時候,就要使用該指令将kay設定為受監控的,如果在事務執行之前key被其他指令改動,那麼整個事務将會被打斷。WATCH指令可用于提供CAS功能。
-
UNWATCH
清楚事務中所有監控的鍵,如果調用了EXEC或者DISCARD指令,那麼就不需要手動調用UNWATCH指令。
Redis事務的原理
-
事務的定義
Redis的事務本質是一組指令的集合,一個事務中的指令要麼全部執行,要麼都不執行。事務的原理是先将屬于一個事務的指令的發送給Redis,存放到一個隊列中,再讓Redis 依次執行這些指令,如果在發送EXEC指令前用戶端斷線了,則Redis會清空事務隊列,事務中的所有指令都不會執行。而一旦用戶端發送了EXEC指令,所有的指令就都會被執行,即使此後用戶端斷線也沒關系,因為Redis中已經記錄了所有要執行的指令。
除此之外,Redis的事務還能保證一個事務内的指令依次執行而不被其他指令插入。也就是說,在事務執行期間,伺服器不會中斷事務而改去執行其他用戶端的指令請求,即不會被其它指令插入,不許加塞,等事務中的所有指令都執行完畢才去處理其他用戶端的指令請求。即一次性、順序性、排他性的執行一系列指令。
-
Redis事務的特性
(1)原子性:Redis的原子性隻能保證批量操作的一次性執行,和傳統mysql事務不同的是,Redis不支援復原,在執行EXEC指令時,如果Redis事務中某條指令執行失敗,其後的指令仍然會被執行,沒有復原。
Redis為什麼不支援復原rollback?
- Redis 操作失敗的原因隻可能是文法錯誤或者錯誤的資料類型操作,這些都是在開發期間能發現的問題,不會進入到生産環境,是以不需要復原。
- Redis 内部設計推崇簡單和高性能,支援事務復原能力會導緻設計複雜,這與Redis的初衷相違背,是以不需要復原能力。
- Redis 的應用場景明顯不是為了資料存儲的高可靠性與強一緻性設計的,而是為了資料通路的高性能而設計,設計者為了簡單性和高性能而部分放棄了原子性。
-
(2)隔離性:事務是一個單獨的隔離操作,沒有隔離級别的概念,事務隊列中的指令在沒有送出之前都不會實際的被執行。在事務中,所有指令都會被序列化,按順序地執行。事務在執行的過程中,其他用戶端發送來的指令請求不會插入到事務執行指令序列中。
(3)持久性:如果Redis運作在某種特定的持久化模式下時,事務也具有持久性。
-
Redis事務的錯誤處理
如果一個事務中的某個指令執行出錯,Redis會怎樣處理呢?要回答這個問題,首先需要知道什麼原因會導緻指令執行出錯。
(1)文法錯誤
文法錯誤指指令不存在或者指令參數的個數不對。比如:
redis>MULTI
OK
redis>SET key value
QUEUED
redis>SET key
(error)ERR wrong number of arguments for 'set' command
redis> errorCOMMAND key
(error) ERR unknown command 'errorCOMMAND'
redis> EXEC
(error)
跟在MULTI指令後執行了3個指令:一個是正确的指令,成功地加入事務隊列;其餘兩個指令都有文法錯誤。而隻要有一個指令有文法錯誤,執行EXEC指令後Redis就會直接傳回錯誤,連文法正确的指令也不會執行。
注意: 再redis2..6.5之前的版本中會忽略文法錯誤的指令,然後執行事務中其他文法正确的指令,就此例而言,SET key value會被執行,EXEC指令會傳回一個結果:1) OK。
(2)運作錯誤
運作錯誤指在指令執行時出現的錯誤,比如使用散列類型的指令操作集合類型的鍵,這種錯誤在實際執行之前Redis是無法發現的,是以在事務裡這樣的指令是會被Redis接受并執行的。如果事務裡的一條指令出現了運作錯誤,事務裡其他的指令依然會繼續執行(包括出錯指令之後的指令),示例如下:
redis>MULTI
OK
redis>SET key 1
QUEUED
redis>SADD key 2
QUEUED
redis>SET key 3
QUEUED
redis>EXEC
1) OK
2) (error) ERR Operation against a key holding the wrong kind of value
3) OK
redis>GET key
"3"
可見雖然SADD key 2出現了錯誤,但是SET key 3依然執行了。
Redis的持久化機制
- Redis是一個基于記憶體的資料庫,所有的資料都存放在記憶體中,如果突然當機,資料就會全部丢失,是以必須有一種機制來保證 Redis 的資料不會因為故障而丢失,這種機制就是 Redis 的持久化機制。
- Redis的持久化機制有兩種,第一種是RDB快照,第二種是AOF日志。RDB快照是一次全量備份,AOF是連續的增量備份。快照是記憶體資料的二進制序列化形式,在存儲上非常緊湊,而 AOF 日志記錄的是記憶體資料修改的指令記錄文本。
RDB機制
RDB快照就是把資料以快照的形式儲存在磁盤上,是某個時間點的一次全量資料備份,以二進制序列化形式的檔案存儲,并且在存儲上非常緊密。
RDB持久化是指在指定的時間間隔内将記憶體中的資料集以快照的方式寫入磁盤,并儲存到一個名為dump.rdb的二進制檔案中,也是預設的持久化方式,它恢複時是将快照檔案從磁盤直接讀到記憶體裡。
-
觸發機制
RDB來說持久化觸發機制有三種:save、bgsave、自動化觸發
(1)save指令觸發
該指令會阻塞目前Redis伺服器,執行save指令期間,Redis不能處理其他指令,直到RDB完成為止,如果資料量大的話會造成長時間的阻塞,是以線上環境一般禁止使用。
(2)bgsave指令觸發
執行該指令時,Redis會在背景異步進行快照操作,快照同時還可以響應用戶端請求。具體流程如下:
執行bgsave指令時,Redis主程序會fork一個子程序來完成RDB的過程(主程序用來處理其他的請求,但是主程序的寫到做不會同步到主記憶體,而是寫到一個副本中,當持久化完成,把副本寫到主記憶體),會先将資料寫入到一個臨時二進制檔案中,待持久化過程都結束了,再用這個臨時檔案替換上次持久化好的檔案(可以了解為Copy On Write機制)。Redis主程序阻塞時間隻有fork階段的那一下。相對于save,阻塞時間很短。基本上 Redis 内部所有的RDB操作都是采用 bgsave 指令
fork的作用是複制一個與目前程序一樣的程序。新程序的所有資料(變量、環境變量、程式計數器等)數值都和原程序一緻,但是是一個全新的程序,并作為原程序的子程序。
-
(3) 自動觸發:
自動觸發是可以在redis.conf配置檔案中修改,預設達到以下三種條件,就會自動觸發持久化,觸發後,底層調用的其實還有bgsave指令:
1分鐘内改了1萬次,5分鐘内改了10次,或15分鐘内改了1次。
save 900 1 #在900秒(15分鐘)之後,如果至少有1個key發生變化,則dump記憶體快照。
save 300 10 #在300秒(5分鐘)之後,如果至少有10個key發生變化,則dump記憶體快照。
save 60 10000 #在60秒(1分鐘)之後,如果至少有10000個key發生變化,則dump記憶體快照。
- 如果不需要持久化機制,則可以注釋掉所有的save指令。
-
RDB執行流程
(1)執行bgsave指令的時候,Redis主程序會檢查是否有子程序在執行RDB/AOF持久化任務,如果有的話,直接傳回,主要是為了性能的考慮,防止兩個程序同時對磁盤進行寫入操作
(2)Redis主程序fork一個子程序來執行執行RDB操作,fork操作會對主程序造成阻塞(影響Redis的讀寫),fork操作完成後會發消息給主程序,進而不再阻塞主程序(阻塞僅指主程序fork子程序的過程,後續子程序執行操作時不會阻塞)
(3)RDB子程序把Redis主程序的記憶體資料,寫入到一個臨時的快照檔案,持久化完成後,再使用臨時快照檔案替換掉原來的RDB檔案。(該過程中主程序的讀寫不受影響,但Redis的寫操作不會同步到主程序的主記憶體中,而是會寫到一個臨時的記憶體區域作為一個副本)
(4)子程序完成RDB持久化後會發消息給主程序,通知RDB持久化完成,并将步驟(3)中的記憶體副本中的增量寫資料同步到主記憶體
-
優勢:
(1)RDB檔案緊湊,全量備份,非常适合用于進行備份和災難恢複。
(2)對于大規模資料的恢複,且對于資料恢複的完整性不是非常敏感的場景,RDB的恢複速度要比AOF方式更加的高效。
(3)生成RDB檔案的時候,redis主程序會fork()一個子程序來處理所有儲存工作,主程序不需要進行任何磁盤IO操作。
-
劣勢:
(1)fork的時候,記憶體中的資料被克隆了一份,大緻2倍的膨脹性需要考慮。
(2)當進行快照持久化時,會開啟一個子程序專門負責快照持久化,子程序會擁有父程序的記憶體資料,父程序修改記憶體子程序不會反應出來,是以在快照持久化期間修改的資料不會被儲存,可能丢失資料。
(3)在一定間隔時間做一次備份,是以如果redis意外down掉的話,就會丢失最後一次快照後的所有修改。
AOF機制
-
什麼是AOF
每次都使用RDB機制全量備份的方式是非常耗時間的,是以Redis還提供了另一種持久化機制AOF(append only file)。AOF日志是持續增量的備份,将Redis執行過的每個寫操作以日志的形式記錄下來(讀操作不記錄),隻許追加檔案但不可以改寫檔案(appendonly.aof檔案)。redis啟動的時候會讀取該檔案進行資料恢複,根據日志檔案的内容将寫指令從前到後執行一次以完成資料的恢複工作。
-
AOF檔案的rewrite機制
(1)因為AOF采用檔案追加方式,檔案會越來越大,為避免出現此種情況,需要定期進行AOF重寫,當AOF檔案的大小超過所設定的門檻值時,Redis就會對AOF檔案的内容壓縮,隻保留可以恢複資料的最小指令集。redis提供了bgrewriteaof指令,fork一個新的程序對aof檔案進行重寫。
(2)重寫原理:AOF檔案持續增長而過大時,會fork出一條新程序來重寫aof檔案,将記憶體中的整個資料庫内容用指令的方式重寫了一個新的aof檔案(注意:在重寫時并不是讀取舊的aof檔案),在執行 BGREWRITEAOF 指令時,Redis 伺服器會維護一個 AOF 重寫緩沖區,該緩沖區會在子程序建立新AOF檔案期間,記錄伺服器執行的所有寫指令。當子程序完成建立新AOF檔案的工作之後,伺服器會将重寫緩沖區中的所有内容追加到新AOF檔案的末尾,使得新舊兩個AOF檔案所儲存的資料庫狀态一緻。最後,伺服器用新的AOF檔案替換舊的AOF檔案,以此來完成AOF檔案重寫操作。
(3)觸發時機:Redis會記錄上次重寫時的AOF大小,預設配置是當AOF檔案大小是上次rewrite後大小的一倍且檔案大于64M時觸發。
-
AOF持久化觸發機制
(1)每修改同步:appendfsync always 同步持久化,每次發生資料變更會被立即記錄到磁盤,性能較差但資料完整性比較好。
(2)每秒同步:appendfsync everysec 異步操作,每秒記錄,如果一秒内當機,有資料丢失。
(3)不同步:appendfsync no 從不同步
Linux 的 glibc 提供了 fsync()函數可以将指定檔案的内容強制從核心緩存刷到磁盤。隻要 Redis 程序實時調用 fsync 函數就可以保證 aof 日志不丢失。但是 fsync 是一個磁盤 IO 操作,它速度很慢,如果每條指令都要 fsync 一次,那麼 Redis 高性能的地位就不保了。
-
優點
(1)AOF可以更好的保護資料不丢失,一般AOF會每隔1秒,通過一個背景線程執行一次fsync操作,最多丢失1秒鐘的資料。
(2)AOF隻是追加寫日志檔案,對伺服器性能影響較小,速度比RDB要快,消耗的記憶體較少
(3)AOF日志檔案即使過大的時候,出現背景重寫操作,也不會影響用戶端的讀寫。
(4)AOF日志檔案的指令通過非常可讀的方式進行記錄,這個特性非常适合做災難性的誤删除的緊急恢複。比如某人不小心用flushall指令清空了所有資料,隻要這個時候背景rewrite還沒有發生,那麼就可以立即拷貝AOF檔案,将最後一條flushall指令給删了,然後再将該AOF檔案放回去,就可以通過恢複機制,自動恢複所有資料。
5、劣勢:
(1)對于相同資料集的資料而言,aof檔案要遠大于rdb檔案,恢複速度慢于rdb。
(2)對于每秒一次同步的情況,aof運作效率要慢于rdb,不同步效率和rdb相同。
注:如果同時開啟兩種持久化方式,在這種情況下,當redis重新開機的時候會優先載入AOF檔案來恢複原始的資料,因為在通常情況下AOF檔案儲存的資料集要比RDB檔案儲存的資料集要完整。
Redis4.0的混合持久化
- 僅使用RDB快照方式恢複資料,由于快照時間粒度較大時,會丢失大量資料。
- 僅使用AOF重放方式恢複資料,日志性能相對 rdb 來說要慢。在 Redis 執行個體很大的情況下,啟動需要花費很長的時間。
為了解決這個問題,Redis4.0開始支援RDB和AOF的混合持久化(預設關閉,可以通過配置項 aof-use-rdb-preamble 開啟)。RDB 檔案的内容和增量的 AOF 日志檔案存在一起,這裡的 AOF 日志不再是全量的日志,而是自持久化開始到持久化結束的這段時間發生的增量 AOF 日志,通常這部分 AOF 日志很小
- 大量資料使用粗粒度(時間上)的rdb快照方式,性能高,恢複時間快。
- 增量資料使用細粒度(時間上)的AOF日志方式,盡量保證資料的不丢失。
在Redis重新開機時,進行AOF重寫的時候就直接把RDB的内容寫到 AOF 檔案開頭。這樣做的好處是可以結合 RDB和 AOF 的優點,快速加載同時避免丢失過多的資料。當然缺點也是有的, AOF 裡面的 RDB 部分是壓縮格式不再是AOF 格式,可讀性較差。
另外,可以使用下面這種方式:Master使用AOF,Slave使用RDB快照,master需要首先確定資料完整性,它作為資料備份的第一選擇;slave提供隻讀服務或僅作為備機,它的主要目的就是快速響應用戶端read請求或災切換。
Redis主從複制原理
什麼是Redis主從複制
-
主從複制的架構
Redis Replication是一種 master-slave 模式的複制機制,這種機制使得 slave 節點可以成為與 master 節點完全相同的副本,可以采用一主多從或者級聯結構。
主從複制的配置要點:
(1)配從庫不配主,從庫配置:slaveof 主庫IP 主庫端口
(2)檢視redis的配置資訊:info replication
-
Redis為什麼需要主從複制
使用Redis主從複制的原因主要是單台Redis節點存在以下的局限性:
(1)Redis雖然讀寫的速度都很快,單節點的Redis能夠支撐QPS大概在5w左右,如果上千萬的使用者通路,Redis就承載不了,成為了高并發的瓶頸。
(2)單節點的Redis不能保證高可用,當Redis因為某些原因意外當機時,會導緻緩存不可用
(3)CPU的使用率上,單台Redis執行個體隻能利用單個核心,這單個核心在面臨海量資料的存取和管理工作時壓力會非常大。
-
主從複制的好處
(1)資料備援:主從複制實作了資料的熱備份,是持久化之外的一種資料備援方式。
(2)故障恢複:如果master宕掉了,使用哨兵模式,可以提升一個 slave 作為新的 master,進而實作故障轉移,實作高可用
(3)負載均衡:可以輕易地實作橫向擴充,實作讀寫分離,一個 master 用于寫,多個 slave 用于分攤讀的壓力,進而實作高并發;
-
主從複制的缺點
由于所有的寫操作都是先在Master上操作,然後同步更新到Slave上,是以從Master同步到Slave伺服器有一定的延遲,當系統很繁忙的時候,延遲問題會更加嚴重,Slave機器數量的增加也會使這個問題更加嚴重。
主從複制的原理
從總體上來說,Redis主從複制的政策就是:當主從伺服器剛建立連接配接的時候,進行全量同步;全量複制結束後,進行增量複制。當然,如果有需要,slave 在任何時候都可以發起全量同步。
-
主從全量複制的流程
Redis全量複制一般發生在Slave初始化階段,這時Slave需要将Master上的所有資料都複制一份,具體步驟如下:
(1)slave伺服器連接配接到master伺服器,便開始進行資料同步,發送psync指令(Redis2.8之前是sync指令)
(2)master伺服器收到psync指令之後,開始執行bgsave指令生成RDB快照檔案并使用緩存區記錄此後執行的所有寫指令
- 如果master收到了多個slave并發連接配接請求,它隻會進行一次持久化,而不是每個連接配接都執行一次,然後再把這一份持久化的資料發送給多個并發連接配接的slave。
- 如果RDB複制時間超過60秒(repl-timeout),那麼slave伺服器就會認為複制失敗,可以适當調節大這個參數
- (3) master伺服器bgsave執行完之後,就會向所有Slava伺服器發送快照檔案,并在發送期間繼續在緩沖區内記錄被執行的寫指令
client-output-buffer-limit slave 256MB 64MB 60,如果在複制期間,記憶體緩沖區持續消耗超過64MB,或者一次性超過256MB,那麼停止複制,複制失敗
-
(4) slave伺服器收到RDB快照檔案後,會将接收到的資料寫入磁盤,然後清空所有舊資料,在從本地磁盤載入收到的快照到記憶體中,同時基于舊的資料版本對外提供服務。
(5) master伺服器發送完RDB快照檔案之後,便開始向slave伺服器發送緩沖區中的寫指令
(6) slave伺服器完成對快照的載入,開始接收指令請求,并執行來自主伺服器緩沖區的寫指令;
(7) 如果slave node開啟了AOF,那麼會立即執行BGREWRITEAOF,重寫AOF
下圖中的sync是2.8之前的指令,2.8之後改成psync。
-
增量複制
Redis的增量複制是指在初始化的全量複制并開始正常工作之後,master伺服器将發生的寫操作同步到slave伺服器的過程,增量複制的過程主要是master伺服器每執行一個寫指令就會向slave伺服器發送相同的寫指令,slave伺服器接收并執行收到的寫指令。
-
斷點續傳
3.1 什麼是斷點續傳
當master-slave網絡連接配接斷掉後,slave重新連接配接master時,會觸發全量複制,但是從2.8版本開始,slave與master能夠在網絡連接配接斷開重連後,隻從中斷處繼續進行複制,而不必重新同步,這就是所謂的斷點續傳。
斷電續傳這個新特性使用psync指令,舊的實作中使用sync指令。Redis2.8版本可以檢測出它所連接配接的伺服器是否支援PSYNC指令,不支援的話使用SYNC指令。master伺服器收到slave發送的psync指令後,會根據自身的情況做出對應的處理,可能是FULLRESYNC runid offset觸發全量複制,也可能是CONTINUE觸發增量複制
指令格式:psync runid offset
-
3.2 工作原理
(1) master伺服器在記憶體緩沖區中給每個slave伺服器都維護了一份同步備份日志(in-memory backlog),緩存最近一段時間的資料,預設大小1m,如果超過這個大小就會清理掉。
(2)同時,master 和 slave 伺服器都維護了一個複制偏移量(replication offset)和 master線程ID(master run id),每個slave伺服器在跟master伺服器進行同步時都會攜帶master run id 和 最後一次同步的複制偏移量offset,通過offset可以知道主從之間的資料不一緻的情況。
(3)當連接配接斷開時,slave伺服器會重新連接配接上master伺服器,然後請求繼續複制。假如主從伺服器的兩個master run id相同,并且指定的偏移量offset在同步備份日志中還有效,複制就會從上次中斷的點開始繼續。如果其中一個條件不滿足,就會進行完全重新同步,因為主運作id不儲存在磁盤中,如果從伺服器重新開機的話就隻能進行完全同步了。
master伺服器維護的offset是存儲在backlog中,msater就是根據slave發送的offset來從backlog中擷取資料的
- (4)在部分同步過程中,master會将本地記錄的同步備份日志中記錄的指令依次發送給slave伺服器進而達到資料一緻。
-
無磁盤化複制
在前面全量複制的過程中,master會将資料儲存在磁盤的rdb檔案中然後發送給slave伺服器,但如果master上的磁盤空間有限或者是使用比較低速的磁盤,這種操作會給master伺服器帶來較大的壓力,那怎麼辦呢?在Redis2.8之後,可以通過無盤複制來達到目的,由master直接開啟一個socket,在記憶體中建立RDB檔案,再将rdb檔案發送給slave伺服器,不使用磁盤作為中間存儲。(無盤複制一般應用在磁盤空間有限但是網絡狀态良好的情況下)
repl-diskless-sync :是否開啟無磁盤複制
repl-diskless-sync-delay:等待一定時長再開始複制,因為要等更多slave重新連接配接過來.
主從複制的其他問題
-
主從複制的特點
(1)Redis使用異步複制,每次接收到寫指令之後,先在内部寫入資料,然後異步發送給slave伺服器。但從Redis 2.8開始,從伺服器會周期性的應答從複制流中處理的資料量。
(2)Redis主從複制不阻塞master伺服器。也就是說當若幹個從伺服器在進行初始同步時,主伺服器仍然可以處理外界請求。
(3)主從複制不阻塞slave伺服器。當master伺服器進行初始同步時,slave伺服器傳回的是以前舊版本的資料,如果你不想這樣,那麼在啟動redis配置檔案中進行設定,那麼從redis在同步過程中來自外界的查詢請求都會傳回錯誤給用戶端;
雖然說主從複制過程中對于從redis是非阻塞的,它會用舊的資料集來提供服務,但是當初始同步完成後,需删除舊資料集和加載新的資料集,在這個短暫時間内,從伺服器會阻塞連接配接進來的請求,對于大資料集,加載到記憶體的時間也是比較多的
-
(4)主從複制提高了redis服務的擴充性,避免單個redis伺服器的讀寫通路壓力過大的問題,同時也可以給為資料備份及備援提供一種解決方案;
(5)使用主從複制可以為master伺服器免除把資料寫入磁盤的消耗,可以配置讓master伺服器不再将資料持久化到磁盤,而是通過連接配接讓一個配置的slave類型的Redis伺服器及時将相關資料持久化到磁盤。不過這種做法存在master類型的Redis伺服器一旦重新開機,因為此時master伺服器不進行持久化,是以資料為空,這時候通過主從同步可能導緻slave類型的Redis伺服器上的資料也被清空,是以這個配置要確定主伺服器不會自動重新開機(詳見第2點的“master開啟持久化對主從架構的安全意義”)
-
master開啟持久化對主從架構的安全意義
使用主從架構,必須開啟master伺服器的持久化機制,不建議用slave伺服器作為master伺服器的資料熱備。當不能這麼做時,比如考慮到延遲的問題,應該将master伺服器配置為避免自動重新開機。否則,在關閉master伺服器持久化機制并開始自動重新開機的情況下,會造成主從伺服器資料被清空的情況。也就是master的持久化關閉,可能在master當機重新開機的時候資料是空的(RDB和AOF都關閉了),此時就會将空資料複制到slave ,導緻slave伺服器的資料也丢了。
-
過期key的處理
對于slave伺服器上過期鍵的處理,主要是有master伺服器負責。如果master過期了一個key,則由master伺服器負責鍵的過期删除處理,然後将相關删除指令以資料同步的方式同步給slave伺服器,slave伺服器根據删除指令删除本地的key。
Redis的高可用
前面說過,通過主從複制,如果master伺服器當機了,可以提升一個slave伺服器作為新的master伺服器,實作故障的轉移,實作高可用。也就是說,當master宕掉之後,可以手動執行“SLAVEOF no one”指令,重新選擇一台伺服器作為master伺服器。但是呢,我們總不能保證每次master宕掉之後,都可以及時察覺并手動執行該指令,這時就可以使用“哨兵模式sentinel”,哨兵模式其實就是“SLAVEOF no one”指令的自動版,能夠背景監控master是否故障,如果故障了,則根據投票數自動将slave轉換為master,如果之前的master重新開機回來,不會造成雙master沖突,因為原本的master會變成slave。
配置步驟:
(1)在自定義的/myredis目錄下建立sentinel.conf檔案(名字絕不能錯)。
(2)配置哨兵,在配置檔案中寫: sentinel monitor 被監控資料庫名字 127.0.0.1 6379 1
最後的數字1,表示主機挂掉後salve投票看讓誰接替成為主機,得票數多少後成為主機。
(3)啟動哨兵:redis-sentinel /myredis/sentinel.conf
Redis哨兵機制原理
什麼是哨兵模式
- 哨兵模式的架構
-
什麼是哨兵模式
在主從模式下(主從模式就是把上圖的所有哨兵去掉),master節點負責寫請求,然後異步同步給slave節點,從節點負責處理讀請求。如果master當機了,需要手動将從節點晉升為主節點,并且還要切換用戶端的連接配接資料源。這就無法達到高可用,而通過哨兵模式就可以解決這一問題。
哨兵模式是Redis的高可用方式,哨兵節點是特殊的redis服務,不提供讀寫服務,主要用來監控redis執行個體節點。哨兵架構下client端第一次從哨兵找出redis的主節點,後續就直接通路redis的主節點,不會每次都通過sentinel代理通路redis的主節點,當redis的主節點挂掉時,哨兵會第一時間感覺到,并且在slave節點中重新選出來一個新的master,然後将新的master資訊通知給client端,進而實作高可用。這裡面redis的client端一般都實作了訂閱功能,訂閱sentinel釋出的節點變動消息。
- 哨兵的主要工作任務
(1)監控:哨兵會不斷的檢查你的Master和Slave是否運作正常。
(2)提醒:當被監控的某個Redis節點出現問題時,哨兵可以通過 API 向管理者或者其他應用程式發送通知。
(3)自動故障遷移:當一個Master不能正常工作時,哨兵會進行自動故障遷移操作,将失效Master的其中一個Slave更新為新的Master,并讓失效Master的其他Slave改為複制新的Master;當用戶端試圖連接配接失效的Master時,叢集也會向用戶端傳回新Master的位址,使得叢集可以使用新Master代替失效Master。
哨兵模式的搭建
- 配置sentinel.conf檔案,配件需要監聽的主從的master節點
sentinel monitor <master‐name> <ip> <redis‐port> <quorum>
(1)master‐name:主節點master的名字
(2)quorum:哨兵叢集中多少個sentinel 認為 master 失效才判定為客觀下線,一般配節點數/2+1,也就是說大于半數
- 如果主從master設定了密碼,還需要配置
sentinel auth-pass <master-name> <password>
- 由于master挂了之後,哨兵會進行重新的選舉,如果slave也配置了連接配接密碼,那麼最好在其他的節點都配置上 masterauth xxx,保證挂了的服務重新開機之後能正常加入主從中去
- 修改心跳檢測的主觀下線時間(後續)
sentinel down-after-milliseconds <master-name> <time>
(1)time:主觀下線門檻值,機關為毫秒ms
- 從伺服器的個數配置
sentinel parallel-syncs mymaster 2
- 啟動指定的哨兵配置檔案啟動哨兵
./redis-server sentinel.conf --sentinel &
-
檢視狀态資訊
配置完之後,進入./redis-cli,輸入info指令,檢視哨兵的狀态資訊
- 再使用同樣的配置檔案,啟動另外兩個哨兵,在檢視資訊之後會發現哨兵數量變成3個
- Java用戶端連接配接哨兵模式,隻需要配置哨兵節點即可
spring.redis.sentinel.master=mymaster #哨兵配置中叢集名字
spring.redis.sentinel.nodes=哨兵ip1:哨兵端口1,哨兵ip2:哨兵端口2,哨兵ip3:哨兵端口3
哨兵模式的工作原理
哨兵是一個分布式系統,可以在一個架構中運作多個哨兵程序,這些程序使用流言協定(gossip protocols)來傳播Master是否下線的資訊,并使用投票協定(agreement protocols)來決定是否執行自動故障遷移,以及選擇哪個Slave作為新的Master。哨兵模式的具體工作原理如下:
-
心跳機制
(1)Sentinel與RedisNode:Redis Sentinel 是一個特殊的 Redis 節點。在哨兵模式建立時,需要通過配置指定 Sentinel 與 Redis Master Node 之間的關系,然後 Sentinel 會從主節點上擷取所有從節點的資訊,之後 Sentinel 會定時向主節點和從節點發送 info 指令擷取其拓撲結構和狀态資訊。
(2)Sentinel與Sentinel:基于 Redis 的訂閱釋出功能, 每個 Sentinel 節點會向主節點的 sentinel:hello 頻道上發送該 Sentinel 節點對于主節點的判斷以及目前 Sentinel 節點的資訊 ,同時每個 Sentinel 節點也會訂閱該頻道, 來擷取其他 Sentinel 節點的資訊以及它們對主節點的判斷
通過以上兩步所有的 Sentinel 節點以及它們與所有的 Redis 節點之間都已經彼此感覺到,之後每個 Sentinel 節點會向主節點、從節點、以及其餘 Sentinel 節點定時發送 ping 指令作為心跳檢測, 來确認這些節點是否可達。
-
判斷master節點是否下線
(1)每個 sentinel 哨兵節點每隔1s 向所有的master、slave以及其他 sentinel 節點發送一個PING指令,作用是通過心跳檢測,檢測主從伺服器的網絡連接配接狀态
(2)如果 master 節點回複 PING 指令的時間超過 down-after-milliseconds 設定的門檻值(預設30s),則這個 master 會被 sentinel 标記為主觀下線,修改其 flags 狀态為SRI_S_DOWN
(3)當sentinel 哨兵節點将 master 标記為主觀下線後,會向其餘所有的 sentinel 發送sentinel is-master-down-by-addr消息,詢問其他sentinel是否同意該master下線
發送指令:sentinel is-master-down-by-addr <ip> <port> <current_epoch> <runid>
ip:主觀下線的服務ip
port:主觀下線的服務端口
current_epoch:sentinel的紀元
runid:*表示檢測服務下線狀态,如果是sentinel的運作id,表示用來選舉領頭sentinel
- (4)每個sentinel收到指令之後,會根據發送過來的 ip和port 檢查自己判斷的結果,回複自己是否認為該master節點已經下線了
回複内容主要包含三個參數(由于上面發送的runid參數是*,這裡先忽略後兩個參數)
down_state(1表示已下線,0表示未下線)
leader_runid(領頭sentinal id)
leader_epoch(領頭sentinel紀元)
-
(5) sentinel收到回複之後,如果同意master節點進入主觀下線的sentinel數量大于等于quorum,則master會被标記為客觀下線,即認為該節點已經不可用。
(6) 在一般情況下,每個 Sentinel 每隔 10s 向所有的Master,Slave發送 INFO 指令。當Master 被 Sentinel 标記為客觀下線時,Sentinel 向下線的 Master 的所有 Slave 發送 INFO 指令的頻率會從 10 秒一次改為每秒一次。作用:發現最新的叢集拓撲結構
-
基于Raft算法選舉領頭sentinel
到現在為止,已經知道了master客觀下線,那就需要一個sentinel來負責故障轉移,那到底是哪個sentinel節點來做這件事呢?需要通過選舉實作,具體的選舉過程如下:
(1)判斷客觀下線的sentinel節點向其他
注意:這時的runid是自己的run id,每個sentinel節點都有一個自己運作時id
-
(2)目标sentinel回複是否同意master下線并選舉領頭sentinel,選擇領頭sentinel的過程符合先到先得的原則。舉例:sentinel1判斷了客觀下線,向sentinel2發送了第一步中的指令,sentinel2回複了sentinel1,說選你為領頭,這時候sentinel3也向sentinel2發送第一步的指令,sentinel2會直接拒絕回複
(3)當sentinel發現選自己的節點個數超過 majority 的個數的時候,自己就是領頭節點
(4)如果沒有一個sentinel達到了majority的數量,等一段時間,重新選舉.
- 故障轉移
故障轉移到底要選擇哪一個slaver節點來作為master呢?
我們會認為哪個slave節點中的資料和master中的資料相識度高哪個slaver就是master了,其實哨兵模式也差不多是這樣判斷的。
- (1) 在進行選擇之前需要先剔除掉一些不滿足條件的slaver,這些slaver不會作為變成master的備選
剔除清單中已經下線的從服務
剔除有5s沒有回複sentinel的info指令的slave
剔除與已經下線的主服務連接配接斷開時間超過 down-after-milliseconds * 10 + master當機時長 的slaver
- (2)選主過程
① 選擇優先級最高的節點,通過sentinel配置檔案中的replica-priority配置項,這個參數越小,表示優先級越高
② 如果第一步中的優先級相同,選擇offset最大的,offset表示主節點向從節點同步資料的偏移量,越大表示同步的資料越多
③ 如果第二步offset也相同,選擇run id較小的
-
修改配置
新的master節點選擇出來之後,還需要做一些事情配置的修改,如下:
(1)領頭sentinel會對選出來的從節點執行slaveof no one 指令讓其成為主節點
(2)領頭sentinel 向别的slave發送slaveof指令,告訴他們新的master是誰誰誰,你們向這個master複制資料
(3)如果之前的master重新上線時,領頭sentinel同樣會給起發送slaveof指令,将其變成從節點
Redis叢集原理詳解
Redis叢集介紹
-
為什麼需要Redis叢集
在講Redis叢集架構之前,我們先簡單看Redis單執行個體的結構,從最開始的一主N重,到讀寫分離,再到Sentinel哨兵機制,單執行個體的Redis緩存足以應對大多數的使用場景,也能實作主從故障遷移。
-
但是,在某些場景下,單執行個體存Redis緩存會存在幾個問題:
(1)寫并發:Redis單執行個體讀寫分離可以解決讀操作的負載均衡,但對于寫操作,仍然是全部落在了master節點上面,在海量資料高并發場景,一個節點寫資料容易出現瓶頸,造成master節點的壓力上升。
(2)海量資料的存儲壓力:單執行個體Redis本質上隻有一台Master作為存儲,如果面對海量資料的存儲,一台Redis的伺服器就應付不過來了,而且資料量太大意味着持久化成本高,嚴重時可能會阻塞伺服器,造成服務請求成功率下降,降低服務的穩定性。
針對以上的問題,Redis叢集提供了較為完善的方案,解決了存儲能力受到單機限制,寫操作無法負載均衡的問題。
-
什麼是Redis叢集
Redis3.0加入了Redis的叢集模式,實作了資料的分布式存儲,對資料進行分片,将不同的資料存儲在不同的master節點上面,進而解決了海量資料的存儲問題。
Redis叢集采用去中心化的思想,沒有中心節點的說法,對于用戶端來說,整個叢集可以看成一個整體,可以連接配接任意一個節點進行操作,就像操作單一Redis執行個體一樣,不需要任何代理中間件,當用戶端操作的key沒有配置設定到該node上時,Redis會傳回轉向指令,指向正确的node。
Redis也内置了高可用機制,支援N個master節點,每個master節點都可以挂載多個slave節點,當master節點挂掉時,叢集會提升它的某個slave節點作為新的master節點。
- 如上圖所示,Redis叢集可以看成多個主從架構組合起來的,每一個主從架構可以看成一個節點(其中,隻有master節點具有處理請求的能力,slave節點主要是用于節點的高可用)
Redis叢集的資料分布算法:哈希槽算法
-
什麼是哈希槽算法
前面講到,Redis叢集通過分布式存儲的方式解決了單節點的海量資料存儲的問題,對于分布式存儲,需要考慮的重點就是如何将資料進行拆分到不同的Redis伺服器上。常見的分區算法有hash算法、一緻性hash算法,關于這些算法這裡就不多介紹。
- 普通hash算法:将key使用hash算法計算之後,按照節點數量來取餘,即hash(key)%N。優點就是比較簡單,但是擴容或者摘除節點時需要重新根據映射關系計算,會導緻資料重新遷移。
- 一緻性hash算法:為每一個節點配置設定一個token,構成一個哈希環;查找時先根據key計算hash值,然後順時針找到第一個大于等于該哈希值的token節點。優點是在加入和删除節點時隻影響相鄰的兩個節點,缺點是加減節點會造成部分資料無法命中,是以一般用于緩存,而且用于節點量大的情況下,擴容一般增加一倍節點保障資料負載均衡。沒看明白
-
Redis叢集采用的算法是哈希槽分區算法。Redis叢集中有16384個哈希槽(槽的範圍是 0 -16383,哈希槽),将不同的哈希槽分布在不同的Redis節點上面進行管理,也就是說每個Redis節點隻負責一部分的哈希槽。在對資料進行操作的時候,叢集會對使用CRC16算法對key進行計算并對16384取模(slot = CRC16(key)%16383),得到的結果就是 Key-Value 所放入的槽,通過這個值,去找到對應的槽所對應的Redis節點,然後直接到這個對應的節點上進行存取操作。
使用哈希槽的好處就在于可以友善的添加或者移除節點,并且無論是添加删除或者修改某一個節點,都不會造成叢集不可用的狀态。當需要增加節點時,隻需要把其他節點的某些哈希槽挪到新節點就可以了;當需要移除節點時,隻需要把移除節點上的哈希槽挪到其他節點就行了;哈希槽資料分區算法具有以下幾種特點:
- 解耦資料和節點之間的關系,簡化了擴容和收縮難度;
- 節點自身維護槽的映射關系,不需要用戶端代理服務維護槽分區中繼資料
- 支援節點、槽、鍵之間的映射查詢,用于資料路由,線上伸縮等場景
槽的遷移與指派指令:CLUSTER ADDSLOTS 0 1 2 3 4 … 5000
預設情況下,redis叢集的讀和寫都是到master上去執行的,不支援slave節點讀和寫,跟Redis主從複制下讀寫分離不一樣,因為redis叢集的核心的理念,主要是使用slave做資料的熱備,以及master故障時的主備切換,實作高可用的。Redis的讀寫分離,是為了橫向任意擴充slave節點去支撐更大的讀吞吐量。而redis叢集架構下,本身master就是可以任意擴充的,如果想要支撐更大的讀或寫的吞吐量,都可以直接對master進行橫向擴充。
-
Redis中哈希槽相關的資料結構
(1)clusterNode資料結構:儲存節點的目前狀态,比如節點的建立時間,節點的名字,節點目前的配置紀元,節點的IP和位址,等等。
- (2)clusterState資料結構:記錄目前節點所認為的叢集目前所處的狀态。
-
(3)節點的槽指派資訊:
clusterNode資料結構的slots屬性和numslot屬性記錄了節點負責處理哪些槽。
slots屬性是一個二進制位數組(bit array),這個數組的長度為16384/8=2048個位元組,共包含16384個二進制位。Master節點用bit來辨別對于某個槽自己是否擁有,時間複雜度為O(1)
-
(4)叢集所有槽的指派資訊:
當收到叢集中其他節點發送的資訊時,通過将節點槽的指派資訊儲存在本地的clusterState.slots數組裡面,程式要檢查槽i是否已經被指派,又或者取得負責處理槽i的節點,隻需要通路clusterState.slots[i]的值即可,時間複雜度僅為O(1)
- 如上圖所示,ClusterState 中儲存的 Slots 數組中每個下标對應一個槽,每個槽資訊中對應一個 clusterNode 也就是緩存的節點。這些節點會對應一個實際存在的 Redis 緩存服務,包括 IP 和 Port 的資訊。Redis Cluster 的通訊機制實際上保證了每個節點都有其他節點和槽資料的對應關系。無論Redis 的用戶端通路叢集中的哪個節點都可以路由到對應的節點上,因為每個節點都有一份 ClusterState,它記錄了所有槽和節點的對應關系。
-
叢集的請求重定向
前面講到,Redis叢集在用戶端層面沒有采用代理,并且無論Redis 的用戶端通路叢集中的哪個節點都可以路由到對應的節點上,下面來看看 Redis 用戶端是如何通過路由來調用緩存節點的:
(1)MOVED請求
-
如上圖所示,Redis 用戶端通過 CRC16(key)%16383 計算出 Slot 的值,發現需要找“緩存節點1”進行資料操作,但是由于緩存資料遷移或者其他原因導緻這個對應的 Slot 的資料被遷移到了“緩存節點2”上面。那麼這個時候 Redis 用戶端就無法從“緩存節點1”中擷取資料了。但是由于“緩存節點1”中儲存了所有叢集中緩存節點的資訊,是以它知道這個 Slot 的資料在“緩存節點2”中儲存,是以向 Redis 用戶端發送了一個 MOVED 的重定向請求。這個請求告訴其應該通路的“緩存節點2”的位址。Redis 用戶端拿到這個位址,繼續通路“緩存節點2”并且拿到資料。
(2)ASK請求
上面的例子說明了,資料 Slot 從“緩存節點1”已經遷移到“緩存節點2”了,那麼用戶端可以直接找“緩存節點2”要資料。那麼如果兩個緩存節點正在做節點的資料遷移,此時用戶端請求會如何處理呢?
-
Redis 用戶端向“緩存節點1”送出請求,此時“緩存節點1”正向“緩存節點 2”遷移資料,如果沒有命中對應的 Slot,它會傳回用戶端一個 ASK 重定向請求并且告訴“緩存節點2”的位址。用戶端向“緩存節點2”發送 Asking 指令,詢問需要的資料是否在“緩存節點2”上,“緩存節點2”接到消息以後傳回資料是否存在的結果。
(3)頻繁重定向造成的網絡開銷的處理:smart用戶端
-
什麼是 smart用戶端:
在大部分情況下,可能都會出現一次請求重定向才能找到正确的節點,這個重定向過程顯然會增加叢集的網絡負擔和單次請求耗時。是以大部分的用戶端都是smart的,所謂的smart用戶端就是指用戶端本地維護一份hashslot => node的映射表緩存,大部分情況下,直接走本地緩存就可以找到hashslot => node,不需要通過節點進行moved重定向。
- JedisCluster的工作原理
- 在JedisCluster初始化的時候,就會随機選擇一個node,初始化hashslot => node映射表,同時為每個節點建立一個JedisPool連接配接池。
- 每次基于JedisCluster執行操作時,首先會在本地計算key的hashslot,然後在本地映射表找到對應的節點node。
- 如果那個node正好還是持有那個hashslot,那麼就ok;如果進行了reshard操作,可能hashslot已經不在那個node上了,就會傳回moved。
- 如果JedisCluter API發現對應的節點傳回moved,那麼利用該節點傳回的中繼資料,更新本地的hashslot => node映射表緩存
- 重複上面幾個步驟,直到找到對應的節點,如果重試超過5次,那麼就報錯,JedisClusterMaxRedirectionException
-
hashslot遷移和ask重定向
如果hashslot正在遷移,那麼會傳回ask重定向給用戶端。用戶端接收到ask重定向之後,會重新定位到目标節點去執行,但是因為ask發生在hashslot遷移過程中,是以JedisCluster API收到ask是不會更新hashslot本地緩存。
雖然ASK與MOVED都是對用戶端的重定向控制,但是有本質差別。ASK重定向說明叢集正在進行slot資料遷移,用戶端無法知道遷移什麼時候完成,是以隻能是臨時性的重定向,用戶端不會更新slots緩存。但是MOVED重定向說明鍵對應的槽已經明确指定到新的節點,用戶端需要更新slots緩存。
Redis叢集中節點的通信機制:goosip協定
redis叢集的哈希槽算法解決的是資料的存取問題,不同的哈希槽位于不同的節點上,而不同的節點維護着一份它所認為的目前叢集的狀态,同時,Redis叢集是去中心化的架構。那麼,當叢集的狀态發生變化時,比如新節點加入、slot遷移、節點當機、slave提升為新Master等等,我們希望這些變化盡快被其他節點發現,Redis是如何進行處理的呢?也就是說,Redis不同節點之間是如何進行通信進行維護叢集的同步狀态呢?
在Redis叢集中,不同的節點之間采用gossip協定進行通信,節點之間通訊的目的是為了維護節點之間的中繼資料資訊。這些中繼資料就是每個節點包含哪些資料,是否出現故障,通過gossip協定,達到最終資料的一緻性。
gossip協定,是基于流行病傳播方式的節點或者程序之間資訊交換的協定。原理就是在不同的節點間不斷地通信交換資訊,一段時間後,所有的節點就都有了整個叢集的完整資訊,并且所有節點的狀态都會達成一緻。每個節點可能知道所有其他節點,也可能僅知道幾個鄰居節點,但隻要這些節可以通過網絡連通,最終他們的狀态就會是一緻的。Gossip協定最大的好處在于,即使叢集節點的數量增加,每個節點的負載也不會增加很多,幾乎是恒定的。
Redis叢集中節點的通信過程如下:
叢集中每個節點都會單獨開一個TCP通道,用于節點間彼此通信。
每個節點在固定周期内通過待定的規則選擇幾個節點發送ping消息
接收到ping消息的節點用pong消息作為響應
使用gossip協定的優點在于将中繼資料的更新分散在不同的節點上面,降低了壓力;但是缺點就是中繼資料的更新有延時,可能導緻叢集中的一些操作會有一些滞後。另外,由于 gossip 協定對伺服器時間的要求較高,時間戳不準确會影響節點判斷消息的有效性。而且節點數量增多後的網絡開銷也會對伺服器産生壓力,同時結點數太多,意味着達到最終一緻性的時間也相對變長,是以官方推薦最大節點數為1000左右。
redis cluster架構下的每個redis都要開放兩個端口号,比如一個是6379,另一個就是加1w的端口号16379。
6379端口号就是redis伺服器入口。
16379端口号是用來進行節點間通信的,也就是 cluster bus 的東西,cluster bus 的通信,用來進行故障檢測、配置更新、故障轉移授權。cluster bus 用的是一種叫gossip 協定的二進制協定
-
gossip協定的常見類型
gossip協定常見的消息類型包含: ping、pong、meet、fail等等。
(1)meet:主要用于通知新節點加入到叢集中,通過「cluster meet ip port」指令,已有叢集的節點會向新的節點發送邀請,加入現有叢集。
(2)ping:用于交換節點的中繼資料。每個節點每秒會向叢集中其他節點發送 ping 消息,消息中封裝了自身節點狀态還有其他部分節點的狀态資料,也包括自身所管理的槽資訊等等。
- 因為發送ping指令時要攜帶一些中繼資料,如果很頻繁,可能會加重網絡負擔。是以,一般每個節點每秒會執行 10 次 ping,每次會選擇 5 個最久沒有通信的其它節點。
- 如果發現某個節點通信延時達到了 cluster_node_timeout / 2,那麼立即發送 ping,避免資料交換延時過長導緻資訊嚴重滞後。比如說,兩個節點之間都 10 分鐘沒有交換資料了,那麼整個叢集處于嚴重的中繼資料不一緻的情況,就會有問題。是以 cluster_node_timeout 可以調節,如果調得比較大,那麼會降低 ping 的頻率。
- 每次 ping,會帶上自己節點的資訊,還有就是帶上 1/10 其它節點的資訊,發送出去,進行交換。至少包含 3 個其它節點的資訊,最多包含 (總節點數 - 2)個其它節點的資訊。
-
(3)pong:piing和meet消息的響應,同樣包含了自身節點的狀态和叢集中繼資料的消息。
(4)fail: 某個節點判斷另一個節點fail之後,向叢集所有節點廣播該節點挂掉的消息,其他節點收到消息後标記已下線。
由于Redis叢集的去中心化以及gossip通信機制,Redis叢集中的節點隻能保證最終一緻性。例如當加入新節點時(meet),隻有邀請節點和被邀請節點知道這件事,其餘節點要等待 ping 消息一層一層擴散。除了 Fail 是立即全網通知的,其他諸如新節點、節點重上線、從節點選舉成為主節點、槽變化等,都需要等待被通知到,也就是Gossip協定是最終一緻性的協定。
- meet指令的實作
-
(1)節點A會為節點B建立一個clusterNode結構,并将該結構添加到自己的clusterState.nodes字典裡面。
(2)節點A根據CLUSTER MEET指令給定的IP位址和端口号,向節點B發送一條MEET消息
(3)節點B接收到節點A發送的MEET消息,節點B會為節點A建立一個clusterNode結構,并将該結構添加到自己的clusterState.nodes字典裡面。
(4)節點B向節點A傳回一條PONG消息。
(5)節點A将受到節點B傳回的PONG消息,通過這條PONG消息,節點A可以知道節點B已經成功的接收了自己發送的MEET消息。
(6)之後,節點A将向節點B傳回一條PING消息。
(7)節點B将接收到的節點A傳回的PING消息,通過這條PING消息節點B可以知道節點A已經成功的接收到了自己傳回的PONG消息,握手完成。
(8)之後,節點A會将節點B的資訊通過Gossip協定傳播給叢集中的其他節點,讓其他節點也與節點B進行握手,最終,經過一段時間後,節點B會被叢集中的所有節點認識。
叢集的擴容與收縮
作為分布式部署的緩存節點總會遇到緩存擴容和緩存故障的問題。這就會導緻緩存節點的上線和下線的問題。由于每個節點中儲存着槽資料,是以當緩存節點數出現變時,這些槽資料會根據對應的虛拟槽算法被遷移到其他的緩存節點上。是以對于redis叢集,叢集伸縮主要在于槽和資料在節點之間移動。
- 擴容
(1) 啟動新節點
(2) 使用cluster meet指令将新節點加入到叢集
(3) 遷移槽和資料:添加新節點後,需要将一些槽盒資料從舊節點遷移到新結點
-
如上圖所示,叢集中本來存在“緩存節點1”和“緩存節點2”,此時“緩存節點3”上線了并且加入到叢集中。此時根據虛拟槽的算法,“緩存節點1”和“緩存節點2”中對應槽的資料會應該新節點的加入被遷移到“緩存節點3”上面。
新節點加入到叢集的時候,作為孤兒節點是沒有和其他節點進行通訊的。是以需要在叢集中任意節點執行 cluster meet 指令讓新節點加入進來。假設新節點是 192.168.1.1 5002,老節點是 192.168.1.1 5003,那麼運作以下指令将新節點加入到叢集中。
192.168.1.1 5003> cluster meet 192.168.1.1 5002
- 這個是由老節點發起的,有點老成員歡迎新成員加入的意思。新節點剛剛建立沒有建立槽對應的資料,也就是說沒有緩存任何資料。如果這個節點是主節點,需要對其進行槽資料的擴容;如果這個節點是從節點,就需要同步主節點上的資料。總之就是要同步資料。
- 如上圖所示,由用戶端發起節點之間的槽資料遷移,資料從源節點往目标節點遷移。
(1)用戶端對目标節點發起準備導入槽資料的指令,讓目标節點準備好導入槽資料。這裡使用 cluster setslot {slot} importing {sourceNodeId} 指令。
(2)之後對源節點發起送指令,讓源節點準備遷出對應的槽資料。使用指令 cluster setslot {slot} importing {sourceNodeId}。
(3)此時源節點準備遷移資料了,在遷移之前把要遷移的資料擷取出來。通過指令 cluster getkeysinslot {slot} {count}。Count 表示遷移的 Slot 的個數。
(4)然後在源節點上執行,migrate {targetIP} {targetPort} “” 0 {timeout} keys {keys} 指令,把擷取的鍵通過流水線批量遷移到目标節點。
(5)重複 3 和 4 兩步不斷将資料遷移到目标節點。
(6)完成資料遷移到目标節點以後,通過 cluster setslot {slot} node {targetNodeId} 指令通知對應的槽被配置設定到目标節點,并且廣播這個資訊給全網的其他主節點,更新自身的槽節點對應表。
- 收縮
- 遷移槽
- 忘記節點,通過指令 cluster forget {downNodeId} 通知其他的節點
- 為了安全删除節點,Redis叢集隻能下線沒有負責槽的節點。是以如果要下線有負責槽的master節點,則需要先将它負責的槽遷移到其他節點。遷移的過程也與上線操作類似,不同的是下線的時候需要通知全網的其他節點忘記自己,此時通過指令 cluster forget {downNodeId} 通知其他的節點。
叢集的故障檢測與故障轉恢複機制
-
叢集的故障檢測
Redis叢集的故障檢測是基于gossip協定的,叢集中的每個節點都會定期地向叢集中的其他節點發送PING消息,以此交換各個節點狀态資訊,檢測各個節點狀态:線上狀态、疑似下線狀态PFAIL、已下線狀态FAIL。
(1) 主觀下線(pfail):當節點A檢測到與節點B的通訊時間超過了cluster-node-timeout 的時候,就會更新本地節點狀态,把節點B更新為主觀下線。
主觀下線并不能代表某個節點真的下線了,有可能是節點A與節點B之間的網絡斷開了,但是其他的節點依舊可以和節點B進行通訊。
-
(2)客觀下線:
由于叢集内的節點會不斷地與其他節點進行通訊,下線資訊也會通過 Gossip 消息傳遍所有節點,是以叢集内的節點會不斷收到下線報告。
當半數以上的主節點标記了節點B是主觀下線時,便會觸發客觀下線的流程(該流程隻針對主節點,如果是從節點就會忽略)。将主觀下線的報告儲存到本地的 ClusterNode 的結構fail_reports連結清單中,并且對主觀下線報告的時效性進行檢查,如果超過 cluster-node-timeout*2 的時間,就忽略這個報告,否則就記錄報告内容,将其标記為客觀下線。
接着向叢集廣播一條主節點B的Fail 消息,所有收到消息的節點都會标記節點B為客觀下線。
-
叢集故障恢複
當故障節點下線後,如果是持有槽的主節點則需要在其從節點中找出一個替換它,進而保證高可用。此時下線主節點的所有從節點都擔負着恢複義務,這些從節點會定時監測主節點是否進入客觀下線狀态,如果是,則觸發故障恢複流程。故障恢複也就是選舉一個節點充當新的master,選舉的過程是基于Raft協定選舉方式來實作的。
-
從節點過濾
檢查每個slave節點與master節點斷開連接配接的時間,如果超過了cluster-node-timeout * cluster-slave-validity-factor,那麼就沒有資格切換成master.
-
投票選舉
(1)節點排序
對通過過濾條件的所有節點進行排序,按照priority,offset,runid排序,排序越靠前的節點,越優先進行選舉。
- priority的值越低,優先級越高
- offset越大,表示從master節點複制的資料越多,選舉時間越靠前,優先進行選舉
- offset相同,run id越小,優先級越高
-
(2)更新配置紀元
每個主節點會去更新配置紀元,這個值是不斷增加的整數。這個值記錄了每個節點的版本和整個叢集的版本。每當發生重要的事情的時候(例如:出現新節點從節點精選)都會增加全局的配置紀元并且賦給相關的主節點,用來記錄這個事件。更新這個值的目的是,保證所有主節點對這件“大事”保持一緻,大家都統一成一個配置紀元,表示大家都知道這個"大事"了。
(3)發起選舉:
更新完配置紀元以後,從節點會向叢集發起廣播選舉的消息(CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST),要求所有收到這條消息,并且具有投票權的主節點進行投票。每個從節點在一個紀元中隻能發起一次選舉。
(4)選舉投票
如果一個主節點具有投票權,并且這個主節點尚未投票給其他從節點,那麼主節點将向要求投票的從節點傳回一條CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示這個主節點支援從節點成為新的主節點。每個參與選舉的從節點都會接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,并根據自己收到了多少條這種消息來統計自己獲得了多少主節點的支援。
如果超過(N/2 + 1)數量的master節點都投票給了某個從節點,那麼選舉通過,這個從節點可以切換成master,如果在 cluster-node-timeout*2 的時間内從節點沒有獲得足夠數量的票數,本次選舉廢棄,更新配置紀元,并進行第二輪選舉,直到選出新的主節點為止。
在第(1)步排序領先的從節點通常會獲得更多的票,因為它觸發選舉的時間更早一些,獲得票的機會更大
-
替換主節點
當滿足投票條件的從節點被選出來以後,會觸發替換主節點的操作。删除原主節點負責的槽資料,把這些槽資料添加到自己節點上,并且廣播讓其他的節點都知道這件事情,新的主節點誕生了。
(1) 被選中的從節點執行SLAVEOF NO ONE指令,使其成為新的主節點。
(2) 新的主節點會撤銷所有對已下線主節點的槽指派,并将這些槽全部指派給自己
(3) 新的主節點對叢集進行廣播PONG消息,告知其他節點已經成為新的主節點
(4) 新的主節點開始接收和處理槽相關的請求
備注:如果叢集中某個節點的master和slave節點都當機了,那麼叢集就會進入fail狀态,因為叢集的slot映射不完整。如果叢集超過半數以上的master挂掉,無論是否有slave,叢集都會進入fail狀态。
Redis叢集的搭建
Redis叢集的搭建可以分為以下幾個部分
- 啟動節點:将節點以叢集模式啟動,讀取或者生成叢集配置檔案,此時節點是獨立的。
- 節點握手:節點通過gossip協定通信,将獨立的節點連成網絡,主要使用meet指令。
- 槽指派:将16384個槽位配置設定給主節點,以達到分片儲存資料庫鍵值對的效果。
Redis叢集的運維
-
資料遷移問題
Redis叢集可以進行節點的動态擴容縮容,這一過程目前還處于半自動狀态,需要人工介入。在擴縮容的時候,需要進行資料遷移。而 Redis為了保證遷移的一緻性,遷移所有操作都是同步操作,執行遷移時,兩端的 Redis均會進入時長不等的阻塞狀态,對于小Key,該時間可以忽略不計,但如果一旦Key的記憶體使用過大,嚴重的時候會接觸發叢集内的故障轉移,造成不必要的切換。
-
帶寬消耗問題
Redis叢集是無中心節點的叢集架構,依靠Gossip協定協同自動化修複叢集的狀态,但goosip有消息延時和消息備援的問題,在叢集節點數量過多的時候,goosip協定通信會消耗大量的帶寬,主要展現在以下幾個方面:
- 消息發送頻率:跟cluster-node-timeout密切相關,當節點發現與其他節點的最後通信時間超過 cluster-node-timeout/2時會直接發送ping消息
- 消息資料量:每個消息主要的資料占用包含:slots槽數組(2kb)和整個叢集1/10的狀态資料
- 節點部署的機器規模:機器的帶寬上限是固定的,是以相同規模的叢集分布的機器越多,每台機器劃分的節點越均勻,則整個叢集内整體的可用帶寬越高
- 在滿足業務需求的情況下盡量避免大叢集,同一個系統可以針對不同業務場景拆分使用若幹個叢集。
- 适度提供cluster-node-timeout降低消息發送頻率,但是cluster-node-timeout還影響故障轉移的速度,是以需要根據自身業務場景兼顧二者平衡
- 如果條件允許盡量均勻部署在更多機器上,避免集中部署。如果有60個節點的叢集部署在3台機器上每台20個節點,這是機器的帶寬消耗将非常嚴重
-
Pub/Sub廣播問題
叢集模式下内部對所有publish指令都會向所有節點進行廣播,加重帶寬負擔,是以叢集應該避免頻繁使用Pub/sub功能。
-
叢集傾斜
叢集傾斜是指不同節點之間資料量和請求量出現明顯差異,這種情況将加大負載均衡和開發運維的難度。是以需要了解叢集傾斜的原因
(1)資料傾斜:
- 節點和槽配置設定不均
- 不同的槽對飲的鍵的數量差異過大
- 集合對象包含大量元素
-
記憶體相關配置不一緻
(2)請求傾斜
合理設計鍵,熱點大集合對象做拆分或者使用hmget代替hgettall避免整體擷取。
-
叢集讀寫分離
叢集模式下讀寫分離成本比較高,直接擴充主節點數量來提高叢集性能是更好的選擇。
Redis的分布式鎖
什麼是分布式鎖
-
什麼是分布式鎖
分布式鎖,即分布式系統的鎖,在單體應用中我們通過鎖解決的是控制共享資源通路的問題,而分布式鎖,就是解決了分布式系統中控制共享資源通路的問題,與單體應用不同的是,分布式系統中競争共享資源的最小粒度從線程更新成了程序。
- 分布式鎖應該具備哪些條件
在分布式系統的環境下,一個方法在同一時間隻能被一個機器的一個線程執行。
高可用的擷取鎖與釋放鎖
高性能的擷取鎖與釋放鎖
具備可重入特性(可了解為重新進入,由多于一個任務并發使用,而不必擔心資料錯誤)
具備鎖失效機制,即自動解鎖,防止死鎖
- 分布式鎖的實作方式
基于資料庫實作分布式鎖
基于Zookeeper實作分布式鎖(注冊中心)
基于資料庫的分布式鎖
基于資料庫的鎖實作也有兩種方式,一是基于資料庫表的增删,另一種是基于資料庫排他鎖。
-
基于資料庫表的增删
基于資料庫表增删是最簡單的方式,首先建立一張鎖的表主要包含下列字段:類的全路徑名+方法名,時間戳等字段。
具體的使用方式:當需要鎖住某個方法時,往該表中插入一條相關的記錄。類的全路徑名+方法名是有唯一性限制的,如果有多個請求同時送出到資料庫的話,資料庫會保證隻有一個操作可以成功,那麼我們就認為操作成功的那個線程獲得了該方法的鎖,可以執行方法體内容。執行完畢之後,需要delete該記錄。
(這裡隻是簡單介紹一下,對于上述方案可以進行優化,如:應用主從資料庫,資料之間雙向同步;一旦挂掉快速切換到備庫上;做一個定時任務,每隔一定時間把資料庫中的逾時資料清理一遍;使用while循環,直到insert成功再傳回成功;記錄目前獲得鎖的機器的主機資訊和線程資訊,下次再擷取鎖的時候先查詢資料庫,如果目前機器的主機資訊和線程資訊在資料庫可以查到的話,直接把鎖配置設定給他就可以了,實作可重入鎖)
-
基于資料庫的排他鎖
基于MySql的InnoDB引擎,可以使用以下方法來實作加鎖操作:
public void lock(){
connection.setAutoCommit(false)
int count = 0;
while(count < 4){
try{
select * from lock where lock_name=xxx for update;
if(結果不為空){
//代表擷取到鎖
return;
}
}catch(Exception e){
}
//為空或者抛異常的話都表示沒有擷取到鎖
sleep(1000);
count++;
}
throw new LockException();
}
在查詢語句後面增加for update,資料庫會在查詢過程中給資料庫表增加排他鎖。獲得排它鎖的線程即可獲得分布式鎖,當獲得鎖之後,可以執行方法的業務邏輯,執行完方法之後,釋放鎖connection.commit()。當某條記錄被加上排他鎖之後,其他線程無法擷取排他鎖并被阻塞。
3. 基于資料庫鎖的優缺點:
上面兩種方式都是依賴資料庫表,一種是通過表中的記錄判斷目前是否有鎖存在,另外一種是通過資料庫的排他鎖來實作分布式鎖。
- 優點是直接借助資料庫,簡單容易了解。
- 缺點是操作資料庫需要一定的開銷,性能問題需要考慮。
基于Zookeeper的分布式鎖
基于zookeeper臨時有序節點可以實作的分布式鎖。每個用戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的瞬時有序節點。 判斷是否擷取鎖的方式很簡單,隻需要判斷有序節點中序号最小的一個。 當釋放鎖的時候,隻需将這個瞬時節點删除即可。同時,其可以避免服務當機導緻的鎖無法釋放,而産生的死鎖問題。 (第三方庫有 Curator,Curator提供的InterProcessMutex是分布式鎖的實作)
Zookeeper實作的分布式鎖存在兩個個缺點:
(1)性能上可能并沒有緩存服務那麼高,因為每次在建立鎖和釋放鎖的過程中,都要動态建立、銷毀瞬時節點來實作鎖功能。ZK中建立和删除節點隻能通過Leader伺服器來執行,然後将資料同步到所有的Follower機器上。
(2)zookeeper的并發安全問題:因為可能存在網絡抖動,用戶端和ZK叢集的session連接配接斷了,zk叢集以為用戶端挂了,就會删除臨時節點,這時候其他用戶端就可以擷取到分布式鎖了。
基于redis的分布式鎖
redis指令說明:
(1)setnx指令:set if not exists,當且僅當 key 不存在時,将 key 的值設為 value。若給定的 key 已經存在,則 SETNX 不做任何動作。
傳回1,說明該程序獲得鎖,将 key 的值設為 value
傳回0,說明其他程序已經獲得了鎖,程序不能進入臨界區。
指令格式:setnx lock.key lock.value
(2)get指令:擷取key的值,如果存在,則傳回;如果不存在,則傳回nil
指令格式:get lock.key
(3)getset指令:該方法是原子的,對key設定newValue這個值,并且傳回key原來的舊值。
指令格式:getset lock.key newValue
(4)del指令:删除redis中指定的key
指令格式:del lock.key
方案一:基于set指令的分布式鎖
- 加鎖:使用setnx進行加鎖,當該指令傳回1時,說明成功獲得鎖
- 解鎖:當得到鎖的線程執行完任務之後,使用del指令釋放鎖,以便其他線程可以繼續執行setnx指令來獲得鎖
(1)存在的問題:假設線程擷取了鎖之後,在執行任務的過程中挂掉,來不及顯示地執行del指令釋放鎖,那麼競争該鎖的線程都會執行不了,産生死鎖的情況。
(2)解決方案:設定鎖逾時時間
- 設定鎖逾時時間:setnx 的 key 必須設定一個逾時時間,以保證即使沒有被顯式釋放,這把鎖也要在一定時間後自動釋放。可以使用expire指令設定鎖逾時時間
(1)存在問題:setnx 和 expire 不是原子性的操作,假設某個線程執行setnx 指令,成功獲得了鎖,但是還沒來得及執行expire 指令,伺服器就挂掉了,這樣一來,這把鎖就沒有設定過期時間了,變成了死鎖,别的線程再也沒有辦法獲得鎖了。
(2)解決方案:redis的set指令支援在擷取鎖的同時設定key的過期時間
-
使用set指令加鎖并設定鎖過期時間:
指令格式:set <lock.key> <lock.value> nx ex <expireTime>
1)存在問題:
① 假如線程A成功得到了鎖,并且設定的逾時時間是 30 秒。如果某些原因導緻線程 A 執行的很慢,過了 30 秒都沒執行完,這時候鎖過期自動釋放,線程 B 得到了鎖。
② 随後,線程A執行完任務,接着執行del指令來釋放鎖。但這時候線程 B 還沒執行完,線程A實際上删除的是線程B加的鎖。
(2)解決方案:
可以在 del 釋放鎖之前做一個判斷,驗證目前的鎖是不是自己加的鎖。在加鎖的時候把目前的線程 ID 當做value,并在删除之前驗證 key 對應的 value 是不是自己線程的 ID。但是,這樣做其實隐含了一個新的問題,get操作、判斷和釋放鎖是兩個獨立操作,不是原子性。對于非原子性的問題,我們可以使用Lua腳本來確定操作的原子性
-
鎖續期:(這種機制類似于redisson的看門狗機制,文章後面會詳細說明)
雖然步驟4避免了線程A誤删掉key的情況,但是同一時間有 A,B 兩個線程在通路代碼塊,仍然是不完美的。怎麼辦呢?我們可以讓獲得鎖的線程開啟一個守護線程,用來給快要過期的鎖“續期”。
① 假設線程A執行了29 秒後還沒執行完,這時候守護線程會執行 expire 指令,為這把鎖續期 20 秒。守護線程從第 29 秒開始執行,每 20 秒執行一次。
② 情況一:當線程A執行完任務,會顯式關掉守護線程。
③ 情況二:如果伺服器忽然斷電,由于線程 A 和守護線程在同一個程序,守護線程也會停下。這把鎖到了逾時的時候,沒人給它續命,也就自動釋放了。
方案二:基于setnx、get、getset的分布式鎖
- 實作原理:下邊的幾個方法在實作中有用到
- setnx(lockkey, 目前時間+過期逾時時間) ,如果傳回1,則擷取鎖成功;如果傳回0則沒有擷取到鎖,轉向步驟(2)
- get(lockkey)擷取值oldExpireTime ,并将這個value值與目前的系統時間進行比較,如果小于目前系統時間,則認為這個鎖已經逾時,可以允許别的請求重新擷取,轉向步驟(3)
- 計算新的過期時間newExpireTime=目前時間+鎖逾時時間,然後getset(lockkey, newExpireTime) 會傳回目前lockkey的值currentExpireTime
- 判斷目前currentExpireTime與oldExpireTime是不是相等,如果相等,說明目前getset設定成功,擷取到了鎖。如果不相等,說明這個鎖又被别的請求擷取走了,那麼目前請求可以直接傳回失敗,或者繼續重試。這個地方用到了類似CAS這個原理,如果過期時間小于目前系統時間,再判斷是否能進行指派。
- 在擷取到鎖之後,目前線程可以開始自己的業務處理,當處理完畢後,比較自己的處理時間和對于鎖設定的逾時時間,如果小于鎖設定的逾時時間,則直接執行del指令釋放鎖(釋放鎖之前需要判斷持有鎖的線程是不是目前線程);如果大于鎖設定的逾時時間,則不需要再鎖進行處理。
- 代碼實作
public boolean lock(long acquireTimeout, TimeUnit timeUnit) throws InterruptedException {
acquireTimeout = timeUnit.toMillis(acquireTimeout);
long acquireTime = acquireTimeout + System.currentTimeMillis();
//使用J.U.C的ReentrantLock
threadLock.tryLock(acquireTimeout, timeUnit);
try {
//循環嘗試
while (true) {
//調用tryLock
boolean hasLock = tryLock();
if (hasLock) {
//擷取鎖成功
return true;
} else if (acquireTime < System.currentTimeMillis()) {
break;
}
Thread.sleep(sleepTime);
}
} finally {
if (threadLock.isHeldByCurrentThread()) {
threadLock.unlock();
}
}
return false;
}
public boolean tryLock() {
long currentTime = System.currentTimeMillis();
String expires = String.valueOf(timeout + currentTime);
//設定互斥量
if (redisHelper.setNx(mutex, expires) > 0) {
//擷取鎖,設定逾時時間
setLockStatus(expires);
return true;
} else {
String currentLockTime = redisUtil.get(mutex);
//檢查鎖是否逾時
if (Objects.nonNull(currentLockTime) && Long.parseLong(currentLockTime) < currentTime) {
//擷取舊的鎖時間并設定互斥量
String oldLockTime = redisHelper.getSet(mutex, expires);
//舊值與目前時間比較
if (Objects.nonNull(oldLockTime) && Objects.equals(oldLockTime, currentLockTime)) {
//擷取鎖,設定逾時時間
setLockStatus(expires);
return true;
}
}
return false;
}
}
tryLock方法中,主要邏輯如下:lock調用tryLock方法,參數為擷取的逾時時間與機關,線程在逾時時間内,擷取鎖操作将自旋在那裡,直到該自旋鎖的保持者釋放了鎖。
(2)釋放鎖的實作方式
public boolean unlock() {
//隻有鎖的持有線程才能解鎖
if (lockHolder == Thread.currentThread()) {
//判斷鎖是否逾時,沒有逾時才将互斥量删除
if (lockExpiresTime > System.currentTimeMillis()) {
redisHelper.del(mutex);
logger.info("删除互斥量[{}]", mutex);
}
lockHolder = null;
logger.info("釋放[{}]鎖成功", mutex);
return true;
} else {
throw new IllegalMonitorStateException("沒有擷取到鎖的線程無法執行解鎖操作");
}
}
存在問題:
(1)這個鎖的核心是基于System.currentTimeMillis(),如果多台伺服器時間不一緻,那麼問題就出現了,但是這個bug完全可以從伺服器運維層面規避的,而且如果伺服器時間不一樣的話,隻要和時間相關的邏輯都是會出問題的
(2)如果前一個鎖逾時的時候,剛好有多台伺服器去請求擷取鎖,那麼就會出現同時執行redis.getset()而導緻出現過期時間覆寫問題,不過這種情況并不會對正确結果造成影響
(3)存在多個線程同時持有鎖的情況:如果線程A執行任務的時間超過鎖的過期時間,這時另一個線程就可以獲得這個鎖了,造成多個線程同時持有鎖的情況。類似于方案一,可以使用“鎖續期”的方式來解決。
前兩種redis分布式鎖的存在的問題
前面兩種redis分布式鎖的實作方式,如果從“高可用”的層面來看,仍然是有所欠缺,也就是說當 redis 是單點的情況下,當發生故障時,則整個業務的分布式鎖都将無法使用。
Redis緩存與資料庫雙寫一緻性
首先,緩存由于其高并發和高性能的特性,已經在項目中被廣泛使用。在讀取緩存方面,大家沒啥疑問,都是按照下圖的流程來進行業務操作。
但是在更新緩存方面,對于更新完資料庫,是更新緩存呢,還是删除緩存呢?更新這個問題上存在一些不同的看法。
讨論三種更新政策:
- 先更新資料庫,再更新緩存
- 先删除緩存,再更新資料庫
- 先更新資料庫,再删除緩存
先更新資料庫,再更新緩存
這個方案一般是不行的。
原因一:從線程安全角度看,同時有請求A和請求B進行更新操作,那麼可能會出現:
(1)線程A更新了資料庫
(2)線程B更新了資料庫
(3)線程B更新了緩存
(4)線程A更新了緩存
這就出現請求A更新緩存應該比請求B更新緩存早才對,但是因為網絡等原因,B卻比A更早更新了緩存,這就導緻了髒資料。
原因二:從業務的場景角度看,存在以下兩個問題
(1)如果是一個資料庫寫多讀少的業務場景求,采用這種方案就會導緻,資料壓根還沒讀到,緩存就被頻繁的更新,浪費性能。
(2)如果你寫入資料庫的值,并不是直接寫入緩存的,而是要經過一系列複雜的計算再寫入緩存。那麼,每次寫入資料庫後,都再次計算寫入緩存的值,無疑是浪費性能的。顯然,删除緩存更為适合。
接下來讨論的就是争議最大的,先删緩存,再更新資料庫。還是先更新資料庫,再删緩存的問題.
先删緩存,再更新資料庫
-
存在問題
該方案會導緻不一緻的原因是:同時有一個請求A進行更新操作,另一個請求B進行查詢操作。那麼會出現如下情形:
(1)請求A進行寫操作前,先删除緩存
(2)請求B查詢發現緩存不存在
(3)請求B去資料庫查詢得到舊值
(4)請求B将舊值寫入緩存
(5)請求A将新值寫入資料庫
就造成了緩存中的值是舊值,如果不采用給緩存設定過期時間政策,該資料永遠都是髒資料。
- 解決方案:延時雙删政策:
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}
轉化為中文描述就是:
(1)先淘汰緩存
(2)再寫資料庫
(3)休眠1秒,再次淘汰緩存
這麼做的目的是将休眠時間内産生的緩存髒資料再次删除(這個休眠時間需要具體根據項目的業務邏輯耗時指定)
-
如果是MySQL的讀寫分離架構怎麼辦?
在這種情況下,造成資料不一緻的原因如下,還是兩個請求,一個請求A進行更新操作,另一個請求B進行查詢操作。
(1)請求A進行寫操作,删除緩存
(2)請求A将資料寫入資料庫了,
(3)請求B查詢緩存發現,緩存沒有值
(4)請求B去從庫查詢,這時,還沒有完成主從同步,是以查詢到的是舊值
(5)請求B将舊值寫入緩存
(6)資料庫完成主從同步,從庫變為新值
-
上述情形,就是資料不一緻的原因。還是使用雙删延時政策。隻是,睡眠時間修改為在主從同步的延時時間基礎上,加幾百ms
4 .采用延時雙删除政策,吞吐量降低怎麼辦?
可以另起一個線程異步執行第二次删除操作,這樣寫的請求就不用沉睡一段時間後再傳回了,進而加大吞吐量。但是如果第二次删除的時候,删除失敗怎麼辦呢?如果第二次删除失敗,就會出現如下情形。還是有兩個請求,一個請求A進行更新操作,另一個請求B進行查詢操作,為了友善,假設是單庫:
> (1)請求A進行寫操作,删除緩存
(2)請求B查詢發現緩存不存在
(3)請求B去資料庫查詢得到舊值
(4)請求B将舊值寫入緩存
(5)請求A将新值寫入資料庫
(6)請求A試圖去删除請求B寫入對緩存值,結果失敗了
也就是說,如果第二次删除緩存失敗,會再次出現緩存和資料庫不一緻的問題。如何解決呢?具體解決方案,且看對第(3)種更新政策的解析。
先更新資料庫,在删除緩存
這個緩存政策也就是: