REA资源-事件-代理模型:从业务事实到事件溯源的数据建模实践
第一次看到“REA”这三个字母我还以为是有人把 README 漏打了一个字母。直到真正扎进一个业务流程混乱的改造项目之后我才意识到 REA 是 Resource-Event-Agent 的缩写中文通常叫资源-事件-代理模型是一套从会计信息系统领域走出来的业务建模方法论。它不是某个框架也不是某种中间件而是帮你回答“系统里到底应当存哪些数据、这些数据之间的关系是什么”的一套思路。我最早接触 REA 是因为要做一套库存加财务对账的系统当时团队照着传统 ERP 的表结构抄了一遍结果发现业务部门根本不愿意认因为那些表描述的是“记账结果”而不是“业务事实”。后来改用 REA 重新梳理才发现很多争执其实不是需求不清而是大家脑子里的模型根本不是一个。这篇文章我就从 REA 的核心概念讲起再用一个具体的小项目带你把表设计出来最后把我踩过的坑、常用的排查技巧一起整理出来。无论你是做后端开发、数据分析还是被领导拉去搞业务流程梳理这套方法论都值得花半小时了解。1. 初识 REA它不是一张表而是一套建模视角1.1 REA 究竟是什么REA 最早是会计信息系统领域提出来的一套企业本体模型目的是把“企业的经济活动”描述得更贴近业务而不是贴近借贷记账法。传统会计系统里数据模型往往围绕“凭证、科目、分录”展开比如一笔销售系统里会存一张销售凭证里面写着借应收账款、贷主营业务收入。这套模型很成熟但它有个问题它是高度汇总后的结果业务人员看不懂开发人员也容易跟着科目表晕头转向。REA 换了个角度把业务活动拆成三个核心类别资源Resource、事件Event、代理Agent。资源是企业拥有的、有价值的东西包括存货、现金、固定资产、服务能力事件是业务过程中的真实发生比如客户下了单、仓库发了货、财务收了款代理则是参与这些事件的主体比如客户、供应商、销售员、仓库管理员。所有业务数据最终都可以落到这三类实体以及它们之间的关联上。我把它理解为一种“事实层”建模。传统表设计试图直接回答“期末库存是多少”REA 则先回答“这个月发生了多少次出库、每次出库涉及什么商品、谁操作的”。前者是结果后者是事实。系统里的任何数字都应该能从事实推导出来这是 REA 给我带来最核心的视角转变。1.2 为什么我不建议直接照搬 ERP 的表结构很多人一开始会问既然 ERP 那么成熟建进销存系统直接照搬老表结构不行吗我自己试过发现问题在于照搬表结构等于照搬别人系统的中间状态而不是业务需求。传统 ERP 的表结构经过了大量性能优化和组织流程定制表拆得极细字段含义极其依赖特定背景。当你把它搬到新项目里最先崩溃的不是存储而是“这个字段到底代表什么时候的数据”这类语义问题。举个例子传统库存表经常会有“现存量、可用量、在途量、冻结量”这几个字段。业务人员问“为什么可用量和现存量差这么多”开发人员往往要翻很久代码才能解释清楚。而如果用 REA 建模系统里只有“入库事件”和“出库事件”现存量是通过事件流水汇总出来的任何一个数字的变化都能追溯到某条事件记录。字段含义清清楚楚对账、排查、报表生成都有据可依。当然REA 不是银弹。它的初衷是描述业务语义并没有替你做性能设计。真实场景下你仍然需要物化视图、汇总表、缓存甚至 CQRS 来支撑查询。但它能把“业务模型”和“性能模型”分开先保证大家理解一致再考虑怎么优化这比一开始就陷入复杂的家谱式表结构要稳妥得多。2. 拆解 REA 模型的核心三要素资源、事件、代理2.1 Resource资源的边界与认定资源是企业在业务活动中涉及的有价值对象。判断一个东西是不是 REA 里的 Resource我习惯看两条一是它是否能在事件中被转移、消耗或创造二是它是否能够被跟踪数量或价值。比如存货、现金、应收账款、银行存款显然都是资源客户算不算资源严格意义上客户不是客户是 Agent但“客户关系”可以被当作一种无形资产资源这个需要看场景但建议不要在早期把边界画得太花哨。实操中容易踩坑的是把“订单状态”当资源。状态是事件发生后的派生结果不是资源本身。打个比方一个包裹从“已发货”变为“已签收”这中间是两次事件的发生签收事件才是真正改变了资源流向的事实状态只是投影出来的一个属性。如果你把状态当成资源去建模会出现大量重复、对不上的数据因为状态会频繁修改而事实事件是只增长的。我还有一个经验给每个资源类型定义好“基本计量单位”千万不要混着存。比如库存系统里箱、包、件如果不统一你会发现自己总是要写一堆奇怪的换算逻辑。更合理的做法是资源主数据里定义标准单位事件中记录数量时强制关联到标准单位展示层再做换算这在报表阶段能省非常多力气。2.2 Event事件是模型里最关键的原子事件是 REA 模型的引擎。没有事件资源和代理只是两张静态名单有了事件业务数据才流动起来。一个合格的 REA 事件应当具备三个特征可追溯、不可变、有参与方。可追溯意味着事件记录了“何时、何地、谁、做了什么”不可变意味着历史事件不能被修改有参与方意味着事件要连接到具体的代理和资源。我在设计事件时通常把事件区分为两类一类是经济事件比如销售出库、采购入库、支付货款另一类是承诺事件比如订单创建、签订合同。后者表示“未来会发生经济事件的约定”它与实际执行事件之间通过关联关系连接。对于大多数中小系统我建议不要把承诺事件建模得太细否则系统里到处是状态机复杂度会直线上升。你可以在业务层把订单状态处理好但底层的事实表仍然只记录经济事件和最终的承诺关系。另一个要把握的点是事件粒度。不是说“用户点击了加入购物车”就要建一个事件REA 关注的是改变资源状态、转移价值、产生义务的事件。过于细碎的事件只会让你的事件表变成日志表查询起来非常痛苦。我后面会专门讲这个坑在第三节落地案例中你也会看到我对事件粒度的取舍。2.3 Agent参与者与职责分离代理是参与事件的内部或外部主体。内部代理一般有销售员、仓库管理员、采购员、财务人员外部代理有客户、供应商、承运商。AGENT 和用户账号最好分开设计。账号是用来做登录鉴权的代理是用来描述业务责任的一个账号可以代理多个代理角色一个代理角色也可以被多个账号操作这种灵活度在权限经常变化的业务里尤其重要。REA 特别强调“职责分离”在建模中的地位。比如入库事件和出库事件如果允许同一个代理同时执行短期内流程很顺但到盘点对账时就会发现“账面没问题、实物对不上”的情况因为你根本无法还原责任链条。所以在事件表里不要只记录一个“操作人”字段我建议用一张 EVENT_AGENT 关联表把操作人、审批人、复核人都明确关联到事件上。这一开始看起来有些繁琐但每次出问题的时候你都会庆幸当时多设计了一行数据。2.4 资源、事件、代理之间最重要的三类关联把三类实体摆出来之后模型能不能跑起来取决于关联关系。REA 里最重要的关联有三组第一组是事件与资源的关联它表达资源的流入或流出。通常我会在关联表里加一个 quantity 字段和 direction 字段direction 代表流入还是流出。第二组是事件与代理的关联它表达谁参与了事件角色一定要细化。第三组是事件与事件的关联它表达业务上的因果对应比如“收款事件”对应“销售发货事件”“采购付款事件”对应“采购入库事件”。这三组关系中事件与事件的关系最容易被忽略但它恰恰是 REA 能够对账的关键。一个事件导致资源增加一定有另一个事件导致资源减少或者同一事件同时产生资源流入和流出。这个思维很像复式记账但它不是记科目而是记业务行为来源。有了这种配对关系资产负债表任何一个数字都能被解释成“特定事件组合的结果”对账只需要比对事件链条而不是比对一堆说不清含义的科目余额。3. 从理论到落地我实战过的库存销售对账项目3.1 项目背景与建模边界当时是在一个做办公用品零售的小公司里改造他们的进销存系统。业务场景不复杂从供应商采购仓库入库销售给客户仓库出库然后收付款对账。但旧系统把库存表、流水表、财务凭证表混在一个库里销售订单和退货单之间没有关联财务月底对账要对一个星期。接手之后我第一件事不是画 ER 图而是拉着业务方用 REA 方式把“业务事件清单”写出来。建模边界要控制好。当时我们决定只关注库存和价值流动不把审批流、日程管理等跟资源变动无关的内容塞进 REA 模型。凡是件“会改变资源数量或价值”的事都是事件凡是只改变“流程状态”但不动资源的事先不建模。边界画清楚之后整个讨论效率一下子高了起来业务方也不再被“借贷分录”这类财务概念带偏。3.2 用一张事件矩阵梳理业务我们做了一张事件矩阵来对齐认知。下面是一个简化的版本每个业务事件都对应了资源流入/流出和参与者角色事件资源变化内部代理外部代理对应事件采购下单承诺采购资源采购员供应商后续生成采购收货事件采购收货商品资源流入仓管员供应商对应采购下单事件支付采购款现金资源流出财务人员供应商对应采购收货事件销售下单承诺销售资源销售员客户后续生成销售发货事件销售发货商品资源流出应收增加仓管员客户对应销售下单事件收到销售款现金资源流入应收减少财务人员客户对应销售发货事件退货入库商品资源流入应收减少仓管员客户对应销售发货事件这个矩阵写完之后开发组很多人都松了口气。因为每个事件该写哪些字段、该跟哪张表关联基本是透明的。而且业务方也能在矩阵上直接指出“采购付款不应该和采购收货直接配对应该是付款对应到采购单”这是我们聊了四轮才对齐出来的差异点如果不是提前画了矩阵后续程序写出来一定返工。3.3 落成关系表设计六张核心表建模完成之后我们落成了六张核心表。结构大致如下为了清爽我省略了主外键以外的细节但实际表里可以把时间戳、备注、版本号都放上去。CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_type VARCHAR(32), resource_name VARCHAR(128), standard_unit VARCHAR(16), created_at TIMESTAMP, updated_at TIMESTAMP ); CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(32), occurred_at TIMESTAMP, recorded_at TIMESTAMP, business_ref VARCHAR(64) ); CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_type VARCHAR(32), agent_name VARCHAR(64) ); CREATE TABLE event_resource ( event_id BIGINT, resource_id BIGINT, direction TINYINT, -- 1 流入-1 流出0 承诺 quantity DECIMAL(18, 3), PRIMARY KEY (event_id, resource_id) ); CREATE TABLE event_agent ( event_id BIGINT, agent_id BIGINT, role_type VARCHAR(32), PRIMARY KEY (event_id, agent_id) ); CREATE TABLE event_link ( link_type VARCHAR(32), left_event_id BIGINT, right_event_id BIGINT );这套结构里有几个设计点我要单独说明。event_resource 表里的 direction 字段代替了单独的“出库表、入库表”这让统计变得非常统一SUM(quantity * direction) 就是资源净变化不需要再 union 两张表。event_link 表则是把事件之间配对关系解耦你可以在不修改事件主表的情况下补充新的业务联系。另外业务单据号如订单号、发票号建议放在 business_ref 字段而不是拆成大量 on 主表上互相引用的外键这样能避免过深的关联查询。3.4 结合事件溯源把流水变成唯一事实源这套结构天然适合事件溯源模式。我们在生产库里把 event 表和 event_resource、event_agent 三张表作为 append-only 日志任何状态都不直接更新而是通过新增事件来表达变化。比如要纠正一笔发错货的问题不会去修改原来的销售发货事件而是新增一个“退回入库”事件再新增一个“重新发货”事件。这样审计追踪非常清楚财务也不再害怕系统里的历史数据被改得乱七八糟。当然事件溯源总有一个绕不开的问题查当前状态性能差。我们的应对方案是维护一组投影表。投影表可以由 Redis 缓存冗余也可以用物化视图定时聚合或者自己写订阅任务去更新汇总表。关键是投影表丢了也能从事件流水重建所以它的可信度永远低于事件表。你不用担心投影表和事实表偶尔对不上因为只要重建流程存在最终一定会收敛。这个思路救了我们好几次想当年有一次某台服务器磁盘损坏好在我们保留了事件流水重建之后数据完全落地。4. 常见问题与排查技巧实录4.1 “关联表太多查询变成三张表 JOIN会不会要命”这是最多人质疑的点。传统订单表一条记录就能查出客户和金额而 REA 风格通常要 event 关联 event_agent 再关联 agent还要 event 关联 event_resource。确实多两个 JOIN但只要索引建对大多数业务体量完全扛得住。而且实际查询通常发生在投影表上不是每次都直接查事件原表。你把写路径和读路径拆开之后JOIN 深度的问题基本就不是问题。如果查询压力真的很大我的建议是不要为查询把 REA 结构拆坏而是增加物化投影表。投影表可以非常“传统”比如直接生成一张宽表 orders_snapshot专门给列表页和报表用。只要保证投影表是从事苏联建的底层 REA 语义就不会被破坏。生产里命令库负责接收事件查询库负责读宽表中间用一个订阅机制同步这是很稳妥的组合。4.2 事件粒度到底怎么切才不会变成垃圾日志答案取决于一句话这个事件是否改变了经济资源或业务责任加入购物车不改变库存也不产生付款义务所以它不是核心经济事件但提交订单产生了未来发货的义务可以作为承诺事件结算单产生支付义务则应作为经济事件。如果你把所有按钮点击都塞进去事件表会疯长业务语义也被稀释。我建议事件分层处理。真正的 REA 事件只放经济事件与关键承诺事件用户行为日志放到另一套日志系统流程节点状态放到工作流表。这样事件链路保持干净对账逻辑只需要读懂核心事件而用户行为分析在大数据平台去查日志就好两者互不污染。4.3 对账时发现对不上该从哪里开始盘每次月底对账团队最先检查的往往不是财务凭证而是事件流水时间线。有两类问题很典型一类是事件漏记比如仓库发了货但没有生成销售发货事件另一类是多记比如同一次退货同时录了退货单和红字销售单。REA 模型下排查思路很清晰先把资源当前结余算出来再与账面数比对差异收窄到具体资源类型然后拉出该资源的相关事件列表逐条检查事件与 event_link。因为每个事件都有代理和业务引用很快就能定位到到底是哪次操作导致的差异。还有一个容易忽略的坑时间戳语义不统一。事件流水里至少要有 occurred_at 和 recorded_at 两个时间字段occurred_at 是业务发生时间recorded_at 是入账时间。如果只存一个时间当业务补录、系统重推时会发生时间倒挂报表立刻乱掉。分布式环境下尤其要注意时钟一致性哪怕只是把两者都存下来排查时都能省很多时间。4.4 幂等与重复事件问题在事件溯源系统里重复提交是个必须处理的问题。同一笔发货请求如果被推送两遍事件表就会生成两条重复事件库存凭空少一次。我们当时的做法是在 event 表上做业务唯一键一般是 event_type business_ref 关键资源号组合唯一。收到事件时先查唯一键存在就跳过。这和支付系统里的幂等键思路一模一样先保证不重复再考虑如何并发。如果业务允许补偿性操作比如“撤销”“退款”我建议不要设计 delete 不同事件而是新增加一个类型为“冲销”的事件。事件主表永不更新冲销事件通过 event_link 的 link_type“REVERSE” 关联到原事件。这样做的最大好处是审计时能看到“原事件冲销事件正确事件”的全过程而不是只看到最终状态这对合规审计和防止扯皮有不可替代的价值。4.5 资源分类太粗或太细都会让模型变难用刚开始建模时我把“商品”整个当作一种资源结果销售报表没问题但财务对账时发现“商品库存成本”没法算因为不同批次进货价不同。后来我们把资源拆成“当前库存商品”和“库存商品批次”或者再细一步通过批次属性来区分。资源拆得过粗无法支撑成本核算拆得过细维护主数据的成本又急剧上升。我的经验是“资源粒度与业务管理粒度对齐”。如果财务只按一个月的平均成本看库存那就不需要按批次建模按商品建模就够了如果客户要求每批货物的进价可追溯那就按批次资源建。用一个资源类型字段加属性扩展表会更灵活不要一上来就为每类资源单独建表否则后续加一种新资源你就要改表结构。5. 落地 REA 后我的一些体会前前后后我用 REA 重构过三个项目最大的感触是这个模型的价值不在于它给你规定了几张表而在于它逼你先回答“业务里到底发生了什么”再去回答“系统里该存什么”。过去开会时业务说“要一个总库存”技术说“总库存就是表里加个字段”两边都很自信结果上线后谁也讲不清楚数字的含义。REA 的六张核心表设计一摆出来客户、销售、仓库、财务各自的责任边界和事件流一目了然讨论终于可以回到真实业务流程上。如果你现在负责一个旧系统改造我建议不要急着把整套架构切到事件溯源先把现有业务中最重要的三类实体梳理出来用一段脚本把旧数据映射成事件流水试试。这种渐进式改造比一次性推翻旧表要稳妥得多团队压力也小。如果真的想把它当作新系统的底座配一个足够熟悉事件溯源的同事一起做否则踩坑成本会高到让你怀疑人生。最后分享一个实用的小习惯每个事件都留一个业务幂等键和一个可以解释业务来源的备注字段。表面上看只是多存了两列但每次排查、对账、数据修复时你都会感谢当初留下了这两个信息。REA 只是起点怎样让模型在真实业务压力下持续好用才是永远的主题。