天天看點

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

參考:《Redis設計與實作》、 JavaGuide

、後端技術指南(微信公衆号)、菜鳥教程以及掘金上忘記了大佬

Redis是一個使用ANSI C編寫的開源、支援網絡、基于記憶體、可選持久化的高性能鍵值對資料庫。

Redis的單線程和高性能

Redis的單線程

Redis 的單線程主要是指 Redis 的網絡 IO 和鍵值對讀寫是由一個線程來完成的,這也是 Redis 對外提供鍵值存儲服務的主要流程。但 Redis 的其他功能,比如持久化、異步删除、叢集資料同步等,其實是由額外的線程執行的。

Redis 使用單線程原因

  1. 單線程程式設計容易并且更容易維護;
  2. Redis 的性能瓶頸不再 CPU ,主要在記憶體和網絡;
  3. 多線程就會存在死鎖、線程上下文切換等問題,甚至會影響性能。

Redis 4.0 增加的多線程主要是針對一些大鍵值對的删除操作的指令;

Redis6.0 引入多線程主要是為了提高網絡 IO 讀寫性能,因為這個算是 Redis 中的一個性能瓶頸(Redis 的瓶頸主要受限于記憶體和網絡)。但是 Redis 的多線程隻是在網絡資料的讀寫這類耗時操作上使用了, 執行指令仍然是單線程順序執行。

Redis 單線程如何處理那麼多的并發用戶端連接配接?

Redis的IO多路複用:redis利用epoll來實作IO多路複用,将連接配接資訊和事件放到隊列中,依次放到檔案事件分派器,事件分派器将事件分發給事件處理器。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?
# 檢視redis支援的最大連接配接數,在redis.conf檔案中可修改,# maxclients 10000
127.0.0.1:6379> CONFIG GET maxclients
    ##1) "maxclients"
    ##2) "10000"      

Redis基于Reactor模式(反應堆模式)開發了自己的網絡模型,形成了一個完備的基于IO複用的事件驅動伺服器,但是不由得浮現幾個問題:

  1. 為什麼要使用Reactor模式呢?
  2. Redis如何實作自己的Reactor模式?

Reactor模式

單純的epoll/kqueue可以單機支援數萬并發,單純從性能的角度而言毫無問題,但是技術實作和軟體設計仍然存在一些差異。

設想這樣一種場景:

  • epoll/kqueue将收集到的可讀寫事件全部放入隊列中等待業務線程的處理,此時線程池的工作線程拿到任務進行處理,實際場景中可能有很多種請求類型,工作線程每拿到一種任務就進行相應的處理,處理完成之後繼續處理其他類型的任務
  • 工作線程需要關注各種不同類型的請求,對于不同的請求選擇不同的處理方法,是以請求類型的增加會讓工作線程複雜度增加,維護起來也變得越來越困難

上面的場景其實和高并發網絡模型很相似,如果我們在epoll/kqueue的基礎上進行業務區分,并且對每一種業務設定相應的處理函數,每次來任務之後對任務進行識别和分發,每種處理函數隻處理一種業務,這種模型更加符合OO的設計理念,這也是Reactor反應堆模式的設計思路。

反應堆模式是一種對象行為的設計模式,主要同于同步IO,異步IO有Proactor模式,這裡不詳細講述Proactor模式,二者的主要差別就是Reactor是同步IO,Proactor是異步IO,理論上Proactor效率更高,但是Proactor模式需要作業系統在核心層面對異步IO進行支援,Linux的Boost.asio就是Proactor模式的代表,Windows有IOCP。

網上比較經典的一張Reactor模式的類圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

圖中給出了5個部件分别為:

  1. handle 可以了解為讀寫事件 可以注冊到Reactor進行監控
  2. Sync event demultiplexer 可以了解為epoll/kqueue/select等作為IO事件的采集器
  3. Dispatcher 提供注冊/删除事件并進行分發,作為事件分發器
  4. Event Handler 事件處理器 完成具體事件的回調 供Dispatcher調用
  5. Concrete Event Handler 具體請求處理函數

更簡潔的流程如下:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

循環前先将待監控的事件進行注冊,當監控中的Socket讀寫事件到來時,事件采集器epoll等IO複用工具檢測到并且将事件傳回給事件分發器Dispatcher,分發器根據讀、寫、異常等情況進行分發給事件處理器,事件處理器進而根據事件具體類型來排程相應的實作函數來完成任務。

Reactor模式在Redis中的實作

Redis處理用戶端業務(檔案事件)的基本流程:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

Redis的IO複用的選擇

#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif
複制代碼      

Redis中支援多種IO複用,源碼中使用相應的宏定義進行選擇,編譯時就可以擷取目前系統支援的最優的IO複用函數來使用,進而實作了Redis的優秀的可移植特性。

  • Redis的任務事件隊列

由于Redis的是單線程處理業務的,是以IO複用程式将讀寫事件同步的逐一放入隊列中,如果目前隊列已經滿了,那麼隻能出一個入一個,但是由于Redis正常情況下處理得很快,不太會出現隊列滿遲遲無法放任務的情況,但是當執行某些阻塞操作時将導緻長時間的阻塞,無法處理新任務。

  • Redis事件分派器

事件的可讀寫是從伺服器角度看的,分派看到的事件類型包括:

  1. AE_READABLE 用戶端寫資料、關閉連接配接、新連接配接到達
  2. AE_WRITEABLE 用戶端讀資料

特别地,當一個套接字連接配接同時可讀可寫時,伺服器會優先處理讀事件再處理寫事件,也就是讀優先。

  • Redis事件處理器

Redis将檔案事件進行歸類,編寫了多個事件處理器函數,其中包括:

  1. 連接配接應答處理器:實作新連接配接的建立
  2. 指令請求處理器:處理用戶端的新指令
  3. 指令回複處理器:傳回用戶端的請求結果
  4. 複制處理器:實作主從伺服器的資料複制
  • Redis C/S一次完整的互動

Redis伺服器的主線程處于循環中,此時Client向Redis伺服器發起連接配接請求,假如是6379端口,監聽端口在IO複用工具下檢測到AE_READABLE事件,并将該事件放入TaskQueue中,等待被處理,事件分派器擷取這個讀事件,進一步确定是新連接配接請求,就将該事件交給連接配接應答處理器建立連接配接;

建立連接配接後Client向伺服器發送了一個get指令,仍然被IO複用檢測處理放入隊列,被事件分派器處理指派給指令請求處理器,調用相應程式進行執行;

伺服器将套接字的AE_WRITEABLE事件與指令回複處理器相關聯,當用戶端嘗試讀取結果時産生可寫事件,此時伺服器端觸發指令回複響應,并将資料結果寫入套接字,完成之後服務端接觸該套接字與指令回複處理器之間的關聯;

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

資料結構

Redis為了平衡空間和時間效率,針對value的具體類型在底層會采用不同的資料結構來實作,其中哈希表和壓縮清單是複用比較多的資料結構,如下圖展示了對外資料類型和底層資料結構之間的映射關系:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?
Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

左邊為基本資料類型,右邊是其實作方式,如Zset有skiplist與ziplist兩種實作方式。

基本資料類型

string

redis的string類型不同于c語言的string,redis中的string是一種simple dynamic string(SDS),可以動态擴容

struct sdshdr{
    //記錄buf數組中已使用位元組的數量
    //等于SDS所儲存字元串的長度
    int len;
    
    // 記錄buf數組中未使用位元組的數量
    int free;
    // 位元組數組,用于儲存字元串
    char buf[]
};      

筆者畫了個圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

從圖中可以知道sds本質分為三部分:header、buf、null結尾符,其中header可以認為是整個sds的指引部分,給定了使用的空間大小、最大配置設定大小等資訊,再用一張網上的圖來清晰看下sdshdr8的執行個體:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

在sds.h/sds.c源碼中可清楚地看到sds完整的實作細節,本文就不展開了要不然篇幅就過長了,快速進入主題說下sds的優勢:

  • O(1)擷取長度: C字元串需要周遊而sds中有len可以直接獲得;
  • 防止緩沖區溢出bufferoverflow: 當sds需要對字元串進行修改時,首先借助于len和alloc檢查空間是否滿足修改所需的要求,如果空間不夠的話,SDS會自動擴充空間,避免了像C字元串操作中的覆寫情況;
  • 有效降低記憶體配置設定次數:C字元串在涉及增加或者清除操作時會改變底層數組的大小造成重新配置設定、sds使用了空間預配置設定和惰性空間釋放機制,說白了就是每次在擴充時是成倍的多配置設定的,在縮容是也是先留着并不正式歸還給OS,這兩個機制也是比較好了解的;
  • 二進制安全:C語言字元串隻能儲存ascii碼,對于圖檔、音頻等資訊無法儲存,sds是二進制安全的,寫入什麼讀取就是什麼,不做任何過濾和限制;
Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

動态擴容的原因:

redis對速度的要求苛刻,如果使用c語言的string,每次修改字元串長度都需要重新配置設定一次記憶體,十分耗時。

list

清單對象的編碼可以是ziplist或者linkedlist。當滿足以下兩個條件時,使用ziplist作為底層結構:

  • 所有字元串元素的長度都小于64位元組
  • 元素數量小于512個

1. ziplist編碼

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

2.linkedlist編碼

結構為雙向連結清單

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?
typedef struct listNode{
    
    //前置節點
    struct listNode *prev;
    //後置節點
    struct listNode *next;
    //節點的值
    void *value;
}listNode;


typedef struct list{
    //表頭
    listNode *head;
    //表尾
    listNode *tail;
    //連結清單所包含的節點數量
    unsigned long len;
    //節點值複制函數
    void *(*dup)(void *ptr);
    //節點值釋放函數
    void *(*free)(void *ptr);
    // 節點值對比函數
    int (*match) (void *ptr,void *key);
}      

Hash

哈希對象的編碼可以是ziplist或者hashtable。當滿足以下條件時使用ziplist存儲:

  • 所有鍵值對的字元串長度都小于64位元組
  • 鍵值對數量小于512
Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

2 hashtable編碼

字典是個層次非常明顯的資料類型,如圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

Redis中的字典使用哈希表作為底層實作,每個字典帶有兩個哈希表,一個平時使用,另一個僅在進行rehash時使用。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?
//字典結構
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;


// hash表
typedef struct dictht{
    //哈希表數組
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩碼,用于計算索引值
    //總是等于size-1
    unsigned long sizemask;
    //該哈希表已有節點的數量
    unsigned long used;
} dictht;

//hash表節點
typedef struct dictEntry{
    // 鍵
    void *key;
    // 值
    union{
        void *val;
        unit64_t u64;
        int64_t s64;
    } v;
    //指向下個hash表節點,形成連結清單
    struct dictEntry *next;
} dictEntry;
      

hash算法

Redis使用MurmurHash2算法計算鍵的哈希值。

hash沖突

解決hash沖突的方法有:

1 開放定址法: 所謂的開放定址法就是一旦發生了沖突,就去尋找下一個空的散列位址,隻要散清單足夠大,空的散列位址總能找到.

2 再哈希法:再哈希法又叫雙哈希法,有多個不同的Hash函數,當發生沖突時,使用第二個,第三個,….,等哈希函數計算位址,直到無沖突。雖然不易發生聚集,但是增加了計算時間。

3 鍊位址法:鍊位址法的基本思想是:每個哈希表節點都有一個next指針,多個哈希表節點可以用next指針構成一個單向連結清單,被配置設定到同一個索引上的多個節點可以用這個單向 連結清單連接配接起來。

4 建立公共溢出區:将哈希表分為基本表和溢出表兩部分,凡是和基本表發生沖突的元素,一律填入溢出表.

Redis使用的是鍊位址法解決hash沖突,即放在連結清單的下一個位置。

rehash過程

rehash過程為擴容或收縮過程,該過程是并不是一次性、集中式地完成,而是分多次、漸近式地完成(因為資料量可能很大,避免對伺服器性能造成影響)

1.若為擴容為副表ht[1]配置設定空間,為原表ht[0]的2倍大小;若是收縮,為大于等于原表used的個數的第一個2的指數次幂。

2.資料遷移

3.将原表置為空

漸進式過程:

  • 為ht[1]配置設定空間,這個過程和普通Rehash沒有差別;
  • 将rehashidx設定為0,表示rehash工作正式開始,同時這個rehashidx是遞增的,從0開始表示從數組第一個元素開始rehash。
  • 在rehash進行期間,每次對字典執行增删改查操作時,順帶将ht[0]哈希表在rehashidx索引上的鍵值對rehash到 ht[1],完成後将rehashidx加1,指向下一個需要rehash的鍵值對。
  • 随着字典操作的不斷執行,最終ht[0]的所有鍵值對都會被rehash至ht[1],再将rehashidx屬性的值設為-1來表示 rehash操作已完成。

漸進式 rehash的思想在于将rehash鍵值對所需的計算工作分散到對字典的每個添加、删除、查找和更新操作上,進而避免了集中式rehash而帶來的阻塞問題。

捎帶腳式的rehash會不會導緻整個過程非常漫長?如果某個value一直沒有操作那麼需要擴容時由于一直不用是以影響不大,需要縮容時如果一直不處理可能造成記憶體浪費,具體的還沒來得及研究,先埋個問題吧!

Set

集合對象的編碼可以是intset或者hashtable。當滿足以下條件時,使用intset結構:

  • 所有元素都是整數
  • 元素數量不超過512個

Sorted Set(Zset)

有序集合的編碼可以是ziplist或者skiplist。

使用ziplist存儲時,每個元素使用兩個相鄰的節點來儲存,第一個節點儲存元素的成員,第二個節點儲存元素的分值(score)。集合元素按分值從小到大排序。

使用skiplist時包含一個字典和一個跳躍表,跳躍表按score從小到大儲存所有集合元素。字典儲存着從member到score的映射。兩種結構通過指針共享相同元素的member和score,不浪費額外記憶體。

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;
      

ZSet中的字典和跳表布局:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

ziplist

ziplist總體結構

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

從圖中我們基本上可以看到幾個主要部分:zlbytes、zltail、zllen、zlentry、zlend。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

來看下ziplist.c中對ziplist的申請和擴容操作,加深對上面幾個屬性的了解:

/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    ZIPLIST_LENGTH(zl) = 0;
    zl[bytes-1] = ZIP_END;
    return zl;
}

/* Resize the ziplist. */
unsigned char *ziplistResize(unsigned char *zl, unsigned int len) {
    zl = zrealloc(zl,len);
    ZIPLIST_BYTES(zl) = intrev32ifbe(len);
    zl[len-1] = ZIP_END;
    return zl;
}
      

跳躍表

Redis隻有兩處使用到了跳躍表,一個是實作有序集合鍵(sorted set),另一個是在叢集節點中用作内部資料結構。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

Redis資料庫

redis伺服器的狀态都儲存在redisServer & redisDb兩個結構中,具體如下圖:

typedef redisServer {
    redisDb *db; // db數組,儲存着伺服器中的所有資料庫 
    int dbnum;   // 伺服器的資料庫數量
    //...
};
typedef struct redisDb {
    dict *dict;     // 鍵空間,儲存着資料庫中的所有鍵值對
    dict *expires;  // 過期字典,儲存着鍵的過期時間
    // ...
} redisDb;      

redisDB的一個例子如下圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

記憶體回收機制

為了讓Redis服務安全穩定的運作,讓使用記憶體保持在一定的門檻值内是非常有必要的,是以我們就需要删除該删除的,清理該清理的,把記憶體留給需要的鍵值對,試想一條大河需要設定幾個警戒水位來確定不決堤不枯竭,Redis也是一樣的,隻不過Redis隻關心決堤即可,來一張圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

圖中設定機器記憶體為128GB,占用64GB算是比較安全的水準,如果記憶體接近80%也就是100GB左右,那麼認為Redis目前承載能力已經比較大了,具體的比例可以根據公司和個人的業務經驗來确定。

筆者隻是想表達出于安全和穩定的考慮,不要覺得128GB的記憶體就意味着存儲128GB的資料,都是要打折的。

B.1 回收的記憶體從哪裡來

Redis占用的記憶體是分為兩部分:存儲鍵值對消耗和本身運作消耗。顯然後者我們無法回收,是以隻能從鍵值對下手了,鍵值對可以分為幾種:帶過期的、不帶過期的、熱點資料、冷資料。對于帶過期的鍵值是需要删除的,如果删除了所有的過期鍵值對之後記憶體仍然不足怎麼辦?那隻能把部分資料給踢掉了。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

B.2 如何實施過期鍵值對的删除

要實施對鍵值對的删除我們需要明白如下幾點:

  • 帶過期逾時的鍵值對存儲在哪裡?
  • 如何判斷帶逾時的鍵值對是否可以被删除了?
  • 删除機制有哪些以及如何選擇?

1.鍵值對的存儲

老規矩來到github看下源碼,src/server.h中給的redisDb結構體給出了答案:

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
複制代碼      

Redis 通過一個叫做過期字典(可以看作是 hash 表)來儲存資料過期的時間。過期字典的鍵指向 Redis 資料庫中的某個 key(鍵),過期字典的值是一個 long long 類型的整數,這個整數儲存了 key 所指向的資料庫鍵的過期時間(毫秒精度的 UNIX 時間戳)。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

過期字典是存儲在 redisDb 這個結構裡的:

typedef struct redisDb {
    ...

    dict *dict;     //資料庫鍵空間,儲存着資料庫中所有鍵值對
    dict *expires   // 過期字典,儲存着鍵的過期時間
    ...
} redisDb;      

2. 鍵值對的過期删除判斷

判斷鍵是否過期可删除,需要先查過期字典是否存在該值,如果存在則進一步判斷過期時間戳和目前時間戳的相對大小,做出删除判斷,簡單的流程如圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

3. 鍵值對的删除政策

經過前面的幾個環節,我們知道了Redis的兩種存儲位置:鍵空間和過期字典,以及過期字典expires的結構、判斷是否過期的方法,那麼該如何實施删除呢?

先抛開Redis來想一下可能的幾種删除政策:

  • 定時删除:在設定鍵的過期時間的同時,建立定時器,讓定時器在鍵過期時間到來時,即刻執行鍵值對的删除;
  • 定期删除:每隔特定的時間對資料庫進行一次掃描,檢測并删除其中的過期鍵值對;
  • 惰性删除:鍵值對過期暫時不進行删除,至于删除的時機與鍵值對的使用有關,當擷取鍵時先檢視其是否過期,過期就删除,否則就保留;

