微服務架構詳談

忙碌的程式設計師發表於2018-09-26

微服務架構和SOA區別

微服務現在辣麼火,業界流行的對比的卻都是所謂的Monolithic單體應用,而大量的系統在十幾年前都是已經是分散式系統了,那麼微服務作為新的理念和原來的分散式系統,或者說SOA(面向服務架構)是什麼區別呢?

我們先看相同點

  • 需要Registry,實現動態的服務註冊發現機制;

  • 需要考慮分散式下面的事務一致性,CAP原則下,兩段式提交不能保證效能,事務補償機制需要考慮;

  • 同步呼叫還是非同步訊息傳遞,如何保證訊息可靠性?SOA由ESB來整合所有的訊息;

  • 都需要統一的Gateway來匯聚、編排介面,實現統一認證機制,對外提供APP使用的RESTful介面;

  • 同樣的要關注如何再分散式下定位系統問題,如何做日誌跟蹤,就像我們電信領域做了十幾年的信令跟蹤的功能;

那麼差別在哪?

  • 是持續整合、持續部署?對於CI、CD(持續整合、持續部署),這本身和敏捷、DevOps是交織在一起的,我認為這更傾向於軟體工程的領域而不是微服務技術本身;

  • 使用不同的通訊協議是不是區別?微服務的標杆通訊協議是RESTful,而傳統的SOA一般是SOAP,不過目前來說採用輕量級的RPC框架Dubbo、Thrift、gRPC非常多,在Spring Cloud中也有Feign框架將標準RESTful轉為程式碼的API這種仿RPC的行為,這些通訊協議不應該是區分微服務架構和SOA的核心差別;

  • 是流行的基於容器框架還是虛擬機器為主?Docker和虛擬機器還是物理機都是架構實現的一種方式,不是核心區別;

微服務架構的精髓在切分

  • 服務的切分上有比較大的區別,SOA原本是以一種“整合”技術出現的,很多技術方案是將原有企業內部服務封裝為一個獨立程式,這樣新的業務開發就可重用這些服務,這些服務很可能是類似供應鏈、CRM這樣的非常大的顆粒;而微服務這個“微”,就說明了他在切分上有講究,不妥協。無數的案例證明,如果你的切分是錯誤的,那麼你得不到微服務承諾的“低耦合、升級不影響、可靠性高”之類的優勢,而會比使用Monolithic有更多的麻煩。

  • 不拆分儲存的微服務是偽服務:在實踐中,我們常常見到一種架構,後端儲存是全部和在一個資料庫中,僅僅把前端的業務邏輯拆分到不同的服務程式中,本質上和一個Monolithic一樣,只是把模組之間的程式內呼叫改為程式間呼叫,這種切分不可取,違反了分散式第一原則,模組耦合沒有解決,效能卻受到了影響。

分散式設計第一原則 — “不要分佈你的物件”

  • 微服務的“Micro”這個詞並不是越小越好,而是相對SOA那種粗粒度的服務,我們需要更小更合適的粒度,這種Micro不是無限制的小。

如果我們將兩路(同步)通訊與小/微服務結合使用,並根據比如“1個類=1個服務”的原則,那麼我們實際上回到了使用Corba、J2EE和分散式物件的20世紀90年代。遺憾的是,新生代的開發人員沒有使用分散式物件的經驗,因此也就沒有認識到這個主意多麼糟糕,他們正試圖重複歷史,只是這次使用了新技術,比如用HTTP取代了RMI或IIOP。

微服務和Domain Driven Design

一個簡單的圖書管理系統肯定無需微服務架構。既然採用了微服務架構,那麼面對的問題空間必然是比較巨集大,比如整個電商、CRM。

如何拆解服務呢?

