天天看點

玩轉redis緩存

五種資料結構簡介

Redis是使用C編寫的,内部實作了一個struct結構體redisObject對象,通過結構體來模仿面向對象程式設計的“多态”,動态支援不同類型的value。作為一個底層的資料支援,redisObject結構體代碼如下定義:

#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */
#define LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
typedef struct redisObject {
   //對象的資料類型,占4bits,共5種類型
    unsigned type:4;        
    //對象的編碼類型,占4bits,共10種類型
    unsigned encoding:4;
    //least recently used
    //實用LRU算法計算相對server.lruclock的LRU時間
    unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */
    //引用計數
    int refcount;  
    //指向底層資料實作的指針
    void *ptr;
} robj;
           

下面介紹type、encoding、ptr3個屬性定義枚舉。

type:redisObject的類型,字元串、清單、集合、有序集、哈希表

//type的占5種類型:
/* Object types */
#define OBJ_STRING 0    //字元串對象
#define OBJ_LIST 1      //清單對象
#define OBJ_SET 2       //集合對象
#define OBJ_ZSET 3      //有序集合對象
#define OBJ_HASH 4      //哈希對象
           

encoding:底層實作結構,字元串、整數、跳躍表、壓縮清單等

/* Objects encoding. Some kind of objects like Strings and Hashes can be
 * internally represented in multiple ways. The 'encoding' field of the object
 * is set to one of this fields for this object. */
// encoding 的10種類型
#define OBJ_ENCODING_RAW 0    /* Raw representation */ //原始表示方式,字元串對象是簡單動态字元串
#define OBJ_ENCODING_INT 1     /* Encoded as integer */         //long類型的整數
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */      //字典
#define OBJ_ENCODING_ZIPMAP 3  /* Encoded as zipmap */          //不在使用
#define OBJ_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */  //雙端連結清單,不在使用
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */         //壓縮清單
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */          //整數集合
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */      //跳躍表和字典
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */ //embstr編碼的簡單動态字元串
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */ //由壓縮清單組成的雙向清單-->快速清單
           

ptr:實際指向儲存值的資料結構

如果一個 redisObject 的 type 屬性為 OBJ_LIST,encoding 屬性為 REDIS_ENCODING_LINKEDLIST,那麼這個對象就是一個 Redis 清單,它的值儲存在一個雙連結清單内,而 ptr 指針就指向這個雙向連結清單;如果一個 redisObject 的type屬性為OBJ_HASH,encoding 屬性REDIS_ENCODING_ZIPMAP,那麼這個對象就是一個 Redis 哈希表,它的值儲存在一個 zipmap 裡,而 ptr 指針就指向這個 zipmap 。

下面這張圖檔中的OBJ_STRING/OBJ_LIST/OBJ_ZSET/OBJ_HASH/OBJ_SET針對的是redisObject中的type,後面指向的REDIS_ENCODING_INT、REDIS_ENCODING_RAW、REDIS_ENCODING_LINKEDLIST等針對的是encoding字段。

玩轉redis緩存

redis結構與編碼組合清單

Redis的底層資料結構有以下幾種,具體的資料結構原理就不細講了:

  • 簡單動态字元串sds(Simple Dynamic String)
  • 雙向連結清單(LinkedList)
  • 字典(Map)
  • 跳躍表(SkipList)

String

字元串對象的底層實作類型如下:

編碼—encoding 對象—ptr
OBJ_ENCODING_RAW 簡單動态字元串實作的字元串對象
OBJ_ENCODING_INT 整數值實作的字元串對象
OBJ_ENCODING_EMBSTR embstr編碼的簡單動态字元串實作的字元串對象

如果一個String類型的value能夠儲存為整數,則将對應redisObject 對象的encoding修改為REDIS_ENCODING_INT,将對應redisObject對象的ptr值改為對應的數值;如果不能轉為整數,保持原有encoding為REDIS_ENCODING_RAW。是以String類型的資料可能使用原始的字元串存儲(實際為sds - Simple Dynamic Strings,對應encoding為REDIS_ENCODING_RAW或OBJ_ENCODING_EMBSTR)或者整數存儲。

字元串編碼存在OBJ_ENCODING_RAW和OBJ_ENCODING_EMBSTR兩種,redis會根據value中字元串的大小動态選擇。建立一個String類型的redis值,配置設定空間的代碼如下:

RedisObj *o = zmalloc(sizeof(RedisObj)+sizeof(struct sdshdr8)+len+1);
           

