天天看點

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

前言

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

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

字元串

哈希表(map)

清單(list)

集合(set)

有序集

下面我們就從 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自己實作了雙端連結清單

雙端連結清單主要兩個作用:

作為redis清單類型的底層實作之一

作為通用資料結構被其他子產品使用

雙端連結清單及其節點的性能特征如下:

節點帶有前驅和後繼指針

連結清單是雙向的,是以對表頭和表尾操作都是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類型的其中一種底層實作。下面分别來說明。

鍵空間:redis是一個鍵值對資料庫,資料庫中的鍵值對就由字典儲存:每個資料庫都有一個與之相對應的字典,這個字典被稱為鍵空間。當使用者添加一個鍵值對到資料庫(不論鍵值對是什麼類型),程式就講該鍵值對添加到鍵空間,删除同理。

用作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步:

給ht[1]配置設定至少2倍于ht[0]的空間

将ht[0]資料遷移到ht[1]

清空ht[0], 将ht[0]指針指向ht[1],ht[1]指針指向ht[0]

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

dictRehashStep:從名字可以看出,是按照step進行的。當字典處于rehash狀态(dict的rehashidx不為-1),使用者進行增删查改的時候會觸發dictRehashStep,這個函數就是将第一個索引不為空的全部節點遷移到ht[1],因為一般情況下節點數目不會超過5(超過基本會觸發強制rehash),是以成本很低,不會影響到響應時間。

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在條件允許的情況下,會使用記憶體映射資料結構來代替内部資料結構,主要有:

整數集合 intset

壓縮清單 ziplist

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

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

1. 整數集合

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

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

隻儲存整數元素

元素的數量不多[因為它不費記憶體,費CPU。量多的話,肯定是CPU為第一考慮]

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

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

length 肯定就是元素的個數喽,然後是具體的元素,我們發現是 int8_t 類型的,實作上它隻是一個象征意義上的類型,到實際配置設定時候,會根據具體元素的類型選擇合适的類型。而且 contents 有兩個特點:

沒有重複元素

元素在數組中從小到大排序

是以,添加元素到intset有下面幾個步驟:

判斷插入元素是否存在于集合,如果存在,沒有任何操作(無重複元素)

看元素的長度是否需要把intset更新,如果需要,先更新

插入元素,而且要保證在contents數組中,從小到大排序

維護length

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

儲存有序、無重複的整數元素

根據元素的值自動選擇對應的類型,但是int set隻更新、不降級

更新會引起整個int set中的contents數組重新記憶體配置設定,并移動所有的元素(因為記憶體不一樣了),是以複雜度為O(N)

因為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中工作的。

一張圖說明問題的本質:

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

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

redisObject對象

基于redisObject對象的類型檢查

基于redisObject對象的顯式多态函數

對redisObject進行配置設定、共享和銷毀的機制

下面看下redisObject的定義:

其中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是這麼幹的:

根據key,檢視資料庫中是否存在對應的redisObject,沒有就傳回null

檢視redisObject的type是否和要執行的操作相符

根據redisObject的encoding屬性選擇對應的資料結構

傳回處理結果

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

對于一些操作來說,傳回值就那幾個。對于整數來說,存入的資料也通常不會太大,是以redis通過預配置設定一些常見的值對象,并在多個資料結構之間(很不幸,你得時指針才能指到這裡)共享這些對象,避免了重複配置設定,節約記憶體。同時也節省了CPU時間

如圖所示:

三個清單的值分别為:

清單 A : [20130101, 300, 10086]

清單 B : [81, 12345678910, 999]

清單 C : [100, 0, -25, 123]

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

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

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

REDIS_ENCODING_INT使用long類型儲存long的值

REDIS_ENCODING_ROW使用sdshdr儲存sds、long long、double、long double等

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

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

哈希表,嗯,它的底層實作也有兩種:

REDIS_ENCODING_ZIPLIST

REDIS_ENCODING_HT(字典)

當建立新的哈希表時,預設是使用壓縮清單作為底層資料結構的,因為省記憶體呀。隻有當觸發了門檻值才會轉為字典:

哈希表中某個鍵或者值的長度大于server.hash_max_ziplist_value(預設為64)

壓縮清單中的節點數量大于server.hash_max_ziplist_entries(預設為512)

清單嘛,其實就是隊列。它的底層實作也有2種:

REDIS_ENCODING_LINKEDLIST

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

試圖往清單中插入一個字元串值,長度大于server..list_max_ziplist_value(預設是64)

ziplist包含的節點超過server.list_max_ziplist_entries(預設值為512)

阻塞指令

對于清單,基本的操作就不介紹了,因為清單本身的操作和底層實作基本一緻,是以我們可以簡單的認為它具有雙端隊列的操作即可。重點讨論一下清單的阻塞指令比較好玩。

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

隻有當這些指令作用于空清單,才會造成用戶端阻塞

如果被處理的清單不為空,它們就執行無阻塞版本的LPOP/RPOP/RPOPLPUSH

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

将用戶端的連接配接狀态更改為“正在阻塞”,并記錄這個用戶端是被那些鍵阻塞(可以有多個),以及阻塞的最長時間

将用戶端的資訊加入到字典server.db[i].blocking_keys中,i就是用戶端使用的資料庫編号

繼續保持用戶端和伺服器端的連接配接,但是不發送任何資訊,造成用戶端阻塞

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

被動脫離:有其他用戶端為造成阻塞的鍵加入了元素

主動脫離:超過阻塞的最長時間

強制脫離:關閉用戶端或者伺服器

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

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

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

