1. 程式人生 > >Redis原始碼解讀

Redis原始碼解讀

Redis設計與實現(一~五整合版)

by @飄過的小牛

前言

專案中用到了redis,但用到的都是最最基本的功能,比如簡單的slave機制,資料結構只使用了字串。但是一直聽說redis是一個很牛的開源專案,很多公司都在用。於是我就比較奇怪,這玩意不就和 memcache 差不多嗎?僅僅是因為memcache是記憶體級別的,沒有持久化功能。而redis支援持久化?難道這就是它的必殺技?

帶著這個疑問,我在網上搜了一圈。發現有個叫做huangz的程式設計師針對redis寫了一本書叫做《redis設計與實現》,而且業界良心搞了一個reids2.6版本的註釋版原始碼。這本書不到200頁,估計2個星期能看完吧,之後打算再看下感興趣部分的原始碼。當然,如果你不知道redis是幹嘛的,請自行谷歌,簡單說就是Key-Value資料庫,而且 value 支援5種資料結構:

  1. 字串
  2. 雜湊表(map)
  3. 列表(list)
  4. 集合(set)
  5. 有序集

下面我們就從 redis 的內部結構開始說起吧:)

一、redis內部資料結構

首先需要知道,redis是用C寫的。眾所周知,任何系統對於字串的操作都是最頻繁的,而恰巧C語言的字串備受詬病。然後作者就封裝了一下 C 語言的字串 char *。

總之,根據redis的業務場景,整個redis系統的底層資料支撐被設計為如下幾種:

  • 簡單動態字串sds(Simple Dynamic String)
  • 雙端連結串列
  • 字典(Map)
  • 跳躍表

下面我們就分別來說說這4種資料結構。

1. 簡單動態字串sds

  • redis的字串表示為sds,而不是C字串(以\0結尾的char*)
  • 對比C字串,sds有以下特性
    • 可以高效執行長度計算O(1)
    • 可以高效執行append操作(通過free提前分配)
    • 二進位制安全
  • sds會為追加操作進行優化,加快追加操作的速度,並降低記憶體分配的次數,代價是多佔用記憶體,且不會主動釋放

這個一看名字就能知道個大概了,因為字串操作無非是增刪查改,如果使用char[]陣列,那是要死人的,任何操作都是O(N)複雜度。所以,要對某些頻繁的操作實現O(1)級效能。但是我們還是得思考:

為什麼要對字串造輪子?

因為redis是一個key-value型別的資料庫,而key全部都是字串,value可以是集合、hash、list等等。同時,在redis的各種操作中,都會頻繁使用字串的長度和append操作,對於char[]來說,長度操作是O(N)的,append會引起N次realloc。而且因為redis分為client和server,傳輸的協議內容必須是二進位制安全的,而C的字串必須保證是\0結尾,所以在這兩點的基礎上開發sds

知道了上面幾點就可以看下實現了,其實實現特別簡單。它通過一個結構體來代表字串物件,內部有個len屬性記錄長度,有個free用於以後的append操作,具體的值還是一個char[]。長度就不說了,只在插入的時候用一下,以後只需要維護len就可以O(1)拿到;對於free也很簡單,vector不也是這麼實現的嘛。就是按照某個閾值進行翻倍疊加。

2. 雙端連結串列

  • redis自己實現了雙端連結串列
  • 雙端連結串列主要兩個作用:
    1. 作為redis列表型別的底層實現之一
    2. 作為通用資料結構被其他模組使用
  • 雙端連結串列及其節點的效能特徵如下:
    • 節點帶有前驅和後繼指標
    • 連結串列是雙向的,所以對錶頭和表尾操作都是O(1)
    • 連結串列節點可以會被維護,LLEN複雜度為O(1)

這玩意當時刷資料結構與演算法分析那本書看過,但是沒怎麼用到過。說白了雙端連結串列就是有2個指標,一個指向連結串列頭,一個指向連結串列尾。對每個節點而言,記錄自己的父節點和子節點,這樣雙向移動速度會快很多。

還是老問題:

為什麼要有雙端連結串列?

在Java或者C++中,都有現成的容器供我們使用,但是C沒有。於是作者自己造了一個雙端連結串列資料結構。而這個也是redis列表資料結構的基礎之一(另外一個還是壓縮列表)。而且雙端連結串列也是一個通用的資料結構被其他功能呼叫,比如事務。

至於實現也是比較簡單,雙端連結串列,肯定有2個指標指向連結串列頭和連結串列尾,然後內部維護一個len儲存節點的數目,這樣當使用LLEN的時候就能達到O(1)複雜度了。其他的,額,對每個節點而言,都有雙向的指標,另外還有針對雙端連結串列的迭代器,也是兩個方向。

3. 字典(其實說Map更通俗)

  • 字典是由鍵值對構成的抽象資料結構
  • redis中的資料庫和雜湊鍵值對都基於字典實現
  • 字典的底層實現為雜湊表,每個字典含有2個雜湊表,一般只是用0號雜湊表,1號雜湊表是在rehash過程中才使用的
  • 雜湊表使用鏈地址法來解決碰撞問題
  • rehash可以用於擴充套件或者收縮雜湊表
  • 對雜湊表的rehash是分多次、漸進式進行的

