1. 程式人生 > 其它 >一文搞懂redis

一文搞懂redis

文章有點長,不過總結的很全,慎入······

作者:一洺 阿里技術

一  什麼是NoSQL?

  Nosql = not only sql(不僅僅是SQL)

  關係型資料庫:列+行,同一個表下資料的結構是一樣的。
  非關係型資料庫:資料儲存沒有固定的格式,並且可以進行橫向擴充套件。

  NoSQL泛指非關係型資料庫,隨著web2.0網際網路的誕生,傳統的關係型資料庫很難對付web2.0大資料時代!尤其是超大規模的高併發的社群,暴露出來很多難以克服的問題,NoSQL在當今大資料環境下發展的十分迅速,Redis是發展最快的。

 

傳統RDBMS和NoSQL

RDBMS 
  - 組織化結構

  - 固定SQL

  - 資料和關係都存在單獨的表中(行列)

  - DML(資料操作語言)、DDL(資料定義語言)等

  - 嚴格的一致性(ACID): 原子性、一致性、隔離性、永續性

  - 基礎的事務
NoSQL 
  - 不僅僅是資料

  - 沒有固定查詢語言

  - 鍵值對儲存(redis)、列儲存(HBase)、文件儲存(MongoDB)、圖形資料庫(不是存圖形,放的是關係)(Neo4j)

  - 最終一致性(BASE):基本可用、軟狀態/柔性事務、最終一致性

二  redis是什麼?

  Redis = Remote Dictionary Server,即遠端字典服務。是一個開源的使用ANSI C語言編寫、支援網路、可基於記憶體亦可持久化的日誌型、Key-Value資料庫,並提供多種語言的API。
  與memcached一樣,為了保證效率,資料都是快取在記憶體中。區別的是redis會週期性的把更新的資料寫入磁碟或者把修改操作寫入追加的記錄檔案,並且在此基礎上實現了master-slave(主從)同步。

三  redis五大基本型別

   Redis是一個開源,記憶體儲存的資料結構伺服器,可用作資料庫,快取記憶體和訊息佇列代理。它支援字串、雜湊表、列表、集合、有序集合,點陣圖,hyperloglogs等資料型別。內建複製、Lua指令碼、LRU收回、事務以及不同級別磁碟持久化功能,同時通過Redis Sentinel提供高可用,通過Redis Cluster提供自動分割槽。

  由於redis型別大家很熟悉,且網上命令使用介紹很多,下面重點介紹五大基本型別的底層資料結構與應用場景,以便當開發時,可以熟練使用redis。

1  String(字串)

1.String型別是redis的最基礎的資料結構,也是最經常使用到的型別。
而且其他的四種類型多多少少都是在字串型別的基礎上構建的,所以String型別是redis的基礎。

2.String 型別的值最大能儲存 512MB,這裡的String型別可以是簡單字串、 複雜的xml/json的字串、二進位制影象或者音訊的字串、以及可以是數字的字串

應用場景

  1、快取功能:String字串是最常用的資料型別,不僅僅是redis,各個語言都是最基本型別,因此,利用redis作為快取,配合其它資料庫作為儲存層,利用redis支援高併發的特點,可以大大加快系統的讀寫速度、以及降低後端資料庫的壓力。
  2、計數器:許多系統都會使用redis作為系統的實時計數器,可以快速實現計數和查詢的功能。而且最終的資料結果可以按照特定的時間落地到資料庫或者其它儲存介質當中進行永久儲存。
  3、統計多單位的數量:eg,uid:gongming   count:0    根據不同的uid更新count數量。
  4、共享使用者session:使用者重新重新整理一次介面,可能需要訪問一下資料進行重新登入,或者訪問頁面快取cookie,這兩種方式做有一定弊端,1)每次都重新登入效率低下 2)cookie儲存在客戶端,有安全隱患。這時可以利用redis將使用者的session集中管理,在這種模式只需要保證redis的高可用,每次使用者session的更新和獲取都可以快速完成。大大提高效率。

2  List(列表)

1.list型別是用來儲存多個有序的字串的,列表當中的每一個字元看做一個元素

2.一個列表當中可以儲存有一個或者多個元素,redis的list支援儲存2^32次方-1個元素。

3.redis可以從列表的兩端進行插入(pubsh)和彈出(pop)元素,支援讀取指定範圍的元素集, 或者讀取指定下標的元素等操作。redis列表是一種比較靈活的連結串列資料結構,它可以充當佇列或者棧的角色。

4.redis列表是連結串列型的資料結構,所以它的元素是有序的,而且列表內的元素是可以重複的。 意味著它可以根據連結串列的下標獲取指定的元素和某個範圍內的元素集。

應用場景

  1、訊息佇列:reids的連結串列結構,可以輕鬆實現阻塞佇列,可以使用左進右出的命令組成來完成佇列的設計。比如:資料的生產者可以通過Lpush命令從左邊插入資料,多個數據消費者,可以使用BRpop命令阻塞的“搶”列表尾部的資料。
  2、文章列表或者資料分頁展示的應用。比如,我們常用的部落格網站的文章列表,當用戶量越來越多時,而且每一個使用者都有自己的文章列表,而且當文章多時,都需要分頁展示,這時可以考慮使用redis的列表,列表不但有序同時還支援按照範圍內獲取元素,可以完美解決分頁查詢功能。大大提高查詢效率。

3  Set(集合)

1.redis集合(set)型別和list列表型別類似,都可以用來儲存多個字串元素的集合。

2.但是和list不同的是set集合當中不允許重複的元素。而且set集合當中元素是沒有順序的,不存在元素下標。

3.redis的set型別是使用雜湊表構造的,因此複雜度是O(1),它支援集合內的增刪改查, 並且支援多個集合間的交集、並集、差集操作。可以利用這些集合操作,解決程式開發過程當中很多資料集合間的問題。

應用場景

  1、標籤:比如我們部落格網站常常使用到的興趣標籤,把一個個有著相同愛好,關注類似內容的使用者利用一個標籤把他們進行歸併。
  2、共同好友功能,共同喜好,或者可以引申到二度好友之類的擴充套件應用。
  3、統計網站的獨立IP。利用set集合當中元素不唯一性,可以快速實時統計訪問網站的獨立IP。

資料結構

  set的底層結構相對複雜寫,使用了intset和hashtable兩種資料結構儲存,intset可以理解為陣列。

4  sorted set(有序集合)

redis有序集合也是集合型別的一部分,所以它保留了集合中元素不能重複的特性,但是不同的是,有序集合給每個元素多設定了一個分數。

redis有序集合也是集合型別的一部分,所以它保留了集合中元素不能重複的特性,但是不同的是,有序集合給每個元素多設定了一個分數,利用該分數作為排序的依據。

應用場景

  1、排行榜:有序集合經典使用場景。例如視訊網站需要對使用者上傳的視訊做排行榜,榜單維護可能是多方面:按照時間、按照播放量、按照獲得的贊數等。
  2、用Sorted Sets來做帶權重的佇列,比如普通訊息的score為1,重要訊息的score為2,然後工作執行緒可以選擇按score的倒序來獲取工作任務。讓重要的任務優先執行。