REDIS_ENCODING_INTSET

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

如果第一個添加進集合的元素是long long類型的,那麼編碼就使用第一種

否則使用第二種

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

intset儲存的整數值個數超過server.set_max_intset_entries(預設值為512)

從第二個元素開始,如果插入的元素類型不是long long的,就要轉化成第二種

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

終于看到最後一個資料結構了,雖然隻有5個- -。。。。首先從指令上就可以區分這幾種了:

GET/SET是字元串

H開頭的是哈希表

L開頭的是清單

S開頭的是集合

Z開頭的是有序集

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

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

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的思路确實是很流弊的。

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

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

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

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

原子性(atomicity):一個事務是一個不可分割的最小工作機關,事務中包括的諸操作要麼都做,要麼都不做。

一緻性(consistency):事務必須是使資料庫從一個一緻性狀态變到另一個一緻性狀态。一緻性與原子性是密切相關的。

隔離性(isolation):一個事務的執行不能被其他事務幹擾。即一個事務内部的操作及使用的資料對并發的其他事務是隔離的,并發執行的各個事務之間不能互相幹擾。

持久性(durability):持續性也稱永久性(permanence),指一個事務一旦送出,它對資料庫中資料的改變就應該是永久性的。接下來的其他操作或故障不應該對其有任何影響。

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

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

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

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

開始事務

指令入隊(看,上面都有QUEUED這個傳回值)

執行事務

這幾個過程都比較簡單,開始事務就是切換到事務模式;指令入隊就是把事務中的每條指令記錄下來,包括是第幾條指令,指令參數什麼的(當然,事務中是不能再嵌套事務的,是以再有事務關鍵字(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為什麼這樣做。

原子性:redis的單條指令是原子性的,但是redis沒有對事務進行原子性保護。如果一個事務沒有執行成功,是不會進行重試或者復原的。

一緻性【redis保證】:這個要分三個層次:

入隊錯誤:如果執行一個錯誤的指令(比如指令參數不對:set key),那麼會被标記為REDIS_DIRTY_EXEC,執行會直接傳回錯誤

執行錯誤:對某個類型key執行其他類型的操作,不會影響結果,是以不會影響事務的一緻性。事務會繼續進行

redis程序被當機:簡單來說,redis有持久化功能。但是這個持久化是建立在執行成功的基礎上,如果不成功是不會進行持久化的。是以,出問題時都會保證要麼事務沒有執行;要麼事務執行成功。是以保證了資料的一緻性。

隔離性【redis保證】:因為redis是單程序程式,并在執行事務時不會中斷,一直執行到事務對列為空,是以隔離性是可以保證的。

持久性:不管是單純的記憶體模式,還是開啟了持久化檔案的功能,事務的每條指令執行過程中都會有時間間隙,如果這時候出現問題,持久化還是無法保證。是以,redis使用的是事務沒執行或者事務執行完成才會進行持久化工作(AOF模式除外,雖然現在還沒有看到- -)

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

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

慢查詢日志是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行左右吧。

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

主要來介紹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和記憶體的平衡。

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

逐個來說,先搞定RDB。

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

SAVE:直接調用rdbSave,阻塞redis主程序,直到儲存完成,這完成過程中,不接受用戶端的請求

BGSAVE:fork一個子程序,子程序負責調用rdbSave,并在儲存完成知乎向主程序發送信号,通知儲存已經完成。因為是fork的子程序,是以主程序還是可以正常工作,接受用戶端的請求

整個流程可以用僞代碼表示:

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

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

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時候主程序阻塞,不再接受用戶端的請求

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

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

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

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

模式 1 的儲存操作隻會在AOF 關閉或 Redis 關閉時執行, 或者由作業系統觸發, 在一般情況下, 這種模式隻需要為寫入阻塞, 是以它的寫入性能要比後面兩種模式要高, 當然, 這種性能的提高是以降低安全性為代價的: 在這種模式下, 如果運作的中途發生停機, 那麼丢失資料的數量由作業系統的緩存沖洗政策決定。

模式 2 在性能方面要優于模式 3 , 并且在通常情況下, 這種模式最多丢失不多于 2 秒的資料, 是以它的安全性要高于模式 1 , 這是一種兼顧性能和安全性的儲存方案。

模式 3 的安全性是最高的, 但性能也是最差的, 因為伺服器必須阻塞直到指令資訊被寫入并儲存到磁盤之後, 才能繼續處理請求。

AOF檔案的還原

對于AOF檔案的還原就特别簡單了,因為AOF是按照AOF協定儲存的redis操作指令,是以redis會僞造一個用戶端,把AOF儲存的指令重新執行一遍,執行之後就會得到一個完成的資料庫,僞代碼如下:

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條指令,恢複到最近的狀态的代價就變為最小。

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

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

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

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

子程序對AOF重寫時,主程序可以繼續執行用戶端的請求

子程序帶有主程序的資料副本,使用子程序而不是線程,可以在避免鎖的情況下,保證資料的安全性

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

因為子程序在進行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重寫:

沒有 BGSAVE 指令在執行

沒有 BGREWRITEAOF 在執行

目前AOF檔案大小 > server.aof_rewrite_min_size(預設為1MB)

目前AOF檔案大小和最後一次AOF重寫後的大小之間的比率大于等于指定的增長百分比(預設為1倍,100%)

預設情況下,增長百分比為100%。也就是說,如果前面三個條件已經滿足,并且目前AOF檔案大小比最後一次AOF重寫的大小大一倍就會觸發自動AOF重寫。