使用什麼樣的方法拆解服務?業界流行1個類=1個服務、1個方法=1個服務、2 Pizza團隊、2周能重寫完成等方法,但是這些都缺乏實施基礎。我們必須從一些軟體設計方法中尋找,物件導向和設計模式適用的問題空間是一個模組,而函數語言程式設計的理念更多的是在程式碼層面的微觀上起作用。
Eric Evans 的《領域驅動設計》這本書對微服務架構有很大借鑑意義,這本書提出了一個能將一個大問題空間拆解分為領域和實體之間的關係和行為的技術。目前來說,這是一個最合理的解決拆分問題的方案,透過限界上下文(Bounded Context,下文簡稱為BC)這個概念,我們能將實現細節封裝起來,讓BC都能夠實現SRP(單一職責)原則。而每個微服務正是BC在實際世界的物理對映,符合BC思路的微服務互相獨立鬆耦合。

微服務架構是一件好事,逼著大家關注設計軟體的合理性,如果原來在Monolithic中領域分析、物件導向設計做不好,換微服務會把這個問題成倍的放大

以電商中的訂單和商品兩個領域舉例,按照DDD拆解,他們應該是兩個獨立的限界上下文,但是訂單中肯定是包含商品的,如果貿然拆為兩個BC,查詢、呼叫關係就耦合在一起了,甚至有了麻煩的分散式事務的問題,這個關聯如何拆解?BC理論認為在不同的BC中,即使是一個術語,他的關注點也不一樣,在商品BC中,關注的是屬性、規格、詳情等等(實際上商品BC這個領域有價格、庫存、促銷等等,把他作為單獨一個BC也是不合理的,這裡為了簡化例子,大家先認為商品BC就是商品基礎資訊), 而在訂單BC中更關注商品的庫存、價格。所以在實際編碼設計中,訂單服務往往將關注的商品名稱、價格等等屬性冗餘在訂單中,這個設計解脫了和商品BC的強關聯,兩個BC可以獨立提供服務,獨立資料儲存

小結

微服務架構首先要關注的不是RPC/ServiceDiscovery/Circuit Breaker這些概念,也不是Eureka/Docker/SpringCloud/Zipkin這些技術框架,而是服務的邊界、職責劃分,劃分錯誤就會陷入大量的服務間的相互呼叫和分散式事務中,這種情況微服務帶來的不是便利而是麻煩。
DDD給我們帶來了合理的劃分手段,但是DDD的概念眾多,晦澀難以理解,如何抓住重點,合理的運用到微服務架構中呢?

我認為如下的幾個架構思想是重中之重

  • 充血模型

  • 事件驅動


歡迎工作一到五年的Java工程師朋友們加入Java架構開發:878249276

