
前阵子重构一套订单系统最折磨我的不是接口性能也不是页面交互而是业务模型怎么建都对不上业务方的口径。旧系统里库存流水、订单状态、财务对账三套数据各玩各的一次订单毛利分析要 join 十几张表拼出来的数字财务不认仓库也不认。后来我换了一种建模思路用 REA 来重新梳理核心领域整个模型一下就清爽了后来跟业务方核对口径也顺了很多。这篇文章就把我用 REA 做业务建模的完整过程、踩过的坑、以及和传统建模方式的对比梳理一遍给正在被业务模型折磨的开发者一个参考。REA 不是什么新东西它是 Resource资源、Event事件、Agent主体三个概念的缩写最早是从会计信息系统领域提出的一种业务建模框架。简单说它把业务流程看成“主体之间通过一系列经济事件完成资源的交换或转化”。听起来有点绕但一句话就能点破任何业务系统本质上都在回答三件事——什么东西资源、发生了什么事件、谁干的主体。如果你正被进销存、供应链、订单履约这类系统的数据模型搞到头秃这篇文章应该能帮你换个角度看问题。1. 先说清楚 REA 到底是什么我第一次看到 REA 的资料时第一反应是“这不就是把会计科目换了个名字吗”后来动手设计才发现它不是换了名字而是换了一套观察业务的坐标系。传统的关系型业务系统尤其在进销存和财务系统里几乎都是围绕“单据 状态”来建模的。采购单有状态、销售单有状态、库存单有状态状态之间流转靠各种审批流和定时任务。这种模型有好的一面——和现实业务操作贴合开发和维护直觉性强。但坏处也明显状态字段越堆越多业务一复杂状态就膨胀最后每个模块都在自己维护一套状态机表关联像蜘蛛网一样。REA 的做法完全不同。它不关心单据流程只关心三件基础事实企业拥有或控制哪些资源Resource比如现金、库存商品、固定资产企业发生了哪些事件Event比如采购入库、销售出库、收到货款每一个事件由哪些主体Agent参与比如供应商、客户、内部员工。然后把这三者之间的关联关系建模成固定模式事件会流入或流出资源事件由主体参与一个经济事件的流出必然伴随另一个经济事件的流入这就是 REA 里的“双向性”原则后面细说。这套模式最核心的价值是把“业务过程”和“记录方式”解耦了。传统建模里订单是一个实体库存是一个实体流水是另一个实体它们之间靠外键硬扭在一起。REA 里订单的语义被拆成“销售事件 资源流动 参与主体”你用这些基础事实可以随时推导出订单在任何时刻的状态而不是靠一个 status 字段去硬记。打个比方传统建模是拍照片系统里存的是每一个瞬间的静态截图REA 建模是拍电影系统里存的是连续发生的原始帧你要任何时候的画面都能重新渲染出来。这就是为什么 REA 特别适合做需要强审计、强追溯、强逻辑一致性的系统。2. 三个核心概念逐个拆2.1 资源Resource不是所有东西都叫资源在 REA 里资源不是“表里的字段”而是“企业能够控制、并且有经济价值的对象”。判断一个东西是不是资源有个简单标准**它能不能被某个事件增加或减少**如果能它才是资源。举个例子。库存里的咖啡豆是资源因为采购事件会增加它销售事件会减少它。现金是资源因为收款事件会增加它付款事件会减少它。但是“订单号”不是资源因为它不会被任何经济事件增减“客户满意度”也不是资源因为说不清楚谁能给它记账式地加减。这个约束非常重要。我见过不少硬套 REA 的团队把订单、合同、审批记录全都建模成资源结果整个模型比传统方式还要混乱。判断资源的标准请记住“可增减、有计量、有经济价值”这三点缺一不可。资源还需要考虑计量单位。咖啡豆可以按公斤计量现金按元计量有时候同一个资源有多个计量维度比如库存按件数管、但财务按金额算模型设计时要做清楚单位换算关系不然事件落地的时候会算错账。2.2 事件EventREA 的绝对核心如果说资源和主体都是静态描述那事件就是 REA 的发动机。**所有资源的增减、所有主体的交易都是通过事件来记录的。**事件有两条基本分类维度建模时先分清楚后面能少踩一半坑。第一维按经济方向分流入事件Inflow和流出事件Outflow。采购咖啡豆是资源流入出售咖啡豆是资源流出。注意这个“流入/流出”是站在企业自身视角说的方向搞反了后面的双向性配对会全部错乱。第二维按业务阶段分承诺事件Commitment和实际事件Execution。下采购订单是一种承诺它承诺将来某个时间点会换取一批咖啡豆真正收到货入库是实际事件。承诺和实际是两回事传统系统把它们全揉在一个“订单状态”里REA 把它们拆开建模这样就能精确回答“已经承诺了多少”“实际兑现了多少”这类问题。事件字段的设计有几个关键注意点。第一事件必须记录发生在哪个时间点最好精确到时间戳因为 REA 的追溯逻辑全靠事件时间排序第二事件要有一个业务唯一编号比如“PO-20240512-001”这是审计追踪的锚点第三事件不能只记录“结果”还要记录“过程参数”比如数量、单价、涉及合同编号这些是后面做财务分析的数据底座。2.3 主体Agent内外要分清主体是事件的参与者分内部主体和外部主体两类。内部主体是企业自身、部门、仓库管理员、业务员外部主体是供应商、客户、物流商。为什么这个区分重要因为 REA 的双向性原则要求一个经济事件的流出方必须是另一个关联事件的流入方如果主体内外不分配对关系就会混乱。举个例子。向供应商采购咖啡豆这个事件涉及的外部主体是供应商内部主体是采购员。什么时候这个采购事件会“闭环”当企业向供应商付款时付款事件的外部主体也是供应商。两个事件通过同一个外部主体建立双向关系。如果你把内部主体和外部主体混在一张表里那这个配对的语义就说不清楚了报表里“我们应该付谁钱”和“谁欠我们钱”必然乱套。主体建模还有一个容易忽略的点主体的社会身份和系统身份要分离。比如同一个供应商在税务系统里有纳税人识别号在银行系统里有账号在业务系统里有供应商编码。REA 的主体表里存的是业务身份标识其它身份信息放到扩展表里不要挤在一张大表里否则后续接入外部数据源做对账时字段冲突会让你怀疑人生。3. 用 REA 实操设计一个进销存系统理论说太多容易飘直接上一套我最近做的批发分销系统的核心模型以咖啡豆贸易场景为例。这个系统要管采购、销售、库存、收付款四个模块我全部用 REA 重新建模。3.1 场景定义与核心业务事件业务场景公司从多个农场采购生咖啡豆入库到自有仓库然后卖给烘焙商烘焙商收货后按账期付款我们去采购时也要给农场付款付款方式是预付加尾款。先把核心经济事件列出来这是建模的第一步也是最重要的一步。事件名称事件类型资源增减内部主体外部主体采购入库流入执行生豆库存 采购员、仓管员农场采购付款流出执行现金 -财务员农场销售出库流出执行生豆库存 -销售员、仓管员烘焙商销售收款流入执行现金 财务员烘焙商下采购承诺承诺事件无实际增减采购员农场下销售承诺承诺事件无实际增减销售员烘焙商看到没有传统系统的“采购单”在 REA 里被拆成了“下采购承诺 采购入库 采购付款”三个事件“销售单”被拆成“下销售承诺 销售出库 销售收款”三个事件。每个事件都有自己的时间点、自己的参与主体、自己影响哪些资源。这带来的直接好处是你永远可以回答“某笔采购单现在走到哪一步了”因为推导路径是确定的而不是靠一个随时可能被改错的状态字段。3.2 领域模型设计事件如何串联资源与主体上面的表格只是事件的清单REA 的精髓在于事件之间的关系。建模时我画了一张非常朴素的领域关系图这里不贴图用文字描述核心是下面四条关系规则。第一条资源流关系Stockflow事件引起资源的增减。采购入库事件流入“生豆库存”销售出库事件流出“生豆库存”。这种关系必须在事件行上记录“方向 数量 单价”也就是说事件表里要冗余保存资源变动的关键参数而不是靠 join 资源表去反推。第二条参与关系Participation事件和主体建立关联。采购事件关联到农场和采购员销售事件关联到烘焙商和销售员。注意一个事件可以关联多个主体所以事件-主体的关系通常要单独建一张关联表而不是在主事件表里塞两个外键你永远不知道以后会不会冒出“分销商的下级经销商也参与这个销售”这种需求。第三条双向关系Duality流入事件和流出事件成对出现。采购入库资源流入企业对应采购付款资源流出企业销售出库资源流出企业对应销售收款资源流入企业。这个配对关系要显式建模通常在两个事件上都保存对方事件的外键或者建一张事件配对表。有了双向关系月末对账就非常简单跑一个 SQL找那些“有入库但没付款”的采购事件就是应付账款找那些“有出库但没收款”的销售事件就是应收账款。第四条承诺与履行的关系Commitment-Execution承诺事件发生时不直接改资源而是生成一条待履行的承诺记录实际执行事件发生时把关联的承诺记录标记为已履行。用两张表分开存就能回答“承诺了 500 吨咖啡豆履行了 400 吨还剩 100 吨待交付”这种业务方天天问的问题。三条关系中最容易出错的是双向关系。很多同学建模时只记录了“销售出库”把“销售收款”扔给了财务系统去管分析的时候又想把两边数据关联起来然后发现没有任何字段能精确匹配。我的建议是从设计第一天就把收付款事件放在同一套模型里双向关系是 REA 的灵魂砍掉它还不如回去用传统状态机。3.3 表结构与关键代码落地领域模型确认后表结构就水到渠成了。我给出核心表的最小设计不包含索引等细节但表和字段的语义是完整的。-- 资源表存所有可增减的实体 CREATE TABLE resource ( id BIGINT PRIMARY KEY AUTO_INCREMENT, resource_type VARCHAR(32) NOT NULL, -- STOCK / CASH / RECEIVABLE name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL, -- 计量单位 UNIQUE KEY uk_resource (resource_type, name) ); -- 主体表内部和外部主体统一存储用 type 区分 CREATE TABLE agent ( id BIGINT PRIMARY KEY AUTO_INCREMENT, agent_type VARCHAR(16) NOT NULL, -- INTERNAL / EXTERNAL agent_role VARCHAR(32) NOT NULL, -- SUPPLIER / CUSTOMER / PURCHASER ... name VARCHAR(128) NOT NULL ); -- 事件表所有业务变动都记在这里 CREATE TABLE event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_no VARCHAR(64) NOT NULL, -- 业务编号 PO-20240512-001 event_type VARCHAR(32) NOT NULL, -- PURCHASE_IN / SALE_OUT / PAY_OUT ... direction TINYINT NOT NULL, -- 1资源流入 -1资源流出 happen_time DATETIME NOT NULL, -- 事件发生时间 resource_id BIGINT NOT NULL, -- 影响的资源 quantity DECIMAL(18,3) NOT NULL, -- 变动数量 unit_price DECIMAL(18,2) NOT NULL, -- 当时单价 commit_event_id BIGINT NULL, -- 关联的承诺事件如果这个是执行事件 paired_event_id BIGINT NULL, -- 双向配对的另一事件 UNIQUE KEY uk_event_no (event_no), KEY idx_resource_time (resource_id, happen_time) ); -- 事件-主体关联表事件和主体的多对多关系 CREATE TABLE event_agent ( event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(32) NOT NULL, -- 参与角色 PRIMARY KEY (event_id, agent_id, role) );表结构的核心思想是业务系统里不存“当前库存数”这种汇总值只存“每一笔导致库存变动的事件”。要算当前库存就查所有入库事件的流入总和减去所有出库事件的流出总和。听起来很低效确实不能每次读都实时聚合所以需要一个物化视图或者库存快照表快照表由事件表驱动刷新。这么做虽然多了一步但好处是无价的——你永远不会出现“库存台账和流水对不上”的问题因为台账本来就是从流水推导出来的。核心业务逻辑也不复杂关键是保证事件写入的一致性。以“销售出库”为例所有相关表必须在同一个数据库事务里更新Transactional public void handleSaleOut(SaleOutCommand cmd) { // 1. 找到对应的销售承诺事件 Event commitEvent eventRepository.findByEventNo(cmd.getCommitEventNo()); // 2. 创建销售出库执行事件 Event saleOut Event.createSaleOut(cmd); saleOut.setCommitEventId(commitEvent.getId()); // 3. 建立和应收事件的双向关系先置为空收款时回填 saleOut.setPairedEventId(null); eventRepository.save(saleOut); // 4. 登记事件参与主体 eventAgentRepository.save(new EventAgent(saleOut.getId(), cmd.getCustomerId(), CUSTOMER)); eventAgentRepository.save(new EventAgent(saleOut.getId(), cmd.getSellerId(), SELLER)); // 5. 更新承诺事件状态 commitEvent.markFulfilled(saleOut); eventRepository.save(commitEvent); // 6. 更新库存快照由事件驱动 stockSnapshotRepository.appendDelta(cmd.getResourceId(), -cmd.getQuantity(), saleOut.getHappenTime()); }注意第 3 步我特意把 pairedEventId 先置空等到收付款事件发生时再回填。为什么不一次性生成收款事件因为实际业务中销售出库和收款往往不同时发生收款可能有分期的、有部分回款。REA 允许一个销售事件对应多个收款事件一个配对多个所以先把配对关系留成空后续事件来了再逐个配对比传统系统里“一个订单关联一条回款记录”的表达能力强得多。4. REA 和传统 ERP 建模的本质区别在哪里很多文章讲 REA 都会对照复式记账法我这里不重复那种教科书式的对比直接说我在实际改模型时感受到的三个本质差异。第一状态机被推导取代。传统表结构里采购单有一个 status 字段取值范围可能是“待审批、已审批、已入库、已付款、已关闭”五个状态十种流转每一处流转都要写判断逻辑。REA 里没有任何状态字段每个事件的组合自然就代表了业务进度。这对于系统的好处是新增一种业务流转时你不需要改状态机逻辑只需要新增一种事件类型然后定义它与现有事件的关系。我在重构后发现代码里大量与状态有关的 if-else 几乎删干净了。第二面向“事实”而不是面向“界面”。传统建模经常被界面牵着走——页面上有一个“订单查询”功能数据库就建一张订单表页面上要显示“当前库存”数据库就搞一个库存字段实时 update。REA 逼迫你先问“业务的底层事实是什么”再去设计页面和接口。这个过程一开始会比较痛苦因为要先梳理业务领域而不是画表单但梳理完之后后续加什么功能都变得很简单因为底层事实是稳定的界面是易变的。第三审计追溯是天生的。REA 的每条事件记录都天然携带时间戳、参与主体、影响资源、关联的承诺和配对事件。传统系统要搞“操作日志表”但日志表记的一般都是谁在什么时间改了哪个字段这种表面信息REA 的事件表本身就是一份完整的、可重放业务过程的审计流。做合规审计或者排查数据争议时这种模型的优势是压倒性的。当然 REA 也不是银弹。它最明显的短板在“工作流”和“审批链”。传统状态机对审批流程的表达非常直接REA 需要额外引入“承诺-执行”机制去模拟“待审批”的状态建模复杂度会上升。我的建议是核心交易链路涉及资源、资金、主体的变动用 REA外围流程审批流、通知流、任务分配用轻量工作流引擎。两套东西配合既享受 REA 的逻辑一致性又不太牺牲工作流表达的方便性。硬要把审批流塞进 REA 模型里会把自己绕晕。5. 落地过程中的常见问题与排查经验这套模型我迭代了三个版本中间踩了不少坑。有些是设计层面的有些是代码层面的挑几个典型的分享一下。5.1 最容易搞错的“事件”和“单据”刚上手的时候团队里一个同事问我采购申请单算不算事件这问题很有代表性。在 REA 里采购申请只是业务单据不是经济事件。经济事件必须对资源产生实际增减或承诺增减。采购申请本质上只是“领导批准你去做一个采购计划”它既不改变资源也不形成对外部主体的承诺所以不应该建模成事件。我当时的处理方式是把采购申请交给审批流引擎去管理而 REA 模型从“下采购承诺”开始。如果你把采购申请也塞进事件表你会发现分析报表时还得反复过滤掉这些非经济事件平白增加复杂度。5.2 双向关系断链后对账对不齐第二版上线后财务反馈说月底对账差了十几单。排查半天发现原因是部分销售收款是财务手动录入的录入时没有回填 paired_event_id导致这些收款事件和销售出库事件之间没有配对关系。这个问题的根源是双向关系的维护被分散在两个功能入口里没有一个统一的配对服务。后来我专门写了一个事件配对服务每次创建收款事件时自动按照“外部主体 资源 金额匹配规则”去找未配对的销售事件找到就自动回填找不到就进入人工匹配队列。这个修复花了一个下午但对账准确率直接从 92% 提升到 99.8%。5.3 同一资源多计量单位导致的库存误差咖啡豆这个场景有个特殊问题从农场采购时按公斤计价但卖给烘焙商时可能按袋计价一袋固定 60 公斤。如果事件表里只存数量不存单位所有分析都会出问题。我最初为省事只存了数量结果库存报表里“袋”和“公斤”混在一起仓库方差点骂娘。后来在事件表里加了两个字段quantity 存的是资源原单位的数量additional_quantity 存的是业务口径下的数量并加了一个 unit_conversion 表维护不同单位之间的换算关系。这个经验也分享出来REA 里的资源计量单位一定要和事件一起建模宁可多写点换算逻辑也不要偷懒。5.4 疑难问题速查表症状可能原因解决思路当前库存算出来是负数事件方向标错或快照表增量更新遗漏检查事件 direction 字段重建库存快照应付账款对不上供应商账单采购付款事件未配对到采购入库事件检查 paired_event_id运行配对服务重新匹配订单状态页显示进度不准承诺事件和执行事件关联丢失检查 commit_event_id补齐承诺履行标记月度报表统计重复一次业务操作重复写入了多条事件在 event_no 上唯一索引并增加写入幂等校验跨部门口径不一致不同模块各自建事件表未统一事件定义建立全局事件字典所有模块共用一套事件类型定义5.5 性能问题的兜底策略事件溯源模式的通病是表数据量增长快聚合计算慢。我在系统里采用了“事件表 周期快照”的双层架构每天凌晨跑批生成前一天的库存和应收应付快照白天查询优先走快照需要追溯明细时才去查事件表同时用消息队列异步消费事件把聚合结果实时同步到 Redis保证 95% 的查询都落在毫秒级。这套方案上线后单月事件量达到百万级别查询依然保持稳定。6. 关于这套模型我个人的实操体会跟 REA 相处了快两个月最大的感受是它把“容易出错的建模决策”提前到了设计期把“后续维护的复杂度”极大降低了。传统建模像施工队图纸边画边改水泥已经浇下去才发现管线冲突REA 建模像搭积木基础块的种类是固定的资源、事件、主体你只需要确定它们之间的关系然后所有业务都能用同一套积木拼出来。如果你准备在项目里尝试 REA我的建议是先把核心交易链路的一两个事件做扎实不要一上来就全量迁移。我从“采购入库”这一个事件开始试点跑通之后业务方认可了才逐步把销售、收付款、库存全部迁过来。另外务必让熟悉业务的同事参与事件清单的梳理很多领域细节比如“运费算不算资源流入”“折扣是不是一种负向事件”他们一说你就明白了自己埋头琢磨反而会走弯路。最后分享一个小技巧给每个事件类型建一个文档卡片写上事件定义、资源流方向、参与主体角色、对应的反向事件放在团队的 Wiki 里。建模讨论时先对齐事件卡片再聊表结构沟通效率能提升一个量级。这套方法我在多个项目里反复用实测下来比任何建模工具的自动生成都管用。