ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

REA建模:用资源-事件-代理重构业务数据模型,让报表口径对不齐成为历史

REA建模:用资源-事件-代理重构业务数据模型,让报表口径对不齐成为历史

我最早听到 REA 这三个字母,是在一个被各种报表需求压到快崩溃的周五下午。业务方甩过来一张电子表格,里面列了十几个口径——商品销售额、订单实付、退款后净值、毛利贡献……每个口径都要对账,而数据库里的订单表、支付表、退款表、库存快照表互相 join,谁都不敢动。如果你也遇到过这种“数据模型越改越复杂、报表口径永远对不齐”的困境,那 REA(Resource–Event–Agent,资源—事件—代理)这个建模思路很可能是你需要的解药。

它不是一套数据库软件,也不是某个中间件,而是一种分析和设计业务数据模型的方法论。它最早来自会计信息系统领域,后来被大量用在订单、库存、供应链这类“业务系统”的底层建模上。我用它重构过一个库存管理模块,也用它给一个模拟电商项目梳理过核心数据模型,效果很明显:表变少了,报表口径有了唯一解释,新增业务需求的时候不用再往老表上堆字段。这篇文章就把我实践中的完整思路、判定方法、建表过程和踩过的坑一起写出来,给正在做业务系统数据模型的同学作个参考。

1. 复式记账没法直接用的地方,恰恰是 REA 的出发点

1.1 传统状态表会在哪里卡住

我们大多数人设计业务系统时,第一反应是建一张订单表,然后给订单加一个 status 字段:待支付、已支付、已发货、已签收、已退款。这个做法很自然,但有个隐患:status 只记录了“当前状态”,却丢了“状态是怎么来的”这件事。

举一个最简单的例子:同样一张订单表里,status 是“已支付”,我们只知道钱到了,但支付发生在哪个时间、谁付的、付了多少、对应的是哪一笔收款记录,通通不在表里。想算“某天实际收到的钱”,只能去查支付记录;可支付记录往往被设计成订单的附属子表,和订单紧紧耦合。一旦订单表结构调整,支付记录就要跟着改。

库存也是一样的毛病。库存表里的“当前库存”其实是一个汇总值,它隐藏了每一笔入库、出库、调整的明细。一旦出现盘亏,你很难追溯到底哪一步错了。你会发现,为了应对业务方的各种问题,你被迫维护第二张、第三张流水表,然后每张流水表还带着自己的状态字段,最后陷入“用更多的表去解释同一件事”的循环。

1.2 状态可以缓存,事件必须留痕

这里有个关键认知:状态不是业务事实本身,它只是一个可以随时重算的缓存。真正不可删除的是事件——每一笔入库、出库、销售、支付、退款、转库,都是发生在某个时间点的客观业务行为。

REA 把这个观点推到极致:数据库里不存“当前状态”,只存事件,需要当前值的时候,从事件推导出来。这个思想本质上和会计里的“流水账”一致:总账余额不是单独存在的,它是所有分录加总后的结果。

比如库存数量,在 REA 里它不来自某一张“库存表”,而是来自所有相关事件的加总:入库事件 +100,销售事件 -1,盘点事件 +5,最后库存 = Σ事件。状态表只是这个公式的缓存,缓存可以删除、可以重建,但事件明细必须保留全量。这样做的好处是,口径永远只有一套,因为所有结果都来自同一批事件。

我当时第一次接触这个想法时,心里第一反应是“这也太慢了吧,查一次库存要把所有流水求和”。后来才发现,这个担心其实是多余的——加总计算可以靠汇总表、物化视图来加速,但真源只有一份。传统设计里“订单状态、库存快照、账户余额”各说各话,REA 把所有这些“结果值”全部降级为缓存,这才是它真正厉害的地方。

2. 资源、事件、代理三要素的判定方法

2.1 资源:业务价值的载体,而不是数据表本身

REA 中的 R(Resource)指的是“对企业有价值的对象”,比如商品库存、现金、应收账款、人力资源。注意,不是所有主数据都是资源。客户是资源吗?在 REA 里通常不把“客户”本身当作资源,而是把他列为代理(Actor)——参与事件的人或部门;商品是资源吗?要看语境。

库存场景里商品是资源,因为它在销售事件中被交换、在采购事件中被获得。但“商品主数据表”本身不是资源,它只是描述资源的属性集合。创建商品主数据这个动作也不是事件,因为它不产生价值的流转,只是把字典数据写进系统。

我自己有一个判断习惯:如果一个对象能够被“获得”,能够被“放弃”,并且它的数量或价值会随着业务行为发生变化,那它大概率是资源;如果它只是用来描述、分类、关联其他数据,那它只是辅助配置表,不要硬塞进资源体系里。

2.2 事件:能留下时间戳的业务行为

E(Event)是 REA 的核心。判断一个业务行为能不能算事件,我的标准很简单:能不能给出一个明确的时间点?

“下单”可以,订单产生在某个时刻;“维护商品信息”不行,因为它更像对主数据的编辑;“添加客户标签”不行,它没有资源的流转。事件还有两个明显特征:会改变资源的数量或价值,并且至少要涉及一个内部代理和一个外部代理。

事件也有粒度问题。一笔订单可能包含三个商品,发货可能分了两次,退款只退了其中一件。事件建模时要么把事件拆到“单据级 + 明细级”两层,要么把每个明细行都当成独立事件。我在实际项目里更倾向后者:明细行即事件,单据本身只是一个聚合展示层。这样后续做数据分析和对账时,天然就是明细粒度,不会被“按单汇总再拆单”的麻烦绊住。

2.3 代理要成对出现,这是最容易被忽略的一条

A(Agent)指参与事件的人、组织或系统。REA 强调事件必须至少关联两个代理:一个内部代理(比如销售员、仓库管理员)和一个外部代理(比如客户、供应商)。

为什么要成对?因为业务事件本质上是双方责任的交接。如果只记“销售员卖了一台电脑”,却不知道卖给哪个客户,这个事件就缺了一半。就算做的是内部调拨,也要有“出库员”和“入库员”两个代理,责任才闭环。

项目里还有一种常见错法:把系统本身当代理。系统定时任务触发了一个状态变更,那不是业务代理,充其量是技术执行通道。真正要记的是“谁的业务动作引发了这次变更”——是客户发起退款,还是客服人工取消,这才是审计需要的。

2.4 一张判定清单

要素核心问题判断标准典型例子
资源什么有价值的东西在流转?能被交换、消耗、生产;有数量或价值度量库存商品、现金、应收账款
事件什么时候发生了什么业务动作?有明确时间点;改变资源数量/价值;连接两组代理销售、收款、入库、退货
代理谁参与了这次业务动作?有能力参与事件的人或组织客户、供应商、员工、部门

这张表我几乎每做一个业务模块都会重新过一遍。特别是做评审的时候,让业务方看着这张表去描述他们的业务,比看流程图更容易收敛——他们讲“现在有个审批流”的时候,我会追问“这个审批动作之后,哪项资源的数量或价值发生了变化”,问三遍之后,需求就清晰了。

3. 用一个订单场景,把 REA 变成能落地的表结构

3.1 场景定义与事件链拆分

以某电商公司为例。业务全景:客户浏览商品、下单、支付、仓库发货、客户签收、退款。

对 REA 来说,浏览商品不是事件,因为没有资源的实际流转。“下单”是不是事件?表面上还没有发生资源交换,但从业务责任看,“下单”产生了销售承诺,企业因此承担交货义务,所以应该被建模为“销售事件”。

更完整的事件链是:

  1. 接收订单事件:企业确认销售,库存商品预期减少,形成应收账款预期。
  2. 收款事件:现金增加,应收账款减少。
  3. 发货事件:库存商品实际减少,交付义务结束。
  4. 退货事件:库存商品增加,退款义务产生或应收账款减少。

每个事件都关联它的资源流入或流出。这个链条一旦理清楚,表结构就呼之欲出了。

3.2 从事件推导表结构

