【Java - redis】數據類型內部編碼和應用場景


筆記:
《Redis設計與實現》
《Redis實戰運維》

1-內部編碼

  1. 每種數據結構都有兩種以上的內部編碼實現,例如list數據結構包含了linkedlist和ziplist兩種內部編碼
    在這裏插入圖片描述

數據結構

  1. 概述
    在這裏插入圖片描述
1 long類型的整數
2 簡單動態字符串|raw

|C字符串|SDS|SDS作用|SDS實現|和C的區別|爲什麼用SDS|||

  1. 關於C語言的字符串?
  • C語言傳統的字符串表示(以空字符結尾的字符數組,以下簡稱C字符串)
  • Redis裏面,C字符串只會作爲字符串字面量(string literal)用在一些無須對字符串值進行修改的地方,比如打印日誌:
    redisLog(REDIS_WARNING,"Redis is now ready to exit, bye bye...");
    
    
  1. redis構建的字符串?
  • 簡單動態字符串(simple dynamic string,SDS)的抽象類型;
  • 用作Redis的默認字符串表示;
  • 當Redis需要的不僅僅是一個字符串字面量,而是一個可以被修改的字符串值時,Redis就會使用SDS來表示字符串值,比如在Redis的數據庫裏面,包含字符串值的鍵值對在底層都是由SDS實現的。
  1. SDS的應用?
  • 1)用來保存數據庫中的字符串值;
    2)用作緩衝區(buffer):AOF模塊中的AOF緩衝區,以及客戶端狀態中的輸入緩衝區,都是由SDS實現的;
  1. SDS的實現?
  • SDS的定義
    1)每個sds.h/sdshdr結構表示一個SDS值:
    struct sdshdr {
        // 記錄buf數組中已使用字節的數量等於SDS所保存字符串的長度
        int len;
        // 記錄buf數組中未使用字節的數量
        int free;
        // 字節數組,用於保存字符串
        char buf[];
    };
    
    

2)示例
·free屬性的值爲0,表示這個SDS沒有分配任何未使用空間。
·len屬性的值爲5,表示這個SDS保存了一個五字節長的字符串。
·buf屬性是一個char類型的數組,數組的前五個字節分別保存了’R’、‘e’、‘d’、‘i’、‘s’五個字符,而最後一個字節則保存了空字符’\0’。
在這裏插入圖片描述
在這裏插入圖片描述
– SDS遵循C字符串以空字符結尾的慣例,保存空字符的1字節空間不計算在SDS的len屬性裏面,並且爲空字符分配額外的1字節空間,以及添加空字符到字符串末尾等操作,都是由SDS函數自動完成的,所以這個空字符對於SDS的使用者來說是完全透明的。遵循空字符結尾這一慣例的好處是,SDS可以直接重用一部分C字符串函數庫裏面的函數。
– 上兩個圖展示的SDS的區別在於,這個SDS爲buf數組分配了五字節未使用空間,所以它的free屬性的值爲5(圖中使用五個空格來表示五字節的未使用空間)。






  1. SDS與C字符串的區別?
  • 根據傳統,C語言使用長度爲N+1的字符數組來表示長度爲N的字符串,並且字符數組的最後一個元素總是空字符’\0’。
  • 1)常數複雜度獲取字符串長度
    – C字符串:C字符串並不記錄自身的長度信息,所以爲了獲取一個C字符串的長度,程序必須遍歷整個字符串,對遇到的每個字符進行計數,直到遇到代表字符串結尾的空字符爲止,這個操作的複雜度爲O(N)。
    – SDS:和C字符串不同,因爲SDS在len屬性中記錄了SDS本身的長度,所以獲取一個SDS長度的複雜度僅爲O(1)。
    設置和更新SDS長度的工作是由SDS的API在執行時自動完成的,使用SDS無須進行任何手動修改長度的工作。
    – 總:通過使用SDS而不是C字符串,Redis將獲取字符串長度所需的複雜度從O(N)降低到了O(1),這確保了獲取字符串長度的工作不會成爲Redis的性能瓶頸。



  • 2)杜絕緩衝區溢出
    – 緩衝區溢出:C字符串不記錄自身長度帶來的另一個問題是容易造成緩衝區溢出(buffer overflow)。
    例子,<string.h>/strcat函數可以將src字符串中的內容拼接到dest字符串的末尾:
    char *strcat(char *dest, const char *src);
    – C字符串:因爲C字符串不記錄自身的長度,所以strcat假定用戶在執行這個函數時,已經爲dest分配了足夠多的內存,可以容納src字符串中的所有內容,而一旦這個假定不成立時,就會產生緩衝區溢出。
    – SDS:與C字符串不同,SDS的空間分配策略完全杜絕了發生緩衝區溢出的可能性:當SDS API需要對SDS進行修改時,API會先檢查SDS的空間是否滿足修改所需的要求,如果不滿足的話,API會自動將SDS的空間擴展至執行修改所需的大小,然後才執行實際的修改操作,所以使用SDS既不需要手動修改SDS的空間大小,也不會出現前面所說的緩衝區溢出問題。




  • 3)減少修改字符串時帶來的內存重分配次數
    – 現象:因爲內存重分配涉及複雜的算法,並且可能需要執行系統調用,所以它通常是一個比較耗時的操作:
    · 在一般程序中,如果修改字符串長度的情況不太常出現,那麼每次修改都執行一次內存重分配是可以接受的。
    · 但是Redis作爲數據庫,經常被用於速度要求嚴苛、數據被頻繁修改的場合,如果每次修改字符串的長度都需要執行一次內存重分配的話,那麼光是執行內存重分配的時間就會佔去修改字符串所用時間的一大部分,如果這種修改頻繁地發生的話,可能還會對性能造成影響。
    – C字符串:因爲C字符串並不記錄自身的長度,所以對於一個包含了N個字符的C字符串來說,這個C字符串的底層實現總是一個N+1個字符長的數組(額外的一個字符空間用於保存空字符)。因爲C字符串的長度和底層數組的長度之間存在着這種關聯性,所以每次增長或者縮短一個C字符串,程序都總要對保存這個C字符串的數組進行一次內存重分配操作:
    · 如果程序執行的是增長字符串的操作,比如拼接操作(append),那麼在執行這個操作之前,程序需要先通過內存重分配來擴展底層數組的空間大小——如果忘了這一步就會產生緩衝區溢出
    · 如果程序執行的是縮短字符串的操作,比如截斷操作(trim),那麼在執行這個操作之後,程序需要通過內存重分配來釋放字符串不再使用的那部分空間——如果忘了這一步就會產生內存泄漏
    – SDS:爲了避免C字符串的這種缺陷,SDS通過未使用空間解除了字符串長度和底層數組長度之間的關聯:在SDS中,buf數組的長度不一定就是字符數量加一,數組裏面可以包含未使用的字節,而這些字節的數量就由SDS的free屬性記錄。






  • 4)二進制安全
    – C字符串:C字符串中的字符必須符合某種編碼(比如ASCII),並且除了字符串的末尾之外,字符串裏面不能包含空字符,否則最先被程序讀入的空字符將被誤認爲是字符串結尾,這些限制使得C字符串只能保存文本數據,而不能保存像圖片、音頻、視頻、壓縮文件這樣的二進制數據。
    – SDS:雖然數據庫一般用於保存文本數據,但使用數據庫來保存二進制數據的場景也不少見,因此,爲了確保Redis可以適用於各種不同的使用場景,SDS的API都是二進制安全的(binary-safe),所有SDS API都會以處理二進制的方式來處理SDS存放在buf數組裏的數據,程序不會對其中的數據做任何限制、過濾、或者假設,數據在寫入時是什麼樣的,它被讀取時就是什麼樣。
    這也是我們將SDS的buf屬性稱爲字節數組的原因——Redis不是用這個數組來保存字符,而是用它來保存一系列二進制數據。
    – 總:通過使用二進制安全的SDS,而不是C字符串,使得Redis不僅可以保存文本數據,還可以保存任意格式的二進制數據。



  • 5)兼容部分C字符串函數
    – 雖然SDS的API都是二進制安全的,但它們一樣遵循C字符串以空字符結尾的慣例:這些API總會將SDS保存的數據的末尾設置爲空字符,並且總會在爲buf數組分配空間時多分配一個字節來容納這個空字符,這是爲了讓那些保存文本數據的SDS可以重用一部分<string.h>庫定義的函數。
    – 總:通過遵循C字符串以空字符結尾的慣例,SDS可以在有需要時重用<string.h>函數庫,從而避免了不必要的代碼重複。
    在這裏插入圖片描述


  1. 未使用空間,SDS實現的空間預分配和惰性空間釋放兩種優化策略? - 減少修改字符串時帶來的內存重分配次數
  • ① 空間預分配
    – 空間預分配用於優化SDS的字符串增長操作:當SDS的API對一個SDS進行修改,並且需要對SDS進行空間擴展的時候,程序不僅會爲SDS分配修改所必須要的空間,還會爲SDS分配額外的未使用空間。
    – 其中,額外分配的未使用空間數量由以下公式決定:
    · 如果對SDS進行修改之後,SDS的長度(也即是len屬性的值)將小於1MB,那麼程序分配和len屬性同樣大小的未使用空間,這時SDS len屬性的值將和free屬性的值相同。舉個例子,如果進行修改之後,SDS的len將變成13字節,那麼程序也會分配13字節的未使用空間,SDS的buf數組的實際長度將變成13+13+1=27字節(額外的一字節用於保存空字符)。
    · 如果對SDS進行修改之後,SDS的長度將大於等於1MB,那麼程序會分配1MB的未使用空間。
    舉個例子,如果進行修改之後,SDS的len將變成30MB,那麼程序會分配1MB的未使用空間,SDS的buf數組的實際長度將爲30MB+1MB+1byte。
    – 通過空間預分配策略,Redis可以減少連續執行字符串增長操作所需的內存重分配次數。
    – 在擴展SDS空間之前,SDSAPI會先檢查未使用空間是否足夠,如果足夠的話,API就會直接使用未使用空間,而無須執行內存重分配。
    – 通過這種預分配策略,SDS將連續增長N次字符串所需的內存重分配次數從必定N次降低爲最多N次。







  • ② 惰性空間釋放
    – 惰性空間釋放用於優化SDS的字符串縮短操作:當SDS的API需要縮短SDS保存的字符串時,程序並不立即使用內存重分配來回收縮短後多出來的字節,而是使用free屬性將這些字節的數量記錄起來,並等待將來使用。
    – 通過惰性空間釋放策略,SDS避免了縮短字符串時所需的內存重分配操作,併爲將來可能有的增長操作提供了優化。
    – 與此同時,SDS也提供了相應的API,讓我們可以在有需要時,真正地釋放SDS的未使用空間,所以不用擔心惰性空間釋放策略會造成內存浪費。


  1. 解釋爲什麼Redis要使用SDS而不是C字符串?
  • 比起C字符串,SDS具有以下優點:
    1)常數複雜度獲取字符串長度。
    2)杜絕緩衝區溢出。
    3)減少修改字符串長度時所需的內存重分配次數。
    4)二進制安全。
    5)兼容部分C字符串函數。




