0.前言
計劃分三篇來梳理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等
多種主流語言的客戶端
,因此無論使用者是什麼語言棧總會找到屬於自己的那款客戶端,受衆非常廣。
筆者查了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的基本步驟爲分配空間->逐個遷移->交換哈希表,詳細過程如下:
-
爲字典的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。
-
將保存在ht[0]中的所有鍵值對重新計算鍵的哈希值和索引值rehash到ht[1]上;
-
重複rehash直到ht[0]包含的所有鍵值對全部遷移到了ht[1]之後釋放 ht[0], 將ht[1]設置爲 ht[0],並在ht[1]新創建一個空白哈希表, 爲下一次rehash做準備。
- 漸進Rehash過程
Redis的rehash動作
並不是一次性完成的,而是分多次、漸進式地完成的,原因在於當哈希表裏保存的鍵值對數量很大時, 一次性將這些鍵值對全部rehash到ht[1]可能會
導致服務器在一段時間內停止服務,這個是無法接受的。
針對這種情況Redis採用了
漸進式rehash,過程的詳細步驟:
-
爲ht[1]分配空間,這個過程和普通Rehash沒有區別;
-
將rehashidx設置爲0,表示rehash工作正式開始,同時這個rehashidx是遞增的,從0開始表示從數組第一個元素開始rehash。
-
在rehash進行期間,每次對字典執行增刪改查操作時,
順帶
將ht[0]哈希表在rehashidx索引上的鍵值對rehash到 ht[1],完成後將rehashidx加1,指向下一個需要rehash的鍵值對。 -
隨着字典操作的不斷執行,最終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來實現了基於事件驅動的反應堆模式,在整個主線程運行工程中不斷協調時間事件和文件事件來完成整個系統的運行,筆者之前寫過兩篇相關的文章,查閱即可得到更深層次的答案。