5  hash(雜湊)

  Redis hash資料結構 是一個鍵值對(key-value)集合,它是一個 string 型別的 field 和 value 的對映表,redis本身就是一個key-value型資料庫,因此hash資料結構相當於在value中又套了一層key-value型資料。所以redis中hash資料結構特別適合儲存關係型物件

應用場景

  1、由於hash資料型別的key-value的特性,用來儲存關係型資料庫中表記錄,是redis中雜湊型別最常用的場景。一條記錄作為一個key-value,把每列屬性值對應成field-value儲存在雜湊表當中,然後通過key值來區分表當中的主鍵。
  2、經常被用來儲存使用者相關資訊。優化使用者資訊的獲取,不需要重複從資料庫當中讀取,提高系統性能。

四  五大基本型別底層資料儲存結構

  在學習基本型別底層資料儲存結構前,首先看下redis整體的儲存結構。
  redis內部整體的儲存結構是一個大的hashmap,內部是陣列實現的hash,key衝突通過掛連結串列去實現,每個dictEntry為一個key/value物件,value為定義的redisObject。結構圖如下:

dictEntry是儲存key->value的地方,再讓我們看一下dictEntry結構體

 

 1 /*
 2  * 字典
 3  */
 4 typedef struct dictEntry {
 5     //
 6     void *key;
 7     //
 8     union {
 9         // 指向具體redisObject
10         void *val;
11         // 
12         uint64_t u64;
13         int64_t s64;
14     } v;
15     // 指向下個雜湊表節點,形成連結串列
16     struct dictEntry *next;
17 } dictEntry;

1  redisObject

  我們接著再往下看redisObject究竟是什麼結構的

/*
 * Redis 物件
 */
typedef struct redisObject {
    // 型別 4bits
    unsigned type:4;
    // 編碼方式 4bits
    unsigned encoding:4;
    // LRU 時間(相對於 server.lruclock) 24bits
    unsigned lru:22;
    // 引用計數 Redis裡面的資料可以通過引用計數進行共享 32bits
    int refcount;
    // 指向物件的值 64-bit
    void *ptr;
} robj;

  *ptr指向具體的資料結構的地址;type表示該物件的型別,即String,List,Hash,Set,Zset中的一個,但為了提高儲存效率與程式執行效率,每種物件的底層資料結構實現都可能不止一種,encoding 表示物件底層所使用的編碼。
redis物件底層的八種資料結構

  REDIS_ENCODING_INT(long 型別的整數)
  REDIS_ENCODING_EMBSTR embstr (編碼的簡單動態字串)
  REDIS_ENCODING_RAW (簡單動態字串)
  REDIS_ENCODING_HT (字典)
  REDIS_ENCODING_LINKEDLIST (雙端連結串列)
  REDIS_ENCODING_ZIPLIST (壓縮列表)
  REDIS_ENCODING_INTSET (整數集合)
  REDIS_ENCODING_SKIPLIST (跳躍表和字典)

好了,通過redisObject就可以具體指向redis資料型別了,總結一下每種資料型別都使用了哪些資料結構,如下圖所示

 

 

前期準備知識已準備完畢,下面分每種基本型別來講。

2  String資料結構

String型別的轉換順序

  • 當儲存的值為整數且值的大小不超過long的範圍,使用整數儲存
  • 當字串長度不超過44位元組時,使用EMBSTR 編碼

  它只分配一次記憶體空間,redisObject和sds是連續的記憶體,查詢效率會快很多,也正是因為redisObject和sds是連續在一起,伴隨了一些缺點:當字串增加的時候,它長度會增加,這個時候又需要重新分配記憶體,導致的結果就是整個redisObject和sds都需要重新分配空間,這樣是會影響效能的,所以redis用embstr實現一次分配而後,只允許讀,如果修改資料,那麼它就會轉成raw編碼,不再用embstr編碼了。

  • 大於44字元時,使用raw編碼

SDS

embstr和raw都為sds編碼,看一下sds的結構體

/* 針對不同長度整形做了相應的資料結構
 * Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. 
 */
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[];
};

  由於redis底層使用c語言實現,可能會有疑問為什麼不用c語言的字串呢,而是用sds結構體。

  1) 低複雜度獲取字串長度:由於len存在,可以直接查出字串長度,複雜度O(1);如果用c語言字串,查詢字串長度需要遍歷整個字串,複雜度為O(n);
  2) 避免緩衝區溢位:進行兩個字串拼接c語言可使用strcat函式,但如果沒有足夠的記憶體空間。就會造成緩衝區溢位;而用sds在進行合併時會先用len檢查記憶體空間是否滿足需求,如果不滿足,進行空間擴充套件,不會造成緩衝區溢位;
  3)減少修改字串的記憶體重新分配次數:c語言字串不記錄字串長度,如果要修改字串要重新分配記憶體,如果不進行重新分配會造成記憶體緩衝區洩露;
  redis sds實現了空間預分配和惰性空間釋放兩種策略
  空間預分配:
    1)如果sds修改後,sds長度(len的值)將於1mb,那麼會分配與len相同大小的未使用空間,此時len與free值相同。例如,修改之後字串長度為100位元組,那麼會給分配100位元組的未使用空間。最終sds空間實際為 100 + 100 + 1(儲存空字元'\0');
    2)如果大於等於1mb,每次給分配1mb未使用空間

  惰性空間釋放:對字串進行縮短操作時,程式不立即使用記憶體重新分配來回收縮短後多餘的位元組,而是使用 free 屬性將這些位元組的數量記錄下來,等待後續使用(sds也提供api,我們可以手動觸發字串縮短);
  4)二進位制安全:因為C字串以空字元作為字串結束的標識,而對於一些二進位制檔案(如圖片等),內容可能包括空字串,因此C字串無法正確存取;而所有 SDS 的API 都是以處理二進位制的方式來處理 buf 裡面的元素,並且 SDS 不是以空字串來判斷是否結束,而是以 len 屬性表示的長度來判斷字串是否結束;
  5)遵從每個字串都是以空字串結尾的慣例,這樣可以重用 C 語言庫<string.h> 中的一部分函式。

  學習完sds,我們回到上面講到的,為什麼小於44位元組用embstr編碼呢?再看一下rejectObject和sds定義的結構(短字串的embstr用最小的sdshdr8)

typedef struct redisObject {
    // 型別 4bits
    unsigned type:4;
    // 編碼方式 4bits
    unsigned encoding:4;
    // LRU 時間(相對於 server.lruclock) 24bits
    unsigned lru:22;
    // 引用計數 Redis裡面的資料可以通過引用計數進行共享 32bits
    int refcount;
    // 指向物件的值 64-bit
    void *ptr;
} robj;
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[];
};

  redisObject佔用空間: 4 + 4 + 24 + 32 + 64 = 128bits = 16位元組

  sdshdr8佔用空間:1(uint8_t) + 1(uint8_t)+ 1 (unsigned char)+ 1(buf[]中結尾的'\0'字元)= 4位元組
  初始最小分配為64位元組,所以只分配一次空間的embstr最大為 64 - 16- 4 = 44位元組

