【3y】從零單排學Redis【青銅】

Java3y發表於2018-11-29

前言

只有光頭才能變強

redis

最近在學Redis,我相信只要是接觸過Java開發的都會聽過Redis這麼一個技術。面試也是非常高頻的一個知識點,之前一直都是處於瞭解階段。秋招過後這段時間是沒有什麼壓力的,所以打算系統學學Redis,這也算是我從零學習Redis的筆記吧。

本文力求講清每個知識點,希望大家看完能有所收穫。

一、介紹一下Redis

首先,肯定是去官網看看官方是怎麼介紹Redis的啦。redis.io/topics/intr…

如果像我一樣,英語可能不太好的,可能看不太懂。沒事,我們們Chrome瀏覽器可以切換成中文的,中文是我們的母語,肯定沒啥壓力了。Eumm...

讀完之後,發現中文也就那樣了。

一大堆沒見過的技術:lua(Lua指令碼)、replication(複製)、Redis Sentinel(哨兵)、Redis Cluster(Redis 叢集),當然我們也會有看得懂的技術:transactions(事務)、different levels of on-disk persistence(資料持久化)、LRU eviction(LRU淘汰機制)..

至少官方介紹Redis的第一句應該是可以很容易看懂:"Redis is an open source (BSD licensed),in-memory data structure store, used as a database,cache and message broker."

Redis是一個開源的,基於記憶體的資料結構儲存,可用作於資料庫、快取、訊息中介軟體。

  • 從官方的解釋上,我們可以知道:Redis是基於記憶體,支援多種資料結構。
  • 從經驗的角度上,我們可以知道:Redis常用作於快取。

就我個人認為:學習一種新技術,先把握該技術整體的知識(思想),再扣細節,這樣學習起來會比較輕鬆一些。所以我們先以“記憶體”、“資料結構”、“快取”來對Redis入門。

1.1為什麼要用Redis?

從上面可知:Redis是基於記憶體,常用作於快取的一種技術,並且Redis儲存的方式是以key-value的形式。

我們可以發現這不就是Java的Map容器所擁有的特性嗎,那為什麼還需要Redis呢?

  • Java實現的Map是本地快取,如果有多臺例項(機器)的話,每個例項都需要各自儲存一份快取,快取不具有一致性
  • Redis實現的是分散式快取,如果有多臺例項(機器)的話,每個例項都共享一份快取,快取具有一致性
  • Java實現的Map不是專業做快取的,JVM記憶體太大容易掛掉的。一般用做於容器來儲存臨時資料,快取的資料隨著JVM銷燬而結束。Map所儲存的資料結構,快取過期機制等等是需要程式設計師自己手寫的。
  • Redis是專業做快取的,可以用幾十個G記憶體來做快取。Redis一般用作於快取,可以將快取資料儲存在硬碟中,Redis重啟了後可以將其恢復。原生提供豐富的資料結構、快取過期機制等等簡單好用的功能。

參考資料:

1.2為什麼要用快取?

如果我們的網站出現了效能問題(訪問時間慢),按經驗來說,一般是由於資料庫撐不住了。因為一般資料庫的讀寫都是要經過磁碟的,而磁碟的速度可以說是相當慢的(相對記憶體來說)

資料庫撐不住了

如果學過Mybaits、Hibernate的同學就可以知道,它們有一級快取、二級快取這樣的功能(終究來說還是本地快取)。目的就是為了:不用每次讀取的時候,都要查一次資料庫

有了快取之後,我們的訪問就變成這樣了:

有了快取提高了併發和效能

二、Redis的資料結構

本文不會講述命令的使用方式,具體的如何使用可查詢API。

Redis支援豐富的資料結構,常用的有string、list、hash、set、sortset這幾種。學習這些資料結構是使用Redis的基礎!

"Redis is written in ANSI C"-->Redis由C語言編寫

首先還是得宣告一下,Redis的儲存是以key-value的形式的。Redis中的key一定是字串,value可以是string、list、hash、set、sortset這幾種常用的。

redis資料結構

但要值得注意的是:Redis並沒有直接使用這些資料結構來實現key-value資料庫,而是基於這些資料結構建立了一個物件系統

  • 簡單來說:Redis使用物件來表示資料庫中的鍵和值。每次我們在Redis資料庫中新建立一個鍵值對時,至少會建立出兩個物件。一個是鍵物件,一個是值物件。

Redis中的每個物件都由一個redisObject結構來表示:


typedef struct redisObject{
	
	// 物件的型別
	unsigned type 4:;

	// 物件的編碼格式
	unsigned encoding:4;

	// 指向底層實現資料結構的指標
	void * ptr;

	//.....


}robj;


複製程式碼

資料結構對應的型別與編碼

簡單來說就是Redis對key-value封裝成物件,key是一個物件,value也是一個物件。每個物件都有type(型別)、encoding(編碼)、ptr(指向底層資料結構的指標)來表示。

以值為1006的字串物件為例

下面我就來說一下我們Redis常見的資料型別:string、list、hash、set、sortset。它們的底層資料結構究竟是怎麼樣的!

2.1SDS簡單動態字串

簡單動態字串(Simple dynamic string,SDS)

Redis中的字串跟C語言中的字串,是有點差距的

Redis使用sdshdr結構來表示一個SDS值:


struct sdshdr{

	// 位元組陣列,用於儲存字串
	char buf[];

	// 記錄buf陣列中已使用的位元組數量,也是字串的長度
	int len;

	// 記錄buf陣列未使用的位元組數量
	int free;
}
複製程式碼

例子:

SDS例子

2.1.1使用SDS的好處

SDS與C的字串表示比較

  1. sdshdr資料結構中用len屬性記錄了字串的長度。那麼獲取字串的長度時,時間複雜度只需要O(1)
  2. SDS不會發生溢位的問題,如果修改SDS時,空間不足。先會擴充套件空間,再進行修改!(內部實現了動態擴充套件機制)。
  3. SDS可以減少記憶體分配的次數(空間預分配機制)。在擴充套件空間時,除了分配修改時所必要的空間,還會分配額外的空閒空間(free 屬性)。
  4. SDS是二進位制安全的,所有SDS API都會以處理二進位制的方式來處理SDS存放在buf陣列裡的資料。

2.2連結串列

對於連結串列而言,我們不會陌生的了。在大學期間肯定開過資料結構與演算法課程,連結串列肯定是講過的了。在Java中Linkedxxx容器底層資料結構也是連結串列+[xxx]的。我們來看看Redis中的連結串列是怎麼實現的:

使用listNode結構來表示每個節點:




typedef strcut listNode{

    //前置節點
    strcut listNode  *pre;

    //後置節點
    strcut listNode  *pre;

    //節點的值
    void  *value;

}listNode

複製程式碼

使用listNode是可以組成連結串列了,Redis中使用list結構來持有連結串列


typedef struct list{

    //表頭結點
    listNode  *head;

    //表尾節點
    listNode  *tail;

    //連結串列長度
    unsigned long len;

    //節點值複製函式
    void *(*dup) (viod *ptr);

    //節點值釋放函式
    void  (*free) (viod *ptr);

    //節點值對比函式
    int (*match) (void *ptr,void *key);

}list

複製程式碼

具體的結構如圖:

【3y】從零單排學Redis【青銅】

2.2.1Redis連結串列的特性

Redis的連結串列有以下特性:

  • 無環雙向連結串列
  • 獲取表頭指標,表尾指標,連結串列節點長度的時間複雜度均為O(1)
  • 連結串列使用void *指標來儲存節點值,可以儲存各種不同型別的值

2.3雜湊表

宣告:《Redis設計與實現》裡邊有“字典”這麼一個概念,我個人認為還是直接叫雜湊表比較通俗易懂。從程式碼上看:“字典”也是在雜湊表基礎上再抽象了一層而已。

在Redis中,key-value的資料結構底層就是雜湊表來實現的。對於雜湊表來說,我們也並不陌生。在Java中,雜湊表實際上就是陣列+連結串列的形式來構建的。下面我們來看看Redis的雜湊表是怎麼構建的吧。

在Redis裡邊,雜湊表使用dictht結構來定義:

	typedef struct dictht{
	    
	    //雜湊表陣列
	    dictEntry **table;  
	
	    //雜湊表大小
	    unsigned long size;    
	
	    //雜湊表大小掩碼,用於計算索引值
	    //總是等於size-1
	    unsigned long sizemark;     
	
	    //雜湊表已有節點數量
	    unsigned long used;
	     
	}dictht