我在落地时倾向于把事件作为主表,资源作为附表,代理作为关联表。针对上面这个场景,核心表至少包含:

CREATE TABLE event_sale_order ( event_id BIGINT PRIMARY KEY, occurred_at TIMESTAMP NOT NULL, order_no VARCHAR(32) NOT NULL, internal_agent_id BIGINT NOT NULL, -- 销售员/客服 external_agent_id BIGINT NOT NULL, -- 客户 resource_id BIGINT NOT NULL, -- 商品 qty INT NOT NULL, unit_price DECIMAL(12,2) NOT NULL ); CREATE TABLE event_payment ( event_id BIGINT PRIMARY KEY, paid_at TIMESTAMP NOT NULL, sale_event_id BIGINT NOT NULL, internal_agent_id BIGINT NOT NULL, -- 收款确认人 external_agent_id BIGINT NOT NULL, -- 客户 amount DECIMAL(12,2) NOT NULL ); CREATE TABLE event_shipment ( event_id BIGINT PRIMARY KEY, shipped_at TIMESTAMP NOT NULL, sale_event_id BIGINT NOT NULL, internal_agent_id BIGINT NOT NULL, -- 仓管员 external_agent_id BIGINT NOT NULL, -- 承运方 qty INT NOT NULL );

如果你用了“明细行即事件”的粒度,那么 event_sale_order 里就是一个个订单明细行,而不是整张订单。序号、优惠、分摊这种订单头字段,可以单独放一张 order_header 表,和明细通过 order_no 关联,用来做界面展示和聚合查询。

这里有一个设计要点:事件表和资源表、代理表之间全部用代理键关联,不要直接写业务编码。业务编码会变,比如商品改 SKU、客户换编号,直接用业务编码做外键,将来纠正数据的时候会牵连一片。

3.3 关键约束与设计细节

第一,每个事件表必须强制 NOT NULL 三个字段:occurred_at、internal_agent_id、external_agent_id。缺任何一个都说明建模不完整。这不是数据库能不能存的问题,而是从源头逼着业务定义清楚。

第二,资源流入流出成对出现。销售事件增加应收账款事件,收款事件减少应收账款——这两个事件必须靠关联字段闭环。我在设计时会为每个事件表增加一个 pair_event_id 字段,用来指向它的配对事件,这样对账时可以双向追溯。

第三,时间戳统一用 UTC 存储。业务上说的“下午三点”在不同时区对不同角色可能完全不是同一个时间点。事件一旦发生就不应该被修改,如果发生错误,正确的做法是补一笔冲正事件,而不是直接 update 原记录。这一点在审计上非常重要,我刚上手的时候放松过,后来被对账数据查到头大,才彻底改成“事件只追加、不修改”。

4. 用 REA 回答“现在库存多少”“这个月卖出多少”的查询视角

4.1 结果值全部来自事件加总

在传统订单表里查“库存还剩多少”,你得先去库存快照表找最近一条记录;快照过期了还要从头扫流水。在 REA 模型里,这个问题变成了一道简单的加减法:

库存数量 = 入库类事件数量 − 出库类事件数量

如果真要写查询,方向也很清晰:

SELECT resource_id, SUM(CASE WHEN event_type = 'IN' THEN qty ELSE 0 END) AS in_qty, SUM(CASE WHEN event_type = 'OUT' THEN qty ELSE 0 END) AS out_qty, SUM(CASE WHEN event_type = 'IN' THEN qty ELSE 0 END) - SUM(CASE WHEN event_type = 'OUT' THEN qty ELSE 0 END) AS stock_qty FROM inventory_event WHERE occurred_at < :query_time GROUP BY resource_id;

这个查询看起来很“重”,但它做到了口径统一:不管谁问、在哪里问、什么时候问,公式只有一个。慢的问题靠缓存解决,但正确性问题从根上消失了。

4.2 报表口径对不齐的根源