群內提供免費的Java架構學習資料(裡面有高可用、高併發、高效能及分散式、Jvm效能調優、Spring原始碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!

微服務和充血模型

DDD那麼複雜的理論,聚合根、值物件、事件溯源,到底我們該怎麼入手呢?

實際上DDD和麵向物件設計、設計模式等等理論有千絲萬縷的聯絡,如果不熟悉OOA、OOD,DDD也是使用不好的。不過學習這些OO理論的時候,大家往往感覺到無用武之地,因為大部分的Java程式設計師開發生涯是從學習J2EE經典的分層理論開始的(Action、Service、Dao),在這種分層理論中,我們基本沒有啥機會使用那些所謂的“行為型”的設計模式,這裡的核心原因,就是J2EE經典分層的開發方式是“貧血模型”。

Martin Fowler在他的《企業應用架構模式》這本書中提出了兩種開發方式“事務指令碼”和“領域模型”,這兩種開發分別對應了“貧血模型”和“充血模型”。

事務指令碼開發模式

事務指令碼的核心是過程,可以認為大部分的業務處理都是一條條的SQL,事務指令碼把單個SQL組織成為一段業務邏輯,在邏輯執行的時候,使用事務來保證邏輯的ACID。最典型的就是儲存過程。當然我們在平時J2EE經典分層架構中,經常在Service層使用事務指令碼。

使用這種開發方式,物件只用於在各層之間傳輸資料用,這裡的物件就是“貧血模型”,只有資料欄位和Get/Set方法,沒有邏輯在物件中。

我們以一個庫存扣減的場景來舉例:

  • 業務場景

首先談一下業務場景,一個下訂單扣減庫存(鎖庫存),這個很簡單
先判斷庫存是否足夠,然後扣減可銷售庫存,增加訂單佔用庫存,然後再記錄一個庫存變動記錄日誌(作為憑證)

  • 貧血模型的設計

首先設計一個庫存表 Stock,有如下欄位

設計一個Stock物件(Getter和Setter省略)

1
2
3
4
5
6
public class Stock {
private String spuId;
private String skuId;
private int stockNum;
private int orderStockNum;
}
  • Service入口

設計一個StockService,在其中的lock方法中寫邏輯
入參為(spuId, skuId, num)
實現虛擬碼

1
2
3
4
5
6
7
count = select stocknum from stock where spuId=xx and skuid=xx
if count>num {
update stock set stocknum=stocknum-num, orderstocknum=orderstocknum+num where skuId=xx and spuId=xx
} else {
//庫存不足,扣減失敗
}
insert stock_log set xx=xx, date= new Date()
  • ok,打完收工,如果做的好一些,可以把update和select count合一,這樣可以利用一條語句完成自旋,解決併發問題(高手)。

小結一下:
有沒有發現,在這個業務領域非常重要的核心邏輯 — 下訂單扣減庫存中操作過程中,Stock物件根本不用出現,全部是資料庫操作SQL,所謂的業務邏輯就是由多條SQL構成。Stock只是CRUD的資料物件而已,沒邏輯可言。

  • 馬丁福勒定義的“貧血模型”是反模式,面對簡單的小系統用事務指令碼方式開發沒問題,業務邏輯複雜了,業務邏輯、各種狀態散佈在大量的函式中,維護擴充套件的成本一下子就上來,貧血模型沒有實施微服務的基礎。

  • 雖然我們用Java這樣的面嚮物件語言來開發,但是其實和過程型語言是一樣的,所以很多情況下大家用資料庫的儲存過程來替代Java寫邏輯反而效果會更好,(ps:用了Spring boot也不是微服務),

領域模型的開發模式

  • 領域模型是將資料和行為封裝在一起,並與現實世界的業務物件相對映。各類具備明確的職責劃分,使得邏輯分散到合適物件中。這樣的物件就是“充血模型” 。

  • 在具體實踐中,我們需要明確一個概念,就是領域模型是有狀態的,他代表一個實際存在的事物。還是接著上面的例子,我們設計Stock物件需要代表一種商品的實際庫存,並在這個物件上面加上業務邏輯的方法

這樣做下單鎖庫存業務邏輯的時候,每次必須先從Repository根據主鍵load還原Inventory這個物件,然後執行對應的lock(num)方法改變這個Inventory物件的狀態(屬性也是狀態的一種),然後再通過Repository的save方法把這個物件持久化到儲存去。

完成上述一系列操作的是Application,Application對外提供了這種整合操作的介面

領域模型開發方法最重要的是把扣減造成的狀態變化的細節放到了Inventory物件執行,這就是對業務邏輯的封裝。
Application物件的lock方法可以和事務指令碼方法的StockService的lock來做個對比,StockService是完全掌握所有細節,一旦有了變化(比如庫存為0也可以扣減),Service方法要跟著變;而Application這種方式不需要變化,只要在Inventory物件內部計算就可以了。程式碼放到了合適的地方,計算在合適層次,一切都很合理。這種設計可以充分利用各種OOD、OOP的理論把業務邏輯實現的很漂亮。

  • 充血模型的缺點

從上面的例子,在Repository的load 到執行業務方法,再到save回去,這是需要耗費一定時間的,但是這個過程中如果多個執行緒同時請求對Inventory庫存的鎖定,那就會導致狀態的不一致,麻煩的是針對庫存的併發不僅難處理而且很常見。
貧血模型完全依靠資料庫對併發的支撐,實現可以簡化很多,但充血模型就得自己實現了,不管是在記憶體中通過鎖物件,還是使用Redis的遠端鎖機制,都比貧血模型複雜而且可靠性下降,這是充血模型帶來的挑戰。更好的辦法是可以通過事件驅動的架構來取消併發。

領域模型和微服務的關係

上面講了領域模型的實現,但是他和微服務是什麼關係呢?在實踐中,這個Inventory是一個限界上下文的聚合根,我們可以認為一個聚合根就是一個微服務程式。

不過問題又來了,一個庫存的Inventory一定和商品資訊是有關聯的,僅僅靠Inventory中的冗餘那點商品ID是不夠的,商品的上下架狀態等等都是業務邏輯需要的,那不是又把商品Sku這樣的重型物件引入了這個微服務?兩個重型的物件在一個服務中?這樣的微服務拆不開啊,還是必須依靠商品庫?!

給大家推薦一個程式設計師學習交流群:878249276,群裡有分享的視訊,面試指導,架構資料,還有思維導圖、群公告有視訊,都是乾貨的,你可以下載來看。主要分享分散式架構、高可擴充套件、高效能、高併發、效能優化、Spring boot、Redis、ActiveMQ、Nginx、Mycat、Netty、Jvm大型分散式專案實戰學習架構師視訊。合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!

事件驅動架構

我們採用了領域驅動的開發方式,使用了充血模型,享受了他的好處,但是也不得不面對他帶來的弊端。這個弊端在分散式的微服務架構下面又被放大。

事務一致性

事務一致性的問題在Monolithic下面不是大問題,在微服務下面卻是很致命,我們回顧一下所謂的ACID原則

  • Atomicity – 原子性,改變資料狀態要麼是一起完成,要麼一起失敗

  • Consistency – 一致性,資料的狀態是完整一致的

  • Isolation – 隔離線,即使有併發事務,互相之間也不影響

  • Durability – 永續性, 一旦事務提交,不可撤銷

在單體服務和關係型資料庫的時候,我們很容易通過資料庫的特性去完成ACID。但是一旦你按照DDD拆分聚合根-微服務架構,他們的資料庫就已經分離開了,你就要獨立面對分散式事務,要在自己的程式碼裡面滿足ACID。
對於分散式事務,大家一般會想到以前的JTA標準,2PC兩段式提交。我記得當年在Dubbo群裡面,基本每週都會有人詢問Dubbo啥時候支撐分散式事務。實際上根據分散式系統中CAP原則,當P(分割槽容忍)發生的時候,強行追求C(一致性),會導致(A)可用性、吞吐量下降,此時我們一般用最終一致性來保證我們系統的AP能力。當然不是說放棄C,而是在一般情況下CAP都能保證,在發生分割槽的情況下,我們可以通過最終一致性來保證資料一致。

例:
在電商業務的下訂單凍結庫存場景。需要根據庫存情況確定訂單是否成交。
假設你已經採用了分散式系統,這裡訂單模組和庫存模組是兩個服務,分別擁有自己的儲存(關係型資料庫),

在一個資料庫的時候,一個事務就能搞定兩張表的修改,但是微服務中,就沒法這麼做了。
在DDD理念中,一次事務只能改變一個聚合內部的狀態,如果多個聚合之間需要狀態一致,那麼就要通過最終一致性。訂單和庫存明顯是分屬於兩個不同的限界上下文的聚合,這裡需要實現最終一致性,就需要使用事件驅動的架構。

事件驅動實現最終一致性

事件驅動架構在領域物件之間通過非同步的訊息來同步狀態,有些訊息也可以同時釋出給多個服務,在訊息引起了一個服務的同步後可能會引起另外訊息,事件會擴散開。嚴格意義上的事件驅動是沒有同步呼叫的。

例子:
在訂單服務新增訂單後,訂單的狀態是“已開啟”,然後釋出一個Order Created事件到訊息佇列上

庫存服務在接收到Order Created 事件後,將庫存表格中的某sku減掉可銷售庫存,增加訂單佔用庫存,然後再傳送一個Inventory Locked事件給訊息佇列

訂單服務接收到Inventory Locked事件,將訂單的狀態改為“已確認”

有人問,如果庫存不足,鎖定不成功怎麼辦? 簡單,庫存服務傳送一個Lock Fail事件, 訂單服務接收後,把訂單置為“已取消”。

好訊息,我們可以不用鎖!事件驅動有個很大的優勢就是取消了併發,所有請求都是排隊進來,這對我們實施充血模型有很大幫助,我們可以不需要自己來管理記憶體中的鎖了。取消鎖,佇列處理效率很高,事件驅動可以用在高併發場景下,比如搶購。

是的,使用者體驗有改變,用了這個事件驅動,使用者的體驗有可能會有改變,比如原來同步架構的時候沒有庫存,就馬上告訴你條件不滿足無法下單,不會生成訂單;但是改了事件機制,訂單是立即生成的,很可能過了一會系統通知你訂單被取消掉。 就像搶購“小米手機”一樣,幾十萬人在排隊,排了很久告訴你沒貨了,明天再來吧。如果希望使用者立即得到結果,可以在前端想辦法,在BFF(Backend For Frontend)使用CountDownLatch這樣的鎖把後端的非同步轉成前端同步,當然這樣BFF消耗比較大。

沒辦法,產品經理不接受,產品經理說使用者的體驗必須是沒有庫存就不會生成訂單,這個方案會不斷的生成取消的訂單,他不能接受,怎麼辦?那就在訂單列表查詢的時候,略過這些cancel狀態的訂單吧,也許需要一個額外的檢視來做。我並不是一個理想主義者,解決當前的問題是我首先要考慮的,我們設計微服務的目的是本想是解決業務併發量。而現在面臨的卻是使用者體驗的問題,所以架構設計也是需要妥協的:( 但是至少分析完了,我知道我妥協在什麼地方,為什麼妥協,未來還有可能改變。

多個領域多表Join查詢

  • 我個人認為聚合根這樣的模式對修改狀態是特別合適,但是對搜尋資料的確是不方便,比如篩選出一批符合條件的訂單這樣的需求,本身聚合根物件不能承擔批量的查詢任務,因為這不是他的職責。那就必須依賴“領域服務(Domain Service)”這種設施。

當一個方法不便放在實體或者值物件上,使用領域服務便是最佳的解決方法,請確保領域服務是無狀態的。

  • 我們的查詢任務往往很複雜,比如查詢商品列表,要求按照上個月的銷售額進行排序; 要按照商品的退貨率排序等等。但是在微服務和DDD之後,我們的儲存模型已經被拆離開,上述的查詢都是要涉及訂單、使用者、商品多個領域的資料。如何搞? 此時我們要引入一個檢視的概念。比如下面的,查詢使用者名稱下訂單的操作,直接呼叫兩個服務自己在記憶體中join效率無疑是很低的,再加上一些filter條件、分頁,沒法做了。於是我們將事件廣播出去,由一個單獨的檢視服務來接收這些事件,並形成一個物化檢視(materialized view),這些資料已經join過,處理過,放在一個單獨的查詢庫中,等待查詢,這是一個典型的以空間換時間的處理方式。

經過分析,除了簡單的根據主鍵Find或者沒有太多關聯的List查詢,我們大部分的查詢任務可以放到單獨的查詢庫中,這個查詢庫可以是關聯式資料庫的ReadOnly庫,也可以是NoSQL的資料庫,實際上我們在專案中使用了ElasticSearch作為專門的查詢檢視,效果很不錯

限界上下文(Bounded Context)和資料耦合

除了多領域join的問題,我們在業務中還會經常碰到一些場景,比如電商中的商品資訊是基礎資訊,屬於單獨的BC,而其他BC,不管是營銷服務、價格服務、購物車服務、訂單服務都是需要引用這個商品資訊的。但是需要的商品資訊只是全部的一小部分而已,營銷服務需要商品的id和名稱、上下架狀態;訂單服務需要商品id、名稱、目錄、價格等等。這比起商品中心定義一個商品(商品id、名稱、規格、規格值、詳情等等)只是一個很小的子集。這說明不同的限界上下文的同樣的術語,但是所指的概念不一樣。 這樣的問題對映到我們的實現中,每次在訂單、營銷模組中直接查詢商品模組,肯定是不合適,因為

  • 商品中心需要適配每個服務需要的資料,提供不同的介面

  • 併發量必然很大

  • 服務之間的耦合嚴重,一旦當機、升級影響的範圍很大。

特別是最後一條,嚴重限制了我們獲得微服務提供的優勢“鬆耦合、每個服務自己可以頻繁升級不影響其他模組”。這就需要我們通過事件驅動方法,適當冗餘一些資料到不同的BC去,把這種耦合拆解開。這種耦合有時候是通過Value Object嵌入到實體中的方式,在生成實體的時候就冗餘,比如訂單在生成的時候就冗餘了商品的資訊;有時候是通過額外的Value Object列表方式,營銷中心冗餘一部分相關的商品列表資料,並隨時關注監聽商品的上下級狀態,同步替換掉本限界上下文的商品列表。

下圖一個下單場景分析,在電商系統中,我們可以認為會員和商品是所有業務的基礎資料,他們的變更應該是通過廣播的方式釋出到各個領域,每個領域保留自己需要的資訊。

保證最終一致性

最終一致性成功依賴很多條件

  • 依賴訊息傳遞的可靠性,可能A系統變更了狀態,訊息發到B系統的時候丟失了,導致AB的狀態不一致

  • 依賴服務的可靠性,如果A系統變更了自己的狀態,但是還沒來得及傳送訊息就掛了。也會導致狀態不一致

我記得JavaEE規範中的JMS中有針對這兩種問題的處理要求,一個是JMS通過各種確認訊息(Client Acknowledge等)來保證訊息的投遞可靠性,另外是JMS的訊息投遞操作可以加入到資料庫的事務中-即沒有傳送訊息,會引起資料庫的回滾(沒有查資料,不是很準確的描述,請專家指正)。不過現在符合JMS規範的MQ沒幾個,特別是保一致性需要降低效能,現在標榜高吞吐量的MQ都把問題拋給了我們自己的應用解決。所以這裡介紹幾個常見的方法,來提升最終一致性的效果。

使用本地事務

還是以上面的訂單扣取信用的例子

  • 訂單服務開啟本地事務,首先新增訂單;

  • 然後將Order Created事件插入一張專門Event表,事務提交;

  • 有一個單獨的定時任務執行緒,定期掃描Event表,掃出來需要傳送的就丟到MQ,同時把Event設定為“已傳送”。

方案的優勢是使用了本地資料庫的事務,如果Event沒有插入成功,那麼訂單也不會被建立;執行緒掃描後把event置為已傳送,也確保了訊息不會被漏發(我們的目標是寧可重發,也不要漏發,因為Event處理會被設計為冪等)。
缺點是需要單獨處理Event釋出在業務邏輯中,繁瑣容易忘記;Event傳送有些滯後;定時掃描效能消耗大,而且會產生資料庫高水位隱患;

我們稍作改進,使用資料庫特有的MySQL Binlog跟蹤(阿里的Canal)或者Oracle的GoldenGate技術可以獲得資料庫的Event表的變更通知,這樣就可以避免通過定時任務來掃描了

不過用了這些資料庫日誌的工具,會和具體的資料庫實現(甚至是特定的版本)繫結,決策的時候請慎重。

使用Event Sourcing 事件溯源

事件溯源對我們來說是一個特別的思路,他並不持久化Entity物件,而是隻把初始狀態和每次變更的Event記錄下來,並在記憶體中根據Event還原Entity物件的最新狀態,具體實現很類似資料庫的Redolog的實現,只是他把這種機制放到了應用層來。

雖然事件溯源有很多宣稱的優勢,引入這種技術要特別小心,首先他不一定適合大部分的業務場景,一旦變更很多的情況下,效率的確是個大問題;另外一些查詢的問題也是困擾。

我們僅僅在個別的業務上探索性的使用Event Souring和AxonFramework,由於實現起來比較複雜,具體的情況還需要等到實踐一段時間後再來總結,也許需要額外的一篇文章來詳細描述

以上是對事件驅動在微服務架構中一些我的理解。

本文篇幅過長,能堅持看到最後的,相信一定不多。感謝閱讀,順便點點讚唄


歡迎工作一到五年的Java工程師朋友們加入Java架構開發:878249276

群內提供免費的Java架構學習資料(裡面有高可用、高併發、高效能及分散式、Jvm效能調優、Spring原始碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!


相關文章