ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

REA业务建模:用资源、事件、代理重构订单与库存系统

REA业务建模:用资源、事件、代理重构订单与库存系统

最近我在整理一个内部项目的时候,给它的代号起了个简短的名字:REA。如果你也经常被业务模型绕晕,这个缩写大概能给你打开口子——REA 在这里不是某个前端框架,也不是某个公司内部系统代号,而是 Resource-Event-Agent 的缩写,也就是“资源-事件-代理”业务建模视角。简单说,REA 让我们不再盯着数据库表字段发愁,而是先把业务动作和资源流转梳理清楚,再谈落地。这篇文章适合那些天天被业务方追着改字段、又怕改出脏数据的技术人,也适合刚接触领域建模、想知道事件从哪儿来的同学。我会用项目实操的方式,把 REA 的核心概念、建模步骤、物理表设计和避坑经验完整讲一遍。

1. 为什么我用REA重新思考业务模型

1.1 从一张订单表说起

以前我做订单系统,第一反应就是建表:订单主表、订单明细表、支付流水表、物流表,再加上一个状态字段。状态从“待支付”到“已支付”再到“已发货”,全靠一堆状态机去控制。刚开始觉得挺清晰,后来业务多了情况就变了:有人要加退换货,有人要统计“有多少订单在支付后又被取消”,还有人问“库存当时到底扣了没有”。这时候每一处改动都牵一发动全身,因为数据模型直接跟着界面和状态走了,业务规则被埋在十几张表的状态判断里。

我后来复盘,发现问题不在代码,而在建模视角。业务方跟我描述一条订单时,说的从来不是“订单表的 status 改成 2”,而是“客户在昨天下午三点支付了这笔订单,支付成功后库存和预存款都变了”。 这句话天然是一个事件(支付),关联了资源(库存、资金)和代理(客户)。 我以前的表结构里只有结果,没有原因;只有当前状态,没有历史事实。于是我开始用 REA 的方式重新画这套业务。

1.2 REA 到底是什么:资源、事件、代理这三顶帽子

REA 是业务建模里非常经典的一套观察框架,它只回答三个问题:业务里有什么有价值的东西可以变?什么东西让它们变?谁负责变?这三个答案分别就是资源(Resource)、事件(Event)、代理(Agent)。

资源不是数据库里的“资源表”,而是业务真正关心的、有价值的东西。比如商品库存、客户预存款、应收账款、设备工时,甚至一条审批记录如果它有业务价值,也可以当作资源。关键是它必须能被某个动作增加或减少。

事件是让资源发生变化的具体动作。比如采购入库让库存增加,销售出库让库存减少,客户支付让现金增加、应收款减少。事件一定发生在某个时间点,而且发生之后就是历史事实,不能再被“改掉”。

代理是参与事件的人或系统。它可以是内部的:运营、仓管、财务;也可以是外部的:客户、供应商、物流平台。在合规审计里,代理信息往往比资源信息更重要,因为你要回答“这笔操作到底是谁做的”。

用咖啡店举例:咖啡豆、现金、做好的咖啡是资源;“顾客付款”“店员做咖啡”“供应商补货”是事件;顾客、店员、供应商是代理。业务闭环就是“代理执行事件,事件消耗或产生资源”。把任何业务场景套进这三个帽子,你会发现绝大部分名词都能找到位置,那些找不到位置的,恰好就是多余的概念或者只是查询视图。

1.3 为什么传统方式容易翻车

有人可能会问:我不搞这套理论,用状态机一样能做系统。能做,但难维护。问题在于传统表结构倾向于“以实体为中心”,订单、商品、用户各自一张表,用外键互相引用。这种方式对单条记录的增删改查很方便,但跨实体的业务一致性、审计追溯和指标统计全都靠应用程序员硬编码。

REA 的优势是把视角从“实体”切换到“变化”。它强制问:哪些资源被哪些事件改变了?哪些代理参与了哪些事件?一旦你开始这么问,就会自然避免把状态存成单一字段,因为状态其实是多个事件叠加的产物。 这个概念听起来很学术,但它落地以后非常实在:审计日志、订单历史、库存流水、财务对账,全部来自同一套事件事实,再也不会有多个团队各看各的字段、口径对不上的问题。