3  List儲存結構

  1. Redis3.2之前的底層實現方式:壓縮列表ziplist 或者 雙向迴圈連結串列linkedlist

  當list儲存的資料量比較少且同時滿足下面兩個條件時,list就使用ziplist儲存資料:

  • list中儲存的每個元素的長度小於 64 位元組;
  • 列表中資料個數少於512個

  2.  Redis3.2及之後的底層實現方式:quicklist
  quicklist是一個雙向連結串列,而且是一個基於ziplist的雙向連結串列,quicklist的每個節點都是一個ziplist,結合了雙向連結串列和ziplist的優點。

ziplist

  ziplist是一種壓縮連結串列,它的好處是更能節省記憶體空間,因為它所儲存的內容都是在連續的記憶體區域當中的。當列表物件元素不大,每個元素也不大的時候,就採用ziplist儲存。但當資料量過大時就ziplist就不是那麼好用了。因為為了保證他儲存內容在記憶體中的連續性,插入的複雜度是O(N),即每次插入都會重新進行realloc。如下圖所示,redisObject物件結構中ptr所指向的就是一個ziplist。整個ziplist只需要malloc一次,它們在記憶體中是一塊連續的區域。
ziplist結構如下:

 

  1、zlbytes:用於記錄整個壓縮列表佔用的記憶體位元組數
  2、zltail:記錄要列表尾節點距離壓縮列表的起始地址有多少位元組
  3、zllen:記錄了壓縮列表包含的節點數量。 
  4、entryX:要說列表包含的各個節點
  5、zlend:用於標記壓縮列表的末端
  為什麼資料量大時不用ziplist?
  因為ziplist是一段連續的記憶體,插入的時間複雜化度為O(n),而且每當插入新的元素需要realloc做記憶體擴充套件;而且如果超出ziplist記憶體大小,還會做重新分配的記憶體空間,並將內容複製到新的地址。如果數量大的話,重新分配記憶體和拷貝記憶體會消耗大量時間。所以不適合大型字串,也不適合儲存量多的元素。

快速列表(quickList)

  快速列表是ziplist和linkedlist的混合體,是將linkedlist按段切分,每一段用ziplist來緊湊儲存,多個ziplist之間使用雙向指標連結。
  為什麼不直接使用linkedlist?
  linkedlist的附加空間相對太高,prev和next指標就要佔去16個位元組,而且每一個結點都是單獨分配,會加劇記憶體的碎片化,影響記憶體管理效率。
quicklist結構

typedef struct quicklist {
    // 指向quicklist的頭部
    quicklistNode *head;
    // 指向quicklist的尾部
    quicklistNode *tail;
    unsigned long count;
    unsigned int len;
    // ziplist大小限定,由list-max-ziplist-size給定
    int fill : 16;
    // 節點壓縮深度設定,由list-compress-depth給定
    unsigned int compress : 16;
} quicklist;

typedef struct quicklistNode {
    // 指向上一個ziplist節點
    struct quicklistNode *prev;
    // 指向下一個ziplist節點
    struct quicklistNode *next;
    // 資料指標,如果沒有被壓縮,就指向ziplist結構,反之指向quicklistLZF結構
    unsigned char *zl;
    // 表示指向ziplist結構的總長度(記憶體佔用長度)
    unsigned int sz;
    // ziplist數量
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    // 預留欄位,存放資料的方式,1--NONE,2--ziplist
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    // 解壓標記,當檢視一個被壓縮的資料時,需要暫時解壓,標記此引數為1,之後再重新進行壓縮
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    // 擴充套件欄位
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

typedef struct quicklistLZF {
    // LZF壓縮後佔用的位元組數
    unsigned int sz; /* LZF size in bytes*/
    // 柔性陣列,存放壓縮後的ziplist位元組陣列
    char compressed[];
} quicklistLZF;

結構圖如下:

  ziplist的長度
  quicklist內部預設單個ziplist長度為8k位元組,超出了這個位元組數,就會新起一個ziplist。關於長度可以使用list-max-ziplist-size決定。
  壓縮深度
  我們上面說到了quicklist下是用多個ziplist組成的,同時為了進一步節約空間,Redis還會對ziplist進行壓縮儲存,使用LZF演算法壓縮,可以選擇壓縮深度。quicklist預設的壓縮深度是0,也就是不壓縮。壓縮的實際深度由配置引數list-compress-depth決定。為了支援快速push/pop操作,quicklist 的首尾兩個 ziplist 不壓縮,此時深度就是 1。如果深度為 2,就表示 quicklist 的首尾第一個 ziplist 以及首尾第二個 ziplist 都不壓縮。

 

4  Hash型別

  當Hash中資料項比較少的情況下,Hash底層才用壓縮列表ziplist進行儲存資料,隨著資料的增加,底層的ziplist就可能會轉成dict,具體配置如

  hash-max-ziplist-entries 512
  hash-max-ziplist-value 64

  在List中已經介紹了ziplist,下面來介紹下dict。看下資料結構

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int iterators; /* number of iterators currently running */
} dict;

