第一次听到“REA”这三个字母,我以为是哪个社区造出来的新热词,直到有一次整理一个库存模块的改造方案时,被同事按着头学了一遍 Resource-Event-Agent 企业建模模式,才发现这套东西早就存在,而且比我想象中实用得多。简单说,REA 就是一套“资源-事件-参与者”的业务建模思路,适合用在订单、库存、会员、结算这类数据密集型系统里。它能解决的问题非常具体:传统建表方式总是先想“余额放哪、状态怎么存”,结果业务规则一改,表结构就跟着大改;REA 则逼你先还原业务事实,把每个业务动作建模成事件,让余额和状态变成可推导的结果,而不是手工维护的字段。
这篇文章我会从 REA 的核心思想讲起,再用一个电商交易的例子带你完整走一遍建模过程,最后把我在实操里踩过的坑和排查方法整理出来。不管你是后端开发、数据分析还是产品经理,只要需要和数据模型打交道,这套思路都值得花半小时认真看看。
1. 先别急着建表,REA 到底在讲什么
1.1 为什么传统“余额表”思路撑不住复杂业务
很多系统的数据模型是从财务报表那套思路延续下来的:先建一张余额表,比如“库存余额表”“客户余额表”,再在每次业务发生时更新字段。这样做在业务单一的时候够用,但一旦业务复杂起来,问题就冒出来了。
我给你举个实际场景。一个订单系统里,库存数量被扣减了,你看到的是一张表里某个数字从 100 变成了 99。可你不知道这个数字是谁在什么时候扣的、扣完之后对应的订单是哪一单、这个订单后来有没有退货。你只知道“当前库存 = 99”,却没办法回答“为什么是 99”。更要命的是,如果同时有退货、换货、调拨、报损、盘点调整这几种业务同时发生,余额表上只能看到一次次加减,所有业务语义全部被抹平了。
传统思路的根源问题在于:它把“事实”和“结论”混在一起。余额是结论,业务动作才是事实,结论应该由事实推导出来,而不是被反复改写。REA 这个模型正好把这件事掰正了。
1.2 三要素:资源、事件、参与者怎么识别
REA 的核心只有三个概念,分别是 Resource(资源)、Event(事件)、Agent(参与者)。
资源是业务世界里那些有价值、可以被交换的东西。商品、现金、库存、积分、服务工时,甚至“应收款”“合同承诺”这种无形的东西,都可以是资源。判断一个东西算不算资源,就看它是否稀缺、可计量、能被业务事件改变。你手里的一瓶水是资源,但“订单审核状态”就不是资源,它只是一个业务状态。
事件是业务发生的动作,它必须落在某个时间点,并且造成资源的流入或流出。“发货”让库存商品流出,“收款”让现金流入,这都是事件。REA 里有个很反直觉的要求:像“提交订单”这种界面操作不一定算事件,真正值得建模的事件往往是业务链条里那些真正产生资源变化的节点,我后面会细讲。
参与者是发起或承担事件的人、系统或组织。“客户提交订单”“仓管员发货”“财务确认到账”,这里的客户、仓管员、财务都是参与者。很多系统把参与者忽略了,所有操作都记成一个“操作人字段”,这个字段不做区分,等于把审计线索丢了一半。
我给你一个直观对比。传统表设计是这样的:订单表里有商品、数量、金额、库存扣减标记、支付标记、发货标记。REA 模型下则是:一个发货事件,关联商品资源、关联仓管员和客户,关联数量;一个收款事件,关联现金资源、关联财务和客户,关联金额。你发现没有,订单表被拆掉了,取而代之的是事件记录和资源记录,业务过程反而变得清楚了。
2. 四条关系线:REA 模型的骨架与灵魂
2.1 存量-流动关系:资源是怎么被事件改变的
资源与事件之间有一条“存量-流动”关系,也叫 stock-flow 关系。这条关系要区分方向:事件导致资源流出,就是“流出”;事件导致资源流入,就是“流入”。建模时最好用动词方向来表达,比如“发货事件消耗了商品库存”“收款事件获取了银行存款”。
我踩过的第一个坑就在这里。一开始我把关系箭头从资源指向事件,画成了“商品流向发货单”,看起来好像也没毛病,但要回答“库存少了 1 件”时就总得绕一步。后来我习惯统一用“事件导致资源变化”的方向来画图:事件在左,资源在右,资源变化方向用流入和流出表示。每次建模都问自己一句:“这个事件让哪个资源多了、哪个资源少了?”答案一旦清晰,箭头就不会画反。
这条关系还决定了你的资源表要怎么设计。资源本身是有“量”这个维度的,数量、单价、金额通常都挂在关系上,而不是挂在事件或资源本身上。比如“发货 2 件,单价 100 元”这件事,数量 2、单价 100 是事件和资源关联后的属性,不是商品资源自己的属性,也不是发货事件自己的属性。
2.2 二元参与关系:事件成对出现才形成业务闭环
REA 模型里有一个非常核心的关系叫 duality,也就是二元参与关系。意思是说,一个业务闭环通常由两个事件配对完成,一个事件消耗资源,另一个事件获取资源。最典型的例子是销售和收款:发货事件消耗了商品库存,收款事件获取了现金(或者产生了应收款资源),这两个事件是配对的。
你可能会问,直接写一笔“销售单,金额 100,已支付”不就行了吗?这样做在简单场景确实行,但现实业务里发货和收款往往不是同时发生的。先发货后付款,或者分期收款,这时候如果你不把销售和收款拆成两个事件,而是塞在一个销售记录里,那“哪一笔货对应哪期款”“还有多少款没收回”这类问题就非常难查。REA 的做法是把两个事件分别建模,再用 duality 关系连接起来,这样每个事件都能独立追溯,同时你又知道它们是一对。
分期付款场景建模成什么样?一个发货事件,配多个收款事件,全部收款事件都和同一个发货事件建立 duality 关系。你顺着关系一查,欠款余额就是发货金额减去已关联的收款金额,一清二楚。
2.3 控制关系与职责分离:每个参与者都要能追责
第三个核心关系是控制关系,也就是事件与参与者之间的关系。这个关系在传统表设计里通常只体现为一个“操作人”字段,但在 REA 里它被提升到了架构级别。一个发货事件要有执行人(仓库操作员)、有审批人(仓库主管)、有对接人(客户),这些参与关系都要显式建模,而且可以建模成多个参与者共同关联到同一个事件上。
这里有一条实际经验:参与者别写成具体的“人名字符串”,最好是参与者的唯一标识,甚至细化到“角色 + 账号”。因为同一个仓库操作员可能调岗变成审批人,如果你只在事件上存一个名字,那历史数据的责任归属就说不清了。用账号和角色落库,配合事件时间,才能真正还原“当时是谁干了这件事”。
还有一个容易被忽略的点。REA 里的参与者可以既是人,也可以是系统。自动化的接口调用、定时任务、消息队列触发,这些都应该有自己的参与者标识。我接手过一个项目,有一张表的数据总出问题但查不到来源,原因是定时任务用的账号和人工操作账号混在一起,最后把系统参与者单独编码,问题才被定位。
下面这张表帮你把这四种关系一次理清:
| 关系类型 | 连接的两端 | 要回答的业务问题 | 示例 |
|---|---|---|---|
| 流入关系 | 事件 → 资源 | 这个事件增加了什么资源 | 收款增加银行存款 |
| 流出关系 | 事件 → 资源 | 这个事件减少了什么资源 | 发货减少商品库存 |
| 二元参与关系 | 事件 → 事件 | 哪两个事件组成了业务闭环 | 收款事件对应发货事件 |
| 控制关系 | 事件 → 参与者 | 谁负责了这个事件 | 仓管员执行发货,客户确认收货 |
3. 用 REA 重做一个电商交易模型
3.1 圈定场景与事件清单
现在进入实操环节。我拿一个简化版电商交易场景来演示,只保留“订单受理—发货—收款—收货确认”这条主链路。
第一步是圈定范围。这个模型不覆盖退款、售后、促销分摊,这些分支以后再加。范围清晰之后,列出事件清单,标准只有一个:事件必须是一个真实发生的动作点,并且产生了资源流入或流出。于是我们得到四个事件:接受订单、发货、收款、确认收货。
你可能注意到,我没有把“顾客点击下单”当成核心事件。下单这个动作在简化模型里更像是一个“意向确认”,真正造成商品资源状态改变的是“接受订单”这个事件,它把商品从可售库存变成已承诺库存。如果你现在做不到这么细,也可以把下单直接合并进接受订单,不影响后面理解。
下一步是把资源列出来。这个场景涉及两类资源:商品库存、银行存款。另外还有一个容易漏掉的资源——应收款。如果发货后收款不是同步的,那么发货这个事件就应该产生“应收款”资源的流入;等到收款事件发生,应收款资源减少、银行存款资源增加。这个细节是 REA 建模比较精华的地方,它让“应收账款账龄”这种指标可以直接从事件流里算出来。
参与者同样列出来:客户、客服专员、仓管员、出纳。一个事件至少要关联两个参与者,比如发货事件关联仓管员和客户(客户是收货方),收款事件关联出纳和客户。如果只记录一个操作人,事件责任链就不完整了。
3.2 数据落地:事件流水表该怎么设计
建模画图是一回事,落到数据库就是另一回事了。REA 在关系型数据库里落地并不复杂,关键是别按传统“订单状态表”的思路建表。我给一个简化版本的表结构,它省略了资源字典、商品分类等外围表,只留主链路。
-- 资源表:存放具体的资源实例,比如某一批次的库存、某笔存款 CREATE TABLE resource ( id BIGINT PRIMARY KEY, resource_type VARCHAR(50) NOT NULL, -- INVENTORY / CASH / RECEIVABLE name VARCHAR(200) NOT NULL, status VARCHAR(30) NOT NULL -- ACTIVE / INACTIVE ); -- 参与者表:人和系统的统一编码 CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_type VARCHAR(30) NOT NULL, -- CUSTOMER / STAFF / SYSTEM account VARCHAR(100) NOT NULL, display_name VARCHAR(100) NOT NULL ); -- 事件表:核心流水表,记录事实,不存余额 CREATE TABLE event ( id BIGINT PRIMARY KEY, event_type VARCHAR(50) NOT NULL, -- ACCEPT_ORDER / SHIP / PAY / CONFIRM event_time TIMESTAMP NOT NULL, resource_id BIGINT NOT NULL REFERENCES resource(id), direction CHAR(3) NOT NULL, -- IN / OUT quantity NUMERIC(18, 4) NOT NULL, amount NUMERIC(18, 2) NOT NULL, agent_id BIGINT NOT NULL REFERENCES agent(id), duality_event_id BIGINT REFERENCES event(id), -- 配对的另一个事件 created_by VARCHAR(100) NOT NULL ); -- 事件-参与者关联表:一个事件关联多个参与者 CREATE TABLE event_agent ( event_id BIGINT NOT NULL REFERENCES event(id), agent_id BIGINT NOT NULL REFERENCES agent(id), role VARCHAR(50) NOT NULL, -- EXECUTOR / APPROVER / BENEFICIARY PRIMARY KEY (event_id, agent_id, role) );我解释几个设计要点。
resource_id和direction是关键组合。每一条事件记录通过 resource_id 指向某个资源,通过 direction 说明是流入还是流出。“发货”事件的 direction 是 OUT,quantity 是 2;“收款”事件的 direction 是 IN,quantity 是 10000,这样库存和资金的增减就能从事件记录里完整还原。
duality_event_id是自引用外键,用来存储二元参与关系。一个发货事件对应一个收款事件时,这个字段直接指向收款事件;分期收款时,多笔收款事件共享同一个发货事件 id,通过这个字段形成一对多关系。
event_agent表把控制关系显式化。事件表里的agent_id记录主要执行人,event_agent 表记录补充参与者(比如审批人、受益人)。实际业务里我一般把事件主要归属人放在事件表,把其他参与角色放在关联表,查询效率和处理灵活性都能兼顾。
提示:这张事件表不是 ER 图的严谨完整版,生产环境还需要资源类型字典、计量单位表、事件类型配置表等。但核心思路已经完整,你能从中看到 REA 和传统建模的本质差别。
3.3 派生余额:为什么当前库存和账户余额不落库
布局好事件表之后,还有一个关键原则:余额不落地,靠事件计算。这可能是 REA 建模里最容易被传统开发人员质疑的一条,因为我们习惯了“库存数量”作为一个字段存在商品表里。
我之前接手一个库存系统,商品表里有一个 stock_qty 字段,每次出入库直接加减它。开发时很方便,但后来业务要支持“补录历史单据”“跨月盘点调整”,所有历史数据都要重新算一遍,然后发现没有任何一张表能还原当时的库存变化过程,因为原来的库存余额字段被反复覆盖,已经丢了历史。换用 REA 思路后,stock_qty 这个字段被彻底删掉,库存量通过事件表实时推导:
SELECT resource_id, SUM(CASE WHEN direction = 'IN' THEN quantity ELSE 0 END) AS total_in, SUM(CASE WHEN direction = 'OUT' THEN quantity ELSE 0 END) AS total_out, SUM(CASE WHEN direction = 'IN' THEN quantity ELSE -quantity END) AS current_qty FROM event WHERE event_time <= '2025-01-01 00:00:00' GROUP BY resource_id;注意这条 SQL 里current_qty的计算方式:所有流入事件的数量减所有流出事件的数量,就是某个时间点的库存。事件表只插入不更新,所以任何时候回放这个查询,都能得到任意时间点的库存快照。
如果事件量太大,每次实时聚合性能扛不住,就用物化视图或者定时任务把结果缓存下来。但缓存表里存的仍然是一个派生的快照,它可以在界面上展示“当前库存”,但永远不能被当作事实来源。这就是 REA 中最重要的一条纪律:事实只有事件流,余额和状态都是可以推倒重算的结果。用这个思路去做月度结算、审计追溯,你会发现几乎所有历史问题都能用一条 SQL 查出来,不需要再去翻一堆历史备份。
4. 实操中最容易踩的坑与排查清单
4.1 五个典型建模错误
我见过不少人学完 REA 后信心满满去建模,结果画出来的图还是换汤不换药。下面这五个错误是出现频率最高的。
第一个错误是拿菜单功能当事件。“订单管理”“库存查询”“客户审核”这些都是你系统里的功能模块,不是业务事件。REA 里要建模的“事件”是业务世界里真实发生的动作,比如“客户签收了包裹”“出纳确认到账”。如果你发现自己在列“商品维护”“报表导出”,那就说明你要往上拔一层,回到业务本身去思考到底发生了什么。
第二个错误是资源与事件的关系方向混乱。有人把“库存”直接指向“发货”,画成“库存产生发货”。正确认知是:发货事件导致了库存资源流出。方向反了,后续计算流入流出量时就会搞混。每次画完关系,选几条事件路径走一下,看能不能说通“谁导致谁”,就能及时发现方向问题。
第三个错误是漏掉资源的流入和流出中的任何一边。一个典型的错误是只建模了“发货减少了库存”,完全没有建“发货增加了应收款”。真实业务里,资源的转移往往是双向的,一个事件消耗一种资源的同时往往又产生了另一种资源。发货消耗了商品,同时产生了应收款;领料消耗了原材料,同时增加了在产品成本。漏掉一边,模型的推导能力就瘸了一条腿。
第四个错误是控制关系只建了一个操作人。某些系统事件表里只有“操作人”一列,审批、复核这些职责全靠 status 字段暗示。REA 思路希望你把事件参与者拆开建模,至少要把执行者和责任者分开,这样权限设计、审计追溯才有数据基础。
第五个错误是过于贪大。第一次引入 REA 就想把全系统所有模块一次建模完成,结果模型庞大到没人能看懂,最后没法落地。我见过最惨的例子是建模文档做了几十页,却没有任何代码接入它。REA 的落地路径应该是选一个业务痛点最明显的模块先切入,比如库存或结算,验证价值后再逐步扩展,而不是直接铺到全公司。
4.2 模型健康度检查清单
判断一版 REA 画得对不对,不用靠专家评审,自己用清单过一遍就行。
对每个事件,检查三件事:第一,它有没有发生时间?没有时间属性的事件一定不合格。第二,它关联了至少一个资源吗?如果某个事件没有任何资源流入流出,它很可能只是系统内部的一个操作步骤,不是真正的业务事件。第三,它有至少两个参与者吗?如果只有一个参与者,那内部职责划分很可能被省略了。
对整条业务链路,检查有没有形成闭环。一套完整的业务,至少包含两组 duality 关系。电商场景里,接受订单和发货是一组,发货和收款是另一组,收款和确认收货之间也可以构成闭环。如果你发现事件链中间断了,比如只有发货没有收款,那你要么是漏了事件,要么是业务根本还没闭环,后面的模型先别急着开发。
用事件记录去回放一个具体业务案例,也是很好的检查方法。拿一个真实订单,沿着事件表从头走一遍:客户下了订单,客服接受了订单,仓库发了货,出纳收了款,客户确认了收货。每一步都能从 event 表和 event_agent 表里查出来,这个模型就基本合格了;如果哪一步查不出来,那这个事件就没建模到位。
4.3 从画模型到写代码的衔接经验
建模完成之后,最常被问到的问题是:REA 模型怎么变成实体类、接口和数据库迁移脚本?我在项目里的做法是从事件表出发,先定义事件实体,再把资源、参与者和关联关系作为它的属性。比如一个领域模型可以这样组织:
- ShipmentEvent 接口,属性包括 eventId、shipTime、itemResource、quantity、warehouseAgent、customerAgent、dualityEventId
- PaymentEvent 接口,属性包括 eventId、payTime、cashResource、amount、cashierAgent、customerAgent、dualityEventId
这个结构几乎没有冗余字段,业务规则都写在事件之间的关联关系上。比传统做法简单直接得多。
如果你的项目采用事件溯源架构(Event Sourcing),REA 模型几乎可以无缝映射。事件表本身就是事实流,资源表相当于事件溯源里的聚合状态快照,事件处理器可以按 resource_id 聚合出当前状态。这个组合很自然,我后面会展开讲。
另一个小技巧:代码里的枚举定义和事件类型表要保持一致。在我的项目里,事件类型、资源方向、参与者角色都从建表语句里的 CHECK 约束抽成一个统一的枚举文件,前后端共用。你可能会觉得这样很基础,但很多 REA 模型死于枚举漂移,前端写“PAY”,后端写“PAID”,模型很快就对不上了。
5. 当你准备把 REA 用到真实项目里时
5.1 先小后大:试点范围怎么选
很多团队引入 REA 失败,不是因为模型不好,而是因为目标太大。我的建议是选一个“跨资源、跨事件、跨参与者”的业务点做试点,因为这类业务恰恰是 REA 相比传统建模最有优势的地方。
举个例子,订单结算模块。一笔订单从下单到完成,商品资源、现金资源、应收款资源都会变化,还有客户、客服、仓管、出纳多方参与,发货与收款之间又有明显的 duality 关系。用传统表建模,你得维护订单表、支付表、退款表、结算表,表之间靠状态字段互相拉扯,每次业务规则一变就要改表结构。用 REA 建模,所有业务事实沉淀成事件流水,结算金额就是事件聚合,规则已经变得可推导。
一个我实际参与过的简化案例是这样的:某公司积分商城要支持积分抵扣现金、订单取消后退积分、过期积分自动清零三种业务。传统模型需要在积分明细表里不断增加新的“变动类型”字段,每加一种业务就动一次表结构,累计加了七八个字段后,积分结余怎么算都乱,最后还是靠重建明细表才解决问题。
改用 REA 思路后,积分本身成为建模的核心对象。每一次积分的增加或扣减都被建模成事件,方向为 IN 或 OUT。账户余额不再作为独立字段存在,而是通过事件表计算。后来新增“积分锁定用于兑换”这样的业务时,不需要改表结构,只需要新增一个事件类型。这个教训让我明白了一个道理:如果你需要在余额表上不断加字段来记录各种变动原因,那说明余额表本身就是建模错误的信号。
5.2 配套工具与协作方式:三色便签工作坊
REA 建模落地,我极力推荐“事件风暴”(Event Storming)的协作方式,不需要复杂工具,三个人和一面白板就够了。我的做法是准备三种颜色的便签纸:绿色代表资源,橙色代表事件,蓝色代表参与者。先把事件从前往后贴出来,再在每个事件旁边贴上它影响的资源和参与的人。
贴便签的过程中,团队最大的收获不是那张图,而是讨论本身。你会听到业务方反复确认“这个事件到底是先发生还是后发生”“这个参与者到底是谁”,这些问题在常规需求评审里往往没机会被问清楚。一张便签贴下去,业务边界立刻被验证了。
等模型稳定下来,我再转移到建模工具里输出正式文档。我常用的是 draw.io 和 PlantUML。PlantUML 的好处是代码版本可维护,适合写到仓库里。一个极简的类图大概是这个样子:
@startuml entity "商品资源" as resource_product entity "发货事件" as event_ship entity "应收款资源" as resource_receivable entity "收款事件" as event_pay resource_product "流出" --> event_ship event_ship "产生" --> resource_receivable event_ship "1" -- "0..*" event_pay : duality @enduml画图不用追求完美,能表达清楚关系就够了。REA 模型的实际价值是让团队对“业务事实”达成一致,而不是生成一份漂亮的文档躺在 wiki 里吃灰。
5.3 与事件溯源、数据中台、财务结算的结合
最后谈一谈 REA 怎么和现代技术栈配合,尤其是与事件溯源、数据中台和财务总账系统的融合。
如果你在做事件溯源架构,REA 可以当作领域事件模型的底层参考。事件溯源里的事件不是随便定义的,事件应当描述“发生了什么”,而不是“状态变成了什么”。而这一点正好是 REA 的原生表达方式。你可以把 event 表作为事件溯源的事件存储,把 resource 聚合状态作为事件回放结果,再用消费者把事件投递到查询侧生成报表。这样做的好处是,审计追踪、数据回溯、指标计算都可以共用同一份事实流,不再需要另建一套日志表。
数据中台场景也很适合。中台要打通各业务线的数据,最怕的就是每张报表的口径不一致。REA 事件流天然适合当作指标计算的事实源,比如零售行业的 GMV 指标、库存周转率、应收款账龄,都可以通过事件聚合得到。因为事件类型的定义是统一的,指标口径自然就统一了。
财务结算方向更有趣。传统财务系统以借贷分录和总账为核心,而业务系统用 REA 建模后,两者可以非常顺畅地衔接。REA 事件流可以映射为财务分录:发货事件对应库存减少、成本增加;收款事件对应银行存款增加、应收款减少。你可以定期把 REA 事件流聚合成总账余额,而不是在业务库里再写一堆存储过程去维护科目余额。这样做,业务系统和财务系统的口径终于站到了同一边。
我个人的体会是:REA 的价值不在于它是一套高深的理论,而在于它把一个很容易被忽略的问题摆到台面上——你的系统是真的记录了事实,还是只记录了结论?很多系统的“余额表”“状态字段”都是一层脆弱的结论,数据对不上账的时候,往回翻历史比登天还难。用 REA 把事实先沉淀下来,后面的查询、统计、审计、财务核算,全都会变得省力。如果你正准备做一个新的订单、库存、结算模块,我建议别急着写表结构,先花半天把事件画出来。这个习惯,会帮你省掉后面无数加班的夜晚。