這個雖然說經常用,但是對於redis來說確實是重中之重。畢竟redis就是一個key-value的資料庫,而key被稱為鍵空間(key space),這個鍵空間就是由字典實現的。第二個用途就是用作hash型別的其中一種底層實現。下面分別來說明。

  1. 鍵空間:redis是一個鍵值對資料庫,資料庫中的鍵值對就由字典儲存:每個資料庫都有一個與之相對應的字典,這個字典被稱為鍵空間。當用戶新增一個鍵值對到資料庫(不論鍵值對是什麼型別),程式就講該鍵值對新增到鍵空間,刪除同理。
  2. 用作hash型別鍵的其中一種底層實現:hash底層實現是通過壓縮列表和字典實現的,當建立一個hash結構的時候,會優先使用空間佔用率小的壓縮列表。當有需要的時候會將壓縮列表轉化為字典

對於字典的實現這裡簡單說明一下即可,因為很簡單。

字典是通過hash表實現的。每個字典含有2個hash表,分別為ht[0]和ht[1],一般情況下使用的是ht[0],ht[1]只有在rehash的時候才用到。為什麼呢?因為效能,我們知道,當hash表出現太多碰撞的話,查詢會由O(1)增加到O(MAXLEN),redis為了效能,會在碰撞過多的情況下發生rehash,rehash就是擴大hash表的大小,從而將碰撞率降低,當hash表大小和節點數量維持在1:1時候效能最優,就是O(1)。另外的rehashidx欄位也比較有看頭,redis支援漸進式hash,下面會講到原理。

下面講一下rehash的觸發條件:

當新插入一個鍵值對的時候,根據used/size得到一個比例,如果這個比例超過閾值,就自動觸發rehash過程。rehash分為兩種:

  • 自然rehash:滿足used/size >= 1 && dict_can_resize條件觸發的
  • 強制rehash:滿足used/size >= dict_force_resize_ratio(預設為5)條件觸發的。

思考一下,為什麼需要兩種rehash呢?答案還是為了效能,不過這點考慮的是redis服務的整體效能。當redis使用後臺子程序對字典進行rehash的時候,為了最大化利用系統的copy on write機制,子程序會暫時將自然rehash關閉,這就是dict_can_resize的作用。當持久化任務完成後,將dict_can_resize設為true,就可以繼續進行自然rehash;但是考慮另外一種情況,當現有字典的碰撞率太高了,size是指標陣列的大小,used是hash表節點數量,那麼就必須馬上進行rehash防止再插入的值繼續碰撞,這將浪費很長時間。所以超過dict_force_resize_ratio後,無論在進行什麼操作,都必須進行rehash。

rehash過程很簡單,分為3步:

  1. 給ht[1]分配至少2倍於ht[0]的空間
  2. 將ht[0]資料遷移到ht[1]
  3. 清空ht[0], 將ht[0]指標指向ht[1],ht[1]指標指向ht[0]

