當前位置:
首頁 > 最新 > 拋開命令驅動,採用事件驅動方式構建服務

拋開命令驅動,採用事件驅動方式構建服務

作者|Ben Stopford

譯者|足下

許多公司的微服務都是基於請求 - 響應的模式構建的,REST 就是這種模式的典型。這很自然,我們自己寫程序也總是這麼做的:對別的代碼模塊進行調用,接收到響應後再繼續下面的處理流程。這也和每天都發生的實際場景非常相像:用戶在瀏覽網頁時,點擊一個按鈕,然後等待變化發生。

但當我們的真實環境由許多獨立的服務構成時,事情就不一樣了。隨著服務的數量漸漸增加,同步交互的網路也在迅速擴大。之前不明顯的可用性問題就會慢慢地引發範圍越來越廣的不可用故障。

不幸的運維工程師們就要拚命地救火了,他們東奔西跑,一個一個服務地查看,將許許多多的二手信息拼湊起來(什麼時候哪個服務對哪個服務發過什麼消息),希望可以定位出問題所在。

這是眾所周知的問題,相應的對策也很多。對策之一就是保證你的服務的 SLA 要比整個系統的高出很多。為達到這一點谷歌還提供了一個方案。另一種對策就很簡單,把服務之間的同步調用斷開就好了。

要這麼實現採用非同步模式就好了。如果你的項目是和在線零售相關的,你會覺得存在像 getImage() 或 processOrder() 這樣的同步介面非常正常,它們都期望會立刻有回復。

但當用戶按下「購買」按鈕時,他實際上是觸發了一個非常複雜的非同步流程,一次真實的購買行為,而且還在現實中要將商品送到客戶的家門口,這些遠遠超出了最初按下按鈕那個行為的範圍。因此將軟體拆散成非同步流程就讓我們可以將要解決的不同問題拆分開,讓我們可以面對一個本來就是非同步的世界。

在實踐中我們也會很自然地接受這一點。我們會定時查詢資料庫去獲取變更過的數據,或者實現某類定時任務來批量獲取數據變更,這些都是變同步為非同步的方法。

把這些問題總結起來我們就可以歸納出,在服務之間相互獨立運行的環境里,向某個服務發出指令讓它完成任務的命令式編程模型並不是非常合適的。

在本文中我們將試試另一種架構,即不是用一連串的命令將多個服務組合起來,而是通過事件流。

這種方法本身是正確的,而且這也是在後續文章(本文是作者系列文章中的一篇)中要討論的許多高級模式的基礎,比如將流處理和事件驅動處理的思想結合起來。

命令、事件和查詢

在研究具體例子之前我們先要澄清三個基本的概念,即服務之間相互交互的三種機制:命令(Command)、事件(Event)和查詢(Query)。如果你以前沒有仔細考慮過,現在弄清楚也為時不晚。

事件的強大在於它們既是事實又是觸發器。外部的數據可以被系統裡面的任何服務重用。但從服務的角度看,事件對系統造成的耦合度要比命令和查詢低,這一點非常重要。

服務之間相互交互的三種機制是:

命令:是一個動作,是一個要求其它服務完成某些操作的請求,它會改變系統的狀態。命令會要求響應。

事件:既是事實又是觸發器,用通知的方式向外部表明發生了某些事。

查詢:是一個請求,查看是否發生了什麼事。重要的是,查詢操作沒有副作用,它們不會改變系統的狀態。

一個簡單的事件驅動流程

下面從一個簡單的例子開始:一位顧客下單購買了一件商品。隨後發生了兩件事:

處理相關的支付;

系統查看這種商品要不要補充庫存;

用請求驅動的方法解決,這將被表述為一個命令鏈。這裡沒有查詢,系統的交互看起來是這樣的:

值得注意的就是「補充庫存」這個業務流程是由訂單服務觸發(或者調用)的。這就把兩個服務的責任混淆起來了,理想的情況下我們還是應該進行更好的關注點分離設計(separation of concerns)。

現在再換個角度看看,相同的流程為什麼用事件驅動的方法處理會更合適一些。

UI 服務發起 OrderRequested 請求,通知系統有顧客下單了,然後等待該訂單被確認(OrderConfirmed)或拒絕,再將結果返回給顧客;

訂單服務和庫存服務都對相同的事件做出響應;

仔細看看這個流程,在 UI 服務和訂單服務之間的交互沒什麼大變化,只是由直接調用變成了通過事件通信而已。

庫存服務很值得注意,現在不再由訂單服務告訴它該幹什麼了。它自己決定要不要參與這次交互的過程。這是這類架構非常重要的特性之一,即由接收者驅動的流程式控制制(Receiver Driven Flow Control)。處理邏輯被推送到事件的接收者一端,而不是發送者。責任方調換了。