複製程式碼

雜湊表的資料結構

我們下面繼續寫看看雜湊表的節點是怎麼實現的吧:


	typedef struct dictEntry {
	    
	    //鍵
	    void *key;
	
	    //值
	    union {
	        void *value;
	        uint64_tu64;
	        int64_ts64;
	    }v;    
	
	    //指向下個雜湊節點,組成連結串列
	    struct dictEntry *next;
	
	}dictEntry;
複製程式碼

從結構上看,我們可以發現:Redis實現的雜湊表和Java中實現的是類似的。只不過Redis多了幾個屬性來記錄常用的值:sizemark(掩碼)、used(已有的節點數量)、size(大小)。

同樣地,Redis為了更好的操作,對雜湊表往上再封裝了一層(參考上面的Redis實現連結串列),使用dict結構來表示:


typedef struct dict {

    //型別特定函式
    dictType *type;

    //私有資料
    void *privdata;
  
    //雜湊表
    dictht ht[2];

    //rehash索引
    //當rehash不進行時,值為-1
    int rehashidx;  

}dict;


//-----------------------------------

typedef struct dictType{

    //計算雜湊值的函式
    unsigned int (*hashFunction)(const void * key);

    //複製鍵的函式
    void *(*keyDup)(void *private, const void *key);
 
    //複製值得函式
    void *(*valDup)(void *private, const void *obj);  

    //對比鍵的函式
    int (*keyCompare)(void *privdata , const void *key1, const void *key2)

    //銷燬鍵的函式
    void (*keyDestructor)(void *private, void *key);
 
    //銷燬值的函式
    void (*valDestructor)(void *private, void *obj);  

}dictType

複製程式碼

所以,最後我們可以發現,Redis所實現的雜湊表最後的資料結構是這樣子的:

【3y】從零單排學Redis【青銅】

從程式碼實現和示例圖上我們可以發現,Redis中有兩個雜湊表

  • ht[0]:用於存放真實key-vlaue資料
  • ht[1]:用於擴容(rehash)

Redis中雜湊演算法和雜湊衝突跟Java實現的差不多,它倆差異就是:

  • Redis雜湊衝突時:是將新節點新增在連結串列的表頭
  • JDK1.8後,Java在雜湊衝突時:是將新的節點新增到連結串列的表尾

2.3.1rehash的過程

下面來具體講講Redis是怎麼rehash的,因為我們從上面可以明顯地看到,Redis是專門使用一個雜湊表來做rehash的。這跟Java一次性直接rehash是有區別的。

在對雜湊表進行擴充套件或者收縮操作時,reash過程並不是一次性地完成的,而是漸進式地完成的。

Redis在rehash時採取漸進式的原因:資料量如果過大的話,一次性rehash會有龐大的計算量,這很可能導致伺服器一段時間內停止服務