在上述的三種政策中定時删除和定期删除屬于不同時間粒度的主動删除,惰性删除屬于被動删除。

三種政策都有各自的優缺點:定時删除對記憶體使用率有優勢,但是對CPU不友好,惰性删除對記憶體不友好,如果某些鍵值對一直不被使用,那麼會造成一定量的記憶體浪費,定期删除是定時删除和惰性删除的折中。

Reids采用的是惰性删除和定期删除的結合,一般來說可以借助最小堆來實作定時器,不過Redis的設計考慮到時間事件的有限種類和數量,使用了無序連結清單存儲時間事件,這樣如果在此基礎上實作定時删除,就意味着O(N)周遊擷取最近需要删除的資料。

但是我覺得antirez如果非要使用定時删除,那麼他肯定不會使用原來的無序連結清單機制,是以個人認為已存在的無序連結清單不能作為Redis不使用定時删除的根本理由,冒昧猜測唯一可能的是antirez覺得沒有必要使用定時删除。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

B.3 記憶體淘汰機制

為了保證Redis的安全穩定運作,設定了一個max-memory的門檻值,那麼當記憶體用量到達門檻值,新寫入的鍵值對無法寫入,此時就需要記憶體淘汰機制,在Redis的配置中有幾種淘汰政策可以選擇,詳細如下:

  • noeviction: 當記憶體不足以容納新寫入資料時,新寫入操作會報錯;
  • allkeys-lru:當記憶體不足以容納新寫入資料時,在鍵空間中移除最近最少使用的 key;
  • allkeys-random:當記憶體不足以容納新寫入資料時,在鍵空間中随機移除某個 key;
  • volatile-lru:當記憶體不足以容納新寫入資料時,在設定了過期時間的鍵空間中,移除最近最少使用的 key;
  • volatile-random:當記憶體不足以容納新寫入資料時,在設定了過期時間的鍵空間中,随機移除某個 key;
  • volatile-ttl:當記憶體不足以容納新寫入資料時,在設定了過期時間的鍵空間中,有更早過期時間的 key 優先移除;

後三種政策都是針對過期字典的處理,但是在過期字典為空時會noeviction一樣傳回寫入失敗,毫無政策地随機删除也不太可取,是以一般選擇第二種allkeys-lru基于LRU政策進行淘汰。

4.0 版本後增加以下兩種:

  • volatile-lfu(least frequently used):從已設定過期時間的資料集(server.db[i].expires)中挑選最不經常使用的資料淘汰
  • allkeys-lfu(least frequently used):當記憶體不足以容納新寫入資料時,在鍵空間中,移除最不經常使用的 key

過期健删除政策強調的是對過期健的操作,如果有健過期而記憶體足夠,Redis不會使用記憶體淘汰機制來騰退空間,這時會優先使用過期健删除政策删除過期健。

記憶體淘汰機制強調的是對記憶體資料的淘汰操作,當記憶體不足時,即使有的健沒有到達過期時間或者根本沒有設定過期也要根據一定的政策來删除一部分,騰退空間保證新資料的寫入。

持久化

快照RDB

Redis 可以通過建立快照來獲得存儲在記憶體裡面的資料在某個時間點上的副本。Redis 建立快照之後,可以對快照進行備份,可以将快照複制到其他伺服器進而建立具有相同資料的伺服器副本(Redis 主從結構,主要用來提高 Redis 性能),還可以将快照留在原地以便重新開機伺服器的時候使用。

AOF

與快照持久化相比,AOF 持久化 的實時性更好,是以已成為主流的持久化方案。預設情況下 Redis 沒有開啟 AOF(append only file)方式的持久化,可以通過 appendonly 參數開啟:

appendonly yes      

開啟 AOF 持久化後每執行一條會更改 Redis 中的資料的指令,Redis 就會将該指令寫入硬碟中的 AOF 檔案。AOF 檔案的儲存位置和 RDB 檔案的位置相同,都是通過 dir 參數設定的,預設的檔案名是 appendonly.aof。

過期删除政策

過期資料的删除政策:

  1. 惰性删除 :隻會在取出 key 的時候才對資料進行過期檢查。這樣對 CPU 最友好,但是可能會造成太多過期 key 沒有被删除。
  2. 定期删除 : 每隔一段時間抽取一批 key 執行删除過期 key 操作。并且,Redis 底層會通過限制删除操作執行的時長和頻率來減少删除操作對 CPU 時間的影響。

定期删除對記憶體更加友好,惰性删除對 CPU 更加友好。兩者各有千秋,是以 Redis 采用的是 定期删除+惰性/懶漢式删除 。

但是,僅僅通過給 key 設定過期時間還是有問題的。因為還是可能存在定期删除和惰性删除漏掉了很多過期 key 的情況。這樣就導緻大量過期 key 堆積在記憶體裡,然後就 Out of memory 了。

怎麼解決這個問題呢?答案就是: Redis 記憶體淘汰機制。

Redis叢集

單執行個體Redis架構

最開始的一主N從加上讀寫分離,Redis作為緩存單執行個體貌似也還不錯,并且有Sentinel哨兵機制,可以實作主從故障遷移。

單執行個體一主兩從+讀寫分離結構:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

單執行個體的由于本質上隻有一台Master作為存儲,就算機器為128GB的記憶體,一般建議使用率也不要超過70%-80%,是以最多使用100GB資料就已經很多了,實際中50%就不錯了,以為資料量太大也會降低服務的穩定性,因為資料量太大意味着持久化成本高,可能嚴重阻塞服務,甚至最終切主。

如果單執行個體隻作為緩存使用,那麼除了在服務故障或者阻塞時會出現緩存擊穿問題,可能會有很多請求一起搞死MySQL。

如果單執行個體作為主存,那麼問題就比較大了,因為涉及到持久化問題,無論是bgsave還是aof都會造成刷盤阻塞,此時造成服務請求成功率下降,這個并不是單執行個體可以解決的,因為由于作為主存儲,持久化是必須的。

是以我們期待一個多主多從的Redis系統,這樣無論作為主存還是作為緩存,壓力和穩定性都會提升,盡管如此,筆者還是建議:Redis盡量不要做主存儲!

