務(wù)建模:資源-事件-參與者模型實戰(zhàn)解析)
看到rea這個標(biāo)題我第一反應(yīng)不是“讀”也不是某個地區(qū)縮寫而是很多年前那個庫存項目——REAResource-Event-Agent。先跟你交個底這篇文章我按“資源-事件-參與者”建模來寫。如果你手里正捏著訂單、庫存、應(yīng)收應(yīng)付這類系統(tǒng)或者被借貸對賬、數(shù)據(jù)改來改去搞到頭大這篇文章能給你另一套建模思路把業(yè)務(wù)當(dāng)成一條事件鏈把余額當(dāng)成事件鏈的投影。前半部分講清楚REA到底在解決什么問題后半部分直接給出表結(jié)構(gòu)、SQL、踩坑經(jīng)驗?zāi)憧梢哉罩?. 為什么無頭無尾的“rea”會被我解釋成業(yè)務(wù)建模1.1 一次庫存對賬事故讓我重新審視“記賬”第一次接觸REA是因為一次庫存模塊重構(gòu)。上下游系統(tǒng)各自維護了一份“庫存量”每天都跑對賬任務(wù)。看起來原理很簡單庫存等于入庫減出庫但實際對賬時經(jīng)常差幾千件。查到最后發(fā)現(xiàn)倉庫有個流程是“先發(fā)貨后補單”補單時單據(jù)時間填的是前幾天財務(wù)系統(tǒng)按入賬時間記賬訂單系統(tǒng)按業(yè)務(wù)發(fā)生時間記賬。兩個時間不一致同一筆業(yè)務(wù)落到不同天里余額自然對不上。傳統(tǒng)做法是對賬日糾錯哪邊不平手動調(diào)一分錄。這種辦法治標(biāo)不治本因為系統(tǒng)里根本沒有一個“這盒物料在幾點幾分被哪個倉庫發(fā)出去了”的獨立事實。當(dāng)時我們最終得出的結(jié)論是與其在余額上反復(fù)找平衡不如把每一次資源變動都記錄成不可變的事件誰要余額誰自己去重放。這個思路就是REA的雛形。1.2 REA是什么三個單詞一條價值鏈REA等于Resource資源、Event事件、Agent參與者。這套模型把企業(yè)經(jīng)濟活動濃縮成三個問題什么東西在變發(fā)生了什么動作誰參與了這個動作舉一個很普通的例子一杯咖啡從豆子到顧客手里??Х榷故琴Y源咖啡豆入庫、烘焙、制作、售出、收款都是事件采購員、咖啡師、顧客都是參與者。庫存余額不是又一個實體而是“入庫事件總量減去出庫事件總量”的結(jié)果。這個直覺比一堆“借庫存貸應(yīng)付”的科目表直接不少。很多年前我第一次看到這三個詞心里也犯嘀咕這不是流水賬嗎等我真正把傳統(tǒng)借貸賬目拆開才發(fā)現(xiàn)事情沒那么簡單。1.3 它解決的核心問題把事實與結(jié)果分離REA最關(guān)鍵的一點是區(qū)分“事實”與“派生結(jié)果”。在傳統(tǒng)系統(tǒng)里庫存余額、應(yīng)收余額被當(dāng)成事實在REA里它們只是視圖。真實的事實是事件流。誰把余額表刪了都不怕事件還在就能重建誰把事件刪了那才叫事故。這個思想不是我的發(fā)明很早就有學(xué)者在企業(yè)建模領(lǐng)域討論過。后來我在技術(shù)評審里反復(fù)復(fù)用它的核心價值一是可解釋性任何余額都能從一個事件鏈推導(dǎo)出來二是可追溯性從發(fā)貨單能一路追溯到供應(yīng)商、采購單、付款三是可擴展性新增業(yè)務(wù)類型時不用改一堆寄存的邏輯只是新增一種事件類型。但很多人聽到這里會追問那借貸記賬法到底哪里不行下面我就認(rèn)真聊聊這個。2. 傳統(tǒng)借貸賬目體系的卡脖子點記錄的是結(jié)論不是事實2.1 借貸分錄的隱含假設(shè)借貸記賬法本身是個非常聰明的設(shè)計它把每筆經(jīng)濟業(yè)務(wù)拆成“資產(chǎn)來源”和“資產(chǎn)去向”兩個方向用“有借必有貸借貸必相等”保證賬目平衡。問題不在于這個設(shè)計本身而在于系統(tǒng)運轉(zhuǎn)久了之后大家把“科目余額”當(dāng)成了唯一事實源。在企業(yè)系統(tǒng)里總賬科目余額本質(zhì)上是聚合結(jié)果。聚合結(jié)果可以被重建但很多系統(tǒng)設(shè)計把它當(dāng)成不可替代的事實于是每次修數(shù)據(jù)都要做沖銷、補記、調(diào)賬。調(diào)來調(diào)去余額看齊了但業(yè)務(wù)事實被攪成一鍋粥。REA模型并不否定記賬功能它只是把借貸背后的經(jīng)濟事件、資源、參與者重新顯式建模。打個比方借貸體系相當(dāng)于直接放映電影截圖REA則把拍攝素材、剪輯腳本、演員分工都存了下來。截圖可以隨時從素材里再生成前提是素材不能被隨意覆蓋。2.2 多幣種、多倉、多租戶下的復(fù)雜度爆炸當(dāng)業(yè)務(wù)只有一臺收銀機時借貸表也夠用。但業(yè)務(wù)一復(fù)雜整個鏈條就開始卡脖子。拿多倉庫舉例A倉發(fā)貨到B倉數(shù)量只是從一個倉庫挪到另一個借貸里卻要先記“A倉庫存減少”再記“B倉庫存增加”。商品價值不一樣時還要補一筆“庫存差異調(diào)整”。業(yè)務(wù)邏輯每多一層這種調(diào)整分錄就多一類。多幣種更痛苦。一個訂單用美元成交、用人民幣對賬、中間還要評估匯兌損益借貸系統(tǒng)里的科目組合成倍增加。其實每種幣種對應(yīng)的現(xiàn)金是一個資源匯兌損益不過是一個特殊事件導(dǎo)致的資源價值重估。如果用REA事件表加一個幣種維度再定義一次重估事件即可。多租戶場景同樣如此。傳統(tǒng)建模往往把客戶編號掛在一個大維表里再用過濾條件把所有查詢包一層。REA天然以Agent區(qū)分主體授權(quán)關(guān)系本身就是模型的一部分不需要額外的租戶魔改。2.3 用事件對偶替代借貸符號再看深一層REA里有個概念叫對偶關(guān)系。我理解它就是在回答“為什么會有借貸相等”在現(xiàn)實經(jīng)濟行為中一個資源的流出往往伴隨另一個資源的流入。賣出咖啡是咖啡豆庫存流出對應(yīng)現(xiàn)金流入采購生豆是供應(yīng)商那邊的資源流出對應(yīng)我們這邊現(xiàn)金流出。兩個事件必須配對這個配對關(guān)系被顯式建模成事件與事件之間的鏈接。一旦把對偶關(guān)系建模出來系統(tǒng)就比傳統(tǒng)借貸表多了一個能力可以在不依賴會計科目的情況下推導(dǎo)出任意兩個資源之間的因果聯(lián)系??促~本時看到的不是孤立的金額而是資金怎么從A跑到B再到C的路徑。這也是我之前覺得“這不就是流水賬”不準(zhǔn)確的原因它確實是流水賬但是一張把流水中每一滴水的來源、去向、責(zé)任人都標(biāo)注清楚的流水賬。下一節(jié)我們把它的邊界真正落到表結(jié)構(gòu)上。3. 資源、事件、參與者三個構(gòu)件的邊界怎么拿捏3.1 Resource不是產(chǎn)品目錄而是價值載體Resource在REA里定義得很寬庫存商品、現(xiàn)金、應(yīng)收賬款、固定資產(chǎn)、知識產(chǎn)權(quán)甚至外包團隊的時間盒都可以是資源。判斷標(biāo)準(zhǔn)很簡單第一它有價值第二它能被企業(yè)計劃和控制第三它能因為一個事件增加或減少。對實施者來說最常見的誤區(qū)是把資源表當(dāng)成產(chǎn)品目錄。產(chǎn)品目錄里那些沒有庫存核算價值的元數(shù)據(jù)屬性應(yīng)該放在單獨的產(chǎn)品維表里資源表只管“算得清增減”的賬。比如同款咖啡豆有“在庫”和“在途”兩種狀態(tài)與其建模成兩個資源類型不如建模成同一資源類型下兩個獨立資源實例或者讓事件的時間戳自然表達(dá)在途與否。狀態(tài)永遠(yuǎn)是視圖不要讓它輕易滲透到資源主數(shù)據(jù)里。我在實際項目里養(yǎng)成了一個習(xí)慣每新建一個Resource之前先問自己一句未來會不會為它單獨創(chuàng)建Event如果不會它可能就是另一個資源上的屬性而不是獨立資源。這句話幫我砍掉過不少設(shè)計沖動。3.2 Event不可再分的經(jīng)濟動作不是操作日志Event是最容易被高估也最容易做錯的一層。它要求記錄的是“不可再分的經(jīng)濟動作”比如一次收貨、一次發(fā)貨、一次收款、一次轉(zhuǎn)賬。判斷標(biāo)準(zhǔn)可以拆成幾條是否能讓某個Resource的數(shù)量或價值發(fā)生變化是不是在真實世界里已經(jīng)客觀發(fā)生拆成更細(xì)的子事件后是否還有業(yè)務(wù)意義舉個例子“點擊下單”這個動作本身不是經(jīng)濟事件它只是表達(dá)購買意圖“創(chuàng)建訂單”也不是經(jīng)濟事件訂單屬于契約真正的事件是“倉庫發(fā)出5箱貨”。很多團隊設(shè)計表時把訂單創(chuàng)建、訂單審核、訂單配送、訂單完成全部塞進一張大日志表美其名曰事件驅(qū)動。這會讓后續(xù)做余量計算時非常痛苦因為一半記錄根本不該影響任何資源。事件表里最需要注意的是方向與事件類型的分開存儲。方向INFLOW/OUTFLOW是計算余額必須的信號類型是業(yè)務(wù)語義。同一種類型在不同場景下可以有不同的方向比如“銷售發(fā)貨”對庫存是OUTFLOW對銷售費用可能是INFLOW。把兩者塞進同一個字段后期會帶來數(shù)不清的隱患。3.3 Agent責(zé)任歸屬與授權(quán)邊界Agent解決的是三個問題誰發(fā)起了事件誰承擔(dān)了結(jié)果誰授權(quán)了這件事落地時最常用的做法是把參與者區(qū)分為內(nèi)部參與者與外部參與者。內(nèi)部參與者就是本系統(tǒng)內(nèi)的員工、角色外部參與者就是客戶、供應(yīng)商、承運方。權(quán)限控制層面Authorization表專門在參與者和事件類型之間建立授權(quán)關(guān)系。比如倉庫主管可以授權(quán)發(fā)貨事件采購員可以發(fā)起采購合同。這個設(shè)計比單純?nèi)M現(xiàn)成權(quán)限框架里更貼合業(yè)務(wù)事實因為業(yè)務(wù)授權(quán)不只是“能不能點按鈕”而是“能不能對該資源執(zhí)行該經(jīng)濟事件”。經(jīng)驗之談Agent表不要企圖記錄所有組織架構(gòu)。部門層級、匯報線是組織信息不是經(jīng)濟參與者。把部門硬塞進Agent里短期看只是多存幾個字段長期看部門調(diào)整時你根本不知道要不要新建一條資源變更事件。4. 一個銷售庫存業(yè)務(wù)的Schema從事件反推賬本4.1 先建Event表而不是先建余額表REA落地時我推薦從Event表起步。這跟傳統(tǒng)系統(tǒng)正好相反傳統(tǒng)系統(tǒng)先定庫存余額表然后為了追溯才去加流水表結(jié)果流水表永遠(yuǎn)滯后永遠(yuǎn)對不上。如果先定義事件所有報表都是從它派生出來的投影表加字段本質(zhì)上是加一種新事件類型改動可控。下面這組DDL非常精簡適合銷售庫存場景起步。實際生產(chǎn)環(huán)境還需要補充租戶ID、數(shù)據(jù)版本號等字段這里先保留最核心的部分。CREATE TABLE resource ( resource_id BINARY(16) PRIMARY KEY, resource_name VARCHAR(64) NOT NULL, resource_type ENUM(ITEM, CASH, ACCOUNT, SERVICE) NOT NULL, unit VARCHAR(16) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE ); CREATE TABLE agent ( agent_id BINARY(16) PRIMARY KEY, agent_name VARCHAR(64) NOT NULL, agent_type ENUM(INTERNAL, EXTERNAL) NOT NULL, is_customer BOOLEAN NOT NULL DEFAULT FALSE, is_supplier BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE event ( event_id BINARY(16) PRIMARY KEY, event_no VARCHAR(32) NOT NULL UNIQUE, event_type VARCHAR(32) NOT NULL, direction ENUM(INFLOW, OUTFLOW) NOT NULL, resource_id BINARY(16) NOT NULL REFERENCES resource(resource_id), quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4), occurred_at DATETIME NOT NULL, recorded_at DATETIME NOT NULL, internal_agent_id BINARY(16) NOT NULL REFERENCES agent(agent_id), external_agent_id BINARY(16) REFERENCES agent(agent_id), UNIQUE KEY uk_dup_guard (event_no, resource_id) );注意event_no這個唯一鍵生產(chǎn)環(huán)境里對應(yīng)的是上游單據(jù)唯一號。兩筆相同的業(yè)務(wù)如果被重復(fù)錄入數(shù)據(jù)庫層會直接拒絕這是冪等的第一道防線。第5章我會細(xì)聊這條設(shè)計為什么重要。4.2 當(dāng)前庫存怎么算一條SQL直接求和需要當(dāng)前庫存時不需要去讀任何余額表直接把Event表按Resource和方向求和即可。SELECT r.resource_name, SUM(CASE WHEN e.direction INFLOW THEN e.quantity ELSE -e.quantity END) AS current_stock FROM event e JOIN resource r ON e.resource_id r.resource_id WHERE e.event_type IN (PURCHASE_ARRIVAL, SALES_SHIPMENT, STOCK_ADJUSTMENT) GROUP BY r.resource_id, r.resource_name;這里我把查詢限定在影響庫存的事件類型里而不是把Event表里所有記錄都拉進來。很多新手會把所有事件全加一遍結(jié)果把收款、賬務(wù)調(diào)整之類的INFLOW也加進了庫存。所以在設(shè)計事件類型時應(yīng)該給每類事件定義“它影響哪類資源、方向是什么”形成一張事件類型注冊表后續(xù)所有查詢都依賴這張表。4.3 應(yīng)收應(yīng)付自動派生發(fā)票和收款本身就是一進一出REA還有一個非常好用的紅利應(yīng)收賬款余額不是單獨維護的而是從發(fā)票事件和收款事件里推導(dǎo)出來。每張發(fā)票是一個INFLOW事件經(jīng)濟資源是“應(yīng)收賬款”每次收款是一個OUTFLOW事件把應(yīng)收賬款減掉。合在一起就是當(dāng)前客戶欠款SELECT a.agent_name AS customer, SUM(CASE WHEN e.event_type INVOICE THEN e.quantity * e.unit_price ELSE 0 END) - SUM(CASE WHEN e.event_type PAYMENT THEN e.quantity * e.unit_price ELSE 0 END) AS outstanding FROM event e JOIN agent a ON e.external_agent_id a.agent_id JOIN resource res ON e.resource_id res.resource_id WHERE res.resource_type ACCOUNT AND e.event_type IN (INVOICE, PAYMENT) GROUP BY a.agent_id, a.agent_name;這種設(shè)計下退款就是一筆新的負(fù)數(shù)量事件而不是去修改原事件。歷史庫存增量沒有被破壞審計鏈路也清晰得多。5. 報表、審計與事件溯源從REA得到的三個紅利5.1 報表不再是“查余額”而是“重放行為”傳統(tǒng)報表是查一張快照表REA是重放一段行為流。我們把余額理解成暫停幀我要看昨天的庫存不依賴定時快照只需要加一個時間過濾條件重放到昨天那一刻。這帶來一個額外能力任何業(yè)務(wù)問題都能回到原始行為去排查。比如財務(wù)問“這批貨物價值怎么波動這么大”傳統(tǒng)系統(tǒng)只能看到調(diào)賬后的金額REA里可以把入庫、出庫、報損、移倉事件全部攤開每一步寫的是什么原因一目了然。它天然適合做審計追蹤因為事件流本身就是一筆完整的行為賬。5.2 不可變事件、冪等鍵、業(yè)務(wù)時間戳三個紅線必須一起立想享受REA的好處前提是把底層規(guī)則立住。我在項目中始終堅持三條紅線。第一事件表只做INSERT禁止UPDATE和DELETE。哪怕錄錯了也只能再補一條調(diào)整事件把錯誤影響抵消掉。聽起來繁瑣但換來的是一勞永逸的簡單任何時刻的事件流都是不可篡改的歷史。第二冪等鍵必須在數(shù)據(jù)庫層兜底。最穩(wěn)妥的方式是給業(yè)務(wù)事件號加唯一索引再配合外部消息或批處理任務(wù)里的去重邏輯。沒有唯一索引再好的應(yīng)用層去重也可能在并發(fā)下漏掉。第三業(yè)務(wù)時間戳與系統(tǒng)入賬時間戳分開存。occurred_at記錄真實業(yè)務(wù)發(fā)生時間recorded_at記錄系統(tǒng)落庫時間。報表按業(yè)務(wù)時間聚合問題排查按入賬時間查詢兩者互不干擾。跨時區(qū)、跨系統(tǒng)的場景下這個雙時間戳設(shè)計能避免很多扯皮。5.3 和事件溯源、微服務(wù)架構(gòu)的配合REA模型與事件溯源架構(gòu)天然親和。微服務(wù)拆分時服務(wù)之間傳遞的往往就是業(yè)務(wù)事件比如庫存扣減、訂單付款成功、退款發(fā)起。REA正好給這些事件定義了標(biāo)準(zhǔn)范式有資源、有參與者、有方向、有時間。但這里我有一個明確的提醒不要把REA事件和消息隊列里的消息畫等號。REA事件是已經(jīng)發(fā)生的經(jīng)濟事實持久化在主庫里MQ消息是傳輸載體可能丟失、亂序、重復(fù)。正確做法是把REA事件作為唯一事實源消息只是它的一個副本或者投影。下游服務(wù)收到消息后可以異步更新自己的投影表投影過期了就從REA事件重放一次。6. 用REA建模時最容易翻車的三個坑6.1 把Event表寫成操作日志第一次落地REA時我見過最典型的翻車現(xiàn)場就是Event表被當(dāng)成萬能的“操作日志”。用戶登錄、按鈕點擊、頁面瀏覽也往里插。結(jié)果查詢余額時全表掃描越來越慢而且業(yè)務(wù)查詢根本不知道該過濾哪些事件類型。判斷標(biāo)準(zhǔn)我再重復(fù)一遍只有會引起Resource增加或減少的動作才配稱為經(jīng)濟事件。行為日志請單獨放到日志系統(tǒng)兩套數(shù)據(jù)的生命周期、存儲策略、訪問模式完全不同混在一起只會兩頭難受。6.2 過度建模把流程節(jié)點全部事件化另一種相反的極端是把所有東西都建模成Event。訂單創(chuàng)建、訂單審核、訂單配貨、訂單發(fā)貨、訂單完成全部分解成一串事件。表面上挺RER實際上業(yè)務(wù)查詢需要跨十幾個事件類型才能拼出一個訂單狀態(tài)開發(fā)效率急劇下降。原因在于訂單本身是契約不是經(jīng)濟事件。契約表達(dá)的是雙方約定事件表達(dá)的是資源實際增減。下單時不產(chǎn)生資源增減貨物出庫才產(chǎn)生。契約表和事件表分開建模一個管承諾一個管事實這樣訂單狀態(tài)自然有了歸屬。6.3 忽視投影層的性能設(shè)計不能只建一個Event表就上線。事件表會無限增長業(yè)務(wù)報表不可能每次都全表重放。合理的做法是在同一事務(wù)里寫事件和更新投影表或者用異步消費者更新投影表。庫存余額、應(yīng)收余額、銷量統(tǒng)計這些高頻查詢走投影表歷史分析走事件表。我曾經(jīng)為了省事報表直接對Event表做全量聚合數(shù)據(jù)量到千萬級以后查詢直接卡死。后來加了一張庫存投影表每次寫入事件時同時更新投影報表查詢從秒級變成毫秒級。所以建議把Event表當(dāng)作唯一事實源但不要讓它成為唯一的查詢源。6.4 把REA套用到根本不需要的地方REA也有明確的適用邊界。純文檔管理、簡單CMS、個人賬本這類業(yè)務(wù)資源流動極少事件類型單一引入一套完整的事件模型只會增加表和邏輯的復(fù)雜度。倒不如用普通業(yè)務(wù)表更直接。什么時候應(yīng)該用REA我的判斷是業(yè)務(wù)里有多種資源頻繁流動存在多個參與者和多步審批授權(quán)同時你還特別需要追溯歷史行為和生成彈性報表。只有這類系統(tǒng)REA的投資才值得。7. 我的落地最小集十個表起步7.1 表清單一個保守但完整的REA骨架如果你準(zhǔn)備在一個真實業(yè)務(wù)里嘗試REA我建議從下面這組表開始。不追求一次覆蓋全部理論先保證核心鏈路跑得通。序號表名作用備注1resource資源主數(shù)據(jù)庫存、現(xiàn)金、應(yīng)收賬戶都在這里2agent參與者主數(shù)據(jù)內(nèi)部角色與外部客戶/供應(yīng)商3authorization參與者對事件類型的授權(quán)控制誰可以發(fā)起哪些行為4event經(jīng)濟事件明細(xì)唯一的原始事實來源5event_duality事件對偶關(guān)系發(fā)貨事件對應(yīng)收款事件6contract契約頭訂單、采購單的承諾信息7contract_item契約行訂單行、商品、數(shù)量、價格8policy業(yè)務(wù)規(guī)則折扣、信用額度、發(fā)貨約束9projection_inventory庫存投影表高性能當(dāng)前庫存10projection_receivable應(yīng)收投影表高性能客戶欠款這十張表并不復(fù)雜但已經(jīng)覆蓋了一個普通銷售庫存業(yè)務(wù)從承諾到履行再到結(jié)算的完整閉環(huán)。7.2 老系統(tǒng)遷移的三步走老系統(tǒng)里沒有事件只有一堆余額和賬單怎么遷移我實踐下來比較穩(wěn)的順序是三步。第一步把歷史余額做成初始事件。先把當(dāng)天庫存、應(yīng)收、應(yīng)付的存量確認(rèn)下來作為時間起點生成一條“期初事件”。這是整條事件鏈的地基。第二步按單據(jù)往回補近期事件。從最近三個月起把采購入庫單、銷售發(fā)貨單、收款單、付款單都反寫成Event表。過程中會遇到單據(jù)編號缺失、上游系統(tǒng)時間不準(zhǔn)等問題盡量在轉(zhuǎn)化期就把臟數(shù)據(jù)清掉。第三步雙寫并行并做日級對賬。老系統(tǒng)繼續(xù)跑一段時間新系統(tǒng)同時接收新業(yè)務(wù)事件每天比對庫存與應(yīng)收余額。只有連續(xù)兩到三周誤差為零才敢切流量。7.3 什么情況下我勸你“別用REA”最后說句掏心窩的話千萬別因為這篇文章覺得REA是銀彈。如果你的團隊只有兩三個人業(yè)務(wù)一個月改不了一次余額表直接一把梭反而更快。我個人的體會是REA最大的價值出現(xiàn)在業(yè)務(wù)進入高速變化期的時候。那時候你改一個庫存規(guī)則傳統(tǒng)系統(tǒng)要動七八個模塊REA系統(tǒng)可能只加一種事件類型。但這是用前期的建模成本換來的——你需要愿意接受“事件先于視圖”的設(shè)計思想并且有足夠的紀(jì)律性維護事件不可變、冪等和時間戳這三條底線。最后再分享一個小技巧技術(shù)評審上遇到“REA是什么”這種問題別只背Resource、Event、Agent三個單詞。把一杯咖啡的完整生命周期講一遍——從采購豆子、烘焙、制作、售出到收款每個環(huán)節(jié)誰是資源、誰是事件、誰是參與者鏈路天然就是一套REA。把故事講清楚比背概念有用得多。