天天看點

帶你讀《微服務架構設計模式》之三:微服務架構中的程序間通信第3章

點選檢視第一章 點選檢視第二章

第3章

微服務架構中的程序間通信

本章導讀

  • 通信模式的具體應用:遠端過程調用、斷路器、用戶端發現、自注冊、服務端發現、第三方注冊、異步消息、事務性發件箱、事務日志拖尾、輪詢釋出者
  • 程序間通信在微服務架構中的重要性
  • 定義和演化API
  • 如何在各種程序間通信技術之間進行權衡
  • 使用異步消息對服務的好處
  • 把消息作為資料庫事務的一部分可靠發送

與大多數其他開發人員一樣,瑪麗和她的團隊在程序間通信(IPC)機制方面有一些經驗。FTGO應用程式有一個REST API,供移動應用程式和浏覽器端JavaScript使用。它還使用各種雲服務,例如Twilio消息服務和Stripe支付服務。但是在像FTGO這樣的單體應用程式中,子產品之間通過語言級方法或函數互相調用。FTGO開發人員通常不需要考慮程序間通信,除非他們正在開發REST API或與雲服務內建有關的子產品。

相反,正如你在第2章中看到的那樣,微服務架構将應用程式建構為一組服務。這些服務必須經常協作才能處理各種外部請求。因為服務執行個體通常是在多台機器上運作的程序,是以它們必須使用程序間通信進行互動。是以,程序間通信技術在微服務架構中比在單體架構中扮演着更重要的角色。當應用程式遷移到微服務時,瑪麗和其他FTGO開發人員将需要花費更多時間來思考程序間通信有關的問題。

目前有多種程序間通信機制供開發者選擇。比較流行的是REST(使用JSON)。但是,需要牢記“沒有銀彈”這個大原則。你必須仔細考慮這些選擇。本章将探讨各種程序間通信機制,包括REST和消息傳遞,并讨論如何進行權衡。

選擇合适的程序間通信機制是一個重要的架構決策。它會影響應用程式可用性。更重要的是,正如我在本章和下一章中所解釋的那樣,程序間通信甚至與事務管理互相影響。一個理想的微服務架構應該是在内部由松散耦合的若幹服務組成,這些服務使用異步消息互相通信。REST等同步協定主要用于服務與外部其他應用程式的通信。

我從介紹微服務架構中的程序間通信開始本章。接下來,我将以流行的REST為例描述基于遠端過程調用的程序間通信。服務發現和如何處理“局部失效”是我會重點讨論的主題。在這之後,我會描述基于異步消息的程序間通信,還将讨論保留消息順序、處理重複消息和實作事務性消息等問題。最後,我将介紹自包含服務的概念,這類服務在處理同步請求時無須與其他服務通信,可以提高可用性。

3.1 微服務架構中的程序間通信概述

有很多程序間通信技術可供開發者選擇。服務可以使用基于同步請求/響應的通信機制,例如HTTP REST或gRPC。另外,也可以使用異步的基于消息的通信機制,比如AMQP或STOMP。消息的格式也不盡相同。服務可以使用具備可讀性的格式,比如基于文本的JSON或XML。也可以使用更加高效的、基于二進制的Avro或Protocol Buffers格式。

在深入細節之前,我想提出一些值得考慮的設計問題。我們先來看看服務的互動方式,我将采用獨立于技術實作的方式抽象地描述用戶端與服務之間的互動。接下來,我将讨論在微服務架構中精确定義API的重要性,包括API優先的設計概念。之後,我将讨論如何進行API演化(變更)這個重要主題。最後,我會讨論消息格式的不同選項以及它們如何決定API演化的難易。讓我們首先從互動方式開始吧。

3.1.1 互動方式

在為服務的API選擇程序間通信機制之前,首先考慮服務與其用戶端的互動方式是非常重要的。考慮互動方式将有助于你專注于需求,并避免陷入特定程序間通信技術的細節。此外,如3.4節所述,互動方式的選擇會影響應用程式的可用性。正如你将在第9章和第10章中看到的,互動方式還可以幫助你選擇更合适的內建測試政策。

有多種用戶端與服務的互動方式。如表3-1所示,它們可以分為兩個次元。第一個次元關注的是一對一和一對多。

  • 一對一:每個用戶端請求由一個服務執行個體來處理。
  • 一對多:每個用戶端請求由多個服務執行個體來處理。

互動方式的第二個次元關注的是同步和異步。

  • 同步模式:用戶端請求需要服務端實時響應,用戶端等待響應時可能導緻堵塞。
  • 異步模式:用戶端請求不會阻塞程序,服務端的響應可以是非實時的。
    帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

一對一的互動方式有以下幾種類型。

  • 請求/響應:一個用戶端向服務端發起請求,等待響應;用戶端期望服務端很快就會發送響應。在一個基于線程的應用中,等待過程可能造成線程阻塞。這樣的方式會導緻服務的緊耦合。
  • 異步請求/響應:用戶端發送請求到服務端,服務端異步響應請求。用戶端在等待響應時不會阻塞線程,因為服務端的響應不會馬上就傳回。
  • 單向通知:用戶端的請求發送到服務端,但是并不期望服務端做出任何響應。

需要牢記,同步請求/響應的互動方式并不會因為具體的程序間通信技術而發生改變。例如,一個服務使用請求/響應的方式與其他服務互動,底層的程序間通信技術可以是REST,也可以是消息機制。也就是說,即使兩個服務通過(異步)消息代理通信,用戶端仍舊可能等待響應。這樣的話,這兩個服務在某種意義上仍舊是緊耦合的。我們稍後在讨論服務間通信和可用性這個話題時,會再深入讨論。

一對多的互動方式有以下幾種類型。

  • 釋出/訂閱方式:用戶端釋出通知消息,被零個或者多個感興趣的服務訂閱。
  • 釋出/異步響應方式:用戶端釋出請求消息,然後等待從感興趣的服務發回的響應。

每個服務通常使用的都是以上這些互動方式的組合。FTGO應用中的某些服務同時使用同步和異步API,有些還可以釋出事件。

現在,我們來看看如何定義服務的API。

3.1.2 在微服務架構中定義API

API或接口是軟體開發的中心。應用是由子產品構成的,每個子產品都有接口,這些接口定義了子產品的用戶端可以調用若幹操作。一個設計良好的接口會在暴露有用功能同時隐藏實作的細節。是以,這些實作的細節可以被修改,而接口保持不變,這樣就不會對用戶端産生影響。

在單體架構的應用程式中,接口通常采用程式設計語言結構(如Java接口)定義。Java 接口制定了一組用戶端可以調用的方法。具體的實作類對于用戶端來說是不可見的。而且,由于Java是靜态類型程式設計語言,如果接口變得與用戶端不相容,那麼應用程式就無法通過編譯。

API和接口在微服務架構中同樣重要。服務的API是服務與其用戶端之間的契約(contract)。如第2章所述,服務的API由用戶端結構可以調用的方法和服務釋出的事件組成。方法具備名稱、參數和傳回類型。事件具有一個類型和一組字段,并且如3.3節所述,釋出到消息通道。

相比單體架構,我們面臨的挑戰在于:并沒有一個簡單的程式設計語言結構可以用來構造和定義服務的API。根據定義,服務和它的用戶端并不會一起編譯。如果使用不相容的API部署新版本的服務,雖然在編譯階段不會出現錯誤,但是會出現運作時故障。

無論選擇哪種程序間通信機制,使用某種接口定義語言(IDL)精确定義服務的API都很重要。人們圍繞着是否應該使用API優先這類方法定義服務展開了一系列有益的争論(www.programmableweb.com/news/how-to-design-great-apis-api-first-design-and-raml/how-to/2015/07/10 )。首先編寫接口定義,然後與用戶端開發人員一起檢視這些接口定義。隻有在反複疊代幾輪API定義之後,才開始具體的服務實作程式設計。這種預先設計有助于你建構滿足用戶端需求的服務。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

如何定義API取決于你使用的程序間通信機制。例如,如果你正在使用消息機制,則API由消息通道、消息類型和消息格式組成。如果你使用HTTP,則API由URL、HTTP動詞以及請求和響應格式組成。在本章的後面,我将解釋如何定義API。

服務的API很少一成不變,它可能會随着時間的推移而發展。讓我們來看看如何做到這一點,并讨論你将面臨的問題。

3.1.3 API的演化

API不可避免地會随着應用功能的增減而發生變化。在單體應用中,變更API并更新所有調用方的代碼是相對簡單的一件事情。如果你使用的是靜态類型的程式設計語言,編譯器就會對那些存在不相容類型的調用給出編譯錯誤。唯一的挑戰在于變更的範圍。當變更使用較廣的API時,可能需要較長的時間。

在基于微服務架構的應用中改變服務的API就沒這麼容易了。服務的用戶端可能是另外的服務,通常是其他團隊所開發的。用戶端也極有可能是由組織之外的人所開發和控制的。你不能夠強行要求用戶端跟服務端的API版本保持一緻。另外,由于現代應用程式有着極高的可用性要求,你一般會采用滾動更新的方式來更新服務,是以一個服務的舊版本和新版本肯定會共存。

為這些挑戰制定應對措施是非常重要的。具體的措施取決于API演化的實際情況。

語義化版本控制

語義化版本控制規範(

http://semver.org

)為API版本控制提供了有用的指導。它是一組規則,用于指定如何使用版本号,并且以正确的方式遞增版本号。語義化版本控制最初的目的是用軟體包的版本控制,但你可以将其用在分布式系統中對API進行版本控制。

語義化版本控制規範(Semvers)要求版本号由三部分組成:MAJOR.MINOR.PATCH。必須按如下方式遞增版本号:

  • MAJOR:當你對API進行不相容的更改時。
  • MINOR:當你對API進行向後相容的增強時。
  • PATCH:當你進行向後相容的錯誤修複時。

有幾個地方可以在API中使用版本号。如果你正在實作REST API,則可以使用主要版本作為URL路徑的第一個元素,如下所述。或者,如果你要實作使用消息機制的服務,則可以在其釋出的消息中包含版本号。目标是正确地為API設定版本,并以受控方式變更它們。讓我們來看看如何處理次要和主要變化。

進行次要并且向後相容的改變

理想情況下,你應該努力隻進行向後相容的更改。向後相容的更改是對API的附加更改或功能增強:

  • 添加可選屬性。
  • 向響應添加屬性。
  • 添加新操作。

如果你隻進行這些類型的更改,那麼老版本的用戶端将能夠直接使用更新的服務,但前提是用戶端和服務都遵守健壯性原則(

https://en.wikipedia.org/wiki/Robustness_principle

),這個原則類似于我們常說的“嚴以律己,寬以待人”。服務應該為缺少的請求屬性提供預設值。同樣,用戶端應忽略任何額外的響應屬性。為了避免問題,用戶端和服務必須使用支援健壯性原則的請求和響應格式。在本節的後面部分,我将解釋為什麼基于文本格式(如JSON和XML)的API通常更容易進行變更。

進行主要并且不向後相容的改變

有時你必須對API進行主要并且不向後相容的更改。由于你無法強制用戶端立即更新,是以服務必須在一段時間内同時支援新舊版本的API。如果你使用的是基于HTTP的程序間通信機制,例如REST,則一種方法是在URL中嵌入主要版本号。例如,版本1路徑以

/v1/...為字首,而版本2路徑以/v2/...為字首。

另一種選擇是使用HTTP的内容協商機制,并在MIME類型中包含版本号。例如,用戶端将使用如下格式針對1.x版的服務API發起Order相關的請求:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

此請求告訴Order Service用戶端需要以版本1.x做出響應。

為了支援多個版本的API,實作API的服務擴充卡将包含在舊版本和新版本之間進行轉換的邏輯。此外,如第8章所述,API Gateway幾乎肯定會使用版本化的API。它甚至可能必須支援許多舊版本的API。

現在我們來看一看消息格式的問題,看選擇哪種格式會影響API變更的難易。

3.1.4 消息的格式

程序間通信的本質是交換消息。消息通常包括資料,是以一個重要的設計決策就是這些資料的格式。消息格式的選擇會對程序間通信的效率、API的可用性和可演化性産生影響。如果你正在使用一個類似HTTP的消息系統或者協定,那麼你需要選擇消息的格式。有些程序間通信機制,如我們馬上就會講到的gRPC,已經指定了消息格式。在這兩種情況下,使用跨語言的消息格式尤為重要。即使我們今天使用同一種程式設計語言來開發微服務應用,那也很有可能在今後會擴充到其他的程式設計語言。我們不應該使用類似Java序列化這樣跟程式設計語言強相關的消息格式。

消息的格式可以分為兩大類:文本和二進制。我們來逐一分析。

基于文本的消息格式

第一類是JSON和XML這樣的基于文本的格式。這類消息格式的好處在于,它們的可讀性很高,同時也是自描述的。JSON消息是命名屬性的集合。相似地,XML消息也是命名元素和值的集合。這樣的格式允許消息的接收方隻挑選他們感興趣的值,而忽略掉其他。是以,對消息結構的修改可以做到很好的後向相容性。

XML文檔結構的定義由XML Schema完成(www.w3.org/XML/Schema)。開發者社群逐漸意識到JSON也需要一個類似的機制,是以使用JSON Schema變得逐漸流行(

http://json-schema.org

)。JSON Schema定義了消息屬性的名稱和類型,以及它們是可選的還是必需的。除了能夠起到文檔的作用之外,應用程式還可以使用JSON Schema來驗證傳入的消息結構是否正确。

使用基于文本格式消息的弊端主要是消息往往過度冗長,特别是XML。消息的每一次傳遞都必須反複包含除了值以外的屬性名稱,這樣會造成額外的開銷。另外一個弊端是解析文本引入的額外開銷,尤其是在消息較大的時候。是以,在對效率和性能敏感的場景下,你可能需要考慮基于二進制格式的消息。

二進制消息格式

有幾種不同的二進制格式可供選擇。常用的包括Protocol Buffers(

https://developers.google.com/Protocol-buffers/docs/overview

)和Avro(

https://avro.apache.org

)。這兩種格式都提供了一個強類型定義的IDL(接口描述檔案),用于定義消息的格式。編譯器會自動根據這些格式生成序列化和反序列化的代碼。是以你不得不采用API優先的方法來進行服務設計。此外,如果使用靜态類型語言編寫用戶端,編譯器會強制檢查它是否使用了正确的API格式。

這兩種二進制格式的差別在于,Protocol Buffers使用tagged fields(帶标記的字段),而Avro的消費者在解析消息之前需要知道它的格式。是以,實行API的版本更新演進,Protocol Buffer要優于Avro。有篇部落格文章對Thrift、Protocol Buffers和Avro做了非常全面的比較(

http://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html

)。

現在我們已經了解了消息格式,再來看看用于傳輸消息的特定程序間通信機制,從遠端過程調用(RPI)模式開始。

3.2 基于同步遠端過程調用模式的通信

當使用基于遠端過程調用(RPI)的程序間通信機制時,用戶端向服務發送請求,服務處理該請求并發回響應。有些用戶端可能會處在堵塞狀态并等待響應,而其他用戶端可能會有一個響應式的非阻塞架構。但與使用消息機制時不同,用戶端假定響應将及時到達。

圖3-1顯示了遠端過程調用的工作原理。用戶端中的業務邏輯調用代理接口,這個接口由遠端過程調用代理擴充卡類實作。遠端過程調用代理向服務送出請求。該請求由遠端過程調用伺服器擴充卡類處理,該類通過接口調用服務的業務邏輯。然後它将回複發送回遠端過程調用代理,該代理将結果傳回給用戶端的業務邏輯。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

代理接口通常封裝底層通信協定。有許多協定可供選擇。在本節中,我将介紹REST和gRPC。我将介紹如何通過正确處理局部故障來提高服務的可用性,并解釋為什麼使用遠端過程調用的基于微服務的應用程式必須使用服務發現機制。

我們先來看看REST。

3.2.1 使用REST

如今開發者非常喜歡使用RESTful風格來開發API(

https://en.wikipedia.org/wiki/Repres-entational_state_transfer

)。REST是一種(總是)使用HTTP協定的程序間通信機制,REST之父Roy Fielding曾經說過:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

REST中的一個關鍵概念是資源,它通常表示單個業務對象,例如客戶或産品,或業務對象的集合。REST使用HTTP動詞來操作資源,使用URL引用這些資源。例如,GET請求傳回資源的表示形式,該資源通常采用XML文檔或JSON對象的形式,但也可以使用其他格式(如二進制)。POST請求建立新資源,PUT請求更新資源。例如,Order Service具有用于建立Order的POST/order端點以及用于檢索Order的GET/orders/{orderId}端點。

很多開發者都表示他們基于HTTP的API是RESTful風格的。但是,如同Roy Fielding在他的部落格中所說,并非所有這些API都是RESTful風格的(

http://rog.gbiv.com/untangled/

2008/rest-apis-must-be-hypertext-driven)。為了更好地了解這個概念,我們來看一看REST成熟度模型。

REST成熟度模型

Leonard Richardson為REST定義了一個成熟度模型(

https://martinfowler.com/articles/richardsonMaturityModel.html

),具體包含以下四個層次。

  • Level 0:Level 0層級服務的用戶端隻是向服務端點發起HTTP POST請求,進行服務調用。每個請求都指明了需要執行的操作、這個操作針對的目标(例如,業務對象)和必要的參數。
  • Level 1:Level 1層級的服務引入了資源的概念。要執行對資源的操作,用戶端需要發出指定要執行的操作和包含任何參數的POST請求。
  • Level 2:Level 2層級的服務使用HTTP動詞來執行操作,譬如GET表示擷取、POST表示建立、PUT表示更新。請求查詢參數和主體(如果有的話)指定操作的參數。這讓服務能夠借助Web基礎設施服務,例如通過CDN來緩存GET請求。
  • Level 3:Level 3層級的服務基于HATEOAS(Hypertext As The Engine Of Application State)原則設計,基本思想是在由GET請求傳回的資源資訊中包含連結,這些連結能夠執行該資源允許的操作。例如,用戶端通過訂單資源中包含的連結取消某一訂單,或者發送GET請求去擷取該訂單,等等。HATEOAS的優點包括無須在用戶端代碼中寫入硬連結的URL。此外,由于資源資訊中包含可允許操作的連結,用戶端無須猜測在資源的目前狀态下執行何種操作(www.infoq.com/news/2009/04/hateoas-restful-api-advantages )。

建議檢查你手中項目的REST API,看看它們達到了哪一個級别。

定義REST API

如前面3.1節所述,你必須使用接口定義語言(IDL)定義API。與舊的通信協定(如CORBA和SOAP)不同,REST最初沒有IDL。幸運的是,開發者社群重新發現了RESTful API的IDL價值。最流行的REST IDL是Open API規範(www.openapis.org),它是從Swagger開源項目發展而來的。Swagger項目是一組用于開發和記錄REST API的工具。它包括從接口定義到生成用戶端樁(stub,存根)和伺服器骨架的一整套工具。

在一個請求中擷取多個資源的挑戰

REST資源通常以業務對象為導向,例如Consumer和Order。是以,設計REST API時的一個常見問題是如何使用戶端能夠在單個請求中檢索多個相關對象。例如,假設REST用戶端想要檢索Order和這個Order的Consumer。純REST API要求用戶端至少發出兩個請求,一個用于Order,另一個用于Consumer。更複雜的情況需要更多往返并且遭受過多的延遲。

此問題的一個解決方案是API允許用戶端在擷取資源時檢索相關資源。例如,客戶可以使用GET/orders/order-id-1345?expand=consumer檢索Order及其Consumer。請求中的查詢參數用來指定要與Order一起傳回的相關資源。這種方法在許多場景中都很有效,但對于更複雜的場景來說,它通常是不夠的。實作它也可能很耗時。這導緻了替代技術的日益普及,例如GraphQL(

http://graphql.org

)和Netflix Falcor(

http://netflix.github.io/falcor

),它們旨在支援高效的資料擷取。

把操作映射為HTTP動詞的挑戰

另一個常見的REST API設計問題是如何将要在業務對象上執行的操作映射到HTTP動詞。 REST API應該使用PUT進行更新,但可能有多種方法來更新訂單,包括取消訂單、修改訂單等。此外,更新可能不是幂等的,但這卻是使用PUT的要求。一種解決方案是定義用于更新資源的特定方面的子資源。例如,Order Service具有用于取消訂單的POST/orders/{orderId}/cancel端點,以及用于修訂訂單的POST/orders/{orderId}/revise端點。另一種解決方案是将動詞指定為URL的查詢參數。可惜的是,這兩種解決方案都不是特别符合RESTful的要求。

映射操作到HTTP動詞的這個問題導緻了REST替代方案的日益普及,例如gPRC,我将在3.2.2節中讨論這項技術。但首先讓我們來看看REST的好處和弊端。

REST的好處和弊端

REST有如下好處:

  • 它非常簡單,并且大家都很熟悉。
  • 可以使用浏覽器擴充(比如Postman插件)或者curl之類的指令行(假設使用的是JSON或其他文本格式)來測試HTTP API。
  • 直接支援請求/響應方式的通信。
  • HTTP對防火牆友好。
  • 不需要中間代理,簡化了系統架構。

它也存在一些弊端:

  • 它隻支援請求/響應方式的通信。
  • 可能導緻可用性降低。由于用戶端和服務直接通信而沒有代理來緩沖消息,是以它們必須在REST API調用期間都保持線上。
  • 用戶端必須知道服務執行個體的位置(URL)。如3.2.4節所述,這是現代應用程式中的一個重要問題。用戶端必須使用所謂的服務發現機制來定位服務執行個體。
  • 在單個請求中擷取多個資源具有挑戰性。
  • 有時很難将多個更新操作映射到HTTP動詞。

雖然存在這些缺點,但REST似乎是API的事實标準,盡管有幾個有趣的替代方案。例如,通過GraphQL實作靈活、高效的資料提取。第8章将讨論GraphQL,并介紹API Gateway模式。

gRPC是REST的另一種替代方案。我們來看看它是如何工作的。

3.2.2 使用gRPC

如上一節所述,使用REST的一個挑戰是,由于HTTP僅提供有限數量的動詞,是以設計支援多個更新操作的REST API并不總是很容易。避免此問題的程序間通信技術是gRPC(www.grpc.io ),這是一個用于編寫跨語言用戶端和服務端的架構(

https://en.wikipedia.org/wiki/Remote_procedure_call

)。gRPC是一種基于二進制消息的協定,這意味着如同前面讨論二進制消息格式時所說的,你不得不采用API優先的方法來進行服務設計。你可以使用基于Protocol Buffer的IDL定義gRPC API,這是谷歌公司用于序列化結構化資料的一套語言中立機制。你可以使用Protocol Buffer編譯器生成用戶端的樁(stub,也稱為存根)和服務端骨架(skeleton)。編譯器可以為各種語言生成代碼,包括Java、C#、Node.js和GoLang。用戶端和服務端使用HTTP/2以Protocol Buffer格式交換二進制消息。

gRPC API由一個或多個服務和請求/響應消息定義組成。服務定義類似于Java接口,是強類型方法的集合。除了支援簡單的請求/響應RPC之外,gRPC還支援流式RPC。伺服器可以使用消息流回複用戶端。用戶端也可以向伺服器發送消息流。

gRPC使用Protocol Buffers作為消息格式。如前所述,Protocol Buffers是一種高效且緊湊的二進制格式。它是一種标記格式。Protocol Buffers消息的每個字段都有編号,并且有一個類型代碼。消息接收方可以提取所需的字段,并跳過它無法識别的字段。是以,gRPC使API能夠在保持向後相容的同時進行變更。

代碼清單3-1顯示了Order Service的gRPC API。它定義了幾種方法,包括createOrder()。此方法将CreateOrderRequest作為參數并傳回CreateOrderReply。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章
帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

CreateOrderRequest和CreateOrderReply是具有類型的消息。例如,Create-OrderRequest消息具有int64類型的restaurantId字段。字段的标記值為1。

gRPC有幾個好處:

  • 設計具有複雜更新操作的API非常簡單。
  • 它具有高效、緊湊的程序間通信機制,尤其是在交換大量消息時。
  • 支援在遠端過程調用和消息傳遞過程中使用雙向流式消息方式。
  • 它實作了用戶端和用各種語言編寫的服務端之間的互操作性。

gRPC也有幾個弊端:

  • 與基于REST/JSON的API機制相比,JavaScript用戶端使用基于gRPC的API需要做更多的工作。
  • 舊式防火牆可能不支援HTTP/2。

gRPC是REST的一個引人注目的替代品,但與REST一樣,它是一種同步通信機制,是以它也存在局部故障的問題。讓我們來看看它是什麼以及如何處理它。

3.2.3 使用斷路器模式處理局部故障

分布式系統中,當服務試圖向另一個服務發送同步請求時,永遠都面臨着局部故障的風險。因為用戶端和服務端是獨立的程序,服務端很有可能無法在有限的時間内對用戶端的請求做出響應。服務端可能因為故障或維護的原因而暫停。或者,服務端也可能因為過載而對請求的響應變得極其緩慢。

用戶端等待響應被阻塞,這可能帶來的麻煩就是在其他用戶端甚至使用服務的第三方應用之間傳導,并導緻服務中斷。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

例如,考慮圖3-2所示的場景,其中Order Service無響應。移動用戶端向API Gateway發出REST請求,如第8章所述,它是API用戶端應用程式的入口點。API Gateway将請求代理到無響應的Order Service。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

OrderServiceProxy将無限期地阻塞,等待響應。這不僅會導緻糟糕的使用者體驗,而且在許多應用程式中,它會消耗寶貴的資源,例如線程。最終,API Gateway将耗盡資源,無法處理請求。整個API都不可用。

要通過合理地設計服務來防止在整個應用程式中故障的傳導和擴散,這是至關重要的。解決這個問題分為兩部分:

  • 必須讓遠端過程調用代理(例如OrderServiceProxy)有正确處理無響應服務的能力。
  • 需要決定如何從失敗的遠端服務中恢複。

首先,我們将看看如何編寫健壯的遠端過程調用代理。

開發可靠的遠端過程調用代理

每當一個服務同步調用另一個服務時,它應該使用Netflix描述的方法(

http://techblog.netflix.com/2012/02/faulttolerance-in-high-volume.html

)來保護自己。這種方法包括以下機制的組合。

  • 網絡逾時:在等待針對請求的響應時,一定不要做成無限阻塞,而是要設定一個逾時。使用逾時可以保證不會一直在無響應的請求上浪費資源。
  • 限制用戶端向伺服器送出請求的數量:把用戶端能夠向特定服務發起的請求設定一個上限,如果請求達到了這樣的上限,很有可能發起更多的請求也無濟于事,這時就應該讓請求立刻失敗。
  • 斷路器模式:監控用戶端送出請求的成功和失敗數量,如果失敗的比例超過一定的門檻值,就啟動斷路器,讓後續的調用立刻失效。如果大量的請求都以失敗而告終,這說明被調服務不可用,這樣即使發起更多的調用也是無濟于事。在經過一定的時間後,用戶端應該繼續嘗試,如果調用成功,則解除斷路器。

Netflix Hystrix(

https://github.com/Netflix/Hystrix

)是一個實作這些和其他模式的開源庫。如果你正在使用JVM,那麼在實作遠端過程調用代理時一定要考慮使用Hystrix。如果你在非JVM環境中開發,則應該找到并使用類似的庫。例如,Polly庫(

https://github.com/App-vNext/Polly

)在.NET社群中很受歡迎。

從服務失效故障中恢複

使用諸如Hystrix之類的庫隻是解決方案的一部分。你還必須根據具體情況決定如何從無響應的遠端服務中恢複你的服務。一種選擇是服務隻是向其用戶端傳回錯誤。例如,這種方法對于圖3-2中所示的場景是有意義的,其中建立Order的請求失敗。唯一的選擇是API Gateway将錯誤傳回給移動用戶端。

在其他情況下,傳回備用值(fallback value,例如預設值或緩存響應)可能會有意義。例如,第7章将描述API Gateway如何使用API組合模式實作findOrder()查詢操作。如圖3-3所示,其GET/orders/{orderId}端點的實作調用了多個服務,包括Order Service、Kitchen Service和Delivery Service,并将結果組合在一起。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

每個服務的資料對客戶來說重要性可能不同。Order Service的資料至關重要。如果此服務不可用,API Gateway應傳回其資料的緩存版本或錯誤。來自其他服務的資料不太重要。例如,即使送餐狀态不可用,客戶也可以向使用者顯示有用的資訊。如果Delivery Service不可用,API Gateway應傳回其資料的緩存版本或從響應中省略它。

在設計服務時考慮局部故障至關重要,但這不是使用遠端過程調用時需要解決的唯一問題。另一個問題是,為了讓一個服務使用遠端過程調用調用另一個服務,它需要知道服務執行個體的網絡位置。表面上看起來很簡單,但在實踐中這是一個具有挑戰性的問題。你必須使用服務發現機制。讓我們來看看它是如何工作的。

3.2.4 使用服務發現

假設你正在編寫一些調用具有REST API的服務的代碼。為了送出請求,你的代碼需要知道服務執行個體的網絡位置(IP位址和端口)。在實體硬體上運作的傳統應用程式中,服務執行個體的網絡位置通常是靜态的。例如,你的代碼可以從偶爾更新的配置檔案中讀取網絡位置。但在現代的基于雲的微服務應用程式中,通常不那麼簡單。如圖3-4所示,現代應用程式更具動态性。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

服務執行個體具有動态配置設定的網絡位置。此外,由于自動擴充、故障和更新,服務執行個體集會動态更改。是以,你的用戶端代碼必須使用服務發現。

什麼是服務發現

正如剛才所見,你無法使用服務的IP位址靜态配置用戶端。相反,應用程式必須使用動态服務發現機制。服務發現在概念上非常簡單:其關鍵元件是服務系統資料庫,它是包含服務執行個體網絡位置資訊的一個資料庫。

服務執行個體啟動和停止時,服務發現機制會更新服務系統資料庫。當用戶端調用服務時,服務發現機制會查詢服務系統資料庫以擷取可用服務執行個體的清單,并将請求路由到其中一個服務執行個體。

實作服務發現有以下兩種主要方式:

  • 服務及其客戶直接與服務系統資料庫互動。
  • 通過部署基礎設施來處理服務發現。(我将在第12章中詳細讨論這一點。)

    我們來逐一進行分析。

應用層服務發現模式

實作服務發現的一種方法是應用程式的服務及其用戶端與服務系統資料庫進行互動。圖3-5顯示了它的工作原理。服務執行個體使用服務系統資料庫注冊其網絡位置。用戶端首先通過查詢服務系統資料庫擷取服務執行個體清單來調用服務,然後它向其中一個執行個體發送請求。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

這種服務發現方法是兩種模式的組合。第一種模式是自注冊模式。服務執行個體調用服務系統資料庫的注冊API來注冊其網絡位置。它還可以提供運作狀況檢查URL,在第11章中有更詳細的描述。運作狀況檢查URL是一個API端點,服務系統資料庫會定期調用該端點來驗證服務執行個體是否正常且可用于處理請求。服務系統資料庫還可能要求服務執行個體定期調用“心跳” API以防止其注冊過期。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

第二種模式是用戶端發現模式。當用戶端想要調用服務時,它會查詢服務系統資料庫以擷取服務執行個體的清單。為了提高性能,用戶端可能會緩存服務執行個體。然後,服用戶端使用負載平衡算法(例如循環或随機)來選擇服務執行個體。然後它向選擇的服務執行個體送出請求。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

Netflix和Pivotal在應用層服務發現方面做了大量的普及工作。Netflix開發并開源了幾個元件,包括:Eureka,這是一個高可用的服務系統資料庫;Eureka Java用戶端;Ribbon,這是一個支援Eureka用戶端的複雜HTTP用戶端。Pivotal開發了Spring Cloud,這是一個基于Spring的架構,使得Netflix元件的使用非常簡單。基于Spring Cloud的服務自動向Eureka注冊,基于Spring Cloud的用戶端是以可以自動使用Eureka進行服務發現。

應用層服務發現的一個好處是它可以處理多平台部署的問題(服務發現機制與具體的部署平台無關)。例如,想象一下,你在Kubernetes上隻部署了一些服務(将在第12章中讨論過),其餘服務在遺留環境中運作。在這種情況下,使用Eureka的應用層服務發現同時适用于兩種環境,而基于Kubernetes的服務發現僅能用于部署在Kubernetes平台之上的部分服務。

應用層服務發現的一個弊端是:你需要為你使用的每種程式設計語言(可能還有架構)提供服務發現庫。Spring Cloud隻能幫助Spring開發人員。如果你正在使用其他Java架構或非JVM語言(如Node.js或GoLang),則必須找到其他一些服務發現架構。應用層服務發現的另一個弊端是開發者負責設定和管理服務系統資料庫,這會分散一定的精力。是以,最好使用部署基礎設施提供的服務發現機制。

平台層服務發現模式

在第12章中,你将了解許多現代部署平台(如Docker和Kubernetes)都具有内置的服務系統資料庫和服務發現機制。部署平台為每個服務提供DNS名稱、虛拟IP(VIP)位址和解析為VIP位址的DNS名稱。用戶端向DNS名稱和VIP送出請求,部署平台自動将請求路由到其中一個可用服務執行個體。是以,服務注冊、服務發現和請求路由完全由部署平台處理。

圖3-6顯示了它的工作原理。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

這種方法是以下兩種模式的組合。

  • 第三方注冊模式:由第三方負責(稱為注冊伺服器,通常是部署平台的一部分)處理注冊,而不是服務本身向服務系統資料庫注冊自己。
  • 服務端發現模式:用戶端不再需要查詢服務系統資料庫,而是向DNS名稱送出請求,對該DNS名稱的請求被解析到路由器,路由器查詢服務系統資料庫并對請求進行負載均衡。
    帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

由平台提供服務發現機制的主要好處是服務發現的所有方面都完全由部署平台處理。服務和用戶端都不包含任何服務發現代碼。是以,無論使用哪種語言或架構,服務發現機制都可供所有服務和客戶使用。

平台提供服務發現機制的一個弊端是它僅限于支援使用該平台部署的服務。例如,如前所述,在描述應用程式級别發現時,基于Kubernetes的發現僅适用于在Kubernetes上運作的服務。盡管存在此限制,我建議盡可能使用平台提供的服務發現。

現在我們已經學習了使用REST或gRPC的同步程序間通信,讓我們來看看替代方案:基于異步消息模式的通信。

3.3 基于異步消息模式的通信

使用消息機制時,服務之間的通信采用異步交換消息的方式完成。基于消息機制的應用程式通常使用消息代理,它充當服務之間的中介。另一種選擇是使用無代理架構,通過直接向服務發送消息來執行服務請求。服務用戶端通過向服務發送消息來送出請求。如果希望服務執行個體回複,服務将通過向用戶端發送單獨的消息的方式來實作。由于通信是異步的,是以用戶端不會堵塞和等待回複。相反,用戶端都假定回複不會馬上就收到。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

我将從概述消息開始本節。我将展示如何獨立于消息技術描述基于消息的架構。接下來,我将對比無代理和有代理的架構,并描述選擇消息代理的标準。然後,我将讨論幾個重要的主題,包括在擴充接收方的同時保持消息的順序、檢測和丢棄重複的消息,以及作為資料庫事務的一部分發送和接收消息。讓我們從檢視消息機制的工作原理開始。

3.3.1 什麼是消息傳遞

Gregor Hohpe和Bobby Woolf在《Enterprise Integration Patterns》一書(Addison-Wesley,2003年)中定義了一種有用的消息傳遞模型。在此模型中,消息通過消息通道進行交換。發送方(應用程式或服務)将消息寫入通道,接收方(應用程式或服務)從通道讀取消息。讓我們先學習消息,然後學習通道。

關于消息

消息由消息頭部和消息主體組成(www.enterpriseintegrationpatterns.com/Message.html )。标題是名稱與值對的集合,描述正在發送的資料的中繼資料。除了消息發送者提供的名稱與值對之外,消息頭部還包含其他資訊,例如發件人或消息傳遞基礎設施生成的唯一消息ID,以及可選的傳回位址,該位址指定發送回複的消息通道。消息正文是以文本或二進制格式發送的資料。

有以下幾種不同類型的消息。

  • 文檔:僅包含資料的通用消息。接收者決定如何解釋它。對指令式消息的回複是文檔消息的一種使用場景。
  • 指令:一條等同于RPC請求的消息。它指定要調用的操作及其參數。
  • 事件:表示發送方這一端發生了重要的事件。事件通常是領域事件,表示領域對象(如Order或Customer)的狀态更改。

在本書描述的微服務架構實踐中大量使用了指令式消息和事件式消息。

現在讓我們看一看通道,即服務溝通的機制。

關于消息通道

如圖3-7所示,消息通過消息通道進行交換(www.enterpriseintegrationpatterns.com/MessageChannel.html )。發送方中的業務邏輯調用發送端接口,該接口封裝底層通信機制。發送端由消息發送擴充卡類實作,該消息發送擴充卡類通過消息通道向接收器發送消息。消息通道是消息傳遞基礎設施的抽象。調用接收器中的消息處理程式擴充卡類來處理消息。它調用接收方業務邏輯實作的接收端接口。任意數量的發送方都可以向通道發送消息。類似地,任何數量的接收方都可以從通道接收消息。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

有以下兩種類型的消息通道:點對點(www.enterpriseintegrationpatterns.com/PointToPointChannel.html )和釋出-訂閱(www.enterpriseintegrationpatterns.com/PublishSubscribeChannel.html )。

  • 點對點通道向正在從通道讀取的一個消費者傳遞消息。服務使用點對點通道來實作前面描述的一對一互動方式。例如,指令式消息通常通過點對點通道發送。
  • 釋出-訂閱通道将一條消息發給所有訂閱的接收方。服務使用釋出-訂閱通道來實作前面描述的一對多互動方式。例如,事件式消息通常通過釋出-訂閱通道發送。

3.3.2 使用消息機制實作互動方式

消息機制的一個有價值的特性是它足夠靈活,可以支援3.1.1節中描述的所有互動方式。一些互動方式通過消息機制直接實作。其他必須在消息機制之上實作。

我們來看看如何實作每種互動方式,從請求/響應和異步請求/響應開始。

實作請求/響應和異步請求/響應

當用戶端和服務使用請求/響應或異步請求/響應進行互動時,用戶端會發送請求,服務會發回回複。兩種互動方式之間的差別在于,對于請求/響應,用戶端期望服務立即響應,而對于異步請求/響應,則沒有這樣的期望。消息機制本質上是異步的,是以隻提供異步請求/響應。但用戶端可能會堵塞,直到收到回複。

用戶端和服務端通過交換一對消息來實作異步請求/響應方式的互動。如圖3-8所示,用戶端發送指令式消息,該消息指定要對服務執行的操作和參數,這些内容通過服務擁有的點對點消息通道傳遞。該服務處理請求,并将包含結果的回複消息發送到用戶端擁有的點對點通道。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

用戶端必須告知服務發送回複消息的位置,并且必須将回複消息與請求比對。幸運的是,解決這兩個問題并不困難。用戶端發送具有回複通道頭部的指令式消息。伺服器将回複消息寫入回複通道,該回複消息包含與消息辨別符具有相同值的相關性ID。用戶端使用相關性ID将回複消息與請求進行比對。

由于用戶端和服務使用消息機制進行通信,是以互動本質上是異步的。理論上,使用消息機制的用戶端可能會阻塞,直到收到回複,但實際上用戶端将異步處理回複。而且,回複通常可以由任何一個用戶端執行個體處理。

實作單向通知

使用異步消息實作單向通知非常簡單。用戶端将消息(通常是指令式消息)發送到服務所擁有的點對點通道。服務訂閱該通道并處理該消息,但是服務不會發回回複。

實作釋出/訂閱

消息機制内置了對釋出/訂閱互動方式的支援。用戶端将消息釋出到由多個接收方讀取的釋出/訂閱通道。如第4章和第5章所述,服務使用釋出/訂閱來釋出領域事件,領域事件代表領域對象的更改。釋出領域事件的服務擁有自己的釋出/訂閱通道,通道的名稱往往派生自領域類。例如,Order Service将Order事件釋出到Order通道,Delivery Service将Delivery事件釋出到Delivery通道。對特定領域對象的事件感興趣的服務隻需訂閱相應的通道。

實作釋出/異步響應

釋出/異步響應互動方式是一種更進階别的互動方式,它通過把釋出/訂閱和請求/響應這兩種方式的元素組合在一起實作。用戶端釋出一條消息,在消息的頭部中指定回複通道,這個通道同時也是一個釋出-訂閱通道。消費者将包含相關性ID的回複消息寫入回複通道。用戶端通過使用相關性ID來收集響應,以此将回複消息與請求進行比對。

應用程式中包含異步API的每個服務都會使用這些實作技術中的一種或多種。帶有異步API調用操作的服務會擁有一個用于送出請求的通道,同樣地,需要釋出事件的服務也會擁有一個事件式消息釋出通道。

如3.1.2節所述,為服務編寫API規範很重要。我們來看看如何設計和定義異步API。

3.3.3 為基于消息機制的服務API建立API規範

如圖3-9所示,服務的異步API規範必須指定消息通道的名稱、通過每個通道交換的消息類型及其格式。你還必須使用諸如JSON、XML或Protobuf之類的标準來描述消息的格式。但與REST和Open API不同,并沒有廣泛采用的标準來記錄通道和消息類型,你需要自己編寫這樣的文檔。

服務的異步API包含供用戶端調用的操作和由服務對外釋出的事件。這些API的記錄方式不盡相同。讓我們從操作開始逐一分析。

記錄異步操作

可以使用以下兩種不同互動方式之一調用服務的操作:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章
  • 請求/異步響應式API:包括服務的指令消息通道、服務接受的指令式消息的具體類型和格式,以及服務發送的回複消息的類型和格式。
  • 單向通知式API:包括服務的指令消息通道,以及服務接受的指令式消息的具體類型和格式。

服務可以對異步請求/響應和單向通知使用相同的請求通道。

記錄事件釋出

服務還可以使用釋出/訂閱的方式對外釋出事件。此API風格的規範包括事件通道以及服務釋出到通道的事件式消息的類型和格式。

消息和消息通道模型是一種很好的抽象,也是設計服務異步API的好方法。但是,為了實作服務,你需要選擇具體的消息傳遞技術并确定如何使用它們的能力來實作設計。讓我們看一看所涉及的内容。

3.3.4 使用消息代理

基于消息傳遞的應用程式通常使用消息代理,即服務通信的基礎設施服務。但基于消息代理的架構并不是唯一的消息架構。你還可以使用基于無代理的消息傳遞架構,其中服務直接互相通信。這兩種方法(如圖3-10所示)具有不同的利弊,但通常基于代理的架構是一種更好的方法。

本書側重于基于消息代理的軟體架構,但還是值得快速浏覽一下無代理的架構,因為有些情況下你可能會發現它很有用。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

無代理消息

在無代理的架構中,服務可以直接交換消息。ZeroMQ(

http://zeromq.org

)是一種流行的無代理消息技術。它既是規範,也是一組适用于不同程式設計語言的庫。它支援各種傳輸協定,包括TCP、UNIX風格的套接字和多點傳播。

無代理的架構有以下一些好處:

  • 允許更輕的網絡流量和更低的延遲,因為消息直接從發送方發送到接收方,而不必從發送方到消息代理,再從代理轉發到接收方。
  • 消除了消息代理可能成為性能瓶頸或單點故障的可能性。
  • 具有較低的操作複雜性,因為不需要設定和維護消息代理。

盡管這些好處看起來很吸引人,但無代理的消息具有以下明顯的弊端:

  • 服務需要了解彼此的位置,是以必須使用3.2.4節中描述的服務發現機制。
  • 會導緻可用性降低,因為在交換消息時,消息的發送方和接收方都必須同時線上。
  • 在實作例如確定消息能夠成功投遞這些複雜功能時的挑戰性更大。

實際上,這些弊端中的一些(例如可用性降低和需要使用服務發現),與使用同步請求/響應互動方式所導緻的弊端相同。

由于這些限制,大多數企業應用程式使用基于消息代理的架構。讓我們來看看它是如何工作的。

基于代理的消息

消息代理是所有消息的中介節點。發送方将消息寫入消息代理,消息代理将消息發送給接收方。使用消息代理的一個重要好處是發送方不需要知道接收方的網絡位置。另一個好處是消息代理緩沖消息,直到接收方能夠處理它們。

有許多消息代理可供選擇。流行的開源消息代理包括:

還有基于雲的消息服務,例如AWS Kinesis(

https://aws.amazon.com/kinesis

)和AWS SQS(

https://aws.amazon.com/sqs/

選擇消息代理時,你需要考慮以下各種因素:

  • 支援的程式設計語言:你選擇的消息代理應該支援盡可能多的程式設計語言。
  • 支援的消息标準:消息代理是否支援多種消息标準,比如AMQP和STOMP,還是它僅支援專用的消息标準?
  • 消息排序:消息代理是否能夠保留消息的排序?
  • 投遞保證:消息代理提供什麼樣的消息投遞保證?
  • 持久性:消息是否持久化儲存到磁盤并且能夠在代理崩潰時恢複?
  • 耐久性:如果接收方重新連接配接到消息代理,它是否會收到斷開連接配接時發送的消息?
  • 可擴充性:消息代理的可擴充性如何?
  • 延遲:端到端是否有較大延遲?
  • 競争性(并發)接收方:消息代理是否支援競争性接收方?

每個消息代理都有不同的側重點。例如,一個非常低延遲的代理可能不會保留消息的順序,不保證消息投遞成功,隻在記憶體中存儲消息。保證投遞成功并在磁盤上可靠地存儲消息的代理可能具有更高的延遲。哪種消息代理最适合取決于你的應用程式的需求。你的應用程式的不同部分甚至可能具有不同的消息傳遞需求。

但是,消息順序和可擴充性很可能是必不可少的。現在讓我們看看如何使用消息代理實作消息通道。

使用消息代理實作消息通道

每個消息代理都用自己與衆不同的概念來實作消息通道。如表3-2所示,ActiveMQ等JMS消息代理具有隊列和主題。基于AMQP的消息代理(如RabbitMQ)具有交換和隊列。 Apache Kafka有主題,AWS Kinesis有流,AWS SQS有隊列。更重要的是,一些消息代理提供了比本章中描述的消息和通道抽象更靈活的消息機制。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

這裡描述的幾乎所有消息代理都支援點對點和釋出-訂閱通道。唯一的例外是AWS SQS,它僅支援點對點通道。

現在讓我們來看看基于代理的消息的好處和弊端。

基于代理的消息的好處和弊端

使用消息有以下很多好處。

  • 松耦合:用戶端發起請求時隻要發送給特定的通道即可,用戶端完全不需要感覺服務執行個體的情況,用戶端不需要使用服務發現機制去獲得服務執行個體的網絡位置。
  • 消息緩存:消息代理可以在消息被處理之前一直緩存消息。像HTTP這樣的同步請求/響應協定,在交換資料時,發送方和接收方必須同時線上。然而,在使用消息機制的情況下,消息會在隊列中緩存,直到它們被接收方處理。這就意味着,例如,即使訂單處理系統暫時離線或不可用,線上商店仍舊能夠接受客戶的訂單。訂單消息将會在隊列中緩存(并不會丢失)。
  • 靈活的通信:消息機制支援前面提到的所有互動方式。
  • 明确的程序間通信:基于RPC的機制總是企圖讓遠端服務調用跟本地調用看上去沒什麼差別(在用戶端和服務端同時使用遠端調用代理)。然而,因為實體定律(如伺服器不可預計的硬體失效)和可能的局部故障,遠端和本地調用還是大相徑庭的。消息機制讓這些差異變得很明确,這樣程式員不會陷入一種“太平盛世”的錯覺。

然而,消息機制也有如下一些弊端。

  • 潛在的性能瓶頸:消息代理可能存在性能瓶頸。幸運的是,許多現代消息代理都支援高度的橫向擴充。
  • 潛在的單點故障:消息代理的高可用性至關重要,否則系統整體的可靠性将受到影響。幸運的是,大多數現代消息代理都是高可用的。
  • 額外的操作複雜性:消息系統是一個必須獨立安裝、配置和運維的系統元件。

現在我們來深入看看基于消息的架構可能會遇到的一些設計難題。

3.3.5 處理并發和消息順序

挑戰之一是如何在保留消息順序的同時,橫向擴充多個接收方的執行個體。為了同時處理消息,擁有多個執行個體是一個常見的要求。而且,即使單個服務執行個體也可能使用線程來同時處理多個消息。使用多個線程和服務執行個體來并發處理消息可以提高應用程式的吞吐量。但同時處理消息的挑戰是確定每個消息隻被處理一次,并且是按照它們發送的順序來處理的。

例如,假設有3個相同的接收方執行個體從同一個點對點通道讀取消息,發送方按順序釋出了Order Created、Order Updated和Order Cancelled這3個事件消息。簡單的消息實作可能就會同時将每個消息給不同的接收方。若由于網絡問題或JVM垃圾收集等原因導緻延遲,消息可能沒有按照它們發出時的順序被處理,這将導緻奇怪的行為。理論上,服務執行個體可能會在另一個服務處理Order Created消息之前處理Order Cancelled消息。

現代消息代理(如Apache Kafka和AWS Kinesis)使用的常見解決方案是使用分片(分區)通道。圖3-11展示了這是如何工作的。該解決方案分為三個部分。

1.分片通道由兩個或多個分片組成,每個分片的行為類似于一個通道。

2.發送方在消息頭部指定分片鍵,通常是任意字元串或位元組序列。消息代理使用分片鍵将消息配置設定給特定的分片。例如,它可以通過計算分片鍵的散列來選擇分片。

3.消息代理将接收方的多個執行個體組合在一起,并将它們視為相同的邏輯接收方。例如,Apache Kafka使用術語消費者組。消息代理将每個分片配置設定給單個接收器。它在接收方啟動和關閉時重新配置設定分片。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

在此示例中,每個Order事件消息都将orderId作為其分片鍵。特定訂單的每個事件都釋出到同一個分片,而且該分片中的消息始終由同一個接收方執行個體讀取。是以,這樣做就能夠保證按順序處理這些消息。

3.3.6 處理重複消息

使用消息機制時必須解決的另一個挑戰是處理重複消息。理想情況下,消息代理應該隻傳遞一次消息,但保證有且僅有一次的消息傳遞通常成本很高。相反,大多數消息代理承諾至少成功傳遞一次消息。

當系統正常工作時,保證傳遞的消息代理隻會傳遞一次消息。但是用戶端、網絡或消息代理的故障可能導緻消息被多次傳遞。假設用戶端在處理消息後、發送确認消息之前,它的資料庫崩潰了,這時消息代理将再次發送未确認的消息,在資料庫重新啟動時向該用戶端或用戶端的另一個副本發送。

理想情況下,你應該使用消息代理,在重新傳遞消息時保留排序。想象一下,用戶端處理Order Created事件,然後緊接着收到了同一Order的Order Cancelled事件,但這時候Order Created事件還沒有得到确認。消息代理應重新投遞Order Created和Order Cancelled事件。如果它僅重新發送Order Created,客戶可以撤回Order的取消。

處理重複消息有以下兩種不同的方法。

  • 編寫幂等消息處理程式。
  • 跟蹤消息并丢棄重複項。

編寫幂等消息處理器

如果應用程式處理消息的邏輯是滿足幂等的,那麼重複的消息就是無害的。所謂應用程式的幂等性,是指即使這個應用被相同輸入參數多次重複調用時,也不會産生額外的效果。例如,取消一個已經被取消的訂單,就是一個幂等性操作。同樣,建立一個已經存在的訂單操作也必是這樣。滿足幂等的消息處理程式可以被放心地執行多次(而不會引起錯誤的結果)隻要消息代理在重新傳遞消息時保持相同的消息順序。

不幸的是,應用程式邏輯通常不是幂等的。或者你可能正在使用消息代理,該消息代理在重新傳遞消息時不會保留排序。重複或無序消息可能會導緻錯誤。在這種情況下,你必須編寫跟蹤消息并丢棄重複消息的消息處理程式。

跟蹤消息并丢棄重複消息

例如,考慮一個授權消費者信用卡的消息處理程式。它必須為每個訂單僅執行一次信用卡授權操作。這段應用程式邏輯在每次調用時都會産生不同的效果。如果重複消息導緻消息處理程式多次執行該邏輯,則應用程式的行為将不正确。執行此類應用程式邏輯的消息處理程式必須通過檢測和丢棄重複消息而成為幂等的。

一個簡單的解決方案是消息接收方使用message id跟蹤它已處理的消息并丢棄任何重複項。例如,它可以存儲它在資料庫表中使用的每條消息的message id。圖3-12顯示了如何使用專用表執行此操作。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

當接收方處理消息時,它将消息的message id作為建立和更新業務實體的事務的一部分記錄在資料庫表中。在此示例中,接收方将包含message id的行插入PROCESSED_MESSAGES表。如果消息是重複的,則INSERT将失敗,接收方可以選擇丢棄該消息。

另一個選項是消息處理程式在應用程式表,而不是專用表中記錄message id。當使用具有受限事務模型的NoSQL資料庫時,此方法特别有用,因為NoSQL資料庫通常不支援将針對兩個表的更新作為資料庫的事務。第7章将介紹這種方法的一個例子。

3.3.7 事務性消息

服務通常需要在更新資料庫的事務中釋出消息。例如,在本書中,你将看到在建立或更新業務實體時釋出領域事件的例子。資料庫更新和消息發送都必須在事務中進行。否則,服務可能會更新資料庫,然後在發送消息之前崩潰。如果服務不以原子方式執行這兩個操作,則類似的故障可能使系統處于不一緻狀态。

傳統的解決辦法是在資料庫和消息代理之間使用分布式事務。然而,在第4章中你會了解到,分布式事務對現今的應用程式而言并不是一個很好的選擇。而且,很多新的消息代理,例如Apache Kafka并不支援分布式事務。

是以,應用必須采用不同的機制確定消息的可靠發送,我們在本章會介紹一些方案。

使用資料庫表作為消息隊列

我們假設你的應用程式正在使用關系型資料庫。可靠地釋出消息的直接方法是應用事務性發件箱模式。此模式使用資料庫表作為臨時消息隊列。如圖3-13所示,發送消息的服務有一個OUTBOX資料庫表。作為建立、更新和删除業務對象的資料庫事務的一部分,服務通過将消息插入到OUTBOX表中來發送消息。這樣可以保證原子性,因為這是本地的ACID事務。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

OUTBOX表充當臨時消息隊列。MessageRelay是一個讀取OUTBOX表并将消息釋出到消息代理的元件。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

你可以對某些NoSQL資料庫使用類似的方法。作為record存儲在資料庫中的每個業務實體都有一個屬性,該屬性是需要釋出的消息清單。當服務更新資料庫中的實體時,它會向該清單附加一條消息。這是原子的,因為它是通過單個資料庫操作完成的。但是,挑戰在于有效地找到那些擁有事件并釋出事件的業務實體。

将消息從資料庫移動到消息代理并對外發送有兩種不同的方法。我們來逐一分析。

通過輪詢模式釋出事件

如果應用程式使用關系型資料庫,則對外釋出插入OUTBOX表的消息的一種非常簡單的方法是讓MessageRelay在表中輪詢未釋出的消息。它定期查詢表:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

接下來,MessageRelay把這些消息發送給消息代理,它把每個消息發送給它們的目的消息通道。最後,MessageRelay把完成發送的消息從OUTBOX表中删除。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章
帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

輪詢資料庫是一種在小規模下運作良好的簡單方法。其弊端是經常輪詢資料庫可能造成昂貴的開銷(導緻資料庫性能下降)。此外,你是否可以将此方法與NoSQL資料庫一起使用取決于NoSQL資料庫支援的查詢功能。這是因為應用程式必須查詢業務實體,而不是查詢OUTBOX表,這可能會無法有效地執行。由于這些弊端和限制,通常在某些情況下,更好的辦法是使用更複雜和高性能的方法,來拖尾(tailing)資料庫事務日志。

使用事務日志拖尾模式釋出事件

更加複雜的實作方式,是讓MessageRelay拖尾資料庫的事務日志檔案(也稱為送出日志)。每次應用程式送出到資料庫的更新都對應着資料庫事務日志中的一個條目。事務日志挖掘器可以讀取事務日志,把每條跟消息有關的記錄發送給消息代理。圖3-14展示了這個方案的具體實作方式。

Transaction-Log-Miner讀取事務日志條目。它将對應于插入消息的每個相關日志條目轉換為消息,并将該消息釋出到消息代理。此方法可用于釋出寫入關系型資料庫中的OUTBOX表的消息或附加到NoSQL資料庫中的記錄的消息。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章
帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

這個方案有一些實際的應用案例和實作可供參考:

雖然這種方法看似晦澀,但效果非常好。挑戰在于實作它需要做一些開發努力。例如,你可以需要編寫調用特定資料庫API的底層代碼。或者,你可以使用開源架構(如Debezium)将應用程式對MySQL、Postgres或MongoDB所做的更改釋出到Apache Kafka。使用Debezium的缺點是它的重點是捕獲資料庫級别的更改,但是用于發送和接收消息的API超出了其範圍。這就是我建立Eventuate Tram架構的原因,該架構可提供消息傳遞API以及事務拖尾和輪詢。

3.3.8 消息相關的類庫和架構

服務需要使用庫來發送和接收消息。一種方法是使用消息代理的用戶端庫,但是直接使用這樣的庫有幾個問題:

  • 用戶端庫将釋出消息的業務邏輯耦合到消息代理API。
  • 消息代理的用戶端庫通常是非常底層的,需要多行代碼才能發送或接收消息。作為開發人員,你不希望重複編寫類似的代碼。另外,作為本書的作者,我不希望我的示範代碼與重複性的底層消息實作代碼混雜在一起。
  • 用戶端庫通常隻提供發送和接收消息的基本機制,不支援更進階别的互動方式。

更好的方法是使用更進階别的庫或架構來隐藏底層的細節,并直接支援更進階别的互動方式。為簡單起見,本書中的示例使用了我的Eventuate Tram架構。它有一個簡單易用的API,可以隐藏使用消息代理的複雜性。除了用于發送和接收消息的API之外,Eventuate Tram還支援更進階别的互動方式,例如異步請求/響應和領域事件釋出。

什麼?!為什麼要使用Eventuate架構?

本書中的代碼示例使用我開發的開源Eventuate架構,這個架構是為事務性消息、事件溯源和Saga量身定做的。之是以選擇使用我的架構,是因為它與依賴注入和Spring架構不同,對于微服務架構所需的許多功能,目前開發者社群還沒有廣泛采用的架構。如果沒有Eventuate Tram架構,許多示範代碼必須直接使用底層消息傳遞API,這會使它們變得更加複雜并且會模糊重要的概念。或者使用一個沒有被廣泛采用的架構,這也會引起批評。

相反,這些示範代碼使用Eventuate Tram架構,該架構具有隐藏實作細節的簡單易懂的API。你可以在應用程式中使用這些架構。或者,你可以研究Eventuate Tram架構并自己重新實作這些概念。

Eventuate Tram還實作了兩個重要機制:

  • 事務性消息機制:它将消息作為資料庫事務的一部分釋出。
  • 重複消息檢測機制:Eventuate Tram支援消息的接收方檢測并丢棄重複消息,這對于確定接收方隻準确處理消息一次至關重要,如3.3.6節所述。

我們來看一看Eventuate Tram的API。

基礎消息API

基礎消息API由兩個Java接口組成:MessageProducer和MessageConsumer。發送方服務使用MessageProducer接口将消息釋出到消息通道。以下是使用此接口的例子:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

接收方服務使用MessageConsumer接口訂閱消息:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

MessageProducer和MessageConsumer是用于異步請求/響應和領域事件釋出的更進階API的基礎。

現在我們來談談如何釋出和訂閱事件。

領域事件釋出API

Eventuate Tram具有用于釋出和使用領域事件的API。第5章将解釋領域事件是聚合(業務對象)在建立、更新或删除時觸發的事件。服務使用DomainEventPublisher接口釋出領域事件。如下是一個具體的例子:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

服務使用DomainEventDispatcher消費領域事件。如下是一個具體的例子:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

事件式消息不是Eventuate Tram支援的唯一進階消息傳遞模式。它還支援基于指令/回複的消息機制。

基于指令/回複的消息

用戶端可以使用CommandProducer接口向服務發送指令消息。例如:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

服務使用CommandDispatcher類接收指令消息。CommandDispatcher使用Message-Consumer接口來訂閱指定的事件。它将每個指令消息分派給适當的處理程式。如下是一個具體的例子:

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

在本書中,你将看到使用這些API發送和接收消息的代碼示例。

如你所見,Eventuate Tram架構為Java應用程式實作事務性消息。它提供了一個相對底層的API,用于以事務方式發送和接收消息。它還提供了更進階别的API,用于釋出和使用領域事件以及發送和處理指令式消息。

現在讓我們看一下使用異步消息來提高可用性的服務設計方法。

3.4 使用異步消息提高可用性

正如你所見,我們需要在不同的程序間通信機制之間權衡利弊。其中的一個重要權衡因素,就是程序間通信機制與系統的可用性之間的關系。在本節中,你會看到,與其他服務采用同步通信機制作為請求處理的一部分,會對系統的可用性帶來影響。是以,應該盡可能選擇異步通信機制來處理服務之間的調用。

我們先看看同步消息帶來的具體問題,以及這些問題是如何影響可用性的。

3.4.1 同步消息會降低可用性

REST是一種非常流行的程序間通信機制。你可能很想将它用于服務間通信。但是,REST的問題在于它是一個同步協定:HTTP用戶端必須等待服務端傳回響應。隻要服務使用同步協定進行通信,就可能降低應用程式的可用性。

要了解原因,請考慮圖3-15中顯示的情況。Order Service有一個用于建立Order的REST API。它調用Consumer Service和Restaurant Service來驗證Order。這兩個服務都有REST API。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

建立訂單的流程如下:

1.用戶端發起HTTP POST/orders請求到Order Service。

  1. Order Service通過向Consumer Service發起一個HTTP GET/consumers/id請求擷取客戶資訊。
  2. Order Service通過向Restaurant Service發起一個HTTP GET/restaurant/id請求擷取餐館資訊。
  3. Order Service使用客戶和餐館資訊來驗證請求。
  4. Order Service建立一個訂單。
  5. Order Service向用戶端發出HTTP響應作為用戶端調用的傳回。

因為這些服務都使用HTTP,是以它們必須同時線上才能夠完成FTGO應用程式中的CreateOrder這個請求。如果上述任意一個服務出了問題,FTGO應用程式将無法建立新訂單。從統計意義上講,一個系統操作的可用性,由其所涉及的所有服務共同決定。如果Order Service服務和它所調用的兩個服務的可用性都是99.53,那麼這個系統操作的整體可用性就是99.5%3,大約是98.5%,這其實是個非常低的數值了。每一個額外增加的服務參與到其中,都會更進一步降低整體系統操作的可用性。

這個問題不僅僅跟基于REST的通信有關。當服務必須從另外一個服務擷取資訊後,才能夠傳回它用戶端的調用,這種情況都會導緻可用性問題。即使服務使用異步消息的請求/響應方式的互動進行通信,也存在此問題。例如,如果通過消息代理向Consumer Service發送消息然後等待響應,則Order Service的可用性将會降低。

如果你想最大化一個系統的可用性,就應該設法最小化系統的同步操作量。我們來看看如何實作。

3.4.2 消除同步互動

在必須處理同步請求的情況下,仍舊有一些方式可以最大限度地降低同步通信的數量。當然,最徹底的方式還是把所有的服務都改成異步API,但是在現實情況下這并不太可能,例如一些公用API總是采用RESTful方式,另外有些情況下服務也必須被設計為采用同步API。

幸運的是,總有一些辦法在不發出同步調用請求的情況下來處理同步的調用請求。我們看看有哪些方法。

使用異步互動模式

理想的情況是,所有的互動都應該使用本章之前所描述的異步互動。例如,讓我們假設FTGO采用請求/異步響應的互動方式來建立訂單。用戶端可以通過向Order Service發送一個請求消息交換消息的方式建立訂單。這個服務随即采用異步交換消息的方式跟其他服務通信完成訂單的建立,并向用戶端發回一個傳回消息。圖3-16展示了具體的設計。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

用戶端和服務端使用消息通道發送消息來實作異步通信。這個互動過程中不存在堵塞等待響應的情況。

這樣的架構非常有彈性,因為消息代理會一直緩存消息,直到有服務端接收并處理消息。然而,問題是服務很多情況下都采用類似REST這樣的同步通信協定的外部API,并且要求對請求立即做出響應。

在這種情況下,我們可以采用複制資料的方式來提高可用性。我們看看如何實作。

複制資料

在請求處理環節中減少同步請求的另外一種辦法,就是進行資料複制。服務維護一個資料副本,這些資料是服務在處理請求時需要使用的。這些資料的源頭會在資料變化時發出消息,服務訂閱這些消息來確定資料副本的實時更新。例如,Order Service可以維護來自Consumer Service和Restaurant Service的資料副本。在這種情況下,Order Service可以在不與其他服務進行互動的情況下完成訂單建立的請求。圖3-17展示了具體的設計。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

Consumer Service和Restaurant Service在它們的資料發生變化時對外釋出事件。Order Service服務訂閱這些事件,并據此更新自己的資料副本。

在有些情況下,複制資料是一種有用的方式,第5章中還會有更具體的讨論,描述Order Service如何從Restaurant Service複制資料以驗證菜單并定價。然而,複制資料的一個弊端在于,有時候被複制的資料量巨大,會導緻效率低下。例如,讓Order Service服務去維護一個Consumer Service的資料副本并不可行,因為資料量實在太大了。複制的另外一個弊端在于,複制資料并沒有從根本上解決服務如何更新其他服務所擁有的資料這個問題。

解決該問題的一種方法是讓服務暫緩與其他服務互動,直到它給用戶端發送了響應。接下來我們将看看它是如何工作的。

先傳回響應,再完成處理

另外一種在請求處理環節消除同步通信的辦法如下:

1.僅使用本地的資料來完成請求的驗證。

2.更新資料庫,包括向OUTBOX表插入消息。

3.向用戶端傳回響應。

當處理請求時,服務并不需要與其他服務直接進行同步互動。取而代之的是,服務異步向其他的服務發送消息。這種方式確定了服務之間的松耦合。正如我們将在下一章看到的,這是通過Saga實作的。

例如,Order Service可以用這種方式建立一個未經驗證(Pending)狀态的訂單,然後通過異步互動的方式直接跟其他服務通信來完成驗證。圖3-18展示了createOrder()被調用時發生的具體過程。

帶你讀《微服務架構設計模式》之三:微服務架構中的程式間通信第3章

事件的順序是:

  1. Order Service在PENDING狀态下建立訂單。
  2. Order Service傳回包含訂單ID的響應給客戶。
  3. Order Service向Consumer Service發送ValidateConsumerInfo消息。
  4. Order Service向Restaurant Service發送ValidateOrderDetails消息。
  5. Consumer Service接收ValidateConsumerInfo消息,驗證消費者是否可以下訂單,并向Order Service發送ConsumerValidated消息。
  6. Restaurant Service收到ValidateOrderDetails消息,驗證菜單項是否有效以及餐館是否可以傳遞訂單的傳遞位址,并向Order Service發送OrderDetailsValidated消息。
  7. Order Service接收ConsumerValidated和OrderDetailsValidated,并将訂單狀态更改為VALIDATED。
  8. ……

Order Service可以按任意順序接收ConsumerValidated和OrderDetails-Validated消息。它通過更改訂單狀态來跟蹤它首先收到的消息。如果它首先收到ConsumerValidated,它會将訂單狀态更改為CONSUMER_VALIDATED;如果它首先收到OrderDetailsValidated消息,則會将其狀态更改為ORDER_DETAILS_VALIDATED。Order Service在收到其他消息時将訂單狀态更改為VALIDATED。

訂單驗證後,Order Service完成訂單建立過程的其餘部分,這些細節将在下一章中讨論。這種方法的優點在于,即使Consumer Service中斷,Order Service仍然會建立訂單并響應其客戶。最終,Consumer Service将重新啟動并處理任何排隊的消息,并且驗證訂單。

在完全處理請求之前響應服務的弊端是它使用戶端更複雜。例如,Order Service在傳回響應時對新建立的訂單的狀态提供最低限度的保證。它會在驗證訂單并授權消費者的信用卡之前立即建立訂單并傳回。是以,為了使用戶端知道訂單是否已成功建立,要麼必須定期輪詢,要麼Order Service必須向用戶端發送通知消息。聽起來很複雜,但是在許多情況下這是首選方法:特别是因為它還解決了我将在下一章中讨論的分布式事務管理問題。在第4章和第5章中,我将介紹Order Service如何使用這種方法。

本章小結

  • 微服務架構是一種分布式架構,是以程序間通信起着關鍵作用。
  • 仔細管理服務API的演化至關重要。向後相容的更改是最容易進行的,因為它們不會影響用戶端。如果對服務的API進行重大更改,通常需要同時支援舊版本和新版本,直到用戶端更新為止。
  • 有許多程序間通信技術,每種技術都有不同的利弊。一個關鍵的設計決策是選擇同步遠端過程調用模式或異步消息模式。基于同步遠端過程調用的協定(如REST)是最容易使用的。但是,理想情況下,服務應使用異步消息進行通信,以提高可用性。
  • 為了防止故障通過系統層層蔓延,使用同步協定服務的用戶端必須設計成能夠處理局部故障,這些故障是在被調用的服務停機或表現出高延遲時發生的。特别是,它必須在送出請求時使用逾時,限制未完成請求的數量,并使用斷路器模式來避免調用失敗的服務。
  • 使用同步協定的架構必須包含服務發現機制,以便用戶端确定服務執行個體的網絡位置。最簡單的方法是使用部署平台實作的服務發現機制:伺服器端發現和第三方注冊模式。但另一種方法是在應用程式級别實作服務發現:用戶端發現和自注冊模式。它需要的工作量更大,但它确實可以處理服務在多個部署平台上運作的場景。
  • 設計基于消息的架構的一種好方法是使用消息和通道模型,它抽象底層消息系統的細節。然後,你可以将該設計映射到特定的消息基礎結構,該基礎結構通常基于消息代理。
  • 使用消息機制的一個關鍵挑戰是以原子化的方式同時完成資料庫更新和釋出消息。一個好的解決方案是使用事務性發件箱模式,并首先将消息作為資料庫事務的一部分寫入資料庫。然後,一個單獨的程序使用輪詢釋出者模式或事務日志拖尾模式從資料庫中檢索消息,并将其釋出給消息代理。