redis是一個複雜而又設計優良的系統,說它複雜是因為整個系統涉及到了很多方面的問題,比如:哈希存儲、網絡模型、叢集特性等等。說它設計優良是因為這些問題它都提供了深思熟慮的解決方案。
我們花大量的時間學習一個技術,不僅為了能更好的使用它,同時希望學習它設計上的一些思路,這樣在解決日常工作碰到的各種各樣的問題的時候思路會更開闊。以下是對redis一小部分内部機制的思考與總結。
redis早期是不支援叢集功能的,如果需要做資料分片,需要在用戶端使用簡單哈希或者一緻性哈希的方式。此時叢集運作方式為:

這樣的方式實作的叢集,簡單并且幾乎能無限線性擴容。但是有一個緻命的問題,叢集擴容減容需要rehash操作,即重新配置設定已經存在的資料,否則部分舊的資料将不能通路,随着叢集規模的擴大,rehash操作越來越困難并且對線上叢集影響越來越大。
redis cluster設計了slot機制來解決這個問題。redis cluster中固定有16384個slot,叢集中整個資料集被散列在這些slot中。slot是一系列資料的結合,它用從0到16383的代号表示,如果一個資料的哈希值對16384取模等于某一個slot的代号,則它應該被存放在該slot中。同時redis cluster的每個節點都維護一個slot到節點的映射表,是以此時叢集的運作方式為:
上文說道slot的設計主要是為了解決rehash問題。當redis cluster需要擴容或者減容的時候,需要遷移slot中的資料到新的節點,并且指派slot到新的節點。因為同一個slot中的資料在内部都被維護在一個跳表中,是以能友善的設計接口來完成以上操作。對于使用者來說隻需要調用相應的接口就能友善的完成擴容減容操作。
小結一下:redis通過引入slot機制并設計遷移slot的接口,解決了基于哈希的叢集擴容減容操作複雜性與對線上叢集影響的問題。
slot機制還有一個很明顯的優勢,就是在處理并發的場景,因為它将資料集進行了分割,實際上減小了鎖的粒度,進而擴大了并發度。java中的concurrenthashmap容器是應用這種機制來實作并發的典型的例子,我們以它為例來說明slot機制為并發場景帶來的好處。
了解java的人都知道concurrenthashmap(這裡隻讨論jdk7中concurrenthashmap)是一種線程安全的哈希容器,但這裡我們不關注它底層并發的實作細節,不管它是cas方式還是鎖的方式,我們将注意力放在segment上。segment就是concurrenthashmap中的slot,為了命名統一我們仍然以slot來稱呼它。
concurrenthashmap初始化的時候需要指定并行度,并行度其實就是slot的個數,與redis一樣slot個數一旦确定就不能更改。slot同樣會負責整個資料集中的一部分資料,并且它還會持有一種并發機制,是以當通路同一個slot内的資料的時候需要遵守并發機制。因為并發機制不是針對整個資料集,而是每個slot獨立擁有自己的并發控制,是以容器的最大并發數是slot的個數而不是一。通常這種技術被稱為分段鎖技術。另concurrenthashmap的運作原理有點類似下圖:
細心的朋友可能會發現concurrenthashmap中每個slot有自己獨立的哈希表,資料在實體上是隔離的。而在redis中整個資料集使用同一個哈希表。雖然redis是單線程模型,資料集并不需要并發機制,但是通過公用資料集的啟發,concurrenthashmap的分段鎖的機制還可以有以下解決方案:
在上邊的方案中slot是一種更虛拟的概念,它沒有獨立的哈希表,而是所有slot公用一個哈希表,并且隻提供同步的功能。當請求過來的時候,key首先通過哈希映射到slot,以擷取通路權限,然後再次哈希映射到哈希表以通路資料。同樣slot數量一經确定就不能改變。
redis采用單線程模型,這一點上它可能是在衆多服務端程式中是最特殊的。單線程模型最大的優勢是實作簡單,但也有很明顯的劣勢,請求容易被其它操作阻塞。
學過作業系統的同學都知道早期單核心cpu也是能同時處理多任務的,方式是把任務切分成多小段,cpu不斷切換執行多個任務,因為每小段任務執行時間總夠短,從使用者的角度來說,多個任務就是在同時執行。
redis單線程模型同樣是采用這種方式處理多任務。對于執行時間長的任務,redis把它們切分成小段,以減小占用cpu的時間,進而減小對前端請求的阻塞時間。這些執行時間長的任務主要有:
redis對于請求采用面向事件的處理模型,基本思路是把每個請求劃分成多個事件,每個事件是處理的最小單元,并且每個請求的多個事件可以不連續處理。這樣多個事件處理複用了同一個線程。
redis對資料過期采用主動與被動兩種政策。主動方式是,定時掃描過期表,處理過期的資料,但是考慮到過期資料可能會很多,一次處理完畢可能需要阻塞線程時間很長,redis采用的方式是,每次隻處理一小部分資料。
當哈希表的哈希因子達到一定門檻值的時候,為了減小沖突率,需要進行rehash操作。redis的rehash機制是這樣,首先建立新的哈希表,其次進行資料遷移。因為整體資料遷移是及其耗時的操作,redis将資料遷移操作散列在每次資料請求進行中,比如前端請求key,如果發現key在老的哈希表中,會将它遷移到新的哈希表中。
在發送端對于大資料檔案的網絡傳輸本身就是分段的,它的分段大小受各個buffer大小的限制。接受端同樣會受到buffer的限制,是以接受同樣是一部分一部分的接受。是以在rdb檔案傳輸的兩端都是分段的,中間是能處理其它任務的。但是在整個傳輸階段,傳輸任務會占用大量資源,對前端請求還是有影響的。
我們拿快遞員送包裹的例子來做類比。假設快遞員送一個包裹需要2個小時,其中119分鐘的時間花費在路上,交接包裹需要1分鐘。現在有10個包裹需要他遞送到同一個小區。如果他每次拿一個包裹總共需要20個小時。但是如果他一次拿10個包裹隻需要兩個小時多一些。
對于redis請求而言,一個用戶端需要等服務端回複上一個請求後才能發送下一個請求,整個請求流程的時間,絕大部分花費在網絡傳輸上。但是如果使用pipeline的方式,一次傳輸多個指令,這樣就大大減小了大量請求的整體響應時間。
但是對于服務端而言一次性太多的指令會阻塞其它用戶端的請求,是以需要對pipeline限制發送指令的數量。
單線程模型避免了處理并發的繁瑣,但是需要特别注意長時間操作的分段處理,避免造成前端請求阻塞。
redis cluster是一種無中心的叢集,每個節點都持有一份叢集狀态,同樣每個節點都能修改叢集狀态。叢集狀态通過節點間不斷交換叢集消息來達到同步狀态。當節點收到叢集消息時,如果消息是較新的,需要更新集自己的群狀态。說到這裡可能有兩個疑問:
叢集狀态,叢集消息是什麼?
如何判斷收到的叢集消息是較新的呢?
我們先看第一個問題叢集狀态與叢集消息,本文中所說的叢集狀态主要指叢集中slot與節點的映射關系;叢集消息指某一個節點所負責的slot資訊。對于某一個節點來說它發出去的消息隻能是自己負責的slot資訊,因為隻有它自己最清楚自己負責哪些slot,對于從節點則發送它的主節點的slot資訊。
其實在redis内部叢集消息不止是發送節點所負責的slot資訊,還包括發送消息節點的主從資訊,與發送消息節點認為的其它節點的狀态資訊(fail/pfail)。這裡為了更好的說明問題把與主題不相關的部分忽略掉。
再來看第二個問題,節點如何判斷收到的叢集消息是較新的。redis中使用版本号機制來判斷消息的新舊,版本号越高表示消息越新。
在redis中這種版本号被稱作configepoch,每個節點都有一個configepoch,它是一個64位的整數。其實configepoch更應該被稱作是slot的版本号,或者某個slot與節點映射關系的版本号。怎麼了解呢?
我們從消息沖突的角度看這個問題,因為configepoch就是為了解決消息沖突的嘛。如上文所說叢集中每個節點都會維護一份叢集狀态快照。快照中每個slot都有一個與之對應的節點,每個節點都有一個configepoch,是以直覺上configepoch就像是slot的版本号。
當某一個節點負責的slot有變化的時候,節點會更新自己的configepoch,并随着叢集心跳傳播該消息,叢集消息是一系列slot、節點與configepoch組成的三元組。當新的叢集消息傳播到其它節點的時候,節點會對比節點本身與叢集消息中對應slot的configepoch來決定是否更新本地叢集狀态快照,從這層意義上看configepoch也更适合被稱作slot的版本号或者消息的版本号。
下圖是slot遷移的叢集狀态更新過程:
上圖的場景中有abc三個節點,它們開始的configepoch分别為8000900010000,并且100号slot由節點c負責。當将100号slot由節點c遷移到節點b的時候,節點b的configepoch變為10001,并且節點b會發送消息稱100号slot由自己負責。當節點ac接收到b的消息時比較configepoch,因為b的configepoch比較大它們更新本地叢集狀态。
當節點b負責的slot發生變化的時候,b的configepoch變為10001,10001是叢集中最大的configepoch。為什麼需要變為最大值而不是加一呢?其實隻需要觀察上邊100号slot變遷過程就能得出答案,開始的時候100号slot由c節點負責,它的configepoch為10000,如果隻是加一b節點的configepoch變為9001,小于10000,這樣節點ac的更新就會失敗。
通過上邊的例子能看出通過configepoch的方式來判斷消息的新舊,關鍵在于處理好如何增加configepoch,保證最近消息的configepoch是叢集中唯一且最大的,在redis中有兩種增加configepoch的方式:
redis中每個節點都會維護一個currentepoch,它的作用像是一個中介。叢集狀态發生變化的時候會增加currentepoch,并且節點交換消息的時候不斷傳播最大的currentepoch。其實redis一直努力保持每個節點都有相同的currentepoch,并且努力保持currentepoch大于等于叢集中最大的configepoch。每當需要增加configepoch的時候采用如下方式:
就能在一定程度上保證新的configepoch是叢集中唯一且最大的。上例中節點b遷入新的slot的後,其實就是通過這種方式來增加b的configepoch的,隻是上圖中并沒有畫出每個節點的currentepoch。
投票方式的主要用在failover場景下,redis的failover算法類似raft協定的選主部分,從節點向叢集中的主節點發出選票請求(注意這裡的送出請求的從節點可能是多個),主節點收到請求後根據自身條件判斷是否應該投贊同票。其中第一個獲得過半贊同投票的從節點能夠晉升為新的主節點。
其實投票的方式增加configepoch同樣是采用上一小節中的公式。隻不過從節點在将currentepoch加一後并沒有直接指派給configepoch,而是詢問其它主節點,判斷自己的currentepoch目前是不是叢集中最大的,當有過半主節點投贊同票的時候才會進行指派,否則該從節點的晉升失敗。
主節點判斷自己是否應該投贊同票的條件中有一個跟currentepoch有關。主節點會對比自己與發出投票從節點的currentepoch,如果自己的比較大則不投贊同票,因為這說明從節點的叢集狀态是很舊的。
但是因為并沒有得到所有節點的贊同投票,是以這種方式同樣不能保證自己的configepoch一定是叢集中唯一且最大值,但它是比上一種方式更嚴格的方式。
其實redis始終努力嘗試保持一個原則:每個節點的configepoch在叢集中應該是唯一的,并且後發生更改的節點的configepoch應該大于之前發生過更改的節點的configepoch。因為這樣能保證每個消息都有唯一的configepoch,并且後發生的消息的configepoch大于前發生消息的configepoch,進而保證最近的消息能完成更新。
我們看以下事件與消息處理流程,它是導緻redis消息沖突的典型場景:
上圖描述的大緻流程是節點a遷入一個slot後,沒等消息傳播到節點c與c',這對主從就發生了主從切換,切換後節點a與c'的configepoch是相同的,這種現象就是redis中的版本号沖突也即消息沖突。
發生版本号沖突後,如果此時節點a與c'發生slot遷移,這裡假設從c'遷移100号slot到a,因為此時a的configepoch已經是叢集中最大的configepoch,是以此時不需要增加configepoch。此時100号slot的消息有歸屬于a與c'兩個版本,并且兩個版本的configepoch是相同的,是以此次遷移的消息更新就失敗了。
為了解決上述沖突,redis規定當節點間交換消息時,如果發現兩個節點的configepoch相同,需要改變其中一個節點的configepoch,改變的方式同樣是configepoch=currentepoch+1。因為消息已經被處理,此時改變哪一個節點的configepoch都是可以的。
沖突解決是依靠交換心跳的,在兩個節點交換心跳包之前,發生遷移操作需要向from與to節點發送setslot指令。雖然setslot指令不會改變to節點的configepoch,但它依然會修改slot與節點的映射關系。這樣即便沖突解決時将from節點的configepoch增大,因為此時它應不負責被遷移過去的slot,是以新消息一樣更新成功了。
分布式系統消息的時序不能保證的,給每個消息加上版本号是解決沖突的最直覺的方法,也是很多分布式場景會采用的方法,但是說起來簡單,現實落地卻是很複雜的。
以上是我在使用redis過程中的一點總結與思考,同時希望自己能多多積累,以開闊思路,更好的解決工作中遇到的問題。
版權聲明:文章為作者辛勤勞動的成果,轉載請注明作者與出處。