《redis設計與實現》1-資料結構與物件篇

kinnylee發表於2018-12-16

前言

  • redis效能為什麼這麼出色?它與其他快取中介軟體有什麼區別?
  • redis底層使用了哪些資料結構支撐它如此高效的效能?
  • 內部豐富的資料型別底層為什麼都使用至少兩種資料結構實現?分別是什麼?
  • 如果合理的使用redis才能發揮它最大的優勢?

學習完《redis設計與實現》前面關於資料結構與物件的章節,以上問題都能得到解答。你也能瞭解到redis作者如此的煞費苦心設計了這麼多豐富的資料結構,目的就是優化記憶體。學完這些內容,在使用redis的過程中,也會合理的使用以適應它內部的特點。當然新版本的redis支援了更多更豐富的特性,該書基於redis3版本,還沒有涉及到那些內容。

《redis設計與實現》這本書非常淺顯易懂,作者黃建巨集老師,90後。另外還是《redis實戰》的譯者

另一篇可參考《redis設計與實現》2-資料庫實現篇

概述

特點

  1. c語言開發,效能出色,純記憶體操作,每秒可處理超過10w讀寫(QPS)
  2. 多種資料結構,單個最大限制可到1GB(memcached只支援字串,最大1M)
  3. 受實體記憶體限制,不能作海量資料的讀寫。適用於較小資料量的高效能操作和運算上
  4. 支援事務,持久化
  5. 單執行緒模型(memcached是多執行緒)

支援的資料型別

  1. Sring
  2. List
  3. Set
  4. SortedSet
  5. hash
  6. Bitmap
  7. Hyperloglogs
  8. Geo
  9. pub/sub

redis為什麼這麼快

  1. 純記憶體操作,沒有磁碟io
  2. 單執行緒處理請求,沒有執行緒切換開銷和競爭條件,也不存在加鎖問題
  3. 多路複用模型epoll,非阻塞io(多路:多個網路連線;複用:複用同一個執行緒) 多路複用技術可以讓單個執行緒高效的處理多個連線請求
  4. 資料結構簡單,對資料操作也簡單。還做了自己的資料結構優化

redis為什麼是單執行緒的

  1. 單執行緒已經很快了,減少多執行緒帶來的網路開銷,鎖操作
  2. 後續的4.0版本在考慮多執行緒
  3. 單執行緒是指處理網路請求的時候只有一個執行緒,並不是redis-server只有一個執行緒在工作。持久化的時候,就是通過fork一個子執行緒來執行。
  4. 缺點:耗時的命令會導致併發的下降,比如keys *

redis的回收策略

  1. volatile-lru:從過期的資料集 server.db[i].expires中挑選最近最少使用的資料
  2. volatile-ttl:從過期的資料集 server.db[i].expires中挑選將要過期的資料淘汰
  3. volatile-random: server.db[i].expires中挑選任意資料淘汰
  4. allkeys-lru: 從資料集(server.db[i].dict)中挑選最近最少使用的資料淘汰
  5. allkeys-random:從資料集(server.db[i].dict)中任意選擇資料淘汰
  6. no-enviction(驅逐):禁止驅逐資料

使用注意

  1. redis單執行緒無法發揮多核cpu效能,可以通過單機開多個redis例項來完善
  2. redis實現分散式鎖:先用setnx(如果不存在才設定)爭搶鎖,搶到後,expire設定過期時間,防止忘記釋放。
  3. redis實現一對多訊息訂閱:sub/pub資料結構
  4. redis實現延時訊息佇列:zadd時間戳作為score 消費的時候根據時間戳+延時時間做查詢操作。

各大版本介紹

redis5版本新增功能:

  • zpopmax zpopmin以及阻塞變種:返回集合中給定分值最大最小的資料數量

