我最早听到 REA 这三个字母是在一个被各种报表需求压到快崩溃的周五下午。业务方甩过来一张电子表格里面列了十几个口径——商品销售额、订单实付、退款后净值、毛利贡献……每个口径都要对账而数据库里的订单表、支付表、退款表、库存快照表互相 join谁都不敢动。如果你也遇到过这种“数据模型越改越复杂、报表口径永远对不齐”的困境那 REAResource–Event–Agent资源—事件—代理这个建模思路很可能是你需要的解药。它不是一套数据库软件也不是某个中间件而是一种分析和设计业务数据模型的方法论。它最早来自会计信息系统领域后来被大量用在订单、库存、供应链这类“业务系统”的底层建模上。我用它重构过一个库存管理模块也用它给一个模拟电商项目梳理过核心数据模型效果很明显表变少了报表口径有了唯一解释新增业务需求的时候不用再往老表上堆字段。这篇文章就把我实践中的完整思路、判定方法、建表过程和踩过的坑一起写出来给正在做业务系统数据模型的同学作个参考。1. 复式记账没法直接用的地方恰恰是 REA 的出发点1.1 传统状态表会在哪里卡住我们大多数人设计业务系统时第一反应是建一张订单表然后给订单加一个 status 字段待支付、已支付、已发货、已签收、已退款。这个做法很自然但有个隐患status 只记录了“当前状态”却丢了“状态是怎么来的”这件事。举一个最简单的例子同样一张订单表里status 是“已支付”我们只知道钱到了但支付发生在哪个时间、谁付的、付了多少、对应的是哪一笔收款记录通通不在表里。想算“某天实际收到的钱”只能去查支付记录可支付记录往往被设计成订单的附属子表和订单紧紧耦合。一旦订单表结构调整支付记录就要跟着改。库存也是一样的毛病。库存表里的“当前库存”其实是一个汇总值它隐藏了每一笔入库、出库、调整的明细。一旦出现盘亏你很难追溯到底哪一步错了。你会发现为了应对业务方的各种问题你被迫维护第二张、第三张流水表然后每张流水表还带着自己的状态字段最后陷入“用更多的表去解释同一件事”的循环。1.2 状态可以缓存事件必须留痕这里有个关键认知状态不是业务事实本身它只是一个可以随时重算的缓存。真正不可删除的是事件——每一笔入库、出库、销售、支付、退款、转库都是发生在某个时间点的客观业务行为。REA 把这个观点推到极致数据库里不存“当前状态”只存事件需要当前值的时候从事件推导出来。这个思想本质上和会计里的“流水账”一致总账余额不是单独存在的它是所有分录加总后的结果。比如库存数量在 REA 里它不来自某一张“库存表”而是来自所有相关事件的加总入库事件 100销售事件 -1盘点事件 5最后库存 Σ事件。状态表只是这个公式的缓存缓存可以删除、可以重建但事件明细必须保留全量。这样做的好处是口径永远只有一套因为所有结果都来自同一批事件。我当时第一次接触这个想法时心里第一反应是“这也太慢了吧查一次库存要把所有流水求和”。后来才发现这个担心其实是多余的——加总计算可以靠汇总表、物化视图来加速但真源只有一份。传统设计里“订单状态、库存快照、账户余额”各说各话REA 把所有这些“结果值”全部降级为缓存这才是它真正厉害的地方。2. 资源、事件、代理三要素的判定方法2.1 资源业务价值的载体而不是数据表本身REA 中的 RResource指的是“对企业有价值的对象”比如商品库存、现金、应收账款、人力资源。注意不是所有主数据都是资源。客户是资源吗在 REA 里通常不把“客户”本身当作资源而是把他列为代理Actor——参与事件的人或部门商品是资源吗要看语境。库存场景里商品是资源因为它在销售事件中被交换、在采购事件中被获得。但“商品主数据表”本身不是资源它只是描述资源的属性集合。创建商品主数据这个动作也不是事件因为它不产生价值的流转只是把字典数据写进系统。我自己有一个判断习惯如果一个对象能够被“获得”能够被“放弃”并且它的数量或价值会随着业务行为发生变化那它大概率是资源如果它只是用来描述、分类、关联其他数据那它只是辅助配置表不要硬塞进资源体系里。2.2 事件能留下时间戳的业务行为EEvent是 REA 的核心。判断一个业务行为能不能算事件我的标准很简单能不能给出一个明确的时间点“下单”可以订单产生在某个时刻“维护商品信息”不行因为它更像对主数据的编辑“添加客户标签”不行它没有资源的流转。事件还有两个明显特征会改变资源的数量或价值并且至少要涉及一个内部代理和一个外部代理。事件也有粒度问题。一笔订单可能包含三个商品发货可能分了两次退款只退了其中一件。事件建模时要么把事件拆到“单据级 明细级”两层要么把每个明细行都当成独立事件。我在实际项目里更倾向后者明细行即事件单据本身只是一个聚合展示层。这样后续做数据分析和对账时天然就是明细粒度不会被“按单汇总再拆单”的麻烦绊住。2.3 代理要成对出现这是最容易被忽略的一条AAgent指参与事件的人、组织或系统。REA 强调事件必须至少关联两个代理一个内部代理比如销售员、仓库管理员和一个外部代理比如客户、供应商。为什么要成对因为业务事件本质上是双方责任的交接。如果只记“销售员卖了一台电脑”却不知道卖给哪个客户这个事件就缺了一半。就算做的是内部调拨也要有“出库员”和“入库员”两个代理责任才闭环。项目里还有一种常见错法把系统本身当代理。系统定时任务触发了一个状态变更那不是业务代理充其量是技术执行通道。真正要记的是“谁的业务动作引发了这次变更”——是客户发起退款还是客服人工取消这才是审计需要的。2.4 一张判定清单要素核心问题判断标准典型例子资源什么有价值的东西在流转能被交换、消耗、生产有数量或价值度量库存商品、现金、应收账款事件什么时候发生了什么业务动作有明确时间点改变资源数量/价值连接两组代理销售、收款、入库、退货代理谁参与了这次业务动作有能力参与事件的人或组织客户、供应商、员工、部门这张表我几乎每做一个业务模块都会重新过一遍。特别是做评审的时候让业务方看着这张表去描述他们的业务比看流程图更容易收敛——他们讲“现在有个审批流”的时候我会追问“这个审批动作之后哪项资源的数量或价值发生了变化”问三遍之后需求就清晰了。3. 用一个订单场景把 REA 变成能落地的表结构3.1 场景定义与事件链拆分以某电商公司为例。业务全景客户浏览商品、下单、支付、仓库发货、客户签收、退款。对 REA 来说浏览商品不是事件因为没有资源的实际流转。“下单”是不是事件表面上还没有发生资源交换但从业务责任看“下单”产生了销售承诺企业因此承担交货义务所以应该被建模为“销售事件”。更完整的事件链是接收订单事件企业确认销售库存商品预期减少形成应收账款预期。收款事件现金增加应收账款减少。发货事件库存商品实际减少交付义务结束。退货事件库存商品增加退款义务产生或应收账款减少。每个事件都关联它的资源流入或流出。这个链条一旦理清楚表结构就呼之欲出了。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 重构业务数据模型时最难的不是建表而是说服团队接受“订单表里没有订单状态字段”这件事。产品经理总想打开后台直接看到“已完成”三个字我的解决方式是保留数据库层面的版本化状态缓存但明确标注它是“从事件推导出来的快照”不是真源。这样既满足了界面的需求又不破坏事件建模的完整性。还有一个实用小技巧凡是业务方说“帮我查一下发生了什么”的需求优先考虑事件建模凡是说“帮我实时显示一个数字”的需求优先考虑状态缓存。事件是根状态是叶顺序别搞反数据模型就不会长歪。