1. 一个包不住的“业务底稿”:为什么我盯上了 REA
你可能也遇到过这种场景:业务方提了一个需求,说要把“订单、发货、收款、退款”串起来做一张全局报表。你打开数据库一看,订单一张表、物流一张表、支付一张表、售后一张表,勉强 join 出来倒是能看,但业务一追问“这批货到底算谁的”“这笔退款对应的是哪笔收款”,你就得翻代码、翻接口文档,翻半天还不敢保证口径一致。
我前段时间就在做类似的事:某公司的内部进销存管理模块从 Excel 迁到系统,老板要求把采购、销售、库存、资金全部打通。一开始想得很简单,建几张业务表,前端关联一下就行,结果需求一提再提,表越建越多,逻辑越搅越乱。那段时间我反复在琢磨一个问题:有没有一种建模方式,能让这些表之间的关系像“记账凭证”一样天然严密,而不是靠开发者在代码里一个个 if 去约束?
后来我重新翻到 REA(Resource-Event-Agent)这个建模思路,算是把问题想通了。REA 最早是会计信息系统领域的建模方法,核心就三个词:资源、事件、代理。它把业务抽象成“谁,用了什么资源,做了什么事件”,不关心借贷分录长什么样,而是关心经济活动的本质。这套思路对于做订单、库存、账务、审计这类系统的人来说,几乎是降维打击。
这篇文章主要面向三类读者:一是准备设计业务数据模型的开发者和架构师,二是做财务、审计系统对接的产品经理和技术负责人,三是被“表越多越乱”困扰的普通后端同学。我会先用一个完整案例把 REA 讲透,再给出可直接落地的建模步骤、表结构设计和踩坑记录,尽量让看完的人能直接用上,而不是停留在概念层面。
2. REA 建模的三块基石:Resource、Event、Agent
很多人第一次接触 REA 会误以为它又是一套新的建模语言,其实它更像是一种“看业务的视角”。你先放下表结构,只回答三个问题:企业手里有哪些有价值的东西?这些东西会因为什么行为变化?这些行为是谁发起的?回答完这三个问题,核心模型已经完成了一半。
2.1 Resource:企业真正控制的经济资源
Resource 在 REA 里不是普通的数据字段,而是“经济资源”,必须满足两个条件:有稀缺性,能带来未来收益。现金、库存商品、应收账款、设备、专利,这些算资源。但“订单编号”“客户名称”这些不是资源,它们只是某条业务记录的属性,不直接参与经济活动,也不会因为事件发生而增减。
举个具体例子。你在电商系统里看到一张订单表,里面有商品名、单价、数量、买家昵称。如果按 REA 建模,“商品”如果已经买断入库,它就是企业的存货资源;“买家昵称”只是代理的一个标识属性;而这张订单本身并不直接对应一个 Resource,真正对应的是下单这个 Event 引发的资源流出承诺,后续才会变成收入或应收账款。
我实操下来的体会是:识别 Resource 最难的不是“什么东西有价值”,而是“不要把状态当资源”。比如订单有“待付款、已付款、已发货、已完成”这些状态,这些状态只是 Event 发生后的结果记录,而不是独立资源。如果一开始把订单状态作为一个 Resource 去建关系,后面所有逻辑都会别扭。
2.2 Event:触发资源变化的经济事件
Event 是 REA 模型里最核心的实体,它记录的是“发生了什么”。采购订单创建、货物入库、销售出库、收到货款、支付货款,这些都是事件。事件与资源的关系是:一个事件会增加资源或减少资源。更准确地说,REA 里的事件应该是“经济事件”,而不是系统里的每一次操作日志。
很多人会把用户点了个按钮就叫事件,比如“用户点击了导出按钮”。这个不是 REA 的 Event,因为它不改变任何经济资源的增减。经济事件必须有资源流的参与,换句话说,它必然会改变某类资源的数量或价值。如果没有任何资源边的变化,那它顶多算系统操作,不该进核心业务模型。
建模的时候要注意每类事件通常是成对出现的,比如“销售出库”对应“商品资源减少”,“收取货款”对应“现金资源增加”。REA 里有一对非常重要的概念叫“交换”和“转换”:
- 交换指的是资源在不同代理之间的转移,比如卖货给客户、向供应商采购
- 转换指的是资源在企业内部形态的变化,比如原材料加工成产成品、现金变成设备
这个区分对后续做流程审计很有价值。交换类的流程涉及外部代理,需要记录对方信息;转换类流程基本只在企业内部发生,代理要么是内部部门,要么是某个责任人。
2.3 Agent:参与事件的人或组织
Agent 决定了一个事件到底是谁发起、谁受益、谁承担责任。在 REA 模型中分为内部代理和外部代理:内部代理通常是员工、部门;外部代理通常是客户、供应商、银行、物流公司。
我见过一些业务系统把所有参与方全塞进一个字段,比如“相关方”或者“往来单位”,看起来很省事,但实际上建模时必须拆开,否则后续对账会出大问题。比如一笔销售出库事件,客户是一个 Agent,业务员是另一个 Agent,仓管员又是一个 Agent。如果全部塞进一个字段,你就无法回答“这个客户的额度是谁审批的”这类审计问题,更没法做绩效统计。
在表设计上,Agent 不一定非要建一张大表把所有角色都放进去。客户可以直接和外部代理表关联,业务员关联内部员工表。REA 强调的是关系而非继承,你不需要把“客户”和“员工”塞进同一张物理表,只要逻辑上它们都属于 Agent 即可。
需要提醒的是:Agent 不要和 User 混为一谈。User 是系统登录账号的集合,Agent 是业务活动中的参与方。同一个员工可以有多个账号,同一个客户公司可以有多个联系人,它们之间是多对多的关系。把这两个概念分开,权限模型和数据模型才不会纠缠在一起。
3. 对比传统建模:REA 赢在哪,输在哪
我用 REA 重新做建模的时候,好几个人问我:这不就像 ER 图吗?和平时建表有什么区别?这里我直接拿我们真实的对比结果说事。
3.1 传统 ER 建模:业务在表之间“飘着”
常规的 ER 方式先列出业务对象,比如订单表、订单明细表、客户表、商品表、库存表,然后用外键连接。这个方式在数据量小、需求稳定的场景下没有问题。但一旦业务流程变长,比如从下单到发货、签收、开票、回款、退货、退款,业务对象会越来越多,表之间的关系也会越来越复杂。
最大的问题是:表与表之间的关系不携带“业务语义”。两个表的外键只告诉你“有关联”,但不告诉你“这个关联是怎么发生的”。例如库存表和订单明细表都关联了商品表,你很难直观看出库存是在哪个动作发生后减少的,是销售还是盘点损耗?是正常出库还是退货入库?这种语义往往隐藏在大量 if 判断、状态位和定时任务里。
3.2 复式记账:严谨,但离业务太远
复式记账(Dr/Cr)处理资金和资产流转非常严谨,但它要求首先把业务翻译成会计分录。系统概要设计时如果直接用借贷分录做底层模型,业务人员看到“借应收账款,贷主营业务收入”会一头雾水,因为这对他们来说不是真实发生的事情,只是财务管理的映射结果。
如果底层只有分录数据,想回答“哪些订单还没发货”这类运营问题,你得从分录里反推,非常痛苦。REA 的好处是它是事件导向的,业务人员看到“销售出库就是商品出去了”,并不需要懂会计。
3.3 REA 与传统方式的对比表
| 建模维度 | 传统 ER 建模 | 复式记账 | REA 模型 | | 核心关注点 | 对象和关系 | 借贷平衡 | 资源变化的经济事件 | | 业务语义 | 靠外键约定 | 靠科目编码约定 | 事件天然携带语义 | | 能否回答“发生了什么” | 较难 | 很难 | 容易 | | 可审计性 | 不强 | 极强 | 强 | | 对业务人员的友好度 | 中 | 低 | 高 | | 上手难度 | 低 | 高 | 中 |
REA 并非要取代复式记账,它更多是作为“业务事件层”的存在。当业务事件层建好了,财务分录可以被推演出来,这就是为什么现在很多财务中台会把 REA 或类似事件驱动模型作为底座,再生成分录。
3.4 REA 并不适合所有场景
我得说句公道话:REA 不适合纯内容类系统,也不适合无资源实体变化的系统。比如一个博客平台、一个课程点播系统,核心是内容和学习行为,不是“经济资源”增减,强行套 REA 反而弄巧成拙。它最适合的是有资源实体、资金流动、库存变动的业务系统,尤其是进销存、财务、供应链、审计这些领域。选型时先判断:你的业务里有没有“数量会增减的资源”?如果没有,REA 大概率帮不上忙。
4. 实操:从 0 到 1 把一个进销存场景 REA 化
理论讲再多,不如把一家公司的进销存业务完整建模一遍。我找一个典型的贸易公司场景:从供应商采购商品,入库到仓库,然后销售给客户,客户付款,我们给供应商付款,月底还要盘点库存。
4.1 第一步:先把业务事件画出来
建模前我先不做任何表设计,只在纸上列事件:
- 向供应商签采购订单
- 供应商发货,我们收货入库
- 向客户销售并出库
- 收到客户货款
- 向供应商支付采购款
- 盘点时发现盘盈或盘亏
你会发现这些事件都是经济事件,每个事件都涉及资源增减或价值变化。我把它们分成两类:交换事件(涉及外部代理)和转换事件(内部变化)。销售、采购、付款、收款属于交换事件,入库、出库、盘点、领料属于转换事件。
这一步看起来简单,但它决定了整个模型的地基。我建议你手头有业务需求时,先画一张事件清单,让业务人员确认“确实有这些事发生”,再往后走。如果业务人员说“我们没有验收环节”,那你就别私自加一个收货入库事件,否则做出来人家不认。
4.2 第二步:识别资源和代理
场景中的经济资源非常明确:
- 库存商品:数量会因采购、销售、盘点而变化
- 现金:因收款、付款而变化
- 应收账款:销售已经发生但钱没收到
- 应付账款:采购已经发生但钱没付
- 存货损耗:盘点时发现的实际库存少于账面库存
代理方面:
- 内部代理:采购员、销售员、仓管员
- 外部代理:供应商、客户
我在这里特意把“采购订单”“销售订单”单独提出来说明一下。从 REA 视角看,订单本身只是一个承诺信息,不是资源。但如果后续业务需要追踪订单的履约情况,可以把订单作为事件组或“单据头”来组织,这个后面讲表结构的时候再细说。
4.3 第三步:定义资源流,锁定关联规则
REA 建模里最关键的一步是“资源-事件”关联。一个事件可以增加一种资源,也可能减少一种资源,甚至一个事件同时关联多个资源。例如“向客户销售并出库”这个事件,它同时减少库存商品资源(出库),但还未形成现金资源增加,这个过程对应的是应收账款资源的变化。
我用的是一套简单规则来检查模型对不对:每个经济事件必须至少连接一个资源和一个代理。如果某个事件连不上任何资源,那说明它不是经济事件;如果连不上代理,说明说不清楚是谁发起的。拿这个规则一筛,很多设计问题就会暴露出来。
以销售出库为例:
- 资源:库存商品(减少)
- 代理:客户(外部)、销售员(内部)
- 事件类型:交换事件
以收取货款为例:
- 资源:现金(增加)
- 代理:客户(外部)
- 事件类型:交换事件
这样每个事件都像是一条有起点和终点的“资源流”,业务链条一目了然。
4.4 第四步:将 REA 模型转化为数据库表结构
这一步最实际。我通常会先建几张基础表,表名使用 r_ 前缀区分资源表,e_ 前缀区分事件表,a_ 前缀区分代理表。
-- 资源表:库存商品 CREATE TABLE r_inventory ( id BIGINT PRIMARY KEY, sku_code VARCHAR(64) NOT NULL, sku_name VARCHAR(128), quantity DECIMAL(18,4) DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 资源表:现金账户 CREATE TABLE r_cash_account ( id BIGINT PRIMARY KEY, account_name VARCHAR(64), balance DECIMAL(18,2) DEFAULT 0 ); -- 代理表:客户 CREATE TABLE a_customer ( id BIGINT PRIMARY KEY, customer_name VARCHAR(128), contact_person VARCHAR(64), phone VARCHAR(32) ); -- 代理表:员工 CREATE TABLE a_employee ( id BIGINT PRIMARY KEY, employee_name VARCHAR(64), department VARCHAR(64) ); -- 代理表:供应商 CREATE TABLE a_supplier ( id BIGINT PRIMARY KEY, supplier_name VARCHAR(128), contact_person VARCHAR(64), phone VARCHAR(32) );事件表是最关键的部分。以销售出库事件为例:
CREATE TABLE e_sales_issue ( id BIGINT PRIMARY KEY, event_no VARCHAR(32), occurrence_time DATETIME NOT NULL, customer_id BIGINT NOT NULL COMMENT '外部代理-客户', employee_id BIGINT NOT NULL COMMENT '内部代理-销售员', inventory_id BIGINT NOT NULL COMMENT '资源-商品', quantity DECIMAL(18,4) NOT NULL COMMENT '出库数量(资源减少)', unit_price DECIMAL(18,4), receivable_amount DECIMAL(18,2) COMMENT '关联的应收金额', related_order_id BIGINT COMMENT '业务单据ID' );用前面定的规则校验一下:销售出库事件有一个资源(库存商品)、两个代理(客户和销售员),符合“经济事件至少连接一个资源和一个代理”的约束。这在后续开发中价值极大,因为每张事件表天然就是一个业务事实记录,不会出现“不知道该关联谁”的悬空数据。
4.5 第五步:用“事件表 + 流水”重建资源视图
REA 模型里,你不需要每天去更新库存表的数值。你只需要把事件流水记录下来,库存当前数量可以由所有相关事件流水汇总得出。比如库存的当前数量,等于初始入库总量加上所有入库事件的数量,减去所有出库事件的数量,再调整盘点差异。
SELECT i.sku_code, SUM(CASE WHEN e.stock_flow_type = 'IN' THEN e.quantity WHEN e.stock_flow_type = 'OUT' THEN -e.quantity ELSE 0 END) AS current_stock FROM r_inventory i LEFT JOIN e_stock_flow e ON e.inventory_id = i.id GROUP BY i.sku_code;这样做的好处是什么?最大的好处是:你可以随时回溯到任意时间点的库存快照,只要在查询条件里加一个“事件发生时间小于等于目标时点”。这在传统“每次操作直接改库存数量”的设计里几乎无法实现——你没有流水,根本不知道过去某个时刻库里到底有什么。
我当时接手那家公司的库存模块,原来的表就是只有当前数量,没有流水,一个负数库存问题查了两周,最后靠翻 Excel 才定位到是一次导入重复导致的。换成 REA 事件流后的第一个月,类似问题被定位到分钟级。
4.6 第六步:事件与单据如何关联
业务系统通常有订单、发货单、发票等“单据”。REA 模型听上去和单据关联不大,但实际上单据可以作为事件的聚合壳。每张订单本质上包含多个事件:创建订单时记录“承诺未来销售”的事件,实际出库时记录“销售出库”的事件,收款时记录“收到现金”的事件。但这是逻辑层面的事,物理存储可以将这些事件通过 order_id 关联到同一张订单主档上,方便业务查询。
我用的办法是:在事件表里加一个 business_doc_id 字段,指向业务单据主键,但业务单据本身不参与 REA 建模,它只是查询展示维度。这样既保留了 REA 的严谨性,又不影响后端工程师习惯的“查订单”方式。
5. 延伸应用:事件溯源、审计追溯与数据治理
建模做完之后,你会发现 REA 的好处会外溢到好几个领域。首先是事件溯源(Event Sourcing)。我们常说事件溯源是一种架构模式,它要求状态可以被事件流重建,而 REA 的事件表天然就是一套规范化的事件流。两者结合可以实现“系统没做坏事”——只要把所有事件追加式落库,当前状态必然等于历史事件的投影,任何篡改都会留下痕迹。
其次是审计追溯。财务审计常常要求提供凭证链:这笔收入是怎么来的?对应哪笔销售?哪个客户下的单?哪个仓管员发的货?REA 的 Resource-Event-Agent 结构让这条链可以无缝追踪。从现金资源出发,查到现金增加事件,关联到客户代理,再关联到销售出库事件,再关联到库存资源减少,整个过程链路完整且不受报表口径影响。
我在这里分享一个真实体会:当时我们给某公司做系统的财务模块,财务总监要求所有收入必须能够“从银行流水追溯到原始订单”。如果是传统数据模型,这个链条需要开发写至少四五个 join 和若干状态判断;但在 REA 模型下,银行流水表本身就是现金资源的事件记录,它天然带着 customer_id 和 related_order_id,追溯几乎零成本。
数据治理方面也有收益。因为每个字段都能落在 Resource、Event、Agent 三种范畴之一,数据标准的制定就变得非常清晰。资源数据关注数量和价值,事件数据关注发生时间、地点、关联对象,代理数据关注角色和权限。各部门在争论数据归属时,只要先问“这是资源、事件还是代理”,大部分争议能一分钟解决。
6. 避坑指南:我在 REA 实施中踩过的 5 个坑
这个部分是完全从实战中来的。很多文章只讲 REA 的优点,但我实际落地时踩了至少五个大坑,每个都足以让项目返工,这里全部记录下来供大家避免。
6.1 坑一:把“状态字段”当成了“资源”
最早我在设计订单模块时,想用 REA 表达订单状态流转,于是设计了一个“订单状态资源表”。后来发现不对:订单的“已支付”状态不是一个可增减的经济资源,而是“收款事件发生后产生的结果”。如果我把它当资源,它的增减逻辑就会变得很别扭,而且事件表里根本没有对应的经济事件来驱动它。
正确的做法是:用事件流水推导状态。你不需要存储“订单已支付”这个状态,你只需要查一查这个订单有没有发生“收取货款事件”。有就是已支付,没有就是未支付。状态只是一个派生查询结果,不应该成为存储实体。
注意:状态字段在传统设计里往往是为了查询效率而冗余存在的。REA 设计里你也可以保留冗余状态字段,但必须明确它是“可派生字段”,只能由事件流水更新,绝不能成为业务逻辑的依据。
6.2 坑二:事件与资源的数量关系搞成一对一
一个事件可能同时影响多个资源,比如“销售出库”这个事件可能同时减少两种不同品类的库存资源。如果我在事件明细表里把 inventory_id 做成单一外键,就会导致同一事件必须复制多行,而这又破坏了事件的唯一性,后续按事件关联代理会出现重复计数。
解决方法是把“事件-资源”的多对多关系拆成一张独立的关联表,即“事件资源明细表”。事件表记录事件本身的信息(时间、代理),事件资源明细表记录这个事件影响了哪些资源、各自的数量和金额。这样既保持了事件完整性,也支持了复杂的多资源场景。
CREATE TABLE e_sales_issue_resource_map ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, inventory_id BIGINT NOT NULL, quantity DECIMAL(18,4) NOT NULL );类似的,“事件-代理”也应该用独立关联表,因为一个事件可能涉及外部代理和内部代理各一人。
6.3 坑三:过度追求“纯 REA”导致查询极难
RET(Resource-Event-Agent)的理想模型里,所有当前状态都由事件流推导,但这在真实系统里很痛苦。比如客户当前余额,如果要每次查询都从几十万条收付款事件里汇总,数据库压力会非常大。
我的做法是“混合模式”:REA 事件表作为可信事实层,同时保留传统的投影表(比如客户余额表、库存当前值表)作为查询优化层。事件流负责写,投影表负责读,投影表通过事件触发异步更新。这兼顾了 REA 的追溯能力和传统系统的查询性能,大家在实际落地时千万不要教条,为了追求模型的学术正确性把系统搞成一个离线数仓。
6.4 坑四:代理表设计过于泛化
一开始我想把所有代理对象放一张超级表 a_agent,用一个 agent_type 字段区分客户、供应商、员工。结果客户有客户等级、供应商有结算周期、员工有部门,各自的专属属性完全不知道怎么塞。如果都塞进一个扩展字段池,查询和校验都会越来越麻烦。
最终我放弃了过度泛化。代理角色在逻辑上共有,但物理存储仍然分开。如果需要统一视图,可以建视图或者在应用层映射。记住 REA 是概念模型,不是强制要求单表的物理模型,分表完全不违背 REA 思想。
6.5 坑五:缺少对“互惠事件”的显式建模
采购和付款、销售和收款,这些事件之间存在互相依存的关系。我在初版模型里没有显式记录这种关系,结果面临问题:客户付了一笔钱,对应的是哪两笔销售订单?如果不建立互惠事件关联,就只能靠白名单规则人工比对。
REA 里有“交换”这个概念,它本质是由一个“提供资源”的事件和另一个“接收资源”的事件成对构成的,比如“销售出库”和“收取货款”。实操中我在事件之间建立“双边关联表”,比如 exchange_links,记录两个事件 ID,分别标注哪方是提供方、哪方是接收方。这样处理部分收款、超额付款、预收款抵扣时都非常有效。
7. 实操心得与后续扩展建议
最后再分享一下我个人的真实体会。
REA 模型不是银弹,它最适合的核心场景还是“有资源流动的业务系统”。如果你正在维护一个报表满天飞、业务口径天天变的进销存或账务系统,我非常推荐先把核心业务事件列清,再用 REA 思路梳理一遍。你会发现原本复杂的表关系会变得很有章法,业务人员也能看懂你的模型设计。
但切勿一上来就全面重构。稳妥的做法是先选一个业务子域做试点,比如先做“库存管理”模块,把入库、出库、盘点这些事件按 REA 建好,跑通后再延伸到采购、销售、资金。我见过不少团队想一把梭,把整个系统推倒重来,最后工期失控、业务方也失去耐心。
还有一个小技巧:在最初画事件清单时,可以让业务人员用“发生什么事了”来描述日常操作,而不是让他们直接给字段和表名。比如仓管员说“货到了我就往上搬”,这个描述就能转成一个“收货入库事件”。从自然语言到事件模型,再从事件模型到表结构,整个过程会比直接从业务人员嘴里要字段顺畅得多。
后续如果你想让这套模型更有扩展性,可以关注两个方向:一是与事件溯源架构结合,用消息队列把事件表变成真正的“事实日志”;二是与财务中台的分录引擎打通,让 REA 事件自动生成借贷分录,减少人工做账。我目前正在做的是把销售出库事件同步生成应收入和成本结转分录,效果还不错,等跑通了我再单独写一篇分享。