戰(zhàn):用事件驅(qū)動(dòng)解決業(yè)務(wù)與財(cái)務(wù)數(shù)據(jù)不一致)
去年年中我在梳理模擬項(xiàng)目X的訂單履約鏈路時(shí)被一個(gè)簡(jiǎn)單的問(wèn)題卡住了財(cái)務(wù)說(shuō)賬上還有120萬(wàn)應(yīng)收賬款業(yè)務(wù)說(shuō)早該催款的只有80萬(wàn)兩邊數(shù)字對(duì)不上。系統(tǒng)里明明都用了同一套訂單數(shù)據(jù)為什么算出來(lái)的結(jié)果會(huì)差這么多追下去才發(fā)現(xiàn)癥結(jié)出在底層數(shù)據(jù)模型上。財(cái)務(wù)模塊延續(xù)的是復(fù)式記賬那一套——憑證、科目、借、貸、余額。每一筆訂單都要經(jīng)過(guò)憑證模板轉(zhuǎn)換成會(huì)計(jì)分錄再由總賬系統(tǒng)匯總成余額。業(yè)務(wù)側(cè)的數(shù)據(jù)模型則圍繞訂單、發(fā)貨單、回款單這些業(yè)務(wù)單據(jù)。兩套模型之間的橋梁是幾十個(gè)字段映射和轉(zhuǎn)換規(guī)則。只要中間任何一張單據(jù)漏配、錯(cuò)配財(cái)務(wù)賬與業(yè)務(wù)臺(tái)賬就會(huì)悄悄分叉。這不是某一家企業(yè)特有的問(wèn)題而是傳統(tǒng)會(huì)計(jì)信息系統(tǒng)的結(jié)構(gòu)性毛病。復(fù)式記賬法誕生于商業(yè)貿(mào)易靠紙質(zhì)憑證的年代它擅長(zhǎng)的是記錄金額的增減而不是描述業(yè)務(wù)發(fā)生了什么。借貸分錄天然地站在結(jié)果的視角站在賬戶(hù)的視角站在一種已經(jīng)被抽象過(guò)、壓縮過(guò)的狀態(tài)上。它不關(guān)心訂單是幾點(diǎn)幾分被確認(rèn)、貨物由誰(shuí)揀選、物流簽收發(fā)生在哪個(gè)環(huán)節(jié)而恰恰是這些過(guò)程信息構(gòu)成了數(shù)字化業(yè)務(wù)系統(tǒng)的核心。正因如此我才開(kāi)始認(rèn)真研究rea。rea的全稱(chēng)是Resource-Event-Agent最早是會(huì)計(jì)信息系統(tǒng)領(lǐng)域的研究者提出的一個(gè)企業(yè)建模框架后來(lái)被大量用在業(yè)務(wù)流程建模、領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)甚至事件溯源架構(gòu)中。它主張用資源、事件、參與者三要素來(lái)描述業(yè)務(wù)并認(rèn)為會(huì)計(jì)賬簿中的數(shù)字應(yīng)該是這套業(yè)務(wù)模型的自然派生結(jié)果而不是需要另起爐灶單獨(dú)維護(hù)的一套數(shù)據(jù)。這篇文章會(huì)給你完整展示我如何用rea模型梳理一條從銷(xiāo)售到收款的全流程包括底層表結(jié)構(gòu)設(shè)計(jì)、與DDD和事件溯源的映射關(guān)系以及落地時(shí)踩過(guò)的四個(gè)坑。如果你也正在被業(yè)務(wù)數(shù)據(jù)與財(cái)務(wù)數(shù)據(jù)不一致折磨或者想在下一個(gè)項(xiàng)目里嘗試事件溯源架構(gòu)這篇文章應(yīng)該能幫你省下不少摸索時(shí)間。1. 數(shù)據(jù)分叉背后借貸式記賬為何拖累了數(shù)字化業(yè)務(wù)1.1 財(cái)務(wù)模塊與業(yè)務(wù)臺(tái)賬各說(shuō)各話(huà)我在模擬項(xiàng)目X里最先碰到的矛盾其實(shí)是很多企業(yè)系統(tǒng)的共性困境。訂單、發(fā)貨、回款在業(yè)務(wù)庫(kù)中都是標(biāo)準(zhǔn)的結(jié)構(gòu)化數(shù)據(jù)每一筆都有時(shí)間戳、有狀態(tài)、有操作人可財(cái)務(wù)模塊不看這些它只認(rèn)憑證。系統(tǒng)里有一個(gè)憑證生成器把每一個(gè)業(yè)務(wù)動(dòng)作翻譯成借xx科目、貸xx科目再匯總進(jìn)總賬。問(wèn)題就出在這個(gè)翻譯層上——翻譯規(guī)則一旦不完整業(yè)務(wù)側(cè)和財(cái)務(wù)側(cè)就再也對(duì)不上。比如一張銷(xiāo)售訂單在業(yè)務(wù)側(cè)已經(jīng)發(fā)貨但憑證生成器因?yàn)槟硞€(gè)狀態(tài)位沒(méi)更新沒(méi)有生成對(duì)應(yīng)的收入確認(rèn)憑證。此時(shí)業(yè)務(wù)臺(tái)賬顯示已發(fā)貨未回款財(cái)務(wù)賬上卻完全沒(méi)有這筆應(yīng)收記錄。等到月底對(duì)賬時(shí)兩邊就差出一大截。你可以在事后補(bǔ)一個(gè)批處理任務(wù)去修復(fù)但每次修復(fù)都是打補(bǔ)丁補(bǔ)丁越打越多系統(tǒng)越來(lái)越難維護(hù)。這種困境的根源是數(shù)據(jù)模型的方向性錯(cuò)誤。借貸記賬法和業(yè)務(wù)事件流是兩套完全不同的抽象層級(jí)。前者把業(yè)務(wù)動(dòng)作壓縮成借貸金額這一種表達(dá)方式后者需要保留業(yè)務(wù)動(dòng)作的完整語(yǔ)義——誰(shuí)、何時(shí)、做了什么、改變了什么。當(dāng)你用一套高度壓縮的數(shù)據(jù)結(jié)構(gòu)去承載需要全細(xì)節(jié)的業(yè)務(wù)邏輯時(shí)摩擦是必然的。1.2 rea是另一種組織業(yè)務(wù)事實(shí)的方式rea給我的第一個(gè)啟發(fā)就是它換了提問(wèn)方式。傳統(tǒng)模型問(wèn)的是這筆業(yè)務(wù)該記在哪個(gè)科目、該借還是該貸rea問(wèn)的是發(fā)生了什么事件、涉及什么資源、由誰(shuí)參與。這三個(gè)問(wèn)題看起來(lái)樸素建立出來(lái)的數(shù)據(jù)模型卻完全不一樣。它不預(yù)設(shè)賬戶(hù)這個(gè)抽象概念而是把業(yè)務(wù)事實(shí)本身作為建模的起點(diǎn)。會(huì)計(jì)意義上的借和貸在rea里不存在取而代之的是資源流入和資源流出而流入流出后得到的狀態(tài)——比如應(yīng)收余額、庫(kù)存數(shù)量——全部可以由事件推導(dǎo)出來(lái)。兩個(gè)模式放在一起對(duì)比會(huì)更直觀(guān)視角傳統(tǒng)借貸模式rea事件模式記錄對(duì)象賬戶(hù)、憑證、科目資源、事件、參與者核心問(wèn)題余額是多少發(fā)生了什么時(shí)間特性期末匯總、過(guò)賬結(jié)轉(zhuǎn)事件發(fā)生時(shí)記錄余額產(chǎn)生方式借貸匯總事件流投影業(yè)務(wù)追溯憑證到流水多跳映射事件天然關(guān)聯(lián)表格里最后一行是我感觸最深的地方。傳統(tǒng)架構(gòu)要做一筆應(yīng)收追溯通常是從科目余額出發(fā)找到憑證從憑證找到業(yè)務(wù)單據(jù)中間還可能經(jīng)過(guò)幾張結(jié)轉(zhuǎn)表rea模型則可以直接定位到哪一筆銷(xiāo)售事件產(chǎn)生了這筆應(yīng)收哪一筆收款事件核銷(xiāo)了它。這種追溯能力對(duì)數(shù)字化業(yè)務(wù)太重要了幾乎決定了一個(gè)系統(tǒng)能否被長(zhǎng)期維護(hù)。2. 拆開(kāi)rea資源、事件、參與者分別回答什么問(wèn)題2.1 資源不是所有資產(chǎn)都能進(jìn)模型很多人第一次接觸rea時(shí)最常犯的錯(cuò)誤就是把資源等同于會(huì)計(jì)科目或資產(chǎn)。實(shí)際上rea中的資源必須滿(mǎn)足兩個(gè)條件第一它必須處于企業(yè)的控制范圍內(nèi)或交換范圍內(nèi)第二它必須能夠在經(jīng)濟(jì)事件中被消耗、轉(zhuǎn)化、轉(zhuǎn)移或創(chuàng)造。舉個(gè)例子庫(kù)存商品是資源因?yàn)殇N(xiāo)售事件會(huì)讓它減少采購(gòu)事件會(huì)讓它增加現(xiàn)金和銀行存款是資源因?yàn)槭湛钍录?huì)讓它增加付款事件會(huì)讓它減少。但廠(chǎng)房、設(shè)備、商標(biāo)權(quán)這些在企業(yè)日常經(jīng)營(yíng)活動(dòng)建模中往往不會(huì)被當(dāng)作rea資源——它們不參與頻繁的業(yè)務(wù)事件流轉(zhuǎn)更多是作為一個(gè)固定的經(jīng)營(yíng)前提存在建模時(shí)把注意力放在它們服務(wù)的那些業(yè)務(wù)事件上即可。為了便于理解我把資源類(lèi)比成水庫(kù)里的水位。水位本身是靜態(tài)的存量你不關(guān)心它的絕對(duì)值而是關(guān)心它在什么時(shí)間點(diǎn)因?yàn)槭裁词录l(fā)生了多少變化。這個(gè)視角和傳統(tǒng)庫(kù)存管理不一樣傳統(tǒng)庫(kù)存管理一上來(lái)就要正確的當(dāng)前庫(kù)存量而rea更關(guān)心庫(kù)存量是如何一步步變成現(xiàn)在這個(gè)值的。2.2 事件只看直接改變資源狀態(tài)的動(dòng)作如果說(shuō)資源是水庫(kù)的水位那么事件就是打開(kāi)閥門(mén)或關(guān)上閥門(mén)的動(dòng)作它是整個(gè)rea模型的樞紐。rea對(duì)事件的定義有一套嚴(yán)格但不難把握的標(biāo)準(zhǔn)一個(gè)事件必須在一個(gè)明確的時(shí)間點(diǎn)發(fā)生必須能被記錄必須與至少一個(gè)參與者相關(guān)并且必須直接改變一個(gè)或多個(gè)資源的狀態(tài)。注意這里我特意強(qiáng)調(diào)直接。這個(gè)直接標(biāo)準(zhǔn)對(duì)建模粒度非常關(guān)鍵?;氐戒N(xiāo)售場(chǎng)景中客戶(hù)點(diǎn)擊了商品詳情頁(yè)就不算事件因?yàn)辄c(diǎn)擊動(dòng)作沒(méi)有直接改變?nèi)魏问芸刭Y源的狀態(tài)。而銷(xiāo)售訂單被確認(rèn)就算事件因?yàn)樗苯幼寧?kù)存商品從可售狀態(tài)變成預(yù)占狀態(tài)或者說(shuō)資源的狀態(tài)發(fā)生了變化。我把這個(gè)判斷標(biāo)準(zhǔn)總結(jié)成一個(gè)輔助公式如果一個(gè)活動(dòng)既不增加、不減少、不轉(zhuǎn)化、也不轉(zhuǎn)移任何資源那它就不應(yīng)該出現(xiàn)在rea模型中。用這個(gè)標(biāo)準(zhǔn)去評(píng)審建模草稿通常能很快發(fā)現(xiàn)哪些地方塞進(jìn)了太多偽事件。2.3 參與者內(nèi)部與外部劃分以及職責(zé)分離參與者是整個(gè)模型中容易被忽略但同樣重要的一環(huán)因?yàn)橘Y源的狀態(tài)變化從來(lái)不是自動(dòng)發(fā)生的背后必須有人的決策或行動(dòng)。rea把參與者分成兩類(lèi)內(nèi)部參與者和外部參與者。內(nèi)部參與者是企業(yè)內(nèi)部的角色例如銷(xiāo)售員、倉(cāng)管員、財(cái)務(wù)人員外部參與者是企業(yè)邊界之外的角色例如客戶(hù)、供應(yīng)商、物流承運(yùn)商。這種區(qū)分在建模時(shí)非常有用因?yàn)樗鼘?duì)應(yīng)了企業(yè)風(fēng)險(xiǎn)管理中的職責(zé)分離原則。一個(gè)合格的事件模型應(yīng)當(dāng)能回答這個(gè)事件由哪個(gè)內(nèi)部參與者觸發(fā)、對(duì)哪個(gè)外部參與者生效這天然支持了審批流、控制流的設(shè)計(jì)。比如銷(xiāo)售員錄入訂單、倉(cāng)庫(kù)員審核出庫(kù)、財(cái)務(wù)人員確認(rèn)收款三個(gè)步驟在三類(lèi)參與者之間形成了內(nèi)部控制鏈路。我曾經(jīng)在一個(gè)項(xiàng)目里見(jiàn)過(guò)反面教材業(yè)務(wù)單據(jù)上只有一個(gè)操作人字段客戶(hù)和員工統(tǒng)統(tǒng)往里面塞。這樣做導(dǎo)致審計(jì)時(shí)完全無(wú)法回答是誰(shuí)代表公司、是誰(shuí)代表客戶(hù)每一個(gè)關(guān)鍵操作的責(zé)任邊界都是模糊的。rea的參與者劃分能強(qiáng)制你把這個(gè)邊界建立起來(lái)這對(duì)系統(tǒng)合規(guī)性是非常大的加分。2.4 三類(lèi)關(guān)系把三要素編織成業(yè)務(wù)網(wǎng)絡(luò)光有三類(lèi)要素還不夠rea模型靠三類(lèi)語(yǔ)義關(guān)系把它們串起來(lái)存量-流量關(guān)系Resource-Event描述資源與事件之間的增減關(guān)系。一個(gè)事件可以使資源流入也可以使資源流出甚至可以同時(shí)流入流出例如調(diào)撥事件。觸發(fā)關(guān)系Event-Event描述事件之間的因果順序。如銷(xiāo)售訂單確認(rèn)觸發(fā)出庫(kù)發(fā)貨出庫(kù)發(fā)貨觸發(fā)收款登記。控制關(guān)系Event-Agent描述參與者與事件之間的責(zé)任關(guān)系。內(nèi)部參與者對(duì)事件負(fù)執(zhí)行責(zé)任外部參與者則作為相對(duì)方。這三類(lèi)關(guān)系就是rea的語(yǔ)法。構(gòu)建模型時(shí)我基本就是圍繞這三類(lèi)關(guān)系反復(fù)自問(wèn)這個(gè)事件改變了什么資源它由誰(shuí)觸發(fā)、影響了誰(shuí)它的前序事件和后續(xù)事件分別是什么一旦能順暢回答這三個(gè)問(wèn)題模型的雛形就出來(lái)了。3. 實(shí)操演練從銷(xiāo)售訂單到回款的一條完整事件鏈3.1 劃清業(yè)務(wù)邊界列出事件候選為了讓討論不懸空我設(shè)定一個(gè)具體的演練對(duì)象某零售企業(yè)的訂單到收款流程。范圍從客戶(hù)提交訂單開(kāi)始到企業(yè)收到貨款、完成資金核銷(xiāo)為止。在動(dòng)手之前我習(xí)慣先列一條候選活動(dòng)清單。這里不用管粒度對(duì)不對(duì)凡是流程中肉眼可見(jiàn)的動(dòng)作都先寫(xiě)下來(lái)客戶(hù)下單、銷(xiāo)售確認(rèn)訂單、倉(cāng)庫(kù)揀貨并出庫(kù)、物流配送、客戶(hù)簽收、客戶(hù)付款、財(cái)務(wù)核銷(xiāo)。這七個(gè)動(dòng)作是我準(zhǔn)備放進(jìn)模型的事件候選。劃定邊界這件事看似簡(jiǎn)單其實(shí)直接影響建模質(zhì)量。我曾經(jīng)見(jiàn)過(guò)有人把客戶(hù)發(fā)起退貨申請(qǐng)也包括進(jìn)訂單到收款的邊界里導(dǎo)致事件鏈變得很復(fù)雜。邊界清晰之后建模的最大價(jià)值反而體現(xiàn)在后續(xù)可擴(kuò)展性上——如果以后要加退貨流程只需要在events表里增加對(duì)應(yīng)的事件類(lèi)型而不用推翻已有的表結(jié)構(gòu)。3.2 用資源狀態(tài)變化過(guò)濾事件候選接下來(lái)用上一章那個(gè)直接改變資源狀態(tài)的標(biāo)準(zhǔn)逐個(gè)過(guò)濾候選動(dòng)作。候選活動(dòng)是否直接改變受控資源狀態(tài)是否進(jìn)入模型說(shuō)明客戶(hù)提交訂單否否待確認(rèn)狀態(tài)資源未變只是草案銷(xiāo)售確認(rèn)訂單是是庫(kù)存從可售變?yōu)轭A(yù)占倉(cāng)庫(kù)揀貨并出庫(kù)是是庫(kù)存實(shí)物數(shù)量減少物流配送否否配送過(guò)程中貨物仍歸企業(yè)狀態(tài)未變客戶(hù)簽收是是物權(quán)轉(zhuǎn)移企業(yè)失去商品控制客戶(hù)付款是是銀行存款增加應(yīng)收債權(quán)減少財(cái)務(wù)核銷(xiāo)否否匹配動(dòng)作資源本身無(wú)變化經(jīng)過(guò)篩選真正進(jìn)入rea模型的事件是四個(gè)訂單確認(rèn)、出庫(kù)發(fā)貨、客戶(hù)簽收、收款到賬。這個(gè)篩選過(guò)程經(jīng)常會(huì)顛覆直覺(jué)。我在實(shí)際項(xiàng)目里發(fā)現(xiàn)大多數(shù)團(tuán)隊(duì)建模不一致根源就在于把一些動(dòng)作和事件混為一談。比如把物流配送單獨(dú)建模成事件會(huì)讓后續(xù)的聚合邊界變得很別扭。3.3 補(bǔ)齊參與者和資源事件-資源-參與者對(duì)照事件確定后資源與參與者就很好找了。我把四個(gè)事件的信息補(bǔ)成一個(gè)對(duì)照表這是建模過(guò)程中最常用來(lái)評(píng)審的產(chǎn)物。事件涉及資源外部參與者內(nèi)部參與者訂單確認(rèn)庫(kù)存商品在庫(kù)可售變預(yù)占客戶(hù)銷(xiāo)售員出庫(kù)發(fā)貨庫(kù)存商品在庫(kù)減少承運(yùn)人倉(cāng)庫(kù)員客戶(hù)簽收在途商品所有權(quán)轉(zhuǎn)移客戶(hù)倉(cāng)庫(kù)員收款到賬銀行存款增加、應(yīng)收債權(quán)減少客戶(hù)財(cái)務(wù)人員這張表一出來(lái)整個(gè)流程的語(yǔ)義就清晰了。每個(gè)事件都在資源層面有明確影響、在參與人層面有明確責(zé)任體。后續(xù)無(wú)論是開(kāi)發(fā)接口、設(shè)計(jì)權(quán)限還是做審計(jì)追溯都能直接從這張表出發(fā)。這里有一個(gè)細(xì)節(jié)需要注意應(yīng)收債權(quán)在這個(gè)表里出現(xiàn)在收款到賬事件中但它并不是一個(gè)獨(dú)立的資源實(shí)體而是訂單確認(rèn)和收款到賬兩個(gè)事件共同形成的派生狀態(tài)。上表為了表達(dá)方便寫(xiě)了減少實(shí)際建模時(shí)不要把它作為資源落表。3.4 落成四張核心表SQL建表與索引思路在很多文本中rea被描述為純概念模型但落到工程上完全可行。我在模擬項(xiàng)目X里就是用四張核心表實(shí)現(xiàn)了這套模型agents表參與者檔案區(qū)分內(nèi)部/外部。resources表資源檔案例如庫(kù)存商品、銀行存款。events表核心事件表記錄事件類(lèi)型、發(fā)生時(shí)間、參與的內(nèi)部與外部參與者。event_resource_links表事件與資源的關(guān)聯(lián)表記錄方向流入/流出和數(shù)量金額。一個(gè)典型的表結(jié)構(gòu)定義大概是這樣以通用SQL風(fēng)格展示CREATE TABLE agents ( agent_id INT PRIMARY KEY, agent_type VARCHAR(20), -- internal / external agent_name VARCHAR(100) ); CREATE TABLE resources ( resource_id INT PRIMARY KEY, resource_name VARCHAR(100), unit VARCHAR(20) ); CREATE TABLE events ( event_id INT PRIMARY KEY, event_type VARCHAR(40), -- e.g. order_confirmed, shipment_made occurred_at DATETIME, internal_agent_id INT, external_agent_id INT, CONSTRAINT fk_events_internal FOREIGN KEY (internal_agent_id) REFERENCES agents(agent_id), CONSTRAINT fk_events_external FOREIGN KEY (external_agent_id) REFERENCES agents(agent_id) ); CREATE TABLE event_resource_links ( link_id INT PRIMARY KEY, event_id INT, resource_id INT, direction VARCHAR(10), -- inflow / outflow quantity DECIMAL(18,4), amount DECIMAL(18,2), CONSTRAINT fk_links_event FOREIGN KEY (event_id) REFERENCES events(event_id), CONSTRAINT fk_links_resource FOREIGN KEY (resource_id) REFERENCES resources(resource_id) );注意這張表里我沒(méi)有為銷(xiāo)售訂單發(fā)貨單回款單這三類(lèi)單據(jù)各建一套獨(dú)立結(jié)構(gòu)而是讓它們統(tǒng)一并入events表再通過(guò)event_type區(qū)分。這樣設(shè)計(jì)有一個(gè)直接好處任何業(yè)務(wù)查詢(xún)都只需要過(guò)濾事件類(lèi)型而不需要跨多張業(yè)務(wù)單據(jù)表做join。索引方面我通常會(huì)為event_type和occurred_at建聯(lián)合索引因?yàn)閹缀跛袌?bào)表和追溯查詢(xún)都會(huì)以這兩個(gè)字段為過(guò)濾條件。event_resource_links表則一定要對(duì)event_id建索引這直接決定了派生余額的查詢(xún)性能。3.5 財(cái)務(wù)報(bào)表由事件流派生應(yīng)收賬款的計(jì)算示例最令我驚喜的部分是財(cái)務(wù)報(bào)表不再需要過(guò)賬環(huán)節(jié)。以應(yīng)收賬款為例它的定義可以直接寫(xiě)成事件投影應(yīng)收余額 訂單確認(rèn)事件中流出的商品金額之和 - 已核銷(xiāo)的收款事件金額之和把這個(gè)邏輯落到數(shù)據(jù)庫(kù)里就是兩條SQLSELECT COALESCE(SUM(amount), 0) AS receivable_generated FROM event_resource_links l JOIN events e ON l.event_id e.event_id WHERE e.event_type order_confirmed AND l.direction outflow;SELECT COALESCE(SUM(amount), 0) AS receivable_settled FROM event_resource_links l JOIN events e ON l.event_id e.event_id WHERE e.event_type payment_received AND l.direction inflow;余額等于兩者的差完全不需要一張單獨(dú)維護(hù)的余額表。這聽(tīng)起來(lái)簡(jiǎn)單但背后帶來(lái)的架構(gòu)變化是巨大的賬務(wù)數(shù)據(jù)從被動(dòng)的結(jié)果存儲(chǔ)變成了由事件實(shí)時(shí)推導(dǎo)的活動(dòng)圖任何一筆余額波動(dòng)都能追蹤到源頭事件。4. 從模型到架構(gòu)rea如何與DDD、事件溯源結(jié)合4.1 事件即領(lǐng)域事件rea天然貼近事件溯源我在設(shè)計(jì)模擬項(xiàng)目X的時(shí)候只需要問(wèn)自己一個(gè)問(wèn)題系統(tǒng)的歷史數(shù)據(jù)是否足夠支撐任意時(shí)點(diǎn)的回放如果需要那么事件溯源幾乎是必然的選擇。而rea的事件恰恰就是事件溯源里需要的領(lǐng)域事件。兩者結(jié)合得如此自然是因?yàn)樗鼈兌荚诨卮鹜活?lèi)問(wèn)題歷史到底發(fā)生了什么而不是現(xiàn)在賬上還剩多少。事件溯源把系統(tǒng)當(dāng)前狀態(tài)看作對(duì)歷史事件流的累積投影rea則告訴我們事件流如何與資源和參與者關(guān)聯(lián)如何從事件流中恢復(fù)出業(yè)務(wù)語(yǔ)義。用rea建模再配事件溯源等于直接把業(yè)務(wù)事實(shí)作為系統(tǒng)的唯一真相來(lái)源。會(huì)計(jì)數(shù)字、庫(kù)存數(shù)量、客戶(hù)余額全都只是投影視圖可以隨時(shí)按需重建這就是數(shù)據(jù)與賬本不再分家的實(shí)現(xiàn)方式。4.2 聚合邊界跟隨事件鏈劃分而不是按單據(jù)劃分引入rea之前我在劃分DDD聚合邊界時(shí)經(jīng)常猶豫訂單聚合要不要包含發(fā)運(yùn)信息發(fā)運(yùn)聚合要不要包含應(yīng)收記錄如果各選各的又回到數(shù)據(jù)不一致的怪圈。rea給了個(gè)比較清晰的判斷依據(jù)一組事件如果它們之間存在觸發(fā)關(guān)系而且一個(gè)內(nèi)部參與者對(duì)另一個(gè)事件負(fù)責(zé)那么這些事件往往應(yīng)該落在同一個(gè)聚合邊界內(nèi)或者至少通過(guò)事件同步保持一致。以銷(xiāo)售到回款為例訂單確認(rèn)、出庫(kù)發(fā)貨、客戶(hù)簽收、收款到賬這條事件鏈共享同一個(gè)客戶(hù)、同一張訂單上下文放在一個(gè)聚合里會(huì)讓數(shù)據(jù)一致性最直觀(guān)代價(jià)是聚合略微變大拆成多個(gè)聚合也可以但需要接受最終一致性帶來(lái)的復(fù)雜度。聚合方案一致性復(fù)雜度適用場(chǎng)景全部事件放一個(gè)聚合強(qiáng)一致低-中訂單量不大單體系統(tǒng)訂單發(fā)貨放一個(gè)聚合收款獨(dú)立訂單側(cè)強(qiáng)一致收款最終一致中以銷(xiāo)售為中心的企業(yè)推薦起點(diǎn)每個(gè)事件一個(gè)聚合最終一致高高并發(fā)、復(fù)雜分銷(xiāo)網(wǎng)絡(luò)我在實(shí)際項(xiàng)目中傾向于選擇中間方案把訂單確認(rèn)與出庫(kù)發(fā)貨放在一個(gè)聚合把收款到賬作為另一個(gè)聚合兩者通過(guò)領(lǐng)域事件通知進(jìn)行協(xié)調(diào)。這樣既保持高頻路徑的強(qiáng)一致性又不至于讓聚合無(wú)限膨脹。4.3 庫(kù)存與余額從存儲(chǔ)字段變成派生投影在這個(gè)模型下庫(kù)存數(shù)量、應(yīng)收余額這些傳統(tǒng)意義上的主數(shù)據(jù)全部變成了派生視圖。你不再需要在庫(kù)存表里維護(hù)一個(gè)實(shí)時(shí)扣減的數(shù)字而是通過(guò)庫(kù)存事件流計(jì)算得出當(dāng)前庫(kù)存。這種思路恰恰是CQRS架構(gòu)所提倡的寫(xiě)模型中只追加不可變事件讀模型中維護(hù)面向查詢(xún)的投影。庫(kù)存的投影可以是一個(gè)物化表每次出庫(kù)或入庫(kù)事件發(fā)生后就增量更新也可以是一張按需重算的動(dòng)態(tài)視圖。使用派生視圖最大的好處是杜絕了分布式事務(wù)。兩個(gè)系統(tǒng)不需要在同一個(gè)事務(wù)里同步扣減庫(kù)存和記錄銷(xiāo)售只需要保證事件流的順序一致。這個(gè)轉(zhuǎn)變讓系統(tǒng)的擴(kuò)展方式從改數(shù)據(jù)庫(kù)變成了追加新的事件類(lèi)型變化很輕影響很可控。4.4 審計(jì)與內(nèi)控控制關(guān)系直接復(fù)用傳統(tǒng)財(cái)務(wù)系統(tǒng)在審計(jì)時(shí)最痛苦的就是追溯一張憑證對(duì)著一堆流水流水又對(duì)應(yīng)一堆業(yè)務(wù)單據(jù)中間任何一步丟了映射審計(jì)就卡殼。rea模型天然把業(yè)務(wù)單據(jù)、業(yè)務(wù)事件、資源和參與者全部串聯(lián)在一起因此審計(jì)追溯的路徑大幅縮短。審計(jì)人員可以直接從一條收款記錄反向跟蹤到對(duì)應(yīng)的銷(xiāo)售事件、出庫(kù)事件、甚至當(dāng)初的訂單確認(rèn)操作以及操作人。這種透明性不是靠事后補(bǔ)錄而是建模時(shí)就把控制和授權(quán)關(guān)系放進(jìn)了事件結(jié)構(gòu)里。控制關(guān)系在rea中還有一個(gè)額外的作用它天然支持權(quán)限設(shè)計(jì)。內(nèi)部參與者和外部參與者被放在不同的字段里系統(tǒng)中查某個(gè)客戶(hù)名下的所有訂單或者查某個(gè)銷(xiāo)售員名下已經(jīng)確認(rèn)但未回款的訂單都只是一次簡(jiǎn)單的事件過(guò)濾不需要額外維護(hù)復(fù)雜的權(quán)限視圖。5. 落地過(guò)程中踩過(guò)的四個(gè)坑5.1 坑一把資源建模成科目翻版我第一次嘗試用rea時(shí)想著資源不就是錢(qián)和貨嗎于是順手把應(yīng)收賬款建成了資源。這個(gè)選擇看起來(lái)方便實(shí)際上打亂了整個(gè)模型的語(yǔ)義。因?yàn)閼?yīng)收賬款不是一個(gè)基礎(chǔ)業(yè)務(wù)實(shí)體而是銷(xiāo)售事件與收款事件共同投影出的派生狀態(tài)。把它作為資源就等于在事件之外又維護(hù)了一套賬戶(hù)體系模型又會(huì)退回到復(fù)式記賬的老路上。正確做法是把應(yīng)收留給派生層資源層只保留客戶(hù)實(shí)際獲得的商品、服務(wù)以及貨幣資金這類(lèi)基礎(chǔ)實(shí)體。這個(gè)教訓(xùn)我后來(lái)又驗(yàn)證到庫(kù)存上庫(kù)存量同樣不是資源庫(kù)存對(duì)應(yīng)的基礎(chǔ)資源是具體的商品實(shí)體而庫(kù)存量是累計(jì)流入流出事件的投影。5.2 坑二事件粒度是最大的敗筆來(lái)源事件粒度是最難拿捏的部分。太小的事件會(huì)把系統(tǒng)變成流水賬例如前面說(shuō)的點(diǎn)擊了詳情頁(yè)一旦混入模型后續(xù)聚合邊界和報(bào)表語(yǔ)義都會(huì)被干擾。太大的事件則會(huì)掩蓋細(xì)節(jié)例如把客戶(hù)簽收和客戶(hù)付款合并成一個(gè)銷(xiāo)售完成事件你會(huì)丟失收款時(shí)間與簽收時(shí)間之間的差異這對(duì)現(xiàn)金流分析至關(guān)重要。我的經(jīng)驗(yàn)是每當(dāng)猶豫要不要把某個(gè)活動(dòng)設(shè)成事件就檢查它是否引起可識(shí)別資源的狀態(tài)變化以及它的發(fā)生時(shí)間對(duì)業(yè)務(wù)決策是否有價(jià)值。這兩個(gè)條件同時(shí)滿(mǎn)足才值得成為模型中的事件。我在項(xiàng)目里曾經(jīng)把倉(cāng)庫(kù)內(nèi)部移庫(kù)也建模成獨(dú)立事件后來(lái)發(fā)現(xiàn)這個(gè)動(dòng)作對(duì)財(cái)務(wù)和外部客戶(hù)沒(méi)有任何意義白白增加了事件量??车糁蟛樵?xún)效率明顯提升聚合邊界也清晰很多。5.3 坑三把業(yè)務(wù)規(guī)則塞進(jìn)事件模型rea擅長(zhǎng)表達(dá)發(fā)生了什么但并不擅長(zhǎng)表達(dá)應(yīng)該發(fā)生什么。折扣規(guī)則、信用額度校驗(yàn)、審批流閾值……這些業(yè)務(wù)規(guī)則可以在應(yīng)用層實(shí)現(xiàn)不需要塞進(jìn)rea模型。我在模擬項(xiàng)目X的早期就犯了把太多校驗(yàn)邏輯塞進(jìn)事件定義里的錯(cuò)誤結(jié)果每個(gè)事件類(lèi)型都要寫(xiě)一堆if-else模型反而被規(guī)則淹沒(méi)。后來(lái)把規(guī)則全部移到應(yīng)用服務(wù)層rea專(zhuān)心作為事實(shí)記錄層代碼結(jié)構(gòu)清爽了很多??梢赃@樣理解rea是數(shù)據(jù)庫(kù)的事實(shí)表業(yè)務(wù)規(guī)則是服務(wù)層的過(guò)濾器。把它們分層之后每個(gè)部分都能獨(dú)立演進(jìn)而不是耦合在一起。5.4 坑四歷史數(shù)據(jù)遷移與期初余額事件引入rea時(shí)最難的是處理歷史數(shù)據(jù)。舊系統(tǒng)里長(zhǎng)期積累的科目余額、未核銷(xiāo)憑證怎么遷入新模型我的做法是引入一個(gè)期初余額事件概念把歷史存量轉(zhuǎn)化為幾個(gè)特定事件。例如遷移日之前的所有應(yīng)收賬款匯總成為一條期初應(yīng)收確認(rèn)事件遷移日之前的所有庫(kù)存數(shù)量匯總成為一條期初庫(kù)存盤(pán)點(diǎn)事件。歷史數(shù)據(jù)轉(zhuǎn)化為的期初事件涉及資源舊系統(tǒng)庫(kù)存數(shù)量期初庫(kù)存盤(pán)點(diǎn)事件庫(kù)存商品舊系統(tǒng)應(yīng)收余額期初應(yīng)收確認(rèn)事件商品/服務(wù)待核銷(xiāo)舊系統(tǒng)應(yīng)付余額期初應(yīng)付確認(rèn)事件采購(gòu)服務(wù)/商品待結(jié)算舊系統(tǒng)銀行余額期初資金到位事件銀行存款這樣設(shè)計(jì)雖然看起來(lái)新增了一個(gè)特殊事件但換來(lái)的是所有后續(xù)邏輯的統(tǒng)一。舊賬不再需要特殊的歷史余額表而是和其他事件一樣參與投影運(yùn)算這大大簡(jiǎn)化了遷移代碼。這里有一個(gè)額外提醒期初事件一定要核對(duì)原系統(tǒng)的明細(xì)。曾經(jīng)有一次遷移我們把應(yīng)收余額只按總額導(dǎo)入結(jié)果后續(xù)老客戶(hù)發(fā)起退貨時(shí)找不到對(duì)應(yīng)的銷(xiāo)售事件可沖銷(xiāo)處理起來(lái)特別麻煩。后來(lái)我調(diào)整策略凡是能追溯到原單據(jù)的應(yīng)收盡量逐筆導(dǎo)入為獨(dú)立事件只有確實(shí)無(wú)法追溯的散單才以總額方式入賬。結(jié)尾在這個(gè)方向投入了很多時(shí)間之后我最后想分享幾個(gè)實(shí)踐層面的體會(huì)。rea并不是要取代會(huì)計(jì)學(xué)而是一種重新組織業(yè)務(wù)事實(shí)的方式。它最打動(dòng)我的地方是逼迫我從記賬結(jié)果退回到業(yè)務(wù)事實(shí)把每個(gè)動(dòng)作看成一條不可變的事件。這個(gè)思考習(xí)慣對(duì)我設(shè)計(jì)DDD聚合、選擇事件溯源架構(gòu)、甚至是評(píng)審別人的數(shù)據(jù)模型都有很直接的幫助。最后給你一個(gè)小技巧。下次評(píng)審任何業(yè)務(wù)模型時(shí)先問(wèn)三個(gè)問(wèn)題這里最核心的資源是什么哪些動(dòng)作直接改變了資源狀態(tài)是誰(shuí)執(zhí)行和推動(dòng)的如果團(tuán)隊(duì)討論始終繞不開(kāi)這三個(gè)問(wèn)題說(shuō)明業(yè)務(wù)建模往正確的方向走了一大步。哪怕不引入全套rea這套提問(wèn)方式也能讓你快速發(fā)現(xiàn)模型里的裂縫。