開發與維運

Redis學習總結–《我的Java打怪日記》

參考:《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多路複用,將連接信息和事件放到隊列中,依次放到文件事件分派器,事件分派器將事件分發給事件處理器。


image

# 查看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模式的類圖:

image

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

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

更簡潔的流程如下:

image

循環前先將待監控的事件進行註冊,當監控中的Socket讀寫事件到來時,事件採集器epoll等IO複用工具檢測到並且將事件返回給事件分發器Dispatcher,分發器根據讀、寫、異常等情況進行分發給事件處理器,事件處理器進而根據事件具體類型來調度相應的實現函數來完成任務。

Reactor模式在Redis中的實現

Redis處理客戶端業務(文件事件)的基本流程:

image

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事件與命令回覆處理器相關聯,當客戶端嘗試讀取結果時產生可寫事件,此時服務器端觸發命令回覆響應,並將數據結果寫入套接字,完成之後服務端接觸該套接字與命令回覆處理器之間的關聯;

                  image

數據結構

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

image

image

左邊為基本數據類型,右邊是其實現方式,如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[]
};


筆者畫了個圖:

image

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

                        image

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

  • O(1)獲取長度: C字符串需要遍歷而sds中有len可以直接獲得;
  • 防止緩衝區溢出bufferoverflow: 當sds需要對字符串進行修改時,首先藉助於len和alloc檢查空間是否滿足修改所需的要求,如果空間不夠的話,SDS會自動擴展空間,避免了像C字符串操作中的覆蓋情況;
  • 有效降低內存分配次數:C字符串在涉及增加或者清除操作時會改變底層數組的大小造成重新分配、sds使用了空間預分配和惰性空間釋放機制,說白了就是每次在擴展時是成倍的多分配的,在縮容是也是先留著並不正式歸還給OS,這兩個機制也是比較好理解的;
  • 二進制安全:C語言字符串只能保存ascii碼,對於圖片、音頻等信息無法保存,sds是二進制安全的,寫入什麼讀取就是什麼,不做任何過濾和限制;

image.png

動態擴容的原因:

redis對速度的要求苛刻,如果使用c語言的string,每次修改字符串長度都需要重新分配一次內存,十分耗時。

list

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

  • 所有字符串元素的長度都小於64字節
  • 元素數量小於512個

1. ziplist編碼

image

2.linkedlist編碼

結構為雙向鏈表

image.png

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

1. ziplist編碼

image

2 hashtable編碼

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

image.png

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

image.png

//字典結構
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中的字典和跳錶佈局:

image.png

ziplist

ziplist總體結構

image

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

image.png

來看下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),另一個是在集群節點中用作內部數據結構。

image.png

Redis數據庫

redis服務器的狀態都保存在redisServer & redisDb兩個結構中,具體如下圖:

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

redisDB的一個例子如下圖:

image

內存回收機制

為了讓Redis服務安全穩定的運行,讓使用內存保持在一定的閾值內是非常有必要的,因此我們就需要刪除該刪除的,清理該清理的,把內存留給需要的鍵值對,試想一條大河需要設置幾個警戒水位來確保不決堤不枯竭,Redis也是一樣的,只不過Redis只關心決堤即可,來一張圖:

image

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

筆者只是想表達出於安全和穩定的考慮,不要覺得128GB的內存就意味著存儲128GB的數據,都是要打折的。

B.1 回收的內存從哪裡來

Redis佔用的內存是分為兩部分:存儲鍵值對消耗和本身運行消耗。顯然後者我們無法回收,因此只能從鍵值對下手了,鍵值對可以分為幾種:帶過期的、不帶過期的、熱點數據、冷數據。對於帶過期的鍵值是需要刪除的,如果刪除了所有的過期鍵值對之後內存仍然不足怎麼辦?那隻能把部分數據給踢掉了。

image

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 時間戳)。

image

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

typedef struct redisDb {
    ...

    dict *dict;     //數據庫鍵空間,保存著數據庫中所有鍵值對
    dict *expires   // 過期字典,保存著鍵的過期時間
    ...
} redisDb;

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

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

                             image

3. 鍵值對的刪除策略

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

先拋開Redis來想一下可能的幾種刪除策略:

  • 定時刪除:在設置鍵的過期時間的同時,創建定時器,讓定時器在鍵過期時間到來時,即刻執行鍵值對的刪除;
  • 定期刪除:每隔特定的時間對數據庫進行一次掃描,檢測並刪除其中的過期鍵值對;
  • 惰性刪除:鍵值對過期暫時不進行刪除,至於刪除的時機與鍵值對的使用有關,當獲取鍵時先查看其是否過期,過期就刪除,否則就保留;

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

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

Reids採用的是惰性刪除和定期刪除的結合,一般來說可以藉助最小堆來實現定時器,不過Redis的設計考慮到時間事件的有限種類和數量,使用了無序鏈表存儲時間事件,這樣如果在此基礎上實現定時刪除,就意味著O(N)遍歷獲取最近需要刪除的數據。

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

                image

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哨兵機制,可以實現主從故障遷移。

單實例一主兩從+讀寫分離結構:

image

單實例的由於本質上只有一臺Master作為存儲,就算機器為128GB的內存,一般建議使用率也不要超過70%-80%,所以最多使用100GB數據就已經很多了,實際中50%就不錯了,以為數據量太大也會降低服務的穩定性,因為數據量太大意味著持久化成本高,可能嚴重阻塞服務,甚至最終切主。

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

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

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