其中:sdshdr8(儲存字元串對象的結構)的大小為3個位元組,加上1個結束符共4個位元組;redisObject的大小為16個位元組;一個embstr固定的大小為16+3+1 = 20個位元組,是以一個最大的embstr字元串為64-20 = 44位元組。建立字元串對象,根據長度使用不同的編碼類型--createRawStringObject或createEmbeddedStringObject。當字元串長度大于44位元組時,使用createRawStringObject,此時redisobj結構和sdshdr結構(存儲具體字元串内容)在記憶體上是分開的;當字元串長度小于等于44位元組時,使用createEmbeddedStringObject,此時redisObj結構和sdshdr結構在記憶體上是連續的。

List

清單的底層實作有2種:REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_LINKEDLIST,ZIPLIST(壓縮清單)相比LINKEDLIST(連結清單)可以節省記憶體,當建立新的清單時,預設是使用壓縮清單作為底層資料結構的。Redis内部會對相關操作做判斷,當list的元數小于配置值: hash-max-ziplist-entries 或者elem_value字元串的長度小于 hash-max-ziplist-value, 可以編碼成 REDIS_ENCODING_ZIPLIST 類型存儲,以節約記憶體。

壓縮清單ziplist結構本身就是一個連續的記憶體塊,由表頭、若幹個entry節點和壓縮清單尾部辨別符zlend組成,通過一系列編碼規則,提高記憶體的使用率,使用于存儲整數和短字元串。

壓縮清單是一系列特殊編碼的連續記憶體塊組成的順序序列資料結構,可以包含任意多個節點(entry),每一個節點可以儲存一個位元組數組或者一個整數值。

壓縮清單資料實作的指針指向的結構如下圖所示:

玩轉redis緩存

壓縮清單資料結構

  • zlbytes:占4個位元組,記錄整個壓縮清單占用的記憶體位元組數。
  • zltail_offset:占4個位元組,記錄壓縮清單尾節點entryN距離壓縮清單的起始位址的位元組數。
  • zllength:占2個位元組,記錄了壓縮清單的節點數量。
  • entry[1-N]:長度不定,儲存資料。
  • zlend:占1個位元組,儲存一個常數255(0xFF),标記壓縮清單的末端。

壓縮清單ziplist結構的缺點是:每次插入或删除一個元素時,都需要進行頻繁的調用realloc()函數進行記憶體的擴充或減小,然後進行資料”搬移”,甚至可能引發連鎖更新,造成嚴重效率的損失。

Hash

建立新的Hash類型時,預設也使用ziplist存儲value,儲存資料過多時,使用hash table。

redisObject對象中存放的是結構體dict,定義如下:

typedefstruct dict {
    dictType *type; //指向dictType結構,dictType結構中包含自定義的函數,這些函數使得key和value能夠存儲任何類型的資料。
    void *privdata; //私有資料,儲存着dictType結構中函數的參數。
    dictht ht[2]; //兩張哈希表。用于擴充或收縮 
    long rehashidx; //rehash的标記,rehashidx==-1,表示沒在進行rehash
    int iterators; //正在疊代的疊代器數量
} dict;
           

其中dictht(Redis中哈希表)定義如下:

typedefstruct dictht { //哈希表
dictEntry **table; //數組位址,數組存放着哈希表節點dictEntry的位址。
unsignedlong size;     //哈希表table的大小,初始化大小為4
unsignedlong sizemask; //值總是等于(size-1)。
unsignedlong used;     //記錄哈希表已有的節點(鍵值對)數量。
} dictht;
           

其中dictEntry就是存放key和value的結構體。

整體的結構如下:

玩轉redis緩存

hash類型的redis值結構

Set

集合的底層實作也有兩種:REDIS_ENCODING_INTSET和REDIS_ENCODING_HT(字典),建立Set類型的key-value時,如果value能夠表示為整數,則使用intset類型儲存value。否則切換為使用hash table儲存各個value(hash table,參考上面Hash的介紹),雖然使用散清單對集合的加入删除元素,判斷元素是否存在等操作時間複雜度為O(1),但是當存儲的元素是整型且元素數目較少時,如果使用散清單存儲,就會比較浪費記憶體,是以整數集合(intset)類型因為節約記憶體而存在。

整數集合(intset)結構體定義如下:

typedefstruct intset {
    uint32_t encoding;  //編碼格式,有如下三種格式,初始值預設為INTSET_ENC_INT16
    uint32_t length;    //集合元素數量
    int8_t contents[];  //儲存元素的數組,元素類型并不一定是ini8_t類型,柔性數組不占intset結構體大小,并且數組中的元素從小到大排列。
} intset;               //整數集合結構
           

整數集合(intset)類型的編碼格式有下面三種:

