從單體架構到分佈式架構,坑多 bug 多!

背景

我們在聊架構風格之前先明確一個問題,什麼是架構?我們爲什麼要選擇架構、用來解決哪些問題?

什麼是架構

書本定義:“軟件的架構是一種抽象的結構,他由軟件的各個組成部分和這些部分之間的依賴關係構成”。

我的理解是,架構就是根據業務選擇合適的技術、中間件,並且按照合適的設計模式對這些模塊,進行組裝來滿足業務特性的需求。

選擇架構風格的目的

我們選擇架構風格的初衷在於 “三更原則”(自己的理解) :更好的降本提效、更快的發版上線、更好的維護系統穩定性。

任何一個架構風格,都可以實現功能性需求,但是一個好的架構風格能在功能性需求之上,提升非功能性需求,那麼你可能會問,什麼是非功能性需求?舉例:擴展性、穩定性等等。

這裏我將會以我認知結合踩過的坑,來給大家詳細講一下,我們是如何從單體架構演進到分佈式架構,在向分佈式單體架構的演進的道路上,又如何進行的抉擇,以及爲什麼最後同時選擇了微服務架構 + 分佈式架構的原因。

接下來就結合一個系統來作爲案例,貫穿主線講解。首先來講一下,最初的單體架構的經歷和轉型。

單體架構

我們在系統創建之初,往往都是集中業務、單點部署系統,所有業務打一個包,快速上線。滿足了業務初期的快速發版上線,而且適合中小公司沒有自己的 paas 平臺,應對初期快速迭代的業務,開發、迭代、測試、發佈都是非常的便捷。那麼單體架構都有什麼類型呢?

單體架構的類型

單體架構也分爲,大泥團架構、分層單體架構、模塊化單體架構,他們的區別是什麼呢?

如下:

單體架構的優缺點

單體架構的優點:

在業務的初期,單體架構的優點,無論從哪個方面來說,都優於其他架構風格,但是隨着業務的增加、耦合,單體架構的缺點也逐漸暴露出來,這個也符合 “康威定律”。那麼單體架構的“後期” 會出暴露出哪些問題呢?

單體架構的缺點:

單體架構的這些缺點,其實影響的還是我上面提到的 “三更原則”。經過上面的鋪墊,相信大家已經對單體架構風格已經有了簡單的理解,那麼光有方法論是不行的,我們得結合項目以及代碼片段來加深理解,做到真正的應用。

接下來我就用一個庫存系統來進行串聯進行講解。先通過這張圖來了解下庫存系統是用來做什麼的?

如上圖:

單體架構的案例:庫存系統

最初的庫存代碼分層如下:

在最初很長的一段時間裏,我們部署了兩個單體服務,一個是 API 接口來保障上游的庫存查詢以及調用,另一個是 web 服務的後臺管理平臺。

這兩個單體服務很好的貼合了最初的業務迭代和發版速度,但是後來隨着業務的增加附加調用量的增加,單體服務的無論是從性能和穩定性都出現了較大的波動。

意料之外,情理之中的事故慘案

2015 年 6 月 26 日晚,也是一個促銷活動的前夕,庫存的 web 管理平臺掛了,原因就是大量庫存導入,服務器的內存不足導致機器宕機。

商家、運營無法通過導表的方式去維護庫存數量,在這之前已經經歷過了多次橫向擴容。還是出現了預料之外的流量和穩定性的問題。

而且在接下來的大促過程當中,庫存的單體服務 API 接口也承受了非常大的壓力。

一方面是上游調用方有很多,比如 APP 端首頁中的門店網關,查詢商品是否有庫存,是否展示。

購物車加車,也會查詢商品庫存的數量,提單則會對庫存數量進行扣減,乃至後續的訂單取消同樣也會調用庫存接口。

另一方面大的 KA 商家通過中臺對接對庫存進行操作,爲了儘可能的讓商家門店的庫存和線上平臺的庫存保持一致,減少線上線下庫存不一致導致的超賣、少賣。

