1. 程式人生 > >說說大型網站分散式服務框架的設計思想

說說大型網站分散式服務框架的設計思想

1 網站功能持續膨脹後的困境與應對方式

原先的網站架構是這樣的:

現有的網站架構

在業務量比較小(日均百萬筆訂單)的情況下,可以很好地支撐系統業務。但隨著業務量的繼續擴大,我們可能會想通過增加應用伺服器的數量來處理這些新增的業務量,但這又給資料庫的連線帶來新的壓力。而且,隨著網站規模的增大、開發人員的增多,每個應用都變得複雜而臃腫,而且存在重複的程式碼。這樣的狀況影響到了整體的研發效率,而且對穩定性也造成了一定的影響。

這種情況下,我們可以拆分應用,把應用變小。這有兩種實現方案。

【1】把大應用拆分為多個:

拆分應用

這樣的好處是可以相對較快地完成。問題是:資料庫的連線壓力仍然存在;而且系統之間會存在一些重複的程式碼,這可以使用共享庫來解決,但使用起來不太方便。

【2】服務化:

服務化

我們在中間加入了服務層。這樣做的好處是:系統架構更清晰了,而且也更加立體了。甚至那些散落在應用系統中的程式碼也可以變為服務咯O(∩_∩)O~

服務化方式可以讓小團隊可以更專注於某個具體的服務或應用上,這樣可以更好、更快速地開發出某個具體的應用。

2 服務框架的設計

2.1 應用從集中式走向分散式所遇到的問題

服務化之後,原先的一些本地呼叫會變為遠端呼叫。這種情況下,我們最關心的是如何提高易用性和降低效能損失。

服務框架呼叫過程

從圖中可以看出,原來在單機的單個程序中,一個方法的呼叫會分散到兩個節點(客戶端與服務端)並經歷多個過程。

這裡會用到最基礎的網路通訊知識(比如使用 Socket 進行通訊)。

需要對呼叫的請求資訊進行編碼,然後傳送給遠端節點,解碼後再進行真正的呼叫。

2.2 服務框架原型設計

2.2.1 客戶端設計

本地方法變為遠端服務流程圖(客戶端呼叫服務端)

如果在請求方和服務方之間有 LVS (Linux Virtual Server,Linux 虛擬伺服器)或硬體負載均衡方案,那麼 “獲取可用服務地址列表” 過程返回的就是LVS 或硬體負載均衡的地址或埠。

如果使用名稱服務,那麼 “獲取可用服務地址列表” 過程返回的就是當前可用服務的地址列表。一般來說,我們會使用 key 的服務名字(完整類名 + 版本號)作為介面的全名。

構造請求資料是把物件變為二進位制資料,也就是序列化。可以直接使用 Java 序列化來完成編碼工作。

通訊方面,可以使用 Socket 做一個簡單實現,把 Java 序列化後的資料傳送過去。

傳送結束後,等待遠端服務執行然後返回結果。收到結果後,對資料進行反序列化,然後得到實際執行的結果。

2.2.2 服務端設計

服務端設計流程

對於服務端,需要在啟動後進行監聽。因為需要持續性地接收請求並進行處理。這裡最重要的是服務的名稱、服務的版本號、需要呼叫的方法名稱和引數,以及呼叫的連線。

“依據名稱與版本號獲取服務” 節點會在本地定位具體提供服務的節點。我們會有一張服務登錄檔,然後根據名稱與版本號對服務例項進行管理。會在啟動時構建對應關係的初始值,並提供執行時的修改,即支援動態釋出服務。

得到具體的服務例項後,可以通過反射方式來呼叫服務,然後序列化結果為二進位制資料,最後通過網路把結果傳送給客戶端。

2.3 客戶端的設計與實現

客戶端也就是服務呼叫端。

客戶端工作流程

2.3.1 使用服務框架