2. 核心建模细节与判断标准

2.1 资源、事件、代理之间常见的三种关系

REA 并不只有三个孤立概念,它更强调概念之间的关系。理解这三种关系,才算真正上手。

第一种是“事件与资源”的关系,也叫存量关系。一个事件可以同时让一种资源增加、让另一种资源减少。比如“销售出库”让库存减少,同时产生一笔“已发货订单资源”或者说“应收账款”增加。这类关系要明确方向:是增加还是减少,是消耗还是产出。

第二种是“事件与代理”的参与关系。一个事件至少有一个内部代理或外部代理参与。内部代理指系统内部负责操作的角色,外部代理指业务关系里的对手方。例如“支付成功”事件,客户是外部代理,财务系统是内部代理。这里要特别注意:如果事件没有任何代理参与,那它很可能不是一个业务事件,只是一个定时脚本或系统自动动作。

第三种是“事件与事件”的交换或转化关系。最常见的是交换关系:一个“销售发货”事件和后来的“收款确认”事件配对,形成一手交钱一手交货的业务闭环。还有转化关系:一个“生产领料”事件和“成品入库”事件配对,形成料件转化为成品。

这三个关系画出来之后,整个业务逻辑就变成了一张网。在建模初期,我建议先不要画数据库 ER 图,而是用卡片和箭头在白板上画这张网。等网画顺了,再想怎么落到表。

2.2 判断一个概念到底该放哪一格

建模时最常见的尴尬是:看到一个名词,不知道它算资源、事件还是代理。比如“订单”算资源还是事件?“订单”更像是一个容器,它记录了“客户确认购买”这个事件和后续一系列履约事件,本身不是资源;“订单状态”更不是资源,它是多个事件发生后的结果。再比如“库存”当然是资源,但“库存表”不是;库存表只是资源当前值的物理载体。

我给自己定了一个判断规则:如果这个东西可以被“增加/减少/消耗/生产”,它大概率是资源;如果它是一个动词或动作,发生在某个时间点,它大概率是事件;如果它有权利发起或决策某个动作,它大概率是代理。用“订单”来试,它能被增加减少吗?订单数量可以统计,但业务上你不会“消耗一张订单”,你只会“提交订单”“取消订单”,所以订单更适合作为“一组事件的聚合”,而不是单一的某个资源。

另一个规则是:如果一个概念在业务讨论中总是和“时间”绑定,说“某天某时发生了什么”,那它更可能是事件而不是资源。 明确这套判断标准以后,团队讨论就不再陷入“我觉得这是个实体”的玄学争论,而是能用三顶帽子快速收敛。

2.3 事件为什么最好用动词短语命名

我给事件命名的原则是:一定是“动词+宾语”。比如“提交订单”“确认付款”“库存扣减”“退货入库”。千万不要用“已支付”“已取消”这种状态式命名,因为它们描述的是结果,不是动作。结果状态应该由事件推导出来,而不是单独当作事件。

为什么这一点这么重要?因为事件一旦用动词短语命名,它天然不可变。你不能修改“确认付款”这个事实,你只能在这个事实之后追加一个“退款”事件。如果事件表里存的是一堆“已付款=1”的布尔值,你根本没法还原当时发生了什么,也没法回答“这个订单一共被修改过几次”这种问题。用动词短语命名,实际上是在给系统建立“事实流”,后续做审计、对账、数据分析都有了统一基础。

3. 一个订单系统的REA重构实操

3.1 先列资源画事件,不碰数据库

为了讲清楚落地过程,我拿一个虚构的“某跨平台订单系统”的订单模块当例子。第一步不是建表,而是把业务方叫到白板前,一起把资源清单列出来。常见的资源有:商品库存、可售数量、客户预存款、优惠券、应收款、积分。每个资源后面,我问一句:什么动作会让它变多或变少?

例如商品库存:采购入库让它变多,销售出库让它变少,退货入库让它变多,盘点调整可能让多也让少。优惠券:优惠券发放让它变多,订单抵扣让它变少。客户预存款:充值让它变多,支付扣款让它变少,退款让它变多。把这些动作全部写成动词短语,就是事件清单。