reids4版本新增功能:

  • 模組功能,提供類似於外掛的方式,自己開發一個.so模組,並加裝 作者本人提供了一個神經網路的module。 可到redis-modules-hub上檢視更多的module 模組功能使得使用者可以將 Redis 用作基礎設施, 並在上面構建更多功能, 這給 Redis 帶來了無數新的可能性。
  • PSYNC:解決了舊版本的 Redis 在複製時的一些不夠優化的地方
  • 快取清理策略優化 新增last frequently used 對已有策略進行優化
  • 非阻塞DEL FLUSHDB FLUSHALL 解決了之前執行這些命令的時候導致阻塞的問題 Flushdb async, flushall async, unlink(替代del)
  • 新增了swapdb:交換資料庫
  • 混合RDB-AOF的持久化格式
  • 新增記憶體使用情況命令:MEMORY

資料結構

  • redis裡面每個鍵值對都是由物件組成的
  • 鍵總是一個字串物件,
  • 值則可以是以下物件的一種:
    • 字串物件
    • 列表物件
    • 雜湊物件
    • 集合物件
    • 有序結合物件

簡單動態字串SDS

資料結構

struct sdshdr {
    uint8_t len; /* used,使用的位元組數 */
    uint8_t alloc; /* excluding the header and null terminator,預分配總位元組數,不包括結束符\0的長度 */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[]; /*c風格的字元,包括結束符\0*/
};
複製程式碼
  • 位於sds.h檔案
  • SDS遵循C字串以\0結尾的慣例,儲存在buf中(不同於nginx的底層實現,nginx實現時不儲存最後一個\0)
  • 但是不計算最後一個字元的長度到len中
  • 保留c風格buf的好處是可以重用一部分c函式庫的函式
    《redis設計與實現》1-資料結構與物件篇

分配和釋放策略

空間預分配

  • 用於優化SDS字串增長操作,以減少連續執行增長操作所需的記憶體重分配次數
  • 擴充套件SDS空間時,先檢查未使用的空間是否足夠,如果足夠直接使用,如果不夠,不僅分配夠用,還預分配一些空間
  • 預分配策略:
    • 修改後的SDS長度(len的值)< 1MB,預分配同樣len大小的空間
    • 修改後的SDS長度(len的值)>= 1MB,預分配1MB大小的空間

惰性空間釋放

  • 用於優化SDS字元縮短操作
  • 縮短SDS空間時,並不立即進行記憶體重分配釋放空間,而是記錄free的位元組數
  • SDS提供相應api,有需要時真正釋放空間

比C字串的優勢

  • 獲取字串的長度時間複雜度由O(N)降到O(1)
  • 避免緩衝區溢位
  • 減少修改字串時帶來的記憶體重分配次數。記憶體分配會涉及複雜演算法,且可能需要系統呼叫,非常耗時。
  • 二進位制安全:c語言的結束符限制了它只能儲存文字資料,不能儲存圖片,音訊等二進位制資料

連結串列

資料結構

位於adlist.h檔案

typedef struct listNode {
    struct listNode *prev; // 前置節點
    struct listNode *next; // 後置節點
    void *value;//節點值
} listNode;

typedef struct list {
    listNode *head; // 表頭節點
    listNode *tail; // 表尾節點
    void *(*dup)(void *ptr); // 節點值複製函式
    void (*free)(void *ptr); // 節點值釋放函式
    int (*match)(void *ptr, void *key); // 節點值對比函式
    unsigned long len; // 節點數量
} list;
複製程式碼

特點

  • 雙端佇列,可以獲取某個節點前置節點和後置節點,複雜度為O(1)
  • 無環
  • 獲取表頭和表尾複雜度為O(1)
  • 帶長度,獲取連結串列長度複雜度為O(1)
  • 多型:使用void*儲存節點值,可儲存不同型別的值
    《redis設計與實現》1-資料結構與物件篇

字典

資料結構

位於dict.h檔案

雜湊表

// 雜湊表
typedef struct dictht {
    dictEntry **table; // 一個陣列,陣列中每個元素都是指向dictEntry結構的指標
    unsigned long size; // table陣列的大小
    unsigned long sizemask; // 值總數size-1
    unsigned long used; // 雜湊表目前已有節點(鍵值對)的數量
} dictht;
複製程式碼

雜湊節點

