你第一次在需求清单里看到rea三个字母的时候我猜反应和我差不多愣一下再猜测它是不是某个代码模块的缩写是不是少写了一个字符。后来我才辗转确认它对应的完整概念是 REAResource-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 里反复改表结构高效得多。你不需要一开始就做到完美往往画到第二个业务闭环很多东西就自然清晰了。