高性能分布式計算與存儲系統設計概要(下篇)
在上篇里,我們主要討論了,這個系統怎樣處理大數據的“讀”操作,當然還有一些細節沒有講述。下篇,我們將主要講述,“寫”操作是如何被處理的。我們都知道,如果只有“讀”,那幾乎是不用做任何數據同步的,也不會有並發安全問題,之所以,會產生這樣那樣的問題,會導致緩存和數據庫的數據不一致,其實根源就在於“寫”操作的存在。下面,讓我們看一看,當系統需要寫一條數據的時候,又會發生怎樣的事情?
同樣,我們還是以friend list為例。現在,我登陸了這個網站,獲取了friend list之后,我添加了一個好友,那么,我的friend list必定要做修改和更新(當然,添加好友這一個動作肯定不會只有修改更新friend list這一個請求,但我們以此為例,其它請求也是類似處理),那么,這個要求修改和更新friend list的請求,和獲取friend list請求類似,在被slave節點中的服務進程處理之前,也是先通過DNS負載均衡,被分配到合適的master節點,再由master節點,分配到合適的相對空閑的負責這一功能的slave點上。現在假設,前面我們已經講過,獲取friend list這樣的請求,非常常用,所以,提供這一供能的服務進程將會有多份,比如,有10份,服務進程編號為0~9,同時運行在10個(也可能僅運行在1個~9個slave節點上!)slave節點上,具體分配請求的時候,選擇哪一個slave節點和哪一份服務進程呢?這當然有許多種規則去影響分配策略,我們就舉一個最簡單的例子,采用用戶id對10取模,得到0~9的結果,即是所選擇的服務進程編號,假設我的用戶id尾號為9,那么我這個請求,只會被分配到編號為9的服務進程去處理(當然,所有用戶id尾號為9的都是如此),編號為9的服務進程,也只負責為數據庫中用戶id尾號為9的那些數據做緩存,而用戶id尾號為0~8的緩存則由其它服務進程來處理。如果所需的請求是以剛才這種方式工作的,那么現在我要求修改和更新friend list的這個請求,將只會被分配到服務進程編號為9的進程來處理,我們稱之為“單點模型”(也就是說,同一條數據只會有一份可用緩存,備份節點上的的不算),你可能已經猜到了,還會有“多點模型”——即同時有好幾個服務進程都會負責同樣的緩存數據,這是更復雜的情況,我們稍后再討論。
現在,我們接着說“單點模型” 。這個修改和更新friend list的請求到了編號為9的服務進程中后,如何被處理呢?緩存肯定先要被處理,之后才考慮緩存去和數據庫同步一致,這大家都知道(否則還要這個系統干嘛?)大家還知道,只要涉及到並發的讀寫,就肯定存在並發沖突和安全問題,這又如何解決呢?我們有兩種方式,來進行讀寫同步。
1、 第一種方式,就是傳統的,加鎖方式——通過加鎖,可以有效地保證緩存中數據的同步和正確,但缺點也非常明顯,當服務進程中同時存在讀寫操作的線程時,將會存在嚴重的鎖競爭,進而重新形成性能瓶頸。好在,通常使用這種方式處理的業務需求,都經過上述的一些負載均衡、分流措施之后,鎖的粒度不會太大,還是上述例子,我最多也就鎖住了所有用戶id尾號為9的這部分緩存數據更新,其它90%的用戶則不受影響。再具體些,鎖住的緩存數據可以更小,甚至僅鎖住我這個用戶的緩存數據,那么,鎖產生的性能瓶頸影響就會更小了(為什么鎖的粒度不可能小到總是直接鎖住每個用戶的緩存數據呢?答案很簡單,你不可能有那么多的鎖同時在工作,數據庫也不可能為每個用戶建一張表),即鎖的粒度是需要平衡和調整的。好,現在繼續,我要求修改和更新friend list的請求,已經被服務進程中的寫進程在處理,它將會申請獲得對這部分緩存數據的鎖,然后進行寫操作,之后釋放鎖,傳統的鎖工作流程。在這期間,讀操作將被阻塞等待,可想而知,如果鎖的粒度很大,將有多少讀操作處於阻塞等待狀態,那么該系統的高性能就無從談起了。
2、有沒有更好的方法呢?當然有,這就是無鎖的工作方式。首先,我們的網站,是一個讀操作遠大於寫操作的網站(如果需求相反,可能處理的方式也就相反了),也就是說,大多數時候,讀操作不應該被寫操作阻塞,應優先保證讀操作,如果產生了寫操作,再想辦法使讀操作“更新”一次,進而使得讀寫同步。這樣的工作方式,其實很像版本管理工具,如svn的工作原理:即,每個人,都可以讀,不會因為有人在進行寫,使得讀被阻塞;當我讀到數據后,由於有人寫,可能已經不是最新的數據了,svn在你嘗試提交寫的時候,進行判斷,如果版本不一致,則重新讀,合並,再寫。我們的系統也是按類似的方式工作的:即每個線程,都可以讀,但讀之前先比較一下版本號,然后讀緩存數據,讀完之后准備返回給Web時,再次比較版本號,如果發現版本已經被更新(當然你讀的數據頂多是“老”數據,但不至於是錯誤的數據,Why?還是參考svn,這是"Copy and Write"原理,即我寫的那一份數據,是copy出來寫的,寫完再copy回去,不會在你讀出的那一份上寫),則必須重新讀,直到讀到的緩存數據版本號是最新的。前面已經說過,比較和更新版本號,可認為是原子操作(比如,利用CAS操作可以很好的完成這一點,關於CAS操作,可以google到一大堆東西),所以,整個處理流程就實現了無鎖化,這樣,在大數據高並發的時候,沒有鎖瓶頸產生。然而,你可能已經發現其中的一些問題,最顯著的問題,就是可能多讀不止一次數據,如果讀的數據較多較大,又要產生性能瓶頸了(苦!沒有辦法),並且可能產生延遲,造成差的用戶體驗。那么,又如何來解決這些問題呢?其實,我們是根據實際的業務需求來做權衡的,如果,所要求的請求,允許一定的延遲存在,實時性要求不是最高,比如,我看我好友發的動態,這樣的緩存數據,並不要求實時性非常高,稍稍有延遲是允許的,你可以想象一下,如果你的好友發了一個狀態,你完全沒有必要,其實也不可能在他點擊“發布”之后,你的動態就得到了更新,其實只要在一小段時間內(比如10秒?)你的動態更新了,看到了他新發布了狀態,就足夠了。假設是這樣的請求,且如果我采用第1種加鎖的方式所產生的性能瓶頸更大,那么,將采用這種無鎖的工作方式,即當讀寫有沖突時候,讀操作重新讀所產生的開銷或延遲,是可以忍受的。比較幸運的是,同時有多個讀寫線程操作同一條緩存數據導致多次的重讀行為,其實並不是總是發生,也就是說,我們系統的大數據並發,主要在多個進程線程同時讀不同條的數據這一業務需求上,這也很容易理解,每個用戶登陸,都是讀他們各自的friend list(不同條數據,且在不同的slave節點上),只不過,這些請求是並發的(如果不進行分布式處理會沖垮服務器或數據庫),但是並不總是會,許多用戶都要同時讀某一條friend list同時我還在更新該條friend list導致多次無效的重讀行為。
我們繼續上面的friend list。現在,我的friend list已經在緩存中被修改和更新了。無論是采用方式1還是方式2進行,在這期間,如果恰好有其它線程來讀我的friend list,那么總之會受到影響,如果是方式1,該請求將等待寫完畢;而如果是方式2,該請求將讀2次(也可能更多,但實在不常見)。這樣的處理方式,應該不是最好的,但前面已經說過了,我們的系統,主要解決:大流量高並發地讀寫多條數據,而不是一條。接下來,該考慮和數據庫同步的事情了。
恩,剛才說了那么多,你有沒有發現,經過我修改和更新friend list后,緩存中的數據和數據庫不一致了呢?顯然,數據庫中的數據,已經過期了,需要對其更新。現在,slave節點中的編號為9的服務進程,更新完了自己的緩存數據后(修改更新我的friend list),將“嘗試”向數據庫更新。注意,用詞“嘗試”表明該請求不一定會被馬上得到滿足。其實,服務進程對數據庫的更新,是批量進行的,可認為是一個TaskContainer(任務容器),每間隔一段時間,或得到一定的任務數量,則成批地向數據庫進行更新操作,而不是每過來一個請求,更新緩存后就更新一次數據庫(你現在知道了這樣做又節省了多少次數據庫操作!)。那么,為什么可以這樣做呢?因為,我們已經有了緩存,緩存就是我們的保障,在“單點模型”下,緩存更新后,任何讀緩存的操作,都只會讀到該緩存,不需要經過數據庫,參看上篇中提到過此問題。所以,數據庫的寫更新操作,可以“聚集”,可以一定延遲之后,再進行處理。你會發現,既然如此,我就可以對這些操作進行合並、優化,比如,兩個寫請求都是操作同一張表,那么可以合並成一條,沒錯,這其實已經涉及到SQL優化的領域了。當然,你也會發現,現在緩存中的新數據還沒有進行持久化,如果在這個時間點,slave節點機器down掉了,那么,這部分數據就丟失了!所以,這個延遲時間並不會太長,通常10秒已經足夠了。即,每10秒,整理一下我這個服務進程中已經更新緩存未更新DB的請求,然后統一處理,如果更杞人憂天(雖然考慮數據安全性決不能說是杞人憂天,但你要明白,其實任何實時服務器發生down行為總是會有數據丟失的,只是或多或少),則延遲間隔可以更短一些,則DB壓力更大一些,再次需要進行實際的考量和權衡。至此,我的friend list修改和更新請求,就全部完成了,雖然,可能在幾十秒之前,就已經在頁面上看到了變化(通過緩存返回的數據)。
那么,讀和寫都已經講述了,還有其它問題嗎?問題還不少。剛才討論的,都是“單點模型”。
即,每一條數據庫中的數據,都只有一份緩存數據與之對應。然而,實際上,“多點模型”是必須存在的,而且是更強大的處理方式,也帶來同步和一致性的更多難題,即每一條數據,可能有多份緩存與之對應。即多個slave節點上的服務進程中,都有一份對應DB中相同數據的緩存,這個時候,又將如何同步呢?我們解決的方式,叫做“最終一致性”原則,關於最終一致性模型,又可以google到一大堆,特別要提出的是GoogleFS的多點一致性同步,就是通過“最終一致性”來解決的,通俗的講,就是同一條數據,同一時刻,只能被一個節點修改。假設,我現在的業務,是“多點模型”,比如,我的friend list,是多點模型,有多份緩存(雖然實際並不是這樣的),那么,我對friend list的修改和更新,將只會修改我被分配到的slave節點服務進程中的緩存,其它服務進程或slave節點的緩存,以及數據庫,將必須被同步更新,這是如何做到的呢?這又要用到上篇曾提到的Notification(通知服務),這個模塊雖然沒有在架構圖中出現,卻是這個系統中最核心的一種服務(當然,它也是多份的,呵呵),即,當一條數據是多點模型時,當某一個服務進程對其進行修改和更新后,將通過向master節點提交Notificaion並通知其它服務進程或其它slave節點,告知他們的緩存已經過期,需要進行更新,這個更新,可能由所進行修改更新的服務進程,發送緩存數據給其它進程或節點,也由可能等待DB更新之后,由其它節點從DB進行更新,從而間接保證多點一致性。等等,剛才不是說,通常10秒才批量更新DB嗎?那是因為在單點模型下,這樣做是合理的,但在多點模型下,雖然也是批理對數據庫進行更新,但這樣的延遲通常非常小,可認為即時對數據庫進行批量更新,然后,通過Notification通知所有有這一條數據的節點,更新他們的緩存。由此可見,多點模型,所可能產生的問題是不少的。那么,為什么要用多點模型呢?假設我有這樣的業務:大數據高並發的讀某一條數據,非常非常多的讀,但寫很少,比如一張XX門的熱門圖片,有很多很多的請求來自不同的用戶都需要這個條數據的緩存,多點模型即是完美的選擇。我許多slave節點上都有它的緩存,而很少更新,則可最大限度的享用到多點模型帶來的性能提升。
還有一些問題,不得不說一下。就是down機和定期緩存更新的問題。先說宕機,很顯然,緩存是slave節點中的服務進程的內存,一旦節點宕機,緩存就丟失了,這時就需要前面我提到過的“重建緩存”,這通常是由master節點發出的,master節點負責監控各個slave節點(當然也可以是其它master節點)的運行狀況,如果發現某個slave節點宕機(沒有了“心跳”,如果你了解一些Hadoop,你會發現它也是這樣工作的),則在slave節點重新運行之后(可能進行了重啟),master節點將通知該slave節點,重建其所負責的數據的緩存,從哪重建,當然是從數據庫了,這需要一定的時間(在我們擁有百萬用戶之后,重建一個slave節點所負責的數據的緩存通常需要幾分鍾),那么,從宕機到slave節點重建緩存完畢這一段時間,服務由誰提供呢?顯然備份節點就出馬了。其實在單點模型下,如果考慮了備份節點,則其實所有的請求都是多點模型。只不過備份節點並不是總是會更新它的緩存,而是定期,或收到Notification時,才會進行更新。master節點在發現某個slave節點宕機后,可以馬上指向含有同樣數據的備份節點,保證緩存服務不中斷。那么,備份節點的緩存數據是否是最新的呢?有可能不是。雖然,通常每次對數據庫完成批量更新后,都會通知備份節點,去更新這些緩存,但還是有可能存在不一致的情況。所以,備份節點的工作方式,是特別的,即對於每次請求的緩存都采用Pull(拉)方式,如何Pull?前面提到的版本管理系統再次出馬,即每次讀之前,先比較版本,再讀,寫也是一樣的。所以,備份節點的性能,並不會很高,而且,通常需要同時負責幾個slave節點的數據的備份,所以,存在被沖垮的可能性,還需要slave節點盡快恢復,然后把服務工作重新還給它。
再說定期緩存更新的問題。通常,所有的slave節點,都會被部署在夜深人靜的某個時候(如02:00~06:00),用戶很少的時候,定期進行緩存更新,以盡可能保證數據的同步和一致性,且第二天上午,大量請求到達時,基本都能從緩存返回最新數據。而備份節點,則可能每30分鍾,就進行一次緩存更新。咦?前面你不是說,備份節點上每次讀都要Pull,比較版本並更新緩存,才會返回嗎?是的,那為什么還要定期更新呢?答案非常簡單,因為如果大部分緩存都是最新的數據,只比較版本而沒有實際的更新操作,所消耗的性能很小很小,所以定期更新,在發生slave節點宕機轉由備份節點工作的時候,有很大的幫助。
最后,再說一下Push(推送)方式,即,每次有數據改動,都強制去更新所有緩存。這種方式很消耗性能,但更能保證實時性。而通常我們使用的,都是Pull(拉)方式,即無論是定期更新緩存,還是收到Notification(雖然收通知是被“推”了一把)后更新緩存,其實都是拉,把新的數據拉過來,就好了。在實際的系統中,兩種方式都有,還是那句話,看需求,再決定處理方式。