分散式系統設計的求生之路
本文由騰訊WeTest團隊提供,轉載聯繫公眾號請申請許可權。更多資訊可直接戳鏈接查看:分散式系統設計的求生之路
微信號:TencentWeTest
作者:Simon,騰訊後台開發高級工程師
WeTest導讀分散式系統理念漸漸成為了後台架構技術的重要選擇,本文介紹了作者在手游領域對分散式系統進行的種種嘗試,並在嘗試中制定了對服務的定義、整體框架的構建以及服務內部拆分的流程。
前言
業務規模不斷擴大,對穩定性、擴展性的要求不斷提高,推動了後台架構技術的不斷革新。面對日益複雜的需求,分散式系統的理念也逐漸深入到後台開發者的骨髓。2013年,借著手游熱潮我對分散式系統開始嘗試。在近三年的摸爬滾打中,踩過不少坑,也從業界技術發展中吸取一些經驗,逐漸形成了目前的設計思路。這裡和大家分享點心得,不敢奢談有多大參考價值,權當拋磚引玉吧。
1. 失敗的首次嘗試
最初考慮使用分散式的出發點很簡單:解決端游開發時單點結構導致容災、擴容困難的問題。一種樸素的想法就是將相同功能的進程作為一個整體對外提供服務。這裡簡要描述下基本框架:這種架構提供了三個基本組件:
Client API, 服務請求者API:
從 Cluster Center Server 獲取服務提供者地址
向Server集群內所有實例註冊,註冊成功則認為可用
通過負載均衡演算法,選擇一個Server實例通信
檢測Server集群內各實例的運行狀態
Server API, 服務提供者API:
向 Cluster Center Server 上報自己的狀態、訪問地址等
接收 Client API 的註冊,並提供服務
向已經註冊成功的Client定時彙報狀態
Cluster Center Server, 集群中心進程:
接收 Server Cluster 上報,確定服務集群的結構,以及各實例的狀態
接收 Client Cluster 的請求,返回可用服務集群列表
這種架構具備了集群的基本雛形,可以滿足容災擴容的基本需求,大家應該也發現不少問題,我這裡總結幾點:1. 服務發現的蹩腳實現
Cluster Center Server 的實現是單點,出現故障時Client請求會異常;沒有提供監控機制,Client只能通過定時請求來獲取服務的最新狀況。2. CS採用Request/Response的通信方式不靈活現實應用中,服務往往存在相互請求,一應一答遠遠不夠,全雙工 是必須要支持的。3. 有瑕疵的保活機制Server對Client定期單邊心跳,有兩個問題:不同Client對保活要求可能不同,有些5s,有些可能1s,如果心跳發起全部在Server,無法滿足差異化要求;服務端作為被動方,承擔監控請求者存活的責任不明智。4. 架構設計的層次不清晰對架構的層次、模塊劃分沒有作出很好的規劃,比如通信底層、服務發現、集群探測與保活等等沒有清晰定義介面,導致相互耦合,替換、維護較為困難。
2. 看看外面的世界
上述問題,歸根結底還是眼界狹窄,自己悶頭造輪子沒跟上業界技術發展的步伐。近幾年微服務架構發展迅速,相比傳統面向服務架構不再過分強調企業服務匯流排,而是深入到單個業務系統內部的組件化。這裡我介紹下自己的調研結果。
2.1 服務協同服務協同是分散式系統一個核心組成部分,概述為:多個進程節點作為整體對外提供服務,服務可以相互發現,服務關注者可以及時獲取被關注者的變化以完成協作。具體運行過程包括:服務註冊 和 服務發現。在實現上涉及以下方面:統一命名 對服務以及其中的節點,進行集中式、統一命名,便於相互區分和訪問。
監控 確定服務的可用性和狀態,當服務狀態變化時,關注者要有途徑獲知。
訪問策略 服務通常包含多個節點,以集群形式存在,Client在每次請求時需要策略確定通信節點,策略目標可能是多樣的,比如 負載均衡 ,穩定映射 等等。
可用性 容災處理,動態擴容。
業界中較為成熟的實現如下表所示:
2.2 消息中間件
亦稱消息隊列,在分散式系統廣泛使用,在需要進行網路通信的節點間建立通道,高效可靠地進行平台無關的數據交流。架構上主要分為兩種:Broker-Based(代理),和 Brokerless(無代理)。前者需要部署一個消息轉發的中間層,提供二次處理和可靠性保證。後者輕量級,直接在內嵌在通信節點上。業界較為成熟的實現如下表所示:2.3 通信協議數據格式
服務間通信,需要將數據結構/對象和傳輸過程中的二進位流做相互轉化,一般稱為 序列化/反序列化 。不同編程語言或應用場景,對數據結構/對象的定義和實現是不同的。在選擇時需要考慮以下方面:通用性 是否支持跨平台、跨語言;業界是否廣泛流行或者支持
可讀性 文本流有天然優勢,純粹二進位流如果沒有便捷可視化工具,調試將會異常痛苦
性能 空間開銷——存儲空間的佔用;時間開銷——序列化/反序列化的快慢
可擴展性 業務的不變之道就是——一直在變,必須具有處理新舊數據之間的兼容性的能力
實現 序列化/反序列化 的組件一般包含:IDL(Interface Description Language), IDL Compiler, Stub/Skeleton。業界目前比較流行的序列化協議有:XML, JSON, ProtoBuf, Thrift, Avro等。關於這幾種協議的實現以及比較,可以參考文章 《序列化和反序列化》。這裡將原文中的選型結論摘錄給大家:
允許高延遲比如100ms以上,內容變更頻繁,且複雜的業務,可以考慮基於XML的SOAP協議。
基於Web browser的Ajax,以及Mobile app與服務端之間的通訊;對於性能要求不太高,或者以動態類型語言為主的場景,JSON可以考慮。
對性能和簡潔性有極高要求的場景,Protobuf,Thrift,Avro都差不多。
對於Terabyte級別數據持久化應用場景,Protobuf和Avro是首要選擇。持久化後的數據若存儲在Hadoop子項目里,或以動態類型語言為主,Avro會是更好的選擇;非Hadoop項目,以靜態類型語言為主,首選Protobuf。
不想造 RPC 的輪子,Thrift可以考慮。
如果序列化之後需要支持不同的傳輸層協議,或者需要跨防火牆訪問的高性能場景,Protobuf可以優先考慮。
3. 重整旗鼓調研周邊後,2015年開搞第二款手游,吸取之前的教訓,這次設計的基本原則是:
系統拆分、解耦,清晰定義系統間介面,隱藏系統內部實現
大框架儘可能通用,子系統可在不同場景替換
下面首先對服務定義,然後介紹整體框架和服務內部拆分。
3.1 服務定義舉個手游的例子,看圖說話:
Service Cluster 服務集群,由功能相同的實例組成,作為整體對外服務,是一個集合。比如 Lobby 提供大廳服務,Battle 提供戰鬥服務,Club 提供工會服務,Trade 提供交易服務。
Service Instance 服務實例,提供某種服務功能的最細粒度,以進程形式存在。比如Club 集群中有兩個實例 3.2.6.1 和 3.2.6.2 ,功能一致。
Service Node 服務節點,是服務發現組件管理的基本單元,可以是集群、實例、層次關係或者業務關心的含義。
Service Key 服務節點的Key,全局唯一的身份標記。key的設計需要能夠體現出層級關係,至少要能夠體現出 Cluster 和 Instance 的包含關係。etcd和zookeeper均支持key層次化的組織關係,類似文件系統的樹形結構。etcd有mkdir直接建立目錄,zookeeper則通過路徑描述父子關係。但不管怎麼都可以在概念層次使用路徑結構 。
集群路徑一定是其中各個實例的父路徑
從功能完整性而言,集群是服務的基本粒度
相同功能的集群在不同前綴路徑下含義不同,服務目標也可以不同,比如:
/Example/wechat/android/w_1/g_1/Lobby 和/Example/wechat/android/w_3/g_2/Lobby 功能上均表示大廳服務,但一個為大區1分組1服務,一個為大區3分組2服務
3.2 服務發現基本流程
先抽象幾個基本操作,不同服務發現組件的API可能略有差異,但應該有對應功能:Create 在服務發現組件中創建 Key 對應的 Service Node,指定全局唯一的標記。
Delete 在服務發現組件中刪除 Key 對應的節點。
Set 設置 Key 對應的 Value, 安全訪問策略或者節點基礎屬性等。
Get 根據 Key 獲取對應節點的數據,如果是父節點可以獲取其子節點列表。
Watch 對節點設置監視器,當該節點自身,以及嵌套子節點數據發生變更時,服務發現組件將變更事件主動通知給監視者。
Service Instance 每次在啟動時,按照下面的流程處理:
生成自己的 Service Path,注意這是服務實例的路徑。
以 Service Path 為key,通過 Create 方法生成節點,Set 數據:對外開放的地址、安全訪問策略等。
生成需要訪問的服務集群的 Service Path,通過 Get 方法獲取集群數據,如果找不到說明該服務不存在;如果可以找到分兩種情況:
該路徑下沒有子節點。說明當前不存在可用的服務實例,對集群路徑設置watcher,等待新的可用實例。
該路徑下有子節點。那麼 Get 所有子節點列表,並進一步 Get 子節點訪問方式和其它數據。同時設置 watcher 到集群路徑,檢測集群是否存在變化,比如新增或減少實例等。
Service Instance 在關閉時,按照下面的流程處理:
通過 Delete 方法刪除自己對應的節點。有些服務發現組件可以在實例生命周期結束時自行刪除,比如zookeeper的臨時節點。對於etcd的目錄,或者zookeeper的父路徑,如果非空,是無法刪除的。
根據上面的抽象可以定義 服務發現 的基本介面,介面的具體實現可以針對不同的組件開發不同的wrapper,但可以和業務解耦。
3.3 服務架構
所有的架構歸根結底還是需要具體到進程層次實現的。目前我們項目開發的分散式架構組件稱之為 DMS(Distributed Messaging System),以 DMS Library 的形式提供,集成該庫即可實現面向服務的分散式通信。下面是 DMS 設計的總體結構:關於Serialize/DeSerialize, APP業務的選擇自由度較高,下面介紹其它Layer的具體實現:
3.3.1 Message Middleware消息中間件前面介紹有很多選擇。DMS 使用的是 ZeroMQ,出發點是:輕量級、性能強大、偏底層所以靈活而且可控性較高。由此帶來的成本是,高級應用場景需要做不少二次開發,而且長達80多頁的資料也需要不少時間。介紹ZeroMQ的文章太多,這裡不打算科普,所以直接給出設計方案。通信模式的選擇ZeroMQ的Socket有多種類型,不同組合可以形成不同的通信模式,列舉幾種常見的:REQ/REP 一應一答,有請求必須等待回應
PUB/SUB 發布訂閱
PUSH/PULL 流水線式處理,上游推數據,下游拉數據
DEALER/ROUTER 全雙工非同步通信
看到這裡,大家可能會覺得選擇PUB/SUB和DEALER/ROUTER應該可以滿足絕大部分應用場景吧。實際上DMS只使用了一種socket類型,那就是ROUTER,通信模式只有一種ROUTER/ROUTER。一種socket,一種通信模式,聽起來很簡單,但真可以滿足要求嗎?
DEALER/ROUTER 是傳統非同步模式,一方connect,一方bind。前端如果要連接多個後端就得建立多個socket。在前面描述的集群服務模式下,一個節點既會作為Client也會作為Server,會有多條入邊(被動接收連接)和出邊(主動發起連接)。這正好就是路由的概念,一個ROUTER socket可以建立多條通路,並對每條通路發送或者接收消息。
PUB/SUB 注重的是擴展性和規模,按照ZeroMQ作者的意思當每秒鐘需要向上千的節點廣播百萬條消息時,你應該考慮使用 PUB/SUB 。好吧,可預見的將來業務規模恐怕還到達不到這種程度,現在先把簡單放在第一位吧。
3.3.2 DMS Protocol
消息結構DMS的協議實現集群管理,消息轉發等基本功能。ZeroMQ的消息可以由 Frame 組成,一個Frame可以為空也可以是一段位元組流,一個完整的消息可以包含多個Frame,稱為Multipart Message。基於這種特點,在DMS定義協議,可以將內容拆分為不同的基本單元,每個單元用一個Frame描述,通過單元組合表示不同的含義。這與傳統方式:一條協議就是一個結構體,不同單元組合需要定義為一個結構體的方式相比更加靈活。下面來看看DMS Protocol的基本組成。首幀一定是對端ID。對端接收後也一定會獲取信息發送端的ID。第二幀包含DMS控制信息。第三、第四幀等全部是業務自定義的傳輸信息,僅對REQ-REP有效:PIDF有兩層含義:所在服務集群的標記,自身的實例標記。這些標記與Service Discovery關於節點key的定義保持一致,有兩種形式 字元串 與 整型,前者可讀方便理解,後者是前者的Hash,提高傳輸效率。使用偽代碼來描述PIDF,大概是下面的樣子:PIDF中的 ClusterID 和 InstanceID 各種取值,會有不同的通信行為:在連接首次建立時,還需要將可讀的服務路徑傳輸給對端:協議命令字
DMS協議全部在每個消息的第二幀即Control Frame中實現。命令字定義為:通信流程——建立連接
通過 Service Discovery 找到server後不要立即連接,而是發送探測包。原因有以下幾點:服務發現雖然可以反映節點是否存活,但一般有延遲,所以從服務發現獲取的節點僅僅是候選節點。
網路底層機制差異較大,有些基於連接,比如raw socket,有些沒有連接,比如shared memory。最好在高層協議中解決連接是否成功。這就好比聲納,投石問路,有回應說明可以連接,沒有回應說明目前連接不可用。
通信流程——業務消息發送
普通消息 若 PIDF 表示對端實例和當前進程直接連接,那麼發送消息
路由消息 若 PIDF 表示對端實例和當前進程沒有直接連接,那麼可以通過直連的實例轉發。路由機制 後文會介紹
廣播消息 若 PIDF InstanceID為負數,則向指定集群內所有實例廣播
通信流程——保活機制
建立連接後,請求者會持續按照自己的間隔向服務者發送探測包。如果請求者連續若干次沒有收到服務者的PONG回包,則請求者認為與服務者的連接已經斷開。如果服務者收到請求者的任何數據包,認為請求者存活,如果超出一定時間沒有收到(含PING),則認為請求者掉線。這個超時時間包含在READY協議中,由請求者告知服務者。通信流程——連接斷開
任何一方收到 DISCONNECT 後,即認為對方主動斷開連接,不要再主動向對方進行任何形式的通信。3.3.3 DMS Kernel
下面介紹 DMS Kernel 如何根據 DMS Protocol 實現相關邏輯,並如何與業務交互。SERVICE MANAGER
self 確定自身 服務路徑,實現服務註冊,以及與目標通信鏈路的註冊,供路由表使用
targets 獲取並監控目標服務的數據以及運行狀態
ACL 訪問控制管理
對服務發現層介面進行封裝,不同的 SERVICE DISCOVERY 功能可能有所不同
ROUTER MANAGER
每個服務實例在主動成功連接對端服務後,通過 SERVICE MANAGER 將連接以邊的形式寫入到 SERVICE DISCOVERY 中,這樣就會以 鄰接邊 的形式生成一張完整的圖結構,也就是routing table。比如: Service 1 和 Service 2,Service 3,Service 4 均有連接,那麼將邊(1,2),(1,3),(1,4) 記錄下來。SERVICE DISCOVERY 關於路由鄰接鏈表的記錄可以使用公共的key,比如: /AppID/Area/Platform/routing_table 。然後所有的服務實例都可以更新、訪問該路徑以便獲得一致的路由表。基礎功能有兩個:Updater 用於向路由表中添加邊,刪除邊,設置邊的屬性(比如權重),並對邊的變化進行監控
Calculator 根據鄰接邊形成的 圖結構 計算路由,出發點是當前實例,給定目標點判斷目標是否可達,如果可達確定路徑並傳輸給下一個節點轉發。默認選擇 Dijkstra 演算法,業務可以定製。
CONNECTION MANAGER
管理 Frontends 即前端請求進入的連接,和 Backends 即向後端主動發起的連接。Backends的目標來源於 Service Manager。Sentinel 對前端發起的連接,通過 READY 協議,可以獲取該連接的失活標準,並通過前端主動包來判斷進入連接是否存活。如果失活,將該連接置為斷開狀態,不再向對應前端主動發包。
Prober 對後端服務進行連接建立和連接保活。
Dispatcher 消息發送時用於確定通信對端實例。連接是基於實例的,但是業務一般都是面向服務集群的,所以Dispathcer 需要實現一定的分配機制,將消息轉發給 服務集群中的某個 具體實例 。注意這裡僅只存在直接連接的單播。分配時應考慮 負載均衡 默認使用一致性哈希演算法,業務完全可以根據具體應用場景自定義。
3.3.4 DMS Interface
DMS API 是DMS對業務提供的服務介面,可以管理服務、通信等基本功能;DMS APP Interface 是DMS要求業務必須實現的介面比如:Dispatcher 的負載均衡策略,對端服務狀態變化通知,以及業務自定義 路由演算法 等等。3.4 應用場景
下面羅列DMS三大類典型應用場景,其它場景應該可以通過這三個例子組合實現:無Broker通信
最基礎的通信方式——兩個集群之間的 Instance 全連接,適合服務數量不多、邏輯不複雜的簡單業務。
Broker通信
對於一個內部聚合的子系統,可能包含N個服務,這些服務之間相互存在較強的交互行為。如果使用無Broker模式可能有兩個問題:鏈路過多:通信層的內存佔用較大;運維維護困難;服務沒有解耦,直接依賴於對端的存在;
這時Broker集群可以承擔消息中轉的作用,而且可以完成一些集中式邏輯處理。注意這裡Broker只是一個名字,通過 DMS Library 可以直接實現。Broker級聯通信
多個子系統相互通信,估計沒有設計者願意把內部細節完全暴露給對方,這時兩個Broker集群就相當於門戶:首先可以實現內部子系統相互通信,以及集中邏輯;其次,可以作為所處子系統的對外介面,屏蔽細節。這樣不同子系統只需通過各自的Broker集群對外提供服務即可。總結本文主要介紹了 DMS 的幾個基礎結構:服務發現、消息中間件以及通信架構。基本思想是:框架分層、層級之間介面清晰定義,以便在不同場景下使用不同的具體實現進行替換。其中 zookeeper,ZeroMQ 只是舉例說明當前的一種實現方式,在不同場景下可以選擇不同組件,只要滿足介面即可。
推薦閱讀: