有次评审一个供应链系统的状态设计,业务方反复跟我强调“订单就应该是订单,你给我拆成采购单、入库单、销售单、出库单这么多表干什么”。当时数据库里确实一堆以 status 字段为核心的业务表,程序里到处在判断“待支付变成已支付”“已支付变成已发货”,改到后面自己都分不清哪个状态是哪个节点产生的。后来我换了个建模视角,先把“订单”这个概念本身丢到一边,只问三个问题:哪些东西是被消耗或增加的,哪些动作导致了变化,以及谁有权利发起这些动作。这三个问题的答案,串起来就是 REA 的核心骨架:资源(Resource)、事件(Event)、代理(Agent)。这套模型最早在会计信息系统领域被作为一种语义建模方式提出来,后来逐渐被用到业务系统设计、领域驱动建模、财务对账系统里。这篇文章就是围绕 REA 做一次完整的建模实战拆解,以一套果蔬批发系统为例,讲清楚概念、建模步骤、物理表落地、对账机制,以及我踩过的坑。想把业务模型做得更贴近真实业务、少在状态上反复救火的读者,这篇文章应该能帮到你。
1. 从“订单不只是一张表”说起:REA建模的底层视角
1.1 传统关系建模的痒处:状态字段越改越糊
做业务系统久了会发现一个规律:凡是重度依赖状态字段的模块,维护成本一定高。原因倒不复杂——状态字段描述的是“结果快照”,而不是“变化过程”。你看到一条订单记录的 status = 3,只能说明它到了某个阶段,但说不清楚它经历了什么、是谁在什么时间让订单产生了变化、当时对库存和资金产生了哪些实际影响。
比如一个典型的订单表,里面会有下单时间、支付时间、发货时间、签收时间,这些时间字段其实是在用“多个快照”模拟事件发生顺序。一旦出现售后、部分退款、部分发货,状态就爆炸了。是我先退款再发货,还是先发货后退款?是两个独立动作,但传统表设计里往往只保留一个最终状态。
REA 的思路不是这样,它把“变化过程”放回建模的中心。事件是一等公民,状态只是事件累计的结果。你要知道某个订单现在是什么情况,就去查它关联过哪些事件,而不是读一个可能已经写乱的 status 字段。
1.2 REA 是什么:资源、事件、代理三角色
REA 的全称拆开是三个概念,Resource、Event、Agent,对应我在开头说的三个问题:
- 资源:业务中会被创造、消耗、交换或转化的“有价值的东西”。现金是资源,库存果品是资源,固定资产是资源。它不是静态档案,而是必须会因业务动作产生增减的东西。
- 事件:直接导致资源增减或权责关系变化的动作。采购入库、销售出库、收款、付款、报损耗,都是事件。
- 代理:发起或参与事件的人、部门、企业。采购员是代理,供应商是代理,客户是代理,我们自己公司也是一类代理。
关键点在“事件即中心”。资源不自己消失,必须有一个事件让它消失;代理不直接改资源,代理是发起事件的主体。资源、事件、代理之间天然组成一张网状关系,而不是传统ER模型那种“表-表”的平面关系。
1.3 为什么会计领域会诞生这套模型
REA 的思想源头在会计信息系统。早期会计系统的数据模型往往围绕“科目”来建,借贷分录是核心。但科目思维有一个短板:它关注的是记账结果,而不是业务本身发生了什么。一笔应收账款的增加,背后可能是卖了一批菜,也可能是垫付了一笔运费,如果不绑定关联的业务事件,数据很容易变成一本糊涂账。
会计领域于是有人提出一个新问题:一套好的业务模型,能不能先不依赖借贷规则,先把“经济后果”描述清楚?结果就是 REA。资源对应经济资源,事件对应经济事件,代理对应经济主体,三者组成的关系能完整表达“谁在什么时间、以什么方式、改变了什么资源”,借贷分录反而成了从 REA 推导出来的记账实现。这一点后来被软件开发者看中,因为它天然适合描述需要审计、追溯、对账的业务系统。
2. 拆开REA的三个核心要素:资源、事件、代理到底指什么
2.1 资源:只有“会被经济事件改变”的才算
建模时最容易摸不清的就是资源。很多同事第一反应是“商品表就是资源表”,其实不准确。资源不是用于展示的商品档案,而是“参与经济存量和流量的实体”。水果的商品档案里有品名、条码、规格,这部分属于主数据;但库存水果才是资源,因为它会被入库事件增加、被出库事件减少。
判断一个东西是不是资源的办法很简单:问一句,它会被哪个事件改变?如果有人给你报了个损耗数,那么被损耗的东西是资源;如果只是描述性的、不会因事件而增减的信息,那就不是资源,归属主数据或其他维表。
还有一个容易忽略的知识点:现金和应收账款也算资源,而且相当重要。很多团队做库存系统时只盯着货物,不看资金,导致货款流向和业务流向对不上。REA 建模时,资金资源和货物资源要同时建模,因为它们会通过销售、采购这类事件发生联动。
2.2 事件:系统里唯一允许写资源的地方
事件是整个 REA 模型的操作入口,记住一条原则:任何资源的增减只能由事件触发,程序里不允许直接 update 资源表里的数量字段。这条规则听起来简单,落地后能避免一大类并发和一致性问题。
事件又分几种类型,常见的是:
- 获取事件:从外部获得资源,比如采购入库、现金收款。
- 转换事件:资源在内部发生形态或性质变化,比如把整箱苹果分装成小盒、把鲜果做成切盘。
- 交换事件:资源在不同代理之间发生所有权转移,比如销售出库、采购退货。
我通常还会给事件加一个生命周期:计划态、执行态、完成态、补偿态。比如“今天要出库100斤”是计划事件,“实际称重出库95斤”是执行事件,“其中有5斤在搬运中损坏改成损耗事件”就是补偿或修正。这样比单一状态字段更能表达业务波折。
2.3 代理:权责人划分,内部代理和外部代理
代理是发起事件的“人”。不是指具体某个员工,而是员工所代表的角色或组织单位。代理需要区分内部和外部:内部代理是我们公司的岗位角色,比如采购员、仓管员、销售员;外部代理是业务对手,比如供应商、批发客户、物流承运方。
很多传统表设计把“供应商ID”“客户ID”挂在单据上就完事了,不区分发起人和责任人。REA 要求明确事件的控制关系:这个采购事件是谁发起的,归属哪个内部代理;这个销售事件指向哪个外部代理;收钱事件里,钱是从哪个客户身上收来的。这样权责边界清楚了,后续做权限、做对账、做责任追溯都有了依据。
2.4 三类事件和三种关系的组合方式
REA 虽然只有三个核心概念,但概念之间的关系是固定的三种,所以组合起来并不散乱:
- 存量-流量关系:事件与资源之间的关系,描述资源被增加或减少。一个采购事件增加库存水果,一个销售事件减少库存水果,都是这类关系。
- 二元关系:一个事件与另一个事件之间的双向配对,描述业务上的“一进一出”或“一前一后”。采购入库事件与付款事件配对,销售出库事件与收款事件配对。
- 控制关系:事件与代理之间的关系,描述该事件由谁发起、对谁负责。
这三个关系的组合方式可以用这个表格说明:
| 关系类型 | 连接的主体 | 表达的业务含义 | 典型例子 |
|---|---|---|---|
| 存量-流量关系 | 事件 -> 资源 | 资源因该事件是增加还是减少 | 采购入库事件 -> 库存果品增加 |
| 二元关系 | 事件 <-> 事件 | 两个经济事件互为业务对应 | 销售出库事件 <-> 收款事件 |
| 控制关系 | 事件 -> 代理 | 谁发起、谁受益、谁负责 | 采购事件 -> 采购员;销售事件 -> 客户 |
一旦这套关系画出来,整个业务系统的语义图基本成型。后面对账、审计、流程编排都从这张图上推导。
3. 实战建模:用REA做一套果蔬批发系统的领域模型
3.1 需求对象界定与领域名词收集
直接讲我做过的一个模拟项目,一套果蔬批发系统。业务背景很简单:一家批发商从农户手里收蔬菜水果,存入冷库,然后卖给周边餐饮门店和生鲜超市。要求能做采购入库、销售出库、损耗登记、收付款对账,还要能回答“某个客户还欠多少钱”“哪批菜的损耗率异常”这类问题。
建模第一步不是画表,而是收集领域名词。我把业务方常说的词全部列出来:农户、供应商、采购员、月结客户、现金客户、采购单、入库单、销售单、出库单、对账单、冷库库位、香蕉、苹果、损耗、分拣、整箱入库、拆零出库、预付款、货款、欠款。我一边听一边在纸上打标,哪些是资源,哪些是事件,哪些是代理。
这个过程很关键,因为业务方的语言里混着表名、单据名、实体对象名和报表指标,如果不先归到 REA 的三角色里,后面建出来的模型很容易变成“旧表换新名”。
3.2 识别资源、事件、代理
接下来把上面这些名词分类。
资源主要有两类,一类是货物资源:库存果品,单位可以是箱、斤。一类是资金资源:现金账户、应收账款、应付账款。不需要把每一批货单独定义为资源,因为批次、保质期这些属于事件或明细属性,而不是资源本身。
事件包括:采购入库事件(增加库存果品)、采购付款事件(减少现金或应付账款)、销售出库事件(减少库存果品)、销售收款事件(增加现金或应收账款)、损耗登记事件(减少库存果品)、分拣转换事件(整箱原料转为拆零商品)、库存盘点调整事件。
代理包括:内部代理是采购员、仓管员、销售员、财务员;外部代理是农户、客户、物流公司。需要注意的是,同一个主体在不同业务场景下可能承担不同角色,比如一个农户既是供应商,也可能直接开车来门店买货变成客户,所以代理角色要独立建模,不能把“供应商主体”和“客户主体”写成两个物理表。
3.3 实体关系落地
这段我用文字加表格来描述最终的关系结构,不用图,因为文字足够讲清楚。
- resource 资源表:保存货物资源和资金资源的档案,product_name、unit、resource_type,其中 resource_type 区分“库存果品”和“资金账户”。
- agent 代理表:保存内部角色和外部主体,agent_name、agent_type(内部/外部)、role_type。
- economic_event 经济事件表:保存每个事件的元信息,event_type、event_time、description、关联发起代理、关联外部代理。
- flow_detail 存量流量明细表:保存事件和资源的增减对应关系,direction 表示正向还是负向,quantity 和 amount 记录具体影响。
- event_link 事件配对表:保存二元关系,即两个事件之间的业务勾稽关系,比如销售事件对应哪个收款事件。
资源、事件、代理的实体关系如下表展示:
| 实体对 | 关系类型 | 关系说明 |
|---|---|---|
| resource <- economic_event | 存量-流量 | 一个事件增减多个资源,一个资源被多个事件影响 |
| agent <- economic_event | 控制 | 一个代理发起多个事件,一个事件涉及多个代理 |
| economic_event <-> economic_event | 二元 | 两个事件通过 event_link 配对,形成业务闭环 |
3.4 转成物理表:SQL片段
有了关系结构,落成数据库表就不难了。以下是核心表的精简建表 DDL,完整字段做了裁剪,保留了主干:
create table resource ( id int primary key, resource_name varchar(50), resource_type varchar(20), -- 'goods' 货物 / 'money' 资金 unit varchar(10) ); create table agent ( id int primary key, agent_name varchar(50), agent_type varchar(10), -- 'internal' 内部 / 'external' 外部 role_type varchar(20) -- 采购员、仓管员、客户、供应商等 ); create table economic_event ( id int primary key, event_type varchar(30), -- purchase_in / sale_out / payment / receipt / loss ... event_time timestamp, internal_agent_id int, external_agent_id int, description varchar(255), foreign key (internal_agent_id) references agent(id), foreign key (external_agent_id) references agent(id) ); create table flow_detail ( id int primary key, event_id int, resource_id int, direction smallint, -- 1 增加 / -1 减少 quantity numeric(18, 3), amount numeric(18, 2), foreign key (event_id) references economic_event(id), foreign key (resource_id) references resource(id) ); create table event_link ( id int primary key, left_event_id int, right_event_id int, relation_type varchar(20), -- 'sales_receipt' 销售-收款 / 'purchase_payment' 采购-付款 foreign key (left_event_id) references economic_event(id), foreign key (right_event_id) references economic_event(id) );建表之后有个很实际的好处:查询业务状态时,很少需要写复杂的状态判断 SQL,更多是聚合事件表。比如查“某个客户至今还剩多少欠款”,思路是找这个客户的销售事件总额,减去已经和它配对的收款事件总额,两者相减就是应收余额,而不是查客户档案上的某个余额字段。这种查询在 building 对账功能时尤其顺手。
3.5 关键查询:未对账单据
下面这个查询是销售对账的基础逻辑,找到已经做了销售出库、但还没有通过 event_link 配对上收款事件的单据:
select e.id as sale_event_id, e.event_time, a.agent_name as customer_name, sum(coalesce(fd.amount, 0)) as sale_amount from economic_event e left join event_link el on (el.left_event_id = e.id and relation_type = 'sales_receipt') join agent a on a.id = e.external_agent_id join flow_detail fd on fd.event_id = e.id where e.event_type = 'sale_out' and el.id is null group by e.id, e.event_time, a.agent_name;这种写法在传统订单表里反而绕,因为传统表需要维护一个应收余额字段,再通过写在别的模块的收款单去更新它,很容易更新漏。REA 里应收余额不是一个被更新的数字,而是“已销售且未配对的销售事件”的聚合结果,天生不会产生更新遗漏。
4. 从模型到实现:聚合边界、状态机与对账机制设计
4.1 事件即聚合根:如何划分一致性边界
领域驱动设计里经常争论聚合根怎么划,REA 给了一个非常自然的答案:事件就是聚合根。一个采购入库事件,聚合它名下的所有 flow_detail,确保“入库单的明细行”和“库存增减明细”在同一个事务里被写入。一个销售出库事件同样如此。
不同事件之间不要强行做成一个大事务。采购入库事件和付款事件完全可能在时间上错开,今天入库,下个月才付款,如果把它们放在同一个聚合里,事务边界会扩大到难以维护。REA 的二元关系正好支持这种松耦合:两个事件各自成为聚合,通过 event_link 在后续关联。
这个设计带来的好处是并发能力提升。两个销售订单同时出库,操作的是不同的事件聚合并发写库,只要不是同时对同一个资源做相反方向的扣减,冲突概率就低。而传统“库存表余额字段”方案每次都要锁同一行,并发一高就出事。
4.2 状态不是字段,是一连串事件的结果
状态机在这个模型里变成了事件链的可视化结果。举个例子,一张销售单当前处于什么状态?不需要一个 status 字段,直接看它关联的事件:还没建销售事件,就是“草稿”;销售事件已经创建但还没和收款事件配对,就是“已发货待收款”;销售事件已经和收款事件完成配对,就是“已结清”。
这样的好处是状态的含义从数据库里“硬编码”变成了可推导的业务逻辑。如果想新增一个“部分收款”的状态,不需要改表加字段,只需要允许一个销售事件和多个收款事件通过 event_link 配对,状态表达式从“是否已配对”变成“配对了百分之多少”。这种演进能力是传统 status 字段很难做到的。
不过要把这套状态推导大规模落在代码里,建议给领域服务提供几个只读方法,比如 is_settled(event),settled_ratio(event),外部不要自己拼 SQL 去算。否则每个模块各算各的,逻辑就散掉了。
4.3 对账与补偿:REA天然的双向查验
我见过太多把对账做成“凌晨批量任务比对两张表”的系统,查出来不平也不知道问题从哪一步开始。REA 模式下,对账不需要批次任务累加比对,而是结构性的:每一笔销售事件必须对应至少一笔收款事件,每一笔采购事件必须对应至少一笔付款事件。对账就是检查 event_link 里是否有未闭合的孤点。
实际业务里有大量非标准情况:客户先付定金,后面分批提货,最后少提了5斤要求退款,这些在 REA 里都是事件组合问题。定金是一笔资金资源增加事件;分批提货是多个销售出库事件;退款是一笔资金资源减少事件。用 event_link 把多个事件互相配对,就能表达“一笔款配多笔货”“一笔货被多次付款”的复杂情况。
关键是 REA 把“权责关系”和“事件时效”绑定在事件树上,天然支持补偿操作。做错了就用反向事件去冲,而不是去修改历史事件的字段。这样审计线索是完整的,不会出现“昨晚批量脚本改错了3个月前的订单但没人知道”的尴尬局面。
4.4 落地时的读模型:允许为了报表做物化
讲到这里可能有人会问:如果所有查询都靠聚合事件,报表性能会不会很吃力?会,所以成熟的落地方案是 CQRS 思路:写模型用事件表,保持干净和可追溯;读模型专门做物化投影,生成库存余额表、应收报表、销售汇总表,用异步方式更新。
什么意思?就是把 REA 当成可信数据源,报表和列表查询从物化表里读取。比如凌晨或每次事件写入后,用一段聚合逻辑生成“今日库存快照”和“客户应收汇总表”。物化表和事件表同时存在,到期对账发现不一致时,永远以事件表为准,重建物化表即可。
我强烈建议不要在写路径上直接去更新各种汇总字段,否则 REA 的追溯优势会被吞掉,退化成传统“订单表+余额表”的老路。正确姿势是事件表只追加,物化表可以批量重建。这个架构虽然在初期看起来多一张表,但维护成本远低于反复修补状态字段。
5. REA建模的高发翻车点与适用边界判断
5.1 翻车一:把一切状态流转都塞进事件
有同事一开始相当兴奋,把“用户点击了商品详情”“购物车加了一件商品”“浏览页面”全都建模成事件,加了海量事件表,最后仓库里堆了几千万条无意义记录。这里要分清楚:REA 关注的是经济事件,也就是能改变资源、产生权责关系的动作。一个商品被加入购物车不改变所有权,也不产生资金增减,它是业务行为但不是经济事件,不需要进 REA 模型。
判断标准还是老办法:这个动作是否让某个资源的存量发生变化?是否让代理之间的权责发生变化?都没有的话,请放到行为日志或风控系统里,别塞进领域模型。事件定义太多太杂,会让核心模型失去主心骨。
5.2 翻车二:资源粒度定错,不是每个批次都要成为资源
果蔬批发里经常遇到批次问题:这一批苹果进价2元,那一批进价3元,能不能把每一批都建成一个资源?如果这么建,资源表会膨胀到无法维护,而且“批”和“货”的边界会被无限拆分,后面任何跨批次的业务逻辑都变得极其难写。
正确的做法是保持资源的可替换性:资源是一类货物,比如“苹果”就是资源,批次、产地、进价是事件或明细的属性。两种不同进价的苹果混在一起出库时,出库事件里记录数量即可,成本核算通过明细金额或者先进先出/加权平均规则计算,不应该靠物理拆分资源表来实现。记住:资源关心的是“可被事件改变”,不是“每一件实物都要一个编号”。
5.3 翻车三:忘了外部代理,账对不上
如果只做仓库进销存,忽略外部代理还能凑合,但一旦涉及应收应付,这个坑立刻炸开。我见过一个库存系统,销售出库事件都建了,却没有登记外部客户代理,结果财务对账时根本不知道哪笔销售对应哪个客户的货款,只能对着几张零散的Excel发挥想象力。
建议从建模型第一天就把外部代理补齐,哪怕当前没有财务需求。供应商、客户、承运商都是外部代理,把它挂在事件上成本很低,回头建对账模块就不需要再补数据。还有一点,内部代理和外部代理在表设计上要留 agent_type 字段,名称可以重复,但角色要区分清楚,否则后面做数据隔离时无从下手。
5.4 REA vs 传统ER建模:该不该用、用在哪儿
REA 不是万能的,它也有不合适的场景。我做过的项目里,那种面向终端、CRUD 简单、业务方只关心“结果数据录进去能查出来”的内勤型系统,用传统 ER 建模反而更快,没必要引入事件语义来增加复杂度。
但在以下场景,REA 价值明显更大:需要财务对账、审计追溯的进销存和供应链系统;业务流程长、涉及多方代理协同的平台;极容易发生状态分叉、退货退款、部分结算的交易系统。判断方法很简单:如果业务方反复追问“这个数据是哪笔业务产生的”“这两个单子为什么对不上”,REA 大概率适合你。
| 对比维度 | 传统ER建模 | REA建模 |
|---|---|---|
| 建模视角 | 实体对象和关系 | 经济事件与资源流转 |
| 状态表达 | 状态字段快照 | 事件链推导 |
| 追溯能力 | 弱,靠日志补偿 | 强,结构天然支持审计 |
| 对账能力 | 需要额外开发比对逻辑 | 事件配对即对账 |
| 变更响应 | 加字段、改枚举 | 加事件、改配对关系 |
| 学习成本 | 低 | 中高 |
6. 复盘:一项真实供应链项目中REA的落地体验
6.1 第一次试点的混乱与纠正
我在一个模拟的农产品供应链项目里第一次完整落地 REA,开头两周团队是痛苦的。大家已经习惯把“订单表”“库存表”当成思考原点,我让他们先写事件流,再找资源,他们写出来的东西还是一股订单味:“创建订单事件”“修改订单事件”“删除订单事件”,这根本不是经济事件。
后来我换了个引导方式:不要从已有单据反推,而是逼自己站在业务现场描述事实。“农户送来了50箱香蕉,仓管员验收后放进冷库;客户来拉货,销售员称了30箱拿走;月底财务给农户打款。”把这类事实逐条拆成“谁对什么资源做了什么”,模型才慢慢正过来。所以试点阶段的混乱不可怕,关键是别急着写代码,先在事件流上反复磨。
6.2 REA带来的最大红利:业务语言对齐
这个项目做到中期,我发现 REA 最大的价值不在数据库设计,而在沟通。业务方和我讨论“某个客户为什么还有欠款”时,我只需要指着一串事件问他:哪笔销售你没收款?哪笔收款还没配对到销售?他能直接回答,而不是对着状态字段猜。
后来业务方在评审会上甚至自己会用“事件”说话了,主动说“损耗要单独记一笔事件,不能直接改库存”。这种业务语言和模型语言的一致性,是传统 ER 模型很难带来的。因为 REA 的记录对象是“业务真相”而不是“软件数据对象”,双方看到的是同一张图。
6.3 小技巧:先列事件流,再找资源和代理
如果只让我留一条实操建议,就是这句话:先列事件流,再找资源和代理。不要从数据表开始,也不要从页面原型开始,先把业务过程中发生的事情按时间顺序列出来。采购入库、销售出库、收款、付款、损耗、盘点调整,按事实写下来。
事件流列完了,资源自然浮现——被这些事件增加或减少的东西就是资源;代理也自然浮现——谁发起这些事件、谁和我们对等交易就是代理。最后再用二元关系把前后事件配起来,整个模型就闭环了。我照着这个顺序重做了多次建模,速度和准确率都比“先画表关系”高得多。
如果你现在正被一堆 status 字段和月底对账折磨,不妨找一个最小的模块先试点,只建模一个采购入库和对应付款事件,把流程跑通。REA 不需要整体切换,拿下一块阵地再扩大,效果会更稳。