最近技术社区里 REA 这个词的出镜频率明显高了起来。不少朋友以为它是哪个新兴赛道的网络热词,其实 REA 一点都不新——它是 Resource、Event、Agent 三个词的缩写,也就是“资源-事件-主体”本体模型,三十年前在会计信息系统研究领域就被正式提出过。真正有意思的是,过去几年里微服务拆分、事件溯源、领域驱动设计这些话题持续走热,让这个老模型又回到了讨论中心。
我最早接触 REA 的时候正被一套零售系统的库存账折腾得够呛,那时候团队里天天为“订单状态到底该维护在哪张表里”吵架。后来试着把模块从“单据加状态”改成“记录会改变资源的事实”,很多纠缠不清的业务规律居然在数据模型层面自己就理顺了。这篇文章就把我对 REA 的理解、建模步骤和踩过的坑整理出来,适合正在做业务建模、DDD 落地或者事件驱动架构的同行参考,也适合想弄明白“领域事件该怎么挑、怎么落库”的后端新人。
1. REA 究竟在说什么——三个词拆开看
1.1 资源、事件、主体,为什么是这三个
REA 的核心思想简单到不像一个学术概念:资源就是有价值、可计量的东西,事件就是改变资源存量的那些发生,主体则是发起或参与事件的实体。看起来平平无奇,但这三者的关系一旦固定下来,就和传统的“实体—关系”建模走的是两条路。
拿家里记账来类比。你记的不是“今天家里番茄余额 3 斤”,而是“某人今天在某家店花了 15 块钱买了 3 斤番茄”。在这里,钱和番茄是资源;购买这个事是事件;某人是主体。至于现在家里还剩多少钱、还有几斤番茄,这本账是能推出来的——把每一笔收入和支出累加,自然得到余额。
这个类比背后是 REA 最重要的立场:状态不是建模的起点,而是事件的投影。库存数、账户余额、客户欠款这些都是“存量视角”,它们会随着业务发生而变化。REA 建议把这些变化的来源——“发生了什么事”——作为系统的底层事实来记录,而不是反过来把各种状态栏位铺满数据库。这一立场和现代事件溯源架构的哲学几乎是同一句话,所以它能在今天重新火起来。
主体在 REA 里也分得细,内部主体和外部主体分开记。还是拿零售举例,顾客是外部主体,收银员是内部主体;如果以后接进来一个自动补货程序,那这个程序也可以作为一个服务主体。这样设计的直接好处是审计时能回答最烦人的问题:“这一步到底是谁做的”。
1.2 冷知识还是新热词,REA 为什么被重新挖出来
REA 理论成型于上世纪九十年代初,最初的土壤是会计信息系统。当时的软件行业被 OLTP 和 ER 图主导,“表、关系、事务”才是主流叙事,“事件”这种带有时间维度和行为色彩的概念在传统关系建模里一直不太好落地。
但今天不一样了。事件溯源把“不可变事件日志”变成了日常架构手段,领域驱动设计把“模型要与业务语言对齐”当成了最高原则,微服务更逼着团队想清楚每个服务到底守护什么数据。REA 恰好给这些问题提供了现成的答案框架:哪些事实必须进账本、资源如何聚合、主体如何参与、补偿如何处理。
再加上这两年 AI Agent 概念到处都是,凡是带“Agent”的术语都会翻出来重新聊一遍。REA 里的 Agent 虽然和智能体不是一回事,指的是业务主体,但概念碰撞让讨论热度更高了。不少团队在聊“AI 来做采购决策”的时候,顺藤摸瓜就摸到了 REA 的这套账本模型。
2. REA 建模和传统建模到底差在哪
2.1 传统订单模型为什么总是缺一条腿
大部分人做业务系统,脑子里第一反应是画表:订单表、订单明细表、库存表、流水表。其实这里已经埋下了一个隐患——我们从“单据”和“状态”出发,而不是从“经济事实”出发。
举个例子,销售退货。传统做法是在订单旁边加一个退货单,再给订单表加一个“是否已退货”的标志位。一开始挺干净,但后面加了部分退款、换货、运费调整,每个新动作都得再配一张表或者一堆状态分支。库存扣减逻辑散落在接口里,财务需要的“应收减少”和“货物退回”是两段各写各的代码。最要命的是,除非靠测试用例长期维护,否则没人敢保证账一定是平的。
我见过一个系统中途统计退货率,发现退货单和原订单的关联是靠一个备注字段人工维护的,当场没法查。这就是典型把“经济事实”降级成了“业务流程附属品”。单据表本质上是一批状态物,而状态物会随业务演进不断增加、不断外溢。
2.2 存量与流量,换个角度看建模
REA 用“存量—流量”的视角重新组织了这一切。库存、现金、应收账款是存量,采购、销售、调拨、报损是流量。存量由流量累积得到,而且流量一旦发生就作为既成事实保留下来。做错了不是删掉这笔流量,而是再记一笔反向流量去冲抵。
这种处理方式对财务语义极其友好。传统数据库里的 UPDATE 会把历史抹掉,审计时只能看操作日志猜过程;REA 风格下事件永远追加,任何余额都是重新计算的结果。它不是一种实现技巧,而是一种建模纪律:不要在事件表上直接改数。如果要纠正,就新增一个补偿事件。库存负数、账实不符这些问题,排查起来的时间会大大缩短,因为所有线索都是留在账本上的。
需要说明的是,REA 并不要求所有数据都变成不可变日志。很多业务系统的事实记录也未必需要“流水账式”存储,但至少模型层的思考方式可以按这个思路走。先把语义想清楚,再用合适的技术存储,两者并不冲突。
2.3 一个借书场景,看清两套模型的走向
假设要做一个图书借阅系统。传统 ER 设计大概是:读者表、图书表、借阅记录表,借阅记录里有“已借出/已归还/已损坏”的状态,另外加罚款表。这个模型应付日常还行,但一旦出现“读者借书后损坏,赔了钱,书还被下架”的情况,就得同时改借阅状态、新增罚款记录、扣减图书库存、标记图书状态。四个地方要同步更新,任何一步漏掉,后面查“这本书怎么了”就非常费劲。
REA 改怎么写?资源是书、读者的信用额度、现金;事件是借出、归还、赔偿支付;主体是读者和图书管理员。“借出”事件会让书的可用副本减少、让读者的未还数量增加;“归还”反向;“赔偿支付”让现金增加、让读者的信用额度恢复。想查“谁还欠着书”,就是把借出事件和归还事件放在一起做投影,不需要维护那一堆状态。
这个例子不是说传统模型一定错,而是说传统模型把“事实”和“状态”混在一张表里,到后期会互相污染。REA 则要求先把不可变事实和可变投影切开,定义清楚了再想怎么落地。
3. 实操一把:采购库存场景的 REA 建模全过程
3.1 场景、边界和基本假设
我拿一个真实的模拟项目来走一遍流程:某零售公司要做库存与成本的统一模块,涉及门店销售、供应商采购、门店间调拨和损耗报损。业务目标是能回答三个问题:当前各 SKU 库存是多少;某一批商品从供应商到门店的完整轨迹是怎样的;每个门店当天毛利是多少。
这个案例按 REA 建模,先划清楚边界:系统关注的主体是门店、仓管员、供应商、顾客;关注资源是商品库存、现金、应收账款/应付账款。至于商品主数据、部门组织架构,它们是资源元数据或者上下文数据,不是经济事件本身,先放到一边。
这里给一个重要提醒:REA 不是要把所有业务对象都硬塞进“资源”框里。比如“订单”,它更像一组事件及其规则的集合,而不是一个独立资源。如果硬把订单做成资源,很容易走回传统状态机老路。
3.2 第一步:把业务里的变动物流明确下来
动手建模第一步不是画类图,而是把所有会“流动”的东西列成两张清单:一张是资源清单,一张是主体清单。
| 类型 | 清单 | 备注 |
|---|---|---|
| 资源 | 商品库存(按 SKU 批次计量)、现金/银行存款、应收账款、应付账款 | 资源是存量,会被事件改变 |
| 内部主体 | 门店、仓管员、采购员、收银员 | 资源的责任方 |
| 外部主体 | 供应商、顾客(会员)、物流承运方 | 与内部主体相对 |
判断一个对象算不算资源,我用一个土办法:它能不能被“增加”或“减少”?它会不会在报表里被求和?如果一个对象只代表某一次发生,比如“一笔退款申请”,它就不是资源,而是事件或事件的附属信息。能用“增减累计”的语言描述它的,才更像资源。
3.3 第二步:找出真正改变资源的经济事件
理解完变动物体,下一步是找经济事件。一个动作如果会让某个资源的数量、价值或权属发生变化,它就够格成为一个经济事件。常见的经济事件按“转换”和“转移”两个大类区分:转换指资源从一种形式变成另一种,把原材料变成商品;转移指资源在主体之间移动,例如商品从总部仓库调拨到门店、现金从顾客账户进到公司账户。
在这个案例里,我列出初步事件清单:采购入库、销售出库、门店间调拨、库存报损、供应商退货。注意事件命名尽量用动词过去式,比如 purchased、sold、transferred、wrote_off。这样做符合“事实已发生”的语义,也避免和未来的“待办状态”混淆。
看起来简单,但实操中很容易漏掉三个不显眼的事件:价格变动、盘点差异、服务费计提。价格变动会影响资源的价值维度;盘点差异既可能来自损耗,也可能来自入错数;服务费计提虽然不是现货实物的移动,但会影响应收和成本。如果前期没纳入,后期它们进来时就会变成打补丁,侵蚀模型一致性。
3.4 第三步:用“参与、来源、接受”把事件钉死
识别出事件之后,关键在于为每个事件建立三条关联:
- 参与关联:这个事件涉及哪些主体,内部主体和外部主体分别是谁。
- 来源关联:这个事件让哪个资源减少,也就是“从哪里来”。
- 接受关联:这个事件让哪个资源增加,也就是“到哪里去”。
做这一步时,我对每个事件逼着自己回答三个问题:谁参与?减少了什么?增加了什么?都能答出来,事件定义才算完整。
拿销售出库事件来看。参与的主体是顾客和收银员;来源关联是“本店商品库存”减少;接受关联是“现金”库存增加,如果顾客用了赊账额度,那接受关联就是“应收账款”增加。采购入库事件反过来,来源关联是“应付款/银行存款”减少,接受关联是“商品库存”增加,参与主体是供应商和采购员。门店调拨则涉及两个内部主体——调出门店和调入门店,来源关联是 A 门店的库存,接受关联是 B 门店的库存。
一开始做这一步会觉得繁琐,但它能把团队讨论从“接口怎么设计”拉到“业务事实到底是什么”的层面,这是最有价值的部分。
3.5 第四步:把 REA 模型落到事件表和投影表
理论理顺之后,落地反而简单。我不建议大多数系统直接上全量事件溯源,因为重放过贵、运维成本高。一个更实用的模式是“事件表 + 投影表”:事件表保存只追加的经济事件,投影表保存当前库存、当前应收这类持续更新的视角。
这里给一个我用着很顺的事件结构,用 Python 的 dataclass 表示:
from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal @dataclass(frozen=True) class EconomicEvent: event_id: str event_type: str # "purchase" / "sale" / "transfer" ... occurred_at: datetime agent_internal: str # 内部主体,如门店编号 agent_external: str | None # 外部主体,如供应商/顾客编号 resource_from: str | None # 来源资源,如库存批次 resource_to: str | None # 接受资源,如现金/应收 quantity: Decimal | None unit_price: Decimal | None ref_tx_id: str | None # 串联一个业务动作拆出的多个事件frozen=True 特别重要,它从数据结构上保证事件不可变。真实系统里,如果你已经用关系库,也可以把事件表设计成只能 INSERT 的普通表,禁止 UPDATE 和 DELETE 权限。
投影表的更新逻辑可以写成纯函数,比如针对库存:
def apply_stock_event(snapshot, event): if event.event_type == "purchase": snapshot.available += event.quantity elif event.event_type == "sale": snapshot.available -= event.quantity elif event.event_type == "transfer": # 源仓库减少、目标仓库增加,由两个事件组成 ... return snapshot事件表是干净的事实,投影表是便于查询的状态;投影表可以随时清空重建,从零开始重放事件,结果必须与业务口径一致,这也是最好的对账验证。
4. 把 REA 放进 DDD 和事件驱动架构里放大
4.1 用 REA 给领域事件定级
很多团队做事件溯源时遇到同一个麻烦:领域事件太多了,一股脑全丢进事件流,结果数据库里全是意义不大的状态变更,真正要出报表时还要到处捞。REA 给了一个非常实用的筛选标准:如果一个事件不改变任何资源的存量或价值,它就不算经济事件,也没有必要进入账本型事件流。
拿一个电商系统举例。“订单已创建”只是业务阶段变了,库存、应收、现金流都没动,它不是经济事件;“支付成功”让现金或应收回表变动,是经济事件;“出库完成”让库存减少,是经济事件;“订单已发货”如果没有涉及资源转变,可能只是一个物流状态,不必进账本账。按这个标准去过滤,事件流的业务价值密度会明显提升,沉淀下来的数据能直接回答财务、仓配、销售三个口径的问题。
4.2 聚合根边界怎么划,REA 也给了解法
DDD 里最难的一步是画聚合边界。画太粗,锁冲突多;画太细,跨聚合事务满天飞。REA 在这方面相当于提供了一个默认边界:把“一种资源的账本”和“改变账本的事件规则”放进一个聚合。
商品主数据是描述性的,库存账本是有状态的,两者不要放在同一个聚合里。库存聚合的核心是“当前库存投影”和“入库/出库/报损规则”;商品基本信息属于另一个只读上下文。这样划分后,所有库存操作走同一个守护层,并发控制集中在账本聚合内部,不再散落在各个用例里。对于采购与销售场景,还可以把“银行账户”和“应收/应付”分别建模成独立的账本,跨账本动作通过事务协调。
4.3 当 REA 的 Agent 遇上 AI Agent
这是最近讨论里最热闹的部分。过去 Agent 就是人和部门,模型里加个 agent_type 字段就够了。现在系统里多了自动补货机器人、智能定价服务、AI 客服,它们在业务上确实会触发经济事件:自动补货机器人发起采购申请,AI 客服做退款审批预约,定价服务触发调价导致的库存价值变动。
处理方式不复杂,在主体表里区分 human、service、ai 即可。关键是每个事件必须有明确的 agent_id,不能把 AI 的行为归到“系统默认”头上。这样后续如果出了责任问题或者审计需要回溯,每一笔账都找得到决策主体。这也说明 REA 的模型不排斥新的自动化角色,它天然就为“多类型主体共同改变资源”设计了位置。
4.4 演进路线与性能考量
完整事件溯源的门槛不低:事件存储、投影重建、并发幂等都要投入。REA 的好处在于它不强迫你一步到位。建议的演进顺序是:
| 阶段 | 做法 | 适用场景 |
|---|---|---|
| 轻量事件表 | 核心账本事件单独存表,其他数据走传统 CRUD | 中小系统,先跑通语义 |
| 关键账本事件溯源 | 库存、资金等账本域全量事件化,周边系统保留传统实现 | 对账要求高、财务敏感系统 |
| 全量事件驱动 | 所有业务事实进事件流,CQRS 读写分离 | 需要完整审计、多端实时同步的场景 |
性能方面,事件表是典型的 append-only,写入压力不大;真正的压力在投影计算。异步投影加定期全量对账是常见组合。我们当时线上是每五分钟增量投影一次,每日凌晨批量重放前一天的事件用于对账,既不挡业务,又保证账账相符。
5. 常见建模问题与踩坑实录
5.1 建模思路速查表
| 常见困惑 | 判断方法与建议 |
|---|---|
| 这个对象是资源还是事件? | 它能不能被增加、减少、求和?能就是资源视角;只是“发生了一次”就偏向事件。 |
| 客户下单算经济事件吗? | 订单创建不改变资源,不算;支付、出库、开票才算。 |
| 库存出现负数怎么处理? | 不要直接改库存投影,查事件序列里是不是缺了入库事件或重复了出库事件,新增补偿事件纠正。 |
| 退货是修改原订单吗? | 不是,它是一笔独立的反向事件,与原事件双关联。 |
| 一个业务动作涉及多个资源怎么办? | 拆成多个事件,用同一个业务事务编号串联,保证原子可见。 |
这张表是我在评审新模块时最常用的检查清单,问几轮下来,模糊建模基本都能被揪出来。
5.2 三个我真实踩过的坑
第一个坑是“所有状态变化都事件化”。早期做模块改造时,我们把流程状态、通知记录、用户点击日志全写成事件,结果几天就存了几十万条无效数据,投影逻辑也越来越臃肿。后来按 REA 重筛,保留真正影响资源的账本事件,其他状态回归普通表。账本干净了,系统也快了不少。
第二个坑是资源粒度没有和业务对齐。一开始我把“库存”粗暴建模成一个资源,后来发现同一商品的不同批次保质期不一样,盘点、报损、追溯都困难。后来把资源拆成“批次实例”,每个批次在进入系统时生成一个资源实例,事件关联到批次粒度。成本不低,但业务上“某批货从哪来到哪去”这种问题终于一次性解决了。
第三个坑是漏掉外部主体。设计调拨事件的时候只记了调入门店和调出仓库,没把承运方作为参与主体记上。后来真出现货损纠纷,无法从事件表直接查到是哪个承运环节出的问题。补模型虽然可行,但历史数据已经缺失,只能靠线下表格补救。教训就是:建模会议一开始就把外部主体列在大清单里,宁可先多留字段。
5.3 用重放与对账把模型焊死
REA 模型是不是建对了,靠测试用例能推进一步,但真正靠得住的是“重放一致”。我们的做法是:构造一段包含采购、销售、调拨、报损、退货的模拟历史数据,编写纯函数投影逻辑,反复重放,每一次结果必须一致。然后与财务导出的“库存余额、应收账款余额”做对账,数字对不上就说明事件定义或投影规则存在空缺。
另一个小技巧是靠“业务造句”来验证模型。让业务人员用“某天某主体通过某事件使某资源增加/减少”的句式描述业务,如果说得通,模型结构就对了;如果哪句话卡住,往往就是事件缺了或资源粒度不对。这个方法比十次设计评审会都管用。
结尾
我个人的体会是,REA 不是一个需要全套上马的“重型武器”,它更像一把尺子,帮你在画表之前先量一量业务事实是否清晰。后来我在每个新模块的设计阶段都坚持白板上写 Resource、Event、Agent 三个词打草稿,用这种方式逼着团队把“发生了什么”和“现在是什么状态”分开讨论。很多争吵其实都在这一步化解了。如果你正被“状态到底维护在哪”“这个事件要不要进账本”这类问题折磨,不妨从小模块开始,试一次 REA 建模,大概会有种打开新思路的感觉。