3 鏈表-linkedlist
  1. 鏈表基本知識?
  • 鏈表提供了高效的節點重排能力,以及順序性的節點訪問方式,並且可以通過增刪節點來靈活地調整鏈表的長度。
  • 作爲一種常用數據結構,鏈表內置在很多高級的編程語言裏面,因爲Redis使用的C語言並沒有內置這種數據結構,所以Redis構建了自己的鏈表實現。
  • 鏈表在Redis中的應用非常廣泛,比如列表鍵的底層實現之一就是鏈表。當一個列表鍵包含了數量比較多的元素,又或者列表中包含的元素都是比較長的字符串時,Redis就會使用鏈表作爲列表鍵的底層實現。
  • 作用:
    – integers列表鍵的底層實現就是一個鏈表,鏈表中的每個節點都保存了一個整數值。
    – 除了鏈表鍵之外,發佈與訂閱、慢查詢、監視器等功能也用到了鏈表,Redis服務器本身還使用鏈表來保存多個客戶端的狀態信息,以及使用鏈表來構建客戶端輸出緩衝區(output buffer)

  1. 鏈表和鏈表節點的實現?
  • 每個鏈表節點使用一個adlist.h/listNode結構來表示:
  • 多個listNode可以通過prev和next指針組成雙端鏈表
    typedef struct listNode {
        // 前置節點
        struct listNode * prev;
        // 後置節點
        struct listNode * next;
        // 節點的值
        void * value;
    }listNode;
    
    

在這裏插入圖片描述

  • 雖然僅僅使用多個listNode結構就可以組成鏈表,但使用adlist.h/list來持有鏈表的話,操作起來會更方便:
  • 如下,list結構爲鏈表提供了表頭指針head、表尾指針tail,以及鏈表長度計數器len,而dup、free和match成員則是用於實現多態鏈表所需的類型特定函數:
    · dup函數用於複製鏈表節點所保存的值;
    · free函數用於釋放鏈表節點所保存的值;
    · match函數則用於對比鏈表節點所保存的值和另一個輸入值是否相等。
    typedef struct list {
        // 表頭節點
        listNode * head;
        // 表尾節點
        listNode * tail;
        // 鏈表所包含的節點數量
        unsigned long len;
        // 節點值複製函數
        void *(*dup)(void *ptr);
        // 節點值釋放函數
        void (*free)(void *ptr);
        // 節點值對比函數
        int (*match)(void *ptr,void *key);
    } list;
    
    



在這裏插入圖片描述

  1. Redis的鏈表實現的特性可以總結如下:
  • 雙端:鏈表節點帶有prev和next指針,獲取某個節點的前置節點和後置節點的複雜度都是O(1)。
  • 無環:表頭節點的prev指針和表尾節點的next指針都指向NULL,對鏈表的訪問以NULL爲終點。
  • 帶表頭指針和表尾指針:通過list結構的head指針和tail指針,程序獲取鏈表的表頭節點和表尾節點的複雜度爲O(1)。
  • 帶鏈表長度計數器:程序使用list結構的len屬性來對list持有的鏈表節點進行計數,程序獲取鏈表中節點數量的複雜度爲O(1)。
  • 多態:鏈表節點使用void*指針來保存節點值,並且可以通過list結構的dup、free、match三個屬性爲節點值設置類型特定函數,所以鏈表可以用於保存各種不同類型的值。
4 字典-hashtable
  1. 字典的基本理論?
  • 字典,又稱爲符號表(symbol table)、關聯數組(associative array)或映射(map),是一種用於保存鍵值對(key-value pair)的抽象數據結構。
  • 在字典中,一個鍵(key)可以和一個值(value)進行關聯(或者說將鍵映射爲值),這些關聯的鍵和值就稱爲鍵值對。
  • 字典中的每個鍵都是獨一無二的,程序可以在字典中根據鍵查找與之關聯的值,或者通過鍵來更新值,又或者根據鍵來刪除整個鍵值對,等等。
  • 字典經常作爲一種數據結構內置在很多高級編程語言裏面,但Redis所使用的C語言並沒有內置這種數據結構,因此Redis構建了自己的字典實現。
  • 字典在Redis中的應用相當廣泛,比如Redis的數據庫就是使用字典來作爲底層實現的,對數據庫的增、刪、查、改操作也是構建在對字典的操作之上的。
  • 除了用來表示數據庫之外,字典還是哈希鍵的底層實現之一,當一個哈希鍵包含的鍵值對比較多,又或者鍵值對中的元素都是比較長的字符串時,Redis就會使用字典作爲哈希鍵的底層實現。
  • 除了用來實現數據庫和哈希鍵之外,Redis的不少功能也用到了字典
  1. 字典的實現?
    Redis的字典使用哈希表作爲底層實現,一個哈希表裏面可以有多個哈希表節點,而每個哈希表節點就保存了字典中的一個鍵值對。
    接下來的三個小節將分別介紹Redis的哈希表、哈希表節點以及字典的實現。

  • 1)哈希表
    – Redis字典所使用的哈希表由dict.h/dictht結構定義:
    ---- table屬性:是一個數組,數組中的每個元素都是一個指向dict.h/dictEntry結構的指針,每個dictEntry結構保存着一個鍵值對。
    ---- size屬性:記錄了哈希表的大小,也即是table數組的大小,而used屬性則記錄了哈希表目前已有節點(鍵值對)的數量。
    ---- sizemask屬性:的值總是等於size-1,這個屬性和哈希值一起決定一個鍵應該被放到table數組的哪個索引上面。
    ---- 展示了一個大小爲4的空哈希表(沒有包含任何鍵值對)。
    typedef struct dictht {
        // 哈希表數組
        dictEntry **table;
        // 哈希表大小
        unsigned long size;
        // 哈希表大小掩碼,用於計算索引值總是等於size-1
        unsigned long sizemask;
        // 該哈希表已有節點的數量
        unsigned long used;
    } dictht;
    
    





在這裏插入圖片描述

  • 2)哈希表節點
    哈希表節點使用dictEntry結構表示,每個dictEntry結構都保存着一個鍵值對:
    ---- key屬性:保存着鍵值對中的鍵;
    ---- v屬性:則保存着鍵值對中的值,其中鍵值對的值可以是一個指針,或者是一個uint64_t整數,又或者是一個int64_t整數。
    ---- next屬性:是指向另一個哈希表節點的指針,這個指針可以將多個哈希值相同的鍵值對連接在一次,以此來解決鍵衝突(collision)的問題。
    typedef struct dictEntry {
        // 鍵
        void *key;
        // 值
        union{
            void *val;
            uint64_tu64;
            int64_ts64;
        } v;
        // 指向下個哈希表節點,形成鏈表
        struct dictEntry *next;
    } dictEntry;
    
    




在這裏插入圖片描述

  • 3)字典
    Redis中的字典由dict.h/dict結構表示:
    – type屬性和privdata屬性是針對不同類型的鍵值對,爲創建多態字典而設置的:
    ---- type屬性:是一個指向dictType結構的指針,每個dictType結構保存了一簇用於操作特定類型鍵值對的函數,Redis會爲用途不同的字典設置不同的類型特定函數。
    ---- privdata屬性:則保存了需要傳給那些類型特定函數的可選參數。
    ---- ht屬性:是一個包含兩個項的數組,數組中的每個項都是一個dictht哈希表,一般情況下,字典只使用ht[0]哈希表,ht[1]哈希表只會在對ht[0]哈希表進行rehash時使用。
    除了ht[1]之外,另一個和rehash有關的屬性就是rehashidx,它記錄了rehash目前的進度,如果目前沒有在進行rehash,那麼它的值爲-1。
    – 如圖,普通狀態下(沒有進行rehash)的字典。
    typedef struct dict {
        // 類型特定函數
        dictType *type;
        // 私有數據
        void *privdata;
        // 哈希表
        dictht ht[2];
        // rehash索引
        //當rehash不在進行時,值爲-1
        in trehashidx; /* rehashing not in progress if rehashidx == -1 */
    } dict;
    
    // == type屬性 ==
        typedef struct dictType {
        // 計算哈希值的函數
        unsigned int (*hashFunction)(const void *key);
        // 複製鍵的函數
        void *(*keyDup)(void *privdata, const void *key);
        // 複製值的函數
        void *(*valDup)(void *privdata, const void *obj);
        // 對比鍵的函數
        int (*keyCompare)(void *privdata, const void *key1, const void *key2);
        // 銷燬鍵的函數
        void (*keyDestructor)(void *privdata, void *key);
        // 銷燬值的函數
        void (*valDestructor)(void *privdata, void *obj);
    } dictType;
    
    







在這裏插入圖片描述

  1. 哈希算法?
    當要將一個新的鍵值對添加到字典裏面時,程序需要先根據鍵值對的鍵計算出哈希值和索引值,然後再根據索引值,將包含新鍵值對的哈希表節點放到哈希表數組的指定索引上面。
  • Redis計算哈希值和索引值的方法如下:
    #使用字典設置的哈希函數,計算鍵key的哈希值
    hash = dict->type->hashFunction(key);
    #使用哈希表的sizemask屬性和哈希值,計算出索引值
    #根據情況不同,ht[x]可以是ht[0]或者ht[1]
    index = hash & dict->ht[x].sizemask;
    

