多層依賴:如何避免落入數據服務接口的陷阱?
前面,我們討論了不同類型系統(如讀多寫少、強一致、寫多讀少和讀寫密集)的優化方法。但在很多復雜的業務系統中,讀寫邏輯往往相互交織、互相制約,這讓優化工作變得更具挑戰。遇到這樣的情況,不妨嘗試一種更為高級的拆分模式——CQRS。你或許已經聽說過 CQRS,卻因為它看似復雜而猶豫不決。不過,今天的課程會通過一些實例來展示 CQRS 與傳統單體服務架構的不同之處。學完后,你會深入理解 CQRS 的設計思路,找到避免數據服務接口陷阱的方法,并理解一些“反直覺”的設計選擇,比如在微服務架構中為何會把 5 個項目拆分成 200 個。
傳統單體架構缺點
我們先看看熟悉的傳統單體架構設計思路,重點關注一下它有什么缺點,這有助于你理解為什么會出現 CQRS 這種模式。這里我們繼續沿用前面課程里用戶中心的案例,請看下圖。圖里面展示了單體服務狀態下的用戶中心,這時候高頻和低頻的讀寫服務放在了一起。
圖片
在我們的印象中,用戶中心讀并發流量大。但實際上用戶中心里,不是所有功能都是讀多寫少類型的,你可以參考后面表格列出的例子。
圖片
可以看到,除了讀多寫少的服務功能外,還有很多其它類型的功能(事實上這取決于調用方的場景)。
圖片
這張圖展示了傳統單體架構的優化方式,我們可以清晰地看到在讀寫操作優化上的差異。首先,關于高并發場景下的寫優化,通常需要使用隊列作為緩沖,以保證數據一致性。對于一些特殊的業務場景,為了確保分布式事務的一致性,寫操作的服務器數量不能過多,因為參與事務協調的機器越多,響應速度就越慢。至于讀優化,主要是通過增加服務器的部署來分擔請求壓力,常見做法是對高頻訪問的數據進行多副本緩存。由于讀操作占用大量內存,并且需要更多的數據層連接,且請求壓力較大,因此我們的部署還必須支持服務和數據層的動態擴展。
通過這些分析,我們發現,在單體架構中,同時優化讀寫操作時,很難在成本和性能之間找到理想的平衡。此外,單體架構的數據庫層也存在一些問題。正如圖中所示,讀寫操作都依賴同一個數據庫,這意味著數據層的性能上限也受到集群性能的限制。而且,由于數據庫和代碼層緊密耦合,一旦業務流量增長,我們就需要擴容或更換數據層,而這個過程會變得非常復雜。
圖片
既然單體服務讀寫混合部署這么復雜,有沒有更簡單的解決方案呢?這時 CQRS 就能派上用場了。
CQRS 的讀寫拆分策略
CQRS,全稱是命令查詢責任分離(Command Query Responsibility Segregation),它是一種將應用程序中的命令(Commands)和查詢(Queries)職責分開的方式。如果你對 CQRS 的詳細理論感興趣,可以課后自行深入學習。今天我們主要聚焦于讀寫拆分的部分。CQRS 最吸引我的地方就是它將讀和寫拆分成不同的項目,這種方式可以說非常符合“微服務”的理念。接下來,我們繼續通過用戶中心的案例來對比和分析。
圖片
在示意圖中可以看到,我們將用戶中心的業務根據高并發寫入和高并發讀取兩個類別,分拆成了獨立的項目部署。這種拆分讓讀寫優化更加靈活,投入成本也更為精準。拆分后,我們可以根據流量調整服務器和基礎服務的規模,顯著降低運維成本、減少服務壓力。此外,讀寫拆分有助于提升數據庫性能,擴大我們在數據層的選擇,比如可以使用 ElasticSearch、ClickHouse、NoSQL 等特殊的數據服務。如果細心觀察,你會發現許多流行的分布式數據服務中也存在類似的讀寫分離實現。
總結來說,CQRS 允許我們將讀寫操作分別優化和部署,最大化利用讀寫優化的混合優勢,同時為系統提供靈活的擴展能力。在此基礎上,我們還可以將常見的讀寫操作封裝成標準模塊,提升優化效率,讓業務在復用這些模塊時能直接具備動態擴展能力。
不過,讀寫分離也有一定代價,因此除非在核心業務中,一般不建議大規模應用。這些優化技巧對運維有較高要求,通常需要運維來配合修改配置和服務調整。比如在數據同步方面,我們一般使用隊列(如 binlog 數據同步隊列或業務變更廣播)來實現。這類同步通常不需編寫額外代碼,而是依賴基礎服務,但如果沒有自動化工具,每次擴展都需要手動調整配置,既繁瑣又易出錯。
此外,一些業務場景可能需要讀強一致性,拆分后的架構可能難以滿足。這里建議引入一個類似 Raft 的讀強一致性 Proxy,來確保業務輕松獲得最新數據。
數據服務接口導致的多層依賴
高并發的業務常常很復雜,而 CQRS 比較適合拆分和優化數據接口,但對于復雜的業務接口我們還要做更多處理。想要理解這一點,先要審視一下我們的編程習慣。很多人認為,把業務接口寫成數據接口這個方式是衡量服務是否靈活的標準,甚至催生出 RESTful 這種 API 設計思路,我們看一個例子。
圖片
觀察上面的網址,可以發現 RESTful 是圍繞數據實體來設計的數據服務。這么做雖然方便快速迭代,但很容易讓上層業務依賴底層的數據結構,而且接口的隔離性很差,修改的時候影響范圍很大。
不僅如此,圍繞數據實體去設計接口還會帶來更多的問題。下圖是一個常見的在線商城服務,可以看到每個具體業務應用(藍色方塊)都是通過拼合多個 Service 服務實現的。
圖片
這種結構日常運轉沒什么問題,但一旦流量增大,對 QPS 要求更高的時候,這個結構就會有大量的網絡損耗,接口的請求耗時鏈路分析如下圖。
圖片
我們目前接口的總返回時間是 420ms,而這個接口依賴了兩個 Service 服務,而這兩個 Service 又各自依賴了其他服務。顯然,這種依賴關系消耗了大量的時間。在這種情況下,使用緩存來減少底層服務接口的調用次數可以提高接口性能,但這種方法也有局限性。它主要適用于優化那些不需要強一致性的讀取服務,而且可能會導致緩存不一致等維護問題。
再想想我們在寫項目時,為何通常會自覺地為每個功能多封裝一層 Service,并且將 Model 層設計成多層目錄?其實,原因很簡單,因為多層依賴的情況非常常見,而實體之間的關系又往往比較復雜,一層 Model 很難涵蓋所有的邏輯和關系。所以,我們通常會不自覺地通過這種方式來進行優化,確保代碼結構更加清晰、靈活,也更易于維護。
圖片
如果是大型系統,業務實體關系更多、更復雜,那么反映在代碼上就會有更多的層級。當我們的系統復雜到一定程度,就會出現多層項目依賴問題,呈現出下圖所示的“搭樂高”狀態。
圖片
可以說,這種用數據實體思路設計接口的方法確實存在一些缺點,也暴露了貧血模型的問題。為了避免這種“搭積木”式的依賴關系,在項目初期,我們通常盡量將依賴邏輯直接在 Model 層實現。不過,如何在 Model 層內部進行分層,功能如何拆分,并沒有統一的參考標準,因此團隊之間的實現往往各不相同,很難形成一致規范,這也導致了后續的優化和維護成本居高不下。
為了解決多層依賴的問題,我們可以考慮充血模型作為一種替代方案。接下來,我們結合接口實現的實例來直觀對比一下這種方法,從而幫助你更好地理解充血模型的優點和應用場景。
圖片
可以看到,和貧血模型直接寫增刪改查不同,充血模型通過拼合多個實體的功能直接提供業務服務。同時,為了更好地拆分復雜的依賴實體關系,充血模型按業務領域劃分了 Model 層,每個領域會包含更多的子領域,這會導致模型內實現有更多的分層。但是因為模型內的實體實現都是業務操作,這樣項目之間接口只有業務依賴關系,而不是數據依賴。業務依賴關系的接口之間只有流程和事務的拼接,訪問頻率也不會那么高,可以很好地緩解系統壓力。
微服務的平層設計
不過,即使我們使用了充血模型,仍然會有一些公共數據存在多層依賴的情況。為此,微服務做出了新嘗試,直接去掉所有服務的層級,設計成大平層的架構。如果你曾經翻過一些微服務資料,可能會疑惑微服務的設計為什么和我們的習慣差異這么大?比如幾個小接口就能封裝成一個項目,再比如服務之間的依賴不是樹形而是星狀。
圖片
圖片
這是因為,在微服務里一個 Service 就是一個獨立部署的項目,同層 Service 服務之間沒有上下層級關系,可以相互調用(雖然不推薦)。這和我們以往的橫切習慣有很大不同,屬于縱切拆分。橫切是按照依賴關系把服務劃分為內網服務、外網服務,縱切可以理解成按業務服務切分,具體效果如圖。
圖片
結合之前的知識,不難發現,橫切服務適用于項目的初期驗證階段,具有較高的復用性,但同時也帶來了復雜的依賴關系,性能優化較為困難。而縱切服務則更適合復雜業務場景,沒有多層依賴的負擔,性能更優。但由于依賴關系需在充血模型內部實現,因此還要解決數據共享的問題,數據同步的難度較高,且業務邏輯的梳理也較為復雜。
盡管縱切結構并不常見,但近年來微服務和領域驅動設計(DDD)都選擇了這種方法。