typedef struct dictht {
    //指標陣列,這個hash的桶
    dictEntry **table;
    //元素個數
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

dictEntry大家應該熟悉,在上面有講,使用來真正儲存key->value的地方
typedef struct dictEntry {
    //
    void *key;
    //
    union {
        // 指向具體redisObject
        void *val;
        // 
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下個雜湊表節點,形成連結串列
    struct dictEntry *next;
} dictEntry;

  我們可以看到每個dict中都有兩個hashtable

  結構圖如下:

 

 

  雖然dict結構有兩個hashtable,但是通常情況下只有一個hashtable是有值的。但是在dict擴容縮容的時候,需要分配新的hashtable,然後進行漸近式搬遷,這時候兩個hashtable儲存的舊的hashtable和新的hashtable。搬遷結束後,舊hashtable刪除,新的取而代之。
  下面讓我們學習下rehash全貌。

5  漸進式rehash

  所謂漸進式rehash是指我們的大字典的擴容是比較消耗時間的,需要重新申請新的陣列,然後將舊字典所有連結串列的元素重新掛接到新的陣列下面,是一個O(n)的操作。但是因為我們的redis是單執行緒的,無法承受這樣的耗時過程,所以採用了漸進式rehash小步搬遷,雖然慢一點,但是可以搬遷完畢。
  擴容條件
  我們的擴容一般會在Hash表中的元素個數等於第一維陣列的長度的時候,就會開始擴容。擴容的大小是原陣列的兩倍。不過在redis在做bgsave(RDB持久化操作的過程),為了減少記憶體頁的過多分離(Copy On Write),redis不會去擴容。但是如果hash表的元素個數已經到達了第一維陣列長度的5倍的時候,就會強制擴容,不管你是否在持久化。
不擴容主要是為了儘可能減少記憶體頁過多分離,系統需要更多的開銷去回收記憶體。
  縮容條件
  當我們的hash表元素逐漸刪除的越來越少的時候。redis於是就會對hash表進行縮容來減少第一維陣列長度的空間佔用。縮容的條件是元素個數低於陣列長度的10%,並且縮容不考慮是否在做redis持久化。
不用考慮bgsave主要是因為我們的縮容的記憶體都是已經使用過的,縮容的時候可以直接置空,而且由於申請的記憶體比較小,同時會釋放掉一些已經使用的記憶體,不會增大系統的壓力。
  rehash步驟
    1、為ht[1] 分配空間,讓字典同時持有ht[0]和ht[1]兩個雜湊表;
    2、定時維持一個索引計數器變數rehashidx,並將它的值設定為0,表示rehash 開始;
    3、在rehash進行期間,每次對字典執行CRUD操作時,程式除了執行指定的操作以外,還會將ht[0]中的資料rehash 到ht[1]表中,並且將rehashidx加一;
    4、當ht[0]中所有資料轉移到ht[1]中時,將rehashidx 設定成-1,表示rehash 結束;(採用漸進式rehash 的好處在於它採取分而治之的方式,避免了集中式rehash 帶來的龐大計算量。特別的在進行rehash時只能對h[0]元素減少的操作,如查詢和刪除;而查詢是在兩個雜湊表中查詢的,而插入只能在ht[1]中進行,ht[1]也可以查詢和刪除。)
    5、將ht[0]釋放,然後將ht[1]設定成ht[0],最後為ht[1]分配一個空白雜湊表。
過程如下圖:

6  set資料結構

  Redis 的集合相當於Java中的 HashSet,它內部的鍵值對是無序、唯一的。它的內部實現相當於一個特殊的字典,字典中所有的 value 都是一個值 NULL。集合Set型別底層編碼包括hashtable和inset。
  當儲存的資料同時滿足下面這樣兩個條件的時候,Redis 就採用整數集合intset來實現set這種資料型別:

  • 儲存的資料都是整數
  • 儲存的資料元素個數小於512個

  當不能同時滿足這兩個條件的時候,Redis 就使用dict來儲存集合中的資料
  hashtable在上面介紹過了,我們就只介紹inset。

inset結構體

typedef struct intset {
    uint32_t encoding;
    // length就是陣列的實際長度
    uint32_t length;
    // contents 陣列是實際儲存元素的地方,陣列中的元素有以下兩個特性:
    // 1.沒有重複元素
    // 2.元素在陣列中從小到大排列
    int8_t contents[];
} intset;

// encoding 的值可以是以下三個常量的其中一個
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

inset的查詢
  intset是一個有序集合,查詢元素的複雜度為O(logN)(採用二分法),但插入時不一定為O(logN),因為有可能涉及到升級操作。比如當集合裡全是int16_t型的整數,這時要插入一個int32_t,那麼為了維持集合中資料型別的一致,那麼所有的資料都會被轉換成int32_t型別,涉及到記憶體的重新分配,這時插入的複雜度就為O(N)了。是intset不支援降級操作。
  **inset是有序不要和我們zset搞混,zset是設定一個score來進行排序,而inset這裡只是單純的對整數進行升序而已**

7  Zset資料結構

  Zset有序集合和set集合有著必然的聯絡,他保留了集合不能有重複成員的特性,但不同的是,有序集合中的元素是可以排序的,但是它和列表的使用索引下標作為排序依據不同的是,它給每個元素設定一個分數,作為排序的依據。
  Zet的底層編碼有兩種資料結構,一個ziplist,一個是skiplist。
  Zset也使用了ziplist做了排序,所以下面講一下ziplist如何做排序。

ziplist做排序

  每個集合元素使用兩個緊挨在一起的壓縮列表節點來儲存,第一個節點儲存元素的成員(member),而第二個元素則儲存元素的分值(score)。
儲存結構圖如下一目瞭然:

skiplist跳錶

  結構體如下,skiplist是與dict結合來使用的,這個結構比較複雜。

/*
 * 跳躍表
 */
typedef struct zskiplist {
    // 頭節點,尾節點
    struct zskiplistNode *header, *tail;
    // 節點數量
    unsigned long length;
    // 目前表內節點的最大層數
    int level;
} zskiplist;

/*
 * 跳躍表節點
 */
typedef struct zskiplistNode {
    // member 物件
    robj *obj;
    // 分值
    double score;
    // 後退指標
    struct zskiplistNode *backward;
    //
    struct zskiplistLevel {
        // 前進指標
        struct zskiplistNode *forward;
        // 這個層跨越的節點數量
        unsigned int span;
    } level[];
} zskiplistNode;

  跳錶是什麼?我們先看下連結串列

  如果想查詢到node5需要從node1查到node5,查詢耗時,但如果在node上加上索引:

 

 

 

   這樣通過索引就能直接從node1查詢到node5

redis跳躍表

  讓我們再看下redis的跳錶結構(圖太複雜,直接從網上找了張圖說明)
 

  • header:指向跳躍表的表頭節點,通過這個指標程式定位表頭節點的時間複雜度就為O(1);
  • tail:指向跳躍表的表尾節點,通過這個指標程式定位表尾節點的時間複雜度就為O(1);
  • level:記錄目前跳躍表內,層數最大的那個節點的層數(表頭節點的層數不計算在內),通過這個屬性可以再O(1)的時間複雜度內獲取層高最好的節點的層數;
  • length:記錄跳躍表的長度,也即是,跳躍表目前包含節點的數量(表頭節點不計算在內),通過這個屬性,程式可以再O(1)的時間複雜度內返回跳躍表的長度。

  結構右方的是四個 zskiplistNode結構,該結構包含以下屬性

  • 層(level):

  節點中用L1、L2、L3等字樣標記節點的各個層,L1代表第一層,L2代表第二層,以此類推。
  每個層都帶有兩個屬性:前進指標和跨度。前進指標用於訪問位於表尾方向的其他節點,而跨度則記錄了前進指標所指向節點和當前節點的距離(跨度越大、距離越遠)。在上圖中,連線上帶有數字的箭頭就代表前進指標,而那個數字就是跨度。當程式從表頭向表尾進行遍歷時,訪問會沿著層的前進指標進行。
  每次建立一個新跳躍表節點的時候,程式都根據冪次定律(powerlaw,越大的數出現的概率越小)隨機生成一個介於1和32之間的值作為level陣列的大小,這個大小就是層的“高度”。

  • 後退(backward)指標:

  節點中用BW字樣標記節點的後退指標,它指向位於當前節點的前一個節點。後退指標在程式從表尾向表頭遍歷時使用。與前進指標所不同的是每個節點只有一個後退指標,因此每次只能後退一個節點。

  • 分值(score):

  各個節點中的1.0、2.0和3.0是節點所儲存的分值。在跳躍表中,節點按各自所儲存的分值從小到大排列。

  • 成員物件(oj):

  各個節點中的o1、o2和o3是節點所儲存的成員物件。在同一個跳躍表中,各個節點儲存的成員物件必須是唯一的,但是多個節點儲存的分值卻可以是相同的:分值相同的節點將按照成員物件在字典序中的大小來進行排序,成員物件較小的節點會排在前面(靠近表頭的方向),而成員物件較大的節點則會排在後面(靠近表尾的方向)。

五  三大特殊資料型別

1  geospatial(地理位置)

1.geospatial將指定的地理空間位置(緯度、經度、名稱)新增到指定的key中。  這些資料將會儲存到sorted set這樣的目的是為了方便使用GEORADIUS或者GEORADIUSBYMEMBER命令對資料進行半徑查詢等操作。

2.sorted set使用一種稱為Geohash的技術進行填充。經度和緯度的位是交錯的,以形成一個獨特的52位整數。 sorted set的double score可以代表一個52位的整數,而不會失去精度。(有興趣的同學可以學習一下Geohash技術,使用二分法構建唯一的二進位制串)

3.有效的經度是-180度到180度 有效的緯度是-85.05112878度到85.05112878度

應用場景

  1. 檢視附近的人
  2. 微信位置共享
  3. 地圖上直線距離的展示

2  Hyperloglog(基數)

  什麼是基數? 不重複的元素

  hyperloglog 是用來做基數統計的,其優點是:輸入的提及無論多麼大,hyperloglog使用的空間總是固定的12KB ,利用12KB,它可以計算2^64個不同元素的基數!非常節省空間!但缺點是估算的值,可能存在誤差

應用場景

  1.網頁統計UV (瀏覽使用者數量,同一天同一個ip多次訪問算一次訪問,目的是計數,而不是儲存使用者)

  傳統的方式,set儲存使用者的id,可以統計set中元素數量作為標準判斷。但如果這種方式儲存大量使用者id,會佔用大量記憶體,我們的目的是為了計數,而不是去儲存id。

3  Bitmaps(位儲存)

 Redis提供的Bitmaps這個“資料結構”可以實現對位的操作。Bitmaps本身不是一種資料結構,實際上就是字串,但是它可以對字串的位進行操作。

 可以把Bitmaps想象成一個以位為單位陣列,陣列中的每個單元只能存0或者1,陣列的下標在bitmaps中叫做偏移量。單個bitmaps的最大長度是512MB,即2^32個位元位。

 應用場景

  兩種狀態的統計都可以使用bitmaps,例如:統計使用者活躍與非活躍數量、登入與非登入、上班打卡等等。

六  Redis事務

  事務本質:一組命令的集合

1  資料庫事務與redis事務

資料庫的事務

  資料庫事務通過ACID(原子性、一致性、隔離性、永續性)來保證。
  資料庫中除查詢操作以外,插入(Insert)、刪除(Delete)和更新(Update)這三種操作都會對資料造成影響,因為事務處理能夠保證一系列操作可以完全地執行或者完全不執行,因此在一個事務被提交以後,該事務中的任何一條SQL語句在被執行的時候,都會生成一條撤銷日誌(Undo Log)。

redis事務

  redis事務提供了一種“將多個命令打包, 然後一次性、按順序地執行”的機制, 並且事務在執行的期間不會主動中斷 —— 伺服器在執行完事務中的所有命令之後, 才會繼續處理其他客戶端的其他命令。
  Redis中一個事務從開始到執行會經歷開始事務(muiti)、命令入隊和執行事務(exec)三個階段,事務中的命令在加入時都沒有被執行,直到提交時才會開始執行(Exec)一次性完成。

  一組命令中存在兩種錯誤不同處理方式
    1.程式碼語法錯誤(編譯時異常)所有命令都不執行
    2.程式碼邏輯錯誤(執行時錯誤),其他命令可以正常執行  (該點不保證事務的原子性)

   為什麼redis不支援回滾來保證原子性,這種做法的優點:

  • Redis 命令只會因為錯誤的語法而失敗(並且這些問題不能在入隊時發現),或是命令用在了錯誤型別的鍵上面:這也就是說,從實用性的角度來說,失敗的命令是由程式設計錯誤造成的,而這些錯誤應該在開發的過程中被發現,而不應該出現在生產環境中。
  • 因為不需要對回滾進行支援,所以 Redis 的內部可以保持簡單且快速。

  鑑於沒有任何機制能避免程式設計師自己造成的錯誤, 並且這類錯誤通常不會在生產環境中出現, 所以 Redis 選擇了更簡單、更快速的無回滾方式來處理事務。

事務監控

  悲觀鎖:認為什麼時候都會出現問題,無論做什麼操作都會加鎖。
  樂觀鎖:認為什麼時候都不會出現問題,所以不會上鎖!更新資料的時候去判斷一下,在此期間是否有人修改過這個資料。
  使用cas實現樂觀鎖
  redis使用watch key監控指定資料,相當於加樂觀鎖

  watch保證事務只能在所有被監視鍵都沒有被修改的前提下執行, 如果這個前提不能滿足的話,事務就不會被執行。
  watch執行流程 

七  Redis持久化

  Redis是一種記憶體型資料庫,一旦伺服器程序退出,資料庫的資料就會丟失,為了解決這個問題Redis供了兩種持久化的方案,將記憶體中的資料儲存到磁碟中,避免資料的丟失兩種持久化方式:快照(RDB檔案)和追加式檔案(AOF檔案),下面分別為大家介紹兩種方式的原理。

  • RDB持久化方式會在一個特定的間隔儲存那個時間點的資料快照。
  • AOF持久化方式則會記錄每一個伺服器收到的寫操作。在服務啟動時,這些記錄的操作會逐條執行從而重建出原來的資料。寫操作命令記錄的格式跟Redis協議一致,以追加的方式進行儲存。
  • Redis的持久化是可以禁用的,就是說你可以讓資料的生命週期只存在於伺服器的執行時間裡。
  • 兩種方式的持久化是可以同時存在的,但是當Redis重啟時,AOF檔案會被優先用於重建資料。

1  RDB持久化

  RDB持久化產生的檔案是一個經過壓縮的二進位制檔案,這個檔案可以被儲存到硬碟中,可以通過這個檔案還原資料庫的狀態,它可以手動執行,也可以在redis.conf配置檔案中配置,定時執行。

工作原理

在進行RDB時,redis的主程序不會做io操作,會fork一個子程序來完成該操作:

  1. Redis 呼叫forks。同時擁有父程序和子程序。
  2. 子程序將資料集寫入到一個臨時 RDB 檔案中。
  3. 當子程序完成對新 RDB 檔案的寫入時,Redis 用新 RDB 檔案替換原來的 RDB 檔案,並刪除舊的 RDB 檔案。

這種工作方式使得 Redis 可以從寫時複製(copy-on-write)機制中獲益(因為是使用子程序進行寫操作,而父程序依然可以接收來自客戶端的請求)

觸發機制

在Redis中RDB持久化的觸發分為兩種:自己手動觸發與自動觸發。

主動觸發

  1. save命令是同步的命令,會佔用主程序,會造成阻塞,阻塞所有客戶端的請求
  1. bgsave

bgsave是非同步進行,進行持久化的時候,redis還可以將繼續響應客戶端請求

bgsave和save對比

命令 save bgsave
IO型別 同步 非同步
阻塞 是(阻塞發生在fock(),通常非常快)
複雜度 O(n) O(n)
優點 不會消耗額外的記憶體 不阻塞客戶端命令
缺點 阻塞客戶端命令 需要fock子程序,消耗記憶體


自動觸發

  1. save自動觸發配置,見下面配置,滿足m秒內修改n次key,觸發rdb
# 時間策略   save m n m秒內修改n次key,觸發rdb
save 900 1
save 300 10
save 60 10000

# 檔名稱
dbfilename dump.rdb

# 檔案儲存路徑
dir /home/work/app/redis/data/

# 如果持久化出錯,主程序是否停止寫入
stop-writes-on-bgsave-error yes

# 是否壓縮
rdbcompression yes

# 匯入時是否檢查
rdbchecksum yes
  1. 從節點全量複製時,主節點發送rdb檔案給從節點完成複製操作,主節點會觸發bgsave命令;
  2. 執行flushall命令,會觸發rdb
  3. 退出redis,且沒有開啟aof時

優點:

  1. RDB 的內容為二進位制的資料,佔用記憶體更小,更緊湊,更適合做為備份檔案;
  2. RDB 對災難恢復非常有用,它是一個緊湊的檔案,可以更快的傳輸到遠端伺服器進行 Redis 服務恢復;
  3. RDB 可以更大程度的提高 Redis 的執行速度,因為每次持久化時 Redis 主程序都會 fork() 一個子程序,進行資料持久化到磁碟,Redis 主程序並不會執行磁碟 I/O 等操作;
  4. 與 AOF 格式的檔案相比,RDB 檔案可以更快的重啟。

缺點:

  1. 因為 RDB 只能儲存某個時間間隔的資料,如果中途 Redis 服務被意外終止了,則會丟失一段時間內的 Redis 資料。
  2. RDB 需要經常 fork() 才能使用子程序將其持久化在磁碟上。如果資料集很大,fork() 可能很耗時,並且如果資料集很大且 CPU 效能不佳,則可能導致 Redis 停止為客戶端服務幾毫秒甚至一秒鐘。

2  AOF(Append Only File)

  以日誌的形式來記錄每個寫的操作,將Redis執行過的所有指令記錄下來(讀操作不記錄),只許追加檔案但不可以改寫檔案,redis啟動之初會讀取該檔案重新構建資料,換言之,redis重啟的話就根據日誌檔案的內容將寫指令從前到後執行一次以完成資料的恢復工作。

AOF配置項

# 預設不開啟aof  而是使用rdb的方式
appendonly no

# 預設檔名
appendfilename "appendonly.aof"

# 每次修改都會sync 消耗效能
# appendfsync always
# 每秒執行一次 sync 可能會丟失這一秒的資料
appendfsync everysec
# 不執行 sync ,這時候作業系統自己同步資料,速度最快
# appendfsync no

  AOF的整個流程大體來看可以分為兩步,一步是命令的實時寫入(如果是appendfsync everysec 配置,會有1s損耗),第二步是對aof檔案的重寫。

AOF 重寫機制

  隨著Redis的執行,AOF的日誌會越來越長,如果例項宕機重啟,那麼重放整個AOF將會變得十分耗時,而在日誌記錄中,又有很多無意義的記錄,比如我現在將一個數據 incr一千次,那麼就不需要去記錄這1000次修改,只需要記錄最後的值即可。所以就需要進行 AOF 重寫。
  Redis 提供了bgrewriteaof指令用於對AOF日誌進行重寫,該指令執行時會開闢一個子程序對記憶體進行遍歷,然後將其轉換為一系列的 Redis 的操作指令,再序列化到一個日誌檔案中。完成後再替換原有的AOF檔案,至此完成。
  同樣的也可以在redis.config中對重寫機制的觸發進行配置:
  通過將no-appendfsync-on-rewrite設定為yes,開啟重寫機制;auto-aof-rewrite-percentage 100意為比上次從寫後文件大小增長了100%再次觸發重寫;
  auto-aof-rewrite-min-size 64mb意為當檔案至少要達到64mb才會觸發制動重寫。

觸發方式

  1. 手動觸發:bgrewriteaof  
  2. 自動觸發 就是根據配置規則來觸發,當然自動觸發的整體時間還跟Redis的定時任務頻率有關係。

優點
  1、資料安全,aof 持久化可以配置 appendfsync 屬性,有 always,每進行一次 命令操作就記錄到 aof 檔案中一次。
  2、通過 append 模式寫檔案,即使中途伺服器宕機,可以通過 redis-check-aof 工具解決資料一致性問題。
  3、AOF 機制的 rewrite 模式。AOF 檔案沒被 rewrite 之前(檔案過大時會對命令 進行合併重寫),可以刪除其中的某些命令(比如誤操作的 flushall))

缺點
  1、AOF 檔案比 RDB 檔案大,且恢復速度慢。2、資料集大的時候,比 rdb 啟動效率低。

3  rdb與aof對比

比較項 RDB AOF
啟動優先順序
體積
恢復速度
資料安全性 丟資料 根據策略決定

 

八  釋出與訂閱

  redis釋出與訂閱是一種訊息通訊的模式:傳送者(pub)傳送訊息,訂閱者(sub)接收訊息。
redis通過PUBLISH和SUBSCRIBE等命令實現了訂閱與釋出模式,這個功能提供兩種資訊機制,分別是訂閱/釋出到頻道、訂閱/釋出到模式的客戶端。

1  頻道(channel)

訂閱

 

釋出

 

完整流程

  釋出者釋出訊息
  釋出者向頻道channel:1釋出訊息hi

   127.0.0.1:6379> publish channel:1 hi(integer) 1

  訂閱者訂閱訊息

   127.0.0.1:6379> subscribe channel:1Reading messages... (press Ctrl-C to quit)1) "subscribe" // 訊息型別2) "channel:1" // 頻道3) "hi" // 訊息內容

  執行subscribe後客戶端會進入訂閱狀態,僅可以使subscribe、unsubscribe、psubscribe和punsubscribe這四個屬於"釋出/訂閱"之外的命令
  訂閱頻道後的客戶端可能會收到三種訊息型別

  • subscribe。表示訂閱成功的反饋資訊。第二個值是訂閱成功的頻道名稱,第三個是當前客戶端訂閱的頻道數量。
  • message。表示接收到的訊息,第二個值表示產生訊息的頻道名稱,第三個值是訊息的內容。
  • unsubscribe。表示成功取消訂閱某個頻道。第二個值是對應的頻道名稱,第三個值是當前客戶端訂閱的頻道數量,當此值為0時客戶端會退出訂閱狀態,之後就可以執行其他非"釋出/訂閱"模式的命令了。