在這裏插入圖片描述
空字典
在這裏插入圖片描述
當字典被用作數據庫的底層實現,或者哈希鍵的底層實現時,Redis使用MurmurHash2算法來計算鍵的哈希值。


  1. 解決鍵衝突?
    當有兩個或以上數量的鍵被分配到了哈希表數組的同一個索引上面時,我們稱這些鍵發生了衝突(collision)。
    Redis的哈希表使用鏈地址法(separate chaining)來解決鍵衝突,每個哈希表節點都有一個next指針,多個哈希表節點可以用next指針構成一個單向鏈表,被分配到同一個索引上的多個節點可以用這個單向鏈表連接起來,這就解決了鍵衝突的問題。

  • 因爲dictEntry節點組成的鏈表沒有指向鏈表表尾的指針,所以爲了速度考慮,程序總是將新節點添加到鏈表的表頭位置(複雜度爲O(1)),排在其他已有節點的前面。
    一個包含兩個鍵值對的哈希表使用鏈表解決k2和k1的衝突
    在這裏插入圖片描述
    在這裏插入圖片描述


  1. rehash?
  • 隨着操作的不斷執行,哈希表保存的鍵值對會逐漸地增多或者減少,爲了讓哈希表的負載因子(load factor)維持在一個合理的範圍之內,當哈希表保存的鍵值對數量太多或者太少時,程序需要對哈希表的大小進行相應的擴展或者收縮。
  • 擴展和收縮哈希表的工作可以通過執行rehash(重新散列)操作來完成,Redis對字典的哈希表執行rehash的步驟如下:
    1)爲字典的ht[1]哈希表分配空間,這個哈希表的空間大小取決於要執行的操作,以及ht[0]當前包含的鍵值對數量(也即是ht[0].used屬性的值):
    · 如果執行的是擴展操作,那麼ht[1]的大小爲第一個大於等於ht[0].used*2的2 n(2的n次方冪);
    · 如果執行的是收縮操作,那麼ht[1]的大小爲第一個大於等於ht[0].used的2 n。
    2)將保存在ht[0]中的所有鍵值對rehash到ht[1]上面:rehash指的是重新計算鍵的哈希值和索引值,然後將鍵值對放置到ht[1]哈希表的指定位置上。
    3)當ht[0]包含的所有鍵值對都遷移到了ht[1]之後(ht[0]變爲空表),釋放ht[0],將ht[1]設置爲ht[0],並在ht[1]新創建一個空白哈希表,爲下一次rehash做準備。




  1. rehash的步驟?
    舉個例子,假設程序要對圖4-8所示字典的ht[0]進行擴展操作,那麼程序將執行以下步驟:
    – 執行rehash之前的字典:
    在這裏插入圖片描述


  • 1)ht[0].used當前的值爲4,4*2=8,而8(2 3)恰好是第一個大於等於4的2的n次方,所以程序會將ht[1]哈希表的大小設置爲8。下圖展示了ht[1]在分配空間之後,字典的樣子。
    – 爲字典的ht[1]哈希表分配空間
    在這裏插入圖片描述

  • 2)將ht[0]包含的四個鍵值對都rehash到ht[1],如圖所示。
    – ht[0]的所有鍵值對都已經被遷移到ht[1]
    在這裏插入圖片描述

  • 3)釋放ht[0],並將ht[1]設置爲ht[0],然後爲ht[1]分配一個空白哈希表,如圖所示。至此,對哈希表的擴展操作執行完畢,程序成功將哈希表的大小從原來的4改爲了現在的8。
    – 完成rehash之後的字典:
    在這裏插入圖片描述

  1. 哈希表的擴展與收縮?
  • 當以下條件中的任意一個被滿足時,程序會自動開始對哈希表執行擴展操作:
    1)服務器目前沒有在執行BGSAVE命令或者BGREWRITEAOF命令,並且哈希表的負載因子大於等於1。
    2)服務器目前正在執行BGSAVE命令或者BGREWRITEAOF命令,並且哈希表的負載因子大於等於5。

  • 其中哈希表的負載因子可以通過公式計算得出。
    # 負載因子= 哈希表已保存節點數量/ 哈希表大小
    load_factor = ht[0].used / ht[0].size
    
    
  • 例如,對於一個大小爲4,包含4個鍵值對的哈希表來說,這個哈希表的負載因子爲:
    load_factor = 4 / 4 = 1
  • 又例如,對於一個大小爲512,包含256個鍵值對的哈希表來說,這個哈希表的負載因子爲:
    load_factor = 256 / 512 = 0.5
  • 根據BGSAVE命令或BGREWRITEAOF命令是否正在執行,服務器執行擴展操作所需的負載因子並不相同,這是因爲在執行BGSAVE命令或BGREWRITEAOF命令的過程中,Redis需要創建當前服務器進程的子進程,而大多數操作系統都採用寫時複製(copy-on-write)技術來優化子進程的使用效率,所以在子進程存在期間,服務器會提高執行擴展操作所需的負載因子,從而儘可能地避免在子進程存在期間進行哈希表擴展操作,這可以避免不必要的內存寫入操作,最大限度地節約內存。
  • 另一方面,當哈希表的負載因子小於0.1時,程序自動開始對哈希表執行收縮操作。
  1. 漸進式rehash?
  • 擴展或收縮哈希表需要將ht[0]裏面的所有鍵值對rehash到ht[1]裏面,但是,這個rehash動作並不是一次性、集中式地完成的,而是分多次、漸進式地完成的。
  • 這樣做的原因在於,如果ht[0]裏只保存着四個鍵值對,那麼服務器可以在瞬間就將這些鍵值對全部rehash到ht[1];但是,如果哈希表裏保存的鍵值對數量不是四個,而是四百萬、四千萬甚至四億個鍵值對,那麼要一次性將這些鍵值對全部rehash到ht[1]的話,龐大的計算量可能會導致服務器在一段時間內停止服務。
  • 因此,爲了避免rehash對服務器性能造成影響,服務器不是一次性將ht[0]裏面的所有鍵值對全部rehash到ht[1],而是分多次、漸進式地將ht[0]裏面的鍵值對慢慢地rehash到ht[1]。
  • 以下是哈希表漸進式rehash的詳細步驟:
    1)爲ht[1]分配空間,讓字典同時持有ht[0]和ht[1]兩個哈希表。
    2)在字典中維持一個索引計數器變量rehashidx,並將它的值設置爲0,表示rehash工作正式開始。
    3)在rehash進行期間,每次對字典執行添加、刪除、查找或者更新操作時,程序除了執行指定的操作以外,還會順帶將ht[0]哈希表在rehashidx索引上的所有鍵值對rehash到ht[1],當rehash工作完成之後,程序將rehashidx屬性的值增一。
    4)隨着字典操作的不斷執行,最終在某個時間點上,ht[0]的所有鍵值對都會被rehash至ht[1],這時程序將rehashidx屬性的值設爲-1,表示rehash操作已完成。



  • 漸進式rehash的好處在於它採取分而治之的方式,將rehash鍵值對所需的計算工作均攤到對字典的每個添加、刪除、查找和更新操作上,從而避免了集中式rehash而帶來的龐大計算量。
  1. 一次完整的漸進式rehash過程?
  • 下圖展示了一次完整的漸進式rehash過程,注意觀察在整個rehash過程中,字典的rehashidx屬性是如何變化的。
  • 1)準備開始rehash
    在這裏插入圖片描述
  • 2)rehash索引0上的鍵值對
    在這裏插入圖片描述
  • 3)rehash索引1上的鍵值對
    在這裏插入圖片描述
  • 4)rehash索引2上的鍵值對
    在這裏插入圖片描述
  • 5)rehash索引3上的鍵值對
    在這裏插入圖片描述
  • 6)rehash執行完畢
    在這裏插入圖片描述
  1. 漸進式rehash執行期間的哈希表操作?
  • 因爲在進行漸進式rehash的過程中,字典會同時使用ht[0]和ht[1]兩個哈希表,所以在漸進式rehash進行期間,字典的刪除(delete)、查找(find)、更新(update)等操作會在兩個哈希表上進行。例如,要在字典裏面查找一個鍵的話,程序會先在ht[0]裏面進行查找,如果沒找到的話,就會繼續到ht[1]裏面進行查找,諸如此類。
  • 另外,在漸進式rehash執行期間,新添加到字典的鍵值對一律會被保存到ht[1]裏面,而ht[0]則不再進行任何添加操作,這一措施保證了ht[0]包含的鍵值對數量會只減不增,並隨着rehash操作的執行而最終變成空表。