然后把每一个事件标上代理角色。比如“订单支付成功”里,客户是外部代理,支付渠道是内部代理,“订单确认收货”里,客户是外部代理,客服系统是内部代理。 这个过程看起来像玩贴纸游戏,但效果相当好:业务方会开始意识到,原来他们平时口语里的“库存没了”不是一个状态,而是某个事件发生后的结果。

3.2 从事件清单到表结构:REA风格的数据模型

白板上的事件清单出来后,就可以考虑物理存储了。这里给出一个简化但可运行的 REA 风格表结构,核心思路是:资源表负责记录当前状态,事件表负责记录不可变事实,两张关联表把事件与资源、事件与代理连接起来。

-- 资源表:保存当前存量 CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_type VARCHAR(50) NOT NULL, -- inventory, prepaid, coupon, receivable quantity DECIMAL(18,4) NOT NULL DEFAULT 0, owner_agent_id BIGINT, version BIGINT NOT NULL DEFAULT 0 ); -- 事件表:记录每个业务事实 CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(100) NOT NULL, -- 动词短语,如 order_paid occurred_at TIMESTAMPTZ NOT NULL, reference_no VARCHAR(64), payload JSONB ); -- 事件与资源的关系:方向为 + 表示增加,- 表示减少 CREATE TABLE event_resource ( event_id BIGINT NOT NULL REFERENCES event(event_id), resource_id BIGINT NOT NULL REFERENCES resource(resource_id), direction VARCHAR(8) NOT NULL, -- 'increment' / 'decrement' delta_qty DECIMAL(18,4) NOT NULL, PRIMARY KEY (event_id, resource_id) ); -- 事件与代理参与关系 CREATE TABLE event_agent ( event_id BIGINT NOT NULL REFERENCES event(event_id), agent_id BIGINT NOT NULL, agent_type VARCHAR(50) NOT NULL, -- customer, supplier, ops, system agent_role VARCHAR(50) NOT NULL, -- initiator, approver, reviewer PRIMARY KEY (event_id, agent_id, agent_role) );

注意这里的event_resource表把每个事件对资源的影响都记录成增量,方向明确、数量明确。这种设计让库存流水天然存在,不需要再单独维护一张“流水表”;资源表只保存当前可用量,可以随时用事件流回放重新计算。我在这类项目里一般会加一个前置条件:任何修改库存和资金的事件,都必须在同一个数据库事务里写 event、event_resource,再更新 resource。这能避免“事件记录了但数量没变”的尴尬。

如果团队已经用事件溯源架构,那 event 表就是最核心的日志表,resource 表可以看作任何时刻的投影。就算不用事件溯源,只把事件表当作审计和流水表,也远远好过在状态字段里打补丁。

3.3 聚合边界该怎么切

REA 的关系网画完以后,另一个问题自然浮出水面:哪些资源、哪些事件应该放在同一个服务或同一个聚合边界里。我的经验是:能由同一个业务请求直接改变的资源,优先放一起;需要跨团队确认的,才放到不同聚合。

比如“销售出库”这个事件,同时影响“库存”减少和“待发货订单”状态。这个过程在一个仓库应用里,库存聚合和发货事件应该被放进同一个本地事务,避免分布式事务带来的麻烦。而“确认收款”会影响“应收款”减少和“客户预存款”增加,它更适合由财务服务来承载,和库存聚合分离。

用 REA 帮助定边界时,还有一个技巧:查看事件与资源的关系密度。一个事件连接了大量资源,说明这个事件是一个核心流程;如果这个流程涉及多个部门,那大概率需要拆成多个子事件。 例如“支付成功”事件可能同时改变订单聚合、账户聚合和积分资源,这时候我会把它拆成“收单成功”“入账完成”“积分发放”几个子事件,分属不同聚合,而不是强行保证一个事件改所有表。

3.4 和领域驱动设计怎么配合

很多做领域驱动设计(DDD)的同学会觉得 REA 和 DDD 是两套体系,其实它们能无缝配合。DDD 里说的聚合根,在 REA 里往往就是一组围绕某个资源的完整事件流。聚合根的标识可以是订单号或账户号,但聚合根内部状态的变化,一定要通过不可变事件记录,而不是直接修改字段。