中臺同步間隔時間都非常短,5 分鐘 - 10 分鐘就要全量同步一次。後續隨着入駐的商家增多,這個量級增長的也非常的迅速。於是我們開啓了單體服務向分佈式服務演進的大門。

分佈式架構

分佈式架構的優點:

這些優點正是我們當時庫存系統欠缺的,尤其是其中的可用性、系統容錯性,是我們系統演進迭代的首要目標。

《分佈式架構體系》中描述到,分佈式架構的核心理念也是按照(功能、業務、領域等)對系統進行拆分,通過合理的拆分結構,實現各業務模塊的解耦,同時通過系統級容錯設計,在廉價硬件基礎設施上構建起高可用、可擴展的開放技術體系。

所以我們庫存系統到底要按照什麼進行拆分,功能?業務?領域?在拆分之前我們一定要明確設計的目標,避免目標方向錯誤帶來的人力、成本資源的浪費。

在弄清楚目標之前,我們先了解下分佈式架構的缺點,通過了解這些缺點來衡量滿足我們目標的前提下,需要進行哪些方面的取捨,就如 CAP 原則一樣,只能滿足其中的兩個,AP 或者 CP。

分佈式架構的缺點:

庫存系統的特點,高可用、高併發、強數據一致性。接下來我們就來講一下,庫存是如何從單體架構向分佈式架構進行的轉型。

單體架構如何向分佈式架構轉型

因爲庫存面臨的最大的問題是穩定性,所以我們首先針對功能進行了拆分。

①功能拆分

這一步是相對簡單的,我們梳理出庫存面向服務的業務方進行服務劃分。這部分無需進行太多代碼的改造,一套接口通過變更不同的 group 別名,部署到不同的集羣即可。

拆分後,不同的服務應對不同的業務方,系統錯誤的隔離性好,不會說出現一損俱損的局面,穩定性上也有了保障。

在解決了穩定性的問題後,留給我們了一些喘氣的間隔,可以有時間去進行代碼的優化。

因爲剛纔也提到了,我們只是通過分佈式的集羣部署來解決容錯性的問題,但是代碼還是一套,臃腫的代碼也會拖慢我們的開發上線速度。

那麼接下來要進行的就是,對業務代碼的解耦,這塊也是難度最高的。我們是如何做的呢?

②業務拆分

業務拆分的思路是什麼呢?

基於上述拆分的思路,庫存系統又是如何劃分的業務模塊呢?動了哪些代碼?

③如何劃分業務模塊

關於業務劃分,網上有很多方法論,事件風暴法、四色建模法等等,但是萬法不離其宗,那就是圍繞事件。 

以庫存系統舉例:庫存初始化(門店 + sku 庫存創建)、庫存數量維護(修改現貨數量、修改可售狀態)、扣減業務(購物車扣減、提單扣減、訂單取消扣減)、提醒業務(缺貨提醒)等。每一個事件都有獨立的鏈路軸,以及時間線可以形成閉環。

④如何在原有模塊上拆分

大多數單體架構都是面向過程的設計,domain 層充斥這個各種 DTO、VO、BO,所以在層與層的數據交互過程中,大都是經歷了多次的 POJO。

另外就是 service 層充斥着和 DAO 層數據交互以及參雜了業務,而且嚴重違反了依賴倒置原則,整個層變得非常的沉重。

這裏舉個例子:

這裏截取部分代碼片段作爲案例,來講述下我們在拆分業務的過程中,需要做一些什麼操作。

如上圖:

原始代碼:

@Service
public class SkuMainServiceImpl implements SkuMainService {
    private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(SkuMainServiceImpl.class);

    @Resource
    private SkuMainDao skuMainDao;
    @Resource
    private ZkConfManagerCenterService zkConfManagerCenterService;
    @Resource
    private ProductImagesService productImagesService;//同級互相引用,未遵循依賴倒置
    @Resource
    private MqService mqServiceImpl;

    @Value("${system.group.environment}")
    private String systemGroupEnvironment;