4 跳躍表-skiplist
  1. 跳躍表基本理論?
  • 跳躍表(skiplist)是一種有序數據結構,它通過在每個節點中維持多個指向其他節點的指針,從而達到快速訪問節點的目的。
  • 跳躍表支持平均O(logN)、最壞O(N)複雜度的節點查找,還可以通過順序性操作來批量處理節點。
  • 在大部分情況下,跳躍表的效率可以和平衡樹相媲美,並且因爲跳躍表的實現比平衡樹要來得更爲簡單,所以有不少程序都使用跳躍表來代替平衡樹。
  • Redis使用跳躍表作爲有序集合鍵的底層實現之一,如果一個有序集合包含的元素數量比較多,又或者有序集合中元素的成員(member)是比較長的字符串時,Redis就會使用跳躍表來作爲有序集合鍵的底層實現。
  • 和鏈表、字典等數據結構被廣泛地應用在Redis內部不同,Redis只在兩個地方用到了跳躍表,一個是實現有序集合鍵,另一個是在集羣節點中用作內部數據結構,除此之外,跳躍表在Redis裏面沒有其他用途。本章將對Redis中的跳躍表實現進行介紹,並列出跳躍表的操作API。本章不會對跳躍表的基本定義和基礎算法進行介紹,如果有需要的話,可以參考WilliamPugh關於跳躍表的論文《Skip Lists:A Probabilistic Alternative to Balanced Trees》,或者《算法:C語言實現(第1~4部分)》一書的13.5節。
  1. 跳躍表的實現?
  • Redis的跳躍表由redis.h/zskiplistNode和redis.h/zskiplist兩個結構定義;
    – 其中,
    zskiplistNode結構:用於表示跳躍表節點;
    zskiplist結構:則用於保存跳躍表節點的相關信息,比如節點的數量,以及指向表頭節點和表尾節點的指針等等。


  • zskiplist結構:位於圖片最左邊的,該結構包含以下屬性:
    – header:指向跳躍表的表頭節點。
    – tail:指向跳躍表的表尾節點。
    – level:記錄目前跳躍表內,層數最大的那個節點的層數(表頭節點的層數不計算在內)。
    – length:記錄跳躍表的長度,也即是,跳躍表目前包含節點的數量(表頭節點不計算在內)。



  • zskiplistNode結構:位於zskiplist結構右方的是四個zskiplistNode結構,該結構包含以下屬性:
    – 層(level):節點中用L1、L2、L3等字樣標記節點的各個層,L1代表第一層,L2代表第二層,以此類推。每個層都帶有兩個屬性:前進指針和跨度。前進指針用於訪問位於表尾方向的其他節點,而跨度則記錄了前進指針所指向節點和當前節點的距離。在上面的圖片中,連線上帶有數字的箭頭就代表前進指針,而那個數字就是跨度。當程序從表頭向表尾進行遍歷時,訪問會沿着層的前進指針進行。
    – 後退(backward)指針:節點中用BW字樣標記節點的後退指針,它指向位於當前節點的前一個節點。後退指針在程序從表尾向表頭遍歷時使用。
    – 分值(score):各個節點中的1.0、2.0和3.0是節點所保存的分值。在跳躍表中,節點按各自所保存的分值從小到大排列。
    – 成員對象(obj):各個節點中的o1、o2和o3是節點所保存的成員對象。
    注意表頭節點和其他節點的構造是一樣的:表頭節點也有後退指針、分值和成員對象,不過表頭節點的這些屬性都不會被用到,所以圖中省略了這些部分,只顯示了表頭節點的各個層。




  • 如下,一個跳躍表:
    在這裏插入圖片描述
  1. zskiplistNode和zskiplist兩個結構-zskiplistNode?
    跳躍表節點的實現由redis.h/zskiplistNode結構定義:
    typedef struct zskiplistNode {
        // 層
        struct zskiplistLevel {
            // 前進指針
            struct zskiplistNode *forward;
            // 跨度
            unsigned int span;
        } level[];
        // 後退指針
        struct zskiplistNode *backward;
        // 分值
        double score;
        // 成員對象
        robj *obj;
    } zskiplistNode;
    
    

  • 1)層
    跳躍表節點的level數組可以包含多個元素,每個元素都包含一個指向其他節點的指針,程序可以通過這些層來加快訪問其他節點的速度,一般來說,層的數量越多,訪問其他節點的速度就越快。
    每次創建一個新跳躍表節點的時候,程序都根據冪次定律(power law,越大的數出現的概率越小)隨機生成一個介於1和32之間的值作爲level數組的大小,這個大小就是層的“高度”。
    圖展示了三個高度爲1層、3層和5層的節點,因爲C語言的數組索引總是從0開始的,所以節點的第一層是level[0],而第二層是level[1],以此類推。
    在這裏插入圖片描述



  • 2)前進指針
    每個層都有一個指向表尾方向的前進指針(level[i].forward屬性),用於從表頭向表尾方向訪問節點。圖5-3用虛線表示出了程序從表頭向表尾方向,遍歷跳躍表中所有節點的路徑:
    – ① 迭代程序首先訪問跳躍表的第一個節點(表頭),然後從第四層的前進指針移動到表中的第二個節點。
    – ② 在第二個節點時,程序沿着第二層的前進指針移動到表中的第三個節點。
    – ③ 在第三個節點時,程序同樣沿着第二層的前進指針移動到表中的第四個節點。
    – ④ 當程序再次沿着第四個節點的前進指針移動時,它碰到一個NULL,程序知道這時已經到達了跳躍表的表尾,於是結束這次遍歷。
    在這裏插入圖片描述





  • 3)跨度
    層的跨度(level[i].span屬性)用於記錄兩個節點之間的距離:
    · 兩個節點之間的跨度越大,它們相距得就越遠。
    · 指向NULL的所有前進指針的跨度都爲0,因爲它們沒有連向任何節點。
    – 初看上去,很容易以爲跨度和遍歷操作有關,但實際上並不是這樣,遍歷操作只使用前進指針就可以完成了,跨度實際上是用來計算排位(rank)的:在查找某個節點的過程中,將沿途訪問過的所有層的跨度累計起來,得到的結果就是目標節點在跳躍表中的排位。
    – 舉個例子,圖5-4用虛線標記了在跳躍表中查找分值爲3.0、成員對象爲o3的節點時,沿途經歷的層:查找的過程只經過了一個層,並且層的跨度爲3,所以目標節點在跳躍表中的排位爲3。
    在這裏插入圖片描述
    – 再舉個例子,圖5-5用虛線標記了在跳躍表中查找分值爲2.0、成員對象爲o2的節點時,沿途經歷的層:在查找節點的過程中,程序經過了兩個跨度爲1的節點,因此可以計算出,目標節點在跳躍表中的排位爲2。
    在這裏插入圖片描述







  • 4)後退指針
    – 節點的後退指針(backward屬性)用於從表尾向表頭方向訪問節點:跟可以一次跳過多個節點的前進指針不同,因爲每個節點只有一個後退指針,所以每次只能後退至前一個節點。
    – 圖5-6用虛線展示瞭如果從表尾向表頭遍歷跳躍表中的所有節點:程序首先通過跳躍表的tail指針訪問表尾節點,然後通過後退指針訪問倒數第二個節點,之後再沿着後退指針訪問倒數第三個節點,再之後遇到指向NULL的後退指針,於是訪問結束。
    在這裏插入圖片描述


  • 5)分值和成員
    – 節點的分值(score屬性)是一個double類型的浮點數,跳躍表中的所有節點都按分值從小到大來排序。
    – 節點的成員對象(obj屬性)是一個指針,它指向一個字符串對象,而字符串對象則保存着一個SDS值。
    – 在同一個跳躍表中,各個節點保存的成員對象必須是唯一的,但是多個節點保存的分值卻可以是相同的:分值相同的節點將按照成員對象在字典序中的大小來進行排序,成員對象較小的節點會排在前面(靠近表頭的方向),而成員對象較大的節點則會排在後面(靠近表尾的方向)。
    – 舉個例子,在圖5-7所示的跳躍表中,三個跳躍表節點都保存了相同的分值10086.0,但保存成員對象o1的節點卻排在保存成員對象o2和o3的節點之前,而保存成員對象o2的節點又排在保存成員對象o3的節點之前,由此可見,o1、o2、o3三個成員對象在字典中的排序爲o1<=o2<=o3。
    在這裏插入圖片描述




  1. zskiplistNode和zskiplist兩個結構-zskiplist?
  • 僅靠多個跳躍表節點就可以組成一個跳躍表,如圖所示。
    在這裏插入圖片描述
  • 但通過使用一個zskiplist結構來持有這些節點,程序可以更方便地對整個跳躍表進行處理,比如快速訪問跳躍表的表頭節點和表尾節點,或者快速地獲取跳躍表節點的數量(也即是跳躍表的長度)等信息,如圖所示。
    zskiplist結構的定義如下:
    typedef struct zskiplist {
        // 表頭節點和表尾節點
        structz skiplistNode *header, *tail;
        // 表中節點的數量
        unsigned long length;
        // 表中層數最大的節點的層數
        int level;
    } zskiplist;
    
    

在這裏插入圖片描述
– header和tail指針分別指向跳躍表的表頭和表尾節點,通過這兩個指針,程序定位表頭節點和表尾節點的複雜度爲O(1)。
– 通過使用length屬性來記錄節點的數量,程序可以在O(1)複雜度內返回跳躍表的長度。
– level屬性則用於在O(1)複雜度內獲取跳躍表中層高最大的那個節點的層數量,注意表頭節點的層高並不計算在內。


6 整數集-intset
  1. 整數集的基本知識?
  • 整數集合(intset)是集合鍵的底層實現之一,當一個集合只包含整數值元素,並且這個集合的元素數量不多時,Redis就會使用整數集合作爲集合鍵的底層實現。
  1. 整數集合的實現
  • 整數集合(intset)是Redis用於保存整數值的集合抽象數據結構,它可以保存類型爲int16_t、int32_t或者int64_t的整數值,並且保證集合中不會出現重複元素。
  • 每個intset.h/intset結構表示一個整數集合:
    typedef struct intset {
        // 編碼方式
        uint32_t encoding;
        // 集合包含的元素數量
        uint32_t length;
        // 保存元素的數組
        int8_t contents[];
    } intset;
    
    