進行遠端服務呼叫時,即可以在中間放置代理伺服器,也可以使用直連,但都必須在呼叫端使用一個客戶端程式。如果呼叫端的系統很多,就必須每個系統實現一遍,這樣成本太高了。所以我們需要一個服務框架的通用實現。

【1】使用服務框架

大多數使用 Java 進行開發的系統都會使用 Spring 來作為元件的容器。所以服務端框架可以在請求發起端提供一個通用的 Bean。這個 Bean 會有這些配置:
* interfaceName - 介面名稱。
* version - 版本號。因為介面是會變化的,使用版本號可以對新、舊方法進行區分隔離。
* group(可選) - 組名稱。如果同一個介面的遠端服務有很多機器,那麼我們可以把這些遠端服務的機器進行歸組,這樣就可以隔離不同的呼叫者。

這個 通用的 Bean,是完成本地和遠端服務的通用橋樑。內部使用 Java 的動態代理技術來完成遠端呼叫。

【2】接入服務框架會遇到的問題

(1)服務框架部署方式

  • 服務框架作為應用的擴充套件方案
    服務框架作為應用的擴充套件

這種方案是把服務框架作為應用的一個依賴包,並與應用一起打包。這樣,服務框架就會變為應用的一個庫,並隨著應用一起啟動。但如果要升級服務框架,就需要更新應用本身。

  • 服務框架是 Web 容器的一部分

服務框架是 Web 容器的一部分

Web 應用一般使用 JBoss、Tomcat、Jetty 作為容器,因此要遵循不同容器所支援的方法,把服務框架作為容器的一部分。

  • 服務框架自身是容器

服務框架自身是容器

但有的情況下可能不需要容器,那麼服務框架自身就需要變為一個容器以提供遠端呼叫和遠端服務功能。

(2)Jar 包衝突

服務框架自身所依賴的包有可能與應用本身所依賴的包,在版本上衝突。這可以使用 ClassLoader 技術解決。

ClassLoader 結構

可以把服務框架自身用到的類和應用所用到的類控制在 User-Defined Class Loader 級別,這樣就可以實現相互隔離。Web 容器對於多個應用的處理,以及 OSGi 對於不同 Bundle 的處理都採用了類似的方法。

實踐中,需要保證服務框架比應用優先啟動,並且把一些需要統一的 jar 包放在 User-Defined Class Loader 所公用的祖先 User-Defined Class Loader 中,統一版本。

2.3.2 選擇服務呼叫者與服務提供者之間的通訊方式

服務呼叫者與服務提供者提供直連

服務註冊查詢中心為呼叫者提供了可用的服務提供者列表。考慮到效率,我們會把列表快取在呼叫者本地。當列表發生變動時,服務中心會發起通知,告知呼叫者需要更新快取。

客戶端得到服務提供者地址後,可以使用隨機、輪詢或權重進行路由選擇。權重一般採用的是動態權重方式,可以根據響應時間等引數進行計算。如果服務提供者的機器能力對等,那麼可以採用隨機或輪詢這些更容易實現的方式。如果服務提供者的機器能力不對等,那麼採用權重計算更合適。

2.3.3 使用基於介面、方法、引數的路由策略

一個叢集中會提供多個服務,每個服務都會有多個方法,所以我們需要提供更加細粒度的路由選擇策略。

如果某個方法執行得非常慢,那麼執行緒可能都陷入執行這個方法的狀態中,這之後再進來的請求就需要排隊等待,而且等待的時間可能會非常長!

有兩種解決方法:
1. 增加資源:提供系統的處理能力。
2. 隔離資源:讓執行快慢、重要級別不同的方法互不影響。

我們可以通過路由選擇,讓其中的某些服務請求到一部分機器,而另一部分服務請求到另一部分機器。

實踐中,可以把路由規則集中管理。根據服務定位服務的叢集地址與介面路由規則中的地址取交集,然後通過負載均衡演算法,最終得到一個可用的地址。

可以把規則定義的更精細些,即基於介面的具體方法來進行路由選擇。

