:用資源事件代理建模積分結(jié)算系統(tǒng))
做后端和架構(gòu)的同學(xué)應(yīng)該都對這種場景不陌生。業(yè)務(wù)方丟過來一句話“我們要上一個積分系統(tǒng)買東西得積分積分能抵現(xiàn)。”你第一反應(yīng)是建表用戶表、訂單表、積分表、流水表。等上線三個月財務(wù)說要對賬你發(fā)現(xiàn)積分流水里只有加減沒有“這筆積分來自哪筆訂單、對應(yīng)哪次消費、被誰消耗”問題一下子全出來了。今天聊的REA模型就是專門用來解決這類建模問題的。REA全稱是Resource-Event-Agent資源-事件-代理是一套面向經(jīng)營活動的領(lǐng)域建模框架不綁定任何框架和語言。這篇文章我會用一個積分結(jié)算系統(tǒng)的完整建模過程講清楚REA是什么、怎么建模、怎么落到存儲和查詢最后把實際踩過的坑一次說清。適合正在做DDD落地、事件溯源以及賬戶、庫存、積分類系統(tǒng)的開發(fā)者參考。1. REA模型認(rèn)知為什么業(yè)務(wù)建模繞不開它1.1 三個要素和一個中心關(guān)系REA模型的三個核心詞匯展開來說并不復(fù)雜。Resource資源凡是“有價值、可以衡量、能在業(yè)務(wù)中流動”的東西都可以歸入這個范疇?,F(xiàn)金、庫存商品、積分、優(yōu)惠券、產(chǎn)能、維修工單中的工時都是資源。判斷標(biāo)準(zhǔn)很直接這個東西在業(yè)務(wù)里會不會被增加、減少、轉(zhuǎn)移如果會它就是Resource而不是什么普通實體或字典表。很多同學(xué)建模時把積分當(dāng)成“用戶的屬性”直接給用戶表加一個point字段這恰恰是問題的起點。積分本質(zhì)上是一種可流轉(zhuǎn)、可消耗、有歸屬的資產(chǎn)應(yīng)該被當(dāng)成Resource來建模。Event事件是發(fā)生在資源上的經(jīng)濟活動。用戶支付、商品出庫、積分發(fā)放、優(yōu)惠券核銷都屬于Event。它記錄的是“發(fā)生了什么”而不是“現(xiàn)在是什么狀態(tài)”。平時做CRUD的時候我們習(xí)慣把賬號狀態(tài)從待支付改成已支付這是狀態(tài)的覆蓋而在REA里我們記錄的是“誰在什么時間做了什么動作”這個動作不會因為業(yè)務(wù)狀態(tài)變化而被抹掉。Agent代理是參與經(jīng)濟活動的人、系統(tǒng)角色或組織。用戶、供應(yīng)商、平臺、渠道商甚至一個負(fù)責(zé)積分過期任務(wù)的定時程序都可以是Agent。在REA模型里Agent不是簡單的標(biāo)簽而是資源流向的歸屬方。每一筆資源變動都必須能回答“這個資源從哪個Agent流出流入哪個Agent”。三者的關(guān)系是一個Event至少連接兩個Agent同時連接流入和流出兩類Resource。為什么必須有雙方因為任何一筆經(jīng)濟活動要成立必然存在一方資源減少、另一方資源增加。如果模型中只看到一個Agent那說明你記錄的很可能不是事實而是某個操作的結(jié)果快照。1.2 用一杯咖啡看懂REA用一個最生活化的例子來拆解比如“你花20元在咖啡店買一杯咖啡”。傳統(tǒng)建模會怎么寫訂單表記錄商品、金額、訂單狀態(tài)支付表記錄支付結(jié)果庫存表扣減庫存??雌饋磉壿嬐暾珜~時就會遇到一個經(jīng)典麻煩訂單顯示已支付財務(wù)的銀行流水里卻沒有這筆錢或者支付成功但訂單狀態(tài)沒被更新你說不清是哪一步發(fā)生的、責(zé)任在哪一方。用REA模型這次交易要被拆成兩個獨立的經(jīng)濟Event。第一個Event是顧客付款。此時支付錢包這類Resource從顧客名下流出20元流入咖啡店的收款賬戶涉及到的Agent是顧客和咖啡店。第二個Event是咖啡交付??Х榷够蛞阎谱骱玫目Х瘸善窂目Х鹊甑膸齑鍾esource中流出1杯轉(zhuǎn)入顧客名下涉及到的Agent同樣是顧客和咖啡店。這里一定會有人問一筆交易為什么要拆成兩個Event因為付款和交付本來就是兩個獨立事實它們可能不是同一時刻發(fā)生。顧客觀看菜單后可能先付款再取貨也可能先取走咖啡后補付錢更可能付款后商家倒閉跑路根本拿不到咖啡。如果把兩者揉在一個訂單Event里后續(xù)處理退款、掉單對賬、庫存損耗時就會非常痛苦。拆開之后“顧客付了錢但沒拿到咖啡”可以定位到第一個Event已完成、第二個Event未發(fā)生“拿到咖啡但沒扣庫存”也可以從Event里直接查出來。這背后正是復(fù)式記賬的思路每一筆資源的流入必然對應(yīng)另一筆資源的流出兩邊都要有據(jù)可查。REA把這種會計思想搬到了軟件建模層面很多看似復(fù)雜的業(yè)務(wù)按這個邏輯梳理后核心關(guān)系會非常干凈。等等有人會問“我開發(fā)的是一個管理后臺不是財務(wù)軟件有必要這么較真嗎”確實不是所有系統(tǒng)都必要但凡是涉及資金、庫存、積分、兌換碼這類“資產(chǎn)型”數(shù)據(jù)的模塊如果沒有這一層事實記錄將來對賬、審計、排查數(shù)據(jù)異常時代價會成倍增加。我見過太多系統(tǒng)因為早期省了這一步后期專門開發(fā)“審計模塊”“對賬系統(tǒng)”結(jié)果還是對不齊最后只能硬編碼各種補丁。1.3 傳統(tǒng)建模與REA建模的對照把傳統(tǒng)CRUD式的建模和REA建模放在一起對比區(qū)別會更直觀。對比維度傳統(tǒng)建模REA建模核心數(shù)據(jù)狀態(tài)字段比如訂單狀態(tài)不可變的經(jīng)濟事實流審計能力依賴操作日志經(jīng)常缺失環(huán)節(jié)事件天然全鏈路可回溯當(dāng)前余額/庫存直接查字段值通過投影計算可從事件推導(dǎo)業(yè)務(wù)擴展需要改表結(jié)構(gòu)、加字段新增事件類型舊事件不動冪等保障依賴狀態(tài)校驗和鎖依賴事件的業(yè)務(wù)唯一性財務(wù)對賬另行開發(fā)對賬邏輯事件本身就是賬本傳統(tǒng)表結(jié)構(gòu)像給系統(tǒng)拍了一張又一張快照我們能看到的永遠是當(dāng)前狀態(tài)REA更像一段完整錄像任何時間點的業(yè)務(wù)都可以被還原。數(shù)據(jù)庫字段里那個“訂單狀態(tài)已完成”更像一張抓拍照片而REA事件流里的“下單、支付、出倉、簽收”是連續(xù)的事實序列。對需要追溯責(zé)任的業(yè)務(wù)來說錄像比照片可靠得多。還不止如此。傳統(tǒng)建模中當(dāng)業(yè)務(wù)需求增加“積分可以跨訂單合并使用”的時候你要么給積分表加一大堆關(guān)聯(lián)字段要么再寫一套合并邏輯。但在REA里積分合并本質(zhì)上是一筆資源重組Event把兩個積分賬戶的資源結(jié)余轉(zhuǎn)移到一個新賬戶事件不改變舊積分賬戶的事實只是做一個新事件。這種擴展方式對新老邏輯都比較溫和。1.4 REA與DDD、事件溯源的真實關(guān)系DDD領(lǐng)域驅(qū)動設(shè)計這么多年都很火但很多團隊落地時卡在同一個問題上通用語言怎么定義聚合根怎么劃分領(lǐng)域事件到底應(yīng)該記錄哪些內(nèi)容REA恰好給了一套可落地的詞法規(guī)范。DDD的“領(lǐng)域事件”在REA里對應(yīng)的是真正的經(jīng)濟Event而不是系統(tǒng)的每一步操作。比如“用戶點擊了積分明細(xì)頁”這種操作不算Event因為它不涉及任何Resource的流入流出“用戶用100積分兌換了一張5元優(yōu)惠券”才是Event因為它涉及兩個Agent用戶和平臺、兩類Resource積分和優(yōu)惠券。如果你的團隊在做事件風(fēng)暴時總是吵不清楚哪些是領(lǐng)域事件引入REA的“雙Agent、雙Resource”校驗一下子就能過濾掉大量噪音。事件溯源Event Sourcing落地時最常見的問題也是事件庫亂。有些團隊把“狀態(tài)變更”都當(dāng)成事件存進去結(jié)果事件流里魚龍混雜重放時根本還原不出業(yè)務(wù)。REA的約束正好解決了這個問題事件必須關(guān)聯(lián)資源、必須關(guān)聯(lián)雙向代理、必須描述經(jīng)濟活動。不符合這三條的就不應(yīng)該進事件庫。所以REA不是DDD的替代品也不是事件溯源框架的前置依賴而是它們之間的粘合劑。我個人的習(xí)慣是先用REA梳理領(lǐng)域詞表再做DDD的聚合設(shè)計最后用事件溯源來落地。順序?qū)α撕竺鏁樅芏唷?. 實操建模用REA給積分結(jié)算系統(tǒng)搭骨架2.1 從一個不過分的模擬項目開始為了把REA落地過程講得具體我假設(shè)自己正在為某社區(qū)團購平臺設(shè)計一套結(jié)算模塊業(yè)務(wù)規(guī)則一共有四條用戶可以用微信充值獲得平臺余額用戶在商城下單時用余額支付同時按支付金額的一定比例獲得積分用戶可以用積分兌換優(yōu)惠券優(yōu)惠券在下一次購買時核銷。需要注意的是充值渠道和商品提供方是外部角色平臺本身也需要作為Agent進入模型。拿到這個需求第一步不是寫代碼也不是畫架構(gòu)圖而是列事件清單。我把所有可能發(fā)生的經(jīng)濟活動寫下來用戶發(fā)起充值、充值成功到賬、用戶下單、支付成功、訂單完成出庫、積分發(fā)放、積分兌換優(yōu)惠券、優(yōu)惠券核銷。事件清單不需要一次就完美后續(xù)隨業(yè)務(wù)梳理不斷補充但第一版必須包含業(yè)務(wù)方最關(guān)心的幾件事尤其是錢和商品的流轉(zhuǎn)。第二步對每個事件確認(rèn)它涉及的Resource和Agent。這里直接給出我梳理后的關(guān)系表這個表本質(zhì)上會成為后面領(lǐng)域模型和數(shù)據(jù)庫設(shè)計的基礎(chǔ)事件流入Resource流出Resource涉及Agent充值成功余額用戶外部資金平臺用戶、平臺支付成功下單商品貨權(quán)用戶余額用戶用戶、平臺訂單完成出庫商品庫存平臺商品在庫平臺平臺、供應(yīng)商積分發(fā)放積分用戶積分池平臺用戶、平臺積分兌換優(yōu)惠券優(yōu)惠券用戶積分用戶用戶、平臺優(yōu)惠券核銷優(yōu)惠券核銷記錄平臺優(yōu)惠券用戶用戶、平臺注意這里“商品貨權(quán)用戶”與“商品在庫平臺”是兩個不同的Resource。REA模型鼓勵把貨權(quán)和庫存分開看待因為用戶下單后貨權(quán)雖然已經(jīng)轉(zhuǎn)移給用戶但商品仍然在平臺倉管里直到出庫才減少庫存。很多庫存對不上的問題正是因為把貨權(quán)轉(zhuǎn)移和庫存減少混為一談。這個點在實際做電商系統(tǒng)時非常重要。第三步確定聚合邊界。我的方案是把“經(jīng)濟事件”作為獨立的不可變聚合根每個Agent的每個Resource類型對應(yīng)一個資源賬戶聚合比如UserAccount聚合里包含用戶的余額賬戶和積分賬戶平臺的資產(chǎn)品類賬戶單獨再建一個聚合。事件聚合負(fù)責(zé)產(chǎn)生事實賬戶聚合負(fù)責(zé)校驗和投影兩者職責(zé)分離不混在一起。2.2 領(lǐng)域模型代碼骨架事件與賬戶分離按照上面的設(shè)計核心代碼可以拆成三層。Resource層比較簡單它包含一個ResourceType和一個數(shù)量字段。Event層承擔(dān)事實記錄必須不可變。Agent層負(fù)責(zé)資源賬戶的承載。我用接近Java的偽代碼把它寫出來public class ResourceAccount { private AgentId agentId; private ResourceType resourceType; private long amount; // 以最小單位存儲比如“分” private int version; public void apply(EconomicEvent event) { // 先校驗后變更 } } public class EconomicEvent { private EventId eventId; private EventType eventType; private Instant occurredAt; private ListEntry entries; public static class Entry { private AgentId agentId; private ResourceType resourceType; private long amount; // 流入記正數(shù)流出記負(fù)數(shù) private EntryDirection direction; } } public class Agent { private AgentId agentId; private AgentType agentType; private String name; }這里最核心的是EconomicEvent中的Entry列表。一個事件至少要有兩條Entry一條表示某Agent的某個Resource增加另一條表示另一個Agent的某個Resource減少。兩條Entry之間滿足金額守恒。比如充值成功事件一條Entry是用戶余額10000分另一條Entry是平臺外部資金-10000分。兩條金額加起來為0這個事件的完整性就初步得到保證。金額守恒看起來像會計學(xué)概念但它對系統(tǒng)質(zhì)量的作用非常直接。在傳統(tǒng)模型里一個不經(jīng)意的小Bug可能導(dǎo)致用戶余額憑空多出1分錢而在REA模型里事件本身自檢就能發(fā)現(xiàn)不平衡測試用例也更容易寫。我經(jīng)常說REA等于把會計的“借貸平衡”內(nèi)置到了業(yè)務(wù)系統(tǒng)的核心數(shù)據(jù)結(jié)構(gòu)里。當(dāng)然代碼只是骨架真正能不能落地還取決于底層存儲和并發(fā)控制這部分放到第3章細(xì)說。2.3 為什么資源單位必須是最小單位這個坑我踩過必須單獨拿出來講。早期做一個優(yōu)惠券計算功能時我用浮點數(shù)表示折扣比如0.88折結(jié)果計算過程中出現(xiàn)了0.30000000000004這類連自己都看不懂的數(shù)字后面所有對賬全部錯位。這不是REA特有的問題而是所有涉及金額、積分、數(shù)量類業(yè)務(wù)都容易犯的錯。在REA建模中所有Resource的數(shù)量都必須選擇整數(shù)類型以最小不可分割單位存儲。金額以“分”存儲積分以1個為單位庫存以“件”為單位。打折、匯率換算這些計算放到入賬之前處理一旦進入Resource賬戶全部是整數(shù)。這個設(shè)計同時解決了兩個問題第一浮點數(shù)誤差從源頭消失第二審計時看到的每一筆事件都是完整的業(yè)務(wù)事實不會出現(xiàn)三條小數(shù)記賬記錄加起來不等于一條原始記錄的情況。另外Resource的流出方向我建議用direction字段而不是簡單的正負(fù)號。正負(fù)號只有兩個方向但業(yè)務(wù)里還有“凍結(jié)”“解凍”“沖正”等狀態(tài)只用正負(fù)號表達很容易把數(shù)量與狀態(tài)混淆。庫存不足時如果系統(tǒng)錯誤地產(chǎn)生一個負(fù)庫存光看數(shù)據(jù)根本判斷不出異常但如果direction明確標(biāo)記為“凍結(jié)失敗”異常就能被精準(zhǔn)定位。2.4 事件不可變與沖正機制很多同學(xué)第一次接觸REA時會問如果事件寫錯了怎么辦用戶的余額被我用一個錯誤事件扣掉了難道不能改掉那條事件嗎答案是原始事件永遠不能改只能新增一個“沖正事件”來糾正。比如充值事件本來是100元結(jié)果系統(tǒng)錄入成了1000元你不能去修改那條充值事件而是要生成一條“充值沖正”事件把多出的900元從用戶余額里扣回來并關(guān)聯(lián)到被沖正的原事件ID。這個規(guī)則看上去死板卻保住了整個系統(tǒng)的底線。一旦事件可修改之前所有基于事件的審計、重放、對賬邏輯都會失去信任基礎(chǔ)。金融系統(tǒng)里為什么要求“記賬憑證不可涂改”因為憑證一旦可改賬本就沒有意義了。REA在這個層面就是數(shù)字化時代的記賬憑證。事件不可變的另一個好處是可以在多系統(tǒng)之間可靠地同步數(shù)據(jù)。下游系統(tǒng)只需要按事件ID增量拉取不需要關(guān)心上游是否修改過數(shù)據(jù)即使下游處理失敗重新消費冪等性也可以通過事件唯一ID保證。這是事件驅(qū)動架構(gòu)中一個很實用的基礎(chǔ)設(shè)計。3. 從模型到落地事件存儲、快照與查詢投影3.1 事件存儲表結(jié)構(gòu)怎么設(shè)計模型最終要落到存儲。在數(shù)據(jù)量不大時完全可以用普通關(guān)系型數(shù)據(jù)庫來實現(xiàn)事件存儲而不必一上來就引入專門的事件溯源中間件。我用PostgreSQL風(fēng)格的表結(jié)構(gòu)來說明但換成MySQL也成立。核心就兩張表一張保存事件流元數(shù)據(jù)一張保存每個具體事件的數(shù)據(jù)。CREATE TABLE event_streams ( id BIGSERIAL PRIMARY KEY, aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, version INT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (aggregate_type, aggregate_id, version) ); CREATE TABLE events ( id BIGSERIAL PRIMARY KEY, stream_id BIGINT NOT NULL REFERENCES event_streams(id), version INT NOT NULL, event_type VARCHAR(128) NOT NULL, event_id VARCHAR(64) NOT NULL UNIQUE, payload JSONB NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() );這里的event_streams表代表一個聚合的版本序列。version字段是遞增的整數(shù)每次追加新事件時必須指定基于哪個version寫入。如果兩個并發(fā)請求都用version3去追加下一個事件數(shù)據(jù)庫的唯一約束就會讓其中一個失敗沖突在源頭被攔下來。events表的event_id是這個事件的業(yè)務(wù)唯一ID不是自增主鍵。比如支付成功事件的事件ID就是支付流水號充值沖正事件的事件ID就是沖正單號。加上唯一約束后即使消費者重復(fù)投遞消息數(shù)據(jù)庫也會把重復(fù)事件擋在外面這是冪等性的最后一道保險。我見過不少團隊把事件和業(yè)務(wù)數(shù)據(jù)混在一起事件表里既存原始事實又存當(dāng)時算出來的余額結(jié)果重放時數(shù)據(jù)結(jié)構(gòu)混亂。記得原則Event只存事實數(shù)據(jù)比如數(shù)量、單價、關(guān)聯(lián)訂單號所有可推導(dǎo)的余額、庫存、狀態(tài)全部放投影不要放事件。3.2 快照策略與應(yīng)用恢復(fù)事件存儲解決了事實保存但性能上不能無限依賴“從頭重放所有事件”。假設(shè)一個用戶賬戶有10萬條積分流水每次加載賬戶狀態(tài)都要重放10萬次系統(tǒng)必然扛不住。這時候需要快照??煺站褪蔷酆显谀硞€版本時刻的完整存檔。恢復(fù)時只從最近的快照開始重放后續(xù)事件而不是從零開始。比如用戶賬戶已經(jīng)發(fā)生10萬條事件系統(tǒng)每100條生成一個快照那么恢復(fù)時最多重放100條性能得到了數(shù)量級提升??煺盏纳刹呗砸骖檶崟r性和成本。我常用的方案是在追加事件的事務(wù)里判斷當(dāng)前版本號是否達到“上次快照版本100”如果達到了就在同一事務(wù)里把聚合狀態(tài)序列化后寫入快照表??煺找部梢耘浜隙〞r任務(wù)做但要注意版本對齊避免快照版本與事件版本不一致導(dǎo)致重放重復(fù)或缺失。CREATE TABLE snapshots ( stream_id BIGINT NOT NULL REFERENCES event_streams(id), version INT NOT NULL, state JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (stream_id, version) );讀取聚合狀態(tài)時先取版本號最大的快照再加載該版本之后的所有事件順序重放得到最新狀態(tài)。快照作為優(yōu)化手段不能取代事件本身如果快照損壞最多重建一個空聚合重放全量事件不影響數(shù)據(jù)正確性。3.3 查詢投影別拿事件庫當(dāng)查詢庫事件庫和普通業(yè)務(wù)庫的查詢特性完全不一樣。事件表的寫入是追加式?jīng)]有任何update拿它來做“查詢用戶當(dāng)前余額”這類高頻業(yè)務(wù)請求非常不適合。正確做法是建立查詢投影用一份獨立的讀模型來服務(wù)查詢。還是說積分系統(tǒng)的例子。我可以建一張賬戶余額讀模型表專門用來支撐業(yè)務(wù)查詢CREATE TABLE account_balance_read_model ( user_id VARCHAR(64) PRIMARY KEY, balance_amount BIGINT NOT NULL, total_points BIGINT NOT NULL, coupon_count INT NOT NULL, updated_at TIMESTAMPTZ NOT NULL );這張表的數(shù)據(jù)不直接寫入業(yè)務(wù)邏輯而是由事件訂閱者異步更新。充值成功、支付成功、積分兌換等事件發(fā)布后訂閱者收到消息重新計算該用戶的最新余額和積分然后更新表中的記錄。查詢接口只讀這張表響應(yīng)速度很快。這個寫模型與讀模型分離的思路本質(zhì)上就是CQRS。REA和CQRS天然合拍REA負(fù)責(zé)把寫側(cè)變成一串不可變的事實CQRS負(fù)責(zé)把讀側(cè)變成面向業(yè)務(wù)場景的投影。在寫側(cè)我們強調(diào)一致性、完整性和審計能力在讀側(cè)我們強調(diào)查詢性能和響應(yīng)速度。兩者互不拖累。需要注意的是投影更新存在“最終一致”窗口事件寫入后到讀模型更新完成前用戶可能看到舊數(shù)據(jù)。對大多數(shù)積分、余額業(yè)務(wù)來說這個時間差可以接受如果業(yè)務(wù)要求強一致可以在同一個事務(wù)里同時更新事件表和投影表代價是寫入延遲升高。我剛落地REA時只顧著保證事件一致性忘了設(shè)計讀模型結(jié)果上線后接口響應(yīng)慢得沒法看后來補了投影表才解決問題。3.4 冪等、并發(fā)控制與重試事件驅(qū)動系統(tǒng)里的冪等性遠比單體CRUD里需要考慮得多。同一個支付成功事件可能因為消息中間件重投、下游服務(wù)重啟等原因被處理兩次。如果沒有冪等保護用戶的300元可能被入賬兩次積分翻倍優(yōu)惠券重復(fù)發(fā)放。冪等的第一道防線在事件唯一鍵上。我用支付回調(diào)里的平臺流水號作為eventId數(shù)據(jù)庫對eventId加唯一索引重復(fù)插入必然失敗。第二道防線在賬戶聚合的版本號上。每個賬戶記錄當(dāng)前version更新時必須攜帶期望version如果version對不上說明有其他事件已經(jīng)先入賬當(dāng)前請求需要重新加載最新狀態(tài)再合并計算。這里給出一個典型的寫入流程偽代碼public void processPayment(PaymentReceivedEvent event) { for (int retry 0; retry 3; retry) { AgentAccount account accountRepository.load(userId); if (eventStore.exists(event.getEventId())) { return; // 已經(jīng)處理過直接忽略 } try { account.apply(event); accountRepository.save(account, account.getVersion()); eventStore.append(event); return; } catch (VersionConflictException ex) { // 說明有并發(fā)事件先寫入重新加載重試 } } throw new RecoverableException(concurrent payment processing); }這個寫法里事件唯一鍵保證了重復(fù)消息不會入賬版本沖突保證了并發(fā)更新不會互相覆蓋。我實際測試過即使兩個線程同時收到同一事件最終也只有一個線程能成功寫入另一個會在下一輪重試時發(fā)現(xiàn)事件已存在并退出。這套機制簡單可靠比分布式鎖輕量很多。4. 復(fù)盤避坑REA落地時最常踩的6個坑4.1 坑一把派生數(shù)據(jù)混進事件里這是REA建模中最典型的錯誤幾乎每個初學(xué)的人都會犯。有人設(shè)計“用戶支付”事件時除了記錄支付金額、訂單號還會加一個字段叫“支付后余額”目的是查詢方便。結(jié)果事件溯源重放到最后一筆時發(fā)現(xiàn)余額和真實余額差了10元。為什么因為早期的某條事件里的“支付后余額”算錯了而這個錯誤在之后的N條事件里被當(dāng)成基礎(chǔ)數(shù)據(jù)繼續(xù)疊加。正確做法是事件只記錄業(yè)務(wù)事實支付了多少錢、來自哪個訂單、發(fā)生于何時。用戶支付后的余額是多少由投影計算得出不能寫進事件。把這個原則記住你的事件流就能永遠保持純凈和可重放。4.2 坑二漏掉系統(tǒng)自動任務(wù)的Agent身份有一類特殊Agent特別容易被遺漏——系統(tǒng)本身。比如平臺規(guī)定“90天未使用的積分自動過期”這個定時任務(wù)每天掃描積分賬戶并執(zhí)行過期操作。很多建模者覺得它不是人就不給它Agent身份。結(jié)果積分過期后業(yè)務(wù)審計想查“誰的積分被清了、為什么被清”時事件流里只有一個資源減少記錄沒有任何Agent承擔(dān)責(zé)任。正確做法是把“積分過期服務(wù)”或者“系統(tǒng)管理Agent”納入模型給它分配一個AgentId。事件里的雙向Agent就變成了“用戶”和“系統(tǒng)管理Agent”。這樣審計時才能回答這筆積分是用戶主動兌換掉還是被規(guī)則自動清理的規(guī)則歸屬哪個系統(tǒng)服務(wù)。4.3 坑三濫用浮點數(shù)處理金額和積分這個坑前面已經(jīng)提過但值得放進避坑清單里再次強調(diào)。任何Resource的入賬和出賬都使用以最小單位為基準(zhǔn)的整數(shù)。打折、匯率、稅率等計算全部放在事件入賬之前計算結(jié)果必須四舍五入到最小單位后再進入事件。如果某天計算結(jié)果出現(xiàn)0.1個積分業(yè)務(wù)上要么向下取整要么單獨生成一個“零頭積分”的Resource絕不能讓一個小數(shù)出現(xiàn)在賬戶流水里。4.4 坑四事件表膨脹查詢性能失控事件存儲的量級和普通業(yè)務(wù)表完全不一樣。一個用戶一年可能產(chǎn)生上千條積分流水一個平臺一年就是上億條。如果不做任何優(yōu)化任何一次加載聚合狀態(tài)都要掃描幾十萬條數(shù)據(jù)系統(tǒng)會卡到?jīng)]法用。解決方案有分層第一層是快照把上千條事件壓縮到最多回放100條第二層是歸檔把超過3個月的舊事件歸檔到冷存儲業(yè)務(wù)查詢走投影表只在審計、對賬、糾紛處理時查冷數(shù)據(jù)第三層投影表本身也要定期清理和壓縮避免重復(fù)投影數(shù)據(jù)越來越多。記住一條事件庫是“真相”投影模型才是“讀接口”不要讓用戶請求直接親吻事件庫。4.5 坑五團隊不認(rèn)可新模型方案被噴成過度設(shè)計REA落地最大的阻力往往不是技術(shù)而是團隊協(xié)作。業(yè)務(wù)方會覺得你繞了一大圈明明一張訂單表就能解決的問題你非要拆成幾個事件和賬戶開發(fā)同事會覺得這套模型復(fù)雜要學(xué)新的概念還要寫一套以前沒寫過的代碼。我的建議是不要試圖一次性全面鋪開。先挑一個最痛的點比如財務(wù)月結(jié)算對不上賬、積分明細(xì)查不清用REA做一個影子模塊和舊系統(tǒng)并行跑一段時間用報表比對新舊模型的結(jié)果。當(dāng)數(shù)據(jù)證明舊模型有缺失、新模型能精準(zhǔn)還原后團隊自然會接受。技術(shù)方案存在的最終目的是解決問題不是證明誰更高級。如果想靠“這個模型很經(jīng)典很權(quán)威”來說服團隊阻力只會更大。4.6 坑六把REA當(dāng)成萬能銀彈最后一條坑是反過來走向極端什么系統(tǒng)都往REA上套。一個簡單的用戶配置管理系統(tǒng)只有查詢和修改既不涉及資源流轉(zhuǎn)也不需要審計對賬硬套REA只會增加無意義的復(fù)雜度。判斷是否該用REA的標(biāo)準(zhǔn)很簡單業(yè)務(wù)里是否存在多方資源流轉(zhuǎn)、是否存在審計追溯訴求、當(dāng)前狀態(tài)是否依賴歷史事實推導(dǎo)如果三個都否普通CRUD可能就夠用了。我自己的經(jīng)驗是REA最適合的場景是“用戶持有資產(chǎn)型數(shù)據(jù)”的業(yè)務(wù)錢包、積分、優(yōu)惠券、庫存、配額。這類業(yè)務(wù)中的數(shù)據(jù)不僅是一個字段更是一種有生命周期、有歸屬方、可流轉(zhuǎn)的資產(chǎn)。判斷清楚再上模型會給你帶來巨大回報判斷不清楚硬上就會變成過度設(shè)計。最后把六個坑整理成速查表方便排查時對照坑位問題表現(xiàn)解決思路派生入事件重放結(jié)果與真實余額不一致事件只記錄原始事實漏掉系統(tǒng)Agent自動過期等操作無責(zé)任方補建系統(tǒng)Agent浮點數(shù)量計算誤差導(dǎo)致對賬失敗全部使用整數(shù)最小單位查詢撞事件庫響應(yīng)越來越慢用快照投影歸檔團隊阻力方案被質(zhì)疑影子運行用數(shù)據(jù)說話過度設(shè)計簡單CRUD也套REA先評估業(yè)務(wù)是否匹配寫在最后一個實用體會。我最早接觸REA時也覺得一個訂單要拆成好幾個事件實在太繞。直到后來一次對賬財務(wù)要我說清楚“某用戶1月5日20點03分用余額買了一箱牛奶之后余額去哪了、積分發(fā)了多少、是否存在資金漏洞”傳統(tǒng)模型里的訂單狀態(tài)和流水表根本理不清是REA幫助我從事件流里逐條還原把資金鏈路完整講了出來。從那以后凡是涉及錢、庫存、積分這類模塊我都會先問自己一句我建的是狀態(tài)還是事實如果只能給一條建議那就是在建模第一周先用Excel把所有事件、資源流入流出、Agent列成一張大表把關(guān)系徹底捋順了再寫代碼。這個笨辦法比畫100張架構(gòu)圖都有效。