#define INTSET_ENC_INT16 (sizeof(int16_t))   //16位,2個位元組,表示範圍-32,768~32,767
#define INTSET_ENC_INT32 (sizeof(int32_t))   //32位,4個位元組,表示範圍-2,147,483,648~2,147,483,647
#define INTSET_ENC_INT64 (sizeof(int64_t))   //64位,8個位元組,表示範圍-9,223,372,036,854,775,808~9,223,372,036,854,775,807
           

intset整數集合之是以有三種表示編碼格式的宏定義,是因為根據存儲的元素數值大小,能夠選取一個最”合适”的類型存儲,”合适”可以了解為:既能夠表示元素的大小,又可以節省空間。是以,當新添加的元素,例如:65535,超過目前集合編碼格式所能表示的範圍,就要進行更新操作。

Sorted Set

有序集合的底層編碼實作也是2種:REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_SKIPLIST。跳躍表在redis中當資料較多時作為有序集合鍵的實作方式之一。跳躍表是一個有序連結清單,其中每個節點包含不定數量的連結,節點中的第i個連結構成的單向連結清單跳過含有少于i個連結的節點。

跳躍表支援平均O(logN),最壞O(N)複雜度的節點查找,大部分情況下,跳躍表的效率可以和平衡樹相媲美。

Redis的持久化

我們知道redis與memcached的一個很大的不同是redis可以将資料持久化到磁盤,能持久化意味着資料的可靠性的提升。

RDB(redis database)是一個磁盤存儲的資料庫檔案,其中儲存的是最後一次寫入時記憶體資料的最後狀态。由于Redis的資料都存放在記憶體中,如果沒有配置持久化,redis重新開機後資料就全丢失了,于是需要開啟redis的持久化功能,将資料儲存到磁盤上,當redis重新開機後,可以從磁盤中恢複資料。redis提供兩種方式進行持久化,一種是RDB持久化(原理是将Reids在記憶體中的資料庫記錄定時dump到磁盤上的RDB持久化,這稱為“半持久化模式”),另外一種是AOF(append only file)持久化(原理是将Reids的記錄檔以追加的方式寫入檔案,這稱為“全持久化模式”)。

RDB的持久化方式通過配置的定時執行間隔定時将記憶體中的資料寫入到一個新的臨時RDB檔案中,然後用這個臨時檔案替換上次持久化的RDB檔案,如此不斷的定時更替。

當redis server重新開機時,會檢查目前配置的持久化方式,如果是AOF(Append Of File)則以AOF資料作為恢複資料,因為AOF備份的準确性往往比RDB更高。如果是隻開啟了RDB模式的話則會加載最新的RDB檔案内容到記憶體中。

另外redis也提供了手動調用的指令來實施RDB備份,包括阻塞的持久化和非阻塞的持久化。

RDB持久化

RDB持久化是指在指定的時間間隔内将記憶體中的資料集快照寫入磁盤,實際操作過程是fork一個子程序,先将資料集寫入臨時檔案,寫入成功後,再替換之前的檔案,用二進制壓縮存儲。

RDB持久化的時間間隔可以配置,和該配置項一起配合使用的還有另一個名額“變更次數”,每次時間間隔時必須同時符合“間隔時間”和“變更次數”兩個條件才會進行RDB持久化,否則當次的持久化過程會推遲到下一個時間間隔再判斷是否符合條件。

AOF持久化

當Redis開啟AOF持久化時,每次接收到操作指令後,先将操作指令和資料以格式化的方式追加到記錄檔檔案的尾部,追加成功後才進行記憶體資料庫的資料變更。這樣記錄檔檔案就儲存了所有的曆史操作過程。該過程與MySQL的bin.log、zookeeper的txn-log十分相似。

AOF儲存的是每次操作的操作序列,相比較而言RDB儲存的是資料快照,是以AOF的記錄檔檔案内容往往比RDB檔案大。

需要注意的是,因為linux對檔案的寫操作采取了“延遲寫入”手段,是以redis提供了always、everysec、no三種選擇來決定直接調用作業系統檔案寫入的刷盤動作。

AOF先記錄後變更的特性決定了資料的可靠性更高,是以當AOF和RDB持久化都配置時,Redis服務在重新開機後會優先選擇AOF資料作為資料恢複标準。

執行AOF資料恢複時,Redis讀取AOF檔案中的“操作+資料”集,通過逐條重放的方式恢複記憶體資料庫。

