【金三銀四】Redis面試熱點之底層實現篇

0.前言

筆者一直都在瘋狂工做甚至還有些焦慮到偶爾失眠,因爲沒有成塊的時間研究新東西,因此就把以前看過的東西抽時間總結了下。git

計劃分三篇來梳理Redis的相關熱點問題,本次爲開山底層實現篇,經過本文你將瞭解到如下內容:github

  • Redis的做者、發展演進和江湖地位面試

  • Redis面試問題的概況redis

  • Redis底層實現相關的問題包括:算法

    **經常使用數據類型底層實現、SDS的原理和優點、字典的實現原理、跳錶和有序集合的原理、Redis的線程模式和服務模型**
    複製代碼

舒適提示內容並不難,就怕你不看數據庫

看不懂能夠先收藏先Mark,等到深刻研究的時間再翻出來看看,你就發現真是24K乾貨呀!中止吹噓,寫點不同的文字吧!api

1.Redis往事

Redis是一個使用ANSI C編寫的開源、支持網絡、基於內存、可選持久化的高性能鍵值對數據庫。Redis的之父是來自意大利的西西里島的 Salvatore Sanfilippo,Github網名antirez,筆者找了做者的一些簡要信息並翻譯了一下,如圖:數組

從2009年第一個版本起Redis已經走過了10個年頭,目前Redis仍然是最流行的key-value型內存數據庫的之一。緩存

優秀的開源項目離不開大公司的支持,在2013年5月以前,其開發由 VMware贊助,而2013年5月至2015年6月期間,其開發由 畢威拓贊助,從2015年6月開始,Redis的開發由 Redis Labs贊助。安全

筆者也使用過一些其餘的NoSQL,有的支持的value類型很是單一,所以不少操做都必須在客戶端實現,好比value是一個結構化的數據,須要修改其中某個字段就須要總體讀出來修改再總體寫入,顯得很笨重,可是Redis的value支持多種類型,實現了不少操做在服務端就能夠完成了,這個對客戶端而言很是方便。

固然Redis因爲是內存型的數據庫,數據量存儲量有限並且分佈式集羣成本也會很是高,所以有不少公司開發了基於SSD的類Redis系統,好比 360開發的SSDB、Pika等數據庫 ,可是筆者認爲 從0到1的難度是大於從1到2的難度 的,毋庸置疑Redis是NoSQL中濃墨重彩的一筆,值得咱們去深刻研究和使用。

2.Redis的江湖地位

Redis提供了Java、C/C++、C#、 PHP 、JavaScript、 Perl 、Object-C、Python、Ruby、Erlang、Golang等 多種主流語言的客戶端 ,所以不管使用者是什麼語言棧總會找到屬於本身的那款客戶端,受衆很是廣。

筆者查了datanyze.com網站看了下Redis和MySQL的 最新市場份額和排名 對比以及 全球Top站點的部署量 對比(網站數據更新到寫做當日2019.12.11):


能夠看到 Redis整體份額排名第9而且在全球Top100站點中部署數量與MySQL基本持平 ,因此Redis仍是有必定的江湖地位的。

3.聊聊實戰

目前Redis發佈的穩定版本已經到了5.x,功能也愈來愈強大,從國內外互聯網公司來看Redis幾乎是 標配了。做爲開發人員在平常筆試面試和工做中遇到Redis相關問題的機率很是大,掌握Redis的相關知識點都十分有必要。

學習和梳理一個複雜的東西確定不能鬍子眉毛一把抓 ,每一個人都有本身的認知思路,筆者認爲要從充分掌握Redis須要 從底向上、從外到內 去理解Redis。

Redis的實戰知識點能夠簡單分爲 三個層次

  • 底層實現 :主要是從Redis的源碼中提煉的問題,包括但不限於底層數據結構、服務模型、算法設計等。

  • 基礎架構 :可用概況爲Redis總體對外的功能點和表現,包括但不限於單機版主從架構實現、主從數據同步、哨兵機制、集羣實現、分佈式一致性、故障遷移等。

  • 實際應用 :實戰中Redis可用幫你作什麼,包括但不限於單機緩存、分佈式緩存、分佈式鎖、一些應用。


深刻理解和熟練使用Redis須要時間錘鍊,要作到信手拈來着實不易,想在短期內突破只能從熱點題目入手,雖然這樣感受有些功利,不過也算無可厚非吧, 爲了吃飯咱們仍是傾向於原諒懶惰的本身,要否則吃土喝風?

4.底層實現熱點題目

底層實現篇的題目主要是與Redis的源碼和設計相關,能夠說是Redis功能的基石,瞭解底層實現可讓咱們更好地掌握功能,因爲底層代碼不少,在後續的基礎架構篇中仍然會穿插源碼來分析,所以本篇只列舉一些熱點的問題。

Q1:  Redis經常使用五種數據類型是如何實現的?

Redis支持的經常使用5種數據類型指的是value類型,分別爲: 字符串String、列表List、哈希Hash、集合Set、有序集合Zset,可是Redis後續又豐富了幾種數據類型分別是Bitmaps、 HyperLogLogs、GEO。

