有關事務的內容,在前面我們已經不只談過一次,沒辦法,這是一個繞不開的話題。你敢說你在開發中不用到它?最起碼聚合進行序列化的時候得啟動一個本地事務吧。當然了,如果你用的是NoSQL,則另當別論,我們也就別抬那個槓了。你必須承認的事實就是無論現在的NoSQL資料庫怎麼發展,關係型的資料庫仍然是業務系統中的主流,最起碼在業務資料儲存的時候大部分公司都用到比如MySQL、SQL Server等。我們都知道的一個常識是在非特殊的業務系統中,讀操作的頻率肯定要大於寫的。那麼特殊的是什麼呢?比如普羅米修斯這種時序資料庫,用於日誌匯聚的Elasticsearch這種文件型資料庫,大多數情況下寫的頻率要多於查詢。回到一般性場景中,NoSQL作為關係型資料庫的補充其存在往往在讀場景中比例會更重,而只讀的話基本上也就不用考慮事務了。
本系列文章學術味道很淡,真要按嚴謹的學術形式來寫那就沒完了。討論一個事務都先從其起源、相關的著作開始,且不說我個人是否有這個能力,您肯定也不喜歡看。所以我們就糙一點,能說明問題即可。首先,我們先對事務做個簡單的分類:本地事務、全域性事務和分散式事務。下面的內容我會基於這三個分類進行展開並著重說明實踐中需要注意的事項。
一、本地事務
本地事務幾乎每個工程師都去用或至少用過,如果你是Spring的客戶相信應該經常使用這樣的註解“@Transactional”。沒錯,當後面的資料庫是關係型的時候Spring已經將事務的使用方式進行了極大的簡化,程式設計師只需要關心業務程式碼即可。這樣好也不好,優點不提,不好的方面就是它把事務的執行方式進行了隱藏。我估計很多人都不知道如何使用編碼的方式實現一個事務。想當年我寫.NET的時候,早期的ADO.NET版本也沒有什麼如“TransactionScope”這種關鍵字,都是通過呼叫SDK手寫事務。雖然麻煩了點,但是可以幫助理解書本中所涉及的有關事務的部分,最起碼有了較多的感性認識。
不小心透露出了年齡,誰沒年輕過啊。迴歸正文,本地事務講究四個特性:原子性、一致性、隔離性和永續性,簡稱ACID。這些其實開發人員也不用特別關心,資料庫全幫您實現了。這些特性的實現方式涉及到MVCC、Un Log、Redo Log等底層原理不在我們們這系列文章範圍內,需要的話請自行百度之。我們要說是在DDD落地時如何以及何時使用本地事務。看過前面文章的筒子們應該可以把答案脫口而出:聚合的儲存。實際使用的時候看您所在專案的技術層級,有條件的話搞一個工作單元,沒條件的話直接使用“@Transactional”就挺好。不要人家說什麼什麼模式好您就不加考慮的使用,那些都是有成本的,尤其是後續的運營成本,工程師寫程式碼的時候爽了真遇到問題就傻眼。總而言之一句話,不論您的聚合的規模是大是小,都必須在持久化的時候使用事務。一個物件的完整性需要在兩個方面共同進行保障:一是在業務層面,不論是聚合物件的建立還是在執行完各種動作後,其不變特性要始終進行維護。我們前面講過的工廠、驗證和聚合相關的內容都是為幹這個事情的。另一方面自然就是在資料的層次上面,在領域層費了半天的勁您卻在資料的層面不進行完整性保障,這不是搞笑呢嗎?我本人就見過很多人寫程式碼的時候只圖快,不論是物件導向還是程式導向,從不使用事務,相當的自信。
對上面的內容總結一下:1)本地事務適用於關係型資料庫;2)聚合的儲存與變更場景必須使用事務進行保障;3)事務由資料庫自身進行實現,可使用框架提供的方法來簡化使用。這裡特別提醒一下:使用Spring的工程師請務必注意使用事務的方式,有的時候你在方法上加的註解並不一定會生效,比如下面的程式碼就是比較典型的錯誤。具體細節還請找一些文章進行詳細瞭解。
@Service public class OrderService { @Transactional(propagation = Propagation.REQUIRED) public void pay(Long orderId, Money cost) { try { Order order = this.orderRepository.findBy(orderId); this.order.pay(cost); } catch (Exception e) { //(1)手動捕獲異常 logger.error(e.getMessage); } } public void pay2(Long orderId, Money cost) { Order order = this.orderRepository.findBy(orderId); if (order == null) { logger.error("no order"); return; } this.pay(order, cost); //(2)公有方法無事務宣告 } @Transactional(propagation = Propagation.REQUIRED) private void pay(Order order, Money cost) { order.pay(cost); } }
二、全域性事務
本地事務一般是一個應用一個資料庫,假如出現一個應用多個資料庫的情況那就是全域性事務該乾的事情了。請務必注意一點:理論上的全域性事務並沒有所謂“一個應用”的限制,理論上可以支援多服務多資料來源的情況。之所以這麼寫是因為我們們只講現實,理論太複雜扯扯就遠了。一般我們說的全域性事務其實是特指基於XA協議的“兩段式提交”(2 Phase Commit,2PC)以及其增強版本“三段提交(3PC)”。具體的原理已經脫離了我們的講述範圍,就個人的經驗來看因為“一個應用多個資料來源”在微服務時代已經很少見,再加上全域性事務的低效率以及無法應對CAP問題,現在幾乎很少用。人們看到了本地事務優秀的ACID特性也期望在分散式環境中可以通過全域性事務來實現同樣的效果,具體的結論與應用廣泛度相信您也看到了,如果興趣的話可以找一些開源的框架玩玩兒,別輕易線上上系統上使用。真實的專案中我有一個同事曾經偷偷著在專案中用過,但在上線前讓我直接打回並強制他使用基於訊息佇列的最終一致性。
三、分散式事務
第三類事務叫作分散式事務。隨著微服務架構大行其道,事務也從原本簡單的在只需要在資料庫層面上的實現功能轉移到架構乃至業務層面,種類也變得繁多起來。工程師無腦程式設計的難度度大大增加,這應該也是進入微服務時代後給人帶來的一種負面效果。常用的分散式事務閉著眼都能說上好幾種:TCC、Saga、本地訊息表、可靠訊息佇列最終一致性……。反正是一堆堆的技術,十分考驗程式設計師的記性力尤其是打算換工作的時候。雖然說種類不少,可您也別亂用,這些都有適合自己的場景的。
1、分散式事務特性
有人認為基於XA協議的2PC也屬於一類分散式事務,不過一般並不這麼定義。嚴格的分散式事務是指多個服務同時訪問多個資料來源時的事務處理機制。既然是分散式你就不能忽略CAP理論所提及的問題。事務講究ACID,而CAP理論則直接告訴你想在分散式環境下保障ACID根本沒戲,直接死了這個心更好。CAP的理論我們們不去證明,其正確性大家都知道的。我想告訴您的是在分散式系統中弱化C而保障AP是一種主流選擇,畢竟P是一種客觀存在問題你想忽略也不行。假如你當前做的系統是分散式的,您就得在前期仔細琢磨琢磨其面向的目標是業務的可用性還是資料的一致性。一般的電商平臺、娛樂網站、甚至是公司內部的一些系統都以AP為目標,你能想像因為網路分割槽造成了淘寶不能用嗎?它一定是先滿足使用者可使用再說出現資料不一致時怎麼進行業務補償;類似銀行、支付平臺這種系統則寧願中斷服務也不能出現不一致的情況,不過並不代表說類似支付寶這種平臺會中斷服務,而是在要求一致性的同時通過使用其它一些手段來保障其可用性,大於99.999%這種程度讓使用者根本感覺不到系統的中斷。基於CP的系統不僅會犧牲其可用性還會有一個比較典型的問題:節點越多可用性越差。Zookeeper是一種典型的基於CP的系統,選主的時候可能會高達幾十至上百秒的時間內系統不可用,放到大部分主流電商系統中就是事故了。
弱化C是大部分分散式系統的主流選擇,雖然無奈但我們仍有一些方法能保障弱一致性,不過叫“弱一致性”不好聽,人們都願意稱之為“最終一致性”。這種方式允許資料存在短暫的不一致性,最後在交付或輸出階段資料可以被及時修正過來。如果ACID的事務是“剛性事務”,那最終一致性的事務就被稱之為“柔性事務”。由於CAP問題,針對分散式事務的特性有人給總結出三點:基本可用(Basically Available)、柔性可用(Soft State)和最終一致性(Eventual Consistency),三點的含義您可在網路上查詢相關的文章。把三點的首字母取來是“BASE”,在英文中有“鹼”的意思; 而ACID在英文中是“酸”的意思。所以你在設計業務系統的時候要在酸鹼中去尋找平衡,別不論什麼情況都追求ACID。客戶和需求他們不懂技術,開發人員要主動思考到底在落地時使用什麼機制來實現事務,這東西搞不好就會有巨大的效能問題。
2、常用分散式事務
上一段我們列舉了幾個常被使用的分散式事務,但他們要怎麼用以及適用什麼場景呢?既然講到這兒了我們就大概說明一下,萬一平常用到了呢?DDD中最常被使用的事務是基於領域事件的Saga式事務,不過不代表您只能使用它,選擇多一點才有利於我們日常的設計。
2.1 TCC(Try-Confirm-Cancel)
熟悉2PC的您應該大概知道其工作原理,簡單來說就是每一個事務參與者在執行完事務後並不提交,死等著事務管理器下令才能做提交操作,在此期間資源只能進行鎖定。這種機制放在高併發的系統中其效能之低您都不用看什麼資料包告,用腳指頭都能想出來。TCC的概念其很簡單,我們不進行資源的鎖定。現在的問題不就是當某個事務出現問題後無法把其它已經完成的事務進行回滾嗎?那我們就在業務程式碼的層次讓每一個事務參與者都實現一些介面。Try:讓事務參與者嘗試執行業務,一般使用資料庫的事務,執行完成後就提交不做任何等待與鎖定。不過由於整個業務並沒有完成,所以在“Try”的階段一般會把資料變成一種中間狀態。比如在電商購物的業務中,常見的操作是下單後對庫存進行扣減。在使用了TCC後,庫存服務的“Try”操作就把庫存數減1,凍結的庫存數加1,使用“凍結”作為中間態而不是直接對庫存進行扣減。“Confirm”:讓事務參與者執行業務確認,上例中就是把凍結的庫存-1。“Cancel”:當某個事務參與者的“Try”失敗後執行回滾操作,上例中就是庫存數加1,凍結的庫數減1。當然了,原理是這樣的,實際在使用TCC的時候有很多的限制,比如“Confirm”操作不能失敗,必須支援冪等操作等。
通過對於TCC的解釋,相信您能想像得到這個機制其實真的挺麻煩的,各種的協調不說對業務的入侵也比較強(我認為是分散式事務中入侵性最強的)。所以您最好別自己寫程式碼開搞,這裡面需要考慮很多的事情,最起碼三個場景的來回撥度就夠您喝一壺了更別提什麼比“空回滾”、“懸掛”等問題。這個時代要求快速載入需求就不能總是從頭造輪子了。再說了,這麼有名的模式一定有現成的框架可用比如Seata,大廠出品質量還是可以的,但在系統的架構上你需要額外部署一兩套Seata服務才能成事; 寫程式碼的時候你只需要找個地方把業務進行統一的組織就行了,所有的排程以及上述提及的各類問題都有Seata這個碎催來解決。以上面的案例來說,您只要在比如訂單服務的某個方法中對“訂單下單的T”和“庫存減一的T”進行呼叫而不用操心另外兩個“C”的執行,完全是自動的。這裡的“某個方法”有個名稱:全域性事務發起方法。
既然談到了Seata框架,裡面還有一種AT模式,具體原理您自行網上查詢 。這東西用起來其實也非常的簡單,不過他的事務回滾是基於資料庫的,通過攔截SQL生成Undo Log和Redo Log,如果某事務參與者用的是NoSQL,那這種方式就不適合了。而TCC是基於使用者邏輯進行事務的提交與回滾,就相對要靈活很多,完全可以針對非關係型資料庫做支撐。還是那句話,強大也是有代價的,TCC對業務的入侵比較重,你得仔細考慮TCC中的每個方法,程式碼寫得多測試也麻煩,不像AT模式加個註解就可以放飛自我了。DDD落地的時候使用AT或TCC其實都行,前提是您已經知道了兩種模式在技術上的主要區別。另外,請務必注意在使用DDD的時候把這些框架的應該點放在應用服務層,包括“TCC”各方法的實現。
2.2 本地訊息表
通過翻閱網上的各類資料都談到了“本地訊息表”的創造者為eBay的Dan Pritchett。但個人對此保留意見,一是本方案其實並無任何的技術難度與其說是Dan Pritchett首創不如說是一種實踐。二是通過不斷重試的方式促進一個事務的完成在計算機領域中早已經是一個被廣泛使用的模式,有個專門的名稱——“最大努力交付”。
假設有如下圖的一個業務:應用A在完成本地事務後通過訊息的方式通知應用B進行自己的本地事務。觀察一下步驟1和2,很可能出現兩類情況:1)按圖的順序操作,本地事務提交成功但寫入訊息佇列失敗造成應用B無法得到通知;2)先寫入訊息佇列再執行本地事務,把兩步驟的順序交換一下,雖然能保證訊息被髮送至佇列但有可能本地事務失敗。應用B端收到了一個錯誤訊息,最為致命的是它還無法判斷訊息是錯誤的。
本地訊息表的思想是:你不就是怕本地事務和傳送訊息無法同時成功嗎?那我們就在寫本地的事務的時候把訊息同業務資料放到同一個事務中並一起存到資料庫中。反正只要本地的事務可以提交成功,那麼基於ACID中的“D”理論,訊息也會被持久化。此時的訊息有一個狀態是“未傳送”,我們可以在生產者側開啟一個後臺執行緒把“未傳送”狀態的資料傳送到MQ中。這種機制能保證訊息在生產端不會丟失,而消費者可以在消費成功後再通知生產者更新一下訊息的狀態為“已處理”。假如應用A與訊息佇列失聯,傳送訊息必然不會成功,但因為我們的後端任務是不間斷的進行傳送重試的,所以只要訊息佇列狀態恢復自然可把訊息投遞到佇列中;假如應用B處理後未能通知到應用A更新訊息的狀態,那應用A肯定還會把同一個訊息投遞給它的。
通過上面的流程,你應該可以看到在實踐中使用本地訊息表需要考慮兩個問題:1)應用A的後臺執行緒在進行訊息傳送重試的時候需要加一個限制,不能無限制的重發以減少應用的負擔;2)應用B也就是消費者需要保證訊息的冪等。
實踐中使用本地訊息表的場景其實已經不多,具體原因有兩個:1)開發進度過於緊張,沒人考慮訊息丟的問題,先上線再說,誰家的系統不得有點BUG;2)很多的訊息佇列中介軟體已經提供了類似的機制比如RockMQ的事務訊息,把本地訊息表的這種機制作到了MQ中,工程師只要提供相應的回撥介面MQ就能實現上面提到的“後臺執行緒”所達到的效果。仍然以上圖為例,假如應用A在步驟1和2執行成功後直接宕掉,RockMQ強大到可以對應用A的另外例項進行回撥以檢查事務是否完成。另外一個被廣泛使用的訊息佇列RabbitMQ也提供了類似的“Confirm”機制,通過使用非同步回撥介面的方式給工程師提供傳送重試機制,以可以將未成功傳送或路由的訊息寫入到資料庫或日誌中,為實現人工干預提供了切入點。
針對上述的案例有一個問題我們要考慮一下:假如步驟1執行成功後突然當機會出現什麼結果?也許訊息日誌是解決此問題的一人方向。
2.3 Saga
1987年普林斯頓大學的Hector Garcia-Molina和Kenneth Salem在ACM發表了一篇署名為“SAGAS”的論文首次提出了這個概念,旨在解決“長時間事務”效率問題。早期Saga主要是為了解決大事務長時間鎖資料庫的問題,直到近幾年才發展成作為分散式環境中的事務來使用。1987年啊,30幾年的東西也能迸發出新生,那個時候中國有幾臺PC?看來還是思想才能經得起時間的考驗。
Saga的原理其實很簡單,把一個大的事務分成若干的小事務,每個事務都是一種原子行為,因此並不會長久的鎖定資料庫;同時,還需要為每個小事務提供對應的補償行為以用於處理回滾操作。把Saga比喻成走路更形象一點,比如您想從A地走到B地一段距離10米的路程,如果中途遇到障礙就退回原點。走的時候一步肯定沒戲所以就分成10步走,這10步就是10個小事務。當走了5步遇到障礙後,我再一步一步的後退5步回來,這裡的後退5步就是補償行為。這裡需要注意一點,我們前進雖然不能把步子跨大了但回退則可以是一步能搞定的事情,具體要看業務的處理策略。
通過前面的TCC詳解我們可以看到案例中引入的所謂“凍結”狀態其實是一種資源預留的操作,但資源預留的操作並不是僅使用“凍結”這種模式,這個讀者需要明白。回到正題,與TCC相比Saga則不需要提前預留什麼資源,對應的補償操作也就簡單得多。不過實際使用的時候也需要考慮很多的元素比如Saga的故障恢復、事務的編排。此外,你還要重點考慮隔離性問題,否則很容易引入難以排查的錯誤;另外,Saga其實並不是僅僅是分散式事務那麼簡單,您可以想像這樣一個場景:一個業務需要四個服務共同協調才能完成,可能的呼叫關係也許如下圖一樣的混亂。這種模式很明顯的問題是服務間相互依賴嚴重及業務排程鏈路複雜。這還僅是4個服務,涉及到8個、10個,相信沒人願意維護這種複雜的呼叫關係,儘管使用了DDD方式進行了系統落地但卻沒有在其中獲益。
而在引入Saga後類似的呼叫關係則會變成下面這樣,各服務間所產生的依賴將不復存在而轉而依賴於Saga,它所帶來的優勢一目瞭然無需過多解釋。另外呢,我們使用上圖所示的業務除錯方式,肯定需要有一個全域性事務發起方法用於編寫排程程式碼。在採用了Saga後我們將可以責任進行轉移,讓它承擔事務排程者也稱之為業務指揮官的角色,來協調各事務參與者執行業務事務或回滾操作,到了下一章我們手寫一個Saga的基礎類庫,您可以見識一下它在事務之外所帶來的其它收益。
縱然Saga有千分的好,其所帶來的負作用也不能小覷:1)靈活性低,你需要詳細考慮每一個正向和反向的處理步驟。此外,一旦流程固化其後續的調整就會比較麻煩,至少需要把整個流程進行一次完整的走查。因為一般情況下我們可能會使用一些狀態資訊來決策流程的走向,而當業務有了變化我們需要加入新的狀態結點的時候,可能需要調整整個流程。此外,通常情況下我們會藉助於訊息佇列來串聯各子事務,如果訊息結構產生變化那可能需要調整多個事務參與者;2)成本高,設計成Seata中那種獨立的Saga管理器模式固然很好,可是需要花費大力氣並投入很多的精力和成本,有的時候是得不償失的。所以在實踐中要不您就直接使用現成的Saga框架比如:Seata、ServiceComb Saga、Axon等;要不您就和我一樣手寫一個簡單的,以較小的投入換取最大的收益。千萬別把目標定得太大,非得和某某框架對標,那些框架都是團隊產物而您一個人是無法與他們抗衡的;3)隔離性與原子性差,這兩上屬性基本上可以不考慮了,您又想處理長事務又想保障ACID,世界上沒那麼好的事兒。事務的隔離性必須通過業務狀態判定或業務規則來限制,Saga框架沒有提供解決方案;原子性幾乎也不用考慮,可以想象得到在某個事務的執行過程中您是可以讀取到部分成功的資料的。綜上所述,工程師在使用Saga的時候需要考慮上述的各類問題才能減少運營時的問題。
總結
本章重點說明了事務的種類以及我們經常使用的場景,技術性很強看似與DDD或領域模型關係不大。但你不能孤立的看待DDD,它僅僅是系統建設過程中的一個部分,優秀的系統是方方面面都優秀才行,從戰略設計到系統建設再到投入運營。事務、分散式鎖等技術性問題是在DDD實踐中不可或缺的,請繃緊腦子中的那個弦繼續我們的學習之旅。下一章我們手寫一個Saga,敬請期待。