:資源-事件-代理模型重構(gòu)訂單庫存系統(tǒng))
REA 這個詞我第一次看到時心里想的是“這又是哪個新中間件”后來一查才發(fā)現(xiàn)它是一套在業(yè)務(wù)系統(tǒng)里非常值得認真研究的建模思想全稱 Resource-Event-Agent翻譯過來就是資源-事件-代理。這篇筆記就來聊聊我完整落地過一遍 REA 框架的實戰(zhàn)記錄從它到底解決什么問題、核心概念怎么區(qū)分到具體怎么把一套訂單加庫存系統(tǒng)改造成 REA 結(jié)構(gòu)最后把實操中踩過的坑和排查經(jīng)驗一并整理出來。適合正在做訂單、庫存、供應(yīng)鏈、財務(wù)對賬或者積分系統(tǒng)重構(gòu)的后端開發(fā)、架構(gòu)師也適合被“訂單狀態(tài)字段越堆越亂”折磨到懷疑人生的同學(xué)。1. REA 到底想解決建模里的哪些問題1.1 傳統(tǒng)表結(jié)構(gòu)建模越做越重的三個信號我見過太多系統(tǒng)是這樣一步一步走向失控的第一個信號訂單表字段開始爆炸。下單時間、支付時間、發(fā)貨時間、確認收貨時間、退款狀態(tài)、發(fā)票狀態(tài)、優(yōu)惠券明細、贈品信息……全都塞在訂單主表里。看起來好像一張表就夠用了實際上每次要加一個新狀態(tài)就要跟著改一堆歷史數(shù)據(jù)、檢查一堆兼容邏輯一個不小心就把線上訂單狀態(tài)搞壞了。第二個信號對賬靠人肉拉數(shù)據(jù)拼。財務(wù)月底要核對庫存金額、應(yīng)收應(yīng)付業(yè)務(wù)說自己按訂單跑了財務(wù)說自己按流水算了兩邊數(shù)字對不上。原因很簡單系統(tǒng)里根本沒有一個統(tǒng)一的事件賬本錢和貨被拆散在不同的業(yè)務(wù)表里面只能靠人寫臨時 SQL 去拉、去導(dǎo)、去手工比對。第三個信號業(yè)務(wù)規(guī)則的執(zhí)行邏輯散落在各處。你今天在訂單 Service 里改庫存扣減邏輯明天發(fā)現(xiàn)采購入庫那邊又用了一套完全不一樣的扣減規(guī)則后天發(fā)現(xiàn)盤點調(diào)整又是第三套。不是代碼寫得差是底層模型壓根沒有達成共識導(dǎo)致大家各做各的。這三大信號本質(zhì)上都是同一個根因我們在用“單據(jù)視角”建表卻希望這套模型能支持“事實追溯”。單據(jù)只是業(yè)務(wù)的呈現(xiàn)形式真正應(yīng)該留在系統(tǒng)里的是業(yè)務(wù)事實本身。1.2 把“憑證驅(qū)動”換成“事件驅(qū)動”不妨用快遞舉個例子??爝f到了哪兒、中轉(zhuǎn)了幾次你不會只往包裹上寫一個“最后位置”字段而是每到一個節(jié)點就掃描一條記錄。查包裹在哪兒看最新一條物流記錄就行出問題要追責看完整時間線就行。訂單系統(tǒng)也是一樣的道理。傳統(tǒng)做法是在訂單上維護 status 字段從待支付改成已支付、已支付改成已發(fā)貨每次修改都是覆蓋歷史。等到哪天你想知道“這個訂單在 3 月 15 號到底處于什么狀態(tài)”你發(fā)現(xiàn)狀態(tài)早就被覆蓋了根本查不回來。REA 的核心轉(zhuǎn)變就是把“狀態(tài)覆蓋”改成“事件追加”。每次業(yè)務(wù)動作都作為一條不可變的事件記錄保存下來當前狀態(tài)可以隨時從事件流推導(dǎo)出來歷史也能隨時回溯。做對賬的時候全部以事件流為準繩錢和貨的來龍去脈清清楚楚。1.3 哪些業(yè)務(wù)適合 REA哪些沒必要REA 不是一個萬能銀彈它天生適合那些“有價值流動、需要審計追溯、涉及多方參與”的場景。適合用 REA不太需要 REA電商訂單、庫存、進銷存純內(nèi)容管理系統(tǒng)、博客、CMS供應(yīng)鏈與多級倉儲簡單 CRUD 展示型應(yīng)用財務(wù)應(yīng)收應(yīng)付、資金賬戶靜態(tài)報表平臺積分、優(yōu)惠券、會員點數(shù)體系無狀態(tài)接口聚合服務(wù)選型邏輯很簡單如果業(yè)務(wù)從頭到尾都只是“單表增刪改查”沒有跨表價值流轉(zhuǎn)也沒有審計要求那 REA 只會給你增加額外復(fù)雜度。但如果你的系統(tǒng)每天要處理庫存、資金、積分這類“會變來變?nèi)ミ€要說得清為什么變”的數(shù)據(jù)REA 的價值會隨著業(yè)務(wù)復(fù)雜度的提升越來越明顯。2. 資源、事件、代理三個核心概念怎么用2.1 資源能衡量價值、能被業(yè)務(wù)流轉(zhuǎn)的存量資源不是說“數(shù)據(jù)庫里的一張表”它有明確特征可以被消耗、產(chǎn)出、交換而且能衡量價值。舉個例子電商系統(tǒng)里商品庫存是資源入一箱貨庫存增加賣一件貨庫存減少現(xiàn)金和應(yīng)收應(yīng)付是資源每筆交易的資金流動會改變它們的余額積分、優(yōu)惠券、服務(wù)時長、帶寬流量包這類虛擬資產(chǎn)同樣是非常典型的資源。判斷一個東西到底算不算資源我有個簡潔標準如果它的數(shù)量會增加或減少而且業(yè)務(wù)上要求你必須能回答“為什么增加、為什么減少、誰觸發(fā)的”它就應(yīng)該建模成資源。反之比如訂單編號、商品名稱這些只是屬性描述不是資源。這里特別提醒一個坑不要什么字段都往資源里塞。資源只保存“存量狀態(tài)”和“基礎(chǔ)屬性”像成本分攤規(guī)則、審批流程配置這些業(yè)務(wù)規(guī)則不能作為資源字段堆進去否則又會退化回普通的寬表設(shè)計。2.2 事件不可變、可追溯、有明確時間點事件是整套 REA 模型里最重要的概念也是我落地時體會最深的部分。事件表示業(yè)務(wù)發(fā)生瞬間的一個事實。采購入庫是一次事件銷售出庫是一次事件積分兌換是一次事件庫存盤點調(diào)整也是一次事件。事件必須包含這幾個要素什么時間發(fā)生哪些資源流入哪些資源流出哪些代理參與事件類型是什么事件一旦寫入就不能修改、不能刪除。如果業(yè)務(wù)操作有錯誤比如入庫數(shù)量錄錯了正確的做法不是去 UPDATE 那條事件記錄而是新增一條“沖正事件”把錯誤影響抵消掉再把正確數(shù)量入賬。這樣看事件流的時候能看到錯誤發(fā)生與修正的全過程審計非常舒服。我第一次做這種設(shè)計時會有點不習(xí)慣因為以前習(xí)慣了“改狀態(tài)”現(xiàn)在改成“追加事實”。但這其實反而讓代碼邏輯更清晰所有寫操作都是 append不需要處理復(fù)雜的更新邏輯。2.3 代理參與主體要區(qū)分角色不要無腦建“用戶表”代理是參與事件的各類主體。最常見的有內(nèi)部員工、外部客戶、供應(yīng)商還有系統(tǒng)本身。但代理不等于用戶表。我在項目里犯過一個典型的錯以為代理就是“用戶”那張表。后來發(fā)現(xiàn)同一個用戶可能同時是內(nèi)部采購審批人、也可能是供應(yīng)商對接人甚至在某些場景下還需要虛擬代理比如“系統(tǒng)自動巡檢”。所以落地時代理要拆成兩層代理實體用來標識“誰”對應(yīng)真實的賬號、公司或系統(tǒng)參與角色用來標識“以什么身份參與”比如買家、賣家、審批人、經(jīng)辦人。一個事件和一個代理之間是多對多關(guān)系。某個銷售出庫事件里客戶是買方、銷售員是經(jīng)辦人、倉管員是發(fā)貨執(zhí)行人這些都是同一條事件上的不同角色。2.4 “誰-何時-何物”的關(guān)系連線規(guī)則把三要素串起來會得到幾條非常穩(wěn)定的規(guī)則我每次建模都會拿它來做自檢沒有任何資源會憑空增加一定有某個事件讓它流入沒有任何資源會憑空減少一定有某個事件讓它流出任何事件都必須有至少一個代理參與資金類和實物類資源的流入流出必須雙向配對也就是價值守恒。拿一條銷售出庫來說事件流入資源流出資源參與代理銷售出庫應(yīng)收賬款/現(xiàn)金增加庫存商品減少客戶、銷售、倉管采購入庫庫存商品增加應(yīng)付賬款增加供應(yīng)商、采購員、倉管你看這不是“隨手記一筆庫存的變化”而是把業(yè)務(wù)動作從源頭到結(jié)果連成了一閉環(huán)。任何一筆庫存增加都能追溯到對應(yīng)的采購入庫事件任何一筆資金流動也都能追溯到對應(yīng)的業(yè)務(wù)事件。這套規(guī)則做順了以后對賬就是按事件流直接展開不用再人肉拼數(shù)據(jù)。3. 一個訂單庫存系統(tǒng)的 REA 落地實錄3.1 先梳理業(yè)務(wù)事件別急著建表我改造某一個電商后臺的時候第一步不是畫表而是拉著業(yè)務(wù)把關(guān)鍵動作一條一條列出來。這里特別關(guān)鍵一定要先把“業(yè)務(wù)上有哪些事實發(fā)生”想清楚再談表怎么建。當時整理出來的核心事件清單大致如下事件類型說明涉及資源變化采購入庫商品到貨庫存增加庫存流入應(yīng)付賬務(wù)對應(yīng)變化銷售出庫客戶下單并發(fā)貨庫存減少庫存流出應(yīng)收賬務(wù)對應(yīng)變化庫存盤點調(diào)整實盤和系統(tǒng)不一致手動修正庫存流入或流出退貨入庫客戶退回商品庫存流入退款/紅沖對應(yīng)金額拒收取消訂單取消需要恢復(fù)庫存庫存回流資金沖銷這一步做完你就發(fā)現(xiàn)業(yè)務(wù)看起來五花八門但落到 REA 模型里無非就是不同事件類型去改變不同資源的方向和數(shù)量。這個認知確立以后后面建表和寫代碼都變得特別順。3.2 檢查事件之間的雙向約束和守恒關(guān)系列完事件清單我做了第二件事拿“雙向約束”去校驗每個事件有沒有配對好。比如采購入庫這個事件它不能只看作“庫存商品增加了”還要看到“應(yīng)付賬款增加了”。否則庫存這邊加了一百箱貨財務(wù)那邊卻不知道要付多少錢月底一查賬資金流和貨物流就對不上。銷售出庫同理庫存減少只是其中一側(cè)另一側(cè)是應(yīng)收賬款或者現(xiàn)金增加。退貨入庫則是相反方向的鏡像操作。我當時畫了一張很簡單的對照表把每個事件的兩側(cè)資源變化都列清楚事件資源側(cè) A資源側(cè) B配對邏輯采購入庫庫存 100應(yīng)付 100貨到即欠款銷售出庫庫存 -2應(yīng)收 2貨出即債權(quán)退貨入庫庫存 1應(yīng)收 -1貨回則退債現(xiàn)金收款現(xiàn)金 2應(yīng)收 -2錢到則消債這步的價值在于業(yè)務(wù)規(guī)則還沒寫代碼就已經(jīng)被結(jié)構(gòu)化地推演過一遍很多后續(xù)開發(fā)階段才會發(fā)現(xiàn)的口徑問題在建模階段就能被擋掉。3.3 數(shù)據(jù)庫表怎么設(shè)計建模確認后我把表結(jié)構(gòu)設(shè)計成下面這套。這里需要說明的是這是基于常見實踐整理出的通用落地方案實際項目可以按需增加字段和索引。資源表CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- inventory / cash / receivable / points measure_unit VARCHAR(16) NOT NULL, -- 計量單位箱、件、元、分 description VARCHAR(256), created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL );代理表CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL, -- internal / customer / supplier / system contact_info VARCHAR(128), status TINYINT NOT NULL DEFAULT 1, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL );事件表只做追加不更新不刪除CREATE TABLE business_event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, -- purchase_in / sale_out / return_in ... occurred_at DATETIME NOT NULL, description VARCHAR(256), request_id VARCHAR(64) NOT NULL, -- 冪等鍵防重復(fù)提交 created_time DATETIME NOT NULL, UNIQUE KEY uk_request_id (request_id) );事件與資源的關(guān)聯(lián)明細表CREATE TABLE event_resource_item ( item_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction VARCHAR(8) NOT NULL, -- in / out quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4) NULL, remark VARCHAR(128), KEY idx_event_id (event_id), KEY idx_resource_id (resource_id) );事件與代理的關(guān)聯(lián)明細表CREATE TABLE event_agent_item ( item_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role_type VARCHAR(32) NOT NULL, -- buyer / seller / auditor / operator KEY idx_event_id (event_id), KEY idx_agent_id (agent_id) );這幾張表的配合方式是業(yè)務(wù)發(fā)生一筆動作業(yè)務(wù)事件寫一條主記錄再通過明細表掛上它影響的多個資源、參與的多個代理。所有查詢都可以從事件主鍵出發(fā)把完整鏈路撈出來。3.4 服務(wù)層代碼如何保證一致性表結(jié)構(gòu)定了之后最核心的實現(xiàn)就是寫事件落庫和資源余額更新的服務(wù)。我用的偽代碼是這樣的實際項目可以用任意語言照這套邏輯去寫class InventoryServiceImpl: tx_manager.transaction def record_inventory_event(self, event_type, occurred_at, agent_ids, resource_id, direction, quantity, request_id, description): # 1. 冪等校驗同一個 request_id 不能重復(fù)入賬 if self.event_repo.exists_by_request_id(request_id): self.logger.warning(duplicated request: %s, request_id) return False # 2. 保存事件主記錄 event_id self.event_repo.insert( event_typeevent_type, occurred_atoccurred_at, descriptiondescription, request_idrequest_id, ) # 3. 保存事件-資源明細 self.event_resource_repo.insert( event_idevent_id, resource_idresource_id, directiondirection, quantityquantity, ) # 4. 保存事件-代理明細 for agent_id, role_type in agent_ids: self.event_agent_repo.insert( event_idevent_id, agent_idagent_id, role_typerole_type, ) # 5. 更新資源余額 balance self.balance_repo.lock_for_update(resource_id) if direction in: balance.quantity balance.quantity quantity elif direction out: if balance.quantity quantity: raise InsufficientStockError(resource_id) balance.quantity balance.quantity - quantity self.balance_repo.update(balance) return True這里最關(guān)鍵的一點是步驟 5 里的lock_for_update。庫存扣減是典型的并發(fā)敏感操作必須在事務(wù)里對目標資源余額做行級鎖否則兩個線程同時賣最后一件商品就可能會出現(xiàn)超賣。另外注意事件明細表和資源余額表是在同一個事務(wù)里提交的。這樣能保證要么事件和余額一起成功要么一起回滾不會出現(xiàn)“事件記了但庫存沒減”或者“庫存減了但事件沒記”這類臟數(shù)據(jù)。3.5 展示層怎么做查詢有人擔心事件驅(qū)動模型查詢會變慢因為每次要看當前庫存都要去掃事件流。實際落地根本不用這么干。我的做法是維護一套“最新余額表”也就是物化視圖或者定時聚合任務(wù)。資源主表里始終保存當前可用數(shù)量事件流保存每一次變化的完整軌跡。平時業(yè)務(wù)查詢走余額表十億級事件量也不怕審計和對賬的時候才去翻事件明細那本來就不追求毫秒級性能。這套設(shè)計本質(zhì)上是把“寫模型”和“讀模型”分開寫側(cè)是追加流水讀側(cè)是查詢余量。比傳統(tǒng)的“主表更新狀態(tài) 流水表存歷史”要清晰得多因為無論是寫還是讀各自走一條最舒服的路徑。4. 常見問題與排坑實錄4.1 事件粒度到底該細到什么程度落地 REA 時最常被問的問題就是一個下單動作到底要拆成幾個事件拆太細比如把“點擊下單”也記錄成事件系統(tǒng)會被海量無意義事件淹沒真正要追溯業(yè)務(wù)時反而找不著重點。拆太粗比如把 “下單支付發(fā)貨” 全部塞進一個事件里那中間任何一步失敗這條事件到底算成功還是失敗就成了說不清的事。我習(xí)慣的粒度標準是只有“真正改變資源狀態(tài)”的動作才值得記成事件。點擊下單本身不改變庫存和資金所以不用建事件但確認支付會讓資金增加所以支付成功必須建事件出庫會讓庫存減少所以發(fā)貨出庫也必須獨立建事件。4.2 庫存可用數(shù)、在途數(shù)、預(yù)占數(shù)怎么處理REA 模型下的資源余額只代表“當前時點的存量”但實際業(yè)務(wù)還經(jīng)常需要區(qū)分可用庫存、在途庫存、預(yù)占庫存。這些怎么辦我給出的解決方式是不同口徑建不同的資源賬戶而不是在一個資源余額表上硬加一堆狀態(tài)字段。比如同一件商品可以拆成“可用庫存”和“預(yù)占庫存”兩個資源賬戶。下單時從可用庫存扣減、轉(zhuǎn)入預(yù)占庫存發(fā)貨時再從預(yù)占庫存扣減同時記一條出庫事件。這樣每個口徑的余額都是獨立的查數(shù)清清楚楚不會出現(xiàn)“可用數(shù)到底是負數(shù)還是預(yù)占沒扣掉”這種糊涂賬。4.3 金額、成本價要不要全部建模成資源資金和庫存是自古以來的對賬難題所以金額相關(guān)的東西最容易觸發(fā) REA 建模沖動。但我實際做下來的體會是不是所有金額都要建模成獨立資源否則資源表會膨脹得讓你哭出來。一個務(wù)實的方案是事件明細上直接保存單價和金額字段只有“真正獨立的資金賬戶”才建模成資源。應(yīng)收應(yīng)付這種需要長期跟蹤余額的賬戶值得用資源建模而單筆訂單里的商品銷售金額只要跟著事件明細記錄就足夠了。判斷邏輯依然是那條老標準這個金額需不需要回答“當前還剩多少”如果不需要直接作為事件屬性保存就可以。4.4 老系統(tǒng)改造時歷史數(shù)據(jù)怎么遷移這是所有改造項目最頭疼的一環(huán)REA 也不例外。如果你試圖把過去三年的所有訂單全部分解成事件再重新入賬工作量會大到項目直接夭折。正確做法是區(qū)分處理對于改造時間點之前的歷史數(shù)據(jù)生成一條“期初初始化事件”直接把當時的庫存余額、應(yīng)收應(yīng)付余額一次性寫入資源余額表作為起點快照對于改造時間點之后的增量數(shù)據(jù)嚴格按照事件驅(qū)動方式入賬。這套“快照 增量”的策略既能保證歷史數(shù)據(jù)可追溯又能把遷移成本控制在一個可接受范圍內(nèi)。我實際落地時發(fā)現(xiàn)賬實相符的關(guān)鍵不在于歷史是否拆得足夠細而在于期初快照選取得準不準。4.5 排坑小貼士最后把我踩過幾次坑之后總結(jié)出來的幾條實操經(jīng)驗寫在這里每一條都是血淚教訓(xùn)冪等鍵一定要做。REA 事件只追加不更新一旦重復(fù)提交庫存余額和資金余額就會被無辜多記一次。事件表里加上唯一 request_id冪等校驗放在事務(wù)最前面能擋住絕大多數(shù)重復(fù)請求。時間字段全部用 UTC 存儲。業(yè)務(wù)顯示層再轉(zhuǎn)本地時區(qū)。如果數(shù)據(jù)庫混著本地時間和 UTC 存月底對賬時你會被時區(qū)問題折磨瘋掉。事件表和余額表的更新必須同一個事務(wù)。沒有例外。拆成兩個事務(wù)的話一定會遇到“事件記錄成功但余額更新失敗”的中間態(tài)。庫存扣減一定要鎖定余額行。不用悲觀鎖或者分布式鎖高并發(fā)下超賣就是一句話的事。定期跑一次對賬任務(wù)。讓系統(tǒng)每天晚上自動把當天所有事件流按資源維度匯總和余額表做一次比對。有差異就報警不要等到月底才發(fā)現(xiàn)問題。這套 REA 模型我后來也在別的業(yè)務(wù)模塊里試過包括積分賬戶、卡券核銷、渠道傭金結(jié)算基本思路完全通用。真正理解它的精髓之后你會發(fā)現(xiàn)所謂架構(gòu)設(shè)計很多時候不是代碼寫得有多炫而是把業(yè)務(wù)的因果關(guān)系在數(shù)據(jù)模型層面擺得足夠正、足夠穩(wěn)。