因爲Redis是基於標準C寫的,只有最基礎的數據類型,所以Redis爲了知足對外使用的5種數據類型,開發了屬於本身 獨有的一套基礎數據結構,使用這些數據結構來實現5種數據類型。

Redis底層的數據結構包括: 簡單動態數組SDS、鏈表、字典、跳躍鏈表、整數集合、壓縮列表、對象。

Redis爲了 平衡空間和時間效率 ,針對value的具體類型在底層 會採用不一樣的數據結構來實現 ,其中哈希表和壓縮列表是複用比較多的數據結構,以下圖展現了對外數據類型和底層數據結構之間的映射關係:


從圖中能夠看到ziplist壓縮列表能夠做爲Zset、Set、List三種數據類型的底層實現,看來很強大,壓縮列表是一種爲了 節約內存而開發的且通過特殊編碼以後的連續內存塊順序型數據結構 ,底層結構仍是比較複雜的。

Q2: Redis的SDS和C中字符串相比有什麼優點?

在C語言中使用N+1長度的字符數組來表示字符串,尾部使用'\0'做爲結尾標誌,對於此種實現 沒法知足Redis對於安全性、效率、豐富的功能的要求,所以Redis單獨封裝了SDS簡單動態字符串結構。

在理解SDS的優點以前須要先看下SDS的 實現細節,找了github 最新的src/sds.h的定義看下:

`typedef char *sds;`

`/*這個用不到 忽略便可*/
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
`

`};`

`/*不一樣長度的header 8 16 32 64共4種 都給出了四個成員
len:當前使用的空間大小;alloc去掉header和結尾空字符的最大空間大小
flags:8位的標記 下面關於SDS_TYPE_x的宏定義只有5種 3bit足夠了 5bit沒有用
buf:這個跟C語言中的字符數組是同樣的,從typedef char* sds能夠知道就是這樣的。
buf的最大長度是2^n 其中n爲sdshdr的類型,如當選擇sdshdr16,buf_max=2^16。
*/
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[];
};

#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
#define SDS_TYPE_MASK 7
#define SDS_TYPE_BITS 3
`

看了前面的定義,筆者畫了個圖:

從圖中能夠知道sds本質分爲三部分:header、buf、null結尾符,其中header能夠認爲是整個sds的指引部分,給定了使用的空間大小、最大分配大小等信息,再用一張網上的圖來清晰看下 sdshdr8的實例


在sds.h/sds.c源碼中可清楚地看到sds完整的實現細節,本文就不展開了要否則篇幅就過長了,快速進入主題說下 sds的優點

  • O(1)獲取長度 : C字符串須要遍歷而sds中有len能夠直接得到;

  • 防止緩衝區溢出bufferoverflow : 當sds須要對字符串進行修改時,首先借助於len和alloc檢查空間是否知足修改所需的要求,若是空間不夠的話,SDS會 自動擴展空間 ,避免了像C字符串操做中的覆蓋狀況;

  • 有效下降內存分配次數 :C字符串在涉及增長或者清除操做時會改變底層數組的大小形成從新分配、sds使用了 空間預分配和惰性空間釋放 機制,說白了就是每次在擴展時是成倍的多分配的,在縮容是也是先留着並不正式歸還給OS,這兩個機制也是比較好理解的;

  • 二進制安全 :C語言字符串只能保存ascii碼,對於圖片、音頻等信息沒法保存,sds是 二進制安全 的,寫入什麼讀取就是什麼,不作任何過濾和限制;

老規矩上一張黃健宏大神總結好的圖:

Q3:Redis的字典是如何實現的?****簡述漸進式rehash的過程。

字典算是Redis5中經常使用數據類型中的明星成員了,前面說過字典能夠基於ziplist和hashtable來實現,咱們只討論 基於hashtable實現的原理。

字典是個 層次很是明顯的數據類型,如圖:

有了個大概的概念,咱們看下最新的src/dict.h 源碼定義