– contents數組:contents數組是整數集合的底層實現:整數集合的每個元素都是contents數組的一個數組項(item),各個項在數組中按值的大小從小到大有序地排列,並且數組中不包含任何重複項。
– length屬性:length屬性記錄了整數集合包含的元素數量,也即是contents數組的長度。

  • 雖然intset結構將contents屬性聲明爲int8_t類型的數組,但實際上contents數組並不保存任何int8_t類型的值,contents數組的真正類型取決於encoding屬性的值:
    · 如果encoding屬性的值爲INTSET_ENC_INT16,那麼contents就是一個int16_t類型的數組,數組裏的每個項都是一個int16_t類型的整數值(最小值爲-32768,最大值爲32767)。
    · 如果encoding屬性的值爲INTSET_ENC_INT32,那麼contents就是一個int32_t類型的數組,數組裏的每個項都是一個int32_t類型的整數值(最小值爲-2147483648,最大值爲2147483647)。
    · 如果encoding屬性的值爲INTSET_ENC_INT64,那麼contents就是一個int64_t類型的數組,數組裏的每個項都是一個int64_t類型的整數值(最小值爲-9223372036854775808,最大值爲9223372036854775807)。


  1. 升級?
  • 每當我們要將一個新元素添加到整數集合裏面,並且新元素的類型比整數集合現有所有元素的類型都要長時,整數集合需要先進行升級(upgrade),然後才能將新元素添加到整數集合裏面。
  • 升級整數集合並添加新元素共分爲三步進行:
    1)根據新元素的類型,擴展整數集合底層數組的空間大小,併爲新元素分配空間。
    2)將底層數組現有的所有元素都轉換成與新元素相同的類型,並將類型轉換後的元素放置到正確的位上,而且在放置元素的過程中,需要繼續維持底層數組的有序性質不變。
    3)將新元素添加到底層數組裏面。


  • 因爲每次向整數集合添加新元素都可能會引起升級,而每次升級都需要對底層數組中已有的所有元素進行類型轉換,所以向整數集合添加新元素的時間複雜度爲O(N)。
  1. 升級過程的例子展示?

  2. 升級之後新元素的擺放位置?

  • 因爲引發升級的新元素的長度總是比整數集合現有所有元素的長度都大,所以這個新元素的值要麼就大於所有現有元素,要麼就小於所有現有元素:
    · 在新元素小於所有現有元素的情況下,新元素會被放置在底層數組的最開頭(索引0);
    · 在新元素大於所有現有元素的情況下,新元素會被放置在底層數組的最末尾(索引length-1)。

  1. 升級的好處?
    整數集合的升級策略有兩個好處,一個是提升整數集合的靈活性,另一個是儘可能地節約內存。
  • 1)提升靈活性
    因爲C語言是靜態類型語言,爲了避免類型錯誤,我們通常不會將兩種不同類型的值放在同一個數據結構裏面。
    – 例如,我們一般只使用int16_t類型的數組來保存int16_t類型的值,只使用int32_t類型的數組來保存int32_t類型的值,諸如此類。
    – 但是,因爲整數集合可以通過自動升級底層數組來適應新元素,所以我們可以隨意地將int16_t、int32_t或者int64_t類型的整數添加到集合中,而不必擔心出現類型錯誤,這種做法非常靈活。


  • 2)節約內存
    當然,要讓一個數組可以同時保存int16_t、int32_t、int64_t三種類型的值,最簡單的做法就是直接使用int64_t類型的數組作爲整數集合的底層實現。不過這樣一來,即使添加到整數集合裏面的都是int16_t類型或者int32_t類型的值,數組都需要使用int64_t類型的空間去保存它們,從而出現浪費內存的情況。
    – 而整數集合現在的做法既可以讓集合能同時保存三種不同類型的值,又可以確保升級操作只會在有需要的時候進行,這可以儘量節省內存。
    – 例如,如果我們一直只向整數集合添加int16_t類型的值,那麼整數集合的底層實現就會一直是int16_t類型的數組,只有在我們要將int32_t類型或者int64_t類型的值添加到集合時,程序纔會對數組進行升級。


  1. 降級?
  • 整數集合不支持降級操作,一旦對數組進行了升級,編碼就會一直保持升級後的狀態。
  • 例子:
    – 對於圖1所示的整數集合來說,即使我們將集合裏唯一一個真正需要使用int64_t類型來保存的元素4294967295刪除了,整數集合的編碼仍然會維持INTSET_ENC_INT64,底層數組也仍然會是int64_t類型的,如圖2所示。
    在這裏插入圖片描述
    在這裏插入圖片描述


7.壓縮列表-ziplist
  1. 壓縮列表的基本知識?
  • 壓縮列表(ziplist)是列表鍵和哈希鍵的底層實現之一。
  • 應用:
    1)當一個列表鍵只包含少量列表項,並且每個列表項要麼就是小整數值,要麼就是長度比較短的字符串,那麼Redis就會使用壓縮列表來做列表鍵的底層實現。
    2)當一個哈希鍵只包含少量鍵值對,比且每個鍵值對的鍵和值要麼就是小整數值,要麼就是長度比較短的字符串,那麼Redis就會使用壓縮列表來做哈希鍵的底層實現。

  1. 壓縮列表的構成?
  • 壓縮列表是Redis爲了節約內存而開發的,是由一系列特殊編碼的連續內存塊組成的順序型(sequential)數據結構。一個壓縮列表可以包含任意多個節點(entry),每個節點可以保存一個字節數組或者一個整數值。
  • 壓縮列表的各個組成部分
    在這裏插入圖片描述
  • 各個組成部分的類型、長度以及用途:
    在這裏插入圖片描述
  1. 壓縮列表的構成-示例?
    1)三個節點的
    ·列表zlbytes屬性的值爲0x50(十進制80),表示壓縮列表的總長爲80字節。
    ·列表zltail屬性的值爲0x3c(十進制60),這表示如果我們有一個指向壓縮列表起始地址的指針p,那麼只要用指針p加上偏移量60,就可以計算出表尾節點entry3的地址。
    ·列表zllen屬性的值爲0x3(十進制3),表示壓縮列表包含三個節點。
    2)五個節點的
    ·列表zlbytes屬性的值爲0xd2(十進制210),表示壓縮列表的總長爲210字節。
    ·列表zltail屬性的值爲0xb3(十進制179),這表示如果我們有一個指向壓縮列表起始地址的指針p,那麼只要用指針p加上偏移量179,就可以計算出表尾節點entry5的地址。
    ·列表zllen屬性的值爲0x5(十進制5),表示壓縮列表包含五個節點。
    在這裏插入圖片描述








  2. 壓縮列表節點的構成?

  • 每個壓縮列表節點都由previous_entry_length、encoding、content三個部分組成:
    在這裏插入圖片描述
  • 每個壓縮列表節點可以保存一個字節數組或者一個整數值
    – 字節數組:可以是以下三種長度的其中一種:
    ·長度小於等於63(2 6–1)字節的字節數組;
    ·長度小於等於16383(2 14–1)字節的字節數組;
    ·長度小於等於4294967295(2 32–1)字節的字節數組;
    – 整數值:則可以是以下六種長度的其中一種:
    ·4位長,介於0至12之間的無符號整數;
    ·1字節長的有符號整數;
    ·3字節長的有符號整數;
    ·int16_t類型整數;
    ·int32_t類型整數;
    ·int64_t類型整數。










  1. 壓縮列表節點的構成?-previous_entry_length
  • 節點的previous_entry_length屬性
    以字節爲單位,記錄了壓縮列表中前一個節點的長度。previous_entry_length屬性的長度可以是1字節或者5字節:
    ·如果前一節點的長度小於254字節,那麼previous_entry_length屬性的長度爲1字節:前一節點的長度就保存在這一個字節裏面。
    ·如果前一節點的長度大於等於254字節,那麼previous_entry_length屬性的長度爲5字節:其中屬性的第一字節會被設置爲0xFE(十進制值254),而之後的四個字節則用於保存前一節點的長度。


  • 因爲節點的previous_entry_length屬性記錄了前一個節點的長度,所以程序可以通過指針運算,根據當前節點的起始地址來計算出前一個節點的起始地址。
  • 壓縮列表的從表尾向表頭遍歷操作就是使用這一原理實現的,只要我們擁有了一個指向某個節點起始地址的指針,那麼通過這個指針以及這個節點的previous_entry_length屬性,程序就可以一直向前一個節點回溯,最終到達壓縮列表的表頭節點。
  1. 一個從表尾節點向表頭節點進行遍歷的完整過程?
    ·首先,我們擁有指向壓縮列表表尾節點entry4起始地址的指針p1(指向表尾節點的指針可以通過指向壓縮列表起始地址的指針加上zltail屬性的值得出);
    ·通過用p1減去entry4節點previous_entry_length屬性的值,我們得到一個指向entry4前一節點entry3起始地址的指針p2;
    ·通過用p2減去entry3節點previous_entry_length屬性的值,我們得到一個指向entry3前一節點entry2起始地址的指針p3;
    ·通過用p3減去entry2節點previous_entry_length屬性的值,我們得到一個指向entry2前一節點entry1起始地址的指針p4,entry1爲壓縮列表的表頭節點;
    ·最終,我們從表尾節點向表頭節點遍歷了整個列表。
    在這裏插入圖片描述





  2. 壓縮列表節點的構成?-encoding

  • 節點的encoding屬性
    記錄了節點的content屬性所保存數據的類型以及長度:
    · 一字節、兩字節或者五字節長,值的最高位爲00、01或者10的是字節數組編碼:這種編碼表示節點的content屬性保存着字節數組,數組的長度由編碼除去最高兩位之後的其他位記錄;
    · 一字節長,值的最高位以11開頭的是整數編碼:這種編碼表示節點的content屬性保存着整數值,整數值的類型和長度由編碼除去最高兩位之後的其他位記錄;


  • 表1記錄了所有可用的字節數組編碼,而表2則記錄了所有可用的整數編碼。表格中的下劃線“_”表示留空,而b、x等變量則代表實際的二進制數據,爲了方便閱讀,多個字節之間用空格隔開。
    在這裏插入圖片描述
    在這裏插入圖片描述

  1. 壓縮列表節點的構成?-content
  • 節點的content屬性
    負責保存節點的值,節點值可以是一個字節數組或者整數,值的類型和長度由節點的encoding屬性決定。
  • 一個保存字節數組的節點示例:
    ·編碼的最高兩位00表示節點保存的是一個字節數組;
    ·編碼的後六位001011記錄了字節數組的長度11;
    ·content屬性保存着節點的值"hello world"。
    在這裏插入圖片描述
    ·編碼11000000表示節點保存的是一個int16_t類型的整數值;
    ·content屬性保存着節點的值10086。





  1. 連鎖更新?
  • 每個節點的previous_entry_length屬性都記錄了前一個節點的長度:
    ·如果前一節點的長度小於254字節,那麼previous_entry_length屬性需要用1字節長的空間來保存這個長度值。
    ·如果前一節點的長度大於等於254字節,那麼previous_entry_length屬性需要用5字節長的空間來保存這個長度值。

  • 一種情況:在一個壓縮列表中,有多個連續的、長度介於250字節到253字節之間的節點e1至eN:
    – 因爲e1至eN的所有節點的長度都小於254字節,所以記錄這些節點的長度只需要1字節長的previous_entry_length屬性,換句話說,e1至eN的所有節點的previous_entry_length屬性都是1字節長的。
    在這裏插入圖片描述

  • 問題:這時,如果我們將一個長度大於等於254字節的新節點new設置爲壓縮列表的表頭節點,那麼new將成爲e1的前置節點:
    – 因爲e1的previous_entry_length屬性僅長1字節,它沒辦法保存新節點new的長度,所以程序將對壓縮列表執行空間重分配操作,並將e1節點的previous_entry_length屬性從原來的1字節長擴展爲5字節長。
    在這裏插入圖片描述

  • 問題分析:
    – e1原本的長度介於250字節至253字節之間,在爲previous_entry_length屬性新增四個字節的空間之後,e1的長度就變成了介於254字節至257字節之間,而這種長度使用1字節長的previous_entry_length屬性是沒辦法保存的。
    – 因此,爲了讓e2的previous_entry_length屬性可以記錄下e1的長度,程序需要再次對壓縮列表執行空間重分配操作,並將e2節點的previous_entry_length屬性從原來的1字節長擴展爲5字節長。
    – 正如擴展e1引發了對e2的擴展一樣,擴展e2也會引發對e3的擴展,而擴展e3又會引發對e4的擴展……爲了讓每個節點的previous_entry_length屬性都符合壓縮列表對節點的要求,程序需要不斷地對壓縮列表執行空間重分配操作,直到eN爲止。
    – Redis將這種在特殊情況下產生的連續多次空間擴展操作稱之爲“連鎖更新”(cascade update)



  • 連鎖更新過程:
    在這裏插入圖片描述
  1. 刪除節點引發的連鎖更新?
  • 除了添加新節點可能會引發連鎖更新之外,刪除節點也可能會引發連鎖更新。
  • 考慮壓縮列表,如果e1至eN都是大小介於250字節至253字節的節點,big節點的長度大於等於254字節(需要5字節的previous_entry_length來保存),而small節點的長度小於254字節(只需要1字節的previous_entry_length來保存),那麼當我們將small節點從壓縮列表中刪除之後,爲了讓e1的previous_entry_length屬性可以記錄big節點的長度,程序將擴展e1的空間,並由此引發之後的連鎖更新
    在這裏插入圖片描述


  • 因爲連鎖更新在最壞情況下需要對壓縮列表執行N次空間重分配操作,而每次空間重分配的最壞複雜度爲O(N),所以連鎖更新的最壞複雜度爲O(N 2)。
  • 注意:
    儘管連鎖更新的複雜度較高,但它真正造成性能問題的機率是很低的:
    · 首先,壓縮列表裏要恰好有多個連續的、長度介於250字節至253字節之間的節點,連鎖更新纔有可能被引發,在實際中,這種情況並不多見;
    · 其次,即使出現連鎖更新,但只要被更新的節點數量不多,就不會對性能造成任何影響:比如說,對三五個節點進行連鎖更新是絕對不會影響性能的;
    – 因爲以上原因,ziplistPush等命令的平均複雜度僅爲O(N),在實際中,可以放心地使用這些函數,而不必擔心連鎖更新會影響壓縮列表的性能。



