你知道REA吗?我第一次看到这个词的时候,以为是“React”的拼写错误,后来才意识到说的是企业建模里的 REA(Resource-Event-Agent,资源-事件-参与者)参考模型。做业务系统的这些年,我见过太多团队陷入“数据模型围着账本转”的泥潭,库存表、流水表、台账表堆了一堆,最后还是说不清“到底发生了什么”。REA 是一个被严重低估的建模思路,它跳开报表和账户,直接刻画业务本身的资源流动、事件记录和参与者责任。这篇文章想把这套模型掰开揉碎,讲讲它背后的逻辑、落地方式,以及我实际踩过的坑,适合正在做业务建模、领域驱动设计、会计信息系统或想要把数据资产盘活的工程师和产品经理。
1. 从一团乱麻的业务数据说起:REA到底解决什么问题
做业务系统的第一年,我被分配去做一个库存管理模块。打开旧系统,满眼是stock_in、stock_out、stock_total这样的表,每条记录都有“单据编号”“经办人”“备注”。看起来很完整,但新功能一上来就抓瞎:要统计某个时间段内某个仓库的库存周转率,要回答“为什么库存数量是对的,金额却对不上”,要追溯某批货到底是从哪个供应商来的、谁审批的、成本价怎么变了?旧模型里这些信息分散在五六个系统里,对来对去,一个晚上就没了。
后来我接触到了 REA 模型,才发现传统设计把“结果”当成了“事实”,而 REA 是反过来,先记录事实,结果自然可以算出来。
1.1 传统业务建模的病灶:一切为了“账”
传统的数据模型通常围绕“账户”“凭证”“余额”来设计,比如采购单、销售单、收款单,本质上是在模仿纸质会计流程。这种模型有两个先天问题:
- 冗余严重:同样的“入库”事实,可能同时写进库存台账、物料流水、财务明细,多方对账才能保证一致。
- 信息丢失:一旦把“发生了什么”压缩成“借多少、贷多少”,业务过程里很多关键上下文——比如事件发生在哪个环节、谁发起的、为哪个约定服务——就被丢掉了。
账面数字如果对不上,排查起来只能靠人肉遍历单据,因为模型里根本没有记录“资源如何流转”的完整链路。
1.2 REA 的核心主张:业务事实是一张网,不是一串数字
REA 提出了一个看上去简单但极其有用的视角:所有业务都是“经济资源”在“经济事件”中被“参与者”处理和转移的过程。库存里的商品是资源,采购入库是一个事件,供应商和仓管员是参与者;销售出库是另一个事件,客户和销售员是参与者。事件之间通过“资源流”连成一张因果网络:一个入库事件产生了可用库存资源,一个出库事件消耗了这些资源。
这种模型不再关心“余额是多少”,只关心“哪些资源因为哪些事件、在哪些人参与下变成了其他资源”。只要你把事实记录完整,余额、周转率、成本、利润全部可以从这张网上推导出来。
2. REA 的三个基本构造块:资源、事件、参与者的深层含义
REA 的基石只有三个元素,但每个元素的理解深度决定建模质量。很多人以为 Resource 就是商品、Event 就是流水、Agent 就是人员,这确实是入门理解,但背后要抠的细节不少。
2.1 Resource(资源):不只是资产,更是“可被事件影响的实体”
在 REA 里,资源是任何对业务有价值、可以被经济事件增加或减少的东西。它可以是物理商品、服务工时、额度、积分、甚至一段数据的使用权。关键特征是:资源必须能标识、能计量、能被事件引用。
我常用的一个判断标准:如果某个实体不会因事件而发生增减变化,那它大概率不是资源,而是分类或配置。比如“供应商”本身是参与者,不是资源;“采购合同”表示的是一个约定关系,也不是资源。只有像“库存A批次”“现金”“每小时咨询服务”这样能数量化、能流进流出的,才叫资源。
2.2 Event(事件):业务里最有价值的“原子事实”
事件是 REA 模型的心脏。它代表一个发生在某个时间点、与一个或多个资源相关的业务动作。比如“采购入库”“销售出货”“人工记账”都是事件。事件要记录时间、涉及的资源数量、参与者,以及它与其他事件之间的依赖关系。
一个细节:在 REA 里,事件之间可以互相建立连接,最常见的是“交换”关系。比如销售出库(资源流出事件)和收款(资金流进事件)通过一个“销售承诺”连接起来。你在数据库里看到的可能是两个事件都关联同一个合约ID,但模型层面它们是一对“交换关系”。
2.3 Agent(参与者):承担权利义务的人或角色
参与者是可对事件负责的人、部门、系统或组织。参与者可以是个体,也可以是一个角色。REA 允许一个参与者在不同事件中扮演不同角色:同一个人可以是采购员,也可以后来变成库管。
拆分角色和参与者很重要,否则会出现“离职了数据怎么改”这样的问题。应该把张三作为参与者独立存表,把“采购员”作为角色独立存表,事件关联的是角色实例或者参与者实例都可以,但要明确语义。一般做法是事件直接关联参与者,同时参与者关联角色定义。
3. 用 REA 改造一个库存模块:从 ER 图到 REA 模型的全过程
讲了这么多概念,不如直接看一个例子。假设某公司要做一个简单的进销存系统,管理商品的采购入库、销售出库和库存盘点。
3.1 先画传统模型长什么样
传统设计一般这样:一张Product表(商品主数据),一张Stock表(当前库存数),一张Transaction表(出入库流水)。每次出入库,先改Stock.quantity,再插入一条Transaction。看起来没毛病,运维第一个月也没问题。
后来需求来了:要记录“这批货是哪个采购订单来的”,要按供应商维度算库存占比,要追溯“仓库里有一批过期批次怎么处理”。传统模型就崩了:Transaction表里根本没有采购订单、供应商、批次这些字段,只能加列、加关联,越加越乱。
而改用 REA,我们在建模一开始就会考虑:商品是被管理的资源,采购入库事件会增加商品可用量,销售出库事件会减少商品可用量,供应商和客户是参与者,采购员和销售员是事件的执行参与者。
3.2 REA 建模的四个步骤
第一步,识别资源。库存模块里最核心的资源是“商品可用量”。如果需要有效期管理,可以把“批次”也作为子资源。第二步,识别事件。最少要有 Purchase-In(采购入库)、Sales-Out(销售出库)、Adjustment(盘点调整)。第三步,识别参与者。涉及供应商、客户、采购员、仓管员、销售员。第四步,连接关系。事件改变资源增量,事件之间通过“承诺/兑现”相连,参与者负责事件发生。
对应到表设计,我建议这样:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| resource_class | resource_class_id, name | 资源分类,比如“商品” |
| resource | resource_id, class_id, sku, name, unit | 资源实例,比如某SKU |
| event | event_id, time_occurred, event_type | 事件表,记录“何时发生了什么” |
| event_resource | event_id, resource_id, quantity, direction | 事件-资源关联,方向表明增加还是减少 |
| event_agent | event_id, agent_id, role_id | 事件-参与者关联 |
| agent | agent_id, name, type | 参与者表 |
| policy | policy_id, description, condition | 业务规则,比如“允许销售数量不得超过当前库存” |
需要说明的是,实际项目不用把 REA 当成银弹,也可以只保留核心的event_resource和event_agent,资源表不一定要严格面向对象设计,关键是把“事件”作为独立的中心实体,而不是流水。
3.3 为什么这种改造值得做
改造后的模型最立竿见影的好处是:新增需求不需要改表结构。比如要支持“多仓库存”,只需要把仓库作为资源的一个维度,或者在event_resource里加一个warehouse_id;要支持“销售退货”,只需要增加一个Sales-Return事件,它把资源从客户那边流回公司,方向是增加。这些在传统模型里可能要加一堆状态位、Flag、Transaction Type,可读性很差。
第二个好处是对账不再靠嘴。由于所有业务结果都是从事件推算出来的,同一张事件表既能服务财务看成本,又能服务运营看库存,数字是一致的。我在实际项目里统计过,改造后至少有 80% 的“数据对不上”问题直接消失,剩下 20% 是外系统数据本身不干净。
4. REA 在软件实现中的落地:数据模型、状态机与审计追溯
概念落地总要过三道坎:数据库表怎么建、代码逻辑怎么组织、历史数据怎么追溯。这一节重点分享我的具体做法。
4.1 表设计的核心原则:事件不可变,资源可重算
在 REA 体系中,事件表一旦写入,就不允许修改或删除。如果需要纠正错误,应该写一个新的“更正事件”来抵消旧事件。这有点像会计里的红字更正,也像事件溯源架构里的补偿事件。好处是任何时刻的状态都能由历史事件重新计算得到,方便审计,也方便做回溯分析。
对应的字段上,我习惯给每个事件加一个occurred_at业务发生时间,和一个recorded_at系统记录时间,两个分开。因为业务时间可能和单据录入时间不一致,混在一个字段里,后期做时序分析会很痛苦。
event_resource表里要存direction字段(in/out),或者用数量正负号表达。我个人更推荐用direction字符串加quantity_abs绝对值,因为它更语义化,避免把负数当异常。事件关联资源时还可以带上unit_cost、unit_price等上下文数值,这样后续算成本就可以直接读取,不需要再去配置表里找。
4.2 代码层面的组织:把 REA 事件当成一等公民
在编码时,可以用领域事件的方式来处理。每次操作走一个EventBus或者一个EventStore,先持久化事件,再更新数据状态。如果使用关系型数据库,可以把“插入事件+更新资源”放在同一个事务里,保证原子性。
一个典型的入库操作伪码大概长这样:
public void recordReceipt(PurchaseOrder order, List<ItemLine> items) { Event receivedEvent = eventRepository.create( "Purchase-In", order.getWarehouseId(), now() ); for (ItemLine line : items) { eventResourceRepository.create( receivedEvent.getId(), line.getSkuId(), line.getQuantity(), "IN", line.getUnitCost() ); resourceQuantityService.increaseAvailable(line.getSkuId(), line.getQuantity()); } eventAgentRepository.create(receivedEvent.getId(), order.getSupplierId(), "supplier"); eventAgentRepository.create(receivedEvent.getId(), currentUserId(), "warehouse-keeper"); }当然,很多项目并不会真的去引入完整的事件溯源架构,采用“事件表+资源状态表”并存的混合模式也是常用方案。只要记住一个原则:状态表可以看作事件的物化视图,一旦状态表和事件表对不上,以事件表为准,并通过对账任务发现数据问题,而不是反过来怀疑事件记录错了。
4.3 用状态机意识对待资源流转
REA 没有显式定义状态机,但每个资源其实都有一个基于事件推得的状态:在途、可售、冻结、作废、已售。不要用字段直接存状态,而是从最新事件推导,或者缓存一个current_status字段,同时由事件链来驱动更新。
我在项目里会额外定义一个“资源生命周期”视图,列出该资源所有事件的时间线和当前状态。前端展示时非常直观,后端排查时也很快能看到是哪一步把它变成当前状态的。这个视图用一条 SQL 就能查出来:
SELECT e.id, e.event_type, e.occurred_at, er.quantity, er.direction FROM event e JOIN event_resource er ON e.id = er.event_id WHERE er.resource_id = 'xxx' ORDER BY e.occurred_at;注意:这里要特别注意时间排序。如果系统允许补录历史事件,
occurred_at可能跨天甚至跨月,不能简单用自增 ID 排序,应该先按业务时间排序,再按记录时间打破平手。
5. 误用 REA 的三个常见失足点:别把分类当实例,别把计划当事件,别把角色当参与者
我看过很多团队声称用了 REA,但建模图上很快变成一锅粥,原因就出在三个基本概念的混用上。这里逐一说明,希望能让后来者少走弯路。
5.1 把“商品分类”当“资源实例”
最常见的错误是:只建了一张“商品分类表”,然后所有事件关联到商品分类上。这样做的后果是,当某个分类下的单个商品价格或批次不同时,事件里根本记录不了这种差异。
正确的做法是,资源表里每一个可流转的商品个体(或每一个 SKU+批次)都是独立的 resource 实例,商品分类只是 resource_class。拿服装系统打比方,不能把“T恤类”当成资源,要把“黑色M码T恤,入库日期2025-03-01”的某个库存批次当成资源。如果连批次都不需要,至少也是 SKU 级别的资源实例。
5.2 把“采购计划”当“采购入库事件”
计划是未来的、有待执行的东西,而事件是已经发生的事实。不少建模方案直接把PurchaseOrder表作为事件表,于是“下单”和“入库”混在一个流程状态里,问题来了:同一个采购订单可能分多次到货,传统模型要么加一堆“已到货数量”字段,要么复制订单行,都很丑陋。
在 REA 中,采购订单是一个“承诺”实体,它连接预期的资源流,但不是事件本身。真正的事件是每一次实际的入库动作。一个订单可以对应多个入库事件,每个入库事件更新对应资源的数量。承诺和事件之间的连接是一对多关系,这样多次到货不需要加特殊逻辑,天然支持。
5.3 把“角色”和“参与者”混为一谈
有张agent表,里面存了“采购员”“仓管员”“销售员”这类名称,然后把事件关联到这个角色名上,等于把角色当成参与者。问题是角色是抽象的,张三今天做采购员,明天也可能做仓管员。如果事件只关联角色名,你就无法回答“张三经手的入库事件有哪些”。
最稳妥的建模是:参与者表存具体的人或系统账号,角色表存角色定义,然后通过关联表(或直接在事件参与者里同时存agent_id和role_id)来表达“某个参与者以某个角色参与了事件”。这样既能按人追溯,也能按角色统计。
6. 为什么团队值得为 REA 多花一次建模的时间
REA 不是银弹,也不会让你的系统自动变快,甚至前期建模会比画传统 ER 图多花不少时间。但如果你面对的是一个长期演进、业务规则复杂、报表需求不断变化的系统,这一笔建模投入,回报率相当高。
6.1 建模前多问三个“REA 式问题”
不管最终是否完整落地,我建议每个团队在做业务模块设计时,都试着问自己三个问题:
- 这个业务里,真正的“事件”是哪些?事件之间如何相互连接?
- 每个事件改变了哪些“资源”的哪些属性?
- 谁是这个事件的“参与者”?他们以什么角色参与?
你会发现,只要这三个问题答清楚了,数据模型、接口设计、权限设计都已经有了骨架。传统建模常常先问“要展示什么报表”,所以设计出来的表总是跟着报表跑,报表一变表结构就跟着变。而 REA 是问“业务事实是什么”,报表只是事实的一种投影,换个投影视角不需要改事实层。
以我之前做过的模拟项目来说,最初同事都不愿意改用 REA,觉得库存表直接存个数量不香吗。后来连续三个迭代需求都涉及“采购成本重估”“退货追溯”“批次过期预警”,大家突然发现,只有事件级别的记录才能支撑这些查询。数量表给不了历史,给不了新旧批次拆分,给不了多参与方责任追溯——这些都得靠事件网。
6.2 落地时可以渐进推进,不必一步到位
如果你身处一个老系统,重构成本极高,不需要把整张业务网络全部推倒重来。可以先把“核心事件”和“核心资源”抽出来,从新模块开始建 REA 关系,然后逐步将历史数据迁移视为“初始事件”。比如上线当天,把当前库存快照作为一条“期初导入”事件,之后所有业务都通过事件记录增量,这样新旧数据可以并存。
进阶建议:如果你对事件溯源架构、CQRS 有了解,REA 可以和它们配合得很好。REA 给出的是领域概念模型,事件溯源给的是存储模式,CQRS 给的是读写分离方向。三者可以各司其职,不存在冲突。
7. 总结不必要,但有几个实在的体会
文章写到这里已经很长,我不想再做那种“本文介绍了”的收尾,只分享几个真实的个人体会。
第一,改善业务流程的关键不是多建几张表,而是换一种看待业务事实的方式。REA 让我学会了“记录事实,而不是记录结果”,业务流程、审批流、权限逻辑最后都变成围绕事件的自然表达,而不是一堆魔法字段。
第二,别急着上工具,先拿纸和笔画一遍。我在实际做某个跨平台系统的时候,第一版直接画数据库表,结果逻辑混乱。后来先在白板上按 REA 画了资源、事件、参与者的关系图,再回头看,原来几个一直“说不清楚需求”的地方,只是因为没有把“事件”单独拆出来。
第三,团队协作时,REA 名字听起来高深,但只要用一次,大家就会上瘾。因为它减少了大量的“我猜你这条记录是什么意思”的沟通成本。事件是公共事实,资源是公共实体,参与者是公共责任方,每个人都基于同一套词汇沟通,效率高很多。
如果你正在为某个业务系统的数据模型头疼,不妨试着找一张白纸,画下你最关心的资源、事件、参与者,然后沿着事件链去追问“后来发生了什么”。也许你会发现,答案比想象中清晰得多。