AOF檔案會不斷增大,它的大小直接影響“故障恢複”的時間,而且AOF檔案中曆史操作是可以丢棄的。AOF rewrite操作就是“壓縮”AOF檔案的過程,當然redis并沒有采用“基于原aof檔案”來重寫的方式,而是采取了類似snapshot的方式:基于copy-on-write,全量周遊記憶體中資料,然後逐個序列到aof檔案中。是以AOF rewrite能夠正确反應目前記憶體資料的狀态,這正是我們所需要的。rewrite過程中,對于新的變更操作将仍然被寫入到原AOF檔案中,同時這些新的變更操作也會被redis收集起來(buffer,copy-on-write方式下,最極端的可能是所有的key都在此期間被修改,将會耗費2倍記憶體),當記憶體資料被全部寫入到新的aof檔案之後,收集的新的變更操作也将會一并追加到新的aof檔案中,此後将會重命名新的aof檔案為appendonly.aof,此後所有的操作都将被寫入新的aof檔案。如果在rewrite過程中,出現故障,将不會影響原AOF檔案的正常工作,隻有當rewrite完成之後才會切換檔案,因為rewrite過程是比較可靠的。

Redis事務

Redis事務通常會使用MULTI,EXEC,WATCH等指令來完成,redis實作事務的機制與常見的關系型資料庫有很大的卻别,比如redis的事務不支援復原,事務執行時會阻塞其它用戶端的請求執行等。

事務實作相關的指令

MULTI

用于标記事務塊的開始。Redis會将後續的指令逐個放入隊列中,每一個指令的傳回結果都是“QUEUED”。隻有先執行MULTI指令後才能使用EXEC指令原子化地執行這個指令序列。總是傳回OK。

EXEC

在一個事務中執行所有先前放入隊列的指令,然後恢複正常的連接配接狀态。EXEC指令的傳回值是隊列中多條指令的有序結果。

當在事務中使用了WATCH指令監控的KEY時,隻有當受監控的鍵沒有被修改時,EXEC指令才會執行事務中的隊列指令集合。

DISCARD

清除所有先前在一個事務中放入隊列的指令,然後恢複正常的連接配接狀态。

如果使用了WATCH指令,那麼DISCARD指令就會将目前連接配接監控的所有鍵取消監控。

WATCH

watch 用于在進行事務操作的最後一步也就是在執行exec 之前對某個key進行監視,如果這個被監視的key被改動,那麼事務就被取消,否則事務正常執行。一般在MULTI 指令前就用watch指令對某個key進行監控。如果目前連接配接監控的key值被其它連接配接的用戶端修改,那麼目前連接配接的EXEC指令将執行失敗。

WATCH指令的作用隻是當被監控的鍵值被修改後阻止事務的執行,而不能保證其他用戶端不修改這一鍵值。

UNWATCH

清除所有先前為一個事務監控的鍵。執行EXEC指令後會取消對所有鍵的監控,如果不想執行事務中的指令也可以使用UNWATCH指令來取消監控。UNWATCH指令,清除所有受監控的鍵。在運作UNWATCH指令之後,Redis連接配接便可以再次自由地用于運作新事務。

redis事務從開始到結束通常會通過三個階段:

1)事務開始

2)指令入隊

3)事務執行

标記事務的開始,MULTI指令可以将執行該指令的用戶端從非事務狀态切換成事務狀态,這一切換是通過在用戶端狀态的flags屬性中打開REDIS_MULTI辨別完成, 在打開事務辨別的用戶端裡,這些指令,都會被暫存到一個指令隊列裡,不會因為使用者的輸入而立即執行。用戶端打開了事務辨別後,隻有指令: EXEC, DISCARD, WATCH,MULTI指令會被立即執行,其它指令伺服器不會立即執行,而是将這些指令放入到一個事務隊列裡面,然後向用戶端傳回一個QUEUED回複 。redis用戶端有自己的事務狀态,這個狀态儲存在用戶端狀态mstate屬性中。

事務的ACID性質詳解

在redis中事務總是具有原子性(Atomicity),一緻性(Consistency)和隔離性(Isolation),并且當redis運作在某種特定的持久化模式下,事務也具有持久性(Durability)。

原子性

事務具有原子性指的是事務中的多個操作當作一個整體來執行,伺服器要麼就執行事務中的所有操作,要麼就一個操作也不執行。但是對于redis的事務功能來說,事務隊列中的指令要麼就全部執行,要麼就一個都不執行,是以redis的事務是具有原子性的(有條件的原子性)。我們通常會知道兩種關于redis事務原子性的說法:一種是要麼事務都執行,要麼都不執行;另外一種說法是redis事務,當事務中的指令執行失敗後面的指令還會執行,錯誤之前的指令不會復原。其實這個兩個說法都是正确的,redis分文法錯誤和運作錯誤。

  • 文法錯誤:如果redis出現了文法錯誤,Redis 2.6.5之前的版本會忽略錯誤的指令,執行其他正确的指令,2.6.5之後的版本會忽略這個事務中的所有指令,都不執行。
  • 運作錯誤:運作錯誤表示指令在執行過程中出現錯誤,比如用GET指令擷取一個散清單類型的鍵值。這種錯誤在指令執行之前Redis是無法發現的,是以在事務裡這樣的指令會被Redis接受并執行。如果事務裡有一條指令執行錯誤,其他指令依舊會執行(包括出錯之後的指令)。