    /**
     * 問題:service層聚合了太多業務邏輯 倒置上層方法沒辦法統一
     * @param skuMainInfoMQEntity
     * @throws Exception
     */
    public 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修改MQ
                    sendSkuModifyMq(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:

(1)Read 服務

(2)Write 服務

抽離到業務層 business 層後:

@Service
public class SkuMainBusinessServiceImpl implements SkuMainBusinessService {
    private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(SkuMainBusinessServiceImpl.class);

    @Resource
    private ZkConfManagerCenterService zkConfManagerCenterService;
    @Resource
    private MqService mqService;
    @Resource
    private SkuMainReadservice skuMainReadservice;
    @Resource
    private SkuMainWriteservice skuMainWriteservice;

    @Value("${system.group.environment}")
    private String systemGroupEnvironment;

    /**
     * 問題:service層聚合了太多業務邏輯 倒置上層方法沒辦法統一
     * @param skuMainInfoMQEntity
     * @throws Exception
     */
    public 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修改MQ
                    skuMainWriteservice.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);
        }
    }

構建好的業務層:

⑤拆分小結

拆分到這裏,業務層的劃分基本就比較清晰了,而且在這個增量整合底層代碼的過程中,面向過程的業務線也都梳理的比較清晰了,底層方法也都提取到了業務層收口,通過接口對外提供服務。那麼接下來我們要面臨的問題就是,如何對具體的讀寫進行拆分。

基於 CQRS 打造分佈式服務

上面我們也提到了,進行了整體功能的拆分,並沒有對具體的讀寫服務的拆分。在面向服務的場景下,功能裏也是分讀服務、寫服務。

那麼我們有什麼原則來指導讀寫服務的分離麼?那就是 CQRS 的思想:命令職責查詢分離,不單單指代碼,同樣也是適用於服務。

①優先拆分讀還是優先拆分寫

建議從拆分讀開始,因爲讀服務相對與寫服務簡單一些,而且更容易提高系統對外服務的穩定性,寫服務的流程相對底層改動比較大,測試的週期也會比較長。

在前期,動寫服務系統出問題的概率會比較大,所以綜合穩定性、擴展性來說,優先拆分讀服務是一個比較好的選擇。

②CQRS 的思想適合所有業務場景麼?

以庫存系統舉例,我們就按照 CQRS 的思想復刻一版,看看會出現什麼問題。

在這個過程中,存在兩個問題:

所以每一個架構、每一種思想都是要結合業務去分析,我們可以借鑑 CQRS 的命令查詢職責分離,在面對業務系統部署的時候,不要死板的遵循固有的模式,要對現有的風格做出一定的取捨。

所以,我們在應對庫存業務的時候,基於 CQRS 的風格創建出了庫存獨有的 CQRS-StockCenter(名字自己起的,哈哈)。

③CQRS 的活學活用:CQRS-StockCenter

如下:

庫存通過這套設計強依賴了 Redis 來作爲庫存查詢、修改的中間件。保障了數據的強一致性。庫存在原有的服務上,分離了讀寫,保障了系統的 CQRS 命令職責查詢分離。

④分佈式的事務

我們大家都知道事務,簡單來說:事務由一組關聯操作構成,A->B->C ,如果執行到 C 報錯了,那麼要回滾 B->A。

對於本地事務來說,這個相對很簡單,如果你用了事務型數據庫比如 mysql,並且不涉及多個數據源的情況下,保障事務的 ACID 非常的容易。

但是我們這裏要提到的就是分佈式的事務。因爲系統拆分後,每個服務是一個獨立的模塊,負責一塊業務,那麼在整個業務軸的流程下,各個服務節點的跨系統事務回滾成爲了一個難題。

業界也有一些方案,比如:JTA(Java Transaction API 即 Java 事務 API)和 JTS(Java Transaction Service 即 Java 事務服務),爲 J2EE 平臺提供了分佈式事務服務。

但是這種需要滿足 XA(兩階段提交)的標準,非常的重,而且現在的業務多樣性,很多數據庫比如:mongo ,並不支持 XA 的標準分佈式事務,一些流行的中間件,比如 RabbitMQ 和 Kafka 也不支持分佈式事務。

總結

留個伏筆:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ACtHNQtJTcIS_vNmmHgFGg