集群架構

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

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

中間層分片:這種情況是在客戶端和服務器端之間增加中間層,充當管理者和調度者,客戶端的請求打向中間層,由中間層實現請求的轉發和回收,當然中間層最重要的作用是對多臺服務器的動態管理。

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

客戶端分片版集群

需要解決一致性哈希的問題,也就是動態擴縮容時的數據問題。(一致性hash算法)

image.png

中間層分片的集群版Redis

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

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

image

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

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

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

官方集群引入slot的概念進行數據分片,之後將數據slot分配到多個Master結點,Master結點再配置N個從結點,從而組成了多實例sharding版本的官方集群架構。

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

分佈式系統一致性協議的目的就是為了解決集群中多結點狀態通知的問題,是管理集群的基礎,如圖展示了基於Gossip協議的官方集群架構圖:

image

同步機制

理解持久化和數據同步的關係,需要從單點故障和高可用兩個角度來分析:

1 單點宕機故障

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

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

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

image

作為一個高可用的緩存系統單點宕機是不允許的,因此就出現了主從架構,對主節點的數據進行多個備份,如果主節點掛點,可以立刻切換狀態最好的從節點為主節點,對外提供寫服務,並且其他從節點向新主節點同步數據,確保整個Redis緩存系統的高可用。

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

image

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

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

我們理解了數據同步對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 分區容忍性

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

                                    image

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

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

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

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

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

3 Redis的最終一致性和複製

Redis選擇了最終一致性,也就是不保證主從數據在任何時刻都是一致的,並且Redis主從同步默認是異步的,親愛的盆友們不要暈!不要蒙圈!

1.最終一致性

最終一致性通過異步複製來實現。

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

image

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

  • 異步複製:當客戶端向主結點寫了hello world,主節點寫成功之後就向客戶端回覆OK,這樣主節點和客戶端的交互就完成了,之後主節點向從結點同步hello world,從結點完成之後向主節點回復OK,整個過程客戶端不需要等待從結點同步完成,因此整個過程是異步實現的。
  • 同步複製:當客戶端向主結點寫了hello world,主節點向從結點同步hello world,從結點完成之後向主節點回復OK,之後主節點向客戶端回覆OK,整個過程客戶端需要等待從結點同步完成,因此整個過程是同步實現的。

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

2.複製

1.從從複製

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

image

2.全量複製和增量複製

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

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

image

全量複製過程分析

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

  • 快照階段:從結點向主結點發起SYNC全量複製命令,主節點執行bgsave將內存中全部數據生成快照併發送給從結點,從結點釋放舊內存載入並解析新快照,主節點同時將此階段所產生的新的寫命令存儲到緩衝區。
  • 緩衝階段:主節點向從節點同步存儲在緩衝區的操作命令,這部分命令主節點是bgsave之後到從結點載入快照這個時間段內的新增命令,需要記錄要不然就出現數據丟失。
  • 增量階段:緩衝區同步完成之後,主節點正常向從結點同步增量操作命令,至此主從保持基本一致的步調。

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

image

考慮一個多從併發全量複製問題

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

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命令,從而陷入循環,因此緩衝區大小的設置很重要,二話不說再來一張圖:

image

增量複製過程分析

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

增量複製是個典型的生產者-消費者模型,使用定長環形數組(隊列)來實現,如果buffer滿了那麼新數據將覆蓋老數據,因此從結點在複製數據的同時向主節點反饋自己的偏移量,從而確保數據不缺失。

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

image

3.無盤複製

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

image

在Redis2.8.18版本之後,開發了無盤複製,也就是避免了生成的RDB文件落盤再加載再網絡傳輸的過程,而是流式的遍歷發送過程,主節點一邊遍歷內存數據,一邊將數據序列化發送給從結點,從結點沒有變化,仍然將數據依次存儲到本地磁盤,完成傳輸之後進行內存加載,可見無盤複製是對IO更友好

Redis分佈式鎖與RedLock算法

1 基於Redis的分佈式鎖簡介

最初分佈式鎖藉助於setnx和expire命令,但是這兩個命令不是原子操作,如果執行setnx之後獲取鎖但是此時客戶端掛掉,這樣無法執行expire設置過期時間就導致鎖一直無法被釋放,因此在2.8版本中Antirez為setnx增加了參數擴展,使得setnx和expire具備原子操作性。

image

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

  image.png

為了保證自己的鎖只能自己釋放需要增加唯一性的校驗,綜上基於單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 可以通過 MULTIEXECDISCARDWATCH 等命令來實現事務(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 如下:

image

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

  1. 原子性(Atomicity): 事務是最小的執行單位,不允許分割。事務的原子性確保動作要麼全部完成,要麼完全不起作用;
  2. 隔離性(Isolation): 併發訪問數據庫時,一個用戶的事務不被其他事務所幹擾,各併發事務之間數據庫是獨立的;
  3. 持久性(Durability): 一個事務被提交之後。它對數據庫中數據的改變是持久的,即使數據庫發生故障也不應該對其有任何影響。
  4. 一致性(Consistency): 執行事務前後,數據保持一致,多個事務對同一個數據讀取的結果是相同的;

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

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

image

你可以將 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 是否合法,有沒有感覺布隆過濾器就是我們想要找的那個“人”。

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

image

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

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

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

  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 刪除即可。


Leave a Reply

Your email address will not be published. Required fields are marked *