隻有當被調用的Redis指令有文法錯誤時,這條指令才會執行失敗(在将這個指令放入事務隊列期間,Redis能夠發現此類問題),或者對某個鍵執行不符合其資料類型的操作:實際上,這就意味着隻有程式錯誤才會導緻Redis指令執行失敗,這種錯誤很有可能在程式開發期間發現,一般很少在生産環境發現。

Redis已經在系統内部進行功能簡化,這樣可以確定更快的運作速度,因為Redis不需要事務復原的能力。

一緻性

事務具有一緻性指的是如果在執行事務之前是一緻的,那麼在事務執行之後,無論事務是否執行成功,資料庫也應該仍然一緻的。 “一緻”指的是資料符合資料庫本身的定義和要求,沒有包含非法或者無效的錯誤資料。redis通過謹慎的錯誤檢測和簡單的設計來保證事務一緻性。如果遇到運作錯誤,redis的原子性也不能保證,是以一緻性也是有條件的一緻性。

隔離性

事務的隔離性指的是即使有多個事務并發在執行,各個事務之間也不會互相影響,并且在并發狀态下執行的事務和串行執行的事務産生的結果完全相同。 因為redis使用單線程的方式來執行事務(以及事務隊列中的指令),并且伺服器保證,在執行事務期間不會對事物進行中斷。是以redis的事務總是以串行的方式運作的,并且事務也總是具有隔離性的 。

持久性

事務的持久性指的是當一個事務執行完畢時,執行這個事務所得的結果已經被保持到永久存儲媒體裡面。 因為redis事務不過是簡單的用隊列包裹起來一組redis指令,redis并沒有為事務提供任何額外的持久化功能,是以redis事務的持久性由redis使用的模式決定 :

  • 當伺服器在無持久化的記憶體模式下運作時,事務不具有持久性,一旦伺服器停機,包括事務資料在内的所有伺服器資料都将丢失 ;
  • 當伺服器在RDB持久化模式下運作的時候,伺服器隻會在特定的儲存條件滿足的時候才會執行BGSAVE指令,對資料庫進行儲存操作,并且異步執行的BGSAVE 不能保證事務資料被第一時間儲存到硬碟裡面,是以RDB持久化模式下的事務也不具有持久性 ;
  • 當伺服器運作在AOF持久化模式下,并且appedfsync的選項的值為always時,程式總會在執行指令之後調用同步函數,将指令資料真正的儲存到硬碟裡面,是以這種配置下的事務是具有持久性的;
  • 當伺服器運作在AOF持久化模式下,并且appedfsync的選項的值為everysec時,程式會每秒同步一次指令資料到磁盤因為停機可能會恰好發生在等待同步的那一秒内,這種可能造成事務資料丢失,是以這種配置下的事務不具有持久性。

過期資料清除

資料過期時間

通過EXPIRE key seconds指令來設定資料的過期時間。傳回1表明設定成功,傳回0表明key不存在或者不能成功設定過期時間。key的過期資訊以絕對Unix時間戳的形式存儲(Redis2.6之後以毫秒級别的精度存儲)。這意味着,即使Redis執行個體沒有運作也不會對key的過期時間造成影響。

key被DEL指令删除或者被SET、GETSET指令重置後與之關聯的過期時間會被清除。

更新了存儲在key中的值而沒有用全新的值替換key原有值的所有操作都不會影響在該key上設定的過期時間。例如使用INCR指令增加key的值或者通過LPUSH指令在list中增加一個新的元素或者使用HSET指令更新hash字段的值都不會清除原有的過期時間設定。

若key被RENAME指令重寫,比如本存在名為mykey_a和mykey_b的key一個RENAME mykey_b mykey_a指令将mykey_b重命名為本已存在的mykey_a。那麼無論mykey_a原來的設定如何都将繼承mykey_b的所有特性,包括過期時間設定。

EXPIRE key seconds應用于一個已經設定了過期時間的key上時原有的過期時間将被更新為新的過期時間。

過期資料删除政策--被動方式結合主動方式

當clients試圖通路設定了過期時間且已過期的key時,這個時候将key删除再傳回空,為主動過期方式。但僅是這樣是不夠的,因為可能存在一些key永遠不會被再次通路到,這些設定了過期時間的key也是需要在過期後被删除的。是以,Redis會周期性的随機測試一批設定了過期時間的key并進行處理。測試到的已過期的key将被删除,這種為被動過期方式。典型的方式為,Redis每秒做10次如下的步驟:

1)随機測試100個設定了過期時間的key

2)删除所有發現的已過期的key

3)若删除的key超過25個則重複步驟1

這是一個基于機率的簡單算法,基本的假設是抽出的樣本能夠代表整個key空間,redis持續清理過期的資料直至将要過期的key的百分比降到了25%以下。這也意味着在任何給定的時刻已經過期但仍占據着記憶體空間的key的量最多為每秒的寫操作量除以4。

redis叢集方案

Redis官方叢集方案Redis Cluster(P2P模式)

redis 3.0版本開始提供的叢集服務,服務端實作的叢集。Redis Cluster将所有Key映射到16384個Slot中,叢集中每個Redis執行個體負責一部分,執行個體之間雙向通信。業務程式通過內建的Redis Cluster用戶端進行操作。用戶端可以向任一執行個體送出請求,如果所需資料不在該執行個體中,則該執行個體引導用戶端自動去對應執行個體讀寫資料。

redis啟動之後,使用者必須開啟叢集模式,通過cluster-enabled yes 設定。通過執行cluster meet 指令來完成連接配接各個redis單例服務,redis 節點必須進行槽(slot)指派,這樣就建立一個redis 叢集了。沒有槽指派,叢集是不能正常運用起來.

redis 叢集是通過分片方式來存儲鍵值的,叢集預設将整個redis 資料庫分成16384個槽(slot),每個節點必須做槽指派。否則叢集處于fail 狀态。通過shell指令來指派槽,必須把16384槽都配置設定到不同節點。

此種方式叢集在添加和删除節點時,需通過手動腳本指令進行添加和删除,槽必須需要重新配置設定。這種叢集不能自動發現節點,節點的健康狀況,缺乏管理頁面監控整個叢集的狀況。

RedisSharding叢集

redis 3.0之前版本的叢集方式,是用戶端實作叢集的方案。建立由N個節點組成一個叢集,各redis節點互相獨立,不會進行互相通信。用戶端預先設定的路由規則,直接對多個Redis執行個體進行分布式通路。

采用一緻性hash算法(将key和節點name同時hashing)将redis 資料散列對應的節點,這樣用戶端就知道從哪個Redis節點擷取資料。當增加或減少節點時,不會産生由于重新比對造成的rehashing。

用戶端實作的叢集缺點:

  • 各個節點互相獨立
  • 一個節點挂的,整個叢集不可用,是以一般redis節點都主從備份,一但某個節點挂了,備份節點成為master。
  • 增加節點時,盡管采用一緻性哈希發送,還是會有key比對不到而丢失,導緻緩存被擊穿
  • 增加節點時,用戶端需重新調整路由規則,有多少個用戶端業務接入,就有多少個用戶端得重新調整。

利用代理中間件實作大規模Redis叢集

通過中間代理層實作的叢集方案以codis最為經典,codis的結構圖如下:

玩轉redis緩存

codis結構圖

這裡以codis為例分析,codis-proxy 是Redis用戶端連接配接的代理服務,用戶端通過連接配接codis-proxy,codis-proxy指定連接配接後面具體的redis執行個體。Redis用戶端通過zk上的注冊資訊來獲得目前可用的proxy清單,進而保證代理的高可用性。

我們為什麼選用codis方案作為redis的叢集方案,原因如下:

  • 整個多台codis-server 就是一個大的存儲系統, 實作負責均衡
  • 由于dashhoard功能,可通過web界面來管理,觀察Codis叢集的狀态,做到可視化操作,添加/删除組、資料分片、添加/删除redis執行個體等操作。
  • 支援熱擴容。即:在不停止服務的情況下,實作叢集裝置的增減。
  • 資料在遷移過程中,不需要停機等待遷移完成,資料平滑的遷移到新的節點,用戶端可以正常通過Proxy通路節點資料,使用者正常通路,無感覺。
  • 高可性:通過codis-ha會自動觀察發現某組master出現異常,就會将改組中節點的salve為master,實作codis-server的主從切換。

redis 典型使用

典型使用場景簡介

場景一:顯示最新的清單; 使用功能:Redis中的清單

在Web應用中,“列出最新的回複”之類的查詢非常普遍,這通常會帶來可擴充性問題。類似的問題就可以用Redis來解決。比如說,我們的一個Web應用想要列出使用者貼出的最新20條評論。在最新的評論邊上我們有一個“顯示全部”的連結,點選後就可以獲得更多的評論。

我們假設資料庫中的每條評論都有一個唯一的遞增的ID字段。我們可以使用分頁來制作首頁和評論頁,使用Redis的模闆:

1)每次新評論發表時,我們會将它的ID添加到一個Redis清單:

LPUSH latest.comments <ID>

2)我們将清單裁剪為指定長度,是以Redis隻需要儲存最新的5000條評論:

LTRIM latest.comments 05000

3)每次我們需要擷取最新評論的項目範圍時,我們調用一個函數來完成(使用僞代碼):

FUNCTION get_latest_comments(start,num_items):

id_list = redis.lrange("latest.comments",start,start+num_items-1)

IF id_list.length < num_items

id_list = SQL_DB("SELECT ... ORDER BY time LIMIT ...")

END

RETURN id_list

我們做了限制不能超過5000個ID,是以我們的擷取ID函數會一直詢問Redis。隻有在start/count參數超出了這個範圍的時候,才需要去通路資料庫。

我們的系統不會像傳統方式那樣“重新整理”緩存,Redis執行個體中的資訊永遠是一緻的。SQL資料庫(或是硬碟上的其他類型資料庫)隻是在使用者需要擷取“很遠”的資料時才會被觸發,而首頁或第一個評論頁是不會麻煩到硬碟上的資料庫了。

場景二:删除與過濾; 使用功能: Redis中的集合

比如郵箱的垃圾郵件功能,包含特定詞或者來自特定發送方。

有些時候你想要給不同的清單附加上不同的過濾器。如果過濾器的數量有限,你可以簡單的為每個不同的過濾器使用不同的Redis清單。

場景三:根據某個屬性進行排名之類; 使用功能:Redis的有序集合

另一個很普遍的需求是各種資料庫的資料并非存儲在記憶體中,是以在大資料量場景下按得分排序,資料庫的性能不夠理想。

典型的比如那些線上遊戲的排行榜,根據得分你通常想要:

  • 列出前100名高分選手
  • 列出某使用者目前的全球排名

如果用資料庫的的order by排序,這種相應時間非常長,無法支援大并發請求。但是這些操作對于Redis來說小菜一碟,即使你有幾百萬個使用者,每分鐘都會有幾百萬個新的得分。

向有序集合添加一個或多個成員,或者更新已存在成員的分數:

ZADD key score1 member1 [score2 member2]
           

得到前100名高分使用者很簡單:

ZREVRANGE key 0 99
           

使用者的全球排名也相似,隻需要:

ZRANK key 
           

場景四:過期項目處理 ; 使用功能:Redis的有序集合和過期時間

另一種常用的項目排序是按照時間排序。并且隻需要保留一定時間内的資料。

這時我們可以使用current_time(unix時間)作為得分,用Redis的有序集合來存儲。并同時通過expire設定time_to_live。

場景五:計數; 使用功能:Redis的原子操作

Redis是一個很好的計數器,這要感謝INCRBY和其他相似指令。可以用于分布式場景下的全局計數器。我相信你曾許多次想要給資料庫加上新的計數器,用來擷取統計或顯示新資訊,但是最後卻由于寫入敏感而不得不放棄它們。現在使用Redis就不需要再擔心了。有了原子遞增(atomic increment),你可以放心的加上各種計數,用GETSET重置,或者是讓它們過期。

**場景六:特定時間内的特定項目; 使用功能:redis的有序集合 **

另一項對于其他資料庫很難,但Redis做起來卻輕而易舉的事就是統計在某段特點時間裡有多少特定使用者通路了某個特定資源。比如我想要知道某些特定的注冊使用者或IP位址,他們到底有多少通路了某篇文章。

每次獲得一次新的頁面浏覽時隻需要這樣做:

SADD page:day1:<page_id>:<user_id>
           

當然你可能想用unix時間替換day1,比如time()-(time()%3600*24)等等。

想知道特定使用者的數量嗎?隻需要使用

SCARD page:day1:<page_id>
           

需要測試某個特定使用者是否通路了這個頁面

SISMEMBER page:day1:<page_id>
           

場景七: Pub/Sub; 使用功能:通過watch指令

Redis的Pub/Sub非常非常簡單,運作穩定并且快速。支援模式比對,能夠實時訂閱與取消頻道。你應該已經注意到像list push和list pop這樣的Redis指令能夠很友善的執行隊列操作了,但能做的可不止這些:比如Redis還有list pop的變體指令,能夠在清單為空時阻塞隊列。

場景八:分布式同步、分布式鎖; 使用功能:鎖(通路同一個key實作)

從redis擷取值N,對數值N進行邊界檢查,自加1,然後N寫回redis中。 這種應用場景很常見,像秒殺,全局遞增ID、IP通路限制等。以IP通路限制來說,惡意攻擊者可能發起無限次通路,并發量比較大,分布式環境下對N的邊界檢查就不可靠,因為從redis讀的N可能已經是髒資料。傳統的加鎖的做法(如java的synchronized和Lock)也沒用,因為這是分布式環境,這種場景就需要分布式鎖。