Redis具體是rehash時這麼幹的:

  • (1:在字典中維持一個索引計數器變數rehashidx,並將設定為0,表示rehash開始。
  • (2:在rehash期間每次對字典進行增加、查詢、刪除和更新操作時,除了執行指定命令外;還會將ht[0]中rehashidx索引上的值rehash到ht[1],操作完成後rehashidx+1。
  • (3:字典操作不斷執行,最終在某個時間點,所有的鍵值對完成rehash,這時將rehashidx設定為-1,表示rehash完成
  • (4:在漸進式rehash過程中,字典會同時使用兩個雜湊表ht[0]和ht[1],所有的更新、刪除、查詢操作也會在兩個雜湊表進行。例如要查詢一個鍵的話,伺服器會優先查詢ht[0],如果不存在,再查詢ht[1],諸如此類。此外當執行新增操作時,新的鍵值對一律儲存到ht[1],不再對ht[0]進行任何操作,以保證ht[0]的鍵值對數量只減不增,直至變為空表。

2.4跳躍表(shiplist)

跳躍表(shiplist)是實現sortset(有序集合)的底層資料結構之一!

跳躍表可能對於大部分人來說不太常見,之前我在學習的時候發現了一篇不錯的文章講跳躍表的,建議大家先去看完下文再繼續回來閱讀:

Redis的跳躍表實現由zskiplist和zskiplistNode兩個結構組成。其中zskiplist儲存跳躍表的資訊(表頭,表尾節點,長度),zskiplistNode則表示跳躍表的節點

按照慣例,我們來看看zskiplistNode跳躍表節點的結構是怎麼樣的:


typeof struct zskiplistNode {
        // 後退指標
        struct zskiplistNode *backward;
        // 分值
        double score;
        // 成員物件
        robj *obj;
        // 層
        struct zskiplistLevel {
                // 前進指標
                struct zskiplistNode *forward;
                // 跨度
                unsigned int span;
        } level[];
} zskiplistNode;

複製程式碼

zskiplistNode的物件示例圖(帶有不同層高的節點):

帶有不同層高的節點

示例圖如下:

跳躍表節點的示例圖

zskiplist的結構如下:

typeof struct zskiplist {
        // 表頭節點,表尾節點
        struct skiplistNode *header,*tail;
        // 表中節點數量
        unsigned long length;
        // 表中最大層數
        int level;
} zskiplist;


複製程式碼

最後我們整個跳躍表的示例圖如下:

跳躍表示例圖

2.5整數集合(intset)

整數集合是set(集合)的底層資料結構之一。當一個set(集合)只包含整數值元素,並且元素的數量不多時,Redis就會採用整數集合(intset)作為set(集合)的底層實現。

整數集合(intset)保證了元素是不會出現重複的,並且是有序的(從小到大排序),intset的結構是這樣子的:


typeof struct intset {
        // 編碼方式
        unit32_t encoding;
        // 集合包含的元素數量
        unit32_t lenght;
        // 儲存元素的陣列
        int8_t contents[];
} intset;

複製程式碼

intset示例圖:

intset示例圖

說明:雖然intset結構將contents屬性宣告為int8_t型別的陣列,但實際上contents陣列並不儲存任何int8_t型別的值,contents陣列的真正型別取決於encoding屬性的值

  • INTSET_ENC_INT16
  • INTSET_ENC_INT32
  • INTSET_ENC_INT64

從編碼格式的名字我們就可以知道,16,32,64編碼對應能存放的數字範圍是不一樣的。16明顯最少,64明顯最大。

如果本來是INTSET_ENC_INT16的編碼,想要存放大於INTSET_ENC_INT16編碼能存放的整數值,此時就得編碼升級(從16升級成32或者64)。步驟如下:

  • 1)根據新元素型別擴充整數集合底層陣列的空間併為新元素分配空間。
  • 2)將底層陣列現有的所以元素都轉換成與新元素相同的型別,並將型別轉換後的元素放到正確的位上,需要維持底層陣列的有序性質不變。
  • 3)將新元素新增到底層陣列。

另外一提:只支援升級操作,並不支援降級操作

2.6壓縮列表(ziplist)

壓縮列表(ziplist)是list和hash的底層實現之一。如果list的每個都是小整數值,或者是比較短的字串,壓縮列表(ziplist)作為list的底層實現。

壓縮列表(ziplist)是Redis為了節約記憶體而開發的,是由一系列的特殊編碼的連續記憶體塊組成的順序性資料結構。

壓縮列表結構圖例如下:

壓縮列表的組成部分

下面我們看看節點的結構圖:

【3y】從零單排學Redis【青銅】

壓縮列表從表尾節點倒序遍歷,首先指標通過zltail偏移量指向表尾節點,然後通過指向節點記錄的前一個節點的長度依次向前遍歷訪問整個壓縮列表

三、Redis中資料結構的物件

再次看回這張圖,覺不覺得就很好理解了?

資料結構對應的型別與編碼

3.1字串(stirng)物件

在上面的圖我們知道string型別有三種編碼格式

  • int:整數值,這個整數值可以使用long型別來表示
    • 如果是浮點數,那就用embstr或者raw編碼。具體用哪個就看這個數的長度了
  • embstr:字串值,這個字串值的長度小於39位元組
  • raw:字串值,這個字串值的長度大於39位元組

embstr和raw的區別

  • raw分配記憶體和釋放記憶體的次數是兩次,embstr是一次
  • embstr編碼的資料儲存在一塊連續的記憶體裡面

編碼之間的轉換

  • int型別如果存的不再是一個整數值,則會從int轉成raw
  • embstr是隻讀的,在修改的時候回從embstr轉成raw

3.2列表(list)物件

在上面的圖我們知道list型別有兩種編碼格式

  • ziplist:字串元素的長度都小於64個位元組&&總數量少於512個
  • linkedlist:字串元素的長度大於64個位元組||總數量大於512個

ziplist編碼的列表結構:

	redis > RPUSH numbers 1 "three" 5
	(integer) 3 

複製程式碼

ziplist的列表結構

linkedlist編碼的列表結構:

linkedlist編碼的列表結構

編碼之間的轉換:

  • 原本是ziplist編碼的,如果儲存的資料長度太大或者元素數量過多,會轉換成linkedlist編碼的。

3.3雜湊(hash)物件

在上面的圖我們知道hash型別有兩種編碼格式

  • ziplist:key和value的字串長度都小於64位元組&&鍵值對總數量小於512
  • hashtable:key和value的字串長度大於64位元組||鍵值對總數量大於512

ziplist編碼的雜湊結構:

ziplist編碼的雜湊結構1
ziplist編碼的雜湊結構2

hashtable編碼的雜湊結構:

hashtable編碼的雜湊結構

編碼之間的轉換:

  • 原本是ziplist編碼的,如果儲存的資料長度太大或者元素數量過多,會轉換成hashtable編碼的。

3.4集合(set)物件

在上面的圖我們知道set型別有兩種編碼格式

  • intset:儲存的元素全都是整數&&總數量小於512
  • hashtable:儲存的元素不是整數||總數量大於512

intset編碼的集合結構:

intset編碼的集合結構

hashtable編碼的集合結構:

hashtable編碼的集合結構

編碼之間的轉換:

  • 原本是intset編碼的,如果儲存的資料不是整數值或者元素數量大於512,會轉換成hashtable編碼的。

3.5有序集合(sortset)物件

在上面的圖我們知道set型別有兩種編碼格式

  • ziplist:元素長度小於64&&總數量小於128
  • skiplist:元素長度大於64||總數量大於128

ziplist編碼的有序集合結構:

ziplist編碼的有序集合結構1

ziplist編碼的有序集合結構2

skiplist編碼的有序集合結構:

skiplist編碼的有序集合結構

有序集合(sortset)物件同時採用skiplist和雜湊表來實現

  • skiplist能夠達到插入的時間複雜度為O(logn),根據成員查分值的時間複雜度為O(1)

編碼之間的轉換:

  • 原本是ziplist編碼的,如果儲存的資料長度大於64或者元素數量大於128,會轉換成skiplist編碼的。

3.6Redis物件一些細節

  • (1:伺服器在執行某些命令的時候,會先檢查給定的鍵的型別能否執行指定的命令。
    • 比如我們的資料結構是sortset,但你使用了list的命令。這是不對的,伺服器會檢查一下我們的資料結構是什麼才會進一步執行命令
  • (2:Redis的物件系統帶有引用計數實現的記憶體回收機制
    • 物件不再被使用的時候,物件所佔用的記憶體會釋放掉
  • (3:Redis會共享值為0到9999的字串物件
  • (4:物件會記錄自己的最後一次被訪問時間,這個時間可以用於計算物件的空轉時間。

最後

本文主要講了一下Redis常用的資料結構,以及這些資料結構的底層設計是怎麼樣的。整體來說不會太難,因為這些資料結構我們在學習的過程中多多少少都接觸過了,《Redis設計與實現》這本書寫得也足夠通俗易懂。

至於我們在使用的時候挑選哪些資料結構作為儲存,可以簡單看看:

  • string-->簡單的key-value
  • list-->有序列表(底層是雙向連結串列)-->可做簡單佇列
  • set-->無序列表(去重)-->提供一系列的交集、並集、差集的命令
  • hash-->雜湊表-->儲存結構化資料
  • sortset-->有序集合對映(member-score)-->排行榜

如果大家有更好的理解方式或者文章有錯誤的地方還請大家不吝在評論區留言,大家互相學習交流~~~

參考部落格:

參考資料:

  • 《Redis設計與實現》
  • 《Redis實戰》

一個堅持原創的Java技術公眾號:Java3y,歡迎大家關注

原創技術文章導航:

相關文章