對象

  1. 概述
  • Redis用到的所有主要數據結構,如簡單動態字符串(SDS)、雙端鏈表、字典、壓縮列表、整數集合等等。
  • Redis並沒有直接使用這些數據結構來實現鍵值對數據庫,而是基於這些數據結構創建了一個對象系統,這個系統包含字符串對象、列表對象、哈希對象、集合對象和有序集合對象這五種類型的對象;
  1. 簡化字符串對象,以下統一使用
  • 使用了一個帶有StringObject字樣的格子來表示一個字符串對象,而StringObject字樣下面的是字符串對象所保存的值
    在這裏插入圖片描述
  1. 對象的表示?
  • Redis中的每個對象都由一個redisObject結構表示,該結構中和保存數據有關的三個屬性分別是type屬性、encoding屬性和ptr屬性
    typedef struct redisObject {
        // 類型
        unsigned type:4;
        // 編碼
        unsigned encoding:4;
        // 指向底層實現數據結構的指針
        void *ptr;
        // ...
    } robj;
    
    
  1. type屬性
  • 記錄了對象的類型,屬性的值可以是如下常量:
    REDIS_STRING(字符串對象)、
    REDIS_LIST(列表對象)、
    REDIS_HASH(哈希對象)、
    REDIS_SET(集合對象)、
    REDIS_ZSET(有序集合對象)




  • TYPE命令
    對一個數據庫鍵執行TYPE命令時,命令返回的結果爲數據庫鍵對應的值對象的類型,而不是鍵對象的類型;
  • 不同類型值對象的TYPE命令輸出
    對象type屬性的值/對象/TYPE命令的輸出
    REDIS_STRING(字符串對象)string、
    REDIS_LIST(列表對象)list、
    REDIS_HASH(哈希對象)hash、
    REDIS_SET(集合對象)set、
    REDIS_ZSET(有序集合對象)zset





  1. ptr指針和encoding屬性
  • 對象的ptr指針指向對象的底層實現數據結構,而這些數據結構由對象的encoding屬性決定。
  • encoding屬性記錄了對象所使用的編碼,也即是說這個對象使用了什麼數據結構作爲對象的底層實現,這個屬性的值可以是以下常量:
    在這裏插入圖片描述
  • 不同編碼的對象所對應的OBJECT ENCODING命令輸出。
    在這裏插入圖片描述
    在這裏插入圖片描述

1 string
  1. string對象編碼?
  • 字符串對象的編碼可以是int、raw或者embstr。
  1. 字符串對象編碼?
  • ① int
    如果一個字符串對象保存的是整數值,並且這個整數值可以用long類型來表示,那麼字符串對象會將整數值保存在字符串對象結構的ptr屬性裏面(將void*轉換成long),並將字符串對象的編碼設置爲int;在這裏插入圖片描述
  • ② raw
    如果字符串對象保存的是一個字符串值,並且這個字符串值的長度大於32字節,那麼字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串值,並將對象的編碼設置爲raw。
    在這裏插入圖片描述

  • ③ embstr
    如果字符串對象保存的是一個字符串值,並且這個字符串值的長度小於等於32字節,那麼字符串對象將使用embstr編碼的方式來保存這個字符串值。
    在這裏插入圖片描述

  1. embstr編碼?
  • embstr編碼是專門用於保存短字符串的一種優化編碼方式,這種編碼和raw編碼一樣,都使用redisObject結構和sdshdr結構來表示字符串對象,但raw編碼會調用兩次內存分配函數來分別創建redisObject結構和sdshdr結構,而embstr編碼則通過調用一次內存分配函數來分配一塊連續的空間,空間中依次包含redisObject和sdshdr兩個結構。
    在這裏插入圖片描述
  1. embstr編碼的字符串對象來保存短字符串值的好處?
  • 1) embstr編碼將創建字符串對象所需的內存分配次數從raw編碼的兩次降低爲一次。
  • 2)釋放embstr編碼的字符串對象只需要調用一次內存釋放函數,而釋放raw編碼的字符串對象需要調用兩次內存釋放函數。
    3) 因爲embstr編碼的字符串對象的所有數據都保存在一塊連續的內存裏面,所以這種編碼的字符串對象比起raw編碼的字符串對象能夠更好地利用緩存帶來的優勢。
  1. 保存long double類型表示的浮點數
  • 可以用long double類型表示的浮點數在Redis中也是作爲字符串值來保存的。
  • 如果我們要保存一個浮點數到字符串對象裏面,那麼程序會先將這個浮點數轉換成字符串值,然後再保存轉換所得的字符串值。
  1. 總結並列出了字符串對象保存各種不同類型的值所使用的編碼方式。
    在這裏插入圖片描述

  2. 編碼轉換?

  • int和embstr編碼的字符串對象在條件滿足的情況下,會被轉換爲raw編碼的字符串對象。
  • int變爲raw
    如對於int編碼的字符串對象來說,如果我們向對象執行了一些命令,使得這個對象保存的不再是整數值,而是一個字符串值,那麼字符串對象的編碼將從int變爲raw。
  • embstr轉換成raw
    因爲Redis沒有爲embstr編碼的字符串對象編寫任何相應的修改程序(只有int編碼的字符串對象和raw編碼的字符串對象有這些程序),所以embstr編碼的字符串對象實際上是隻讀的。當我們對embstr編碼的字符串對象執行任何修改命令時,程序會先將對象的編碼從embstr轉換成raw,然後再執行修改命令。因爲這個原因,embstr編碼的字符串對象在執行修改命令之後,總會變成一個raw編碼的字符串對象。
2 list
  1. 列表對象的編碼?
  • 列表對象的編碼可以是ziplist或者linkedlist。
  • 1)ziplist
    ziplist編碼的列表對象使用壓縮列表作爲底層實現,每個壓縮列表節點(entry)保存了一個列表元素。
    在這裏插入圖片描述

  • linkedlist
    linkedlist編碼的列表對象使用雙端鏈表作爲底層實現,每個雙端鏈表節點(node)都保存了一個字符串對象,而每個字符串對象都保存了一個列表元素。
    在這裏插入圖片描述

  1. 對象嵌套?
  • 注意,linkedlist編碼的列表對象在底層的雙端鏈表結構中包含了多個字符串對象,這種嵌套字符串對象的行爲在稍後介紹的哈希對象、集合對象和有序集合對象中都會出現,字符串對象是Redis五種類型的對象中唯一一種會被其他四種類型對象嵌套的對象。
  1. 編碼轉換?
  • 使用ziplist編碼
    當列表對象可以同時滿足以下兩個條件時,列表對象使用ziplist編碼:
    1)列表對象保存的所有字符串元素的長度都小於64字節;
    2)列表對象保存的元素數量小於512個;不能滿足這兩個條件的列表對象需要使用linkedlist編碼。
    以上兩個條件的上限值是可以修改的,具體請看配置文件中關於list-max-ziplist-value選項和list-max-ziplist-entries選項的說明。



  • 使用linkedlist編碼
    對於使用ziplist編碼的列表對象來說,當使用ziplist編碼所需的兩個條件的任意一個不能被滿足時,對象的編碼轉換操作就會被執行,原本保存在壓縮列表裏的所有列表元素都會被轉移並保存到雙端鏈表裏面,對象的編碼也會從ziplist變爲linkedlist。