同樣是為了效能(當用戶對一個很大的字典插入時候,你不能讓系統阻塞來完成整個字典的rehash。所以redis採用了漸進式rehash。說白了就是分步進行rehash。具體由下面2個函式完成:

  1. dictRehashStep:從名字可以看出,是按照step進行的。當字典處於rehash狀態(dict的rehashidx不為-1),使用者進行增刪查改的時候會觸發dictRehashStep,這個函式就是將第一個索引不為空的全部節點遷移到ht[1],因為一般情況下節點數目不會超過5(超過基本會觸發強制rehash),所以成本很低,不會影響到響應時間。
  2. dictRehashMilliseconds:這個相當於時間片輪轉rehash,定時進行redis cron job來進行rehash。會在指定時間內對dict的rehashidx不為-1的字典進行rehash

上面講完了rehash過程,但是以前在組內分享redis的時候遇到過一個問題:

當進行rehash時候,我進行了增刪查改怎麼辦?是在ht[0]進行還是在ht[1]進行呢?

redis採用的策略是rehash過程中ht[0]只減不增,所以增加肯定是ht[1],查詢、修改、刪除則會同時在ht[0]和ht[1]進行。

Tips: redis為了減少儲存空間,rehash還有一個特性是縮減空間,當多次進行刪除操作後,如果used/size的比例小於一個閾值(現在是10%),那麼就會觸發縮減空間rehash,過程和增加空間類似,不詳述了。

3. 跳躍表

  • 跳躍表是一種隨機化資料結構(它的層是隨機產生的),查詢、新增、刪除操作都是O(logN)級別的。
  • 跳躍表目前在redis的唯一用處就是有序集型別的底層資料結構之一(另外一個還是字典)
  • 當然,根據redis的特性,作者對跳躍表進行了修改
    • socre可以重複
    • 對比一個元素需要同時檢查它的score和member
    • 每個節點帶有高度為1的後退指標,用於從表尾方向向表頭方向迭代

redis使用了跳躍表,但是我發現。。。。我竟然不知道跳躍表是什麼東東。虧我還覺得資料結構基礎還湊合呢= =。於是趕緊去看了《資料結構與演算法分析》,算是知道是啥玩意的。說白了,就是連結串列+二分查詢的結合體。這裡主要是研究redis的,所以就不細談這個資料結構了。

和雙端連結串列、字典不同的是,跳躍表在reids中不是廣泛使用的,它在redis中的唯一作用就是實現有序集資料型別。所以等到集合的時候再深入瞭解。

上一章我們介紹了redis的內部結構:

但是,建立這些完整的資料結構是比較耗費記憶體的,如果對於一個特別簡單的元素,使用這些資料結構無異於大材小用。為了解決這個問題,redis在條件允許的情況下,會使用記憶體對映資料結構來代替內部資料結構,主要有:

  1. 整數集合 intset
  2. 壓縮列表 ziplist

當然了,因為這些結構是和記憶體直接打交道的,就有節省記憶體的優點,而又因為對記憶體的操作比較複雜,所以也有操作複雜,佔用的CPU時間更多的缺點。

這個要掌握一個平衡,才能使redis的總體效率更好。目前,redis使用兩種記憶體對映資料結構。

1. 整數集合

整數集合用於有序、無重複的儲存多個整數值,它會根據元素的值,自動選擇該用什麼長度的整數型別來儲存元素。比如,在一個int set中,最大的元素可以用int16_t儲存,那麼這個int set的所有元素都是int16_t,當插入一個元素是int32_t的時候,int set會先將所有元素升級為int32_t,再插入這個元素。總的來說,整數集合會自動升級。

看名字我們就知道它的用途:

  1. 只儲存整數元素
  2. 元素的數量不多[因為它不費記憶體,費CPU。量多的話,肯定是CPU為第一考慮]

那麼我們看一下 intset 的定義:

 1 typedef struct intset {
 2 
 3     // 儲存元素所使用的型別的長度
 4     uint32_t encoding;
 5 
 6     // 元素個數
 7     uint32_t length;    
 8 
 9     // 儲存元素的陣列
10     int8_t contents[];  
11 
12 } intset;

其中 encoding 儲存的是 intset 中元素的編碼型別,比如是 int16_t還是 int32_t等等。具體的定義在 intset.c 中:

1 #define INTSET_ENC_INT16 (sizeof(int16_t))
2 #define INTSET_ENC_INT32 (sizeof(int32_t))
3 #define INTSET_ENC_INT64 (sizeof(int64_t))

length 肯定就是元素的個數嘍,然後是具體的元素,我們發現是 int8_t 型別的,實現上它只是一個象徵意義上的型別,到實際分配時候,會根據具體元素的型別選擇合適的型別。而且 contents 有兩個特點:

  1. 沒有重複元素
  2. 元素在陣列中從小到大排序

所以,新增元素到intset有下面幾個步驟:

  1. 判斷插入元素是否存在於集合,如果存在,沒有任何操作(無重複元素)
  2. 看元素的長度是否需要把intset升級,如果需要,先升級
  3. 插入元素,而且要保證在contents陣列中,從小到大排序
  4. 維護length

簡單總結一下整數集合的特點:

  1. 儲存有序、無重複的整數元素
  2. 根據元素的值自動選擇對應的型別,但是int set只升級、不降級
  3. 升級會引起整個int set中的contents陣列重新記憶體分配,並移動所有的元素(因為記憶體不一樣了),所以複雜度為O(N)
  4. 因為int set是有序的,所以查詢使用的是binary search

2. 壓縮列表

本質來說,壓縮列表就是由一系列特殊編碼的記憶體塊構成的列表,一個壓縮列表可以包含多個節點,每個節點可以儲存一個長度受限的字元陣列(不以為\0結尾的char陣列)或者整數。說白了,它是以記憶體為中心的資料結構,一般列表是以元素型別的位元組總數為大小,而壓縮列表是以它最小記憶體塊進行擴充套件組成的列表。下面我來說一下。

壓縮列表分為3個部分:

  • header:10位元組,儲存整個壓縮列表的資訊,有尾節點到head的偏移量、節點個數、整個壓縮列表的記憶體(位元組)
  • 節點:一個結構體、由前一個節點的大小(用於向前遍歷)、元素型別and長度、具體值組成
  • 哨兵:就是一個1位元組的全為1的記憶體,表示一個壓縮列表的結束

其中壓縮列表的節點值得說一下,它可以儲存兩類資料:

那麼,怎樣實現呢?很簡單,通過 encoding + length 就可以搞定。encoding 佔2位,00,01,10,11表示不明的型別,只有11代表的是節點中存放的是整型,其他3個代表節點中存放的都是字串。而根據這2位的不同,又對應著不同的長度。

所以,由 encoding 可以知道元素的型別和這個元素的範圍(比如 encoding 為01,包括 encoding 在內的2byte 代表長度,所以最長是214 - 1;如果 encoding 為00,包括 encoding 在內的1byte 代表元素的長度,所以最大值為26 -1 )

然後新增元素大概是下面醬紫滴(對於列表來說,新增元素預設是加在列表尾巴的):

  • 首先通過壓縮列表的head資訊,找到壓縮列表的尾巴到head的偏移量(因為可能重新分配記憶體,所以指標的話會失效)
  • 根據要插入的值,計算出編碼型別和插入值的長度。然後還有前一個節點所用的空間、然後對壓縮列表進行記憶體充分配
  • 初始化entry節點的所有相關資訊:pre_entry_length、encoding、length、content
  • 更新head中的長度啦、尾偏移啦、壓縮列表總位元組啦

上面吐槽了壓縮列表沒有next指標,現在發現有了= =,但是不是指標,因為壓縮列表會進行記憶體充分配,所以指標代表的記憶體地址需要一直維護,而當使用偏移量的話,就不需要更改一次維護一次。向後遍歷是通過頭指標+節點的大小(pre_entry_length+encoding+length的總大小)就可以跳到下一個節點了

不過,說實話,壓縮列表這個設計的好處我還沒有看到,可能還需要和後面的東西結合吧。

重讀之後看到了,(^__^) 嘻嘻……

本質上面已經說的很清楚了——節省記憶體。所以它不像上一章講到的那種分配固定的大小,而 intset 和 ziplist 完全是根據記憶體定做的,一個位元組也不多(當然,有些操作還是會有浪費的)。

前言

這一章主要是講redis內部的資料結構是如何實現的,可以說是redis的根基,前面2章介紹了redis的內部資料結構:

redis的記憶體對映資料結構:

而這一章,就是具體將這些資料結構是如何在redis中工作的。

1. 總觀redis內部實現

一張圖說明問題的本質:

Screen Shot 2014-09-02 at 23.11.30.png

之後,我們再根據這張圖來說明redis中的資料架構為什麼是醬紫滴。前面我們已經說過,redis中有5種資料結構,而它們的底層實現都不是唯一的,所以怎樣選擇對應的底層資料支撐呢?這就需要“多型”的思想,但是因為redis是C開發的。所以通過結構體來模仿物件的“多型”(當然,本質來說這是為了讓自己能更好的理解)。

為了完成這個任務,redis是這樣設計的:

  • redisObject物件
  • 基於redisObject物件的型別檢查
  • 基於redisObject物件的顯式多型函式
  • 對redisObject進行分配、共享和銷燬的機制

下面看下redisObject的定義:

/*
 * Redis 物件
 */
typedef struct redisObject {

    // 型別
    unsigned type:4;

    // 對齊位
    unsigned notused:2;

    // 編碼方式
    unsigned encoding:4;

    // LRU 時間(相對於 server.lruclock)
    unsigned lru:22;

    // 引用計數
    int refcount;

    // 指向物件的值
    void *ptr;

} robj;

其中type、encoding、ptr是最重要的3個屬性:

  • type:redisObject的型別,字串、列表、集合、有序集、雜湊表
  • encoding:底層實現結構,字串、整數、跳躍表、壓縮列表等
  • ptr:實際指向儲存值的資料結構

舉個例子就是:

如果一個 redisObject 的 type 屬性為 REDIS_LIST , encoding 屬性為 REDIS_ENCODING_LINKEDLIST ,那麼這個物件就是一個 Redis 列表,它的值儲存在一個雙端連結串列內,而 ptr 指標就指向這個雙端連結串列;
如果一個 redisObject 的 type 屬性為 REDIS_HASH , encoding 屬性為 REDIS_ENCODING_ZIPMAP ,那麼這個物件就是一個 Redis 雜湊表,它的值儲存在一個 zipmap 裡,而 ptr 指標就指向這個 zipmap ;諸如此類。

所以,當執行一個操作時,redis是這麼幹的:

  1. 根據key,檢視資料庫中是否存在對應的redisObject,沒有就返回null
  2. 檢視redisObject的type是否和要執行的操作相符
  3. 根據redisObject的encoding屬性選擇對應的資料結構
  4. 返回處理結果

然後reids還搞了一個記憶體共享,這個挺讚的:

對於一些操作來說,返回值就那幾個。對於整數來說,存入的資料也通常不會太大,所以redis通過預分配一些常見的值物件,並在多個數據結構之間(很不幸,你得時指標才能指到這裡)共享這些物件,避免了重複分配,節約記憶體。同時也節省了CPU時間

如圖所示:

Screen Shot 2014-09-02 at 23.11.39.png

三個列表的值分別為:

  • 列表 A : [20130101, 300, 10086]
  • 列表 B : [81, 12345678910, 999]
  • 列表 C : [100, 0, -25, 123]

最後一個:redis對物件的管理是通過最原始的引用計數方法。

2. 字串

字串是redis使用最多的資料結構,除了本身作為SET/GET的操作物件外,資料庫中的所有key,以及執行命令時提供的引數,都是用字串作為載體的。

在上面的圖中,我們可以看見,字串的底層可以有兩種實現:

  1. REDIS_ENCODING_INT使用long型別儲存long的值
  2. REDIS_ENCODING_ROW使用sdshdr儲存sds、long long、double、long double等

說白了就是除了long是通過第一種儲存以外,其他型別都是通過第二種儲存滴。

然後新建立的字串,都會預設使用第二種編碼,在將字串作為鍵或者值儲存進資料庫時,程式會嘗試轉為第一種(為了節省空間)

3. 雜湊表

雜湊表,嗯,它的底層實現也有兩種:

  • REDIS_ENCODING_ZIPLIST
  • REDIS_ENCODING_HT(字典)

當建立新的雜湊表時,預設是使用壓縮列表作為底層資料結構的,因為省記憶體呀。只有當觸發了閾值才會轉為字典:

  • 雜湊表中某個鍵或者值的長度大於server.hash_max_ziplist_value(預設為64)
  • 壓縮列表中的節點數量大於server.hash_max_ziplist_entries(預設為512)

4. 列表

列表嘛,其實就是佇列。它的底層實現也有2種:

  • REDIS_ENCODING_ZIPLIST
  • REDIS_ENCODING_LINKEDLIST

當建立新的列表時,預設是使用壓縮列表作為底層資料結構的,還是因為省記憶體- -。同樣有一個觸發閾值:

  • 試圖往列表中插入一個字串值,長度大於server..list_max_ziplist_value(預設是64)
  • ziplist包含的節點超過server.list_max_ziplist_entries(預設值為512)

阻塞命令

對於列表,基本的操作就不介紹了,因為列表本身的操作和底層實現基本一致,所以我們可以簡單的認為它具有雙端佇列的操作即可。重點討論一下列表的阻塞命令比較好玩。

當我們執行BLPOP/BRPOP/BRPOPLPUSH的時候,都可能造成客戶端的阻塞,它們被稱為列表的阻塞原語,當然阻塞原語並不是一定會造成客戶端阻塞:

  • 只有當這些命令作用於空列表,才會造成客戶端阻塞
  • 如果被處理的列表不為空,它們就執行無阻塞版本的LPOP/RPOP/RPOPLPUSH

上面兩條的意思很簡單,因為POP命令是刪除一個節點,那麼當沒有節點的時候,客戶端會阻塞直到一個元素新增進來,然後再執行POP命令,那麼,對客戶端的阻塞過程是這樣的:

  1. 將客戶端的連線狀態更改為“正在阻塞”,並記錄這個客戶端是被那些鍵阻塞(可以有多個),以及阻塞的最長時間
  2. 將客戶端的資訊加入到字典server.db[i].blocking_keys中,i就是客戶端使用的資料庫編號
  3. 繼續保持客戶端和伺服器端的連線,但是不傳送任何資訊,造成客戶端阻塞

響應的,解鈴須有繫鈴人:

  1. 被動脫離:有其他客戶端為造成阻塞的鍵加入了元素
  2. 主動脫離:超過阻塞的最長時間
  3. 強制脫離:關閉客戶端或者伺服器

上面的過程說的很簡單,但是在redis內部要執行的操作可以很多的,我們用一段虛擬碼來演示一下被動脫離的過程:

def handleClientsBlockedOnLists():

# 執行直到 ready_keys 為空
while server.ready_keys != NULL: 
    # 彈出連結串列中的第一個 readyList
    rl = server.ready_keys.pop_first_node() 
    # 遍歷所有因為這個鍵而被阻塞的客戶端
    for client in all_client_blocking_by_key(rl.key, rl.db):

        # 只要還有客戶端被這個鍵阻塞,就一直從鍵中彈出元素
        # 如果被阻塞客戶端執行的是 BLPOP ,那麼對鍵執行 LPOP
        # 如果執行的是 BRPOP ,那麼對鍵執行 RPOP
        element = rl.key.pop_element()
        if element == NULL:
            # 鍵為空,跳出 for 迴圈
            # 餘下的未解除阻塞的客戶端只能等待下次新元素的進入了 
            break
        else:
            # 清除客戶端的阻塞資訊
            server.blocking_keys.remove_blocking_info(client) 
            # 將元素返回給客戶端,脫離阻塞狀態
            client.reply_list_item(element)

至於主動脫離,更簡單了,通過redis的cron job來檢查時間,對於過期的blocking客戶端,直接釋放即可。虛擬碼如下:

def server_cron_job():

    # cron_job其他操作 ...

    # 遍歷所有已連線客戶端
    for client in server.all_connected_client:

        # 如果客戶端狀態為“正在阻塞”,並且最大阻塞時限已到達        
        if client.state == BLOCKING and \
        client.max_blocking_timestamp < current_timestamp(): 

        # 那麼給客戶端傳送空回覆, 脫離阻塞狀態
        client.send_empty_reply()

        # 並清除客戶端在伺服器上的阻塞資訊
        server.blocking_keys.remove_blocking_info(client)         

    # cron_job其他操作 ...

5. 集合

這個就是set,底層實現有2種:

  • REDIS_ENCODING_INTSET
  • REDIS_ENCODING_HT(字典)

對於集合來說,和前面的2種不同點在於,集合的編碼是決定於第一個新增進集合的元素:

  1. 如果第一個新增進集合的元素是long long型別的,那麼編碼就使用第一種
  2. 否則使用第二種

同樣,切換也需要達到一個閾值:

  • intset儲存的整數值個數超過server.set_max_intset_entries(預設值為512)
  • 從第二個元素開始,如果插入的元素型別不是long long的,就要轉化成第二種

然後對於集合,有3個操作的演算法很好玩,但是因為沒用到過,就暫時列一下:

6. 有序集

終於看到最後一個數據結構了,雖然只有5個- -。。。。首先從命令上就可以區分這幾種了:

  • GET/SET是字串
  • H開頭的是雜湊表
  • L開頭的是列表
  • S開頭的是集合
  • Z開頭的是有序集

繼續說有序集,這個東西我還真的沒用過。。。其他最起碼都瞭解過,這個算是第一次接觸。現在看來,它也算一個sort過的map,sort的依據就是score,對score排序後得到的集合。

首先還是底層實現,有2種:

  • REDIS_ENCODING_ZIPLIST
  • REDIS_ENCODING_SKIPLIST

這個竟然用到了跳躍表,不用這個的話,跳躍表好像都快被我忘了呢。。對於編碼的選擇,和集合類似,也是決定於第一個新增進有序集的元素:

  • 如果滿足:1.伺服器屬性server.zset_max_ziplist_entries值大於0(預設為128)2.元素的member長度小於伺服器屬性server.zset_max_ziplist_value(預設為64),就以第一種作為底層資料結構
  • 否則使用第二種

對於編碼的轉換閾值是這樣的:

  • ziplist儲存的元素數量超過伺服器屬性server.zset_max_ziplist_entries的值(預設為128)
  • ziplist的元素長度大於伺服器屬性server.zset_max_ziplist_value(預設為64)

那我們知道,如果有序集是用ziplist實現的,而ziplist終對於member和score是按次序儲存的,如member1,score1,member2,score2...這樣的。那麼,檢索時候就蛋疼了,肯定是O(N)複雜度,既然這樣,效率一下子就沒有了。慶幸的是,轉換成跳躍表之後,redis搞的很高明:

它用一個字典和一個跳躍表同時來儲存有序集的元素,而且因為member和score是在記憶體區域其字典有指標,就可以共享一塊記憶體,不用每個元素複製兩份。

通過使用字典結構,並將 member 作為鍵,score 作為值,有序集可以在 O(1) 複雜度內:

  • 檢查給定member是否存在於有序集(被很多底層函式使用)
  • 取出 member 對應的 score 值(實現 ZSCORE 命令)

通過使用跳躍表,可以讓有序集支援以下兩種操作:

  • 在 O(logN) 期望時間、O(N) 最壞時間內根據 score 對 member 進行定位(被很多底層函式使用)
  • 範圍性查詢和處理操作,這是(高效地)實現 ZRANGE 、ZRANK 和 ZINTERSTORE等命令的關鍵

通過同時使用字典和跳躍表,有序集可以高效地實現按成員查詢和按順序查詢兩種操作。所以,對於有序集來說,redis的思路確實是很流弊的。

7. 總結

上面幾個小節講述了redis的資料結構的底層實現,但是沒有涉及到具體的命令,如果調研後發現redis的某種資料結構滿足需求,就可以對症下藥,去檢視redis對應的API即可。

前言

這一章主要講解redis內部的一些功能,主要分為以下4個:

那麼,我們就來逐個擊破!

1. 事務

事務對於剛接觸計算機的人來說可能會比較抽象。因為事務是對計算機某些操作的稱謂。通俗來說,事務就是一個命令、一組命令執行的最小單元。事務一般具有ACID屬性(redis只支援兩種,下文詳細說明):

  • 原子性(atomicity):一個事務是一個不可分割的最小工作單位,事務中包括的諸操作要麼都做,要麼都不做。
  • 一致性(consistency):事務必須是使資料庫從一個一致性狀態變到另一個一致性狀態。一致性與原子性是密切相關的。
  • 隔離性(isolation):一個事務的執行不能被其他事務干擾。即一個事務內部的操作及使用的資料對併發的其他事務是隔離的,併發執行的各個事務之間不能互相干擾。
  • 永續性(durability):持續性也稱永久性(permanence),指一個事務一旦提交,它對資料庫中資料的改變就應該是永久性的。接下來的其他操作或故障不應該對其有任何影響。

那麼,redis是通過MULTI/DISCARD/EXEC/WATCH這4個命令來實現事務功能。對事務,我們必須知道事務安全性是一個非常重要的。

事務提供了一種“將多個命令打包,然後一次性、按順序執行”的機制,並且在事務執行期間不會中斷——意思就是在事務完成之前,客戶端的其他命令都是阻塞狀態。

以下是一個事務的例子,它先以 MULTI 開始一個事務,然後將多個命令入隊到事務中,最後 由EXEC 命令觸發事務,一併執行事務中的所有命令:

redis> MULTI
OK
redis> SET book-name "Mastering C++ in 21 days"
81
QUEUED
redis> GET book-name
QUEUED
redis> SADD tag "C++" "Programming" "Mastering Series"
QUEUED
redis> SMEMBERS tag
QUEUED
redis> EXEC
1) OK
2) "Mastering C++ in 21 days"
3) (integer) 3
4) 1) "Mastering Series"
   2) "C++"
   3) "Programming"