分布式鎖可以基于很多種方式實作,不管哪種方式,他的基本原理是不變的:用一個狀态值表示鎖,對鎖的占用和釋放通過狀态值來辨別。

Redis為單程序單線程模式,采用隊列模式将并發通路變成串行通路,且多用戶端對Redis的連接配接并不存在競争關系。redis的SETNX指令可以友善的實作分布式鎖,設定成功,傳回 1 ,否則傳回 0 。

上面的鎖定邏輯有一個問題:如果一個持有鎖的用戶端失敗或崩潰了不能釋放鎖,該怎麼解決?我們可以通過鎖的鍵對應的時間戳來判斷這種情況是否發生了,如果目前的時間已經大于鎖對應的值,說明該鎖已失效,可以被重新使用。

發生這種情況時,不能簡單的通過DEL來删除鎖,然後再SETNX一次,當多個用戶端檢測到鎖逾時後都會嘗試去釋放它,這裡就可能出現一個競态條件。 為了讓分布式鎖的算法更穩鍵些,持有鎖的用戶端在解鎖之前應該再檢查一次自己的鎖是否已經逾時,再去做DEL操作(不是DEL,而是getset指令),這個時候可能已經被其他線程先set值了,通過比較值錢get的值和getset傳回的值是否相等,可以判别目前線程是否獲得鎖。

更多關于分布式鎖的實作,請參考

Java分布式鎖三種實作方案

如何解決緩存擊穿

緩存穿透是指查詢一個不存在的資料,導緻這個不存在的資料每次請求都要到存儲層去查詢。在流量大時,可能DB就挂掉了,要是有人利用不存在的key頻繁攻擊我們的應用,這就是漏洞。

緩存擊穿問題一般出現在某個高并發通路的key突然到了過期時間。緩存擊穿和緩存雪崩的差別在于前者針對某一key緩存,後者則是很多key。

有人會說不設定過期時間,就不會出現熱點key過期問題,也就是“實體”不過期,這樣就不存在緩存擊穿的問題。從功能上看,如果緩存資料不過期,那就成靜态的資料了,理論上也就不需要redis這種緩存。

緩存使用可以從實時性和是否熱點兩個次元來選擇解決緩存擊穿方案。

實時性

大家都知道,緩存是從資料中同步過來的,是以它是有延遲的。業務對延遲的容忍度,就是實時性要求。實時性是業務要求,是一個無法妥協的變量。有的延遲可以在秒級别,有的能到分鐘級别,有的就是零容忍。不同的實時性要求,就可以采取不同的緩存同步政策。

熱點

熱點值指的的一個資源(注意不是頁面)被同時通路的使用者數,這個值比較高時才是熱點。熱點是技術要求,因為緩存擊穿問題基本都是熱點引起的,是以在設計緩存方案的時候必須要考慮熱點。舉例:商品詳情頁面,假設這個頁面的并發量是1w,但其中最大的商品的并發量卻可能很低,假設隻有50并發。我們認為這個頁面不存在熱點。這裡的熱點,指的是資源熱點,或者說資料熱點。

玩轉redis緩存

image.png

處理方案(綠色方框)

1)懶加載

玩轉redis緩存

懶加載

先從緩存中取,如果沒有則從資料庫中取,再放入緩存。

特點:維護成本低、實時性差,命中率低(遇到熱點,可能出現資料庫擊穿的問題)

2)推送

玩轉redis緩存

推送

通過獨立的任務,周期性的将資料刷入緩存。這裡除了任務之外,也可能是一個消息觸發。

特點:維護成本适中,實時性适中(周期性任務),命中率100%

推送的方案通常可以結合任務中間件或消息中間件(公司可以考慮我的另一篇文章

DRC實戰

),他們具有更大的靈活性。幹預度強,也可以實作降級。

3)懶加載:二級緩存

玩轉redis緩存

二級緩存

送資料庫擷取資料後,放入一個短期緩存和一個長期緩存。在短期緩存過期後,通過加鎖控制去資料庫加載資料的線程數。沒有獲得鎖的,直接從二級緩存擷取資料。

特點:維護成本适中,實時性适中,命中率100%,該方案可以解決動态熱點。是推送方案的補充

4)雙寫

玩轉redis緩存

雙寫

一邊寫入資料庫,一邊寫入緩存

特點:維護成本最高(侵入代碼),實時性高,命中率100%

使用優先級:1)>2)>3)>4)。響應的維護成本越低越優先