歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux基礎 >> Linux教程 >> Redis設計與實現

Redis設計與實現

日期:2017/2/27 15:57:10   编辑:Linux教程

前言

項目中用到了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開啟了但是沒有寫入磁盤,於是自己麻溜就做了;第二種情況, 因為每秒都需要做,主進程不可能用一個定時器去寫入磁盤,這時候用一個子線程就可以圓滿完成;第三種情況,因為一個命令基本都是特別小的,所以執行一次操 作估計非常非常快,所以主進程再調用子線程造成的上下文切換都顯得有點得不償失了,於是主進程自己搞定。【待驗證】

對於上面三種方式來說,最好的應該是第二種,因為阻塞操作會讓 Redis 主進程無法持續處理請求,所以一般說來,阻塞操作執行得越少、完成得越快,Redis 的性能就越好。

  • 模 式 1 的保存操作只會在AOF 關閉或 Redis 關閉時執行, 或者由操作系統觸發, 在一般情況下, 這種模式只需要為寫入阻塞, 因此它的寫入性能要比後面兩種模式要高, 當然, 這種性能的提高是以降低安全性為代價的: 在這種模式下, 如果運行的中途發生停機, 那麼丟失數據的數量由操作系統的緩存沖洗策略決定。
  • 模式 2 在性能方面要優於模式 3 , 並且在通常情況下, 這種模式最多丟失不多於 2 秒的數據, 所以它的安全性要高於模式 1 , 這是一種兼顧性能和安全性的保存方案。
  • 模式 3 的安全性是最高的, 但性能也是最差的, 因為服務器必須阻塞直到命令信息被寫入並保存到磁盤之後, 才能繼續處理請求。

AOF文件的還原

對於AOF文件的還原就特別簡單了,因為AOF是按照AOF協議保存的redis操作命令,所以redis會偽造一個客戶端,把AOF保存的命令重新執行一遍,執行之後就會得到一個完成的數據庫,偽代碼如下:

def READ_AND_LOAD_AOF():

    # 打開並讀取 AOF 文件
    file = open(aof_file_name)
    while file.is_not_reach_eof():

        # 讀入一條協議文本格式的 Redis 命令
        cmd_in_text = file.read_next_command_in_protocol_format()

        # 根據文本命令,查找命令函數,並創建參數和參數個數等對象
        cmd, argv, argc = text_to_command(cmd_in_text)

        # 執行命令
        execRedisCommand(cmd, argv, argc)

    # 關閉文件
    file.close()

AOF重寫

上面提到,AOF可以對redis的每個操作都記錄,但這帶來一個問題,當redis的操作越來越多之 後,AOF文件會變得很大。而且,裡面很大一部分都是無用的操作,你如我對一個整型+1,然後-1,然後再加1,然後再-1(比如這是一個互斥鎖的開 關),那麼,過一段時間後,可能+1、-1操作就執行了幾萬次,這時候,如果能對AOF重寫,把無效的命令清除,AOF會明顯瘦身,這樣既可以減少AOF 的體積,在恢復的時候,也能用最短的指令和最少的時間來恢復整個數據庫,迫於這個構想,redis提供了對AOF的重寫。

所謂的重寫呢,其 實說的不夠明確。因為redis所針對的重寫實際上指數據庫中鍵的當前值。AOF 重寫是一個有歧義的名字,實際的重寫工作是針對數據庫的當前值來進行的,程序既不讀寫、也不使用原有的 AOF 文件。比如現在有一個列表,push了1、2、3、4,然後刪除4、刪除1、加入1,這樣列表最後的元素是1、2、3,如果不進行縮減,AOF會記錄4次 redis操作,但是AOF重寫它看的是列表最後的值:1、2、3,於是它會用一條rpush 1 2 3來完成,這樣由4條變為1條命令,恢復到最近的狀態的代價就變為最小。

整個重寫過程的偽代碼如下:

def AOF_REWRITE(tmp_tile_name):

  f = create(tmp_tile_name)

  # 遍歷所有數據庫
  for db in redisServer.db:

    # 如果數據庫為空,那麼跳過這個數據庫
    if db.is_empty(): continue

    # 寫入 SELECT 命令,用於切換數據庫
    f.write_command("SELECT " + db.number)

    # 遍歷所有鍵
    for key in db:

      # 如果鍵帶有過期時間,並且已經過期,那麼跳過這個鍵
      if key.have_expire_time() and key.is_expired(): continue

      if key.type == String:

        # 用 SET key value 命令來保存字符串鍵

        value = get_value_from_string(key)

        f.write_command("SET " + key + value)

      elif key.type == List:

        # 用 RPUSH key item1 item2 ... itemN 命令來保存列表鍵

        item1, item2, ..., itemN = get_item_from_list(key)

        f.write_command("RPUSH " + key + item1 + item2 + ... + itemN)

      elif key.type == Set:

        # 用 SADD key member1 member2 ... memberN 命令來保存集合鍵

        member1, member2, ..., memberN = get_member_from_set(key)

        f.write_command("SADD " + key + member1 + member2 + ... + memberN)

      elif key.type == Hash:

        # 用 HMSET key field1 value1 field2 value2 ... fieldN valueN 命令來保存哈希鍵

        field1, value1, field2, value2, ..., fieldN, valueN =\
        get_field_and_value_from_hash(key)

        f.write_command("HMSET " + key + field1 + value1 + field2 + value2 +\
                        ... + fieldN + valueN)

      elif key.type == SortedSet:

        # 用 ZADD key score1 member1 score2 member2 ... scoreN memberN
        # 命令來保存有序集鍵

        score1, member1, score2, member2, ..., scoreN, memberN = \
        get_score_and_member_from_sorted_set(key)

        f.write_command("ZADD " + key + score1 + member1 + score2 + member2 +\
                        ... + scoreN + memberN)

      else:

        raise_type_error()

      # 如果鍵帶有過期時間,那麼用 EXPIREAT key time 命令來保存鍵的過期時間
      if key.have_expire_time():
        f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp())

    # 關閉文件
    f.close()

AOF重寫的一個問題:如何實現重寫?

是使用後台線程還是使用子進程(redis是單進程的),這個問題值得討論下。額,對進程線程只是概念級的,等看完之後得拿redis的進程、線程機制開刀好好學一下。

redis肯定是以效率為先,所以不希望AOF重寫造成客戶端無法請求,所以redis采用了AOF重寫子進程執行,這樣的好處有:

  1. 子進程對AOF重寫時,主進程可以繼續執行客戶端的請求
  2. 子進程帶有主進程的數據副本,使用子進程而不是線程,可以在避免鎖的情況下,保證數據的安全性

當然,有有點肯定有缺點:

  • 因為子進程在進行AOF重寫時,主進程沒有阻塞,所以肯定繼續處理命令,而這時候的命令會對現在的數據修改,這些修改也是需要寫入AOF文件的。這樣重寫的AOF和實際AOF會出現數據不一致。

為 了解決這個問題,redis增加了一個AOF重寫緩存(在內存中),這個緩存在fort出子進程之後開始啟用,redis主進程在接到新的寫命令之後,除 了會將這個寫命令的協議內容追加到AOF文件之外,還會同時追加到這個緩存中。這樣,當子進程完成AOF重寫之後,它會給主進程發送一個信號,主進程接收 信號後,會將AOF重寫緩存中的內容全部寫入新AOF文件中,然後對新AOF改名,覆蓋老的AOF文件。

在整個AOF重寫過程中,只有最後的寫入緩存和改名操作會造成主進程的阻塞(要是不阻塞,客戶端請求到達又會造成數據不一致),所以,整個過程將AOF重寫對性能的消耗降到了最低。

AOF觸發條件

最後說一下AOF是如何觸發的,當然,如果手動觸發,是通過BGREWRITEAOF執行的。如果要用redis的自動觸發,就要涉及下面3個變量(AOF的功能要開啟哦 appendonlyfile yes):

  • 記錄當前AOF文件大小的變量aof_current_size
  • 記錄最後一次AOF重寫之後,AOF文件大小的變量aof_rewrite_base_size
  • 增長百分比變量aof_rewrite_perc

每當serverCron函數(redis的crontab)執行時,會檢查以下條件是否全部滿足,如果是的話,就會觸發自動的AOF重寫:

  1. 沒有 BGSAVE 命令在執行
  2. 沒有 BGREWRITEAOF 在執行
  3. 當前AOF文件大小 > server.aof_rewrite_min_size(默認為1MB)
  4. 當前AOF文件大小和最後一次AOF重寫後的大小之間的比率大於等於指定的增長百分比(默認為1倍,100%)
默認情況下,增長百分比為100%。也就是說,如果前面三個條件已經滿足,並且當前AOF文件大小比最後一次AOF重寫的大小大一倍就會觸發自動AOF重寫
Copyright © Linux教程網 All Rights Reserved