你第一次在需求清单里看到rea三个字母的时候,我猜反应和我差不多:愣一下,再猜测它是不是某个代码模块的缩写,是不是少写了一个字符。后来我才辗转确认,它对应的完整概念是 REA,Resource-Event-Agent,中文常叫“资源—事件—代理”。这不是一个前端库,也不是什么新框架,而是一套源自会计信息系统研究的业务建模思路。我这两年做了两个和订单、库存、结算相关的项目,都是在被对账、审计搞得筋疲力尽之后,从 REA 里重新找到了建模方向。这篇文章,就是想给同样正在为供应链或财务系统建模的人,补一堂从 REA 思想到落地的完整实操课。
REA 听起来很学术,其实并不复杂:世界上所有可记录的商业行为,都能用“谁在什么时候,做了什么,让哪些资源发生了流动”来描述。只要把资源和资源之间的关系、参与的人、发生的事件拆清楚,后面的账怎么记、报表怎么出,都能自然推导出来。下面我从原理慢慢讲到怎么建表,再讲它和事件溯源的关系。
1. 三个字母解码:从“财务系统无法解释业务”到 REA 的出现
1.1 传统企业系统的两张皮:业务系统记自己的事,财务系统记另一本账
我先说一个很多后台系统从业者都会撞上的场景。假设你要给一家零售公司做进销存和结算改造,旧代码里通常会有这几张表:产品表、库存表、订单表、收款表、发票表。看起来没问题,每个表各司其职。但到了月末对账,问题就来了:订单表统计的是“已发货的数量”,库存表统计的是“账面减少的数量”,财务那边统计的是“应该确认的收入金额”,三组数字经常打架。
为什么打架?因为设计的时候,每一波人被分开思考了:运营关心订单什么时候履约,仓库关心库存剩多少,财务关心钱和成本有没有匹配。三套表都有各自的更新时间点和字段口径。比如订单表可能用下单时间,仓库用出库时间,财务用开票或收款时间。同一个业务行为,被记成了三个互不相干的事实,时间对不上,状态对不上,金额口径也对不上。
这种“两张皮”结构几乎是所有手工对账痛苦的根源。后来我遇到 REA 这个概念时,它核心要解决的恰恰就是这个问题:先不要急着按部门拆表,而是把业务本身当作一条完整的经济事件链来描述。业务活动到底发生了什么、资源往哪个方向流、谁参与,这些搞清楚了,各部门的报表就都应该只是对同一条链路的视角投影,而不是各自独立的一本账。
1.2 REA 的出生背景没那么玄乎,就是一个“反着来”的建模想法
REA 的历史其实很值得聊。它最早是会计信息系统研究人员在思考的一个问题:会计系统能不能不再依赖一张写满借贷科目的表,而是从底层经济活动直接建模。传统会计是先记账,就是把交易结果记下来;REA 想的是,能不能先记录业务本身,账务只作为业务的一种自动推导结果。
这套模型在上世纪八十年代左右被系统化之后,很长时间集中在学术讨论和财务口径研究中。直到后来领域驱动设计、事件溯源这些概念流行起来,大家回头一看:这不就是事件溯源的远房亲戚吗?于是 REA 开始被更多做订单、库存、结算、供应链系统的人重新拾起来。
我个人的理解是,REA 更像一张“语法树”。它不规定你企业的具体科目,也不强制你用什么数据库,它只规定你描述经济行为的语法:谁参与了什么事件,事件让什么资源增加或减少。只要这套语法是完整的,账务、库存、结算都可以被它生成出来。换句话说,它提供的是从“业务事实”到“财务结果”的翻译框架。
这第一部分的关键价值在于:如果你是在传统 ERP 环境里工作,其实不需要推翻现有系统,你只需要把 REA 作为领域建模的桥头堡,用来梳理模块之间的数据流。开发票、发货、收款这些直观上不同的动作,放到 REA 里不过是几组资源流动的不同口径。
2. 三个构件玩一天:资源、事件、代理的职责与边界
2.1 资源:能控制、能计量、有价值
REA 给资源下的定义比较严格:它必须同时满足“有价值、可控制、可计量”三个条件。说得更白一点:你需要能度量它,你能决定它的命运,并且它在交换或使用中能带来价值。现金是资源,库存商品是资源,仓库里的设备是资源,尚未收回的债权在某个时点上也可以被视为资源;但一条“系统里的操作日志”就不是资源,因为它没有办法形成独立的价值流动,也没有被谁真正控制。
不要把资源简单理解成“物理物料”。在电商场景里,一张优惠券是不是资源?看你怎么定义它。如果优惠券代表了企业未来需要承担的负债,并且可以被领取、使用、过期,那它就可以建成一个资源。资源和“科目”不一样,后面我会专门讲,资源的核心特征是参与流动,而不是承担借贷平衡。
我建议建模时用一句话来验收:这个对象能不能出现在一个经济事件的“流入”或“流出”中?如果不能,它就不应该是资源,更不要直接给它建一张累计变动表。很多过度设计,就来自这一步没想清楚。
2.2 事件:真正让资源发生变化的那一下
事件是 REA 的核心,甚至可以说整个模型的其他部分都是围绕事件组织的。事件表示实际发生的经济行为,例如“采购入库”“销售出库”“收到货款”“支付货款”“盘点调整”等。它的特征是在某一个时点,让至少一种资源发生了方向性变化。
这里特别容易踩坑:事件不等于系统动作。用户点击了“提交订单”按钮,这是一个系统操作;真正的事件可能是“订单被创建”,而如果订单还没支付、还没发货,它可能只是创建了一个业务实体,或者什么都没有改变。把点击、访问、查询这些系统行为当成事件,是 REA 建模里最常见的污染源。后面我会专门展开怎么过滤。
一个经济事件可以同时影响多种资源,也可以只影响一种资源。比如“现金盘点不符”这个事件,可能同时增加一种损失资源、减少一种现金资源。这要求在建模时不要把事件设计成单一字段表,而要给它配一张“事件资源流水表”,这样才能表达一个事件同时向多条资源流动的复杂性。
2.3 代理:谁在参与,以及内部代理和外部代理
代理就是参与经济事件的人和主体。它可以是一个客户,可以是一个供应商,也可以是公司内部的员工、部门或业务员。REA 里通常会区分内部代理和外部代理,因为很多流程的会计处理位置不一样:向客户销售时,客户是外部代理,销售员是内部代理;向上游采购时,供应商是外部代理,仓管或采购员是内部代理。
把代理单独拉成一张表,而不是像传统订单表那样把客户信息直接塞在订单字段里,带来一个好处:后续可以围绕同一个代理做更多查询。某个客户从首次接触到现在参与过哪些事件,某个供应商在某个结算周期内一共形成了多少应付事件,这些在传统的一对一关联下很难自然展开。
你可以把代理设计成分组结构,比如客户这个角色对应一组用户账号,也可以把代理层级展开为集团客户和子公司客户,这些都属于实现细节。REA 本身不限制建模深度,它只要求事件必须能追溯到代理。没有代理参与的经济活动是不成立的,也是无法认可、无法审计的。
2.4 把它们拼在一起:一次超市购物活动如何完整表达
来举个小例子。一家有实体货架的超市,进了 100 瓶矿泉水。采购入库事件描述为:供应商(外部代理)把 100 瓶水送进仓库,库存资源增加,参与代理是供应商和仓管员。随后顾客购买了 2 瓶水并支付现金。销售事件描述为:顾客和收银员参与,货架库存减少 2 瓶;收款事件描述为:现金资源增加相应金额。
这里已经出现两个事件:一个减库存,一个加钱。它们之间还有一层对应关系:一手交货,一手交钱。这在 REA 里称为“二元性”——任一经济活动通常都不是孤立的,它往往需要通过另一个事件完成价值交换。后面的表格设计里,二元性要靠事件之间的配对字段来表达。丢掉二元性,就等于丢掉审计线索里最关键的闭环,后面对账会非常痛苦。
3. 从借贷记账到 REA:一个买水动作如何把自己拆成两笔分录
3.1 复式记账的“借”与“贷”:一场古老而精致的平衡游戏
如果你学过会计,肯定熟悉这条式子:资产 = 负债 + 所有者权益。在这个等式下,每一笔经济业务都要被拆成至少两个科目,一借一贷,金额相等。复式记账能存在几百年,是因为它通过平衡机制让记账错误更容易被发现。
但复式记账有一个与现代软件系统格格不入的地方:它要求你预先确定一套科目体系,然后把业务翻译成科目借贷。如果业务固定,这没问题;可一旦系统要同时服务多个业态,比如零售、批发、服务、线上订单一起跑,科目表会变得非常臃肿,而且经常出现“这个业务我该挂到哪个科目”的争论。业务团队和财务团队会因为一个归类问题扯皮,底层原因不是责任心,而是建模时缺少了业务事实层。
REA 把它反了过来:先明确业务事件和资源流,科目借贷只是最后输出环节的一种编码。这对系统设计的影响是结构性的——代码里不再到处散落科目判断,而是把科目生成逻辑收拢到少数几个模板里。
3.2 从 REA 自动推导:卖 2 瓶水,产生的两对分录
用前面超市的例子把金额补齐:矿泉水的进价成本是每瓶 2 元,销售价是每瓶 4 元。顾客买 2 瓶,支付现金 8 元。如果按照 REA 梳理,会得到两个事件:
销售事件:库存资源流出 2 件,金额按成本算为 4 元。 收款事件:现金资源流入 8 元。
再往传统总账投影,收款事件产生第一对分录:借现金 8,贷营业收入 8;销售事件产生第二对分录:借营业成本 4,贷库存商品 4。两对分录合起来,收入、成本、现金、库存全齐了,而且借贷仍然平衡。
这里要注意,把“营业收入”和“营业成本”推导出来的关键,是 REA 提前知道了业务双方的关系:一个收款事件对应一个销售事件,销售事件涉及货物资源流出。如果只给传统的订单表数据,这套推导通常需要大量 if-else;如果用 REA 建模,推导规则反而可以做成非常小的模板,复用性很好。
3.3 复式记账与 REA,到底谁替代谁
先说清楚:REA 不是要替代复式记账。恰恰相反,复式记账在现代财务体系里的地位不可动摇。REA 提供的是一种更稳定的“生产分录的位置”,它把企业活动描述成原始事件,复式记账则根据规则把这些原始事件变成账。
| 维度 | 传统复式记账模型 | REA 模型 |
|---|---|---|
| 核心对象 | 科目、凭证、余额 | 资源、事件、代理 |
| 记录粒度 | 一笔凭证,按借贷方向 | 一个经济事件,带多方向资源流 |
| 与业务的关系 | 先有财务规则,再映射业务 | 先有业务事实,再推导财务规则 |
| 审计线索 | 靠凭证号串联 | 靠事件与资源的流向闭合衔接 |
| 对软件开发的影响 | 通常要在代码里写死科目 | 更倾向于以事件流和资源流建模 |
如果面对的是传统财务软件,你可能只能在“凭证”这一层兼容 REA,把生成的凭证作为事件投影即可;如果是自研业务平台,完全可以考虑把 REA 直接作为核心数据模型。两者并不冲突,关键是你需要想清楚自己当前处在哪个层级。
4. 可落地的关系模型:基于 REA 设计订单履约的四张表
4.1 先圈范围,再做名词盘点
用一个更真实一点的场景练手:一个简化后的“零售门店订单履约”系统。需要覆盖进货、销售、收款、退款,先不讨论发票、物流、税费,避免篇幅失焦。
做 REA 建模,第一步不是画 ER 图,而是先做名词盘点:
- 资源:库存商品、现金仓库(银行账户)、代金券(如果用于兑换)
- 代理:顾客、供应商、收银员、库管员
- 事件:采购入库、销售出库、收款、退款、付款
然后问自己:哪些事件是成对的?销售出库往往与收款成对,退款往往与退款返回现金成对,采购入库与付款成对。把这些对偶关系记下来,建表时就能体现二元性。
4.2 四张表的核心结构
我不太喜欢把每个事件类型都建成单独的一张表,比如 sales_events、purchase_events、payment_events,这会让公共逻辑散落各处。相比之下,用四张基础表完成这件事,让事件类型只是事件表里的一个字段,更符合 REA 的通用性。
下面是可直接在 PostgreSQL 里运行的结构:
create table agent ( id bigserial primary key, agent_type varchar(32) not null, -- internal / external role varchar(32) not null, -- customer / supplier / staff / group name varchar(128) not null, ext_code varchar(64) unique ); create table resource ( id bigserial primary key, resource_type varchar(32) not null, -- product / cash / voucher code varchar(32) not null unique, name varchar(128) not null, unit varchar(16) not null, current_qty numeric(18,4) not null default 0 ); create table event ( id bigserial primary key, event_type varchar(32) not null, -- purchase_in / sale_out / receive_cash / pay_cash / refund agent_id bigint not null references agent(id), occurred_at timestamptz not null, counter_event_id bigint references event(id) ); create table event_resource_flow ( id bigserial primary key, event_id bigint not null references event(id), resource_id bigint not null references resource(id), direction varchar(8) not null check (direction in ('in','out')), quantity numeric(18,4) not null, amount numeric(18,2) not null );这套结构抓住了 REA 的几个关键点。事件表用 event_type 区分业务类型,不同类型共享一种事件结构;counter_event_id 用于表达二元性,比如“销售出库”的对偶伙伴是“收款”;event_resource_flow 是事件与资源之间的多对多关系,可以同时记录多种资源的进出。
为什么不把数量直接写进事件表?因为一个事件可能同时影响多个资源。比如“销售出库”如果同时发出两个不同商品,它不用拆成两个事件,只要在 flow 表里写两行即可。这在现实业务里非常重要。
4.3 用 SQL 描写一次完整的卖水过程
继续卖水的例子。先把水和现金两个资源建好,再把顾客与收银员两位代理建好。
insert into resource(resource_type, code, name, unit, current_qty) values ('product','WATER','瓶装矿泉水','瓶',1000), ('cash','CASH_MAIN','门店主现金','元',500); insert into agent(agent_type, role, name) values ('external','customer','顾客甲'), ('internal','staff','收银员小张'); -- 销售出库事件,由收银员小张发起 insert into event(event_type, agent_id, occurred_at) values ('sale_out', 2, now()); -- 取该事件 id 作为 sale_event_id,假设为 1 insert into event_resource_flow(event_id, resource_id, direction, quantity, amount) values (1, 1, 'out', 2, 4); -- 收款事件,与销售事件配对,参与代理同样是收银员小张 insert into event(event_type, agent_id, occurred_at, counter_event_id) values ('receive_cash', 2, now(), 1); -- 假设收款事件 id 为 2 insert into event_resource_flow(event_id, resource_id, direction, quantity, amount) values (2, 2, 'in', 8, 8);注意这里的 sale_out 的 amount 用的是成本价 4 元,而 receive_cash 的 amount 用的是销售价 8 元。一个代表货物资源的流出成本,一个代表现金资源的流入收入,二者本来就是不同维度,用 flow 表的多行去承载,比挤在同一张事件表里要干净得多。
4.4 从真实表里查库存、查对账结果
资源表里的 current_qty 只是一种查询缓存,真正可信的账目来源应该是事件资源流水。查询当前库存时,我一般直接对流水做累计:
select r.code, r.name, sum(case when f.direction = 'in' then f.quantity when f.direction = 'out' then -f.quantity else 0 end) as stock_qty from resource r left join event_resource_flow f on f.resource_id = r.id group by r.code, r.name;如果要查每个事件在各资源上的流出/流入金额,可以按事件汇总:
select e.id, e.event_type, sum(case when f.direction = 'out' then f.amount else 0 end) as out_amount, sum(case when f.direction = 'in' then f.amount else 0 end) as in_amount from event e join event_resource_flow f on f.event_id = e.id group by e.id, e.event_type;这里有个实践忠告:不要每做一次业务就去更新 resource.current_qty 并依赖它做报表。因为一旦出现事件漏记或重复记账,current_qty 会悄悄失真,而且几乎无迹可寻。更稳妥的做法是把它当作性能缓存,通过异步任务刷新;数据权威源永远是“事件流 + 资源流”。
5. 当事件溯源遇到 REA:意图事件和执行事件不能混为一谈
5.1 两者的世界观长得实在太像了
写过事件溯源架构的人,第一眼看到 REA 几乎都会觉得眼熟:都是“事件是事实的唯一来源”,都是“通过重放事件得到当前状态”。在简单订单系统里,OrderPlaced、OrderShipped、PaymentReceived 这些事件,和 REA 里的“销售事件”“收款事件”看起来可以一一对上。于是很多人会想:我是不是可以直接把 REA 当作事件溯源的一种实现方式?
我认为可以,但不能全盘照搬。事件溯源里的“事件”是一个技术概念,泛指应用在某个时刻发生过的事实,比如 UserClicked、EmailSent、OrderStateChanged;而 REA 里的“事件”是一个业务概念,指的是经济资源的增减。两者有交集,但不等价。
5.2 承诺事件与执行事件,必须分开存
企业流程并不是一步到位的。客户先下了一个意向订单,系统先把库存锁住,再等支付,最后才发货。在这个链条里,“锁定库存”并没有真正发生库存资源的流动,它只是承诺未来会发生销售;支付成功后才真正触发经济事件。
如果把“锁定库存”也塞进 REA 事件表并让它去增减库存资源,会导致两个严重问题:一是库存数字提前减少,其他客户下单时被错误挡住;二是“承诺”和“执行”混在一起,审计无法分清哪一步是真正交到了客户手上。
我的建议是:REA 事件表只记录真正造成资源增减的执行事件;承诺库存、预占额度、待付款这类状态,可以放进单独的承诺表或业务对象的状态字段中。如果一定要让承诺也进入事件流,就给事件表增加一个 phase 字段,区分 committed 和 executed,并且禁止承诺阶段写入 event_resource_flow 的资源变动。这一步看起来只是加个字段,实际能救回大量对账时间。
5.3 微服务转账场景中的 REA 与幂等控制
再举一个微服务间协作的例子:账户 A 向账户 B 转账。从 REA 的角度看,这是一个典型的二元事件对:事件一是转出,账户 A 的现金资源流出;事件二是转入,账户 B 的现金资源流入。两个事件发生在不同服务中,但必须保证要么都成功、要么都不成功。
这时 counter_event_id 就不再只是审计字段,而是幂等控制的天然钥匙。接收端在处理转入事件时,可以先去查 counterpart_event_id 是否已经存在;如果存在,说明是重复消息,直接返回最终结果。很多对账问题,来自两个服务各自记录自己那半边事件,却没有任何字段把两半拼起来;REA 要求成对出现,天然解决了跨服务账务拼接的问题。
当然,落地时不可能让每个服务都直接建同一套 REA 表,但你至少应该在跨服务消息里保留“业务事件号”和“对偶事件号”,并在接收端做重复校验。这比单纯用消息队列的幂等键更贴近业务——它不光能去重,还能让两边各自从业务上确认“我这半边到底对应哪半边”。
6. 我的踩坑记录:四个把 REA 用出事故的建模决策
6.1 把操作日志当成了经济事件
我第一次用 REA 建模时,过度兴奋,把用户下单、点支付、点退款按钮这些后端收到请求的动作,全都录成了事件。结果事件表几天就爆了,而且里面大量事件根本没有影响任何资源流,后面的对账统计全是噪声。
反复试过之后,我给“可入事件表”的事件定义了三道过滤:
- 这个事件是否导致至少一种资源的数量或金额变化?
- 这个事件是否由某个内部或外部代理参与?
- 这个事件是否发生在真实时空中的一个业务节点?
只要三个条件有任何不满足,那它就不是 REA 经济事件,而是普通应用日志。从此以后,再也不敢把点击次数、页面访问埋进业务事件表里。
6.2 把“应收账款”直接当成了资源
另一个更隐蔽的坑,发生在财务科目和 REA 资源之间。当时项目里有张应收款表,业务方觉得它就是一项资产,自然应该作为资源表存余额。结果这张表每天都在变,一边对接销售订单,一边对接回款,最后总是差几笔抹不平。
后来回头想,原因在于“应收账款”其实是“销售事件已发生但对应收款事件尚未完成”的派生状态。REA 里的正确姿势是只记录销售事件和收款事件,并在查询时算出“已经发货但未回款”的集合,而不是再单独建一个资源。一旦你把它建成资源,就相当于凭空塞进了一个本应由其他事件推导出来的累计值,对不上账几乎是必然的。
这不是说项目里完全不能有应收字段,而是要分清:展示字段和建模字段是两回事。报表可以展示应收余额,但表结构不应该让应收余额来自一个独立资源,除非它有独立于销售/收款之外的发生原因。
6.3 丢掉了二元性的另一半,账永远对不平
有段时间,我为了图省事,销售出库事件记了,收款事件也记了,但没有给它们建立 counter_event_id 的连接。结果月底对账时,系统里有三笔收款找不到来源订单,还有两笔订单迟迟找不到收款。不是业务上出错,而是数据关联无法闭环,连确定到底谁错了都做不到。
REA 的二元性不是一种学术装饰,它代表业务交换本身:一手交钱一手交货。如果在业务场景里必须出现成对事件,就直接在代码层保持约束:写主事件的同时,必须找到或创建对偶事件,二者缺一就拒绝事务。做不到这一步,REA 反而会比传统单据更乱,因为它少了凭证号那层现成线索。
6.4 事件粒度过粗或过细,都会让系统跑得很别扭
建模初期我常陷入两个极端:要么把一个“订单”当作一个事件,导致一个订单里的多个商品无法部分发货;要么把一个库存数量的微小变化都当作事件,导致事件表塞满琐碎流水,报表里全是细碎条目。
我现在的原则是:事件粒度对齐“资源流可变的最小真实业务单元”。比如一个订单包含 10 个商品,物流一次发 10 个,那销售事件就是 10 个商品共用一个事件,flow 表写 10 行;如果分两批发货,那就要拆成两个销售事件。看起来都是小事,但粒度一旦错,后面的部分发货、部分退款都会很难做,甚至需要重建事件结构。
6.5 留给自己的 REA 实操清单
现在我每次做业务建模,都会先按下面几步走一遍:
- 用一句话描述业务闭环,找出外部代理和内部代理。
- 写资源清单,凡不能参与流入/流出的对象,一律先踢出清单。
- 列事件清单,给每个事件标出“造成哪种资源的流入/流出”。
- 检查事件之间是否存在成对关系,把对偶关系写进设计文档。
- 确定承诺阶段与执行阶段的边界,承诺不进资源流水,执行才进。
- 最后才根据事件和资源流水设计表结构。
这套清单不一定永远正确,但至少帮我避开了大部分自造的对账难题。你在实践时,完全可以按自己的业务裁剪,不需要照搬。
我个人的体会是,REA 并不是那种要做大面积平台改造才能用上的高冷理论。它更像是一个让人“想清楚业务再动手”的思维脚手架:哪怕是快速做一个小工具,只要在开工前用“代理 + 事件 + 资源流”的方式把逻辑捋一遍,后面写代码和做报表都会顺很多。
最后分享一个很小但很实用的经验:第一次建模时,拿一支笔把所有事件之间的连接箭头画在纸上,再在每个箭头旁标上“in”或“out”,会比坐在 IDE 里反复改表结构高效得多。你不需要一开始就做到完美,往往画到第二个业务闭环,很多东西就自然清晰了。