叢集架構

要支援叢集首先要克服的就是分片問題,也就是一緻性哈希問題,常見的方案有三種:

用戶端分片:(N主N從模式)這種情況主要是類似于哈希取模的做法,當用戶端對服務端的數量完全掌握和控制時,可以簡單使用。

中間層分片:這種情況是在用戶端和伺服器端之間增加中間層,充當管理者和排程者,用戶端的請求打向中間層,由中間層實作請求的轉發和回收,當然中間層最重要的作用是對多台伺服器的動态管理。

服務端分片:(N主N從模式)不使用中間層實作去中心化的管理模式,用戶端直接向伺服器中任意結點請求,如果被請求的Node沒有所需資料,則向用戶端回複MOVED,并告訴用戶端所需資料的存儲位置,這個過程實際上是用戶端和服務端共同配合,進行請求重定向來完成的。

用戶端分片版叢集

需要解決一緻性哈希的問題,也就是動态擴縮容時的資料問題。(一緻性hash算法)

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

中間層分片的叢集版Redis

在Redis官方釋出叢集版本之前,業内有一些方案迫不及待要用起自研版本的Redis叢集,其中包括國内豌豆莢的Codis、國外Twiter的twemproxy。

核心思想都是在多個Redis伺服器和用戶端Client中間增加分片層,由分片層來完成資料的一緻性哈希和分片問題,每一家的做法有一定的差別,但是要解決的核心問題都是多台Redis場景下的擴縮容、故障轉移、資料完整性、資料一緻性、請求處理延時等問題。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

業内Codis配合LVS等多種做法實作Redis叢集的方案有很多都應用到生成環境中,表現都還不錯,主要是官方叢集版本在Redis3.0才出現,對其穩定性如何,很多公司都不願做小白鼠,不過事實上經過疊代目前已經到了Redis5.x版本,官方叢集版本還是很不錯的,至少筆者這麼認為。

服務端分片的官方叢集版本

官方版本差別于上面的Codis和Twemproxy,實作了伺服器層的Sharding分片技術,換句話說官方沒有中間層,而是多個服務結點本身實作了分片,當然也可以認為實作sharding的這部分功能被融合到了Redis服務本身中,并沒有單獨的Sharding子產品。

官方叢集引入slot的概念進行資料分片,之後将資料slot配置設定到多個Master結點,Master結點再配置N個從結點,進而組成了多執行個體sharding版本的官方叢集架構。

Redis Cluster 是一個可以在多個 Redis 節點之間進行資料共享的分布式叢集,在服務端,通過節點之間的特殊協定進行通訊,這個特殊協定就充當了中間層的管理部分的通信協定,這個協定稱作Gossip流言協定。

分布式系統一緻性協定的目的就是為了解決叢集中多結點狀态通知的問題,是管理叢集的基礎,如圖展示了基于Gossip協定的官方叢集架構圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

同步機制

了解持久化和資料同步的關系,需要從單點故障和高可用兩個角度來分析:

1 單點當機故障

假如我們現在隻有一台作為緩存的Redis機器,通過持久化将熱點資料寫到磁盤,某時刻該Redis單點機器發生故障當機,此期間緩存失效,主存儲服務将承受所有的請求壓力倍增,監控程式将當機Redis機器拉起。

重新開機之後,該機器可以Load磁盤RDB資料進行快速恢複,恢複的時間取決于資料量的多少,一般秒級到分鐘級不等,恢複完成保證之前的熱點資料還在,這樣存儲系統的CacheMiss就會降低,有效降低了緩存擊穿的影響。

在單點Redis中持久化機制非常有用,隻寫文字容易讓大家睡着,我畫了張圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

作為一個高可用的緩存系統單點當機是不允許的,是以就出現了主從架構,對主節點的資料進行多個備份,如果主節點挂點,可以立刻切換狀态最好的從節點為主節點,對外提供寫服務,并且其他從節點向新主節點同步資料,確定整個Redis緩存系統的高可用。

如圖展示了一個一主兩從讀寫分離的Redis系統主節點故障遷移的過程,整個過程并沒有停止正常工作,大大提高了系統的高可用:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

從上面的兩點分析可以得出個小結論【劃重點】:

持久化讓單點故障不再可怕,資料同步為高可用插上翅膀。

我們了解了資料同步對Redis的重要作用,接下來繼續看資料同步的實作原理和過程、重難點等細節問題吧!

2 Redis系統中的CAP理論

對分布式存儲有了解的讀者一定知道CAP理論,說來慚愧筆者在2018年3月份換工作的時候,去Face++曠視科技面後端開發崗位時就遇到了CAP理論,除了CAP理論問題之外其他問題都在射程内,是以最終還是拿了Offer。

在理論計算機科學中,CAP定理又被稱作布魯爾定理Brewer's theorem,這個定理起源于加州大學伯克利分校的計算機科學家埃裡克·布魯爾在2000年的分布式計算原理研讨會PODC上提出的一個猜想。

在2002年麻省理工學院的賽斯·吉爾伯特和南希·林奇發表了布魯爾猜想的證明,使之成為一個定理。它指出對于一個分布式計算系統來說,不可能同時滿足以下三點:

  • C Consistent 一緻性 連貫性
  • A Availability 可用性
  • P Partition Tolerance 分區容忍性

來看一張阮一峰大佬畫的圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

舉個簡單的例子,說明一下CP和AP的相容性:

了解CP和AP的關鍵在于分區容忍性P,網絡分區在分布式存儲中再平常不過了,即使機器在一個機房,也不可能全都在一個機架或一台交換機。

這樣在區域網路就會出現網絡抖動,筆者做過1年多DPI對于網絡傳輸中最深刻的三個名詞:丢包、亂序、重傳。是以我們看來風平浪靜的網絡,在伺服器來說可能是風大浪急,一不小心就不通了,是以當網絡出現斷開時,這時就出現了網絡分區問題。

對于Redis資料同步而言,假設從結點和主結點在兩個機架上,某時刻發生網絡斷開,如果此時Redis讀寫分離,那麼從結點的資料必然無法與主繼續同步資料。在這種情況下,如果繼續在從結點讀取資料就造成資料不一緻問題,如果強制保證資料一緻從結點就無法提供服務造成不可用問題,進而看出在P的影響下C和A無法兼顧。