3 hash
  1. 哈希對象的編碼?
  • 哈希對象的編碼可以是ziplist或者hashtable。
  • ziplist編碼
    ziplist編碼的哈希對象使用壓縮列表作爲底層實現,每當有新的鍵值對要加入到哈希對象時,程序會先將保存了鍵的壓縮列表節點推入到壓縮列表表尾,然後再將保存了值的壓縮列表節點推入到壓縮列表表尾,因此:
    ·保存了同一鍵值對的兩個節點總是緊挨在一起,保存鍵的節點在前,保存值的節點在後;
    ·先添加到哈希對象中的鍵值對會被放在壓縮列表的表頭方向,而後來添加到哈希對象中的鍵值對會被放在壓縮列表的表尾方向。
    在這裏插入圖片描述



  • hashtable編碼
    hashtable編碼的哈希對象使用字典作爲底層實現,哈希對象中的每個鍵值對都使用一個字典鍵值對來保存:
    ·字典的每個鍵都是一個字符串對象,對象中保存了鍵值對的鍵;
    ·字典的每個值都是一個字符串對象,對象中保存了鍵值對的值。
    在這裏插入圖片描述



  1. 編碼轉換?
  • 當哈希對象可以同時滿足以下兩個條件時,哈希對象使用ziplist編碼:
    ·哈希對象保存的所有鍵值對的鍵和值的字符串長度都小於64字節;
    ·哈希對象保存的鍵值對數量小於512個;不能滿足這兩個條件的哈希對象需要使用hashtable編碼。

  • 對於使用ziplist編碼的列表對象來說,當使用ziplist編碼所需的兩個條件的任意一個不能被滿足時,對象的編碼轉換操作就會被執行,原本保存在壓縮列表裏的所有鍵值對都會被轉移並保存到字典裏面,對象的編碼也會從ziplist變爲hashtable。
4 set
  1. 集合對象的編碼
    集合對象的編碼可以是intset或者hashtable。
  • intset編碼
    intset編碼的集合對象使用整數集合作爲底層實現,集合對象包含的所有元素都被保存在整數集合裏面。
    在這裏插入圖片描述

  • hashtable編碼
    hashtable編碼的集合對象使用字典作爲底層實現,字典的每個鍵都是一個字符串對象,每個字符串對象包含了一個集合元素,而字典的值則全部被設置爲NULL。
    在這裏插入圖片描述

  1. 編碼轉換?
  • 當集合對象可以同時滿足以下兩個條件時,對象使用intset編碼:
    ·集合對象保存的所有元素都是整數值;
    ·集合對象保存的元素數量不超過512個。
    不能滿足這兩個條件的集合對象需要使用hashtable編碼。


  • 對於使用intset編碼的集合對象來說,當使用intset編碼所需的兩個條件的任意一個不能被滿足時,就會執行對象的編碼轉換操作,原本保存在整數集合中的所有元素都會被轉移並保存到字典裏面,並且對象的編碼也會從intset變爲hashtable。
5 zset
  1. 有序集合的編碼
  • 有序集合的編碼可以是ziplist或者skiplist。
  • ziplist編碼
    ziplist編碼的壓縮列表對象使用壓縮列表作爲底層實現,每個集合元素使用兩個緊挨在一起的壓縮列表節點來保存,第一個節點保存元素的成員(member),而第二個元素則保存元素的分值(score)。
    壓縮列表內的集合元素按分值從小到大進行排序,分值較小的元素被放置在靠近表頭的方向,而分值較大的元素則被放置在靠近表尾的方向。
    在這裏插入圖片描述
    在這裏插入圖片描述



  • skiplist編碼
    skiplist編碼的有序集合對象使用zset結構作爲底層實現,一個zset結構同時包含一個字典和一個跳躍表:
    typedef struct zset {
        zskiplist *zsl;
        dict *dict;
    } zset;
    

在這裏插入圖片描述
有序集合元素同時被保存在字典和跳躍表中
有序集合元素同時被保存在字典和跳躍表中(上圖)
爲了展示方便,圖在字典和跳躍表中重複展示了各個元素的成員和分值,但在實際中,字典和跳躍表會共享元素的成員和分值,所以並不會造成任何數據重複,也不會因此而浪費任何內存。


  • zset結構中的zsl跳躍表按分值從小到大保存了所有集合元素,每個跳躍表節點都保存了一個集合元素:跳躍表節點的object屬性保存了元素的成員,而跳躍表節點的score屬性則保存了元素的分值。通過這個跳躍表,程序可以對有序集合進行範圍型操作,比如ZRANK、ZRANGE等命令就是基於跳躍表API來實現的。
    除此之外,zset結構中的dict字典爲有序集合創建了一個從成員到分值的映射,字典中的每個鍵值對都保存了一個集合元素:字典的鍵保存了元素的成員,而字典的值則保存了元素的分值。通過這個字典,程序可以用O(1)複雜度查找給定成員的分值,ZSCORE命令就是根據這一特性實現的,而很多其他有序集合命令都在實現的內部用到了這一特性。
    有序集合每個元素的成員都是一個字符串對象,而每個元素的分值都是一個double類型的浮點數。值得一提的是,雖然zset結構同時使用跳躍表和字典來保存有序集合元素,但這兩種數據結構都會通過指針來共享相同元素的成員和分值,所以同時使用跳躍表和字典來保存集合元素不會產生任何重複成員或者分值,也不會因此而浪費額外的內存。

  1. 爲什麼有序集合需要同時使用跳躍表和字典來實現?
  • 在理論上,有序集合可以單獨使用字典或者跳躍表的其中一種數據結構來實現,但無論單獨使用字典還是跳躍表,在性能上對比起同時使用字典和跳躍表都會有所降低。舉個例子,如果我們只使用字典來實現有序集合,那麼雖然以O(1)複雜度查找成員的分值這一特性會被保留,但是,因爲字典以無序的方式來保存集合元素,所以每次在執行範圍型操作——比如ZRANK、ZRANGE等命令時,程序都需要對字典保存的所有元素進行排序,完成這種排序需要至少O(NlogN)時間複雜度,以及額外的O(N)內存空間(因爲要創建一個數組來保存排序後的元素)。
  • 另一方面,如果我們只使用跳躍表來實現有序集合,那麼跳躍表執行範圍型操作的所有優點都會被保留,但因爲沒有了字典,所以根據成員查找分值這一操作的複雜度將從O(1)上升爲O(logN)。因爲以上原因,爲了讓有序集合的查找和範圍型操作都儘可能快地執行,Redis選擇了同時使用字典和跳躍表兩種數據結構來實現有序集合。
  1. 編碼轉換?
  • 不能滿足以上兩個條件的有序集合對象將使用skiplist編碼。(可修改條件上限)
    ·有序集合保存的元素數量小於128個;
    ·有序集合保存的所有元素成員的長度都小於64字節;

  • 對於使用ziplist編碼的有序集合對象來說,當使用ziplist編碼所需的兩個條件中的任意一個不能被滿足時,就會執行對象的編碼轉換操作,原本保存在壓縮列表裏的所有集合元素都會被轉移並保存到zset結構裏面,對象的編碼也會從ziplist變爲skiplist。

2-應用場景

1. string

  1. string類型應用場景?
  • 1)緩存功能
    比較典型的緩存使用場景,如圖
    在這裏插入圖片描述
    – 其中,Redis作爲緩存層,MySQL作爲存儲層,絕大部分請求的數據都是從Redis中獲取。由於Redis具有支撐高併發的特性,所以緩存通常能起到加速讀寫和降低後端壓力的作用。
    //1)該函數用於獲取用戶的基礎信息:
        UserInfo getUserInfo(long id){
         
                
            ...
        }
    //2)首先從Redis獲取用戶信息:
        // 定義鍵
        userRedisKey = "user:info:" + id;
        // 從Redis獲取值
        value = redis.get(userRedisKey);
        if (value != null) {
         
                
            // 將值進行反序列化爲UserInfo並返回結果
            userInfo = deserialize(value);
            return userInfo;
        }
    //3)如果沒有從Redis獲取到用戶信息,需要從MySQL中進行獲取,並將結果回寫到Redis,添加1小時(3600秒)過期時間:
        // 從MySQL獲取用戶信息
        userInfo = mysql.get(id);
        // 將userInfo序列化,並存入Redis
        redis.setex(userRedisKey, 3600, serialize(userInfo));
        // 返回結果
        return userInfo    
    //總結:整個功能的僞代碼如下:
        UserInfo getUserInfo(long id){
         
                
            userRedisKey = "user:info:" + id
            value = redis.get(userRedisKey);
            UserInfo userInfo;
            if (value != null) {
         
                
                userInfo = deserialize(value);
            } else {
         
                
                userInfo = mysql.get(id);
                if (userInfo != null)
                redis.setex(userRedisKey, 3600, serialize(userInfo));
            }
            return userInfo;
        }
        
    



  • 2)計數
    – Redis作爲計數的基礎工具,可以實現快速計數、查詢緩存的功能,同時數據可以異步落地到其他數據源。
    – 例如筆者所在團隊的視頻播放數系統就是使用Redis作爲視頻播放數計數的基礎組件,用戶每播放一次視頻,相應的視頻播放數就會自增1:
    long incrVideoCounter(long id) {
         
              
        key = "video:playCount:" + id;
        return redis.incr(key);
    }
    
    
    – 應用:實際上一個真實的計數系統要考慮的問題會很多:防作弊、按照不同維度計數,數據持久化到底層數據源等。

  • 3)共享Session
    現象:
    一個分佈式Web服務將用戶的Session信息(例如用戶登錄信息)保存在各自服務器中,這樣會造成一個問題,出於負載均衡的考慮,分佈式服務會將用戶的訪問均衡到不同服務器上,用戶刷新一次訪問可能會發現需要重新登錄,這個問題是用戶無法容忍的。
    解決:
    爲了解決這個問題,可以使用Redis將用戶的Session進行集中管理,如
    圖,在這種模式下只要保證Redis是高可用和擴展性的,每次用戶更新或者查詢登錄信息都直接從Redis中集中獲取。
    在這裏插入圖片描述
    在這裏插入圖片描述






  • 4)限速
    很多應用出於安全的考慮,會在每次進行登錄時,讓用戶輸入手機驗證
    碼,從而確定是否是用戶本人。但是爲了短信接口不被頻繁訪問,會限制用戶每分鐘獲取驗證碼的頻率,例如一分鐘不能超過5次
    (例如一些網站限制一個IP地址不能在一秒鐘之內訪問超過n次也可以採用類似的思路。)
    //僞代碼給出了基本實現思路:
        phoneNum = "138xxxxxxxx";
        key = "shortMsg:limit:" + phoneNum;
        // SET key value EX 60 NX
        isExists = redis.set(key,1,"EX 60","NX");
        if(isExists != null || redis.incr(key) <=5){
         
               
            // 通過
        }else{
         
               
            // 限速
        }
        
    



