如果你做过业务系统特别是那种每天都要跟销售单、采购单、出入库、回款对账打交道的系统大概率遇到过很别扭的时刻业务方嘴里的“订单”“发货”“收钱”到了数据库里被拆成一堆字段和状态改起来牵一发动全身。我也踩过这种坑后来逼着自己补了一套叫 REA 的领域建模方法才算慢慢把业务和账目两套逻辑捋顺。REA 全称 Resources-Events-Agents中文一般翻成“资源—事件—参与者”模型。它最早出现在会计信息系统研究里后来被不少 ERP、进销存、订单中台项目拿来做底层建模参考。这篇文章我会把 REA 的核心思路、一步步建模的方法、以及落地时踩过的坑讲清楚希望能帮你下次做业务建模时少走弯路。1. 为什么业务建模绕不开 REA从借贷分录到业务事件1.1 传统分录模型到底卡在哪很多人一开始接触业务系统看到的数据库结构往往是一张“凭证表”或者“流水表”字段大概长这样凭证号、借方科目、贷方科目、金额、业务引用、备注。记账逻辑没错数据也能查但一旦要回答业务问题就非常吃力。举个很常见的场景财务要一笔销售回款业务想知道“这笔钱对应哪个销售单、哪个客户、哪个销售员跟进、从哪个仓库发的货”。凭证表里通常只有一个biz_ref靠拼接字符串指回业务单或者再加一个business_type区分各种单据。这在一开始能用可一旦出现拆分发货、部分退款、多人参与、多资源混合收款表结构就会变成一堆“辅助字段”的堆积地。我见过最夸张的报价系统一张流水表里塞了十几个可空字段只为了兼容不同业务类型后来任何改动都伴着重构风险。这背后的问题不是字段不够多而是建模视角错了。传统借贷分录记录的是“钱从哪进、从哪出”本质是结果型模型。它对财务对账足够但对企业经营来说太薄。经营过程里的商品流动、责任归属、先后顺序、承诺与执行全部被压扁成了一条条金额记录。丢失的信息看似可以靠外围表补回来但补得越多模型越碎业务规则越散。1.2 REA 的解题姿势把“发生了什么”放在第一位REA 模型提出了一种相反的思路先记录“发生了什么”再让“账怎么记”从事件中推导出来。它把业务系统看作一系列经济资源在参与者之间流动的过程核心单元不是科目而是“经济事件”。我打个比方。你去咖啡店买咖啡站在业务系统的角度真正发生的事情是咖啡豆/咖啡液这份资源从店里流向顾客同时现金从顾客手里流向收银机。传统做法可能直接写一条“营业收入 30现金 30”的分录。REA 的做法则记录两个事件一个是“卖出咖啡”事件表示资源流出另一个是“收到现金”事件表示资源流入。这两个事件通过一种“双向性”关系配对共同构成一次交换。至于“营业收入”这种科目口径完全是后续从事件里算出来的结果。这种思路的价值在于模型不再只关心最终余额而是把完整过程变成可查询、可追溯、可审计的事实记录。库存为什么少了因为有销售出库事件。现金为什么多了因为有收款事件。业务上想统计某个客户贡献了多少收入也能直接基于事件聚合出来而不是去猜凭证上的业务引用指向哪里。我第一次把 REA 用在项目里时最大的感受是“表结构终于不再追着业务规则跑了”。业务加一个新动作往往只需要新增一种事件类型而不是改一堆字段和状态机。这也是我决定把 REA 作为业务建模默认方法的原因。2. REA 的三个核心构件与两组关键关系2.1 Resource、Event、Agent 各自管什么REA 的基本构件只有三个但每个词的边界要抠清楚否则一建模就跑偏。Resource资源指的是有经济价值、且能被参与方控制的东西。库存商品、现金、应收账款、服务工时、积分甚至一段产线产能只要参与交换并产生价值流动都可以建模为资源。要注意的是不是所有业务对象都是资源。订单号、合同编号、审批状态这类东西不是资源它们是流程附属信息或者约束条件。建模时我会反复问自己这个对象会不会在事件中发生流入或流出如果不会它就不是资源。Event事件指的是资源实际发生流入或流出的“那一刻”。这里的关键词是“实际”。签合同不是事件它只代表双方承诺未来会交换货物发出去才是事件现金到账才是事件。如果系统里只有单据状态而没有真正的事件记录就会出现“单据显示已发货库存却没有减少”的经典事故。REA 把事件当作原子事实时间、数量、参与人都在事件上打标签方便后续追溯。Agent参与者指的是参与事件的个人、组织或角色。内部参与者如仓管员、销售员、财务外部参与者如客户、供应商、物流商。同一个自然人在不同场景可以是不同 Agent比如某人身兼仓管和采购建模时就分两个角色身份而不是把“名字”当主键。因为后续要回答“谁对这个事件负责”角色和权限往往比人更重要。2.2 Stockflow 与 Duality让模型真正转起来三个构件不能孤立存在REA 的关系才是精髓。最核心的两组关系是“资源—事件”关系以及“事件—事件”关系。资源与事件之间的关系叫 Stockflow中文可以理解成“资源流”。一个事件会让某类资源增加或减少增加就是 inflow减少就是 outflow。这个关系非常重要因为它把“事件”和“量变”绑在了一起。比如“销售出库”事件和“库存商品”资源之间是 outflow 关系“收款”事件和“现金”资源之间是 inflow 关系。单独看一个事件它只是动作加上资源流以后它就变成了一笔可计量的经济事实。事件与事件之间的关系叫 Duality我习惯叫“双向性”或“配对关系”。现实中的大部分经济业务都是交换卖出商品要收到钱采购原料要付钱提供服务要取得报酬。REA 要求把一次完整交换拆成一对配对事件一个资源流出事件一个资源流入事件。这两个事件共同成立业务才算闭环。来自实际项目的经验是建模时如果只看到单个事件而不去找配对事件系统很容易变成一条条孤立的流水。比如只记录销售出库不记录收款库存是准了应收账款却没了来源只记录采购入库不记录付款应付账款就会凭空消失。REA 的双向性强制我们把“资源的减少”和“资源的增加”绑定在一起看这样业务循环才完整后续做财务核算也有据可依。除了这两组核心关系REA 里还有 Commitment承诺、Reciprocal互惠等扩展概念。在订单场景中“接受订单”可以建模为未来销售事件的承诺“询价”“审批”则更多属于流程性信息。我一般先不碰这些扩展概念等核心事件和交换关系稳定以后再按需补充。建模最怕一开始就想把宇宙都装进去REA 的好处就是主干特别简单扩展点又很清晰足够支撑复杂场景。3. 一个订货流程的完整 REA 建模推演3.1 选定业务场景与业务循环纸上谈兵没意思我用一个常见的电商自营订货场景来完整推演一遍。假设某公司运营一个自营商城业务流程是这样的客户在线下单并支付仓库随后拣货发货货物送达后订单完成同时公司需要向供应商采购补货供应商送货入库后公司按账期付款。拿到这个场景第一步不是直接设计表而是先圈出“业务循环”。这个流程里存在两个典型的循环一个是收入循环商品流向客户现金流入公司另一个是支出循环商品从供应商流入公司现金流出公司。两个循环通过“库存商品”这个资源连接起来形成整个企业的经营链路。圈出循环后我会画一张很简单的表把每个循环涉及的事件、资源、参与者、配对事件列出来。不用画花哨流程图一张表足够了。业务循环经济事件资源流入/流出参与者配对事件收入循环销售出库库存商品 流出客户、仓管员收款收入循环收款现金 流入客户、财务销售出库支出循环采购入库库存商品 流入供应商、仓管员付款支出循环付款现金 流出供应商、财务采购入库这张表里没有出现“订单表”因为订单本身不是经济事件。客户下单只是表达购买意图属于 REA 里的 Commitment真正让资源发生流动的是后来的销售出库和收款。我并不是说系统里不能有订单表而是想说订单表可以作为业务过程单据存在但它不能替代事件记录。如果把订单状态当成资源变化依据遇到取消、拒收、部分发货时就会乱套。3.2 先定事件再定资源最后补基数明确了循环和事件后建模顺序是固定的先定经济事件再给事件挂资源和参与者最后定关系基数。以收入循环为例。“销售出库”事件和“库存商品”资源之间是 outflow 关系“收款”事件和“现金”资源之间是 inflow 关系。“销售出库”和“收款”通过付款单、支付流水等体现双向性。如果客户是先款后货那就是收款事件先发生销售出库事件后发生如果是货到付款顺序反过来。REA 并不规定哪个事件必须先发生只要求它们配对存在。接下来补参与者。这里的参与者不只是“客户”和“公司”还要细分为具体角色。销售出库事件至少涉及客户和仓管员收款事件涉及客户和财务。如果把“客户”当成唯一参与者后续想统计仓管员绩效、财务回款效率就做不到了。实际操作中我习惯把参与者角色建模成一个关联表一个事件可以挂多个参与者每个参与者带一个角色类型。这样既不会丢责任信息也不会把一张表塞满冗余列。最后是基数判断。一个客户可以发生多次销售出库所以 Agent 到 Event 是一对多一次销售出库可以包含多个商品行所以 Event 到 Resource 是多对多。多对多关系意味着需要一个中间表来记录每行事件中的商品、数量和方向。很多新手会下意识地在事件表里加一个product_id字段这是不对的。一旦一次销售包含十种商品事件表就要拆成十行而它们明明是同一个事件的不同资源行。正确做法是事件表只记事件本身商品和数量放到event_resource中间表里。这个推演做完之后可以很自然地回答很多以前要“写特殊逻辑”的问题某个订单还差多少回款把销售出库事件和收款事件做配对查询就知道。某仓管员的发货准确率按仓管员角色聚合销售出库事件的资源行就知道。REA 不是说让一切都变简单而是让复杂规则有了统一的落点。4. 从 REA 模型到数据库表结构把设计落到 DDL4.1 实体与关联关系的映射原则模型画清楚了落地就变成一件机械活。REA 实体和关联关系映射成数据库表有一条简单规则一个实体一张表一个多对多关系一张关联表。Agent 实体映射为rea_agent表字段包括 agent_id、agent_name、agent_type、状态等。Resource 实体映射为rea_resource表字段包括 resource_id、resource_name、resource_type、单位等。Event 实体映射为rea_event表这是整个模型的核心字段包括 event_id、event_type、occurred_at、备注以及配对事件的counterpart_event_id。Stockflow 关系因为是多对多需要单独建一张rea_event_resource表。每行记录一个事件中某种资源的数量变化以及变化方向inflow 还是 outflow。很多业务同学会问库存余额不是直接存在库存表里吗我的回答是库存余额可以作为“读模型”缓存但真正的源事实是事件。只要事件记录完整余额随时可以重算如果只维护余额字段一旦漏记或重复记账对账就无从下手。Duality 关系可以建模为事件表里的counterpart_event_id字段。这里有个细节配对事件并非只能一一对应。比如一个销售出库事件可能被分三次收款那就需要三个收款事件都指向同一个销售出库事件反过来一次合并支付可能覆盖多个销售单那就需要多个销售出库事件都指向同一个收款事件。所以counterpart_event_id不能设计成唯一索引否则会把业务卡死。正确做法是允许一对多、多对一查询时按事件类型和配对事件 ID 聚合。参与关系同样是多对多需要一张rea_event_agent表字段包括 event_id、agent_id、role_type。使用角色而不使用单一字段是因为一个事件可能有多个内部参与者创建人、审核人、执行人、确认人。把这些塞进事件表会让列爆炸放到关联表里反而干净。4.2 一张可以直接参考的核心 DDL这里给一套最小但完整的 PostgreSQL 风格 DDL。为了减少篇幅我省略了索引和完整约束但在真实项目里这些反而很重要。create table rea_agent ( agent_id bigint primary key, agent_name varchar(128) not null, agent_type varchar(32) not null, -- customer / supplier / employee status varchar(16) not null default active ); create table rea_resource ( resource_id bigint primary key, resource_name varchar(128) not null, resource_type varchar(32) not null, -- inventory / cash / receivable unit varchar(16), current_qty numeric(18,3) -- 只作为冗余缓存不当作源事实 ); create table rea_event ( event_id bigint primary key, event_type varchar(32) not null, -- sale / cash_receipt / purchase / cash_disbursement occurred_at timestamp not null, counterpart_event_id bigint, remark varchar(255), constraint fk_counterpart foreign key (counterpart_event_id) references rea_event(event_id) ); create table rea_event_resource ( event_id bigint not null references rea_event(event_id), resource_id bigint not null references rea_resource(resource_id), quantity numeric(18,3) not null, flow_type char(3) not null, -- inc / dec primary key (event_id, resource_id) ); create table rea_event_agent ( event_id bigint not null references rea_event(event_id), agent_id bigint not null references rea_agent(agent_id), role_type varchar(32) not null, -- customer / handler / reviewer primary key (event_id, agent_id, role_type) );这套结构看起来比普通业务表“多”了好几张表但真要写业务逻辑时反而清爽。例如要统计“某客户未回款金额”可以这样想先找该客户参与的销售出库事件再找这些事件配对的收款事件用销售出库的资源流金额减去已配对收款金额剩下的就是应收。所有口径都能从事件事实推导出来不需要依赖业务方口头约定的“特殊状态”。我把这种结构叫“事实型表结构”。它的核心特点是事件表只增不改资源数量变化通过新增事件行表达余额类数据全部可重算。这对于审计、对账、报表追溯特别有价值。系统上线初期可能看不出太大优势但一旦业务跑三五年数据量上来、规则多起来这种模型改起来比传统“状态机 大宽表”稳得多。5. 落地过程中的常见坑与排查技巧实录5.1 事件与订单混淆销售单不等于销售事件最典型的问题是团队把“订单表”当经济事件用。订单创建、订单支付、订单发货、订单完成全部靠订单表一个status字段驱动。这个设计在业务简单时没问题可一旦出现“先付款后发货”“先发货后付款”“部分发货”“退款不退货”等组合状态枚举就会膨胀每个状态组合都靠代码 if-else 维护。我的排查经验是凡是在order_status里看到超过六种状态就要怀疑建模层面把事件和单据混在一起了。正确做法是订单表保留为“承诺模型”专门管理业务过程和文案真正的销售事件、收款事件、物流事件各自落到事件表里。两者通过业务编号关联但各自的更新时机不同。订单状态可以由事件推导而不是反过来靠状态驱动事件。在实际项目里我做过一个改造把订单表的status从“驱动一切”降级为“聚合展示”核心流程改成写事件表再异步同步订单状态。上线后很多历史遗留的“状态不同步”问题反而自然消失了因为状态不再是唯一事实来源。5.2 库存对不上余额表被直接更新另一个高频问题是库存数量对不上。翻开代码一看销售出库时直接update rea_resource set current_qty current_qty - 1。这个操作本身没错但它没有留下“为什么减少”的事件记录。如果同事在同一时间用批处理修正库存或者某个接口被重复调用余额就会出现偏差而且没人能说清偏差发生在哪一笔。正确做法是任何数量变化都先写rea_event_resource再更新rea_resource.current_qty作为缓存。更新余额和写事件必须在同一个数据库事务里否则极端情况下会出现事件已经写入但余额没扣的情况。更稳妥一点余额表甚至可以不做实更新而是用每日汇总任务重算核心源事实永远在事件表里。我遇到过团队为了性能去掉事件层最后每次对账都要翻历史日志的场景那种代价远远超过一次更新余额的开销。5.3 退款退货删除记录是最省事也最危险的做法退货时第一反应常常是把原来的销售事件删掉或者把库存加回去。这在实现上一点也不难却会让历史事实“变形”。审计要求的是“当时发生了什么”而不是“现在看起来怎样”。初始单据上写着卖出十件后来退了两件正确语义是原始事件记录卖出十件后续新增一个“退货”事件把两件商品回流到库存。用 REA 来表达就是新增一条return事件方向是 inflow并通过counterpart_event_id指回原始的销售出库事件。这样就保留了完整链路卖了多少、退了多少、实际应收多少、客户最终拿到多少。财务核算时再根据配对关系冲销收入而不是去修改历史收入记录。把“更正”建模成“新事件”是我在这个模型里学到的对“不可变性”最直观的理解。5.4 一次现场排查复盘锁定库存到底算不算出库印象很深的一个项目业务方问“库存报表为什么老是跟仓库实物对不上”。现场查下来发现报表里把“已锁定库存”也算成已出库可实物明明还在仓库里。从 REA 角度看“锁定库存”是承诺不是资源流出。商品还在仓库就不能对库存资源记 outflow。我们最后把“库存锁定”单独建模成一个资源状态或者承诺记录与销售出库事件完全分开。报表口径因此调整成“仓库实物 账面库存 已锁定未出库 - 已出库未发货”很快就和实物对上了。这个案例给我的经验是排查询题之前先问一句“这到底是不是经济事件”很多异常其实源于把承诺、状态、事件三种不同性质的东西混在一张表里。5.5 常见问题速查表症状可能原因排查方向处理建议库存对不上直接更新余额没有事件记录查余额表日志、接口调用记录新增事件表余额改为派生值收入没有对应回款漏建 Duality 配对查销售事件counterpart_event_id补配对关系禁止孤立事件订单状态混乱用状态机表达业务事实列出所有状态组合与事件订单降级为承诺模型事件驱动状态退货后历史报表不对删除或修改原始事件查原始事件是否变更新增反向事件保留不可变历史一个事件多个商品存不下在事件表里放product_id查事件表字段和行数改用event_resource中间表这张速查表后来成了组内新人建模培训材料。它并不是高深理论而是让每个人在动手写表之前先停下来想这个动作会改变什么资源它是不是和另一个事件配对参与角色是谁想清楚这三个问题大部分建模坑都能避开。6. 用 REA 指导系统演进两个提醒和一点个人心得6.1 模型怎么跟着业务一起长大不少团队做数据模型喜欢一步到位把未来可能用到的字段全设计进去。REA 的结构反而让系统更容易增量演进。新增一种业务比如“发放优惠券”拆解一下就是“优惠券”这种资源从公司流向客户对应的事件是“发券”配对事件可以是“客户获得优惠券后的未来消费”或者直接就是一项营销费用支出。由于事件表是通用的只需要新增event_type再加必要的业务扩展字段不需要推翻整张表结构。新增一种参与者角色也一样在rea_event_agent里多插一行就可以。比如原来销售出库事件只挂了客户和仓管员后来要记录“承运商”就多加一个角色类型查询时按角色过滤即可。这种演进方式比我以前在业务表里加可空字段、加冗余列要舒服得多。不过这里也要给一个反向提醒REA 不是银弹。如果项目只是一个简单的分类信息展示或者只有一张配置表的增删改查硬套 REA 会显得很重。建模要跟业务形态匹配不能为了模型优雅而牺牲交付效率。我自己的判断标准是只要系统里出现“余额、对账、审计、资源数量变化、多方参与”这五类需求中的任意两个REA 就值得用。6.2 建模时的一个常用习惯先写业务句式再画表我现在拿到需求之后不会第一时间打开数据库设计工具而是先写一串 REA 句式。句式模板是某个参与者在某个时间参与了某类事件导致某类资源增加或减少并与另一事件构成双向交换。把需求里的每个业务动作都写成这种句式后再把它拆成表结构就会特别自然。事件变成事件表资源变成资源表参与者变成参与者表并由关联表把关系串起来。这个习惯看似麻烦其实是在逼自己理解业务本质而不是被原型界面带着走。界面上的按钮是“确认发货”还是“保存出库单”都不重要重要的是它背后是否让库存资源发生了一次真实流出。6.3 我个人实践中最值钱的一点做了几个 REA 项目之后我最强烈的体会是这个模型表面上是在教你建表实际上是在教你区分“过程”和“结果”。以前做系统默认先建结果表库存表、应收表、流水表都是结果后来改成先建事件表让结果从事件中算出来代码里那些“手工调账”“历史数据订正”的临时逻辑就慢慢绝迹了。如果你正准备重构一个跟业务生命周期强相关的系统我建议先别急着优化 SQL也别急着选新框架可以花半天时间把核心业务流程用 REA 句式过一遍。写的过程中你会重新认识自己的业务也会更清楚哪些表是真正的事实哪些表只是缓存和视图。这套方法不是只给架构师用的后端开发、产品经理、数据分析师都能从中获得一个共同语言。建模这件事很多时候缺的不是技术能力而是一套足够简单的思考框架。