資料結構

  基於頻道的釋出訂閱模式是通過字典資料型別實現的

struct redisServer {
    // ...
    dict *pubsub_channels;
    // ...
};

  其中,字典的鍵為正在被訂閱的頻道, 而字典的值則是一個連結串列, 連結串列中儲存了所有訂閱這個頻道的客戶端。

訂閱
  當使用subscribe訂閱時,在字典中找到頻道key(如沒有則建立),並將訂閱的client關聯在連結串列後面。
  當client 10執行subscribe channel1 channel2 channel3時,會將client 10分別加到 channel1 channel2 channel3關聯的連結串列尾部。

 

釋出
  釋出時,根據key,找到字典彙總key的地址,然後將msg傳送到關聯的連結串列每一臺機器。
退訂
  遍歷關聯的連結串列,將指定的地址刪除即可。

 

2  模式(pattern)

  pattern使用了萬用字元的方式來訂閱
  萬用字元中?表示1個佔位符,*表示任意個佔位符(包括0),?*表示1個以上佔位符。
  所以當使用 publish命令傳送資訊到某個頻道時, 不僅所有訂閱該頻道的客戶端會收到資訊, 如果有某個/某些模式和這個頻道匹配的話, 那麼所有訂閱這個/這些頻道的客戶端也同樣會收到資訊。