领域服务对应代理发起的动作。比如“钱包扣款”这个领域服务,执行者可能是“支付系统”,参与事件的代理信息就写在 event_agent 表里。领域事件在 REA 模型里不再只是一个消息通知,而是一个真正改变资源状态的业务事实,这样消息和状态就统一了。

如果项目需要走事件溯源,REA 提供的语义比单纯的事件流更完整。传统事件溯源只要记下领域事件,但是 REA 还能告诉你这个事件关联了哪些资源、哪些代理、事件之间如何两两配对。这样在写查询投影时,你可以快速构造出“某个代理在某个时间段让某类资源增加了多少”这类报表,而不用从 JSON payload 里到处挖信息。

4. 常见问题与排查技巧实录

4.1 建模阶段反复掉进的坑

我见过太多团队把 REA 变成“换了一组新名词的 ER 图”。他们画了一堆卡片,把资源贴满墙,但事件只有两三个,代理全都标成“用户”,结果模型跟之前的表结构没有本质区别。原因往往是没有从业务流程出发,而是从已有数据字典出发反向套概念。

正确的做法是先讲业务场景,用“谁在什么时间做了什么,让哪类资源发生了变化”这个句式描述每一个用例。如果一个业务过程里没有资源变化,那它大概率不是核心业务事件,只是一个查询逻辑。 另外,资源不要贪多。库存、资金、额度这类能够直接计量的资源一定要建模;至于“订单”这种聚合容器,不要把它硬塞进资源,否则关系会乱套。

4.2 状态老是算不对,问题出在哪

在做订单查询时,最常见的表象是“状态和流水对不上”。比如订单显示已支付,但支付流水表里查不到记录。传统状态字段容易产生这种不一致,因为在更新状态时可能忘了写流水。REA 模型下,状态只能从事件推导,所以不存在“有状态没事件”的情况。如果发现不一致,第一反应应该是去查事件表,而不是去改状态字段。

还有一个典型问题是库存持续被扣成负数。这往往是因为“扣减库存”事件和“校验库存”不在同一个事务边界内,或者资源表的版本号没有用于乐观锁。解决办法是在resource表增加版本号,并在更新库存时使用UPDATE ... SET quantity = ?, version = version + 1 WHERE resource_id = ? AND version = ?这种带版本条件的语句,保证并发安全性。

4.3 一份可以抄走的建模排查清单

在每次模型评审前,我会把所有问题整理成一张速查表,逐条过:

症状可能原因排查方向
事件表里没有记录,但状态变了仍在用旧状态字段直接改状态改成事件驱动更新状态
状态字段和历史流水对不上更新状态与写流水不是同一事务强制写事件和改资源在同一个事务
很多资源只有名称没有数量把查询视图当资源去掉纯视图概念,只保留可计量的资源
事件命名是“已xx”把状态当成事件改名成动词短语并补上发生时间
代理信息缺失事件表里没有参与人增加 event_agent 关联表
库存超卖资源更新没有版本控制增加乐观锁或分布式锁

这些条目可能看着零散,但它们基本覆盖了业务建模过程中绝大多数翻车点。每次建模新模块前过一遍,能省不少返工时间。

4.4 哪些情况不适合一上来就上 REA

说句实在话,REA 不是一个适合所有场景的银弹。如果项目只是一个简单的内容管理系统,或者是一张配置表、一个纯查询报表,REA 只会带来不必要的复杂度。比如一个“用户资料修改”功能,改的就是用户资源本身,没有事件与事件配对,也没有多个代理协作,硬套模型会很别扭。

更稳妥的做法是识别核心业务域。订单、库存、资金、合同、采购这类涉及资源增减和多角色协作的领域,非常适合 REA;而权限管理、消息通知、配置中心这类系统,用传统 CRUD 反而更清晰。在一个大型系统里,往往只有核心交易域需要 REA,外围系统保持轻量模型,二者之间用明确接口隔离。

我在项目里的习惯是:只有当一个模块开始频繁出现“状态流转”“对账”“审计”这些词时,才切换到 REA 重新建模。 这样既避免了过度设计,又能在最关键的地方获得最大收益。把这套思路贯彻下去,你会发现团队讨论业务时不再争论字段,而是认真讨论资源和事件,这本身就意味着模型已经走在正确的轨道上了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进