《redis設計與實現》1-數據結構與對象篇

前言

  • redis性能爲何這麼出色?它與其餘緩存中間件有什麼區別?
  • redis底層使用了哪些數據結構支撐它如此高效的性能?
  • 內部豐富的數據類型底層爲何都使用至少兩種數據結構實現?分別是什麼?
  • 若是合理的使用redis才能發揮它最大的優點?

學習完《redis設計與實現》前面關於數據結構與對象的章節,以上問題都能獲得解答。你也能瞭解到redis做者如此的煞費苦心設計了這麼多豐富的數據結構,目的就是優化內存。學完這些內容,在使用redis的過程當中,也會合理的使用以適應它內部的特色。固然新版本的redis支持了更多更豐富的特性,該書基於redis3版本,尚未涉及到那些內容。ios

《redis設計與實現》這本書很是淺顯易懂,做者黃建宏老師,90後。另外仍是《redis實戰》的譯者nginx

另外一篇可參考《redis設計與實現》2-數據庫實現篇redis

概述

特色

  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函數庫的函數

分配和釋放策略

空間預分配

  • 用於優化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*保存節點值,可保存不一樣類型的值

字典

數據結構

位於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;
複製代碼

字典類型

每一個字典類型保存一簇用於操做特定類型鍵值對的函數api

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使用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;
複製代碼

  • 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來表示

壓縮列表的構成

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

壓縮列表節點的構成

  • 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
    • raw
    • embstr
  • 浮點數在redis中也是做爲字符串對象保存,涉及計算時,先轉回浮點數。
字符串對象內容 長度 編碼類型
整數值 - int
字符串值 小於32字節 embstr
字符串值 大於32字節 raw

embstr編碼是專門用於保存短字符串的一種優化編碼方式。這種編碼和raw編碼同樣,都使用redisObject結構和sdshdr結構來表示對象。區別在於:安全

  • raw編碼調用兩次內存分配函數來分別建立redisObject和sdrhdr結構
  • embstr則調用一次內存分配函數來建立一塊連續空間,裏面包括redisObject和sdrhdr

編碼轉換

int編碼和embstr編碼的對象知足條件時會自動轉換爲raw編碼的字符串對象bash

  • int編碼對象,執行命令致使對象再也不是整數時,會轉換爲raw對象
  • embstr編碼沒有相應執行函數,是隻讀編碼。涉及修改時,會轉換爲raw對象

字符串命令

redis中全部鍵都是字符串對象,因此全部對於鍵的命令都是針對字符串鍵來構建的

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

列表對象

  • 列表對象的編碼能夠是
    • ziplist
    • linkedlist

編碼轉換

使用ziplist編碼的兩個條件以下,不知足的都用linkedlist編碼(這兩個條件能夠在配置文件中修改):

  • 保存的全部字符串元素的長度都小於64字節
  • 列表的元素數量小於512個

列表命令

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

哈希對象

哈希對象的編碼能夠是

  • ziplist
  • hashtable

編碼轉換

  • 使用ziplist須要知足兩個條件,不知足則都使用hashtable(這兩個條件能夠在配置文件中修改)
    • 全部鍵值對的鍵和值的字符串長度都小於64字節
    • 鍵值對數量小於512個

哈希命令

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

集合對象

集合對象的編碼能夠是:

  • intset:全部元素保存在整數集合裏
  • hashtale:字典的值爲null

編碼轉換

集合使用intset須要知足兩個條件,不知足時使用hashtable(參數可經過配置文件修改)

  • 保存的全部元素都是整數值
  • 元素數量不超過512個

集合命令

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

有序結合對象

有序集合的編碼能夠是

  • ziplist:每一個元素使用兩個緊挨在一塊兒的節點表示,第一個表示成員,第二個表示分值。分值小的靠近表頭,分值大的靠近表尾
  • skiplist:使用zset做爲底層實現,zset結構同時包含了字典和跳躍表,分別用於根據key查找score和分值排序或範圍查詢
// 兩種數據結構經過指針共享元素成員和分值,不會浪費內存
typedef struct zset {
    zskplist *zsl; //跳躍表,方便zrank,zrange
    dict *dict; //字典,方便zscore
}zset;
複製代碼

編碼轉換

當知足如下兩個條件時,使用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設計與實現》
相關文章
相關標籤/搜索