前面已經介紹了DDD分層架構的實體和值對象,本文將介紹聚合以及與其高度相關的併發主題。程序員
我在以前已經說過,初學者第一步須要將業務邏輯儘可能放到實體或值對象中,給實體「充血」,這樣可讓業務邏輯高度內聚,併爲你提供業務邏輯的惟一訪問點。而聚合則是第二步,它將多個相關業務概念包裝到單一的概念中,從而大幅簡化系統設計,因爲受傳統數據建模思惟影響,我在聚合方面吃過大虧,花了將近一年才真正用起來,爲了你少走彎路,我會把一些要點總結出來供你參考。數據庫
聚合包裝一組高度相關的對象,做爲一個數據修改的單元。編程
聚合最外層的對象稱爲聚合根,它是一個實體。聚合根劃分出一個清晰的邊界,聚合根外部的對象,不能直接訪問聚合根內部對象,若是須要訪問內部對象,必須首先訪問聚合根,再導航到聚合的內部對象。緩存
聚合表明很強的包含關係,聚合內部的對象脫離了聚合,應該是毫無心義的,或不是你真正關注的,它是聚合的一個組成部分,這與UML中的組成聚合概念相近。安全
在剛開始接觸DDD時,咱們受到傳統數據建模思惟影響,根據範式要求設計出多張表,會很天然的每張表映射成一個實體,每一個實體都是聚合。我最初就是這樣使用的,幹了一年左右才醒悟過來,雖然這樣也能夠實現功能。架構
那麼這有什麼問題?併發
當把每一個表映射成獨立的聚合時,咱們在思考問題的時候,會把每一個表做爲獨立對等的概念進行思考,從而使你的大腦分不清主次,淹沒在錯綜複雜的表關係中。框架
如今若是系統有100張數據庫表,每張表以任意方式關聯,映射成100個聚合。你在進行思考時,以相同方式對待這100個聚合,很快就會頭暈目眩。有經驗的開發者知道經過切割模塊能夠下降複雜度,但各個模塊之間錯綜複雜的關係依然存在。ide
若是經過聚合的方式進行思考,狀況則大不相同。把高度相關的概念封裝到一個聚合中,而且將聚合中的對象儘可能使用值對象建模,不只能夠減小表數量,在概念上也更加簡單和清晰。如今假定仍是100張表,每5張表映射到一個聚合中,那麼具備20個聚合。咱們在思考問題時,整個聚合成爲一個獨立思考的單元,聚合內部的附屬對象已經成爲二等公民,你並不須要隨時想到它們。因爲聚合根外部對象只能直接訪問聚合根,因此複雜的關係被封裝到聚合內部。咱們如今只須要考慮聚合根之間的關係,整個系統設計會大幅簡化,系統的耦合度獲得控制。性能
另外一方面,聚合對倉儲產生影響。因爲倉儲表明的是聚合的集合,換句話說,每一個聚合應該擁有一個倉儲。若是每一個表都映射爲聚合,那麼會致使大量的倉儲,哪怕採用了依賴注入框架,整個系統的依賴複雜度仍是很是高。
若是一組相關對象須要知足某些業務規則,而且這幾個對象是離散的獨立對象,那麼實施一致性規則就很是困難。你可能須要在每一個用到的地方進行各類判斷,從而致使複雜度和冗餘。
我幾乎在每篇文章都給你反覆強調充血模型的重要性,是想激起你的注意。對於上面的問題,其實是須要一個統一的驗證點。可以給你提供惟一的業務邏輯訪問點的位置就在實體中,因此把這一組相關對象組合爲一個聚合,並在聚合上強制實施驗證規則能夠很好的解決問題。
從聚合的定義能夠看出,聚合不只是一組對象的抽象概念,並且還要作一些實際工做,即做爲一個總體更新數據。數據更新很容易就會碰到併發問題,聚合有義務提供相關支持來解決併發衝突,這是經過使用樂觀離線鎖來完成的。
併發是一個複雜的問題,僅瞭解一點樂觀離線鎖並不能順利完成相關工做。有些業務場景須要使用悲觀離線鎖進行補充。另外,數據庫也有本身的併發模型,一樣有樂觀和悲觀模式,那麼,聚合中使用的併發模型與數據庫中的併發模型關係怎樣?
對併發問題認識不清,輕則致使系統性能低下,重則致使數據錯亂,因此我將在本文對開發中可能碰到的併發問題進行簡單介紹。
不少程序員都喜歡追求設計的「正確性」,好比他會問,這一堆對象中哪一個纔是正確的聚合。只要是設計問題,因爲每一個人理解不一樣,確定答案不同。更有經驗的開發人員可以獲得更好的設計,更接近於「標準答案」,但那是創建在充分理解的基礎上。若是一個高手告訴你某個類應該是聚合,你卻沒有真正理解他的用意,這種狀況可能致使你設計出一個艱澀的系統。因此正確性是因人而異的,你應該因地制宜,而不是人云亦云。
另外,高手告訴你的聚合也不見得是合適的,由於他不必定了解你的業務實際狀況,聚合不只受邏輯上的概念影響,而且還受到併發、性能等因素制約。
下面介紹選擇聚合的通常性規律,能夠幫助你進行一些決策。
第一步,尋找具備包含或組成關係的相關對象。
某些對象有附屬的子項,好比訂單Order和訂單項OrderItem,它們具備包含關係,訂單包含訂單項的集合,或者能夠認爲一個訂單是由N個訂單項組成的。
找到的N組相關對象成爲聚合的候選,能不能成爲聚合須要通過後面的篩選。
第二步,考慮聚合內部的子對象集合,是否須要被聚合根外部的對象直接訪問,若是須要,將其從聚合中移出,並建模爲獨立聚合。
雖然一個對象可能從概念上被另外一個對象包含,但若是這種包含關係很弱,通常意味着子對象離開該聚合可能仍然有意義,外界對象但願可以直接和它打交道。
第三步,聚合內部致使併發衝突嚴重時,進行聚合拆分。
前兩步是從概念上選擇聚合,但聚合還受到其它因素影響,好比並發、性能等。
經過樂觀離線鎖能夠保證,兩次提交的聚合不會發生更新丟失。若是聚合只包含它自己,出現衝突的可能性就很小。但因爲聚合中每每包含集合,甚至是多個集合,因此各個集合之間的修改可能致使併發衝突很嚴重。
好比一個聚合中包含兩個實體集合,用戶A正在編輯聚合的第一組實體集合,與此同時,用戶B 開始編輯同一個聚合的第二組實體集合,第一我的提交成功,第二我的將更新失敗。
若是用戶常常須要對聚合內的不一樣集合進行單獨編輯,這就說明聚合中的概念可能具備獨立性,應該拆分出來。當聚合內部集合常常致使更新失敗時,果斷進行拆分是必須的。
設計一個大型聚合,除了可能常常致使併發衝突外,還可能致使低下的性能。好比酒店包含不一樣的房型,每一個房型包含不一樣的價格政策,每種價格政策的價格又不一樣,價格可能每隔幾天都會變化,若是把酒店做爲一個大型聚合,把其它都做爲集合包含進來,建立一個酒店聚合的開銷可能很驚人。
當聚合中的子對象集合的層級超過2級,好比子對象又包含孫對象集合,須要考慮是否會致使併發和性能問題。另一個聚合中包含子對象集合的數量也須要控制,好比一個聚合包含10個子對象集合,出現衝突的可能性就會很大。還有一個問題是,包含的子對象集合的元素個數也要考慮,好比一個商品,須要記錄商品的價格變更歷史,因爲價格是商品的一個屬性,因此可能會把價格變更歷史也放到商品中。若是價格常常變更,好比天天2次,一年就會產生700條記錄,能夠看到,有些子對象集合剛開始數據量不大,但會持續增長,這種狀況也須要進行聚合拆分。
若是一個聚合良好表達了一個總體概念,把附屬信息都封裝起來,而且沒有致使併發衝突常常發生,還性能良好,能夠認爲設計至關成功了,固然,這很不容易。
上面介紹了聚合的基本概念,因爲聚合更新與併發密切相關,下面將介紹應用程序開發中隨時可能碰到的併發問題,並討論相關解決方案。同時,將應用程序級別的併發模型與數據庫事務級別的併發模型進行比較,這樣能夠對併發解決方案有更清晰的認識。
若是多個操做同時集中在同一條數據上,就可能形成併發,致使數據不一致。併發產生的數據不一致現象主要有如下幾種:
1. 髒讀
當事務A正在更新數據,但還未提交,另外一個事務B獲取了正在更新的數據,發生髒讀。因爲當前數據處於中間狀態,若是事務A更新失敗,則發生回滾,將致使事務B讀取的數據是錯誤的。
髒讀有百害而無一利,應該儘可能避免。
2. 不可重複讀
事務A讀取了須要的數據,另外一個事務B對這些數據進行了更改,當事務A準備用這些數據進行計算時,實際上數據已經被改變了,這種狀況稱爲不可重複讀。換句話說,在同一個事務中,兩次發出相同條件的Select語句獲取的結果不一樣。
不可重複讀大部分時候都不是問題,在一次計算中,應該使用老版本的數據,仍是必須使用最新的數據進行計算,這是一個業務問題。
3. 幻讀
事務A使用範圍條件讀取了須要的數據,另外一個事務B在該範圍添加了一些數據,當事務A準備用剛纔獲取的數據進行精確統計時,但實際上還有漏網之魚,這種狀況稱爲幻讀。
絕大部分的系統都不須要考慮這個問題,避免幻讀只在某些高精度的場景下才須要,好比銀行對賬。
4. 丟失更新
前三種問題主要發生在數據庫事務級別,丟失更新則發生在應用程序業務級別。丟失更新的概念很簡單,就是後一我的把前一我的的操做覆蓋了,致使前一我的的更新丟失。
客戶Customer,它有三個屬性:標識Id,名稱Name,描述Description,其中一條數據爲:Id=1,Name=」a」,Description=」Hello」。
如今張三把Id爲1的客戶編輯界面打開,而後就吃飯去了。
李四對Id=1的客戶進行編輯,修改了Name爲「b」,保存成功。
張三吃完飯回來,繼續幹活,他把Description改爲」Haha」,保存以後,李四修改的Name=」b」又變回Name=」a」,李四的工做白乾了。
丟失更新是嚴重的數據修改錯誤,應該堅定避免。
5. 重複更新
重複更新是前面幾種問題的變體,因爲危害很大,因此我專門把它拿出來討論。
重複更新在概念上也很簡單,原本只容許執行一次的操做,如今執行了屢次。
考慮一個在線充值的場景,如今用戶在第三方支付平臺支付了100元,第三方支付平臺向你的系統發送了一個支付成功的確認,你的系統如今須要爲充值編號爲1對應的客戶餘額增長100。假定你開啓了一個數據庫事務來完成這個操做,正在執行的過程當中,第三方支付平臺系統抽筋,又向你的系統重複發送了一次支付確認請求,以下圖所示。
上面的過程執行完畢,你的系統給客戶充值200元,客戶很是滿意,覺得你買一送一。
從上面能夠看到,該程序員雖然不懂併發,但仍是有防護編程意識,在事務開始的最前面,經過充值狀態判斷來防止重複充值。
經過狀態判斷的方式通常能夠抵擋大部分的重複更新操做,只在運氣極背的時候碰上併發而致使錯誤,因爲併發極難重現,並且在數據量比較大時也不容易經過肉眼觀察出來,因此碰到這種問題通常都是不了了之。
若是你的系統須要和錢打交道,那麼增強併發知識的學習就很是有必要,這可讓你的公司少賠一點錢。
觀察前三種併發問題,都是讀和寫之間併發形成的。Sql Server數據庫爲了解決讀寫併發衝突,首先引入了悲觀併發模型,經過鎖進制來解決讀寫衝突。
前面說過,髒讀是必需要避免的問題。Sql Server數據庫在讀取前經過獲取共享鎖來解決這個問題,在更新數據時會獲取獨佔鎖,因爲共享鎖與獨佔鎖沒法共存,致使讀取數據時,更新被阻塞,或在更新數據時,讀取被阻塞,從而解決了髒讀。
雖然髒讀被解決了,但卻引入了讀寫阻塞的問題,在有一些數據量和併發量的系統上,性能可能表現得很低下。有一些程序員發現能夠經過添加鎖提示With(NoLock)得到更好的性能,這實際上是走回了老路。With(NoLock)鎖提示將默認的事務隔離級別(讀已提交)下降爲讀未提交,讀未提交事務隔離級別在讀取數據前不獲取共享鎖,因此不會阻塞,但它會致使髒讀。更好的方法是經過添加緩存機制,以及數據讀寫分離,將頻繁的查詢從主庫卸載。
從Sql Server 2005開始支持樂觀併發模型,它經過在修改或刪除數據前將數據的老版本存儲到臨時數據庫TempDB的版本存儲區來解決讀寫併發致使的不一致,並解決了讀寫阻塞問題。Sql Server爲樂觀併發提供了兩個新的事務隔離級別——快照隔離級別和讀已提交快照隔離級別。
快照隔離級別解決了不可重複讀和幻讀的問題,但須要犧牲更多的更新性能(由於在修改或刪除數據前須要先備份到版本存儲區)和TempDB存儲空間。因爲大部分系統不可重複讀和幻讀都不是大問題,因此通常推薦使用讀已提交快照隔離級別,它不只開銷更小,並且行爲上與悲觀模型更兼容。
悲觀併發模型還包括另外兩個事務隔離級別,可重複讀隔離級別經過把共享鎖生命週期延長到事務結束來解決不可重複讀的問題,而可序列化隔離級別經過鍵範圍鎖或表鎖來限制查詢範圍內的添加,解決了幻讀。這兩個事務隔離級別通常不要使用,由於將共享鎖的持續時間延長會致使更大範圍的阻塞,另外延長共享鎖持續時間可能致使轉換死鎖。能夠經過使用更新鎖或快照隔離級別來代替這兩個事務隔離級別。
在上面重複更新的例子中,進行充值狀態判斷是防止重複更新的關鍵,該範例之因此抵擋不住併發,是由於在獲取充值記錄時,默認獲取的是共享鎖,因爲多個事務都可以獲取共享鎖,且共享鎖默認生命週期很是短暫,因此讓另外一個事務有了可趁之機。解決辦法很簡單,在獲取充值記錄時添加鎖提示With(UpdLock),這樣在充值記錄L1上獲取到更新鎖,更新鎖的特色是隻有一個事務可以獲取更新鎖,生命週期持續到事務結束或成功轉換爲獨佔鎖,這樣在事務1獲取到充值記錄L1時,該記錄被更新鎖鎖定,事務2在開啓事務後,準備獲取充值記錄L1時就被阻塞,直到事務1提交事務。當事務1成功提交事務時,充值狀態已改成「已充值」,因此事務2進行判斷時就會跳出事務,後續充值不會被執行。
使用With(UpdLock)解決重複更新須要手工編寫存儲過程,對於面向對象開發很明顯不太適用。
聚合經過引入樂觀離線鎖能夠解決丟失更新和重複更新的問題。
觀察上面丟失更新的例子,張三把操做界面一打開就吃飯去了,請問如何經過數據庫事務解決這個問題?
數據庫事務在開啓以後,會鎖定大量資源,若是它在某些數據上獲取了獨佔鎖,在事務提交以前不會釋放,因此對事務的一個基本要求就是執行要快。很明顯,你不能在張三把界面一打開的時候,就開一個事務等待他輸入,在保存的時候再提交事務,由於他的輸入時間不肯定,可能致使一個很長時間的事務。
能夠看到,數據庫的併發模型也不是萬能的,對於上面的場景須要使用應用程序級別的併發控制。若是張三和李四不會常常修改同一條記錄,就可使用樂觀離線鎖來解決更新丟失的問題。
樂觀是指併發衝突機率很低,離線是指操做不是在同一個數據庫事務中完成的,好比打開編輯頁面時使用一個事務進行讀取,中間則與數據庫事務無關,在保存時會開啓另外一個事務進行更新,能夠看到這個過程是跨數據庫事務的操做。樂觀鎖的優點是最大化系統併發度。
樂觀離線鎖經過爲每行數據添加一個版本號來識別當前數據的版本,在獲取數據時將版本號保存下來,更新數據時將版本號做爲Where中的過濾條件,若是該記錄被更新,則版本號會發生變化,因此致使更新數據時影響行數爲0,經過引起一個併發更新異常讓你瞭解數據已經被別人更新。
樂觀離線鎖不只能夠解決丟失更新,並且一樣能夠解決重複更新。當第二個操做得到充值聚合時,若是充值狀態爲「未充值」,它繼續後面的步驟。第一個操做更新完成後版本號發生改變,當第二個操做試圖提交更新時,就會檢測到併發衝突。在併發異常處理中,甚至對第二個操做進行重試都是安全的,由於它從新獲取充值聚合時,充值狀態已經爲「已充值」,這樣就攔截了非法操做。能夠看到,重複更新的問題,無論用哪一種方法,都須要根據狀態判斷進行防護編程。
Sql Server數據庫提供了Timestamp的數據類型來支持樂觀離線鎖,每當有數據插入或更新,這個字段會自動生成版本數據。
與此同時,Entity Framwork也提供了IsRowVersion來配置樂觀離線鎖。
從上面的描述能夠看出,樂觀離線鎖是應用程序級別的併發模型,與數據庫的樂觀併發模型沒有什麼關係,雖然Sql Server數據庫的樂觀併發模型也有行版本的概念。這也意味着你在應用程序級別使用的是樂觀鎖,而Sql Server數據庫中卻使用的是悲觀鎖。
使用樂觀離線鎖的前提是併發衝突機率很低,若是衝突機率很高,使用樂觀離線鎖雖然不會致使系統數據錯亂,但會致使用戶十分抓狂,由於每次保存成功都須要運氣。
對於衝突機率很高的場景,須要引入悲觀離線鎖,下面繼續介紹。
一個100人的客服團隊,他們的工做是對某種申請單進行處理。客服處理一個申請單的時間大體5分鐘,每成功處理一個申請單可提成1元,每當用戶提交一個申請單,全部客服均可以看見。
一個編號爲1的申請單過來了,爲了爭取拿到那一元錢提成,100名客服爭先恐後的打開業務處理界面並開始授理。一名18歲的小妹眼明手快,只花了3分零2秒就提交了,「耶,1元到手」。另外一名小妹花了3分零8秒,提交的時候,系統彈出一個友情提示「因爲你的動做較慢,1元提成已經被人捷足先登了」。以後,連續不斷的失敗,你們只能感嘆本身運氣很差,另外有點走神,但願下一次能夠拿到提成。
故事說完了,該系統採用樂觀離線鎖設計,雖然整個操做沒有致使數據出錯,但整個客服團隊的辦事效率低得嚇人,近乎串行操做。
解決上面的問題,有兩個常見辦法。
一種辦法是經過一套自動調度策略開發一個申請單自動分配服務,申請單一來,未處理前就已經肯定好由誰處理了,這樣就不會形成激烈的競爭,使用樂觀離線鎖也許就能知足需求。
另外一種辦法是使用悲觀離線鎖,開發一個鎖管理器,鎖管理器須要在數據庫中建表,記錄鎖定時間,鎖定人,業務編號等信息,在申請單列表界面的每行都放一個「鎖定」按鈕,當第一我的點擊「鎖定」按鈕時,向鎖管理器添加鎖記錄,一旦被鎖定,其它人不能編輯操做界面或進行提交,界面控件應該處於凍結狀態,更嚴格的甚至不能打開編輯界面。
使用這種方案有一些問題,在點擊「鎖定」按鈕時可能存在併發問題,這能夠經過爲鎖管理器的業務編號創建惟一索引,保證不會在同一個業務編號上插入兩條鎖定記錄,固然,這要求你的業務編號多是Guid,否則惟一性須要添加更多屬性來識別。
既然容許鎖定,就須要有解鎖功能,解鎖能夠經過簡單的刪除鎖定數據來完成。當編輯完成時,還須要對該業務編號自動解鎖。也可能須要根據角色權限進行解鎖,當某個客服鎖定數據後就下班回家了,這致使其它人沒法處理,因此更高級別的小組長可能容許對他的下級鎖定的數據進行解鎖。
若是須要強大的鎖管理器,你能夠仿照Sql Server悲觀鎖進行設計,加入鎖模式、鎖粒度、持續時間等要素。
能夠看到,悲觀離線鎖,在實現和操做上並不簡單,它只應該成爲樂觀離線鎖的補充。
你能夠把樂觀離線鎖放到每一個實體中,但這樣太複雜,把樂觀離線鎖放到聚合根上,則整個聚合均可以得到併發控制能力,這稱爲粗粒度鎖。
另外,能夠在聚合根和映射的層超類型上將樂觀離線鎖封裝起來,稱爲隱含鎖。
因爲聚合根自己是一個實體,因此大部分的操做在實體層超類型中已經實現,惟一須要增長樂觀離線鎖的支持。另外還須要增長几個接口,用於後面的泛型約束。
namespace Util.Domains { /// <summary> /// 實體 /// </summary> public interface IEntity { } /// <summary> /// 實體 /// </summary> /// <typeparam name="TKey">標識類型</typeparam> public interface IEntity<out TKey> : IEntity { /// <summary> /// 標識 /// </summary> TKey Id { get; } } } namespace Util.Domains { /// <summary> /// 聚合根 /// </summary> public interface IAggregateRoot : IEntity { /// <summary> /// 版本號(樂觀鎖) /// </summary> byte[] Version { get; set; } } /// <summary> /// 聚合根 /// </summary> /// <typeparam name="TKey">標識類型</typeparam> public interface IAggregateRoot<out TKey> : IEntity<TKey>, IAggregateRoot { } } namespace Util.Domains { /// <summary> /// 聚合根 /// </summary> /// <typeparam name="TKey">標識類型</typeparam> public abstract class AggregateRoot<TKey> : EntityBase<TKey>, IAggregateRoot<TKey> { /// <summary> /// 初始化聚合根 /// </summary> /// <param name="id">標識</param> protected AggregateRoot( TKey id ) : base( id ) { } /// <summary> /// 版本號(樂觀鎖) /// </summary> public byte[] Version { get; set; } } } using System; using System.ComponentModel.DataAnnotations; using Util.Validations; namespace Util.Domains { /// <summary> /// 聚合根 /// </summary> public abstract class AggregateRoot : AggregateRoot<Guid> { /// <summary> /// 初始化聚合根 /// </summary> /// <param name="id">標識</param> protected AggregateRoot( Guid id ) : base( id ){ } /// <summary> /// 驗證 /// </summary> protected override void Validate( ValidationResultCollection results ) { if ( Id == Guid.Empty ) results.Add( new ValidationResult( "Id不能爲空" ) ); } } }
總結
3. 《實現領域驅動設計》第10章——聚合,它提供了不少關於聚合方面的最佳實踐和指導原則。固然我不必定嚴格按它的要求去作,我會用本身以爲最簡單的方法,當這些方法出現問題時,它給我指出方向。
本篇繼續發揚個人代碼少,廢話多的風格,因爲數據庫併發主題很是複雜,我也只挑了一些以爲對你們有幫助的部分簡單介紹。若是有錯誤的地方,還請高手批評指正。
.Net應用程序框架交流QQ羣: 386092459,歡迎有興趣的朋友加入討論。
謝謝你們的持續關注,個人博客地址:http://www.cnblogs.com/xiadao521/
下載地址:http://files.cnblogs.com/xiadao521/Util.2014.12.4.1.rar