2.3.4 同城多機房場景

每個機房都會有容量上限,如果網站的規模很大,那麼就需要多個機房咯。

多機房場景

分佈在兩個機房的呼叫者會對等地看待分佈在不同機房中的服務提供者的機器。同城機房一般採用光纖直連,所以頻寬足夠大,延遲也可以接受。當然如果可以在同一機房更好。

服務註冊查詢中心為不同機房的呼叫者提供相同的服務提供者列表。然後在服務框架中進行地址過濾。實踐中,因為每個機房的網段不同,所以可以依此區分不同的機房。

還有一個問題,機房未必既是服務呼叫者又是服務提供者,在機房很多時,這個問題會變得更加明顯。我們可以採用虛擬機器房的概念,把物理上的多個機房看做一個邏輯機房來處理路由規則。當然也可以根據業務與應用的特點把一個物理機房拆分成多個邏輯機房。

2.3.5 服務呼叫者的流控處理

有兩種控制方式:
1. 0 - 1 開關。
2. 設定最大閾值 - 表示每秒可以進行的請求數,如果超過這個數的話就拒絕請求或排隊。

可以基於這些維度進行控制:
* 根據服務端的介面、方法設定閾值。
* 根據來源進行控制。

2.3.6 序列化與反序列化

序列化就是把記憶體物件變為二進位制資料;而反序列化就是把二進位制資料變為記憶體物件。

Java 本身已經提供了序列化與反序列化方法,但要注意:
1. 跨語言問題:如果整個分散式系統中的呼叫者或服務提供者使用 Java 之外的語言來實現,就要考慮跨語言問題。
2. Java 序列化與反序列化方法自身的效能開銷。
3. 序列化後的長度。

可以選擇 HTTP 作為通訊協議,服務呼叫中的具體協議可以採用 XML 、JSON 或其它的二進位制表示方式。

實踐中需要具體考慮協議的擴充套件性、向後相容性。顯式地標明版本號很重要。可擴充套件的屬性會方便我們對協議進行擴充套件。

2.3.7 選擇網路通訊的實現方式

建議採用 NIO 的方式,因為客戶端與服務端的連線是可以複用的。

NIO 方式

NIO 方式可以通過一個連線來進行多個併發請求操作。而對外需要提供類似阻塞的同步請求方式,所以我們需要完成非同步轉同步的工作,還要處理呼叫超時的情況以及對傳送的資料進行流量保護。

客戶端使用 NIO 流程示意圖

IO 執行緒只負責連線 SOCKET,進行資料收發操作。需要傳送的資料都會進入資料佇列,這樣就可以複用 SOCKET。這裡要關注資料佇列的長度,因為可能會造成記憶體的溢位。通訊物件佇列儲存了多個執行緒使用的通訊物件,它用於喚醒請求執行緒。如果在遠端呼叫超時前有結果返回,那麼 IO 執行緒就會通知通訊物件,由通訊物件通知請求執行緒結束等待,並把結果傳送給請求執行緒,以便進行後續處理。這裡還設計了定時任務,它負責檢查佇列中那些已經超時的通訊物件,並通知請求執行緒這些通訊物件已經超時。

2.3.8 多種非同步呼叫方式

【1】Oneway

只發送請求而不關心結果。

Oneway 呼叫方式

這裡只需要把要傳送的資料放入資料佇列即可,然後就可以處理後續任務咯。IO 執行緒也只需要從資料佇列中讀取資料,然後通過 SOCKET 傳送出去。這種方式相當於不保證可靠送達的通知。

【2】Callback

這種方式下的請求方傳送請求後,會繼續執行後續操作,等對方響應後進行回撥。

Callback 呼叫方式

這裡也是通過定時任務來支援超時情況。如果超時仍然需要執行回撥,告知已經超時。建議使用新的執行緒來執行回撥操作,這樣不會因為執行回撥時間久而影響 IO 執行緒或定時任務執行緒。

