REA模型:以资源-事件-代理重构业务数据建模与对账逻辑
看到“rea”这个标题估计不少人的第一反应跟我一样这不是 React 少打了个 c就是某个函数里的 read 写错了。但如果它是一个项目代号那就有意思了——去年我在给某团队重构中后台系统时正好把核心模块代号起为 rea全称是 Resource-Event-Agent也就是“资源-事件-代理”模型。这套模型原本是会计信息系统领域用来做业务语义建模的经典框架核心思想一句话就能概括把业务系统中的所有变化都看成某个资源因为某个事件发生了权属或数量的变动并且这个事件一定关联了某个代理。那段时间我最大的感受是传统 CRUD 表结构只关心“字段够不够存”而 REA 关心的是“业务到底有没有被完整描述”。如果你正在设计订单、库存、资金类的数据架构或者被对账逻辑折磨得够呛这篇文章值得花十分钟看完。我会讲清楚 REA 模型到底是什么、怎么用它从零搭建一套“购销存”最小闭环以及我在实操中踩过的坑和排查思路。1. 为什么是 REA先把业务建模的底层问题说透1.1 传统 CRUD 建表到底把什么弄丢了传统方式下我们设计业务表通常是从“界面长什么样”倒推的。订单表要有订单号、客户名、商品名、数量、金额、状态出入库单表要有单据号、仓库、商品、数量。这类表建起来很快录入数据也方便但麻烦会在后面一点一点冒出来。举个例子。我在模拟项目X里见过一张库存流水表里面记录了商品的入出库数量也写了经办人但月底财务对账时光查“这批货是谁买的、从哪个供应商那里买的、采购单和入库单是不是同一张单子”就费了半天劲。原因很直接流水表只记录了“结果”没记录“原因”。它知道库存从 0 变成了 100但不知道这 100 是采购入库、销售退货还是盘盈来的也不知道对应的采购订单在哪、付款到哪一步了。说白了传统表结构最容易丢的东西是“谁→对谁→做了什么→影响了什么资源→发生在什么时间”这条完整的事件链。而没有事件链后续的统计、对账、审计都要靠 join 一堆表外加人工判断去“补线索”表越多口径越乱。1.2 资源、事件、代理REA 的三件套REA 模型把业务世界抽象成三个核心概念这也是它名称的来源Resource资源有价值、且能被一个主体控制的物品或权利。比如库存商品、现金、应收账款、固定资产甚至服务工时也算。判断标准很简单——它能不能被“转移”或“消耗”。Event事件让资源发生变化的经济活动。比如采购入库、销售出库、支付货款、收到退款。注意这里我刻意不用“订单”作为主事件订单只是承诺真正影响资源状态的是“履行”动作。Agent代理参与事件的主体。通常分为内部代理和外部代理两类内部代理是员工、部门外部代理是客户、供应商、承运商。REA 最关键的设计原则是业务数据库里应该保存的是“事件流”而不是“修改后的余额”。余额、库存量、应收应付这类数值都是事件流的派生结果可以实时计算也可以定期物化。这个原则带来的直接好处是只要事件记录没丢、没改余额随时可以重算任何一次对账偏差都能追到具体事件。1.3 三种核心关系把业务链条串起来理解了三个实体之后真正让 REA 跑起来的是它们之间的三种关系我在建模时会把它们画成关系矩阵来梳理。第一是资源与事件的关系。一次事件往往会影响多个资源。一张销售出库单可能同时减少多个商品的库存一次采购入库可能同时增加商品库存和减少银行存款。所以资源与事件之间是多对多关系需要中间表记录“本次事件对某个资源的影响是什么数量多少金额多少方向是增还是减”。第二是代理与事件的关系。一个事件可能涉及多个代理。一次销售事件既有外部客户作为往来单位也有内部销售员、仓库管理员作为经办人。代理与事件也是多对多关系并且中间表里最好带上“角色”字段否则你分不清这张记录里的代理是客户还是仓管。第三是事件与事件的关系也就是事件链。比如“采购订单”事件链接着“收货入库”事件再链接着“付款”事件。事件链表达的是一件事从承诺到履行的完整过程这也是对账时最重要的线索。我在实际建模中会单独建一张事件链关系表记录事件之间的父子关系或前后置关系。提示REA 不是让你把表拆得更碎而是让你在拆表之前先想明白哪些数据是原始事件哪些数据是派生结果。这决定了后续所有查询和统计的稳定性。2. 从需求到模型用 REA 搭一个购销存最小闭环2.1 先圈需求别急着建表某团队当时的需求很典型小团队内部业务系统要管理采购、销售、库存和资金收付业务规模不大但要能回答三个问题——现在库存是多少、还欠供应商多少钱、客户还欠我们多少钱。这个规模正好适合用 REA 完整跑一遍。我先列了一张业务动作清单把术语统一好采购相关创建采购订单、供应商发货、仓库收货入库、发起付款、供应商确认收款。销售相关创建销售订单、仓库发货出库、客户签收、发起收款、客户确认付款。库存相关盘点调整、采购退货出库、销售退货入库。盘点调整这里严格说不是经济事件但也可以用“事件”表达盘盈盘亏本身是对账面资源的校正动作所以也放进事件表只是事件类型要单独标识。资金相关收款、付款、退款。这些动作看起来不少但用 REA 一归类就很清爽资源就两类商品和资金代理就两组内部员工和外部往来单位事件就是上面这些动作本身。模型复杂度一下子降下来了。2.2 实体表设计资源、事件、代理分别怎么建在设计表结构时我遵循了“先拆语义再定物理表”的顺序。下面是我们最终落地的核心表结构你可以直接参考。首先是资源表。商品资源和资金资源差异较大建议分开建。商品资源表常见字段如下CREATE TABLE resource_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_code VARCHAR(32) NOT NULL UNIQUE COMMENT 商品编码, product_name VARCHAR(128) NOT NULL COMMENT 商品名称, spec VARCHAR(64) NULL COMMENT 规格, unit VARCHAR(16) NOT NULL COMMENT 计量单位, is_active TINYINT NOT NULL DEFAULT 1 COMMENT 是否启用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 商品资源表;资金资源表很简单就是结算账户或者说“钱放哪了”可以是银行账户、现金账户、虚拟账户。关键是要有账户编码和币种。CREATE TABLE resource_fund ( id BIGINT PRIMARY KEY AUTO_INCREMENT, fund_code VARCHAR(32) NOT NULL UNIQUE COMMENT 账户编码, account_name VARCHAR(128) NOT NULL COMMENT 账户名称, currency_code CHAR(3) NOT NULL DEFAULT CNY, is_active TINYINT NOT NULL DEFAULT 1 ) COMMENT 资金资源表;然后是代理表。内部代理通常是员工外部代理是客户和供应商这两类属性差异很大我强烈建议分开。内部代理表记录员工信息外部代理表用agent_type区分客户和供应商也可以后续扩展成承运商、服务商。CREATE TABLE agent_internal ( id BIGINT PRIMARY KEY AUTO_INCREMENT, employee_no VARCHAR(32) NOT NULL UNIQUE COMMENT 工号, display_name VARCHAR(64) NOT NULL COMMENT 姓名, department VARCHAR(64) NULL, is_active TINYINT NOT NULL DEFAULT 1 ) COMMENT 内部代理表; CREATE TABLE agent_external ( id BIGINT PRIMARY KEY AUTO_INCREMENT, agent_code VARCHAR(32) NOT NULL UNIQUE COMMENT 往来单位编码, agent_type CHAR(1) NOT NULL COMMENT C客户 V供应商, agent_name VARCHAR(128) NOT NULL COMMENT 往来单位名称, contact_name VARCHAR(64) NULL, contact_phone VARCHAR(32) NULL, is_active TINYINT NOT NULL DEFAULT 1 ) COMMENT 外部代理表;接下来是核心事件表。这里有个设计取舍是把采购订单、入库单、销售订单、出库单、收付款都分开建各自的事件表还是统一建一张大事件表我在模拟项目X里选择了后者——事件表统一配合事件类型字段区分再搭配事件属性扩展表。这样做的原因有三个一是所有事件的公共属性时间、状态、幂等号、备注只需要维护一套二是后续归档、统计、审计时不用跨多张表合并三是扩展新事件类型时只需要加类型枚举和扩展属性不用新建表。CREATE TABLE business_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_type VARCHAR(32) NOT NULL COMMENT 事件类型PURCHASE_ORDER/RECEIPT/PAYMENT/SALES_ORDER/SHIPMENT..., event_no VARCHAR(48) NOT NULL UNIQUE COMMENT 业务单据号幂等唯一, occurred_at DATETIME NOT NULL COMMENT 业务发生时间, status VARCHAR(16) NOT NULL COMMENT DRAFT/COMMITTED/CANCELLED..., total_amount DECIMAL(18,2) NULL COMMENT 事件总金额仅作展示和核对, remark VARCHAR(512) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 业务事件表;事件与资源的中间表这是整个模型里最关键的一张表每次事件对资源的具体影响都记在这里CREATE TABLE event_resource ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id BIGINT NOT NULL, resource_type VARCHAR(32) NOT NULL COMMENT 动的是商品还是资金, resource_id BIGINT NOT NULL COMMENT 对应 resource_product.id 或 resource_fund.id, quantity DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT 数量商品类资源必填资金类资源填0, unit_price DECIMAL(18,4) NULL COMMENT 单价, amount DECIMAL(18,2) NOT NULL COMMENT 该资源行金额, direction TINYINT NOT NULL COMMENT 资源流入1流出-1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_event_id (event_id), KEY idx_resource (resource_type, resource_id) ) COMMENT 事件-资源影响明细表;事件与代理的中间表和上一张表配合记录“谁参与了这件事以什么角色参与”CREATE TABLE event_agent ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id BIGINT NOT NULL, agent_type VARCHAR(16) NOT NULL COMMENT INTERNAL/EXTERNAL, agent_id BIGINT NOT NULL, role_type VARCHAR(32) NOT NULL COMMENT CREATOR/APPROVER/CUSTOMER/SUPPLIER/HANDLER..., created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_event_id (event_id), KEY idx_agent (agent_type, agent_id) ) COMMENT 事件-代理参与表;最后是事件链关系表用于把采购订单、入库、付款这些事件串成链条。注意这里保存的是事件之间的引用关系比如“收货事件”是由哪个“采购订单事件”触发的CREATE TABLE event_link ( id BIGINT PRIMARY KEY AUTO_INCREMENT, prev_event_id BIGINT NOT NULL COMMENT 前置事件ID, next_event_id BIGINT NOT NULL COMMENT 后续事件ID, link_type VARCHAR(16) NOT NULL COMMENT CAUSES/COMPENSATES/REVOKES..., created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_prev_event (prev_event_id), KEY idx_next_event (next_event_id) ) COMMENT 事件链关系表;2.3 一个容易踩的坑金额到底记在事件头还是事件行在建表时某团队同事提出了一个很典型的疑问business_event.total_amount和event_resource.amount都存金额会不会冗余答案是两者都有必要但用途完全不同。事件头金额是人为录入的“单据金额”用于展示和人工核对事件行金额是参与计算的“影响金额”用于余额聚合和账实核对。如果只记一行金额一张单子包含多个商品时你没法知道哪个商品占的金额是多少而如果只用行金额、不记头金额单据被多次编辑后你很难判断原始的“开单金额”到底是哪一版。所以在模拟项目X里我们约定一切统计以event_resource.amount为准business_event.total_amount只作为“单据面见金额”。月底对账如果行汇总和头金额对不上说明有人改了明细没改头或者反了过来这是需要排查的信号而不是直接忽略。这个模型跑通后我再讲一下具体的实操落地过程。3. 实操全过程建表、造数、验证账实一致性3.1 落地四步走关系矩阵→物理表→视图→校验任务第一步先把需求清单里的每个动作都填进一张关系矩阵表。矩阵的行是资源列是事件交叉点写“数量”“-金额”。比如“采购入库”这一列在“商品资源”行的交叉点写“数量金额”在“资金资源”行写“-金额应付款”。这张矩阵看起来没什么技术含量却是整个建模中最重要的一步因为它逼着你把每个业务动作对资源的影响方向敲定避免后续写代码时出现“同一类事件有的加、有的减”的口径混乱。第二步按矩阵建物理表。这里有个实操建议外键约束不要建得太死。因为事件表、中间表都会频繁插入而且历史事件不允许修改外键约束在并发写入时很容易成为瓶颈。我们最终只在event_resource.event_id、event_agent.event_id上建了普通索引外键关系完全由应用层保证。数据库层的强约束只留下了business_event.event_no唯一索引这是幂等底线。第三步建视图。REA 模型下库存余额、应收余额这些数值都是派生数据不建议写业务代码去维护“余额表”而是用一个统一的可查询视图撑住。我先建了“库存余额视图”它本质上是把所有event_resource按照商品分组、按方向求和CREATE VIEW v_product_balance AS SELECT er.resource_id, rp.product_code, rp.product_name, rp.unit, SUM( CASE WHEN er.direction 1 THEN er.quantity WHEN er.direction -1 THEN -er.quantity ELSE 0 END ) AS balance_quantity, SUM( CASE WHEN er.direction 1 THEN er.amount WHEN er.direction -1 THEN -er.amount ELSE 0 END ) AS balance_amount FROM event_resource er LEFT JOIN resource_product rp ON er.resource_id rp.id WHERE er.resource_type PRODUCT GROUP BY er.resource_id, rp.product_code, rp.product_name, rp.unit;第四步把日常对账常用但聚合成本高的统计建成定时刷新任务。比如“应付余额”和“应收余额”这两个指标每天凌晨跑一次物化任务把结果存到单独的统计表。业务查询时优先读统计表需要追溯明细时再回事件表查。这套做法避免了实时 join 大量中间表导致性能问题。3.2 跑通一条采购入库事件流现在我用一条最小数据链路演示整个过程向供应商 S 采购商品 A 共 100 件单价 5 元金额 500 元货已到库。第一步插一条采购订单事件状态为COMMITTED已确定INSERT INTO business_event (event_type, event_no, occurred_at, status, total_amount, remark) VALUES (PURCHASE_ORDER, PO20250315001, 2025-03-15 10:00:00, COMMITTED, 500.00, 采购100件商品A);第二步插入事件-资源明细表示“商品 A 流入库存数量 100金额 500方向为 1”INSERT INTO event_resource (event_id, resource_type, resource_id, quantity, unit_price, amount, direction) SELECT id, PRODUCT, 1, 100, 5.00, 500.00, 1 FROM business_event WHERE event_no PO20250315001;注意这里用了SELECT id FROM business_event而不是硬编码事件 ID是为了保证幂等性和可读性。实际项目里你可以在插入时先查一次event_no是否存在存在就跳过这是防重复执行的基本功。第三步插入事件-代理表示“供应商 S 是此事件的往来单位员工 E 是经办人”INSERT INTO event_agent (event_id, agent_type, agent_id, role_type) SELECT id, EXTERNAL, 1001, SUPPLIER FROM business_event WHERE event_no PO20230315001; INSERT INTO event_agent (event_id, agent_type, agent_id, role_type) SELECT id, INTERNAL, 501, APPROVER FROM business_event WHERE event_no PO20230315001;第四步当仓库实际收货后再插入一条“收货入库”事件并通过event_link把采购订单事件和收货事件连起来INSERT INTO business_event (event_type, event_no, occurred_at, status, total_amount, remark) VALUES (RECEIPT, RECV20250315001, 2025-03-15 14:00:00, COMMITTED, 500.00, 实际入库); INSERT INTO event_link (prev_event_id, next_event_id, link_type) SELECT po.id, rec.id, CAUSES FROM business_event po, business_event rec WHERE po.event_no PO20250315001 AND rec.event_no RECV20250315001;这时候查询库存视图就能看到商品 A 的库存余额为 100 件、金额 500 元。这就是 REA 的直观优势库存数值不是某个“库存余额表”里写死的而是你能从事件流里一步步算出来的。3.3 账实一致性校验的核心 SQL模型落地之后验证它靠不靠得住就看三组校验 SQL。第一组是资源余额校验也就是库存数量、库存金额不能为负除特殊场景外SELECT resource_id, SUM( CASE WHEN direction 1 THEN quantity ELSE -quantity END ) AS balance FROM event_resource WHERE resource_type PRODUCT GROUP BY resource_id HAVING balance 0;这组查询如果有结果说明某笔出库事件对应的入库数量不够扣或者入库事件被误删了优先检查对应的business_event.status是否被错误修改。第二组是事件链完整性校验。比如一张销售出库单如果找不到对应的销售订单事件那说明事件链断了最常见的原因是有人直接在执行层补了出库单而没有走订单流程。校验 SQL 可以这样写SELECT r.event_no, e.prev_event_id FROM business_event r LEFT JOIN event_link e ON e.next_event_id r.id WHERE r.event_type IN (RECEIPT, SHIPMENT) AND e.prev_event_id IS NULL;有结果就说明链断了需要人工核查。这条规则在项目上线后几乎成了每周必跑的任务效果很好。第三组是分派一致性校验。校验的是同一张事件头金额是否和所有事件行金额合计一致。如果确认了所有统计都以行金额为准那这个校验就是用来抓“改单不彻底”的SELECT be.event_no, be.total_amount, SUM(er.amount) AS line_total FROM business_event be JOIN event_resource er ON er.event_id be.id GROUP BY be.id, be.event_no, be.total_amount HAVING ABS(be.total_amount - line_total) 0.01;这三组 SQL 是 REA 模型运行的基本护栏每个月至少跑一次。跑完之后财务和研发都能拿着同样的结果开会不用再争“谁的数据错了”。4. 常见问题与排查实录4.1 多对多中间表如何避免聚合时“算重”REA 的三张中间表看起来漂亮一旦开始做报表第一个遇到的坑就是“重复聚合”。我这里说的不是多表 join 后的笛卡尔积而是更隐蔽的口径问题。举个例子。一张销售出库单同时出了两个商品A 商品 10 件B 商品 20 件总金额 100 元。事件行表里有两行记录各带金额。如果写报表 SQL 时图省事先 join 事件头再 join 事件行最后 sum(事件头.total_amount)你得到的是两个商品各一次 100 元总计 200 元。报表整整多了一倍。我们踩过一次这个坑之后定了一条硬性约定所有涉及金额的统计一律用event_resource行表永不直接 sumbusiness_event.total_amount。头表的金额只允许出现在“单号列表”“详情展示”这样的只读场景。在代码 review 环节这条规则被写进了检查项后来某个月对账差异缩小到零这条约定功不可没。4.2 业务规则到底放哪一层REA 模型只给了骨架它不会告诉你“库存不能为负”这种规则写在哪里。我在实际中的经验是分三层处理最底层的数据完整性约束比如事件号必须唯一、方向字段只能为 1 或 -1、金额不能为负这些直接写在数据库层。用CHECK约束或者唯一索引挡死从物理上杜绝脏数据。中间层的业务规则校验比如“销售出库前确认库存足够”、“采购收货前确认订单已提交”这些放在应用服务层。原因很简单这些规则会随业务变化频繁调整放在数据库触发器里改一次规则就要改一次 schema上线成本太高还容易出现触发器栈嵌套导致死锁。顶层的流程编排规则比如“先收款后发货还是先发货后收款”这属于业务流程决策放在服务编排层根本不需要进数据库。做这个分层的时候我们内部也有过争执后来用一次线上事故分了胜负有同事把“订单金额不能为空”的规则写进数据库触发器结果某次临时需要允许零元订单做促销测试时改触发器的脚本卡在发布流程里多花了四个小时。从那天开始团队就统一了“数据库只守底线不写业务判断”的原则。4.3 和传统 ERP/财务系统如何互相翻译很多团队不会完全推倒原有的 ERP 系统REA 新模型和旧系统之间需要做数据映射。这里我整理了一张我们在模拟项目X里用过的映射表几乎可以直接抄REA 概念传统财务/ERP 概念映射说明Resource商品/资金资源存货科目、现金科目、银行科目资源表对应到科目余额商品资源映射到存货科目资金资源映射到货币资金科目Economic Event经济事件业务凭证、记账凭证一次采购入库事件就是传统库存模块的“入库单”加财务模块的“借存货/贷应付”凭证事件-资源明细凭证分录行事件资源行对应到分录的借方或贷方direction1 即资源增加direction-1 即资源减少Agent代理往来单位辅助核算、部门、员工外部代理映射到客户/供应商档案内部代理映射到操作员和部门事件链单据流转关系、凭证引用关系事件链对应到“采购订单→入库单→采购发票→付款单”的关联关系有了这张映射表REA 模型的数据可以很自然地导出到传统财务报表里。我们实际做的事情是每天把新增事件同步到一个“导出中间表”然后由 ERP 那边的程序读取自动生成记账凭证。映射关系就靠event_resource.resource_type和direction两个字段判断借贷方向。注意翻译映射有一个容易忽略的点——传统科目的余额是一个时点值而 REA 事件流是时期值。导数据时如果直接拿 REA 的累计事件流覆盖科目余额大概率对不上。我建议只导出增量事件让老系统自己更新余额不要用新系统的余额去覆盖老系统的余额。4.4 性能、归档与报表优化REA 模型把大量信息拆到中间表业务跑半年后event_resource表很容易膨胀到几千万行。几千万行并不算多但如果没有合理的查询路径报表就能把库拖垮。我们的优化方案是组合拳。第一按事件时间做分区表按月分区。business_event和event_resource都以occurred_at作为分区键这样查上个月的报表时实际只扫一个分区速度提升明显。第二把日常报表统一改成物化视图每天凌晨重建。实时查询只保留“单号详情查询”和“异常校验”两类核心场景其他所有聚合统计全部走物化结果。第三中间表索引不过度设计但三个复合索引必须有(resource_type, resource_id)、(event_id)、(agent_type, agent_id)。如果查询经常按时间过滤再把occurred_at加到第一个索引前部。我还见过一个挺典型的反面案例。某团队为了让库存余额查询更快专门建了一张“库存余额表”每次出库都在事务里同时更新这张余额表。表面上看查询快了但事务锁冲突直线上升而且一旦出现运行时异常导致余额表和事件表不一致恢复数据变成了灾难。REA 模型鼓励的“余额是派生数据”这个原则从长远看能救你很多次。4.5 事件字段随意扩展导致的“类型沼泽”再补一个我在两种模型之间切换时踩过的坑。REA 模型的business_event.event_type一开始只定义了十几种类型结果业务越接越多有人图省事直接把“退货”“换货”“赠品”“内部领用”都塞进两个通用类型里然后靠备注字段区分。一个月后写报表时前端开发根本不知道该怎么归类只能到处问。后来我们花了三天把所有事件重新梳理了一遍增加了一个sub_type字段并且明确每种子类型的资源影响方向。这件事给我最大的教训是REA 模型是语义模型不是分类垃圾桶。业务上含义不同的事件就算字段长得再像也一定要用不同的类型编码定义否则模型的“自解释性”会越来越差最终又退化成大家各自为政的表结构。结尾整个 rea 项目做下来我最大的体会是REA 模型最值钱的地方不在那几张表而是逼着你在动手写代码之前先把业务语义按“资源、事件、代理”三个维度梳理了一遍。它让开发、财务、业务方用同一种语言描述“一笔业务到底发生了什么”而不是各看各的表、各说各的话。那位架构师听完我讲这套模型之后说了一句话我记到现在传统表结构是为“录入”设计的所以统计一直在打补丁REA 是为“事件”设计的统计只是事件流的一次投影。最后分享一个小技巧如果你正准备给自己的系统做建模规划别一上来就画表结构找个周末拿一张真实的采购单、一张销售单用白纸把“资源、事件、代理”三个维度分别列出来再画清楚事件之间的链条。能把这两张单子的闭环跑通后面的表设计和对账逻辑都会顺畅很多。