一個事務主要經歷3個階段:

  1. 開始事務
  2. 命令入隊(看,上面都有QUEUED這個返回值)
  3. 執行事務

這幾個過程都比較簡單,開始事務就是切換到事務模式;命令入隊就是把事務中的每條命令記錄下來,包括是第幾條命令,命令引數什麼的(當然,事務中是不能再巢狀事務的,所以再有事務關鍵字(MULTI/DISCARD/WATCH)會立即執行的);執行事務就是一下子把剛才那個事務的命令執行完。

  • DISCARD: 取消一個事務,它會清空客戶端的整個事務佇列,然後將客戶端從事務狀態調整回非事務狀態,最終返回字串OK給客戶端,說明事務已經取消
  • MULTI:因為redis不允許事務巢狀,所以,當在事務中輸入MULTI時,redis伺服器會簡單返回一個錯誤,然後繼續等待該事務的其他操作,就好像沒有輸入過MULTI一樣
  • WATCH:WATCH用於在事務開始之前監視任意數量的鍵,當呼叫EXEC執行事務時,如果任意一個監視的鍵被修改了,那麼整個事務就不再執行,直接返回失敗。【事務安全性檢查】

對於上面的WATCH來說,我們可以看成一個鎖。這個鎖在執行期間是不可以修改(類比為開啟鎖)的,這樣才能保證這次事務是隔離的,安全的。那麼,WATCH是如何觸發的呢?

