1. 程式人生 > >Redis面試熱點之底層實現篇

Redis面試熱點之底層實現篇

通過本文你將瞭解到以下內容:

  • Redis的作者、發展演進和江湖地位
  • Redis面試問題的概況
  • Redis底層實現相關的問題包括:
    常用資料型別底層實現、SDS的原理和優勢、字典的實現原理、跳錶和有序集合的原理、Redis的執行緒模式和服務模型

溫馨提示:內容並不難,就怕你不看。

看不懂可以先收藏先Mark,等到深入研究的時間再翻出來看看,你就發現真是24K乾貨呀!停止吹噓,寫點不一樣的文字吧!

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等多種主流語言的客戶端,因此無論使用者是什麼語言棧總會找到屬於自己的那款客戶端,受眾非常廣。

筆者查了http://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的實現,因此查閱即可。
深入理解跳躍連結串列[一]
深入理解跳錶在Redis中的應用

Q5:Redis為什麼使用單執行緒?講講Redis網路模型以及單執行緒如何協調各種事件執行起來的?
Redis在新版本中並不是單純的單執行緒服務,一些輔助工作會有BIO後臺執行緒來完成,並且Redis底層使用epoll來實現了基於事件驅動的反應堆模式,在整個主執行緒執行工程中不斷協調時間事件和檔案事件來完成整個系統的執行,筆者之前寫過兩篇相關的文章,查閱即可得到更深層次的答案。
理解Redis單執行緒執行模式
理解Redis的反應堆模式
淺析Redis 4.0新特性之LazyFree

5.巨人的肩膀

    • https://lynnapan.github.io/2017/07/14/redis_sds/
    • http://redisbook.com/index.html
&nbs