訂閱釋出完整流程

  釋出者釋出訊息

127.0.0.1:6379> publish b m1
(integer) 1
127.0.0.1:6379> publish b1 m1
(integer) 1
127.0.0.1:6379> publish b11 m1
(integer) 1

  訂閱者訂閱訊息

127.0.0.1:6379> psubscribe b*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "b*"
3) (integer) 3
1) "pmessage"
2) "b*"
3) "b"
4) "m1"
1) "pmessage"
2) "b*"
3) "b1"
4) "m1"
1) "pmessage"
2) "b*"
3) "b11"
4) "m1"

資料結構

  pattern屬性是一個連結串列,連結串列中儲存著所有和模式相關的資訊。

struct redisServer {
    // ...
    list *pubsub_patterns;
    // ...
};
// 連結串列中的每一個節點結構如下,儲存著客戶端與模式資訊
typedef struct pubsubPattern {
    redisClient *client;
    robj *pattern;
} pubsubPattern;

資料結構圖如下:

訂閱
  當有信的訂閱時,會將訂閱的客戶端和模式資訊新增到連結串列後面。

 

釋出
  當釋出者釋出訊息時,首先會發送到對應的頻道上,在遍歷模式列表,根據key匹配模式,匹配成功將訊息發給對應的訂閱者。

 

  完成的釋出虛擬碼如下

