在軟件架構中,有一種模式雖鮮為人知的,但值得引起更多的關注。面向數據的架構(Data-Oriented Architecture)由 Rajive Joshi在RTI 的2007 年白皮書中首次提出,而維也納大學(University of Vienna)的Christian Vorhemus 和Erich Schikuta 在2017 年的 iiWAS 論文中又再次對其進行了描述。 DOA 是對傳統二分法的顛覆,它介于單體架構和微服務(Microservices)、面向服務的架構(Service-Oriented Architecture)之間。單體架構由一個單體二進制文件(binary)和數據存儲組成;微服務、面向服務的架構由許多小型的、分布式的、獨立的二進制文件組成,并且每個二進制文件都有自己的數據存儲。在面向數據的架構中,單體數據存儲是系統中狀態的唯一來源,并由松耦合無狀態的微服務對其進行操作。
我很幸運,我的前雇主也采用了這種非同尋常的架構選擇。它提醒我們,事情可以用不同的方式來做。無論如何,面向數據的架構都不是銀彈。它有自己獨特的成本和收益。不過,我確實發現,許多大型公司和生態系統都陷入了某種種類型的瓶頸,而這種類型的瓶頸正是面向數據的架構能解決的。
單體架構簡介
由于許多架構通常都是在與單體架構(Monolithic Architecture)進行對比的情況下定義的,因此,花一些時間來介紹單體架構是值得的。畢竟,它是服務端軟件開發傳說中的自然狀態。
在單體(monolithic)服務中,大部分服務端代碼都在一個程序中,該程序與一個或多個數據庫通信,并處理功能計算的各個方面。假設有一個交易系統,它接收客戶購買或出售某種證券的請求,為它們定價,并完成訂單。
在單體服務中,仍然可以將代碼組件化并分離到各個模塊中,但是程序中不同組件之間的 API 邊界不是強制的。程序中唯一經過嚴格定義的 API 通常是:**(a)UI 和服務端之間的 API(可以使用任何它們約定好的 REST/HTTP 協議);(b)服務端和數據存儲之間的 API(可以使用任何它們約定好的查詢語言);或(c)** 服務端與其外部依賴之間的 API。
面向服務的架構和微服務
另一方面,面向服務的架構(Service-oriented architectures,SOA)將單體程序分解成各個相互獨立的、組件化的功能服務。在我們的交易應用程序中,我們可能需要一個單獨的服務來作為外部 API 接收請求并處理客戶響應;第二個單獨的系統來接收報價和其他市場相關的信息;第三個系統來跟蹤訂單和風險等。這些服務之間的接口都是一個個形式化定義的 API 層。服務之間通常通過 RPC 進行點對點的通信,此外,通過其他通信技術(如,消息傳遞和發布訂閱模式)進行通信也是很常見的。
面向服務的架構允許根據需要對不同的服務進行獨立(并行)開發和推理。這些服務是松耦合的,這就意味著一個全新的服務現在可以重用其他服務了。
由于 SOA 中的每個服務都定義了自己的 API,因此可以獨立訪問每個服務并與之交互。開發人員如果要調試或模擬各個功能部分,可以分別調用各個組件,并且新流程可以重新組合這些單獨的服務以啟用新的行為。
微服務是面向服務的架構的一種形式。根據服務對象的不同,它們可能與 SOA 不同,因為這些服務本應特別小巧輕量,或者它們只是 SOA 的同義詞。
規模問題
在 SOA 中,各個組件通過每個組件各自定義的特定 API 直接相互通信。為了通信,每個組件都可以單獨尋址(即,使用 IP 地址、服務地址或其他內部標識符來相互發送請求 / 消息)。這意味著架構中的每個組件都需要了解它們的依賴關系,并且需要專門與它們的依賴進行集成。
依賴于架構的拓撲結構,這可能意味著需要一個額外的組件來跟蹤了解所有之前的組件。此外,這可能還意味著要替換一個已經與其他 N 個組件通信的單個服務也是一種挑戰:我們需要注意保留我們定義的任何點對點的 API,并確保有一個遷移計劃,用于將每個組件從老的尋址服務移動到新的尋址服務上。由于服務到服務的 API 是點對點的(ad-hoc)(1),這通常意味著組件之間的 RPC 可以是任意復雜的,這可能會增加將來 API 變更的影響面。因為如果要對服務中被其他服務依賴的每個 API 進行變更都將是一項艱巨的任務。
我要說的是,隨著微服務生態系統的發展,在規模上,它變得很容易受到如下問題的影響:
- 隨著組件數量的增長(2),集成的復雜度也以 N^2 的級別增加。
- 網絡的形狀變得很難用先驗來推理;即,創建或維護測試環境或沙箱將需要進行大量的推理才能確保圖中的任何組件都不具有外部依賴性
我的一些朋友也提出了一些他們在使用大規模面向服務的架構時遇到的問題:
隨著 SOA 規模的增長,我發現的另一個問題是服務之間的循環依賴。由于我們是單獨發布各個服務的,很少從頭開始構建整個系統,因此很容易引入循環并破壞 DAG。
大規模 SOA 另一個值得注意的問題是:它們要求我們提前了解所有未來的客戶工作流。假設我們需要跨多個垂直領域來隔離單個工作流的數據,如果沒有做到提前了解,那么我們要么會遇到性能問題,因為它將試圖保證跨多個持久化存儲的事務性;要么需要重新定義要用哪些垂直主服務器來復制(緩存,但實際上是持久化到數據庫中的)數據。
面向數據的架構
在面向數據的架構( Data-Oriented Architecture,DOA)中,系統仍然圍繞小型的、松耦合的標準來組織組件,就像在 SOA、微服務中一樣。但是 DOA 與微服務的區別主要體現在兩個方面:
- 組件通常是無狀態的
DOA 沒有對每個相關組件的數據存儲進行組件化和聯合,而是要求按照集中管理的全局模式來描述數據或狀態層。
- 最小化了組件之間的交互,并通過數據層的交互來替代
在我們的交易系統中,接收不同證券報價的組件在我們的數據存儲中只是以一種規范的形式來發布價格。系統可以通過查詢數據層的價格來使用這些報價,而無需通過特定的 API 向某個特定的服務(或一組服務)請求價格。
這里,集成的代價是線性的。變更 DOA 模式意味著最多只需要更新 N 個組件,而不是它們之間互聯的最大值 N^2。
真正令人矚目的地方在于不同的提供者可以填充獨立的高級數據類型。如果我們用一張表來替換一個服務,這并不會帶來太大的簡化。但是當同一個通用數據類型有多個源時,這樣做就會有很大的幫忙。假設交易系統需要連接到多個市場,每個市場都會將客戶的請求發布到詢價(RFQ)表中,那么下游系統就可以查詢這個表,而無需關心客戶請求到底來自何處。
組件通信類型
由于在 DOA 中最小化了組件之間的交互,那么如何通過數據層的交互來代替當今 SOA 中組件之間的通信呢?
1. 數據生產和消費
設計 DOA 系統的主要方法是將組件組織成數據的生產者和消費者。
如果我們能夠在較高層次上將業務邏輯編寫為一系列的 map、filter、reduce、flatMap 和其他一元(monadic)操作,那么我們就可以將 DOA 系統編寫成一系列的組件,每個組件都查詢或訂閱其輸入并產生其輸出。 DOA 面臨的挑戰在于這些中間步驟是可見的、可查詢的數據,這意味著需要對其進行良好的封裝和表示,并且需要將其與特定的業務邏輯概念對應。不過,它的優勢在于系統的行為是可從外部觀察、跟蹤和審核的。
在 SOA 交易系統中,從市場上接收訂單的組件可能會使用 RPC 調用來確定如何對訂單進行定價、報價或交易。在 DOA 中,微服務接收來自市場的請求(通常是通過 SOA 的方式)并生成詢價(RFQ),而其他生產者則生產定價數據,等等。另一個微服務通過請求來查詢 RFQ,該 RFQ 會結合它們的所有定價以輸出報價、訂單或任何其他需要響應的自定義數據。
2. 觸發動作和行為
有時,RPC 是組件之間通信的最簡單方式。雖然在設計良好的 DOA 系統中(3),其大部分組件間的通信采用的是生產者 / 消費者模式,但是我們可能仍需要采用直接的方式來讓組件 X 告訴 Y 去做 Z。
首先,必須考慮是否可以將 RPC 重組為事件(event)及其影響(effect)。即,不是讓組件 X 向發生事件 E 的組件 Y 發送 RPC 請求,而是詢問 X 是否可以生成事件 E,并讓組件 Y 通過消費這些事件來驅動響應?
這種方法,我稱之為基于數據的事件(data-based events),它可以很好地逆轉我們通常使用的組件通信方式。它之所以如此強大是因為它使我們可以將“松耦合”這個術語提升到一個全新的層次。系統不需要知道誰在消費它的事件(即,系統不是一個絕對需要知道他們在調用誰的 RPC 調用方),生產者也無需擔心事件的來源,只需知道這些事件的業務邏輯語義即可。
當然,存在一種簡單的方法可以實現基于數據的事件,在這種方法中,每個事件都是以與 RPC 請求序列化版本 1:1 的對應關系持久化到自身表的數據庫中。在這種情況下,基于數據的事件根本不會使系統解耦合。為了使基于數據的事件能正常運行,則要求將請求 / 響應轉換成的持久化事件必須是有意義的業務邏輯結構。
基于數據的事件有時可能不太合適。例如,我們實際上要觸發某個特定組件中的行為。在這些情況下,可能仍然需要保留少量的實際組件到組件的 RPC。
面向數據的架構的成功案例研究
高集成問題空間
我之所以一直以交易 / 財務軟件為例,部分原因在于財務通常需要較大的集成表面積。一個典型的允許較小客戶進行交易的賣方公司,通常會與許多市場進行整合,以與客戶進行互動,而許多流動資金提供者則會通過其獲取價格并下訂單。在請求進入市場到對客戶做出響應之間需要處理的業務邏輯是一個復雜的、多階段的流程。
在高集成問題空間中,單個服務可能需要了解許多其他服務。為了避免 O(N^2) 的集成成本及具有高扇出比率的復雜獨立服務的出現,圍繞數據生產者和消費者的重新配置系統可以使集成更加簡單。假設要進行一個新的集成,不能編寫 N 個新系統,也不能編寫一個具有向 N 個其他系統進行復雜扇出的系統,那么集成過程可能需要編寫一個適配器,該適配器以通用的 DOA 模式生產數據、消費最終的輸出并以正確的線性格式來呈現。
隱含地是,集成中出現了一種新的復雜性:需要考慮模式。任何新的集成對我們的系統而言都應該是原生的,并且我們的模式應該能在不添加補充、修改和特殊用例的情況下擴展。這本身就是一項艱巨的任務。但是,當集成的數量足夠多時,難度就會降低,而且往往是值得的。
沙箱數據以及數據隔離的推理
如果我們要手動建模或測試,則希望最好能在生產之外進行。但是,某些 SOA 生態系統的架構方式通常意味著,想要知道某個服務所處的環境或特定環境是否完全獨立并不那么容易。
環境是指內部一致、連接一致的服務集合,通常或理想情況下,它應與生產的拓撲結構相同。由于 SOA 服務通常是可獨立尋址的,因此,環境一致性斷言要求環境中的每個服務必須與環境中的其他服務就調用哪個地址能達成共識。RPC、訂閱模式(pubsub)和數據流不能從一個環境泄漏到另一個環境中。
很明顯,在 SOA 中有很多方法可以解決這個問題,比如轉換到能為服務生成正確配置的服務注冊中心 (4),或者,如果是通過 URI 訪問服務,則隱藏直接的服務地址,以支持某個環境前綴下的不同路徑(5)。
然而,在 DOA 中,環境的概念要簡單得多。知道組件連接到哪個數據存儲層就足以描述它所處的環境了。由于所有組件都不在內部存儲任何狀態,因此數據是根據定義來隔離的。組件僅通過數據存儲進行通信,因此不存在將數據從一種環境泄漏到另一種環境的危險。
面向數據架構比你想象的更接近現實
如今,有很多類似于面向數據的架構的通用案例。將所有(或大部分)數據保存在一個大型數據存儲中的數據單體,在系統架構上就非常接近于 DOA。
例如,知識圖譜(Knowledge Graphs)就是一個廣義的數據單體。也就是說,它們通常不是很通用;許多與業務邏輯相關的狀態可能會丟失。
GraphQL 通常被用作標準化的數據存儲層,就像數據單體一樣。 GraphQL 是否能成功地成為 DOA 系統的后端,在很大程度上取決于系統對模式設計的選擇:選擇與業務邏輯概念相關的通用模式和表,而不是選擇特定于該數據特定源的模式和表。
權衡取舍
這種架構也不是萬能的。當面向數據的架構消除了某些類型的問題時,就會出現新的問題:它要求設計人員需要認真考慮數據的所有權。當多個寫程序修改同一記錄時,可能會很麻煩,它通常會鼓勵系統仔細劃分記錄的寫入所有權。而且,由于組件間的 API 是在數據中編碼的,因此必須采用需要謹慎考慮的共享全局模式。
我記得 google 的 Protocol Buffers 文檔,在討論如何根據需要將模式中的字段標記為 required 時,它會警告說:“ Required Is Forever ”。在 Broadway Technology,首席技術官(CTO)Joshua Walsky 曾對 DOA 模式說過類似的話:數據是永遠存在(Data Is Forever)。事實證明,出于與 Protobuf 警告類似的原因,在松耦合的分布式系統中,從表中刪除列確實非常困難。
我的建議是:如果您擔心自己的架構存在水平擴展問題,那么就可以考慮以數據單體為中心來進行設計了。
備注
(1)服務到服務的 API 不一定是點對點的,但是組件到組件的直接通信通常意味著,為了達到某個給定的目的,需在兩者之間傳遞參數。
(2)一個架構的集成復雜度增長是否真能達到 N^2,實際上取決于架構的拓撲結構。如果在我們使用的系統中集成是主要的瓶頸之一,則可能會遇到這個問題。
例如,集成了各種流動資金提供者和場外交易(OTC)市場的交易系統,在理想情況下不應處于這樣的場景中:每個管理市場訂單的組件都需要了解每個提供流動資金的組件。
(3)非常適合的 DOA 就是精心設計的。
(4)假設服務調用對方是基于直接地址的(例如,IP 或正在運行的進程的某些內部地址模式),并且服務基于命令行參數能知道在何處訪問特定的服務,那么就可能需要使用更適合的邏輯來包裝這些服務了,對應的邏輯需要根據環境來構造正確的標志。
(5) 例如,與其通過 IP 地址或特定于某個服務的內部 URI 來訪問該特定服務,不如將每個服務構造在一個服務端路由的“路徑”下。例如,使用 ://env.namespace.company.com/Employees/* 而不是 ://process1.namespace.company.com/*