REA模型实战:资源-事件-代理人重构订单交易与对账体系
如果你翻过我之前的项目复盘应该记得我一直在折腾企业内部的数据建模。这次要聊的标题是 rea也就是 Resource-Event-Agent资源-事件-代理人模型。我最近在内部代号为“某订单中台”的模拟项目里把一个订单交易模块从传统的借贷流水模型切换到 REA 模型过程踩了不少坑但最终换来的是事件不丢、职责清晰、跨部门对账不用再靠拍桌子。这篇博客就是把这套实践拆开说说为什么选它、核心结构怎么拆、落库怎么设计、以及最容易被忽略的坑。适合正在设计业务数据模型或账务系统的开发同学也适合想理解“为什么技术这么建模”的产品经理一起看。1. 整体设计与思路拆解1.1 为什么把传统借贷模型换成 REA传统业务系统的账务核心绝大多数是复式记账借一笔、贷一笔金额相等报表能平。但“能平”不等于“看得懂”。一旦要回答“这个收入是哪个客户带来的对应的成本是哪批货的”往往要左联交易表、右联订单表、再跑去库存系统捞半天数据。业务人员要一个数开发就得现写一个接口结果每个报表口径都不一样月底对账变成多方拉锯战。REA 模型的做法完全不同。它不把“账”作为第一实体而是先把业务底层拆成三个要素资源Resource、事件Event、代理人Agent。一笔业务发生了先记录是谁、在什么时间、对什么资源、做了什么动作再把资源流的增减挂到事件下面。这样查询口径天然统一因为所有业务数据都长在同一棵树上。一个生活化的类比传统复式记账像只拍下账户余额变化的快照月底对余额平就结束了REA 更像是给仓库装了持续录像的监控每一件货怎么进来、怎么出去、经手人是谁、对应哪笔资金流动都能从头回放。这也是我决定切换模型的核心原因——我们希望未来的系统能回答“为什么”而不是只回答“是多少”。1.2 REA 的核心实体与关系资源、事件、代理人REA 并不是三个简单的表而是一组稳定的业务抽象。资源Resource库存、现金、固定资产也包括无形资源比如客户信用额度、可用的服务工时。资源必须能被计量和估值。事件Event有经济意义的动作比如采购入库、销售发货、收到货款、退货。事件是 REA 中最核心的部分也是审计追踪的锚点。代理人Agent参与经济事件的个人或组织包括内部代理人员工、部门和外部代理人客户、供应商。三者之间的关键关系也有三类。资源流Resource Flow描述资源从事件中流入或流出双重性Duality把两个互补事件绑在一起比如销售和收款、采购和付款控制Control定义代理人与事件之间的职责关系。举个例子。客户下了一张 100 元销售订单仓库发出价值 80 的货客户支付 100 元现金。传统系统会生成一张销售单加一张收款单两个单据各自独立REA 里则是一个销售事件、一个收款事件销售事件下有商品资源的流出收款事件下有现金资源的流入客户作为外部代理人同时关联两个事件销售员作为内部代理人关联销售事件。事件之间的 dual 关系把销售和收款连成一条业务链。这样后续做应收分析时直接沿着 dual 关系找就不会出现“钱到账了却不知道对应哪笔销售”的尴尬。2. 核心细节解析与实操要点2.1 从业务单据到事件流的映射方法大部分系统里的订单、发货、发票、收款是各自独立的表每张表有自己的主键和状态位。开始 REA 建模时建议先忘掉这些表单形态转而问一个问题这项业务产生了几类经济事件我习惯先列一张事件字典把所有业务动作变成动词承诺、销售、发货、收款、退货、采购入库、付款。每个动作就是一个事件类型。接着做一步关键的抽象把“单据”降级为“事件载体”。一张发货单不是事件本身而是发货事件的录入界面和数据载体销售订单也不是销售事件而是承诺事件的呈现。这样每个单据的明细行最后都会落到资源流表里而单据的状态字段可以被事件完成状态替代。正规的 REA 实现甚至不需要“订单状态”字段因为事件字典本身就能表达“是否已承诺、是否已履行、是否已收款”。这里有一个容易犯的错误试图给每个单据类型抽象一个事件类型。比如把发货单当成“发货单事件”把发票当成“发票事件”。实际上一张发货单可能包含多个资源应该对应同一个发货事件只是资源流有多行。如果你建了“订单事件表”“发货单事件表”“发票事件表”三个事件表那就退回到单据建模了REA 的价值也就没了。2.2 资源与事件的粒度设计资源粒度是一个绕不开的决策。按 SKU 还是按批次我个人的实践建议是只要存在退货或质量追溯至少拆分到批次序列号级一般用于高单价资产设备比如医疗仪器或大型机械。如果只做到 SKU 级别看起来简单但出了客诉想定位是哪批原料的问题时只能干瞪眼。事件粒度同样需要仔细拿捏。事件应该忠于业务发生的动作次数。一个销售订单允许分三次发货就有三个发货事件每次发货触发库存资源流出每次收款对应一个收款事件。不要为了“简化”把整个订单合并成一个事件那样粒度太粗后续没法定位是哪一批货出了问题。但同时也要警惕过度建模。有人会把“仓库位置变化”也建模成事件这通常不是经济事件因为位置移动不产生资源价值的增加或减少。更合理的做法是用资源上的状态字段去记录库位而不是每一次移动都造一条独立事件。否则资源流表里会混入大量无效行查询性能和可读性双双下降。判断标准很简单这个动作是否改变了资源的可用数量、价值或归属如果都没有它就不是经济事件。2.3 参与者角色建模与权限控制Agent 表和系统登录用户表要分开。用户是能打开电脑操作系统的人Agent 是业务链条上的责任主体。一个人可以由多个 Agent 身份比如既是内部销售人员又是审批人。在 REA 中Agent 表的每条记录代表一个可追溯的业务参与方后续报表要按区域、按团队统计时需要 join agent 表而不是直接 join 用户表。权限控制可以延伸到事件层。因为事件与代理人本身就有关联关系所以可以方便地做到事件级权限只允许具有“仓库管理员”角色的 agent 创建发货事件只有“财务专员”角色可以创建收款事件。这种模型天然支持事件级审计每个事件记录上都有 participated agent 关联谁发起、谁确认、谁审核都在同一张关系表里。我在实际项目里还加了一个“职责类型”字段比如 initiator、approver、customer、seller。这样一条事件可以挂多个代理人和多个角色不会出现“单据上是两个人做的数据库里只有创建人”这种信息丢失。简单说Agent 关系的正确建模让后面所有“按人追责”的报表都不需要重新补数。3. 实操过程与核心环节实现3.1 数据库表结构与关系定义说了这么多理念落到数据库上其实并不复杂。我建议至少准备以下这些表resources资源、agents代理人、events事件、event_agents事件-代理人关系、resource_flows资源流、event_links事件关联。下面是一套通用建表脚本不绑定特定数据库产品。-- 资源表 CREATE TABLE resources ( id BIGINT PRIMARY KEY, code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- sku / batch / cash / receivable unit VARCHAR(16), status VARCHAR(16) DEFAULT active ); -- 代理人表 CREATE TABLE agents ( id BIGINT PRIMARY KEY, code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, agent_type VARCHAR(16) NOT NULL, -- internal / external role VARCHAR(64) ); -- 事件表 CREATE TABLE events ( id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, occurred_at TIMESTAMP NOT NULL, recorded_at TIMESTAMP NOT NULL, source_system VARCHAR(32), source_id VARCHAR(64), description VARCHAR(512), UNIQUE(source_system, source_id) ); -- 事件-代理人关系 CREATE TABLE event_agents ( event_id BIGINT NOT NULL REFERENCES events(id), agent_id BIGINT NOT NULL REFERENCES agents(id), role_type VARCHAR(32) NOT NULL, -- initiator / approver / customer / seller PRIMARY KEY (event_id, agent_id, role_type) ); -- 资源流表 CREATE TABLE resource_flows ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES events(id), resource_id BIGINT NOT NULL REFERENCES resources(id), quantity DECIMAL(18,4) NOT NULL, direction VARCHAR(8) NOT NULL, -- inflow / outflow unit_value DECIMAL(18,4), amount DECIMAL(18,4) ); -- 事件关联表 CREATE TABLE event_links ( event_id BIGINT NOT NULL REFERENCES events(id), linked_event_id BIGINT NOT NULL REFERENCES events(id), link_type VARCHAR(32) NOT NULL, -- duality / fulfills / cancels PRIMARY KEY (event_id, linked_event_id, link_type) );我专门把 resource_flows 独立成表而不是在 events 表里放一个 resource_id因为一个事件经常包含多条资源流。比如一次采购会同时影响库存和应付一次销售发货会影响库存和应收。独立表还能让后续按资源维度做汇总性能更好。events 表上的 UNIQUE(source_system, source_id) 是实战中最重要的设计之一。它的作用是幂等同一个来源系统、同一条来源记录只能产生一个事件。后面讲到重复消息处理时这个唯一键能救命。3.2 一个完整事件链的实现销售、发货、收款用一个例子把事件链串起来。假设销售订单 SHIP-20240610-003向客户卖出价值 600 元的商品客户之后支付了 600 元现金。在 REA 模型里至少会产生两个事件而不是一张销售单和一张收款单。第一个事件是销售/发货事件 E2001它同时产生两条资源流商品库存资源 R88数量 2方向 outflow金额 600表示库存资源减少。应收账款资源 R90数量 1方向 inflow金额 600表示公司拥有了向客户收款的权益。第二个事件是收款事件 E2002它也产生两条资源流现金资源 R91方向 inflow金额 600。应收账款资源 R90方向 outflow金额 600结清应收。这两个事件之间再用 event_links 建立 duality 关联。这样从 E2001 就能直接跳到 E2002看到这笔销售最终有没有回款。下面只展示 E2001 的插入片段E2002 结构相同。BEGIN; INSERT INTO events (id, event_type, occurred_at, recorded_at, source_system, source_id) VALUES (E2001, sales_delivery, 2024-06-10 10:00:00, now(), order, SHIP-20240610-003); INSERT INTO event_agents (event_id, agent_id, role_type) VALUES (E2001, 203, customer), (E2001, 112, seller); INSERT INTO resource_flows (event_id, resource_id, quantity, direction, unit_value, amount) VALUES (E2001, 88, 2, outflow, 300.00, 600.00), (E2001, 90, 1, inflow, 600.00, 600.00); INSERT INTO event_links (event_id, linked_event_id, link_type) VALUES (E2001, E2002, duality); COMMIT;这里我有一个经验resource_flows.amount 我统一用正数方向由 direction 字段表达。这样处理库存时不会混淆“负库存”处理应收时也不会出现一个字段里正负含义随时变化的状况。金额的借贷含义留给财务映射层去解释业务层只关心方向。整个流程必须放在同一个数据库事务里。如果 event 插入成功但 resource_flow 失败回滚后什么都不会留下。这也是 REA 实现里最容易踩的坑事件和资源流必须同生共死。3.3 与财务系统的对接设计与预聚合REA 模型不是用来替代财务系统的而是作为业务中台的事实底座。财务系统依然需要复式记账凭证只是凭证可以由 REA 自动生成。我实现的方式是加一个映射服务读取事件和资源流后生成会计凭证资源流入的方向生成借方分录资源流出的方向生成贷方分录。比如销售发货事件 E2001 的两条资源流映射出来就是应收账款增加借记应收账款 600。销售收入确认贷记销售收入 600。库存成本结转则通过另一个成本事件处理不会和销售收入混在同一个事件里。这个映射的好处是财务同学可以随时从凭证号反查到一个具体事件再通过 event_agents 找到客户和销售员把业务与财务真正打通。查询性能方面REA 明细表 join 多但业务报表不需要每次跑全量大表。建议建一个按天和按代理人的预聚合物化视图类似下面这样CREATE MATERIALIZED VIEW daily_agent_flow AS SELECT e.occurred_at::date AS biz_date, a.id AS agent_id, e.event_type, SUM(rf.amount) AS total_amount, COUNT(DISTINCT e.id) AS event_count FROM events e JOIN event_agents ea ON ea.event_id e.id JOIN agents a ON a.id ea.agent_id JOIN resource_flows rf ON rf.event_id e.id GROUP BY biz_date, a.id, e.event_type;再配合索引策略events 表建 (occurred_at, event_type) 复合索引resource_flows 表建 (resource_id, occurred_at) 复合索引。实时报表只查最近几天历史报表查物化视图。这样的分层设计能保证模型不被性能问题逼回旧结构。4. 常见问题与排查技巧实录4.1 事件已记录但资源状态没更新不少团队第一次落地 REA 时会遇到一个怪现象报表里销售金额已经算进去了库存却显示没扣。多数根因是资源流表和库存汇总表没有放在同一个事务里。REA 中的 resource_flows 是事实表库存汇总只是派生物必须保证写入事件时同时写入对应资源流并更新汇总表。我的做法是把“写事件 写资源流 更新汇总”放在一个事务里并且在汇总表加一个 version 字段用乐观锁防止并发扣错。如果更新汇总时受影响行数为 0说明数据已被修改整个事务回滚重试。这样能在源头上避免“财务有数、仓库没数”的经典事故。4.2 重复事件导致金额翻倍症状大家应该很熟某客户同一天收到两条一模一样的销售记录金额翻倍。常见于消息队列重试接口调用方重推了两次。REA 的标准解法就是 events 表上的 UNIQUE(source_system, source_id)。所有外部写入都必须带上业务来源 ID不能只靠数据库自增主键。插入时用数据库的“冲突不插入”语法或者先做幂等查询再决定是更新还是忽略。我建议强制要求所有 MQ 消费者和接口层在写入前就检查唯一键而不是等到底层报错才知道重试了。这个规则越早定后面越省心。4.3 联表查询性能下降怎么办REA 标准模型天然有多表 join事件日志量大之后性能一定会成为话题。但不要一上来就做复杂的宽表视图应该先定位 top N 慢查询。大多数情况无非是时间范围没走索引、按 agent 维度统计没有预聚合。先把 events 表和 resource_flows 表的复合索引建齐再把日汇总物化视图跑起来绝大多数报表问题都能解决。如果还不够可以按月份做分区表或者用异步任务把明细转成另一张只读分析表。这里我要特别强调不要为了省事给业务表加一堆冗余字段去破坏模型。加汇总表和宽表可以改事实表结构要谨慎否则后续所有口径都会跟着乱。4.4 团队认知不一致怎么推动最后这个问题比技术难。业务方习惯说“订单表、发货单表”你一上来就讲“资源、事件、代理人”对方会怀疑你在搞学术。我在项目里的推动技巧是先把术语翻译成业务动作让业务方用带动词的方式描述流程比如“采购入库、销售发货、收到付款、退回货物”。然后把这些动词直接落到 event_type 字典里展示同一张表如何统领所有动作。当业务方看到事件链可以直接追溯到具体客户和实物批次时认同感会明显上升。不要试图用 ER 图说服对方要用活生生的业务流程说服。REA 的落地有一半是建模问题另一半是沟通问题。最后说一个实操体会REA 不是银弹它也解决不了业务动作本身边界不清的问题。我在模拟项目里最大的收获不是表结构而是设计流程先定义事件字典再讨论资源粒度最后才落到建表。顺序一旦反了就会变成给旧系统换皮。如果你手里也有一个账目混乱的业务模块不妨先用笔在纸上把所有带动词的业务动作列出来标出每个动作会改变哪类资源的哪个属性然后再打开数据库。项目代号 rea 我们内部一直沿用团队笑称它是“重”重定义业务规则重建对账信任。这句话到现在依然适用。