def PUBLISH(channel, message):
    # 遍歷所有訂閱頻道 channel 的客戶端
    for client in server.pubsub_channels[channel]:
        # 將資訊傳送給它們
        send_message(client, message)
    # 取出所有模式,以及訂閱模式的客戶端
    for pattern, client in server.pubsub_patterns:
        # 如果 channel 和模式匹配
        if match(channel, pattern):
            # 那麼也將資訊發給訂閱這個模式的客戶端
            send_message(client, message)

退訂
  使用punsubscribe,可以將訂閱者退訂,將改客戶端移除出連結串列。

九  主從複製

  什麼是主從複製

  主從複製,是指將一臺Redis伺服器的資料,複製到其他的Redis伺服器。2.前者稱為主節點(master),後者稱為從節點(slave);資料的複製是單向的,只能由主節點到從節點預設情況下,每臺redis伺服器都是主節點;且一個主節點可以有多個從節點(或者沒有),但一個從節點只有一個主

  主從複製的作用主要包括

  • 資料冗餘:主從複製實現了資料的熱備份,是持久化之外的一種資料冗餘方式。
  • 故障恢復:當主節點出現問題時,可以由從節點提供服務,實現快速的故障恢復;實際上是一種服務的冗餘。
  • 負載均衡:在主從複製的基礎上,配合讀寫分離,可以由主節點提供寫服務,由從節點提供讀服務(即寫Redis資料時應用連線主節點,讀Redis資料時應用連線從節點),分擔伺服器負載;尤其是在寫少讀多的場景下,通過多個從節點分擔讀負載,可以大大提高Redis伺服器的併發量。
  • 高可用基石:除了上述作用以外,主從複製還是哨兵和叢集能夠實施的基礎,因此說主從複製是Redis高可用的基礎。

  主從庫採用的是讀寫分離的方式

 

1  原理

  分為全量複製與增量複製
  全量複製:發生在第一次複製時
  增量複製:只會把主從庫網路斷連期間主庫收到的命令,同步給從庫

2  全量複製的三個階段

  第一階段是主從庫間建立連線、協商同步的過程。
  主要是為全量複製做準備。從庫和主庫建立起連線,並告訴主庫即將進行同步,主庫確認回覆後,主從庫間就可以開始同步了。
  具體來說,從庫給主庫傳送 psync 命令,表示要進行資料同步,主庫根據這個命令的引數來啟動複製。psync 命令包含了主庫的 runID 和複製進度 offset 兩個引數。runID,是每個 Redis 例項啟動時都會自動生成的一個隨機 ID,用來唯一標記這個例項。當從庫和主庫第一次複製時,因為不知道主庫的 runID,所以將 runID 設為“?”。offset,此時設為 -1,表示第一次複製。主庫收到 psync 命令後,會用 FULLRESYNC 響應命令帶上兩個引數:主庫 runID 和主庫目前的複製進度 offset,返回給從庫。從庫收到響應後,會記錄下這兩個引數。這裡有個地方需要注意,FULLRESYNC 響應表示第一次複製採用的全量複製,也就是說,主庫會把當前所有的資料都複製給從庫。
  第二階段,主庫將所有資料同步給從庫。
  從庫收到資料後,在本地完成資料載入。這個過程依賴於記憶體快照生成的 RDB 檔案。
  具體來說,主庫執行 bgsave 命令,生成 RDB 檔案,接著將檔案發給從庫。從庫接收到 RDB 檔案後,會先清空當前資料庫,然後載入 RDB 檔案。這是因為從庫在通過 replicaof 命令開始和主庫同步前,可能儲存了其他資料。為了避免之前資料的影響,從庫需要先把當前資料庫清空。在主庫將資料同步給從庫的過程中,主庫不會被阻塞,仍然可以正常接收請求。否則,Redis 的服務就被中斷了。但是,這些請求中的寫操作並沒有記錄到剛剛生成的 RDB 檔案中。為了保證主從庫的資料一致性,主庫會在記憶體中用專門的 replication buffer,記錄 RDB 檔案生成後收到的所有寫操作。
  第三個階段,主庫會把第二階段執行過程中新收到的寫命令,再發送給從庫。
  具體的操作是,當主庫完成 RDB 檔案傳送後,就會把此時 replication buffer 中的修改操作發給從庫,從庫再重新執行這些操作。這樣一來,主從庫就實現同步了。