在任何對資料庫鍵空間進行修改的命令執行成功後,multi.c/touchWatchKey函式都會被呼叫——它會檢查資料庫的watch_keys字典,看是否有客戶端在監視被修改的鍵,如果有的話,就把這個監視的是客戶端的REDIS_DIRTY_CAS開啟。之後,執行EXEC前,會對這個事務的客戶端檢查是否REDIS_DIRTY_CAS被開啟,開啟的話就說明事務的安全性被破壞,直接返回失敗;反之則正常進行事務操作。

事務的ACID性質

前面說到,事務一般具有ACID屬性,但是redis只保證兩種機制:一致性和隔離性。對於原子性和永續性並沒有支援,下面說明redis為什麼這樣做。

  1. 原子性:redis的單條命令是原子性的,但是redis沒有對事務進行原子性保護。如果一個事務沒有執行成功,是不會進行重試或者回滾的。
  2. 一致性【redis保證】:這個要分三個層次:
    1. 入隊錯誤:如果執行一個錯誤的命令(比如命令引數不對:set key),那麼會被標記為REDIS_DIRTY_EXEC,執行會直接返回錯誤
    2. 執行錯誤:對某個型別key執行其他型別的操作,不會影響結果,所以不會影響事務的一致性。事務會繼續進行
    3. redis程序被凍結:簡單來說,redis有持久化功能。但是這個持久化是建立在執行成功的基礎上,如果不成功是不會進行持久化的。所以,出問題時都會保證要麼事務沒有執行;要麼事務執行成功。所以保證了資料的一致性。
  3. 隔離性【redis保證】:因為redis是單程序程式,並在執行事務時不會中斷,一直執行到事務對列為空,所以隔離性是可以保證的。
  4. 永續性:不管是單純的記憶體模式,還是開啟了持久化檔案的功能,事務的每條命令執行過程中都會有時間間隙,如果這時候出現問題,持久化還是無法保證。所以,redis使用的是事務沒執行或者事務執行完成才會進行持久化工作(AOF模式除外,雖然現在還沒有看到- -)

2. 訂閱與釋出

這個東西沒有仔細看,但是大概知道是啥功能的。我想了一下,可以使用這個功能來完成跨平臺之間訊息的推送。比如我開發了一個app,分別有web版本、ios版本、Android版本、Symbian版本。那麼,我可以結合模式+頻道,將訊息推送到所有安裝此應用的平臺上。

3. Lua指令碼

這是redis2.6版本最大的亮點。但是我們好像木有用過- -所以,以後有需求的時候再好好研究一下吧。

4. 慢查詢日誌

慢查詢日誌是redis系統提供的一個檢視系統性能的功能。它的每一條記錄的是一條命令的執行時間。所以,你可以在redis.conf中設定當超過slowlog_log_slower_than的時候,將這個命令記錄下來;因為慢查詢日誌是一個FIFO佇列(用連結串列實現的),所以還有一個slowlog_max_than來限制佇列長度,如果溢位,就從隊頭刪除最舊的,將最新的新增到隊尾。

前言

這一章是講redis內部運作機制的,所以算是redis的核心。在這一章中,將會學習到redis是如何設計成為一個非常好用的nosql資料庫的。下面我們將要討論這些話題:

  • redis是如何表示一個數據庫的?它的操作是如何進行的?
  • redis的持久化是怎樣觸發的?持久化有什麼作用(memcache就沒有)
  • redis如何處理使用者的輸入?又試如何將執行結果返回給使用者呢?
  • redis啟動的時候,都需要做什麼初始化工作?傳入伺服器的命令又是以什麼方法執行的?