將控制權交給接收者,這種做法減少了服務之間的耦合,並將架構的可插拔特性提升到了一個新層次,可以非常容易地加入或者剔除某個模塊。

架構變得越複雜,這種可插拔的特性就變得越重要。假設我們現在要向系統中加入一個管理實時報價的新服務,根據某件商品的供求狀態實時地調整價格。

在命令驅動的架構下,我們就該引入一個 maybeUpdatePrice() 的方法調用,庫存服務和訂單服務都會調用它。但在事件驅動的架構下,這個服務只不過是訂閱了共享數據流的一個新服務而已,它負責在達到一定的觸發條件之後,發出更新價格的指令。

將事件和查詢放在一起

上面的例子只考慮了命令和事件,沒有提到查詢。我們在文章開篇就定義了所有的交互操作,即命令、事件和查詢。

如果系統架構不是非常簡單,那查詢操作就一定是必不可少的。所以在這裡我們讓這個例子再複雜一些,在訂單服務處理支付之前,先檢查一下庫存是否充足。

如果用請求驅動的方法來解決這個問題,就會向庫存服務發送一條請求,獲取當前的庫存數量。這就把架構搞得不倫不類了,本來事件流就只是用做通知的,讓各個服務都從中獲取自己感興趣的信息,可現在查詢卻直接去找數據源了。

在比較大型的系統中,服務都是相互之間獨立演進的,但遠程調用會大大增加服務間的耦合,將服務在運行時捆綁在一起。事實上我們可以採用內部化的辦法來避免這類跨服務的查詢。用事件流在各個服務中緩存數據集,那麼在本地就可以完成查詢。

這樣在實現增加庫存檢查的功能時,訂單服務可以訂閱庫存事件流,並保存在本地資料庫中,它將查詢這個「視圖」來驗證庫存是否充足。

純粹的事件驅動系統是沒有遠程查詢的概念的,事件會將狀態帶給各個服務,由它們自己進行本地保存。

這種「通過事件傳輸狀態,再基於本地狀態完成查詢操作」的方法主要有三個優點:

更好的解耦:查詢都是在本地完成的,沒有跨上下文的調用。與命令驅動的實現方式相比,這樣做不會將服務捆綁在一起。

更高的自治度:訂單服務本地有一份自己管理的庫存數據的副本,所以它可以在上面完成任何操作,而不會受限於庫存服務提供的查詢功能。

高效的連接操作:如果我們要對一個訂單中的每件貨物都查詢庫存,這實際上是在兩個服務之間做一次跨網路的連接操作。在負載增大或者有更多的數據源要參與進來時,這樣做的複雜度是不可想像的。「通過事件傳輸狀態,再基於本地狀態完成查詢操作」的方法解決了這個問題,它讓查詢(及連接)操作可以在本地完成。

當然這個方法也有它自己的固有缺點。服務會變得自己有狀態。它們需要自己不斷地跟進和維護這份傳輸過來的數據副本。狀態的複製也讓某些問題變得難以定位(怎樣實現原子地減少庫存的數量?),還要小心數據不一致問題。不過這些問題都相應地有可行的解決方案了,只要多考慮一些就好。與維護更大更複雜的系統所要花費的精力相比,這樣做還是相當值得的。

寫入者唯一原則

實現這類系統時,一個非常有用的原則就是將產生某種數據的責任交給某個單一的服務,即寫入者唯一。這樣庫存服務就只管理「庫存清單」,而訂單服務就只維護訂單,等等。

這樣單一代碼路徑(但並不一定是單個進程)的方法就解決了一致性、驗證和其它許多寫入問題。所以在下面的例子中,訂單服務負責所有與修改訂單狀態相關的操作,但整個事件卻要涉及訂單、支付和物流等多個服務,每個都由相對應的服務進行管理。

必須將事件的傳播和責任關聯起來,因為事件都不是臨時的,不是偷偷摸摸的。事件代表了眾所周知的事實,是外部的數據。因此,每個服務都要自己承擔起持續地管理共享數據的責任:修復錯誤、處理模式變化等。

上圖中每種顏色都代表了 Kafka 中的一個 Topic,分別屬於訂單、物流和支付。購物車服務開啟整個流程。當一位用戶按下「購買」按鈕時,它發起「請求訂單」事件,一直等到「確認訂單」事件之後,才把結果返回給用戶。

另外三個服務各自管理整個流程中屬於自己的狀態變化。比如在支付完成後,訂單服務就會將訂單從「驗證通過」狀態變為「已確認」。

將模式和集群服務混合起來

上文描述的某些模式與 Enterprise Messaging 看起來很像,但事實上還是有微妙區別的。Enterprise Messaging 主要管理狀態的傳遞,它有效地將多個資料庫跨網路地結合起來了。