其他幾種情況就不深入了,從上面我們可以得出結論:當Redis多台機器分布在不同的網絡中,如果出現網絡故障,那麼資料一緻性和服務可用性無法兼顧,Redis系統對此必須做出選擇,事實上Redis選擇了可用性,或者說Redis選擇了另外一種最終一緻性。

3 Redis的最終一緻性和複制

Redis選擇了最終一緻性,也就是不保證主從資料在任何時刻都是一緻的,并且Redis主從同步預設是異步的,親愛的盆友們不要暈!不要蒙圈!

1.最終一緻性

最終一緻性通過異步複制來實作。

我來一下解釋同步複制和異步複制(注意:考慮讀者的感受 我并沒有寫成同步同步和異步同步 ):

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

一圖勝千言,看紅色的數字就知道同步複制和異步複制的差別了:

  • 異步複制:當用戶端向主結點寫了hello world,主節點寫成功之後就向用戶端回複OK,這樣主節點和用戶端的互動就完成了,之後主節點向從結點同步hello world,從結點完成之後向主節點回複OK,整個過程用戶端不需要等待從結點同步完成,是以整個過程是異步實作的。
  • 同步複制:當用戶端向主結點寫了hello world,主節點向從結點同步hello world,從結點完成之後向主節點回複OK,之後主節點向用戶端回複OK,整個過程用戶端需要等待從結點同步完成,是以整個過程是同步實作的。

Redis選擇異步複制可以避免用戶端的等待,更符合現實要求,不過這個複制方式可以修改,根據自己需求而定吧。

2.複制

1.從從複制

假如Redis高可用系統中有一主四從,如果四個從同時向主節點進行資料同步,主節點的壓力會比較大,考慮到Redis的最終一緻性,是以Redis後續推出了從從複制,進而将單層複制結構演進為多層複制結構,筆者畫了個圖看下:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

2.全量複制和增量複制

全量複制是從結點因為故障恢複或者新添加從結點時出現的初始化階段的資料複制,這種複制是将主節點的資料全部同步到從結點來完成的,是以成本大但又不可避免。

增量複制是主從結點正常工作之後的每個時刻進行的資料複制方式,涓涓細流同步資料,這種同步方式又輕又快,優點确實不少,不過如果沒有全量複制打下基礎增量複制也沒戲,是以二者不是沖突存在而是互相依存的。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?
全量複制過程分析

Redis的全量複制過程主要分三個階段:

  • 快照階段:從結點向主結點發起SYNC全量複制指令,主節點執行bgsave将記憶體中全部資料生成快照并發送給從結點,從結點釋放舊記憶體載入并解析新快照,主節點同時将此階段所産生的新的寫指令存儲到緩沖區。
  • 緩沖階段:主節點向從節點同步存儲在緩沖區的操作指令,這部分指令主節點是bgsave之後到從結點載入快照這個時間段内的新增指令,需要記錄要不然就出現資料丢失。
  • 增量階段:緩沖區同步完成之後,主節點正常向從結點同步增量操作指令,至此主從保持基本一緻的步調。

借鑒參考1的一張圖表,寫的很好:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

考慮一個多從并發全量複制問題:

如果此時有多個從結點同時向主結點發起全量同步請求會怎樣?

Redis主結點是個聰明又誠實的家夥,比如現在有3個從結點A/B/C陸續向主節點發起SYNC全量同步請求。

  • 主節點在對A進行bgsave的同時,B和C的SYNC指令到來了,那麼主節點就一鍋燴,把針對A的快照資料和緩沖區資料同時同步給ABC,這樣提高了效率又保證了正确性。
  • 主節點對A的快照已經完成并且現在正在進行緩沖區同步,那麼隻能等A完成之後,再對B和C進行和A一樣的操作過程,來實作新節點的全量同步,是以主節點并沒有偷懶而是重複了這個過程,雖然繁瑣但是保證了正确性。

再考慮一個快照複制循環問題:

主節點執行bgsave是比較耗時且耗記憶體的操作,期間從結點也經曆裝載舊資料->釋放記憶體->裝載新資料的過程,記憶體先升後降再升的動态過程,進而知道無論主節點執行快照還是從結點裝載資料都是需要時間和資源的。

抛開對性能的影響,試想如果主節點快照時間是1分鐘,在期間有1w條新指令到來,這些新指令都将寫到緩沖區,如果緩沖區比較小隻有8k,那麼在快照完成之後,主節點緩沖區也隻有8k指令丢失了2k指令,那麼此時從結點進行全量同步就缺失了資料,是一次錯誤的全量同步。

無奈之下,從結點會再次發起SYNC指令,進而陷入循環,是以緩沖區大小的設定很重要,二話不說再來一張圖:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?
增量複制過程分析

增量複制過程稍微簡單一些,但是非常有用,試想複雜的網絡環境下,并不是每次斷開都無法恢複,如果每次斷開恢複後就要進行全量複制,那豈不是要把主節點搞死,是以增量複制算是對複雜網絡環境下資料複制過程的一個優化,允許一段時間的落後,最終追上就行。

增量複制是個典型的生産者-消費者模型,使用定長環形數組(隊列)來實作,如果buffer滿了那麼新資料将覆寫老資料,是以從結點在複制資料的同時向主節點回報自己的偏移量,進而確定資料不缺失。

這個過程非常好了解,kakfa這種MQ也是這樣的,是以在合理設定buffer大小的前提下,理論上從的消費能力是大于主的生産能力的,大部分隻有在網絡斷開時間過長時會出現buffer被覆寫,從結點消費滞後的情況,此時隻能進行全量複制了。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

3.無盤複制

了解無盤複制之前先看下什麼是有盤複制呢? 所謂盤是指磁盤,可能是機械磁盤或者SSD,但是無論哪一種相比記憶體都更慢,我們都知道IO操作在服務端的耗時是占大頭的,是以對于全量複制這種高IO耗時的操作來說,尤其當服務并發比較大且還在進行其他操作時對Redis服務本身的影響是比較大大,之前的模式時這樣的:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

在Redis2.8.18版本之後,開發了無盤複制,也就是避免了生成的RDB檔案落盤再加載再網絡傳輸的過程,而是流式的周遊發送過程,主節點一邊周遊記憶體資料,一邊将資料序列化發送給從結點,從結點沒有變化,仍然将資料依次存儲到本地磁盤,完成傳輸之後進行記憶體加載,可見無盤複制是對IO更友好。

Redis分布式鎖與RedLock算法

1 基于Redis的分布式鎖簡介

最初分布式鎖借助于setnx和expire指令,但是這兩個指令不是原子操作,如果執行setnx之後擷取鎖但是此時用戶端挂掉,這樣無法執行expire設定過期時間就導緻鎖一直無法被釋放,是以在2.8版本中Antirez為setnx增加了參數擴充,使得setnx和expire具備原子操作性。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

在單Matster-Slave的Redis系統中,正常情況下Client向Master擷取鎖之後同步給Slave,如果Client擷取鎖成功之後Master節點挂掉,并且未将該鎖同步到Slave,之後在Sentinel的幫助下Slave更新為Master但是并沒有之前未同步的鎖的資訊,此時如果有新的Client要在新Master擷取鎖,那麼将可能出現兩個Client持有同一把鎖的問題,來看個圖來想下這個過程:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

為了保證自己的鎖隻能自己釋放需要增加唯一性的校驗,綜上基于單Redis節點的擷取鎖和釋放鎖的簡單過程如下:

// 擷取鎖 unique_value作為唯一性的校驗
SET resource_name unique_value NX PX 30000
// 釋放鎖 比較unique_value是否相等 避免誤釋放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
複制代碼      

這就是基于單Redis的分布式鎖的幾個要點。

2 Redlock算法基本過程

Redlock算法是Antirez在單Redis節點基礎上引入的高可用模式。在Redis的分布式環境中,我們假設有N個完全互相獨立的Redis節點,在N個Redis執行個體上使用與在Redis單執行個體下相同方法擷取鎖和釋放鎖。

現在假設有5個Redis主節點(大于3的奇數個),這樣基本保證他們不會同時都宕掉,擷取鎖和釋放鎖的過程中,用戶端會執行以下操作:

  • 擷取目前Unix時間,以毫秒為機關
  • 依次嘗試從5個執行個體,使用相同的key和具有唯一性的value擷取鎖

    當向Redis請求擷取鎖時,用戶端應該設定一個網絡連接配接和響應逾時時間,這個逾時時間應該小于鎖的失效時間,這樣可以避免用戶端死等

  • 用戶端使用目前時間減去開始擷取鎖時間就得到擷取鎖使用的時間。當且僅當從半數以上的Redis節點取到鎖,并且使用的時間小于鎖失效時間時,鎖才算擷取成功
  • 如果取到了鎖,key的真正有效時間等于有效時間減去擷取鎖所使用的時間,這個很重要
  • 如果因為某些原因,擷取鎖失敗(沒有在半數以上執行個體取到鎖或者取鎖時間已經超過了有效時間),用戶端應該在所有的Redis執行個體上進行解鎖,無論Redis執行個體是否加鎖成功,因為可能服務端響應消息丢失了但是實際成功了,畢竟多釋放一次也不會有問題

上述的5個步驟是Redlock算法的重要過程,也是面試的熱點,有心的讀者還是記錄一下吧

管道技術

Redis是一種基于用戶端-服務端模型以及請求/響應協定的TCP服務。這意味着通常情況下一個請求會遵循以下步驟:

  • 用戶端向服務端發送一個查詢請求,并監聽Socket傳回,通常是以阻塞模式,等待服務端響應。
  • 服務端處理指令,并将結果傳回給用戶端。

Redis 管道技術

Redis 管道技術可以在服務端未響應時,用戶端可以繼續向服務端發送請求,并最終一次性讀取所有服務端的響應。

$(echo -en "PING\r\n SET runoobkey redis\r\nGET runoobkey\r\nINCR visitor\r\nINCR visitor\r\nINCR visitor\r\n"; sleep 10) | nc localhost 6379

+PONG
+OK
redis
:1
:2
:3      

以上執行個體中我們通過使用 PING 指令檢視redis服務是否可用, 之後我們設定了 runoobkey 的值為 redis,然後我們擷取 runoobkey 的值并使得 visitor 自增 3 次。

在傳回的結果中我們可以看到這些指令一次性向 redis 服務送出,并最終一次性讀取所有服務端的響應

Redis事務

Redis 可以通過

MULTI

EXEC

DISCARD

WATCH

等指令來實作事務(transaction)功能。

> MULTI
OK
> SET USER "Guide哥"
QUEUED
> GET USER
QUEUED
> EXEC
1) OK
2) "Guide哥"      

使用

MULTI

指令後可以輸入多個指令。Redis 不會立即執行這些指令,而是将它們放到隊列,當調用了

EXEC

指令将執行所有指令。

這個過程是這樣的:

  1. 開始事務(

    MULTI

    )。
  2. 指令入隊(批量操作 Redis 的指令,先進先出(FIFO)的順序執行)。
  3. 執行事務(

    EXEC

    )。

你也可以通過

DISCARD

指令取消一個事務,它會清空事務隊列中儲存的所有指令。

> MULTI
OK
> SET USER "Guide哥"
QUEUED
> GET USER
QUEUED
> DISCARD
OKCopy to clipboardErrorCopied      

WATCH

指令用于監聽指定的鍵,當調用

EXEC

指令執行事務時,如果一個被

WATCH

指令監視的鍵被修改的話,整個事務都不會執行,直接傳回失敗。

> WATCH USER
OK
> MULTI
> SET USER "Guide哥"
OK
> GET USER
Guide哥
> EXEC
ERR EXEC without MULTICopy to clipboardErrorCopied      

Redis 官網相關介紹

https://redis.io/topics/transactions

如下:

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

但是,Redis 的事務和我們平時了解的關系型資料庫的事務不同。我們知道事務具有四大特性: 1. 原子性,2. 隔離性,3. 持久性,4. 一緻性。

  1. 原子性(Atomicity): 事務是最小的執行機關,不允許分割。事務的原子性確定動作要麼全部完成,要麼完全不起作用;
  2. 隔離性(Isolation): 并發通路資料庫時,一個使用者的事務不被其他事務所幹擾,各并發事務之間資料庫是獨立的;
  3. 持久性(Durability): 一個事務被送出之後。它對資料庫中資料的改變是持久的,即使資料庫發生故障也不應該對其有任何影響。
  4. 一緻性(Consistency): 執行事務前後,資料保持一緻,多個事務對同一個資料讀取的結果是相同的;