帶著這幾個問題,我們就來學習一下redis的內部運作機制,當然,我們重點是學習它為什麼要這樣設計,這樣設計為什麼是最優的?有沒有可以改進的地方呢?對細節不必太追究,先從整體上理解redis的框架是如何搭配的,然後對哪個模組感興趣再去看看原始碼,好像2.6版本的程式碼量在5W行左右吧。

1. 資料庫

嗯,好像一直用的都是預設的資料庫。廢話不說,直接上一個資料庫結構:

typedef struct redisDb {
    //資料庫編號
    int id;

    //儲存資料庫所有鍵值對資料,也成為鍵空間(key space)
    dict *dict;

    //儲存著鍵的過期資訊
    dict *expires;

    //實現列表阻塞原語,如BLPOP
    dict *blocking_keys;
    dict *ready_keys;

    //用於實現WATCH命令
    dict *watched_keys
}

主要來介紹3個屬性:

  • id:資料庫編號,但是不是select NUM這個裡面的,id這個屬性是為redis內部提供的,比如AOF程式需要知道當前在哪個資料庫中操作的,如果沒有id來標識,就只能通過指標來遍歷地址相等,效率比較低
  • dict:因為redis本身就是一個鍵值對資料庫,所以這個dict存放的就是整個資料庫的鍵值對。鍵是一個string,值可以是redis五種資料結構的任意一種。因為資料庫本身是一個字典,所以對資料庫的操作,基本都是對字典的操作
  • 鍵的過期時間:因為有些資料是臨時的,或者不需要長期儲存,就可以給它設定一個過期時間(當然,key不會同時存在在key space和expire的字典中,兩者會公用一個記憶體塊)

