一、背景
我們在聊架構風格之前先明確一個問題,什么是架構?我們為什么要選擇架構、用來解決哪些問題?
1、什么是架構
書本定義:“軟件的架構是一種抽象的結構,他由軟件的各個組成部分和這些部分之間的依賴關系構成”。我的理解是,架構就是根據業務選擇合適的技術、中間件,并且按照合適的設計模式對這些模塊,進行組裝來滿足業務特性的需求。
2、選擇架構風格的目的
我們選擇架構風格的初衷在于 “三更原則”(自己的理解) :更好地降本提效、更快地發版上線、更好地維護系統穩定性。
任何一個架構風格,都可以實現功能性需求,但是一個好的架構風格能在功能性需求之上,提升非功能性需求,那么你可能會問,什么是非功能性需求?舉例:擴展性、穩定性等等。
這里我將會以我認知結合踩過的坑,來給大家詳細講一下,我們是如何從單體架構演進到分布式架構,在向分布式單體架構的演進的道路上,又如何進行的抉擇,以及為什么最后同時選擇了微服務架構+分布式架構的原因。接下來就結合一個系統來作為案例,貫穿主線講解。
首先來講一下,最初的單體架構的經歷和轉型。
二、單體架構
我們在系統創建之初,往往都是集中業務、單點部署系統,所有業務打一個包,快速上線。滿足了業務初期的快速發版上線,而且適合中小公司沒有自己的paas平臺,應對初期快速迭代的業務,開發、迭代、測試、發布都是非常的便捷。那么單體架構都有什么類型呢?
1、單體架構的類型
單體架構也分為大泥團架構、分層單體架構、模塊化單體架構,他們的區別是什么呢?
1)大泥團單體架構:毫無分層、所有模塊聚焦在一起,相互穿插(除非是你接手需要改造,否則不要創建這樣的架構風格,這種大泥團架構很難拆分,到最后的下場往往都是重新搭建)。
2)分層單體架構:普遍的選擇,架構進行了簡單的分層,比如傳統的mvc三層架構。
3)模塊化單體架構:一般是隨著業務的發展,由分層單體架構演變而來,特點就是引入了多個業務模塊并且提供相應的服務能力。
2、單體架構的優缺點
1)單體架構的優點
- 應用的開發很簡單
- 易于對應用程序大規模的更改
- 測試相對簡單、直觀
- 部署簡單明了
- 橫向擴展不費吹灰之力
在業務的初期,單體架構的優點,無論從哪個方面來說,都優于其他架構風格,但是隨著業務的增加、耦合,單體架構的缺點也逐漸暴露出來,這個也符合“康威定律”。那么單體架構的“后期”會暴露出哪些問題呢?
2)單體架構的缺點
- 代碼庫膨脹
- 過度的復雜性會嚇退開發者
- 開發速度慢
- 從代碼提交到實際部署的周期很長,而且容易出問題
- 難以擴展
- 系統的穩定性得不到保障
- 需要長期依賴某個可能過時的技術棧
單體架構的這些缺點,其實影響的還是我上面提到的“三更原則”。經過上面的鋪墊,相信大家已經對單體架構風格已經有了簡單的理解,那么光有方法論是不行的,我們得結合項目以及代碼片段來加深理解,做到真正的應用。
接下來我就用一個庫存系統來進行串聯進行講解。先通過這張圖來了解下庫存系統是用來做什么的?
- 創建之初,1個服務提供商品庫存維護、庫存查詢、庫存扣減能力。
- 隨著業務的發展,庫存面向多個服務:B端業務,平臺內部業務系統、平臺外部中臺。C端業務,訂單商品扣減庫存、網關查詢庫存數量。
3、單體架構的案例:庫存系統
最初的庫存代碼分層如下:
- API:對外提供的dubbo服務
- common:封裝了公共方法
- dao:封裝了數據庫dbcp交互
- domain:實體類
- innerApi:系統內部api交互
- router:廢棄
- rpc:上下游rpc交互
- service:業務邏輯層
- web:web服務層
- worker:任務調度層
在最初很長的一段時間里,我們部署了兩個單體服務,一個是API接口來保障上游的庫存查詢以及調用,另一個是web服務的后臺管理平臺。這兩個單體服務很好的貼合了最初的業務迭代和發版速度,但是后來隨著業務的增加附加調用量的增加,單體服務的無論是從性能和穩定性都出現了較大的波動。
4、意料之外,情理之中的事故慘案
2015年6月26日晚,也是一個促銷活動的前夕,庫存的web管理平臺掛了,原因就是大量庫存導入,服務器的內存不足導致機器宕機。商家、運營無法通過導表的方式去維護庫存數量,在這之前已經經歷過了多次橫向擴容。還是出現了預料之外的流量和穩定性的問題。
而且在接下來的大促過程當中,庫存的單體服務API接口也承受了非常大的壓力。
一方面是上游調用方有很多,比如App端首頁中的門店網關,查詢商品是否有庫存,是否展示。購物車加車,也會查詢商品庫存的數量,提單則會對庫存數量進行扣減,乃至后續的訂單取消同樣也會調用庫存接口。
另一方面大的KA商家通過中臺對接對庫存進行操作,為了盡可能的讓商家門店的庫存和線上平臺的庫存保持一致,減少線上線下庫存不一致導致的超賣、少賣。中臺同步間隔時間都非常短,5分鐘-10分鐘就要全量同步一次。后續隨著入駐的商家增多,這個量級增長得也非常的迅速。于是我們開啟了單體服務向分布式服務演進的大門。
三、分布式架構
1、分布式架構的優缺點
1)分布式架構的優點
- 可用性高
- 可擴展性高
- 系統容錯性高
- 業務代碼可讀性高
- 維護簡單
這些優點正是我們當時庫存系統欠缺的,尤其是其中的可用性、系統容錯性,是我們系統演進迭代的首要目標。
《分布式架構體系》中描述到,分布式架構的核心理念也是按照(功能、業務、領域等)對系統進行拆分,通過合理的拆分結構,實現各業務模塊的解耦,同時通過系統級容錯設計,在廉價硬件基礎設施上構建起高可用、可擴展的開放技術體系。
所以我們庫存系統到底要按照什么進行拆分,功能?業務?領域?在拆分之前我們一定要明確設計的目標,避免目標方向錯誤帶來的人力、成本資源的浪費。在弄清楚目標之前,我們先了解下分布式架構的缺點,通過了解這些缺點來衡量滿足我們目標的前提下,需要進行哪些方面的取舍,就如CAP原則一樣,只能滿足其中的兩個,AP或者CP。
2)分布式架構的缺點
- 服務多,人員對拆分后的業務模塊理解要花費一些成本
- 技術棧升級耗費人力
- 分布式事務的保持
- 業務模塊之間的rpc交互損耗
庫存系統的特點,高可用、高并發、強數據一致性。接下來我們就來講一下,庫存是如何從單體架構向分布式架構進行的轉型。
2、單體架構如何向分布式架構轉型
因為庫存面臨的最大的問題是穩定性,所以我們首先針對功能進行了拆分。
1)功能拆分
這一步是相對簡單的,我們梳理出庫存面向服務的業務方進行服務劃分。這部分無需進行太多代碼的改造,一套接口通過變更不同的group別名,部署到不同的集群即可。
拆分后,不同的服務應對不同的業務方,系統錯誤的隔離性好,不會說出現一損俱損的局面,穩定性上也有了保障。在解決了穩定性的問題后,留給我們了一些喘氣的間隔,可以有時間去進行代碼的優化。因為剛才也提到了,我們只是通過分布式的集群部署來解決容錯性的問題,但是代碼還是一套,臃腫的代碼也會拖慢我們的開發上線速度。那么接下來要進行的就是,對業務代碼的解耦,這塊也是難度最高的。我們是如何做的呢?
2)業務拆分
業務拆分的思路是什么呢?
- 以業務本身為導向,充分了解系統業務模型,劃分業務邊界
- 業務依賴的范圍,細分功能,盡量減少功能之間的重復依賴
- 根據拆分功能的影響大小進行評估,拆小保大
- 拆分的過程中不要修改業務邏輯,不要進行拆分之外的任何優化動作(除非是bug)
基于上述拆分的思路,庫存系統又是如何劃分的業務模塊呢?動了哪些代碼?
3)如何劃分業務模塊
關于業務劃分,網上有很多方法論,事件風暴法、四色建模法等等,但是萬法不離其宗,那就是圍繞事件。以庫存系統舉例:庫存初始化(門店+sku庫存創建)、庫存數量維護(修改現貨數量、修改可售狀態)、扣減業務(購物車扣減、提單扣減、訂單取消扣減)、提醒業務(缺貨提醒)等。每一個事件都有獨立的鏈路軸,以及時間線可以形成閉環。
4)如何在原有模塊上拆分
大多數單體架構都是面向過程的設計,domain層充斥這個各種DTO、VO、BO,所以在層與層的數據交互過程中,大都是經歷了多次的POJO。另外就是service層充斥著和DAO層數據交互以及參雜了業務,而且嚴重違反了依賴倒置原則,整個層變得非常的沉重。這里舉個例子:
- 同層級間相互引用
- service層包含了太多業務邏輯,無法保障原子性
這里截取部分代碼片段作為案例,來講述下我們在拆分業務的過程中,需要做一些什么操作。
- 對service層進行CQS的拆分
- 把業務邏輯從原有的service層抽離,保障service方法遵循SRP原則。
- 新增業務聚合層(或者向六邊形架構里提到的adapter轉接口)來聚合service層的方法
①原始代碼
@Servicepublic class SkuMainServiceImpl implements SkuMainService {private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(SkuMainServiceImpl.class);@Resourceprivate SkuMainDao skuMainDao;@Resourceprivate ZkConfManagerCenterService zkConfManagerCenterService;@Resourceprivate ProductImagesService productImagesService;//同級互相引用,未遵循依賴倒置@Resourceprivate MqService mqServiceImpl;@Value("${system.group.environment}")private String systemGroupEnvironment;* 問題:service層聚合了太多業務邏輯 倒置上層方法沒辦法統一* @param skuMainInfoMQEntity* @throws Exceptionpublic void editorSaveProuct(SkuMainInfoMQEntity skuMainInfoMQEntity) throws Exception {try {SkuMainBean skuMainBean = skuMainInfoMQEntity.getSkuMainBean();if (skuMainBean == null) {throw new Exception("修改參數為空!");SkuMainBean originalSku = this.getSkuMainBeanBySkuId(skuMainBean.getId());if (originalSku == null) {throw new Exception("無效SkuId!");SkuMainBean skuMainUpdate = updateIsWeightMark(skuMainBean);SkuMainBean skuMainPre = this.get(skuMainUpdate.getId());// 系統下架的商品 強制下架if (skuMainPre != null && skuMainPre.getSystemFixedStatus() != null && skuMainPre.getSystemFixedStatus().equals(SystemFixedStatusEnum.SYSTEM_FIXED_STATUS_DOWN.getCode())) {skuMainUpdate.setFixedStatus(FixedStatusEnum.PRODUCT_DOWN.getCode());boolean flag = skuMainDao.editorProduct(skuMainUpdate);if (flag) {if (!zkConfManagerCenterService.isDefaultStoreStatisticsscore(skuMainBean.getOrgCode())) {SkuMainBean saveSkumainBean = this.get(skuMainUpdate.getId());// 防止未查到,把緩存覆蓋if (saveSkumainBean != null) {cacheSkuMainBean(saveSkumainBean);// 發送Sku修改MQsendSkuModifyMq(SkuModifyOpSourceEnum.MIX_UPDATE_SKU, originalSku, new SkuMainInfoMQEntity(skuMainUpdate));ProductImagesBean productImagesBean = productImagesService.queryImagesBySkuId(skuMainUpdate.getId());SkuMainInfoCheckMQEntity skuMainInfoCheckMQEntity = new SkuMainInfoCheckMQEntity();skuMainInfoCheckMQEntity.setSkuMainBean(skuMainUpdate);skuMainInfoCheckMQEntity.setProductImagesBean(productImagesBean);mqServiceImpl.sendJosMQ(skuMainInfoCheckMQEntity, MqTypeEnum.RcsKeywordsCheck);mqServiceImpl.sendJosMQ(skuMainInfoCheckMQEntity, MqTypeEnum.SenseKeyWordsCheck);} else {LOGGER.info("add open platform sku , not not not send mq! skuId = {}", skuMainBean.getId());} catch (Exception e) {LOGGER.error("修改商品信息失敗.e:", e);throw new Exception(e);
②CQS和SRP的改造,拆解GOD Classes
- Read服務
- Write服務
③抽離到業務層business層后
@Servicepublic class SkuMainBusinessServiceImpl implements SkuMainBusinessService {private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(SkuMainBusinessServiceImpl.class);@Resourceprivate ZkConfManagerCenterService zkConfManagerCenterService;@Resourceprivate MqService mqService;@Resourceprivate SkuMainReadservice skuMainReadservice;@Resourceprivate SkuMainWriteservice skuMainWriteservice;@Value("${system.group.environment}")private String systemGroupEnvironment;* 問題:service層聚合了太多業務邏輯 倒置上層方法沒辦法統一* @param skuMainInfoMQEntity* @throws Exceptionpublic void editorSaveProuct(SkuMainInfoMQEntity skuMainInfoMQEntity) throws Exception {try {SkuMainBean skuMainBean = skuMainInfoMQEntity.getSkuMainBean();if (skuMainBean == null) {throw new Exception("修改參數為空!");SkuMainBean originalSku = skuMainReadservice.getSkuMainBeanBySkuId(skuMainBean.getId());if (originalSku == null) {throw new Exception("無效SkuId!");SkuMainBean skuMainUpdate = skuMainWriteservice.updateIsWeightMark(skuMainBean);SkuMainBean skuMainPre = skuMainReadservice.queryDbById(skuMainUpdate.getId());// 系統下架的商品 強制下架if (skuMainPre != null && skuMainPre.getSystemFixedStatus() != null && skuMainPre.getSystemFixedStatus().equals(SystemFixedStatusEnum.SYSTEM_FIXED_STATUS_DOWN.getCode())) {skuMainUpdate.setFixedStatus(FixedStatusEnum.PRODUCT_DOWN.getCode());boolean flag = skuMainWriteservice.editorProduct(skuMainUpdate);if (flag) {if (!zkConfManagerCenterService.isDefaultStoreStatisticsScore(skuMainBean.getOrgCode())) {SkuMainBean saveSkumainBean = skuMainservice.queryDbById(skuMainUpdate.getId());// 防止未查到,把緩存覆蓋if (saveSkumainBean != null) {skuMainWriteservice.cacheSkuMainBean(saveSkumainBean);// 發送Sku修改MQskuMainWriteservice.sendSkuModifyMq(SkuModifyOpSourceEnum.MIX_UPDATE_SKU, originalSku, new SkuMainInfoMQEntity(skuMainUpdate));} else {LOGGER.info("add open platform sku , not not not send mq! skuId = {}", skuMainBean.getId());} catch (Exception e) {LOGGER.error("修改商品信息失敗.e:", e);throw new Exception(e);
④構建好的業務層
5)拆分小結
拆分到這里,業務層的劃分基本就比較清晰了,而且在這個增量整合底層代碼的過程中,面向過程的業務線也都梳理的比較清晰了,底層方法也都提取到了業務層收口,通過接口對外提供服務。那么接下來我們要面臨的問題就是,如何對具體的讀寫進行拆分。
3、基于CQRS打造分布式服務
上面我們也提到了,進行了整體功能的拆分,并沒有對具體的讀寫服務的拆分。在面向服務的場景下,功能里也是分讀服務、寫服務。那么我們有什么原則來指導讀寫服務的分離么?那就是CQRS的思想:命令職責查詢分離,不單單指代碼,同樣也是適用于服務。
1)優先拆分讀還是優先拆分寫
建議從拆分讀開始,因為讀服務相對于寫服務簡單一些,而且更容易提高系統對外服務的穩定性,寫服務的流程相對底層改動比較大,測試的周期也會比較長。在前期,動寫服務系統出問題的概率會比較大,所以綜合穩定性、擴展性來說,優先拆分讀服務是一個比較好的選擇。
2)CQRS的思想適合所有業務場景嗎
以庫存系統舉例,我們就按照CQRS的思想復刻一版,看看會出現什么問題。
- 每一次修改同步庫存寫入任務表
- schedule任務讀取任務表
- 把任務表的修改數據同步到Read服務中的redis中
在這個過程中,存在兩個問題:
- 大數據量任務同步的問題。也就是Event Bus同步redis的數據同步速度問題。
- 延遲問題。庫存要求實時性非常高,如果因為任務積壓導致的延遲,會讓庫存陷入困境之中。大量的庫存數量不對導致的超賣、超賣會瞬間擊潰業務。
所以每一個架構、每一種思想都是要結合業務去分析,我們可以借鑒CQRS的命令查詢職責分離,在面對業務系統部署的時候,不要死板的遵循固有的模式,要對現有的風格做出一定的取舍。所以,我們在應對庫存業務的時候,基于CQRS的風格創建出了庫存獨有的CQRS-StockCenter(名字自己起的 哈哈)
3)CQRS的活學活用:CQRS-StockCenter
- business業務層寫入命令
- writeService服務寫入讀服務Redis
- MQ消息作為異步數據補全寫入MySQL備份、寫入流水
庫存通過這套設計強依賴了Redis來作為庫存查詢、修改的中間件。保障了數據的強一致性。庫存在原有的服務上,分離了讀寫,保障了系統的CQRS命令職責查詢分離。
4)分布式的事務
我們大家都知道事務,簡單來說:事務由一組關聯操作構成,A->B->C ,如果執行到C報錯了,那么要回滾B->A。
對于本地事務來說,這個相對很簡單,如果你用了事務型數據庫比如mysql,并且不涉及多個數據源的情況下,保障事務的ACID非常的容易。
但是我們這里要提到的就是分布式的事務。因為系統拆分后,每個服務是一個獨立的模塊,負責一塊業務,那么在整個業務軸的流程下,各個服務節點的跨系統事務回滾成為了一個難題。業界也有一些方案,比如
- JTA(JAVA Transaction API即Java事務API)和JTS(Java Transaction Service即Java事務服務),為J2EE平臺提供了分布式事務服務。
但是這種需要滿足XA(兩階段提交)的標準,非常的重,而且現在的業務多樣性,很多數據庫比如:mongo ,并不支持XA的標準分布式事務,一些流行的中間件,比如RabbitMQ和kafuka也不支持分布式事務。
作者丨樹洞君
來源丨網址:https://juejin.cn/post/7121885160068349982