传统设计里,订单表存状态,汇总表存快照,同一笔销售在订单模块算一次,在财务模块又算一次,两者经常差一点点。差在哪里?差在“事件时间”和“入账时间”不同、差在“下单”和“支付成功”被记在了不同状态里、差在各种临时补丁规则。

我观察下来,大多数“对不齐”不是计算错误,而是因为同时存在两套真相。REA 的价值在于它逼着你把所有“结果值”都定义成“某个事件集合的推导结果”。这样哪怕推导逻辑有 bug,修复后重新算一遍就对齐了,不存在一边改了另一边没改的情况。

4.3 增量汇总表怎么维护

事件明细表会非常快地膨胀,尤其是做详细粒度事件建模的时候。我不建议每次都全表扫描。实际项目里我用的是每日汇总表:

CREATE TABLE daily_resource_summary ( resource_id BIGINT NOT NULL, summary_date DATE NOT NULL, in_qty INT NOT NULL DEFAULT 0, out_qty INT NOT NULL DEFAULT 0, in_amount DECIMAL(14,2) NOT NULL DEFAULT 0, out_amount DECIMAL(14,2) NOT NULL DEFAULT 0, PRIMARY KEY (resource_id, summary_date) );

每天晚上用事件表跑一次当天的汇总,覆盖写入这张汇总表。报表查询只读 daily_resource_summary,按日期区间累计即可。这张表只是缓存,不是真源,任何可疑数据都可以删掉重建,因为它可以完全从事件表算回来。

这也是我信任这个模型的一个重要原因:缓存可以随便坏,但真源不会丢。

5. 落地 REA 时最容易翻车的几个地方

5.1 预算和配额不是资源

我一开始做预算模块的时候,顺手把“预算金额”建成了资源表,然后设计了一个“预算占用”事件来消耗它。结果发现这个模型怎么都圆不上——预算不会因为一个采购事件就真的“流入”某个账户,它只是约束条件,不是资源。

正确的处理是:预算作为独立的控制规则存在,采购事件发生时才去校验预算余额。如果你想看预算消耗到多少,可以基于事件明细实时计算,但“预算余额”本身不是资源。把它当资源建模,很快就会遇到“预算的流入事件是什么”这个无法回答的问题。

5.2 事件的时间边界与在途状态

业务人员特别喜欢提“在途订单”“未发货订单”这类说法。如果我在事件表里加一个 status 来表示“还没有完成”,那就又回到状态表的陷阱里了。

我的做法是:事件在业务完成时记录,未完成的状态单独建模为“承诺”或“计划”。比如销售订单已经创建但客户还没支付,那么“销售承诺”是存在的,但“收入确认事件”还没有发生。把承诺表和事件表分开,统计实际销售时只查事件表,统计销售线索时查承诺表,两边永远不混。

5.3 代理粒度的取舍

内部代理到底用“人”还是“部门”?我的经验是:事件表里记录操作人,同时冗余一个部门字段。这样保留审计粒度(谁做的),同时报表按部门聚合时直接分组,不需要反查人员组织架构。缺点是有冗余,但部门调整不影响历史事件的归属,因为事件发生那一刻的部门已经被记下来了。

外部代理也有类似问题:客户可能既是一个公司主体,又包含多个门店。我会让事件表里的 external_agent_id 统一指向最细粒度的交易对象,然后维护一张代理关系树,方便向上汇总。不要一会记门店、一会记上级公司,口径又会乱。

最后再分享一个我在实践中的体会:第一次用 REA 重构业务数据模型时,最难的不是建表,而是说服团队接受“订单表里没有订单状态字段”这件事。产品经理总想打开后台直接看到“已完成”三个字,我的解决方式是保留数据库层面的版本化状态缓存,但明确标注它是“从事件推导出来的快照”,不是真源。这样既满足了界面的需求,又不破坏事件建模的完整性。

还有一个实用小技巧:凡是业务方说“帮我查一下发生了什么”的需求,优先考虑事件建模;凡是说“帮我实时显示一个数字”的需求,优先考虑状态缓存。事件是根,状态是叶,顺序别搞反,数据模型就不会长歪。

RELATED READING

延伸阅读

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