Redis 是不支援 roll back 的,因而不滿足原子性的(而且不滿足持久性)。

Redis 官網也解釋了自己為啥不支援復原。簡單來說就是 Redis 開發者們覺得沒必要支援復原,這樣更簡單便捷并且性能更好。Redis 開發者覺得即使指令執行錯誤也應該在開發過程中就被發現而不是生産過程中。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

你可以将 Redis 中的事務就了解為 :Redis 事務提供了一種将多個指令請求打包的功能。然後,再按順序執行打包的所有指令,并且不會被中途打斷。

緩存擊穿、緩存穿透、緩存雪崩

緩存擊穿

     描述:

     緩存在某個時間點過期的時候,恰好在這個時間點對這個Key有大量的并發請求過來,這些請求發現緩存過期一般都會從後端DB加載資料并回設到緩存,這個時候大并發的請求可能會瞬間把後端DB壓垮。緩存被“擊穿”的問題,這個和緩存雪崩的差別在于這裡針對某一key緩存,緩存雪崩則是很多key。

     解決方案:

1.設定熱點資料永遠不過期;

2.加互斥鎖 (熱點資料緩存中沒有的話使用setnx方法設定,設定成功則取loaddb并設定緩存,否則重試get方法)。

互斥鎖參考代碼如下:

//2.6.1前單機版本鎖
String get(String key) {  
   String value = redis.get(key);  
   if (value  == null) {  
    if (redis.setnx(key_mutex, "1")) {  
        // 3 min timeout to avoid mutex holder crash  
        redis.expire(key_mutex, 3 * 60)  
        value = db.get(key);  
        redis.set(key, value);  
        redis.delete(key_mutex);  
    } else {  
        //其他線程休息50毫秒後重試  
        Thread.sleep(50);  
        get(key);  
    }  
  }  
      

緩存穿透

緩存穿透說簡單點就是大量請求的 key 根本不存在于緩存中,導緻請求直接到了資料庫上,根本沒有經過緩存這一層。舉個例子:某個黑客故意制造我們緩存中不存在的 key 發起大量請求,導緻大量請求落到資料庫。

解決方法:

1.緩存無效的key,下次請求直接傳回

2.布隆過濾器,布隆過濾器是一個非常神奇的資料結構,通過它我們可以非常友善地判斷一個給定資料是否存在于海量資料中。我們需要的就是判斷 key 是否合法,有沒有感覺布隆過濾器就是我們想要找的那個“人”。

加入布隆過濾器之後的緩存處理流程圖如下。

Redis學習總結--《我的Java打怪日記》Redis的單線程和高性能資料結構Redis資料庫記憶體回收機制持久化Redis叢集同步機制Redis分布式鎖與RedLock算法管道技術Redis事務緩存擊穿、緩存穿透、緩存雪崩如何保證緩存和資料庫資料的一緻性?

但是,需要注意的是布隆過濾器可能會存在誤判的情況。總結來說就是: 布隆過濾器說某個元素存在,小機率會誤判。布隆過濾器說某個元素不在,那麼這個元素一定不在。

為什麼會出現誤判的情況呢? 我們還要從布隆過濾器的原理來說!

我們先來看一下,當一個元素加入布隆過濾器中的時候,會進行哪些操作:

  1. 使用布隆過濾器中的哈希函數對元素值進行計算,得到哈希值(有幾個哈希函數得到幾個哈希值)。
  2. 根據得到的哈希值,在位數組中把對應下标的值置為 1。

我們再來看一下,當我們需要判斷一個元素是否存在于布隆過濾器的時候,會進行哪些操作:

  1. 對給定元素再次進行相同的哈希計算;
  2. 得到值之後判斷位數組中的每個元素是否都為 1,如果值都為 1,那麼說明這個值在布隆過濾器中,如果存在一個值不為 1,說明該元素不在布隆過濾器中。

然後,一定會出現這樣一種情況:不同的字元串可能哈希出來的位置相同。 (可以适當增加位數組大小或者調整我們的哈希函數來降低機率)

緩存雪崩

緩存在同一時間大面積的失效,後面的請求都直接落到了資料庫上,造成資料庫短時間内承受大量請求。 這就好比雪崩一樣,摧枯拉朽之勢,資料庫的壓力可想而知,可能直接就被這麼多請求弄當機了。

針對 Redis 服務不可用的情況:

  1. 采用 Redis 叢集,避免單機出現問題整個緩存服務都沒辦法使用。
  2. 限流,避免同時處理大量的請求。

針對熱點緩存失效的情況:

  1. 設定不同的失效時間比如随機設定緩存的失效時間。
  2. 緩存永不失效。

如何保證緩存和資料庫資料的一緻性?

細說的話可以扯很多,但是我覺得其實沒太大必要。我個人覺得引入緩存之後,如果為了短時間的不一緻性問題,選擇讓系統設計變得更加複雜的話,完全沒必要。

下面單獨對 Cache Aside Pattern(旁路緩存模式) 來聊聊。

Cache Aside Pattern 中遇到寫請求是這樣的:

寫操作:更新 DB,然後直接删除 cache 。

讀操作:先從緩存中讀,沒讀到從資料庫中讀,再更新到緩存。

如果更新資料庫成功,而删除緩存這一步失敗的情況的話,簡單說兩個解決方案:

  1. 緩存失效時間變短(不推薦,治标不治本) :我們讓緩存資料的過期時間變短,這樣的話緩存就會從資料庫中加載資料。另外,這種解決辦法對于先操作緩存後操作資料庫的場景不适用。
  2. 增加 cache 更新重試機制(常用): 如果 cache 服務目前不可用導緻緩存删除失敗的話,我們就隔一段時間進行重試,重試次數可以自己定。如果多次重試還是失敗的話,我們可以把目前更新失敗的 key 存入隊列中,等緩存服務可用之後,再将 緩存中對應的 key 删除即可。