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