// 每個dictEntry都儲存著一個鍵值對,表示雜湊表節點
typedef struct dictEntry {
    void *key; // 鍵值對的鍵
    // 鍵值對的值,可以是指標,整形,浮點型
    union { 
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // 雜湊表節點指標,用於解決鍵衝突問題
} dictEntry;
複製程式碼

《redis設計與實現》1-資料結構與物件篇

字典型別

每個字典型別儲存一簇用於操作特定型別鍵值對的函式

typedef struct dictType {
    // 計算雜湊值的函式
    uint64_t (*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;
複製程式碼

字典

// 字典
typedef struct dict {
    dictType *type; // 不同鍵值對型別對應的操作函式
    void *privdata; // 需要傳遞給對應函式的引數
    dictht ht[2]; // ht[0]用於存放資料,ht[1]在進行rehash時使用
    long rehashidx; /* rehashing not in progress if rehashidx == -1,目前rehash的進度*/
    unsigned long iterators; /* number of iterators currently running */
} dict;
複製程式碼

《redis設計與實現》1-資料結構與物件篇

雜湊演算法

  • redis使用MurmurHash2演算法計算鍵的hash值
  • 雜湊值與sizemask取或,得到雜湊索引
  • 雜湊衝突(兩個或以上數量鍵被分配到雜湊表陣列同一個索引上):鏈地址法解決衝突

rehash

  • 對雜湊表進行擴充套件或收縮,以使雜湊表的負載因子維持在一個合理範圍之內
  • 負載因子 = 儲存的節點數(used)/ 雜湊表大小(size)

rehash步驟包括

  • 為字典的ht[1]雜湊表分配空間,大小取決於要執行的操作以及ht[0]當前包含的鍵值對數量
    • 擴充套件操作:ht[1]大小為第一個大於等於ht[0].used乘以2的2的n次冪
    • 收縮操作:ht[1]大小為第一個大於等於ht[0].used的2的n次冪
  • 將儲存在ht[0]的所有鍵值對rehash到ht[1]上面:重新計算鍵的雜湊值和索引值
  • 當所有ht[0]的鍵值對都遷移到ht[1]之後,釋放ht[0],將ht[1]置為ht[0],並新建一個恐怖hash作為ht[1]

自動擴充套件的條件

  • 伺服器沒有執行BGSave命令或GBRewriteAOF命令,並且雜湊表的負載因子 >= 1
  • 伺服器正在執行BGSave命令或GBRewriteAOF命令,並且雜湊表的負載因子 >= 5
  • BGSave命令或GBRewriteAOF命令時,伺服器需要建立當前伺服器程式的子程式,會耗費記憶體,提高負載因子避免寫入,節約記憶體

自動收縮的條件

  • 雜湊表負載因子小於0.1時,自動收縮

漸進式rehash

  • ht[0]資料重新索引到ht[1]不是一次性集中完成的,而是多次漸進式完成(避免hash表過大時導致效能問題)

漸進式rehash詳細步驟

  • 為ht[1]分配空間,讓自動同時持有兩個雜湊表
  • 字典中rehashidx置為0,表示開始執行rehash(預設值為-1)
  • rehash期間,每次對字典執行操作時,順帶將ht[0]雜湊表在rehashidx索引上的所有鍵值對rehash到ht[1]
  • 全部rehash完畢時,rehashidx設為-1

注意點

  • rehash的所有操作會在兩個雜湊表進行
  • 新增加的值一律放入ht[1],保證資料只會減少不會增加

跳躍表

  • 跳躍表是一種有序資料結構,通過在每個節點維持多個指向其他節點的指標,達到快速訪問節點的目的
  • 時間複雜度:最壞O(N),平均O(logN)
  • 大部分情況下,效率可與平衡樹媲美,不過比平衡樹實現簡單
  • 有序集合的底層實現之一

資料結構

位於server.h檔案中

// 跳躍表節點
typedef struct zskiplistNode {
    sds ele; // 成員物件
    double score; // 分值,從小到大排序
    struct zskiplistNode *backward; // 後退指標,從表尾向表頭遍歷時使用
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前進指標
        unsigned long span; // 跨度,記錄兩個節點之間的距離
    } level[]; // 層,是一個陣列
} zskiplistNode;

// 跳躍表相關資訊
typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 表頭和表尾
    unsigned long length; // 跳躍表長度(包含節點的數量)
    int level; // 跳躍表內層數最大那個節點的層數(不包括表頭節點層數)
} zskiplist;
複製程式碼

《redis設計與實現》1-資料結構與物件篇

  • level陣列的大小在每次新建跳躍表的時候,隨機生成,大小介於1-32直接
  • 遍歷操作只使用前進指標,跨度用來計算排位(rank),沿途訪問的所有層跨度加起來就是節點的排位
  • 多個節點可以包含相同的分支,但每個節點成員物件是唯一的

整數集合

  • intset是集合鍵的底層實現之一
  • 當一個集合只包含整數值原素,且數量不多時,會使用整數集合作為底層實現

資料結構

位於intset.h檔案

typedef struct intset {
    uint32_t encoding; // 編碼方式
    uint32_t length; // 長度
    int8_t contents[]; // 內容,陣列內容型別取決於encoding屬性,並不是int8_t。按照大小排序,沒有重複
} intset;
複製程式碼

升級

  • 當我們要將一個新元素新增到整數集合裡,並且新元素的型別比整數集合現有所有的元素型別都要長時,集合要先進行升級才能新增新資料
  • 升級步驟包括三步:
    • 根據型別,擴充套件大小,分配空間
    • 將底層陣列資料都轉換成新的型別,並反倒正確位置
    • 新元素新增到底層陣列裡面
  • 新增元素可能導致升級,所以新增新元素的世界複雜度為O(N)
  • 不支援降級,升級後將一直保持新的資料型別

升級的好處

  • 提高靈活性
  • 節約記憶體

壓縮列表

  • ziplist是列表鍵和雜湊鍵的底層實現之一
  • redis為了節約記憶體而開發的順序型資料結構
  • 當列表鍵只包含少量列表項,且每個列表項要麼是小整數,要麼是短字串,就使用ziplist作為列表鍵底層實現
  • 壓縮列表遍歷時,從表位向表頭回溯遍歷
  • ziplist沒有專門的struct來表示

壓縮列表的構成

《redis設計與實現》1-資料結構與物件篇

屬性 型別 長度 用途
zlbytes uint32_t 4位元組 整個壓縮列表佔用的記憶體位元組數
zltail uint32_t 4位元組 表尾節點距離壓縮列表起始地址有多少位元組,無需遍歷就可得到表尾節點
zllen uint16_t 2位元組 節點數量,小於65535時是實際值,超過時需要遍歷才能算出
entryN 列表節點 不定 包含的各個節點
zlend uint8_t 1位元組 特殊值0xFF,末端標記

壓縮列表節點的構成

《redis設計與實現》1-資料結構與物件篇

  • previos_entry_length:前一個節點的長度,用於從表尾向表頭回溯用
    • 如果前面節點長度小於254位元組,preivos_entry_length用1位元組表示
    • 如果前面節點長度小於254位元組,preivos_entry_length用5位元組表示,第1個位元組為0xFE(254),後面四個位元組表示實際長度
  • encoding:記錄content的型別以及長度,encoding分為兩部分,高兩位和餘下的位數,最高兩位的取值有以下情況:
    最高兩位取值 表示是資料型別 encoding位元組數 餘下的bit數 最大範圍
    00 字元陣列 一個位元組 6bit 63位
    01 字元陣列 兩個位元組 14bit 2^14-1
    10 字元陣列 五個位元組 4*8,第一個位元組餘下的6bit留空 2^32-1位
    11 整數 1個位元組 000000 int16_t型別整數
    11 整數 1個位元組 010000 int32_t型別整數
    11 整數 1個位元組 100000 int64_t型別整數
    11 整數 1個位元組 110000 24位有符號整數
    11 整數 1個位元組 111110 8位有符號整數
    11 整數 1個位元組 xxxxxx 沒有content,xxxx本身就表示了0-12的整數
  • content:儲存節點的值

連鎖更新

  • 連續多個節點大小介於254左右的節點,因擴充套件導致連續記憶體分配的情況。不過在時間情況下,這種情況比較少。

物件

概述

  • redis並沒有直接使用前面的資料結構來實現鍵值對的資料庫,而是基於資料結構建立了一個物件系統,每種物件都用到前面至少一種資料結構
  • 每個物件都由一個redisObject結構來表示
//server.h
typedef struct redisObject {
   unsigned type:4; //型別
   unsigned encoding:4; // 編碼
   // 物件最後一個被命令程式訪問的時間
   unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                           * LFU data (least significant 8 bits frequency
                           * and most significant 16 bits access time). */
   int refcount; // 引用計數
   void *ptr; // 指向底層的資料結構指標
} robj;
複製程式碼

使用物件的好處

  • 在執行命令之前,根據物件型別判斷一個物件是否可以執行給定的命令
  • 針對不同廠家,Wie物件設定多種不同的資料結構實現,從而優化效率
  • 實現了基於引用計數的記憶體回收機制,不再使用的物件,記憶體會自動釋放
  • 引用計數實現物件共享機制,多個資料庫共享同一個物件以節約記憶體
  • 物件帶有時間時間積累資訊,用於計算空轉時間

redis中的物件

  • 字串物件
  • 列表物件
  • 雜湊物件
  • 集合物件
  • 有序結合物件

物件的型別與編碼

物件的型別

物件 物件type屬性 type命令的輸出
字串物件 REDIS_STRING string
列表物件 REDIS_LIST list
雜湊物件 REDIS_HASH hash
集合物件 REDIS_SET set
有序集合物件 REDIS_ZSET zset

物件的編碼

  • 編碼決定了ptr指向的資料型別,表明使用什麼資料型別作為底層實現
  • 每種型別物件至少使用兩種不同的編碼
  • 通過編碼,redis可以根據不同場景設定不同編碼,極大提高靈活性和效率
編碼常量 對應的資料結構 OBJECT ENCODING命令輸出
REDIS_ENCODING_INT long型別的整數 “int”
REDIS_ENCODING_EMBSTR embstr編碼的簡單動態字串 “embstr”
REDIS_ENCODING_RAW 簡單動態字串 “raw”
REDIS_ENCODING_HT 字典 “hashtable”
REDIS_ENCODING_LINKEDLIST 雙端連結串列 “linkedlist”
REDIS_ENCODING_ZIPLIST 壓縮列表 “ziplist”
REDIS_ENCODING_INTSET 整數集合 “intset”
REDIS_ENCODING_SKIPLIST 跳躍表和字典 “skiplist”

字串物件

  • 字串物件的編碼可以是
    • int
      《redis設計與實現》1-資料結構與物件篇
    • raw
      《redis設計與實現》1-資料結構與物件篇
    • embstr
      《redis設計與實現》1-資料結構與物件篇
  • 浮點數在redis中也是作為字串物件儲存,涉及計算時,先轉回浮點數。
字串物件內容 長度 編碼型別
整數值 - int
字串值 小於32位元組 embstr
字串值 大於32位元組 raw

embstr編碼是專門用於儲存短字串的一種優化編碼方式。這種編碼和raw編碼一樣,都使用redisObject結構和sdshdr結構來表示物件。區別在於:

  • raw編碼呼叫兩次記憶體分配函式來分別建立redisObject和sdrhdr結構
  • embstr則呼叫一次記憶體分配函式來建立一塊連續空間,裡面包括redisObject和sdrhdr

編碼轉換

int編碼和embstr編碼的物件滿足條件時會自動轉換為raw編碼的字串物件

  • int編碼物件,執行命令導致物件不再是整數時,會轉換為raw物件
  • embstr編碼沒有相應執行函式,是隻讀編碼。涉及修改時,會轉換為raw物件

字串命令

redis中所有鍵都是字串物件,所以所有對於鍵的命令都是針對字串鍵來構建的

  • set
  • get
  • append
  • incrbyfloat
  • incrby
  • decrby
  • strlen
  • strrange
  • getrange

列表物件

  • 列表物件的編碼可以是
    • ziplist
      《redis設計與實現》1-資料結構與物件篇
    • linkedlist
      《redis設計與實現》1-資料結構與物件篇

編碼轉換

使用ziplist編碼的兩個條件如下,不滿足的都用linkedlist編碼(這兩個條件可以在配置檔案中修改):

  • 儲存的所有字串元素的長度都小於64位元組
  • 列表的元素數量小於512個

列表命令

  • lpush
  • rpush
  • lpop
  • rpop
  • lindex
  • llen
  • linsert
  • lrem
  • ltrim
  • lset

雜湊物件

雜湊物件的編碼可以是

  • ziplist
    《redis設計與實現》1-資料結構與物件篇
  • hashtable
    《redis設計與實現》1-資料結構與物件篇

編碼轉換

  • 使用ziplist需要滿足兩個條件,不滿足則都使用hashtable(這兩個條件可以在配置檔案中修改)
    • 所有鍵值對的鍵和值的字串長度都小於64位元組
    • 鍵值對數量小於512個

雜湊命令

  • hset
  • hget
  • hexists
  • hdel
  • hlen
  • hgetall

集合物件

集合物件的編碼可以是:

  • intset:所有元素儲存在整數集合裡
    《redis設計與實現》1-資料結構與物件篇
  • hashtale:字典的值為null
    《redis設計與實現》1-資料結構與物件篇

編碼轉換

集合使用intset需要滿足兩個條件,不滿足時使用hashtable(引數可通過配置檔案修改)

  • 儲存的所有元素都是整數值
  • 元素數量不超過512個

集合命令

  • sadd
  • scard
  • sismember
  • smembers
  • srandmember
  • spop
  • srem

有序結合物件

有序集合的編碼可以是

  • ziplist:每個元素使用兩個緊挨在一起的節點表示,第一個表示成員,第二個表示分值。分值小的靠近表頭,分值大的靠近表尾
    《redis設計與實現》1-資料結構與物件篇
  • skiplist:使用zset作為底層實現,zset結構同時包含了字典和跳躍表,分別用於根據key查詢score和分值排序或範圍查詢
// 兩種資料結構通過指標共享元素成員和分值,不會浪費記憶體
typedef struct zset {
    zskplist *zsl; //跳躍表,方便zrank,zrange
    dict *dict; //字典,方便zscore
}zset;
複製程式碼

《redis設計與實現》1-資料結構與物件篇

編碼轉換

當滿足以下兩個條件時,使用ziplist編碼,否則使用skiplist(可通過配置檔案修改)

  • 儲存的元素數量少於128個
  • 成員長度小於64位元組

有序集合命令

  • zadd
  • zcard
  • zcount
  • zrange
  • zrevrange
  • zrem
  • zscore

型別檢查和命令多型

redis的命令可以分為兩大類:

  • 可以對任意型別的鍵執行,如
    • del
    • expire
    • rename
    • type
    • object
  • 只能對特定型別的鍵執行,比如前面各種物件的命令。是通過redisObject的type屬性實現的

記憶體回收

redis通過物件的refcount屬性記錄物件引用計數資訊,適當的時候自動釋放物件進行記憶體回收

物件共享

  • 包含同樣數值的物件,鍵的值指向同一個物件,以節約記憶體。
  • redis在初始化時,建立一萬個字串物件,包含從0-9999的所有整數值,當需要用到這些值時,伺服器會共享這些物件,而不是新建物件
  • 數量可通過配置檔案修改
  • 目前不包含字串的物件共享,因為要比對字串是否相同本身就會造成效能問題

物件空轉時長

  • 空轉時長=現在時間-redisObject.lru,lru記錄物件最後一次被訪問的時間
  • 當redis配置了最大記憶體(maxmemory)時,回收演算法判斷記憶體超過該值時,空轉時長高的會優先被釋放以回收記憶體

參考命令

# 設定字串
set msg "hello world"
rpush numbers 1 2 3 4 5
llen numbers
lrange numbers 0 5
# 獲取鍵值使用的底層資料結構
object encoding numbers
# 檢視物件的引用計數值
object refcount numbers
# 物件空轉時長: value=now-object.lru
object idletime numbers
複製程式碼

參考文獻

  • 《redis設計與實現》

相關文章