這其中比較好的一個是redis對於過期鍵的處理,我當時看到這裡想,可以弄一個定時器,定期來檢查expire字典中的key是否到了過期時間,但是這個定時器的時間間隔不好控制,長了的話已經過期的鍵還可以訪問;短了的話,又註定會影像系統的效能。

  • 定時刪除:定時器方法,和我想法一致
  • 懶惰刪除:這個類似線段樹的lazy操作,很巧妙(總算資料結構沒白學啊。。。)
  • 定期刪除:上面2個都有短板,這個是結合兩者的一個折中策略。它會定時刪除過期key,但是會控制時間和頻率,同時也會減少懶惰刪除帶來的記憶體膨脹

lazy機制:

當你不用這個鍵的時候,我才懶得刪除。當你訪問redis的某個key時,我就檢查一下這個key是否存在在expire中,如果存在就看是否過期,過期則刪除(優化是標記一下,直接返回空,然後定時任務再慢慢刪除這個);反之再去redis的dict中取值。但是缺點也有,如果用於不訪問,記憶體就一直佔用。加入我給100萬個key設定了5s的過期時間,但是我很少訪問,那麼記憶體到最後就會爆掉。

所以,redis綜合考慮後採用了懶惰刪除和定期刪除,這兩個策略相互配合,可以很好的完成CPU和記憶體的平衡。

2. RDB

因為當前專案用到了這個,必須要好好看看啊。戰略上藐視一下,就是redis資料庫從記憶體持久化到檔案的意思。redis一共有兩種持久化操作:

逐個來說,先搞定RDB。

對於RDB機制來說,在儲存RDB檔案期間,主程序會被阻塞,直到儲存成功為止。但是這也分兩種實現:

  • SAVE:直接呼叫rdbSave,阻塞redis主程序,直到儲存完成,這完成過程中,不接受客戶端的請求
  • BGSAVE:fork一個子程序,子程序負責呼叫rdbSave,並在儲存完成知乎向主程序傳送訊號,通知儲存已經完成。因為是fork的子程序,所以主程序還是可以正常工作,接受客戶端的請求

整個流程可以用偽程式碼表示:

def SAVE():

    rdbSave()


def BGSAVE():

    pid = fork()

    if pid == 0:

        # 子程序儲存 RDB
        rdbSave()

    elif pid > 0:

        # 父程序繼續處理請求,並等待子程序的完成訊號
        handle_request()

    else:

        # pid == -1
        # 處理 fork 錯誤
        handle_fork_error()

當然,寫入之後就是load了。當redis服務重啟,就會將存在的dump.rdb檔案重新載入到記憶體中,用於資料恢復,那麼redis是怎麼做的呢?

額,這一節重點是RDB檔案的結構,如果有興趣,可以自己去看下dump.rdb檔案,然後對照一下很容易就明白了。

3. AOF

AOF是append only file的縮寫,意思是追加到唯一的檔案,從上面對RDB的介紹我們知道,RDB的寫入是觸發式的,等待多少秒或者多少次寫入才會持久化到檔案中,但是AOF是實時的,它會記錄你的每一個命令。

同步到AOF檔案的整個過程可以分為三個階段:

  • 命令傳播:redis將執行的命令、引數、引數個數都發送給AOF程式
  • 快取追加:AOF程式將收到的資料整理成網路協議的格式,然後追加到AOF的記憶體快取中
  • 檔案寫入和儲存:AOF快取中的內容被寫入到AOF檔案的尾巴,如果設定的AOF儲存條件被滿足,fsync或者或者fdatasync函式會被呼叫,將寫入的內容真正儲存到磁碟中

對於第三點我們需要說明一下,在前面我們說到,RDB是觸發式的,AOF是實時的。這裡怎麼又說也是滿足條件了呢?原來redis對於這個條件,有以下的方式:

  • AOF_FSYNC_NO:不儲存。這時候,呼叫flushAppendOnlyFile函式的時候WRITE都會執行(寫入AOF程式的快取),但SAVE會(寫入磁碟)跳過,只有當滿足:redis被關閉、AOF功能被關閉、系統要重新整理快取(空間不足等),才會進行SAVE操作。這種方式相當於迫不得已才會進行SAVE,但是很不幸,這三種操作都會引起redis主程序的阻塞
  • AOF_FSYNC_EVERYSEC:每一秒儲存一次。因為SAVE是後臺子執行緒呼叫的,所有主執行緒不會阻塞。
  • AOF_FSYNC_ALWAYS:每執行一個命令儲存一次。這個很好理解,但是因為SAVE是redis主程序執行的,所以在SAVE時候主程序阻塞,不再接受客戶端的請求

補充:對於第二種的流程可能比較麻煩,用一個圖來說明:

Screen Shot 2014-09-02 at 23.12.05.png

如果仔細看上面的條件,會發現一會SAVE是子執行緒執行的,一會是主程序執行的,那麼怎樣從根本上區分呢?

我個人猜測是區分操作的頻率,第一種情況是服務都關閉了,主程序肯定會做好善後工作,發現AOF開啟了但是沒有寫入磁碟,於是自己麻溜就做了;第二種情況,因為每秒都需要做,主程序不可能用一個定時器去寫入磁碟,這時候用一個子執行緒就可以圓滿完成;第三種情況,因為一個命令基本都是特別小的,所以執行一次操作估計非常非常快,所以主程序再呼叫子執行緒造成的上下文切換都顯得有點得不償失了,於是主程序自己搞定。【待驗證】