第一次在项目文档里看到“REA”三个字母,我没太当回事。当时以为它不过是某个框架的缩写,直到自己动手做一套订单加库存的数据模型,翻了旧账本、对了几个月的流水,才发现 REA 的价值被绝大多数人低估了。REA 全称 Resource、Event、Agent,也就是资源、事件、代理人,它是一套描述企业业务流程的数据建模理念。它不像传统财务系统那样从“会计科目”出发,而是从“业务实际发生了什么”出发,把每一笔交易拆成三件事:动了什么资源、发生了哪些事件、谁参与其中。
这套模型特别适合做订单、库存、收付款这类偏业务系统的底层架构,也适合给数据分析师搭一套可回溯、可推演的事实表。这篇博客我结合一个图书销售收款的业务场景,把 REA 的建模步骤、表结构和踩过的坑一次性说清楚。想摆脱“对不上账”和“改个需求就要调表”的同学,可以先从这里入手。
1. 为什么说 REA 是对“业务发生了什么”的结构化还原
1.1 传统台账里的“余额”让人越记越糊涂
很多小团队一开始都用 Excel 管理业务。举个例子,一家社区书店每天的销售流程是这样:客户来订书,店员记一张销售单;仓库按销售单发货;财务收到货款后,在收款记录里填一行金额。表面上一切正常,但时间一长问题就暴露了。
销售单上写着“客户A买了3本书”,库存表里手动把库存数量减去3,收款表里记了一笔300元。这三张表互相独立,没人记录“这3本书具体是哪天发的”、“哪次发货对应哪张销售单”、“收到的300元到底对应哪笔发货”。等月底想对账时,只能靠人工逐条比对,经常发现库存数量对不上,收款金额和销售金额差出一截。
问题出在哪儿?出在把“业务行为”压缩进了零散的字段里。记录的时候只存结果,不存过程,丢失了时间、动作、参与人这些关键信息。刚开始数据量小还看不出问题,一旦业务跑起来,数据就成了孤岛,谁也说不清某个数字是怎么来的。
1.2 事件驱动:先把事实记下来再说
REA 想解决的就是“数据可理解、可追溯”的问题。它的核心思路不是直接记账,而是记录“事件”。一笔客户订单是一个事件,一次仓库发货是一个事件,一笔收款也是一个事件。资源之间的流转,全部由事件触发;每个事件,都会挂上对应的代理人。
于是你可以从事件出发,问出很多传统表回答不了的问题:某天某个客户订了哪些书、谁来负责发货、约定什么时候收款、实际哪一笔款对应哪一张发货单。不用再去几张表之间来回猜,把事件串起来就是完整的业务流程。
这套思路在会计领域被称为“事件驱动会计”。传统的复式记账只在凭证上写借贷,REA 则先把业务事实记录为事件,等需要出报表时,再从事件推导出会计分录。数据的可信度,从源头上就比“人工维护余额”高得多。
1.3 谁适合读这份建模思路
如果你是做业务系统建模的产品或研发,REA 能帮你把订单、库存、支付这类模块的数据结构设计得更有弹性。如果你是做数据仓库或数据分析的同学,REA 里事件明细天然接近事实表,后续做指标统计会顺手很多。即便是财务从业者,想理解经济业务的本质、不想被凭证格式束缚,也可以把 REA 当作一种看问题的视角。
还有正在自学系统设计的在校同学,REA 是一个非常经典又容易上手的建模案例,拿它练手,比照着教科书画 ER 图更贴近真实业务。接下来我从三要素开始,一点一点拆开讲。
2. 拆开核心三件套:资源、事件、代理人
2.1 资源:能创造价值,也能被控制
资源是 REA 模型里第一个关键词。说到资源,千万别直接理解成“会计科目”里的存货或者现金。REA 里资源的判断标准很简单:企业能不能控制它、它能不能参与交换、它能不能带来经济价值。图书、库存商品、现金、固定资产、服务,甚至一项知识产权,只要符合这三个条件,都可以算资源。
为什么要把资源单独建模,而不是像传统台账那样维护一个“当前库存”字段?因为资源是有生命周期的。一本书从采购入库到上架,再到销售出库,中间有在途、在库、已售等多种状态。如果你只在表里存一个“现有数量”,那“数量从10变成7”的过程就完全丢了。到底是卖了3本,还是报损了1本、退货了1本?这些过程都必须用事件记录。
实际操作中,我的建议是:每类资源建一张主表,存它的标识和相对稳定的属性。比如图书表存 ISBN、书名、分类,但不直接存“库存数量”。库存数量的来源,永远是对入库事件和出库事件的汇总。这样做初期会觉得查询麻烦一点,但换来了数据完全可回溯。
2.2 事件:业务里真正发生的动作
事件是 REA 模型的基石。很多人在建模时常犯一个错误,就是把“单据”当“事件”。比如建一张销售订单表,里面塞了订单号、客户、金额、商品明细,然后就以为订单事件记录完了。实际上,单据只是事件的载体,真正的事件是“客户提交了订购请求”、“仓库完成了发货”、“财务收到了付款”这些具体的动作。
REA 里还要区分承诺事件和实际事件。承诺事件是未来要履行的义务,典型的有销售订单、采购合同、服务预约;实际事件是已经发生的交易,比如发货、收货、付款、收款。这两者必须分开建模,原因是它们发生的时间不同。
举例来说,客户6月签了一份全年采购合同,8月才陆续提货。如果只看到订单就把金额都算进收入,报表和现金流就会严重失真。把承诺事件和实际事件分开,就能清楚知道“合同金额多少”和“实际履约多少”是两回事。
事件还应保留足够细的粒度。一张销售订单里如果包含3种书,就不要只在订单表里记一个总金额,而应该把每种书拆到订单明细行。后续发货、退货、对账,都要靠这些明细行去匹配。粒度越细,模型的可扩展性越强。
2.3 代理人:谁参与了这件事
代理人是事件的参与者,可以是企业内部的人,比如销售员、仓库管理员、出纳,也可以是外部主体,比如客户、供应商、物流公司。把代理人单独建模,最直接的好处就是责任可追溯。
我做过的一个系统里,同一个客户既是采购方,又是供应商,还承担过一部分配送业务。如果我在每一张业务表里都写死一个“客户名称”字段,这种多重身份就会让数据乱成一锅粥。正确的做法是建一张主体表,存放人和组织的基本信息,再通过角色表或外键字段表达“他这次是以什么身份参与的”。
代理人和事件之间是多对多的关系。一个销售事件里有客户、销售员、审批人多个角色;一个代理人也可能参与数十个事件。建模时,在事件表里存放参与者外键,或者建一张参与关系表,看业务复杂度来定。基本要求是:任何一笔事件,都能回答“谁在什么时间做了什么”。
3. 把三要素串起来:三类关系与一条增值链
3.1 存量-流量关系:资源如何被事件改变
资源与事件之间的关系,在 REA 里叫存量-流量关系。通俗地说,事件会让资源增加或者减少。收货事件让库存增加,发货事件让库存减少;收款事件让银行存款增加,付款事件让银行存款减少。
这组关系可以用一个很朴素的等式来约束:期末存量 = 期初存量 + 流入 - 流出。这个等式看起来像最普通的库存公式,但它提醒了我一个重要原则:不要直接修改存量字段,要让所有存量变化都来自事件。你可以在数据库里建一个“库存汇总表”,但它只能作为查询缓存,不能成为录入入口。
我做库存模块时,就把每一次入库、出库、报损都记录为独立事件。盘点时如果发现账实不符,不用去猜“库存数量字段是不是被谁改过”,只需要把事件流水拉出来,和实物记录逐条比对,很快就能定位到是哪一笔操作出了问题。这种“事件流水账 + 汇总视图”的方式,是 REA 建模最扎实的底盘。
3.2 二元性关系:两个事件才构成完整交换
在 REA 里,一个完整的经济交换至少包含两个对应的事件。销售和收款是一对,采购和付款是一对,发货和收货是一对。这种成对出现的关系,与复式记账“有借必有贷”的内在逻辑是一脉相承的,但表现形式不同。
传统复式记账在一张凭证里写借贷分录;REA 则在两个或多个事件之间建立关联。比如一张发货单金额3万元,客户分三笔付款,分别是1万、1万、1万。传统账套里你看到的是应收账款余额被逐个减值,但对账时还得靠人工判断哪笔钱对应哪批货。REA 里,每笔收款事件都可以关联到发货事件,核销关系一目了然。
建模时,可以在收款事件表里加一个“关联发货单号”字段,也可以单独建一张核销关联表。我更推荐后一种方式,因为业务常常出现部分核销、合并核销的情况,单独建表能更灵活地表达多对多关系。二元性关系是 REA 建模中最容易被忽略、却最能提升对账效率的部分。
3.3 参与关系与流程链条
参与关系解决的是“这个事件由谁参与”的问题。资源、事件、代理人之间的完整连接,构成一个业务循环。
举个例子,销售收款循环里有这样一组连接:客户与销售订单事件建立参与关系,销售订单事件通过订单明细与图书资源建立存量-流量关系,图书资源又通过发货明细与发货事件建立存量-流量关系,发货事件与收款事件建立二元性关系,收款事件又通过参与关系关联到出纳和客户。
多个业务循环再首尾相接,就形成了企业的增值链。销售收款循环结束后,现金资源增加,下一步可能是采购付款循环,用现金去购买新的图书资源。REA 模型到了这一层,就不只是数据表设计了,它还能帮你梳理企业整体运转逻辑,对流程优化也很有帮助。
4. 拿“图书销售收款”走一遍完整建模
4.1 建模总步骤
我归纳为六步,顺序很重要,先业务后技术:
- 明确业务范围,圈定要建模的流程边界。
- 识别资源:列出这个流程里涉及哪些有价值、可交换的对象。
- 识别事件:列出所有已经发生的业务动作,并区分承诺事件和实际事件。
- 识别代理人:列出参与这些事件的人和组织。
- 确定关系:补充存量-流量关系、二元性关系和参与关系。
- 设计表结构:把实体和关系转成关系数据库中的表和外键。
第二步到第五步一定要先写清楚,不要急着开建表语句。模型设计得不对,SQL 写再多也是白搭。
4.2 从业务场景中识别 REA 元素
假设我们在做一家社区书店的销售收款系统。业务过程是:客户下订单,销售员接单;仓库按订单发货;出纳收到货款。按六步走,识别结果如下表。
| 类型 | 名称 | 说明 |
|---|---|---|
| 资源 | 图书 | 书店持有的可销售商品 |
| 资源 | 现金 | 收款后企业持有的资金 |
| 事件 | 客户订单 | 承诺事件,约定未来交付图书 |
| 事件 | 发货 | 实际事件,图书流出企业 |
| 事件 | 收款 | 实际事件,现金流入企业 |
| 代理人 | 客户 | 外部代理人,参与下单和收款 |
| 代理人 | 销售员 | 内部代理人,负责订单 |
| 代理人 | 出纳 | 内部代理人,负责收款 |
关系也一并梳理出来:客户订单通过订单明细关联图书;发货通过发货明细关联图书;收款关联现金;发货和收款构成二元性关系;客户参与订单和收款事件,销售员参与订单事件,出纳参与收款事件。
这里注意,我特意没有把“应收账款”列为资源。应收账款的本质是发货和收款之间尚未完成的时间差,是一个抽象的中间状态,应该靠事件关系去计算,而不是靠新建一张资源表。这一点我在后面的坑里会详细展开。
4.3 表结构与建表 SQL
按上面的识别结果,我设计了几张核心表。先建资源表和代理人表,再建事件表,最后建明细表。用最基础的关系数据库语法就能表达。
create table book ( id bigint primary key, isbn varchar(20), title varchar(100), category varchar(50), unit_price numeric(10,2) ); create table cash_account ( id bigint primary key, account_no varchar(30), account_name varchar(50), currency varchar(10) ); create table customer ( id bigint primary key, customer_name varchar(50), contact_info varchar(200) ); create table employee ( id bigint primary key, employee_name varchar(50), role varchar(30) );事件表按承诺事件和实际事件分开。销售订单和订单明细放一起,发货和发货明细放一起,收款单独一张表。
create table event_sales_order ( id bigint primary key, order_no varchar(30), order_date date, customer_id bigint references customer(id), salesperson_id bigint references employee(id) ); create table event_sales_order_item ( id bigint primary key, order_id bigint references event_sales_order(id), book_id bigint references book(id), quantity integer ); create table event_delivery ( id bigint primary key, delivery_no varchar(30), delivery_date date, order_id bigint references event_sales_order(id) ); create table event_delivery_item ( id bigint primary key, delivery_id bigint references event_delivery(id), book_id bigint references book(id), quantity integer ); create table event_payment ( id bigint primary key, payment_no varchar(30), paid_date date, delivery_id bigint references event_delivery(id), cash_account_id bigint references cash_account(id), customer_id bigint references customer(id), cashier_id bigint references employee(id), amount numeric(10,2) );这套表结构最大的特点,是“余额”不单独存放。订单金额、发货金额、未收金额都能通过关联查询算出来。事件表里存的全是已经发生的动作,时间、参与人、关联单据都完整保留。
4.4 用查询反向验证模型
建完表之后,我会用几个SQL来验证模型是否成立。第一个查询是统计每种图书的发货销量,确认库存流出有据可查。
select b.title, sum(di.quantity) as sold_qty from event_delivery d join event_delivery_item di on di.delivery_id = d.id join book b on b.id = di.book_id group by b.title;第二个查询用来核对“发了多少货”和“收了多少钱”是否一致,也就是验证二元性关系。这是对账最重要的场景。
select d.id as delivery_id, sum(di.quantity * b.unit_price) as delivery_amount, coalesce(sum(p.amount), 0) as paid_amount, sum(di.quantity * b.unit_price) - coalesce(sum(p.amount), 0) as unpaid_amount from event_delivery d join event_delivery_item di on di.delivery_id = d.id join book b on b.id = di.book_id left join event_payment p on p.delivery_id = d.id group by d.id;如果这个查询跑出来的未收金额长时间居高不下,说明发货和收款之间出现了脱节。通过 REA 模型,你可以直接看到是哪张发货单、哪个客户、哪笔收款没配对。这比传统方式翻几张表逐个核对要高效得多。
5. REA 与传统复式记账的对比:值不值得迁移
5.1 从科目到事实:两种视角的差异
传统复式记账以会计科目为中心。每一笔业务被拆成“借什么、贷什么”,最终汇总到总账和报表。这种做法的优点是标准、合规、财务人员熟悉,缺点是当业务复杂时,科目离真实业务太远,很多明细信息被压缩掉了。
REA 以业务事实为中心。先记录事件,再在需要时把事件推导成凭证。我做过一个对比,用一张表能说清楚两边的差异。
| 对比维度 | 传统借贷记账 | REA 建模 |
|---|---|---|
| 核心视角 | 会计分录、科目余额、报表优先 | 业务事实、事件流、流程优先 |
| 数据粒度 | 凭证分录,通常按单据汇总 | 事件和行项目,保留完整明细 |
| 可追溯性 | 余额可看,但来源链条长 | 一切汇总从事件出发,来源清晰 |
| 业务扩展 | 新业务可能要加新科目 | 新增事件和资源即可,结构稳定 |
| 理解成本 | 需要会计背景 | 业务人员也能较快理解 |
我并不是说传统记账一无是处。法定报表、税务申报仍然强烈依赖会计科目,这是现实约束。REA 更合适的定位是“业务系统的底层事件层”,而不是替代整个财务核算体系。
5.2 从 REA 推传统记账凭证
既然 REA 记录的是事件,那就意味着,任何时候需要出财务报表,都可以通过一套映射规则把事件转成会计分录。举个例子,一次发货事件发生后,对应到传统记账就是这样:
- 发货确认成本:借“主营业务成本”,贷“库存商品”。
- 收款确认收入:借“银行存款”,贷“主营业务收入”,同时处理税费相关科目。
问题来了,这些分录从哪来?来源就是 REA 里的事件类型和资源增减关系。发货事件意味着图书资源流出,收款事件意味着现金资源流入。只要维护一张“事件类型到会计科目”的映射表,系统就能自动生成凭证。
这也说明,REA 和复式记账不是对立关系,而是分层关系。REA 在底层记录事实,传统记账在上层输出报表。两者结合,既保证了业务的可追溯性,又满足了财务合规要求。
5.3 什么时候适合用 REA
结合我自己的项目经验,这几类场景用 REA 收益最大:
第一,新系统从零开始建模。没有历史包袱,可以直接按事件驱动设计,避免一开始就掉进“科目表跟着业务改”的循环里。
第二,业务模式变化快。电商、订阅服务、多业态经营,这类系统经常要加新玩法。REA 的扩展方式是新增事件或新增资源,老表结构不用大动。
第三,审计和溯源要求高。需要对数据血缘做完整追溯的场合,REA 的事件明细能清楚还原每一笔数据的来源。
但如果你维护的是一个已经稳定运行多年的传统财务系统,且近期没有大的业务重构,那就不必为了用 REA 而 REA。比较务实的做法是在现有系统的数据仓库层,按 REA 思路搭一套事件明细模型,把传统凭证和业务事件映射起来。这样既不影响老系统,也能享受事件模型的灵活性。
6. 实践中的坑与常见问题速查
6.1 坑一:把整张单据当成事件
我第一次建模时,就把销售订单表直接当成了事件表,里面既放订单头,又塞订单明细。结果后来要对接发货和退货,发现订单表里没法干净表达“订了3本书,只发了2本,还退了1本”。
后来我把订单头拆成事件表,把商品明细拆成独立的明细表,发货和退货同样用明细表记录,再通过外键关联。这个结构彻底解决了部分发货、部分退货的问题。只要涉及“一对多”的业务,事件表和明细表必须分开建。
6.2 坑二:把应收账款这类科目硬塞进资源
应收账款在传统会计里是一个科目,但放到 REA 模型里,它不适合作为“资源”出现。原因是应收账款不是企业当前控制的资源,而是一种未来获取资源的经济权利,它的价值和时间高度不确定。
如果硬把它建模成资源,很容易在模型里引入大量虚拟事件,比如“确认应收”事件、“冲销应收”事件,最后模型退化成了带新马甲的会计科目表。
正确做法是:通过发货事件和收款事件的二元性关系,计算某张发货单关联的收款金额,未收部分就是“应收账款”。查未收余额时,直接用发货金额减去已关联收款金额,账龄也可以按发货日期算,不需要另建虚拟资源。
6.3 坑三:代理人不区分身份与角色
有一个客户在我负责的系统里,既购买图书,又向我们供应文具。起初我在订单表里写了“客户名称”,在采购表里也写了“供应商名称”,两个字段各自维护,结果同一家公司出现了两套名称,数据完全对不齐。
解决方法是把“主体”和“角色”分开。建一张客户主体表,存公司或个人的基本信息;在业务事件里通过外键表达它当前参与的角色。比如销售订单事件关联“客户角色”,采购订单事件关联“供应商角色”。如果一个人在公司内部还兼任多个岗位,同样用角色字段或角色关联表来处理。
6.4 坑四:为了性能放弃事件明细
REA 模型要求保持事件明细,但很多人在实际开发中会发现,查询余额和汇总经常要 join 多张表,性能不够理想。为了性能,有人会选择在资源表里直接加“当前余额”字段,每次事件发生后更新这个字段。
这样做表面上是优化,实际是在破坏数据可追溯性。一旦某次更新写错,余额就再也无法被完整还原。我的做法是保持事件流水不变,在查询层做优化。比如给常用汇总查询建物化视图,或者每天生成一张库存快照表。底层事件永远保留,上层读模型随意裁剪,这样才兼顾准确和效率。
6.5 常见问题速查
| 问题 | 我的回答 |
|---|---|
| REA 是不是只能用在会计系统? | 不是,只要是记录“谁在何时对什么资源做了什么动作”的业务,都可以用。 |
| 用普通数据库可以实现 REA 吗? | 完全可以,外键关系加事件表就够了,REA 本质是一套建模约定,不需要特殊工具。 |
| 销售订单是事件还是文档? | 销售订单是承诺事件,但要注意把单据头和明细行拆开;它代表的是一种未来交付义务。 |
| 退货怎么建模? | 增加一个退货事件,资源和销售发货形成反向的存量-流量关系,再和原发货事件建立关联。 |
| REA 能直接替代企业资源计划系统吗? | 不能,REA 解决的是核心业务数据结构问题,但企业资源计划系统还包含大量流程引擎、权限策略和外围系统,两者不是一回事。 |
最后分享一个小习惯:我拿到业务流程后,会先用便签把事件列出来,先不管表和字段。事件顺序理清楚了,资源和代理人自然会浮现出来。REA 模型最大的收益不在于一张表设计得漂亮,而在于它逼你把“业务到底如何发生”这件事问到底。把这个基本功做到位,以后新需求追加,往往只是新增一个事件,而不是推翻整张表结构。