最近帮某电商团队做架构评审,看到他们把核心订单表设计成了十几个状态字段,从待支付、已支付、待发货一路枚举到已取消、已退款,然后每加一种营销玩法就往表里塞一列。我随口问了一句:“如果有一天想统计‘哪个运营同学在什么时间因为什么活动把什么商品卖给了什么人’,这张表能回答吗?”对面沉默了。这正是我开始反复研究 REA 建模的原因。
REA 的全称是 Resource、Event、Agent,也就是资源、事件、参与者。它不是什么新框架,也不是某个中间件,而是一套用来描述业务领域的建模思想,最早源于会计信息系统研究,后来被大量用在企业应用、微服务拆分和事件溯源架构里。简单说,它把业务系统从“记录当前状态”的思维,扭转到“记录业务事实”的思维。这篇文章我会用订单、库存、资金这类最常见业务场景,把 REA 的核心概念、建模步骤、表结构设计和落地避坑一次性讲透,适合正在做领域建模、微服务拆分,或者被状态机表和状态字段折磨过的后端开发同学参考。
1. REA 到底在讲什么
1.1 先忘掉订单表,想一下业务流程本身
很多系统设计的起点是“我要一张订单表”,然后顺着这张表派生出支付表、发货表、售后表。这种思路不是不行,而是容易被业务文档牵着鼻子走:业务方说“订单有三种状态”,你就建三个状态;业务方说“订单可能被拆分”,你又加一个拆分表。到最后,表结构成了业务流程的复印件,业务规则一变,表结构就得跟着改。
REA 换了一个切入角度:不要先问“我现在需要存哪些数据”,而是先问“业务发生的时候,到底发生了什么事实”。举个例子,用户买东西这件事,在传统表结构里体现为“订单表插入一条状态为已支付的记录”;在 REA 里则体现为一个链式事实:某用户(Agent)在某时刻(Time)执行了支付(Event),导致资金从用户账户流入平台账户(Resource 转移)。
这个视角的转变很关键。前者记录的是“现在是什么样”,后者记录的是“过去发生了什么”,而且通过事件之间的资源流动关系,可以随时推导出“现在是什么样”。
1.2 三个核心词汇:资源、事件、参与者
REA 的三个核心概念,用生活场景类比特别好理解。
第一个叫资源(Resource)。资源是业务中有价值的东西,可以是实物,也可以是虚拟物,比如商品、库存、现金、积分、优惠券、物流服务。资源的特征是可被计量、可被追踪、有价值。就像冰箱里的食材,你能数得清有多少,也知道它们被做成了什么菜。
第二个叫事件(Event)。事件是业务世界里发生过的一次动作,是你系统需要记录的最核心事实。继续用厨房类比:洗菜、切菜、炒菜、盛菜都是事件。在电商场景里,下单、支付、发货、签收、退款都是事件。REA 强调一条硬规矩:资源不会凭空消失或出现,任何资源的进出都必然由某个事件触发。
第三个叫参与者(Agent)。参与者是执行或参与事件的人或组织,可以是用户、客服、运营人员,也可以是供应商、物流公司,甚至可以是一个自动化系统。参与者回答的是“谁干的”这个问题。对应到厨房类比里,就是谁做的这道菜。
为什么这三个词就够了?因为任何业务事实都能用“谁(Agent)在什么时候(Time)做了什么(Event),导致什么资源(Resource)发生了什么变化”这句话来概括。当你发现某个业务信息没法装进这三类时,通常说明它要么是描述性属性,要么你们的业务边界还没理清。
1.3 REA 最核心的一条结构规律
REA 模式不只是三个概念堆在一起,它还有一条非常重要的结构规律:任何一个经济事件,必然涉及“资源流入”和“资源流出”两组关系,也就是所谓的 dual effect,双向效果。做账的人听到这里应该会很眼熟,这不就是复式记账里“有借必有贷,借贷必相等”吗?没错,REA 的学术根基就在会计学。
举个例子,销售发货这个事件,在 REA 里一定是这样的结构:商品资源流出(库存减少),同时现金类资源流入(应收增加或现金增加)。如果只记录库存减少,没有记录资金流入,这个事件就是残缺的。不要让系统里出现“只减库存但不产生应收”的发货动作,一旦出现,往往意味着业务流程有漏洞或者建模有遗漏。
这条双向规律还有一个衍生结构叫关联链:一个事件依赖另一个事件。比如“支付”依赖“下单”,“发货”依赖“支付”,这些事件之间的时序关系,天然构成了业务链路。传统表结构用状态字段来表达这种链路,而 REA 用事件之间的关系来表达,两种表达方式的扩展性完全不同。
2. 用 REA 重新建模一次完整交易
2.1 先从业务规则说起
我习惯用一个固定场景:某用户在某商城购买一件商品,从下单到支付再到发货、签收,最后系统完成收入确认。这个场景麻雀虽小,却能覆盖资源、事件、参与者之间的绝大多数关系。建模之前先把业务规则写清楚,不要直接跳到表结构。
业务规则假设如下:用户浏览商品后提交订单,订单包含一件商品及数量;用户付款后,平台通知仓库发货;仓库发货后库存减少,同时产生一笔应收;用户签收后,这笔应收转为确认收入;如果用户申请退款,则资金从平台退回用户,商品不需要退回(按虚拟商品处理)。
先别急着设计订单表、流水表,我们按照 REA 的步骤走一遍,你会发现最终的表结构跟传统设计不一样,但业务语义更完整。
2.2 一步步拆出资源、事件和参与者
第一步是找参与者。这个场景里有用户、运营/客服、仓库管理员,可能还有自动化的库存系统。参与者很好找,只要问一句:谁发起了这个动作?谁对这个动作负责?把每个能独立承担行为的主体都列出来。
第二步是找资源。资源要有价值且可计量。这里能抽出来的资源包括:商品类目下的实际商品(或者更准确的发售库存单元)、用户钱包里的余额、平台账户中的资金、应收债权、确认后的销售收入。很多人会把“订单”也当成资源,这是不对的。订单是多个事件和关系的聚合文档,它不是资源,资源是从这个订单里流入流出的那些有价值的东西。
第三步,也是最核心的一步,找事件。把业务规则里的每个动词抽出来:提交订单、支付、发货、签收、确认收入、退款。这些就是事件。注意,我们这里说的“状态”不是事件,“已支付”这个状态只是“支付成功事件发生之后的结果”,系统里应该记录的是支付成功这个事实,而不是把订单的状态字段翻成已支付。
第四步是画事件之间的依赖关系。支付事件依赖提交订单事件,发货事件依赖支付事件,签收依赖发货,确认收入依赖签收。这些依赖关系构成了整个业务的生命周期。看到没有,这就是一个天然的事件流图谱,跟状态机相比,它没有硬编码“订单状态”这个字段,而是用事实之间的关系来表达流程。
第五步是把事件和资源关联起来。支付事件导致用户余额减少、平台账户余额增加;发货事件导致库存资源减少、应收债权增加;签收事件导致应收债权转为确认收入。每一条资源流动都是双向成对出现的,能对上账。
2.3 建模结果长什么样
经过上面五步,这个场景的 REA 模型可以抽象为下面这张事实关系网:
参与者包括用户、仓库管理员、系统自动化流程。资源包括库存商品、用户余额、平台资金、应收债权、销售收入。事件包括提交订单、支付成功、发货出库、签收完成、收入确认、退款完成。
事件之间的依赖链路是:提交订单 → 支付成功 → 发货出库 → 签收完成 → 收入确认。退款完成是一个独立旁路事件,它消耗资金资源,但需要参考支付成功事件来确认退款金额和退款对象。
每条事件与资源的关联都是成对出现的:支付事件使“用户余额减少 + 平台资金增加”;发货事件使“库存商品减少 + 应收债权增加”;签收事件使“应收债权减少 + 销售收入增加”。
这就是 REA 模型的核心产出物。它不叫订单表,也不叫状态机,而是一张“业务事实网络”。后面做数据库设计、做微服务,甚至做数据审计,都只需要围绕这张网络展开。
3. 把 REA 模型落到工程代码和表结构里
3.1 物理表怎么设计
建模完成后要落库。这里要注意,REA 是一种逻辑建模思想,不是强制的物理表模板。你可以用关系数据库实现,用事件表加资源表的方式实现,也可以采用事件溯源方式实现。先说最常见的关系库做法。
资源表往往拆成两类:一类是相对静态的基础资源,比如商品资源表,记录 SKU、名称、规格;另一类是动态的余额/库存资源,比如账户余额表、库存存量表。这两类资源的区别在于是否有“当前存量”的概念。库存存量表要回答“现在还有多少”,账户余额表要回答“现在还有多少钱”,所以它们都需要实时数值字段。
事件表是 REA 实现的灵魂。一个事件不仅是一行日志,它要能完整还原业务事实。我给出一个通用的事件表核心结构(省略租户、软删除等公共字段):
CREATE TABLE business_event ( id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, occurred_at DATETIME NOT NULL, agent_id BIGINT NOT NULL, agent_type VARCHAR(32) NOT NULL, trace_id VARCHAR(64), payload JSON );这个表只记录“发生过什么”,不直接记录“变成了什么状态”。agent_id 和 agent_type 用来定位参与者,occurred_at 是业务发生时间,不是系统入库时间。payload 用来保存事件发生时的快照信息,比如下单事件可以存当时的商品快照、价格快照。
接下来是事件与资源的关联明细表。这张表是 REA 落到物理实现的桥梁:
CREATE TABLE event_resource_line ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_type VARCHAR(32) NOT NULL, resource_id BIGINT NOT NULL, direction VARCHAR(16) NOT NULL, quantity DECIMAL(18, 4) NOT NULL, unit_amount DECIMAL(18, 4), amount DECIMAL(18, 4) );direction 字段用来标记资源流入还是流出,配合事件表的 event_type,就能完整还原“这个事件让哪些资源发生了什么变化”。例如支付成功事件对应两行:一行是用户余额方向为流出,一行是平台资金方向为流入。这两行记录共用同一个 event_id,这就是双向结构在物理表里的直接体现。
3.2 资源和事件谁先落库:事务边界问题
工程上最容易犯的错是:事件表和资源表分开写,中间没有事务保护,导致库存扣了但事件没记录,或者事件记录了但库存没扣。正确做法是,在同一个数据库事务里同时写入事件表和资源变动明细表,并且以事件表为主记录,资源更新作为伴随操作。
举个例子,扣减库存时,先插入一条“发货出库”事件,再插入两条 event_resource_line,一条指向库存资源且方向为流出,一条指向应收债权且方向为流入,最后更新库存存量表的 current_quantity。这三步必须在一个事务里完成。如果中途失败,全部回滚,保证“有事件必有资源变动,有资源变动必有事件”。
这里有一个实用的设计习惯:任何直接修改资源表数值的代码都应该被视为违规操作。所有数值变动必须由事件驱动,代码里不允许出现“直接调用 update inventory set quantity = quantity - 1”这种语句。这样的约束一开始会让开发觉得啰嗦,但它能防止后期出现大量对不上账的脏数据。我在多个项目里实测过,这个约束能省掉无数次对账排查的加班。
3.3 REA 与事件溯源天然契合
关系库方案能跑,但最舒服的落地方式其实是事件溯源(Event Sourcing)。事件溯源的本质是:不以当前状态作为系统主数据,而是把一系列不可变事件作为事实来源,状态只是事件的投影。
REA 里的每个事件,天然就是事件溯源里的一个追加日志。Resource 是投影结果,Agent 是事件元数据的一部分,Event 就是日志本身。当你收到一个支付事件后,通过重放所有相关事件,可以重建出用户余额、库存存量、收入确认数。不需要在建表时考虑状态字段,也不用担心状态被并发覆盖。
事件溯源落地时要考虑查询性能,所以一般会配合 CQRS,单独建一套读取模型表。比如“订单查询模型表”,它就是通过订阅事件流,把订单当前状态聚合出来。写侧是 REA 事件流,读侧是普通的冗余表。这套组合拳在多人协作的中大型业务系统里非常好用,因为它把写模型的复杂性集中在“事实”上,把读模型做成可以灵活裁剪的视图。
3.4 微服务边界怎么划分
REA 也能给微服务拆分提供非常清晰的方向。很多人拆服务是照着功能模块拆的,比如搞个订单服务、搞个支付服务、搞个库存服务。但 REA 的启发是:围绕“资源聚落”和“事件链”来划边界。
一个服务应该负责一组紧密关联的资源,以及作用于这些资源的事件。比如库存服务负责库存资源和对库存的入库、出库、盘点事件;资金服务负责用户余额、平台资金和支付、退款事件;订单服务负责订单文档、应收债权和下单、签收事件。每个服务内部的表结构遵循 REA 规范,服务之间通过事件传递事实。
这样做的好处是,服务之间不再频繁互相调用查询接口来判断“订单支付了没”,而是各自维护事件流,通过事件消息同步。库存服务接收到支付成功事件后,就知道可以发货了;资金服务接收到退款事件后,自动执行资金转出。这种模式把系统从“请求-响应”的耦合改成了“事实-反应”的联动,扩展性明显更好。当然不是所有系统都适合微服务,但如果团队决定走微服务,REA 是比按功能模块切更不容易踩坑的划界方式。
4. 常见问题与坑点实录
4.1 把事件活生生做成了状态机
这是新手最容易掉进去的坑。业务方说订单有下单、支付、发货、签收四个状态,有人就直接建一张订单状态表,然后用事件表记录状态的流转变化。表面看好像也用了事件,比如状态变更表,但本质上还是在描述状态,不是在记录事实。
怎么分辨呢?看你的事件记录有没有回答“导致这个事实发生的资源流动是什么”。如果一条“支付成功”记录下面没有挂着资金资源的流入流出,那它只是一次状态变更日志,不是 REA 意义上的事件。要修正也很简单,把状态流转的思考放到一边,重新问自己:这笔业务的资源到底发生了什么变化。想清楚这个问题,事件表自然就找齐了资源流。
4.2 库存和余额的当前值到底要不要存
有一种极端做法是只存事件流,不存任何当前资源数值,每次查询都从零重放所有事件。这在理论上成立,但实际查询性能和实现复杂度都很高,尤其是库存、余额这类高频访问的资源,每次都重放根本不现实。
我的建议是双写:既保留事件明细,也保留资源当前值表。事件是事实依据,资源表是便于查询的投影。关键是资源表的值必须通过事件来更新,不能绕过事件直接改。这相当于给系统加了一条纪律,开发同学执行起来会不习惯,但一旦形成约束,后续排查对不上账的问题会轻松非常多。曾经有个业务线因为后台一键修复功能直接改了余额,导致好几笔账对不上,最后花了两周反查日志。后来我把后门改成“插入一笔调整事件 + 系统自动改余额”,再也没出过这类事故。
4.3 多对多关联越来越多,查询越来越慢
REA 建模跑起来后,事件与资源的关系都是多对多,event_resource_line 表的数据量会涨得飞快。很多同学一开始不在意,等到几千万行的时候,join 查询开始变慢,就开始怀疑 REA 模式有问题。
其实这不是 REA 的锅,是查询设计问题。读性能应该交给读取模型,而不是直接查事件明细表。正确做法是:事件明细表只服务写侧和审计,所有线上查询走 CQRS 的读模型。比如查询订单当前状态,直接查订单读取模型表,这张表已经冗余了状态字段,根本不需要 join 事件表。成本是用异步处理器或者消息订阅来维护读模型,收益是查询干净、快速,而且不同查询场景可以建不同的读模型表,各自进化。
4.4 什么场景不适合硬套 REA
如果只是一张内部管理后台的配置表,或者一个给运维用的一次性脚本,硬上 REA 就是把简单问题复杂化。REA 的价值在于记录高价值的业务事实,常见于电商交易、金融账户、供应链、预订类系统,这些场景对账复杂、生命周期长、审计要求高。简单 CRUD 应用直接改字段仍然是最高效的方案。
选择标准可以这样判断:这个业务是否需要回答“谁在什么时候因为什么让什么资源发生了什么变化”?是否经常需要对已发生的事实做追溯、审计、重算?如果两个都否,那传统状态表够用了;如果两个都是,那用 REA 模型,长期来看收益远大于前期投入。
| 常见症状 | 实际原因 | 处理方式 |
|---|---|---|
| 事件表只是状态变更日志 | 没建模资源流动 | 回到流程,给每个事件补资源流入流出关系 |
| 出现账户余额直接修数 | 资源表缺少事件约束 | 所有资源变更必须经由事件事务,封死后门 |
| 查询事件表 join 过多 | 读模型缺失 | 引入 CQRS,单独建查询投影表 |
| 库存扣减对不上账 | 事件和资源更新不在同一事务 | 事件插入与资源更新放同一事务内原子提交 |
| 服务间状态靠互查接口判断 | 边界划分错误 | 服务之间改走事件消息,共享事实流 |
| 简单后台管理也想用 REA | 过度设计 | 评估业务是否真的需要事件追溯,不需要则保持简洁 |
5. 我的一点实操体会
如果你问我去掉所有理论后 REA 到底教会了我什么,我的答案是三句话:第一,先找事件,不要先画 ER 图;第二,资源和参与者都可以后补,但事件漏了,后面很难补;第三,所谓状态,只是事实投影出来的影子。
之前在某供应链系统里做一个入库出库模块,团队一开始用传统库存流水表设计,字段越加越多,还要写一堆触发器来保证一致性。后来重新按 REA 梳理,把入库、出库、盘点、报废全部作为事件,把商品、货位、批次作为资源,整个逻辑瞬间清晰了,连仓储同事都能指着事件流跟我说“这一步少了一个损耗事件”。能让业务人员无障碍理解技术模型,这是我在其他建模方法里很少见到的体验。
如果这篇文章给你一个启发,建议你从手头最熟悉的业务场景开始练手。拿一张白纸,把你日常处理的某个业务流程从“状态”思维切换成“事实”思维,列出事件、资源、参与者,再看看原来的表结构哪里设计别扭了。不用急着改代码,光是这个思考换框的过程,就足够帮你发现自己系统里很多隐藏的耦合和遗漏。