可擴展的有狀態服務

一直以來,無狀態服務(Stateless Service)在架構設計中都被當作鐵律,因為無狀態的服務很容易橫向擴展,只需要在負載均衡之後增加節點就可以處理更多請求。但是,無狀態服務也不是完美無缺的,其中一個缺點就是和數據層之間的請求延遲,以及為了解決這種延遲增加緩存所帶來的複雜性和一致性問題。

有沒有想過引入「有狀態服務」(stateful service)來解決問題?在網上查一查,你會發現很少有人提及有狀態服務,Wikipedia甚至都沒有這個詞條。

Caitiff McCaffrey是Twitter的資深工程師,她在StrangeLoop大會上發表了主題為「構建可擴展的有狀態服務」的演講,不但釐清了有狀態服務的基本理念,也列舉了微軟、Facebook以及Uber等公司使用有狀態服務的多個案例作為佐證。也許她的演講可以為我們帶來一些啟示。

在讀下去之前,有一些基本的分散式系統概念是需要理解的,如果你對這些概念還不熟悉,不妨自行Google一下:

  • Sticky Session
  • Data Locality
  • CAP Theorem
  • AP/CP System
  • Cluster Membership
  • Consensus Protocol
  • Gossip protocols
  • Consistent Hashing
  • Back Pressure
  • DHT(Distributed Hashtable)
  • StopTheWorld(Garbage Collection)

我們先來回顧一下眾所周知的無狀態服務:把所有數據放在資料庫中,就可以在服務層橫向擴展;但是當流量不斷上升,總有一天會突破資料庫處理能力的極限,於是我們對資料庫做分片(sharding),或者轉向NoSQL資料庫,希望通過犧牲強一致性來提高可用性。這時,不可避免地,一些資料庫的邏輯就要滲透到應用層中。

另一方面,我們考慮一下無狀態服務的過程:用戶向服務層發送請求,服務層處理請求的節點A從資料庫載入數據,返回給用戶,當用戶發下一個請求的時候,可能會分配到另一個服務節點B,節點B需要去資料庫重新載入數據,此時A節點的數據被拋棄。這個反覆載入數據的過程其實很浪費,尤其對於頻繁交互的應用(比如遊戲,和一些重交互的網路應用)來說尤其如此。

Stateful的好處

首先要強調的是,Stateful並不是靈丹妙藥,大多數場景下,我們仍然需要無狀態的服務以及橫向擴展能力。但有狀態服務的確有兩個明顯的優點:

  1. Data Locality(數據本地化)。在有狀態服務中,當客戶端第一次發出請求時,服務仍然需要到資料庫載入數據,但是自此以後,數據將會保存在節點本地,這個客戶端下一個請求必須由同一個節點處理,這種模式就節省了很多數據網路傳輸。即使資料庫服務已經停止,服務節點仍然能夠獨立處理請求。

    n圖
  2. 有狀態服務有更高的可用性(A)和更強的一致性(C)。在CAP三個屬性中,我們只能選擇CP系統或者AP系統,如果我們選擇AP,就要在一致性上作出讓步。但是這個讓步本身也有程度上的差別,對於有狀態的服務來說,由於採用了Sticky Session(即一個用戶的數據都在一個固定節點上),一致性會更強。見下圖(對於其中一致性的各種級別請自行查閱):

除此以外,Sticky Session還有一個工程上的好處,對於開發分散式系統的工程師來說,因為不需要擔心數據載入到不同節點、緩存層次帶來的一致性問題,一個用戶只和一台伺服器交互,這個模型更加容易理解,更容易編程實現。

怎樣實現?

如何實現Sticky Session?

第一種顯而易見的方法就是長連接(HTTP或者TCP),這個方式簡單易行,但是缺點在於不穩定,一旦連接斷開,Sticky Session也就結束了。另外,由於各個用戶產生的負載並不相同,有一些節點會承受過高負載(熱節點),因此,必須實現反向壓力(Back Pressure),當一個節點無法承受負載時,可以有選擇性地斷開一些連接,讓一些用戶連接到其他輕負載節點上。

另一種更好的辦法就是實現集群內部路由。客戶端仍然可以連接任何一個節點,由這個節點負責把請求路由到含有用戶數據的節點上。為了實現這種方案,需要集群具有兩種能力:

  • 集群歸屬(Cluster Membership) - 集群中有哪些節點?生存狀態如何?
  • 負載分配(Workload Distribution)

集群歸屬

Membership可以有幾種類型:靜態、Gossip以及一致性(Consensus)系統。

  1. 靜態系統最為簡單,可以用一個配置文件來維護各個節點的地址,然後把這個文件部署在集群中每個節點上。缺點也顯而易見:運維困難,難於擴展。當節點宕機或者增加節點的時候,都要更新配置,重啟整個集群。對於需要保證高可用的服務來說,這是不可接受的。動態的集群歸屬能增加運維的靈活性。
  2. Gossip協議就是讓各個節點之間互相散播消息來維護集群狀態,每個節點都有自己獨立的World State,並根據接受的消息更新。在達到穩定狀態以後,所有的節點會擁有一致的World State。但是當出現網路中斷、增加、減少節點的時候,節點本地的World State就會出錯。這涉及一個設計上的取捨:因為不需要一致性協議的協調過程,這個方法的可用性更高,但是代價就是代碼需要處理路由到錯誤節點帶來的不確定性。
  3. 一致性協議。當前有很多開源的一致性協議實現,比如ZooKeeper/Etcd等。一致性系統能保證集群歸屬信息的準確和及時更新,但是問題在於協調的效率較低。所以除非絕對必要,不建議採用這種方案。

負載分配

把工作分配給集群中的節點大概有三種方式:

1. 隨機分配給任意節點。用這種方式,寫請求可以由任意節點處理,但是讀請求需要向每一個節點發出查詢。嚴格來說,這不是Sticky Session,但是實際中往往很有效。

2. 一致性Hash。可以通過SessionID或者UserID的Hash值來決定處理節點。一致性Hash會把請求ID映射到一個圓上,在圓上順時針移動,遇到第一個節點就是處理請求的節點(具體實現請查閱Wikipedia)。這種分配方式是確定性的,帶來的問題就是熱節點:很多請求可能被分配到一個節點上,導致節點過載,由於確定性的分配方式,不能把負載轉移到其他節點。所以,每台節點的計算資源都要留足餘量(headroom),以降低過載的可能性。

3. 分散式Hashtable。用一個分散式的Hashtable來維護、查詢請求分配的節點,用這種方式,當一個節點宕機或者過載的時候,可以更改Hashtable把負載重新分配到其他節點。

真實案例

Facebook Scuba

Scuba是Facebook實現的一個分散式內存資料庫。它使用了靜態的集群歸屬,負載分配使用了隨機的分配策略,讀請求需要查詢集群中的每個節點。因為在真實環境中,不可能保證所有節點都同時在線,所以用戶的讀請求不一定能返回所有數據,他們的做法是返回查詢到的數據,以及數據的完整程度(百分比),由客戶端決定這些數據是否足夠。

Uber Ringpop

Ringpop是一個基於node的應用層分片協議。仔細考慮一下Uber的服務,你發現它很適合根據地理位置分片處理,把一個位置用戶的請求發送到一個固定的節點上,並在這個節點上維護行程信息。Ringpop使用Gossip協議維護集群歸屬,並使用一致性Hash來分配負載。為了避免熱節點問題,必須保證每個節點有足夠處理能力。

Microsoft Orleans

Orleans是一個基於Actor的分散式系統運行時,Halo 4這個遊戲就是基於Orleans開發的。Orleans採用Gossip協議維護歸屬信息,負載分配採用了一致性Hash和DHT相結合的方式。用戶的ID經過一致性Hash映射到一個節點,這個節點保存了這個用戶對應的DHT,再查詢DHT定位到處理用戶請求的Actor位置。對這個項目感興趣的,尤其是做遊戲開發的同學可以關注一下這個開源項目,可以參見文末參考鏈接。

需要注意的問題

不受限的數據結構

注意不要使用不受限的隊列和內存數據結構,有狀態的服務更容易出現OutOfMemory的問題,或者垃圾回收器會StopTheWorld。在無狀態的服務中,很多數據結構都是伴隨請求的生命周期產生和消失的,內存在請求結束以後就會被垃圾回收,所以即使段時間內存不足,也不會有很大影響。但是在有狀態服務中,很多session的時間很長,會累積大量數據,代碼必須對這種可能性加以防範。

內存管理

同上一條,因為很多數據會長時間駐留內存,會給垃圾回收機制帶來很多影響:對於垃圾回收器來說,回收很老的一代(Generation)內存代價是比較大的。你需要對垃圾回收器做性能的調優,或者乾脆使用不需要GC的技術(比如C++)。

重新載入數據

大部分時候,有狀態服務是不需要重載數據的。有三個例外:

  1. 第一次連接。這時候往往載入數據很耗時,所有數據要從資料庫讀出,所以為了用戶體驗,第一個請求不要載入太多數據;另外,即使第一個請求載入數據超時,也不要停止載入,因為客戶端總會再次連接,而第二次連接的響應速度會很快,因為數據已經讀入本地內存。
  2. 崩潰恢復,這時候可能需要預載入一些數據。
  3. 部署新代碼,每次部署新代碼需要重啟服務並載入所有數據。對於使用確定性負載分配的方案,這會是一個問題。 這裡值得一提的是Facebook部署新代碼時採取的一個聰明的技巧。Facebook的服務重啟以後載入數據,由於數據量巨大,可能需要幾個小時的時間,這對於快速上線新代碼非常不利。他們採用了一個方法,分離了內存和進程的生命周期:在部署新代碼的時候,原進程停止服務,開始把數據轉移到共享內存中,然後退出,在啟動新進程以後,可以直接拷貝共享內存到程序中,在幾分鐘內快速開始提供服務。

總結

  1. 關於集群歸屬和負載分配的策略,並沒有一個「最好」的方案,需要根據需求做出權衡。
  2. 儘管Stateful是一個比較新的概念,沒有必要害怕嘗試,很多知名企業都有成功經驗。
  3. 多讀論文。其實關於Stateful的很多理念並不是最近發明的,這個演講中很多方案都來自於60/70年代的論文,都是已經解決的問題,不要重複發明輪子。

參考

youtube.com/watch?

CaitieM20/Talks

Making the Case for Building Scalable Stateful Services in the Modern Era - High Scalability -

The Case for Building Scalable Stateful Services

research.facebook.com/p

uber/ringpop-node

Orleans - Virtual Actors - Microsoft Research

dotnet/orleans

Creating scalable backends for games using open source Orleans framework


推薦閱讀:

Windows 黑科技工具推薦 #多媒體
譯文: iPhone X的AR引擎對於設計師意味著什麼?
諸葛錦囊 | 以分答為例,數據化的用戶研究應該這樣做
2017年黑五VPS/域名/虛擬主機優惠盤點

TAG:互联网 | 软件架构 | 软件开发 |