【3】Future

Java 的 Future 是一個很好的特性。

Future

可以通過 Future 來獲取通訊結果並控制超時,是不是很方便呀O(∩_∩)O~

【4】可靠非同步

保證非同步請求能夠在遠端被執行,這一般是通過訊息中介軟體來實現的。

總結如下:

非同步通訊方式 特性
oneway 單向通知
callback 回撥,被動通知
Future 主動控制超時並獲取結果
可靠非同步 保證非同步請求在遠端被執行

2.3.9 優化呼叫多個遠端服務(Future 方式)

實踐中,會出現一個請求中呼叫多個遠端服務的情況:
呼叫多個服務

完成這次請求需要 95ms(15+25+35+20),在 95ms 的大部分時間裡是在等待遠端結果。

可以這樣優化:

並行呼叫多個服務

我們按照順序請求這些服務,然後再統一等待返回結果(依賴 Future 方式)。這樣等待的時間就會變得更短,也就更高效。當然,前提是所呼叫的服務之間沒有依賴關係。如果服務之間存在依賴關係,那麼就只能等待之前的服務響應後才能進行後續的服務。

注意反序列化的工作一般是使用 IO 執行緒,但這會影響 IO 執行緒的效率。也可以把反序列化的工作放在其他執行緒進行處理。

2.4 服務提供端的設計

服務提供端有兩項工作:
* 註冊並管理本地服務。
* 根據請求定位服務並執行。

2.4.1 暴露遠端服務

服務提供端也是通過 spring 配置一個 bean 來暴露服務的。

這個 Bean 與服務呼叫端相似,也有這些配置:

interfaceName - 介面名稱。
version - 版本號。因為介面是會變化的,使用版本號可以對新、舊方法進行區分隔離。
group(可選) - 組名稱。如果同一個介面的遠端服務有很多機器,那麼我們可以把這些遠端服務的機器進行歸組,這樣就可以隔離不同的呼叫者。
target(新增)- 表明需要具體執行服務的 Bean。

這個 Bean 需要把自己的服務註冊到服務查詢中心。

2.4.2 處理請求流程

在服務框架啟動時需要監聽服務埠。

服務端處理請求流程

在網路通訊部分,會由多個 IO 執行緒進行通訊處理。呼叫服務的工作是在工作執行緒(非 IO 執行緒)內進行的,而反序列化工作在 IO 執行緒還是工作執行緒則取決於具體實現。

2.4.3 隔離不同服務的執行緒池

服務端的工作執行緒是執行緒池,我們可以在服務端進行控制,根據服務的名稱、方法和引數,來確定具體執行服務的執行緒池。

2.4.4 服務端的流控處理

對於不同來源的服務呼叫者,都需要實現 0-1 開關以及設定閾值的功能。這樣就可以對不同的服務呼叫者進行分級,確保為優先順序高的服務呼叫者優先提供服務,這也是確保穩定性的策略。

可以把序列化、協議、通訊等公用的功能放在一起實現,形成一個完整的服務框架。因為某個服務的呼叫者,可能是另一個服務的提供者。

整個服務框架作為一個產品,預設使用隨機選擇服務地址的策略,在某些場景下再用到權重。服務框架必須做到模組化、可配置、模組可替換,並留有一定的擴充套件點。

2.5 升級服務

有兩種情況:
1. 介面不變,只是完善內部程式碼 - 處理簡單,採用灰度釋出的方式驗證後就可以全部發布咯O(∩_∩)O~
2. 需要修改原有介面:
【1】在介面中增加新方法 - 處理簡單,讓需要新方法的呼叫者使用新方法,舊方法可以繼續使用。
【2】對介面中的某些方法修改呼叫的引數列表。相對複雜,有這些方法:
* 版本號 - 需要使用新版本的方法呼叫者使用新版本的服務。
* 設計方法時考慮引數的可擴充套件性 - 可行,但不太好。因為引數列表可擴充套件意味著採用類似 Map 的方式來傳遞引數,這樣做不僅不直觀,而且對引數的校驗也會比較複雜。