十  哨兵機制

  哨兵的核心功能是主節點的自動故障轉移
  下圖是一個典型的哨兵叢集監控的邏輯圖

  Redis Sentinel包含了若個Sentinel節點,這樣做也帶來了兩個好處:

  1. 對於節點的故障判斷是由多個Sentinel節點共同完成,這樣可以有效地防止誤判
  2. 即使個別Sentinel節點不可用,整個Sentinel叢集依然是可用的。

  哨兵實現了一下功能

  1. 監控:每個Sentinel節點會對資料節點(Redis master/slave 節點)和其餘Sentinel節點進行監控
  2. 通知:Sentinel節點會將故障轉移的結果通知給應用方
  3. 故障轉移:實現slave晉升為master,並維護後續正確的主從關係
  4. 配置中心:在Redis Sentinel模式中,客戶端在初始化的時候連線的是Sentinel節點集合,從中獲取主節點資訊

  其中,監控和自動故障轉移功能,使得哨兵可以及時發現主節點故障並完成轉移;而配置中心和通知功能,則需要在與客戶端的互動中才能體現。

1  原理

監控

  Sentinel節點需要監控master、slave以及其它Sentinel節點的狀態。這一過程是通過Redis的pub/sub系統實現的。Redis Sentinel一共有三個定時監控任務,完成對各個節點發現和監控:

  1. 監控主從拓撲資訊:每隔10秒,每個Sentinel節點,會向master和slave傳送INFO命令獲取最新的拓撲結構
  2. Sentinel節點資訊交換:每隔2秒,每個Sentinel節點,會向Redis資料節點的__sentinel__:hello頻道上,傳送自身的資訊,以及對主節點的判斷資訊。這樣,Sentinel節點之間就可以交換資訊
  3. 節點狀態監控:每隔1秒,每個Sentinel節點,會向master、slave、其餘Sentinel節點發送PING命令做心跳檢測,來確認這些節點當前是否可達

主觀/客觀下線

  主觀下線
  每個Sentinel節點,每隔1秒會對資料節點發送ping命令做心跳檢測,當這些節點超過down-after-milliseconds沒有進行有效回覆時,Sentinel節點會對該節點做失敗判定,這個行為叫做主觀下線。
  客觀下線
  客觀下線,是指當大多數Sentinel節點,都認為master節點宕機了,那麼這個判定就是客觀的,叫做客觀下線。
  那麼這個大多數是指多少呢?這其實就是分散式協調中的quorum判定了,大多數就是過半數,比如哨兵數量是5,那麼大多數就是5/2+1=3個,哨兵數量是10大多數就是10/2+1=6個。
注:Sentinel節點的數量至少為3個,否則不滿足quorum判定條件。

哨兵選舉

  如果發生了客觀下線,那麼哨兵節點會選舉出一個Leader來進行實際的故障轉移工作。Redis使用了Raft演算法來實現哨兵領導者選舉,大致思路如下:

  1. 每個Sentinel節點都有資格成為領導者,當它主觀認為某個資料節點宕機後,會向其他Sentinel節點發送sentinel is-master-down-by-addr命令,要求自己成為領導者;
  2. 收到命令的Sentinel節點,如果沒有同意過其他Sentinel節點的sentinelis-master-down-by-addr命令,將同意該請求,否則拒絕(每個Sentinel節點只有1票);
  3. 如果該Sentinel節點發現自己的票數已經大於等於MAX(quorum, num(sentinels)/2+1),那麼它將成為領導者;
  4. 如果此過程沒有選舉出領導者,將進入下一次選舉。

故障轉移

  選舉出的Leader Sentinel節點將負責故障轉移,也就是進行master/slave節點的主從切換。故障轉移,首先要從slave節點中篩選出一個作為新的master,主要考慮以下slave資訊:

  1. 跟master斷開連線的時長:如果一個slave跟master的斷開連線時長已經超過了down-after-milliseconds的10倍,外加master宕機的時長,那麼該slave就被認為不適合選舉為master;
  2. slave的優先順序配置:slave priority引數值越小,優先順序就越高;
  3. 複製offset:當優先順序相同時,哪個slave複製了越多的資料(offset越靠後),優先順序越高;
  4. run id:如果offset和優先順序都相同,則哪個slave的run id越小,優先順序越高。

  接著,篩選完slave後, 會對它執行slaveof no one命令,讓其成為主節點。
  最後,Sentinel領導者節點會向剩餘的slave節點發送命令,讓它們成為新的master節點的從節點,複製規則與parallel-syncs引數有關。
  Sentinel節點集合會將原來的master節點更新為slave節點,並保持著對其關注,當其恢復後命令它去複製新的主節點。
  注:Leader Sentinel節點,會從新的master節點那裡得到一個configuration epoch,本質是個version版本號,每次主從切換的version號都必須是唯一的。其他的哨兵都是根據version來更新自己的master配置。

十一  快取穿透、擊穿、雪崩

1  快取穿透

  • 問題來源

  快取穿透是指快取和資料庫中都沒有的資料,而使用者不斷髮起請求。由於快取是不命中時被動寫的,並且出於容錯考慮,如果從儲存層查不到資料則不寫入快取,這將導致這個不存在的資料每次請求都要到儲存層去查詢,失去了快取的意義。在流量大時,可能DB就掛掉了,要是有人利用不存在的key頻繁攻擊我們的應用,這就是漏洞。
如發起為id為“-1”的資料或id為特別大不存在的資料。這時的使用者很可能是攻擊者,攻擊會導致資料庫壓力過大。

  • 解決方案
  1. 介面層增加校驗,如使用者鑑權校驗,id做基礎校驗,id<=0的直接攔截;
  2. 從快取取不到的資料,在資料庫中也沒有取到,這時也可以將key-value對寫為key-null,快取有效時間可以設定短點,如30秒(設定太長會導致正常情況也沒法使用)。這樣可以防止攻擊使用者反覆用同一個id暴力攻擊。
  3. 布隆過濾器。類似於一個hash set,用於快速判某個元素是否存在於集合中,其典型的應用場景就是快速判斷一個key是否存在於某容器,不存在就直接返回。布隆過濾器的關鍵就在於hash演算法和容器大小。

2  快取擊穿

  • 問題來源

  快取擊穿是指快取中沒有但資料庫中有的資料(一般是快取時間到期),這時由於併發使用者特別多,同時讀快取沒讀到資料,又同時去資料庫去取資料,引起資料庫壓力瞬間增大,造成過大壓力。

  • 解決方案

  1、設定熱點資料永遠不過期。
  2、介面限流與熔斷,降級。重要的介面一定要做好限流策略,防止使用者惡意刷介面,同時要降級準備,當介面中的某些服務不可用時候,進行熔斷,失敗快速返回機制。
  3、加互斥鎖

3  快取雪崩

  • 問題來源

  快取雪崩是指快取中資料大批量到過期時間,而查詢資料量巨大,引起資料庫壓力過大甚至down機。和快取擊穿不同的是,快取擊穿指併發查同一條資料,快取雪崩是不同資料都過期了,很多資料都查不到從而查資料庫。

  • 解決方案
  1. 快取資料的過期時間設定隨機,防止同一時間大量資料過期現象發生。
  2. 如果快取資料庫是分散式部署,將熱點資料均勻分佈在不同的快取資料庫中。
  3. 設定熱點資料永遠不過期