【转】构建微服务架构的最佳实践2/3
本文是這一系列文章的第二篇,將介紹服務的交互。
服務的交互
微服務架構提倡有許多職責單一的小服務組成,這些服務之間互相交互。然而這就造成了一系列的問題,比如:服務之間如何發現彼此?是否采用統一的協議?如果一個服務無法與其他服務通信會怎樣?我會在接下來的內容里討論部分相關話題
通信協議
隨著服務數量越來越多,在服務間使用標準化通信方法愈加重要。由于服務不一定使用相同語言編寫,通信協議的選擇必須不依賴具體語言和平臺。此外還要同時考慮同步和異步通信。
首先,傳輸協議
HTTP是同步通信的最佳選擇。HTTP客戶端幾乎已得到所有語言支持,很多云平臺都內建了HTTP負載均衡器,該協議本身內建了用于緩存、持久連接、壓縮、身份驗證以及加密所需的機制。最重要的是,圍繞該協議有一個穩健成熟的工具生態體系可供使用:緩存服務器、負載均衡器、優秀的瀏覽器端調試器,甚至可對請求進行重播的代理。
協議較為繁瑣是HTTP的不足之一,它需頻繁發送純文本頭字段(Header),并頻繁建立和終止連接。相比HTTP生態系統已經帶來的巨大價值,我們可以辯解說接受這些不足是一個合理的權衡。然而,時下已經有了另外一個更好的選項:HTTP/2。通過對頭字段進行壓縮并用一個持久連接實現多路復用請求(Multiplexing request),該協議有效解決了上述問題,同時維持與老版本客戶端的向后兼容性。HTTP目前依然實用,未來一樣很好用。
話雖如此,如果已達到一定規模,通過降低內部傳輸的開銷可對底線造成較顯著的改善,那么也許更適合用其他傳輸方式。
對于異步通信,需要實施發布訂閱模式。為此有兩個主要方法:
- 使用消息代理(Broker):所有服務將事件推送至該代理,其他服務可訂閱需要的事件。這種情況下將由消息代理定義所用傳輸協議。由于一個集中化的代理很容易造成單點故障,一定要確保此類系統具備容錯性和橫向伸縮能力。
- 使用服務所交付的Webhook:服務可暴露出一個供其他服務訂閱事件使用的端點,隨后該服務會將事件以Webhook形式(例如在主體中包含序列化消息的HTTP POST)提供給已訂閱的目標服務。此類Webhook的交付應由服務所管理的異步工作進程發送。這種方式可避免單點故障并獲得固有的橫向伸縮能力,同時這樣的功能也可直接構建在服務模板中。
企業服務總線(ESB)或消息傳遞設施呢?
一個重量級的消息傳遞基礎設施的存在通常會鼓勵將業務邏輯脫離服務本身而進入消息層。這種做法會導致服務的內聚性降低,并增加了額外一層,從而會降低服務內聚力,并增加了額一層,隨著使用時間延長可能無意間導致復雜度逐漸提高。與一個服務有關的任何業務邏輯都應屬于該服務,并由該服務的團隊負責管理。強烈建議堅守智能的服務+啞管道這樣的原則,以確保不同團隊維持自治力。
接著再談談序列化格式
這方面有兩個主要的競爭者:
- JSON:RFC 7159定義的一種純文本格式。
- Protocol Buffers:谷歌創建的一種基于二進制連接格式的接口描述語言。
JSON是一種穩定并得到廣泛應用的序列化格式,瀏覽器包含對該格式的原生解析能力,瀏覽器內建調試器也能很好地顯示這種內容。唯一不足在于要具備JSON解析器/序列器,好在所有語言都已提供。使用JSON最主要的麻煩在于每條信息會重復包含屬性名,導致傳輸效率低下,但傳輸協議的壓縮功能可緩解這一問題。
在解析和網絡傳輸方面Protocol buffers更高效,并經歷了谷歌高負荷環境的考驗。取決于消息定義文件,這種格式需針對不同語言具備解析器/序列器生成器。不同語言對該格式的支持不像JSON那么廣泛,不過大部分現代化語言均已支持。為了使用這種格式,服務器需要預先與客戶端共享消息定義文件。
JSON更易上手更通用,Protocol buffers更精益更快速,但在.proto文件的共享和編譯方面會產生些許額外開發負擔。這兩種格式都是不錯的選擇,選定一個堅持使用吧。
對于“服務異常”的定義
正如需要自動化的監控和警報機制,確定所有服務有統一的異常定義,也是一個好主意。
對HTTP傳輸協議來說這一點很簡單。服務通常可生成200、300以及400系列的HTTP狀態代碼。任何500錯誤代碼或超時通常可認定服務出現故障。這些代碼也可用于反向代理和負載均衡器,如果這些組件無法與后端實例通信,通常會拋出502(Bad Gateway)或503(Service Unavailable)錯誤。
API的設計
好的API必須易用且易理解,可在不暴露底層實現細節的情況下提供完成任務所需的信息,這些信息數量恰恰滿足需求,不多不少。同時API的演化只會對現有用戶造成最少量影響。API的設計更像是一種藝術而非科學。
由于已選擇HTTP作為傳輸協議,為了釋放HTTP的全部潛力,還要將HTTP與REST配合使用。RESTful API提供了資源豐富的端點,可通過GET、POST以及PATCH等動詞操作。我之前寫的一篇有關RESTful API設計的文章詳細介紹了對外API的設計,這篇文章的大部分內容也適用于微服務API的設計。
但是為什么服務API必須是面向資源的?
這樣可以讓不同服務的API實現一致性并更簡潔。借此可通過更易于理解的方式檢索或搜索內容,無須尋找修改資源某一特定屬性所需的方法,可直接針對資源使用PATCH(部分更新)。這樣可減少API上的端點數量,有助于進一步降低復雜度。
由于大部分現代化公開API都是RESTful API,因此有豐富的工具可供使用。例如客戶端庫、測試自動化工具,以及自省代理(Introspecting proxy)。
服務發現
在服務實例變化不定的環境中,用硬編碼指定IP地址的方式是行不通的,需要通過某種發現機制讓服務能相互查找。這意味著對于到底有哪些可用服務必須具備“權威信息來源”。此外還要通過某種方式借助這個權威來源發現服務實例之間的通信,并對其進行均衡。
服務注冊表
服務注冊表可以作為信息的權威來源。其中包含有關可用服務的信息,以及服務網絡位置。考慮到該服務本身的一些關鍵特質(是一種單一故障點),該服務必須具備極高容錯能力。
可通過兩種方式將服務注冊至服務注冊表:
- 自注冊:服務可在啟動過程中自行注冊,并在生命周期的不同階段(初始化、正在接受請求、正在關閉等)過程中發送狀態信息。此外服務還要定期向注冊表發送心跳信號,以便讓注冊表知道自己處于活躍狀態。如果無法收到心跳信號,注冊表會將服務標記為已關閉。這種方式最適合包含在服務模板中。
- 外部監控:可通過外部服務監控服務運行狀況并酌情更新注冊表。很多微服務平臺使用這種方法,通常還會用這種外部服務負責服務的整個生命周期管理。
在大架構方面,服務注冊表也可充當監控系統或系統可視化工具所用狀態信息的來源。
發現和負載均衡
創建可用的注冊表只解決了問題的一半,還需要實際使用這種注冊表才能讓服務以動態的方式相互發現!此時主要有兩種方法:
- 智能服務器:客戶端將請求發送至已知負載均衡器,負載均衡器可通過注冊表得知可用實例。這是一種傳統做法,但可用于通過負載均衡器端點傳輸的所有流量。服務器端負載均衡器通常是云平臺的標配。
- 智能客戶端:客戶端通過服務注冊表發現實例清單并決定要連接哪個實例。這樣就無須使用負載均衡器,并能提供一個額外收益:讓網絡流量的分散更均勻。Netflix借助Ribbon采用了這種方式,并通過該技術提供了基于策略的高級路由功能。若要使用這種方式,需要通過特定語言的客戶端庫實現發現和均衡功能。
使用負載均衡器和DNS實現更簡單的發現機制
在大部分云平臺上,獲得最基本服務發現功能最簡單的辦法是為每個服務添加一條指向負載均衡器的DNS記錄。此時負載均衡器的已注冊實例清單將成為服務注冊表,DNS查詢將成為服務發現機制。運行狀況異常的實例會自動被負載均衡器移除,并在恢復運行后重新加入。
去中心化的交互
當有多個服務需要相互協調時,主要可通過兩種方法實施復雜工作流:使用集中化編排程序(Orchestrator),或使用去中心化交互。
集中化的編排程序會通過一個進程對多個服務進行協調以完成大規模工作流。服務對工作流本身及所涉及的具體細節完全不知情。編排程序會處理復雜的安排和協調,例如強制規定不同服務的運行順序,或對某個服務請求失敗后重試。為確保編排程序了解執行進展,此時通信通常是同步的。使用編排程序最大的挑戰在于要在一個集中位置建立業務邏輯。
去中心化的交互中,更大規模工作流內的每個服務將完全自行負責自己的角色。服務之間通常會相互偵聽,盡快完成自己的工作,如果出錯則盡快重試,并在執行完畢后送出相關事件。此時通信通常是異步的,業務邏輯依然保留在相關服務中。這種方式的挑戰之處在于需要追蹤工作流整體的執行進度。
去中心化交互可更好地滿足我們的要求:弱耦合,高內聚,每個服務自行負責自己的界限上下文。所有這些特征最終都可提高團隊的自治能力。通過服務監控所有相互協調的其他服務所發出的事件,這種方法也可用被動方式對工作流整體的狀態進行追蹤。
版本控制
變化是不可避免的,重點在于如何妥善管理這些變化。API的版本控制能力,以及同時對多個版本提供支持的能力,這些都可大幅降低變化對其他服務團隊造成的影響。這樣大家將有更多時間按自己的計劃更新代碼。每個API都應該有版本控制機制!
雖然如此,無限期地維持老版本這本身也是一個充滿挑戰的工作。無論出于什么原因,對老版本的支持只需要維持數周,最多數月。這樣其他團隊才能獲得自己需要的時間,不會進一步拖累你自己的開發速度。
將不同版本作為單獨的服務來維護,這種做法如何呢?
雖然聽起來挺好,但其實很糟糕。創建一個全新服務,這本身就會帶來不小的開銷。要監控的內容更多,可能出錯的東西也更多。老版本中發現的Bug很有可能也要在新版中修復。
如果服務的所有版本需要對底層數據獲得共享視圖,情況將變得更復雜。雖然可以讓所有服務與同一個數據庫通信,但這又成了一個糟糕的主意!所有服務會與持久存儲架構建立非常緊密的耦合。在任何版本中對架構所做的任何改動都會無意導致其他版本服務的中斷。最終也許只能使用相互同步的多份基準代碼。
那么多個版本到底該如何維護?
所有受支持的版本應共存于同一份基準代碼和同一個服務實例中。此時可使用結構版本化(Versioning scheme)確定請求的到底是哪個版本。可行的情況下,老的端點應當更新以將修改后的請求中繼至對應新端點。雖然同一個服務中多版本共存的局面不會降低復雜度,但可避免無意中增加復雜度,導致本就復雜的環境變得更復雜。
限制一切
一個服務如果超負荷運轉,那么讓它直接快速的失敗,要好過拖累其他服務。所有類型的請求需要對不同情況下的使用進行一定的限制。此外還要通過某種方法,按照需要提高對使用情況的限制。這樣可確保服務穩定,而負責服務的團隊也將有機會對使用量的進一步激增做好規劃。
雖然此類限制對不能自動伸縮的服務最重要,但對于可自動伸縮的服務最好也加以限制。你肯定不希望以“驚喜”的方式了解到設計決策中所包含的局限!然而對可自動伸縮的服務進行的限制可略微放寬一些。
為了幫助服務團隊獲得自助服務管理能力,限制機制的管理界面可包含在服務模板中,或在平臺層面上通過集中化服務的方式提供。
連接池
請求量突然激增會使得服務對下游服務造成極大壓力,這樣的壓力還會順著整個鏈條繼續向下傳遞。連接池有助于在請求量短時間內激增時“撫平”影響。通過合理設置連接池規模,即可即可對在任意時間內向下游發出的請求數量做出限制。
可為每個需要通信的服務設置一個獨立連接池,借此將下游服務中存在的故障隔離在系統的特定位置。
.. 別忘了要快速失敗
如果無法從池中獲得連接,此時最好能快速失敗,而不要無限期堵塞。這個速度決定了其他服務要等你等多久。故障本身對團隊來說也是一種預警,并會導致一些很有用的疑問:是否需要擴容了?是否下游服務中斷了?
更短的超時
設想一下這樣的場景:一個服務接到大量請求開始超負荷并變慢,進而對該服務的所有調用都開始變慢。這種問題會持續對上游造成影響,最終用戶界面開始顯得遲鈍。用戶請求得不到預期回應,開始四處亂點期待著能自己解決問題(遺憾的是這種事情經常發生),這種做法只會讓問題進一步惡化。這就是連鎖故障。很多服務會在出現故障的同時發出警報,相信我,你絕對不想就這種問題獲得第一手的親身體驗。
由于有多個服務相互支撐并可能出現故障,此時確定問題的根源成了一個充滿挑戰的工作。故障是服務本身的內部問題造成的,還是因為某個下游服務?這種場景很適合為下游API的調用使用較短超時值。超時值使得多個服務不會“緩慢地”逐漸進入故障狀態,而是可以讓一個服務真正發生故障時其他服務能快速故障,并從中判斷出問題根源。
因此僅使用默認的30秒超時值還不夠好。要將超時值設置為下游服務認為合理的時間。舉例來說,如果預計某個服務的響應時間為10 – 50毫秒,那么超時值只要大于500毫秒就已經不合適了。
容忍不相關的變更
服務API會逐漸演化。需要與API的使用方進行協調的變更,其發布速度會遠低于無須這種協調的變更。為了將耦合程度降至最低,服務應當能容忍與之通信的服務中所產生的不相關變更。這其實意味著如果服務中加入了字段,或改動/刪除了不再使用的字段,不應該導致與該服務通信的其他服務出現故障。
如果所有服務都能容忍不相關變更,就可在無須任何協調的情況下對API進行額外改動。對于比較重大但依然不相關的變更,也只需要使用該服務的團隊運行自己的測試工具確認一切都能正常工作即可。
斷路開關
與故障資源進行的任何通信企圖都會產生成本。消耗端需要使用資源嘗試發起請求,這會用到網絡資源,同時也會消耗目標端的資源。
斷路開關可防止發起注定會失敗的請求。該機制的實現非常簡單:如果到某個服務的請求出現較多失敗,添加一個標記并停止在接下來一段時間里繼續向這個服務發請求。但同時也要定期允許發起一個請求,借此確認該服務是否重新上線,確認上線即可取消這樣的標記。
斷路開關的邏輯要封裝在服務模板所包含的客戶端庫中。
關聯ID
一個用戶發出的請求可能引起多個服務執行操作,因此對某一特定請求的影響范圍進行調試可能會很難。此時一種簡化該過程的方法是:在服務請求中包含一個關聯ID。關聯ID是一種唯一標識符,可用于區分每個服務傳遞給任意下游請求的請求來源。通過與集中化日志機制配合使用,可輕松看到請求在整個基礎架構中的前進路徑。
該ID可由面向用戶的聚合服務,或由任何需要發出請求,但該請求并非傳入請求直接導致的意外結果的服務生成。任何足夠隨機的字符串(例如UUID)都可用作這個用途。
維持分布式一致性
在最終一致的世界里,服務可通過訂閱事件饋送源(Feed)的方式與其他服務同步數據。
雖然聽起來很簡單,但魔鬼往往隱藏在細節里。數據庫和事件流通常是兩個不同系統,這使得你非常難以用原子級方式同時寫入這兩個系統,進而難以確保最終一致性。
可以使用本地數據庫事務封裝數據庫操作,同時將其寫入事件表。隨后事件發布程序會從事件表讀取。但并非所有數據庫都支持此類事務,事件發布程序可能要從數據庫提交日志中讀取信息,但并非所有數據庫都能暴露此類日志。
... 或者就保持不一致的狀態,稍后再修復吧
分布式系統很難實現一致性。就算以分布式一致性為核心特性的數據庫系統也要很多額外操作才能實現。與其打這樣一場硬仗,其實也可以考慮使用某種盡可能足夠好的同步解決方案,并在事后通過專門的過程找出并修復不一致的地方。
這種方式也能實現最終一致性,只不過“不一致的窗口期”可能會略微長于通過復雜的方式跨越不同系統(數據庫和事件流)實現一致性時的窗口期。
每塊數據都應該有一個單一數據源(Single source of truth)
就算要跨越多個服務復制某些數據,也應該讓一個服務始終成為任何其他數據的單一數據來源。對數據的所有更新需要在這個數據源上進行,同時這個數據源也可在未來用于進行一致性驗證時的記錄來源。
如果某些服務需要強一致怎么辦?
首先需要復查服務邊界是否正確設置。如果服務需要強一致,通常將數據共置在一個服務(以及一個數據庫)這樣的做法更合理,這樣可用更簡單方式提供事務保障。
如果確認服務邊界設置無誤但依然需要強一致,則要檢查一下分布式事務,這種機制很難妥善實現,同時可能會在兩個服務間產生強耦合。建議將其作為最后的手段。
身份認證
所有API請求需要進行身份認證。這樣服務團隊才能更好地分析使用模式,并獲得用于管理不同使用模式下對請求進行限制所需的標識符。
這種標識符是服務團隊為使用該服務的用戶提供的,具備唯一性的API密鑰。必須具備某種頒發和撤銷此類API密鑰的方法。這些方法可內建于服務模板,或通過集中化身份認證服務在平臺層面上提供,這樣還可讓服務團隊以自助服務的方式管理自己的密鑰。
自動重試
在能夠“快速失敗”后,還需要能以自動方式對某些類型的請求進行重試。對于異步通信這一能力更為重要。
故障后的服務恢復上線后,如果有大量其他服務正在同一個重試窗口內重試,此時很容易給系統造成巨大壓力。這種情況也叫驚群效應(Thundering herd),使用隨機化的重試窗口可輕松避免這種問題。如果基礎架構沒有實施斷路開關,建議將隨機化重試窗口與指數退避(Exponential backoff)配合使用以便讓請求進一步分散。
遇到持久的故障又該怎么辦?
有時候故障可能是格式有誤的請求造成的,并非目標服務故障所致。這種情況下無論重試多少次都不會成功。當多次重試失敗后,應將此類請求發送至一個死信隊列(Dead queue)以便事后分析。
僅通過暴露的API通信
服務間的通信只能通過已確立的通信協議進行,不能有例外。如果發現有服務直接與其他服務的數據庫通信,肯定是哪里做錯了。
另外要主意:如果能對服務通信方式做出通用假設(Universal assumption),就能更容易地為防火墻后的服務組件提供更穩妥的保護。
經濟因素
當一個團隊使用另一個團隊提供的服務時,他們通常會假設這些服務是免費的。雖然可以免費使用,但對其他團隊以及組織來說,依然會產生成本。為了更高效地利用現有資源,團隊需要了解不同服務的成本。
有一種很強大的方式可以幫我們做到這一點:為用到的其他服務提供服務發票(Service invoice)。發票中不要只列出用到的其他服務,而是列出實際成本金額。服務開發和運維成本可轉嫁給服務的用戶,而服務實際成本應包含開發成本、基礎架構成本,以及使用其他服務的成本。這樣就可以將總成本均攤計算出每個請求的價格,并可隨著請求數量和成本的變化定期(例如每年一次或兩次)調整。
如果使用其他服務的成本完全透明,開發者將能更好地了解怎樣做對自己的服務或整個組織是最有益的。
客戶端庫
談到其他服務,要做的工作還有很多。例如:發現、身份認證、斷路開關、連接池,以及超時。與其讓每個團隊完全從零開始自行重寫這一套機制,可考慮將其與合理的默認值一起封裝到客戶端庫中。
客戶端庫不能包含與任何服務有關的業務邏輯。其范圍應該僅限于輔助性的內容,例如連接性、傳輸、日志,以及監控。另外要提防共享客戶端庫可能造成的風險。
作者:Vinay Sahni,閱讀英文原文:Best Practices for Building a Microservice Architecture
感謝禚嫻靜對本文的審校。
轉載于:https://www.cnblogs.com/jintheway/articles/6376440.html
總結
以上是生活随笔為你收集整理的【转】构建微服务架构的最佳实践2/3的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 对象转json时,Date类型字段处理。
- 下一篇: 请求头和响应头