事件合作講的是服務如何通過分發事件、觸發服務完成某些動作,最終達到某些業務目標的。因此這是一個關於業務處理的模式,而不是簡單的狀態移動的機制。

但通常我們總會希望能在自己構建的系統中把這個模式的兩面都利用上。事實上,這個模式的優點之一正是在於它適用的場合,既可以處理宏觀的事務,又可以處理微觀的事務,甚至一起處理。

模式混用的場景是非常常見的。也許我們又希望有遠程查詢的便利性,又不想辛苦地去維護本地數據集,尤其是在數據量會增長的情況下。這樣做就可以更容易地部署簡單的功能(在希望構建一些輕量級的、無服務的和事件流時,這一點尤其重要)。原因也可能是我們正處於無狀態的容器或瀏覽器之中。

方法就是約束這些查詢介面的範圍,最好是通過受限上下文。受限上下文在這裡指若干服務的集合,它們可以共享相同的部署或域模型。最好的方案是讓系統的架構中可以有許多專門的、有針對性的視圖,而不是簡單的共享資料庫。

要限制遠程查詢的範圍,也可以使用集群上下文模式。在這裡事件流是上下文之間唯一的通信模式。但在一個上下文內部的服務在需要時就會同時使用事件驅動處理和請求驅動視圖。

下面例子中的系統被分成了三個子系統,不同子系統之間只通過事件通信。在每個子系統內部,我們都會使用細粒度的事件驅動流。有些會包括視圖層(查詢層)。

這就是在解耦和方便之間做出的折衷,讓我們可以將細粒度的服務與大型實體,即現有系統的程序及現成的產品混合起來,其中會有許多真實的服務狀態。

集群上下文模式

事件驅動服務的五個主要好處是:

解耦:打破阻塞式調用的長鏈,拆分同步工作流。代理節點解耦服務,這樣就可以更容易地加入新服務,或改進現有的。

離線與非同步工作流:當用戶按下一個按鈕後會發生許多事。有些是同步的,有些是非同步的。對後者和前者的設計都垂手可得。

狀態遷移:事件成了系統內的數據集。流提供了非常有效的方法來實現數據分發,這樣就可以在一個受限的上下文內部重組和查詢。

連接:不同的服務可以更容易地組合、連接和擴大數據集。連接操作都是在本地完成的,速度很快。

可追蹤性:在有了一個集中的、不可變的清單來記下每一次變更之後,調試分散式系統的「謀殺之迷」問題就很容易了。

總結

在事件驅動的設計中,我們使用的是事件而不是命令。事件觸發處理。它們也可以轉變成供我們本地查詢的視圖。在小型系統中,必要時可以回退去做遠程同步調用,但在大型系統中我們對這樣的操作進行限制,比較理想的是限制到單個受限上下文中。

但這些方法只不過是模式而已,只是把系統組合起來的一些指導原則而已。使用時不能過於教條主義。比如,當某些內容幾乎不會改變時,實現一個全局性的查詢服務也不失為一個好主意。

方法就是從某個事件的基線開始。事件不會讓服務之間耦合起來,而把對流程的控制權交給事件接收者,這樣顧慮更少,可插拔性更好。

事件驅動設計的另一個優點是它們不僅適用於小型的、交互性很高的系統,對於大型的、複雜的架構也很適合。事件的模式給了服務自主性,讓它們可以自由地演進,不再受限於命令和查詢模式的複雜聯繫。對於運維工程師來說,他們的工作沒有太大改進,但他們為分散式問題犯難的次數應該會大大減少,因為現在至少事件有跡可循。

這篇文章中不僅討論了事件,也涉及了一點分散式日誌和流處理。用 Kafka 實現這種架構時,設計原則稍有不同。Broker 的存在讓我們有用武之地,讓我們可以獲得外部的數據,必要時還可以追溯過去的數據。

流平台非常適合這種處理事件和構建視圖的模型。視圖自然地嵌入在服務之內,完成遠程服務查詢,或者在持續的流中查詢固化下來的數據。

這樣就可以進行全局性的優化:利用事件流和事件存儲的持久性特性,用流處理工具來對事件進行處理,將多個服務和我們可以查詢的固化視圖關聯起來。這些模式很強大,我們可以用專門為事件流設計的處理工具來重新審視整個業務處理過程。但所有的優化都基於我們在這裡討論的內容,通過一套主流的工具集就可以實現。

感謝 Antony Stubbs、Tim Berglund、Kaufman Ng、Gwen Shapira 和 Jay Kreps,謝謝他們幫忙審閱我的文章。

本文翻譯自文章 Build Services on a Backbone of Events,翻譯與發布已獲得原作者 Ben Stopford 授權。原文地址:https://www.confluent.io/blog/build-services-backbone-events/

今日薦文

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 聊聊架構 的精彩文章:

TAG:聊聊架構 |