3 優化

【1】 拆分服務

需要拆分的服務必須是那些能夠提供公共功能的服務。

【2】 服務的粒度

根據業務的實際情況來劃分服務的粒度。

【3】優雅與實用之間的平衡

有些功能直接在服務呼叫者的機器上實現會更合適、也更經濟。

一般情況下,是由服務提供者完成與快取和資料庫的互動。
由服務提供者完成與快取和資料庫的互動

但如果服務呼叫者讀取資料的頻率很高的話,直接讓服務呼叫者讀取快取會更合適。我們可以做一個客戶端,把讀取快取的邏輯放在客戶端中,如果快取讀取成功就結束操作,否則就到服務提供者那裡讀取資料庫,更新快取。其他的寫操作還是由服務提供者進行處理。

優化後

這是一個實用的方案,因為對大多數資料的請求只需讀取一次快取就可以咯O(∩_∩)O~

【4】分散式環境下的合併請求

對於熱點資料,如果可以對這些任務進行合併處理,那麼就會明顯降低整個系統的負載。

假設在單機環境中,一個根據請求讀取資料並生成報表的服務。在執行的過程中,我們會從遠端讀取大量的資料,然後進行復雜的統計計算,最後生成報表。我們可以增加快取來減少資料讀取和計算的工作量。如果有的資料已經計算過,那麼之後的請求直接使用其結果即可。快取的時間是由業務特性決定的。

合併請求後的流程

可以依賴 Future 來實現。

而在分散式環境下,上述的思路會出現問題。因為分散式環境會涉及多個節點,很難判斷這些節點是否有同樣的任務在執行。在分散式環境中,需要獨立於服務呼叫者、提供者之外的節點,即分散式鎖服務來控制,這會有額外的開銷,需要權衡。

也可以根據一定的路由規則把同樣的請求傳送到同一個服務提供者,然後再在這些服務提供者的機器上進行單機控制,這樣做可以降低複雜度。

對於比較消耗系統資源的操作,都可以在服務呼叫者中進行單機的多執行緒控制。

實踐中,要根據具體場景和實際資料支援來做選擇。

4 治理服務

我們把服務治理分為兩方面:
* 管理服務 - 控制、操作整個分散式系統中的服務。
* 檢視服務 - 檢視執行時的狀態、資訊和歷史資料等等。

檢視服務包含這些內容:
* 服務資訊。
* 服務質量 - 被呼叫服務的出錯率、響應時間等對服務質量進行評估。
* 服務容量 - 根據所提供服務的總能力(請求數量)以及當前所使用的容量進行評估。
* 服務依賴 - 某個服務與上下游服務的依賴關係。
* 服務分佈 - 同樣服務機器的具體分佈情況(主要是跨機房的分佈情況)。
* 統計服務 - 服務執行時的資訊統計。
* 服務報表 - 非實時服務的各種統計資訊報表,包括不同時段的對比以及分時統計資訊。
* 監視服務 - 採集服務執行期間的關鍵資料、規則處理與告警。監視服務只提供決策的資料基礎,並根據已定義的規則進行告警。

管理服務包含這些內容:
* 服務上下線。
* 服務路由。
* 服務限流降級。
* 歸組服務。
* 管理服務執行緒池。
* 機房規則 - 多機房、虛擬機器房規則的管理。
* 授權服務 - 使用一些重要的服務,需要有授權與鑑權的支援。

5 服務框架與 ESB 模型的不同

ESB 模型

ESB 模型是從面向服務體系結構(SOA)發展過來的,它可以解耦多樣化型別的服務呼叫者和服務提供者。

服務框架與 ESB 模型的不同點說明如下:

- 服務框架 ESB 模型
模型 點對點 匯流排式
物件 面向同構系統 面向異構系統(不同的廠商)