`//哈希節點結構
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

//封裝的是字典的操做函數指針
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;

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
//哈希表結構 該部分是理解字典的關鍵
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

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

C語言的好處在於定義必須是由最底層向外的,所以咱們能夠看到一個明顯的層次變化,因而筆者又畫一圖來展示具體的 層次概念:


  • 關於dictEntry

dictEntry是哈希表節點,也就是咱們存儲數據地方,其保護的成員有:key,v,next指針。key保存着鍵值對中的鍵,v保存着鍵值對中的值,值能夠是一個指針或者是uint64_t或者是int64_t。next是指向另外一個哈希表節點的指針,這個指針能夠將多個哈希值相同的鍵值對鏈接在一次,以此來 解決哈希衝突 的問題。

如圖爲兩個衝突的哈希節點的鏈接關係:

  • 關於dictht

從源碼看哈希表包括的成員有table、size、used、sizemask。table是一個數組,數組中的每一個元素都是一個指向dictEntry結構的指針, 每一個dictEntry結構保存着一個鍵值對;size 屬性記錄了哈希表table的大小,而used屬性則記錄了哈希表目前已有節點的數量。sizemask等於size-1和哈希值計算一個鍵在table數組的索引,也就是計算index時用到的。

如上圖展現了一個大小爲4的table中的哈希節點狀況,其中k1和k0在index=2發生了哈希衝突,進行開鏈表存在,本質上是先存儲的k0, k1放置是發生衝突爲了保證效率直接放在衝突鏈表的最前面,由於該鏈表沒有尾指針

  • 關於dict

從源碼中看到dict結構體就是字典的定義,包含的成員有type,privdata、ht、rehashidx。其中dictType指針類型的type指向了操做字典的api,理解爲函數指針便可, ht是包含2個dictht的數組 ,也就是字典包含了2個哈希表,rehashidx進行rehash時使用的變量,privdata配合dictType指向的函數做爲參數使用,這樣就對字典的幾個成員有了初步的認識。

  • 字典的哈希算法
`//僞碼:使用哈希函數,計算鍵key的哈希值
hash = dict->type->hashFunction(key);
//僞碼:使用哈希表的sizemask和哈希值,計算出在ht[0]或許ht[1]的索引值
index = hash & dict->ht[x].sizemask;
//源碼定義
#define dictHashKey(d, key) (d)->type->hashFunction(key)
`

redis使用MurmurHash算法計算哈希值,該算法最初由Austin Appleby在2008年發明, MurmurHash算法的不管數據輸入狀況如何均可以給出隨機分佈性較好的哈希值而且計算速度很是快,目前有 MurmurHash2和 MurmurHash3等版本。

  • 普通Rehash從新散列

哈希表保存的鍵值對數量是 動態變化的,爲了讓哈希表的負載因子維持在一個合理的範圍以內,就須要對哈希表進行擴縮容。

擴縮容是經過執行rehash從新散列來完成,對字典的哈希表 執行普通rehash的基本步驟爲分配空間->逐個遷移->交換哈希表,詳細過程以下

  1. 爲字典的ht[1]哈希表分配空間,分配的空間大小取決於要執行的操做以及ht[0]當前包含的鍵值對數量: 擴展操做時ht[1]的大小爲第一個大於等於ht[0].used*2的2^n; 收縮操做時ht[1]的大小爲第一個大於等於ht[0].used的2^n ;

    擴展時好比h[0].used=200,那麼須要選擇大於400的第一個2的冪,也就是2^9=512。

  2. 將保存在ht[0]中的全部鍵值對從新計算鍵的哈希值和索引值rehash到ht[1]上;

  3. 重複rehash直到ht[0]包含的全部鍵值對所有遷移到了ht[1]以後釋放 ht[0], 將ht[1]設置爲 ht[0],並在ht[1]新建立一個空白哈希表, 爲下一次rehash作準備。

  • 漸進Rehash過程

Redis的rehash動做 並非一次性完成的,而是分屢次、漸進式地完成的,緣由在於當哈希表裏保存的鍵值對數量很大時, 一次性將這些鍵值對所有rehash到ht[1]可能會 致使服務器在一段時間內中止服務,這個是沒法接受的。

針對這種狀況Redis採用了 漸進式rehash,過程的詳細步驟:

  1. 爲ht[1]分配空間,這個過程和普通Rehash沒有區別;

  2. 將rehashidx設置爲0,表示rehash工做正式開始,同時這個rehashidx是遞增的,從0開始表示從數組第一個元素開始rehash。

  3. 在rehash進行期間,每次對字典執行增刪改查操做時, 順帶 將ht[0]哈希表在rehashidx索引上的鍵值對rehash到 ht[1],完成後將rehashidx加1,指向下一個須要rehash的鍵值對。

  4. 隨着字典操做的不斷執行,最終ht[0]的全部鍵值對都會被rehash至ht[1],再將rehashidx屬性的值設爲-1來表示 rehash操做已完成。

漸進式 rehash的思想在於 將rehash鍵值對所需的計算工做分散到對字典的每一個添加、刪除、查找和更新操做上,從而避免了集中式rehash而帶來的阻塞問題

看到這裏不由去想這種 捎帶腳式的rehash 會不會致使整個過程很是漫長?若是某個value一直沒有操做那麼須要擴容時因爲一直不用因此影響不大,須要縮容時若是一直不處理可能形成內存浪費,具體的還沒來得及研究, 先埋個問題吧

Q4:跳躍鏈表瞭解嗎?Redis的Zset如何使用跳錶實現的?

ZSet這種數據類型也很是有用,在作排行榜需求時很是有用,筆者就曾經使用這種數據類型來實現某日活2000w的app的排行榜,因此瞭解下ZSet的底層實現頗有必要,以前筆者寫過兩篇文章介紹跳躍鏈表和ZSet的實現,所以查閱便可。

Q5:Redis爲何使用單線程? 講講Redis網絡模型以及單線程如何協調各類事件運行起來的?

Redis在新版本中並非單純的單線程服務,一些輔助工做會有BIO後臺線程來完成,而且Redis底層使用epoll來實現了基於事件驅動的反應堆模式,在整個主線程運行工程中不斷協調時間事件和文件事件來完成整個系統的運行,筆者以前寫過兩篇相關的文章,查閱便可獲得更深層次的答案。

相關文章
相關標籤/搜索