2. hash

  1. 哈希應用場景?
  • 關係型數據表記錄的兩條用戶信息,用戶的屬性作爲表的列,每條用戶信息作爲行。

    id name age city
    1 tom 23 beijng
    2 mike 30 tianjin
  • 將其用哈希類型存儲
    在這裏插入圖片描述

  • 相比於使用字符串序列化緩存用戶信息,哈希類型變得更加直觀,並且在更新操作上會更加便捷。可以將每個用戶的id定義爲鍵後綴,多對fieldvalue對應每個用戶的屬性,類似如下僞代碼:

    UserInfo getUserInfo(long id){
         
            
        // 用戶id作爲key後綴
        userRedisKey = "user:info:" + id;
        // 使用hgetall獲取所有用戶信息映射關係
        userInfoMap = redis.hgetAll(userRedisKey);
        UserInfo userInfo;
        if (userInfoMap != null) {
         
            
            // 將映射關係轉換爲UserInfo
            userInfo = transferMapToUserInfo(userInfoMap);
        } else {
         
            
            // 從MySQL中獲取用戶信息
            userInfo = mysql.get(id);
            // 將userInfo變爲映射關係使用hmset保存到Redis中
            redis.hmset(userRedisKey, transferUserInfoToMap(userInfo));
            // 添加過期時間
            redis.expire(userRedisKey, 3600);
        }
        return userInfo;
    }
    
    
  • 不同
    但是需要注意的是哈希類型和關係型數據庫有兩點不同之處:
    – 哈希類型是稀疏的,而關係型數據庫是完全結構化的,例如哈希類型
    每個鍵可以有不同的field,而關係型數據庫一旦添加新的列,所有行都要爲其設置值(即使爲NULL)
    – 關係型數據庫可以做複雜的關係查詢,而Redis去模擬關係型複雜查詢開發困難,維護成本高。



  1. 三種緩存方法-方案的實現方法和優缺點分析。
    緩存用戶信息:
    1)原生字符串類型:每個屬性一個鍵。
    set user:1:name tom
    set user:1:age 23
    set user:1:city beijing
    


優點:簡單直觀,每個屬性都支持更新操作。
缺點:佔用過多的鍵,內存佔用量較大,同時用戶信息內聚性比較差,
所以此種方案一般不會在生產環境使用。
2)序列化字符串類型:將用戶信息序列化後用一個鍵保存。
set user:1 serialize(userInfo)
優點:簡化編程,如果合理的使用序列化可以提高內存的使用效率。
缺點:序列化和反序列化有一定的開銷,同時每次更新屬性都需要把全
部數據取出進行反序列化,更新後再序列化到Redis中。
3)哈希類型:每個用戶屬性使用一對field-value,但是隻用一個鍵保
存。
hmset user:1 name tomage 23 city beijing
優點:簡單直觀,如果使用合理可以減少內存空間的使用。
缺點:要控制哈希在ziplist和hashtable兩種內部編碼的轉換,hashtable會消耗更多內存。











3. list

  1. list列表的使用場景?

  2. 消息隊列
    lpush+brpop命令組合即可實現阻塞隊列,生產者客戶端使用lrpush從列表左側插入元素,多個消費者客戶端使用brpop命令阻塞式的“搶”列表尾部的元素,多個客戶端保證了消費的負載均衡和高可用性。
    在這裏插入圖片描述

  3. 文章列表
    每個用戶有屬於自己的文章列表,現需要分頁展示文章列表。此時可以
    考慮使用列表,因爲列表不但是有序的,同時支持按照索引範圍獲取元素。

  • 1|每篇文章使用哈希結構存儲,例如每篇文章有3個屬性title、timestamp、content:
    hmset acticle:1 title xx timestamp 1476536196 content xxxx
    ...
    hmset acticle:k title yy timestamp 1476512536 content yyyy
    ...
    
  • 2|向用戶文章列表添加文章,user:{id}:articles作爲用戶文章列表的鍵(key):
    lpush user:1:acticles article:1 article3
    ...
    lpush user:k:acticles article:5
    ...
    
    
  • 3|分頁獲取用戶文章列表,例如下面僞代碼獲取用戶id=1的前10篇文
    章:
    articles = lrange user:1:articles 0 9
    for article in {articles}
        hgetall {article}
    

  • 使用列表類型保存和獲取文章列表會存在兩個問題。
    第一,如果每次分頁獲取的文章個數較多,需要執行多次hgetall操作,此時可以考慮使用Pipeline批量獲取,或者考慮將文章數據序列化爲字符串類型,使用mget批量獲取。
    第二,分頁獲取文章列表時,lrange命令在列表兩端性能較好,但是如果列表較大,獲取列表中間範圍的元素性能會變差,此時可以考慮將列表做二級拆分,或者使用Redis3.2的quicklist內部編碼實現,它結合ziplist和linkedlist的特點,獲取列表中間範圍的元素時也可以高效完成。

  1. 擴展?
    實際上列表的使用場景很多,在選擇時可以參考以下口訣:
  • lpush+lpop=Stack(棧)
  • lpush+rpop=Queue(隊列)
  • lpsh+ltrim=Capped Collection(有限集合)
  • lpush+brpop=Message Queue(消息隊列)

4.set

  1. 標籤?
  • 集合類型比較典型的使用場景是標籤(tag)。
  • 例如一個用戶可能對娛樂、體育比較感興趣,另一個用戶可能對歷史、新聞比較感興趣,這些興趣點就是標籤。有了這些數據就可以得到喜歡同一個標籤的人,以及用戶的共同喜好的標籤,這些數據對於用戶體驗以及增強用戶黏度比較重要。
  • 例如一個電子商務的網站會對不同標籤的用戶做不同類型的推薦,比如對數碼產品比較感興趣的人,在各個頁面或者通過郵件的形式給他們推薦最新的數碼產品,通常會爲網站帶來更多的利益。
  1. 實現標籤功能?
    下面使用集合類型實現標籤功能的若干功能。
  • 1|給用戶添加標籤
    sadd user:1:tags tag1 tag2 tag5
    sadd user:2:tags tag2 tag3 tag5
    ...
    sadd user:k:tags tag1 tag2 tag4
    ...
    
  • 2|給標籤添加用戶
    sadd tag1:users user:1 user:3
    sadd tag2:users user:1 user:2 user:3
    ...
    sadd tagk:users user:1 user:2
    ...
    
  1. 開發注意問題:開發提示1:
    用戶和標籤的關係維護應該在一個事務內執行,防止部分命令失敗造成的數據不一致,有關如何將兩個命令放在一個事務,參考事務以及Lua的使用方法。
  • 3|刪除用戶下的標籤
    srem user:1:tags tag1 tag5
    ...
    
  • 4|刪除標籤下的用戶
    srem tag1:users user:1
    srem tag5:users user:1
    ...
    
  • 3|和4|也是儘量放在一個事務執行。
  • 5|計算用戶共同感興趣的標籤可以使用sinter命令,來計算用戶共同感興趣的標籤,如下代碼所示:
    sinter user:1:tags user:2:tags
    
  1. 開發注意問題:開發提示2:
    前面只是給出了使用Redis集合類型實現標籤的基本思路,實際上一個
    標籤系統遠比這個要複雜得多,不過集合類型的應用場景通常爲以下幾種:

  • sadd=Tagging(標籤)
  • spop/srandmember=Random item(生成隨機數,比如抽獎)
  • sadd+sinter=Social Graph(社交需求)

5-zset

  1. zset的應用場景?
  • 有序集合比較典型的使用場景就是排行榜系統。
  • 例如視頻網站需要對用戶上傳的視頻做排行榜,榜單的維度可能是多個方面的:按照時間、按照播放數量、按照獲得的贊數。
  1. 使用贊數這個維度,記錄每天用戶上傳視頻的排行榜。
    主要需要實現以下4個功能:
    (1)添加用戶贊數
    例如用戶mike上傳了一個視頻,並獲得了3個贊,可以使用有序集合的zadd和zincrby功能:
    zadd user:ranking:2016_03_15 mike 3
    



如果之後再獲得一個贊,可以使用zincrby:
zincrby user:ranking:2016_03_15 mike 1
(2)取消用戶贊數
由於各種原因(例如用戶註銷、用戶作弊)需要將用戶刪除,此時需要
將用戶從榜單中刪除掉,可以使用zrem。
例如刪除成員tom:
zrem user:ranking:2016_03_15 mike
(3)展示獲取贊數最多的十個用戶
此功能使用zrevrange命令實現:
zrevrangebyrank user:ranking:2016_03_15 0 9
(4)展示用戶信息以及用戶分數
此功能將用戶名作爲鍵後綴,將用戶信息保存在哈希類型中,至於用戶
的分數和排名可以使用zscore和zrank兩個功能:
hgetall user:info:tom zscore user:ranking:2016_03_15 mike zrank user:ranking:2016_03_15 mike















發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章