REA模型实战:用资源、事件、代理重构业务数据模型
先说点题外话。做ERP、供应链、财务系统或者数据中台的朋友应该都经历过这种场面需求方今天要一张新报表明天加一个统计口径后天又要按事业部拆数。开发团队被迫在订单表、流水表、应收表、应付表之间反复横跳最后连对账都变成几十行SQL拼出来的“祖传秘方”。我自己有一套参考答案想聊一聊REA模型英文是Resource-Event-Agent资源-事件-代理。它不是新出的中间件也不是某个具体框架而是一种把业务动作放到模型核心位置的语义框架。我第一次系统性地用REA是在某电商系统的库存与结算模块重构里最大收获不是表变少了而是所有业务人员终于有了同一张可以讨论的底图。这篇文章会从“为什么流水表越来越拧巴”讲起再展开REA的三要素怎么划、落地成什么表结构以及我们实操过程中踩过的坑和选型边界适合正在做订单、库存、采购、财务、结算类系统的同学参考。1. 为什么我劝你别急着给业务系统设计一堆“流水表”1.1 早期项目的“流水表大法”确实能满足90%的需求刚入行的时候接到一个进销存的后台模块我的第一反应也是建流水表。订单表、出入库记录表、付款记录表、发票表每张表都清晰标注了业务单据对应的字段。查询时左关联右关联再用SUM汇总一下一张报表就出来了。那段时期数据量不大流程也简单这种“一张单据一张表”的做法没有任何问题。直到业务发展起来问题开始冒头。第一个典型问题是历史被覆盖。订单表里一个状态字段从“已下单”改成“已发货”原来的下单时间、操作人、库存占用信息全部被覆盖了。想回答“这批货到底是谁在什么时间确认发出”的时候只能翻日志。第二个问题是跨表拼接业务链条。销售、出库、应收、回款、退款分别存在不同的系统或表中每次做“客户全生命周期分析”都要反复写关联逻辑。业务人员问你“这个客户总共付了几次款、退了几次货、哪次退款跟哪次付款有关”你可能会发现数据模型根本回答不了。这就是典型的“只记录了结果没记录过程”。流水表本质上是一张二维的账本它记录的是“最后变成了什么”而不是“业务是怎么发生的”。1.2 对不上的账很多不是Excel的锅而是模型缺了事件与代理我遇到过最典型的一次对账事故出现在某家企业的销售与收款模块。表面问题是财务说系统里的应收账款明细跟银行流水对不上。开发查了很久发现某些订单被多次退款某些退款又被调整过金额。因为原始表设计只保存了当前生效的应收余额并没有保存每一笔“退款事件”和“冲销事件”。所以一旦有人工改单系统里就只剩下被覆盖后的余额过程全丢了。用REA的思路看这里缺的其实是两样东西一是缺事件。每一笔业务动作比如下单、发货、退款、调整都应该被当作独立的、不可覆盖的经济事件保存下来而不是最终状态的一个字段。二缺代理。这件事是谁发起的跟哪个客户相关由哪个销售负责这些主体信息不应该散落在各个业务表的“操作人字段”里而应该成为与事件直接关联的独立维度。会计学里有一句话记账凭证的价值不在于“数字是多少”而在于数字背后“发生了什么经济事件”。这句话放到系统设计里更准确。你最终要对账、要审计、要做风控靠的不是一张打了各种补丁的流水表而是一条完整的、可复现的业务事件链。1.3 REA不是复杂的理论而是一种划分模型的口诀REA的全称是Resource-Event-Agent核心就三个概念资源Resource企业拥有或控制的、能够带来经济价值的对象比如库存商品、现金、原材料、固定资产、应收账款。事件Event使资源增加或减少的业务动作比如采购入库、销售发货、收款、付款、领料。代理Agent参与这些事件的主体分为内部代理员工、部门和外部代理客户、供应商、承运商。用一个生活化的类比来理解超市里苹果是资源收银台扫码结账是事件结账时站在两边的是顾客和收银员这两位代理。业务系统里翻来覆去做的其实就是这些事谁、在什么时间、让什么资源发生变化。在REA视角下资源、事件、代理不是三张独立的孤表而是相互连接的网络。事件与资源之间是多对多关系决定了库存数量和价值如何变动事件与代理之间是多对多关系决定了责任归属事件与事件之间通过“增-减”关系串联构成完整的业务交换链条。所以REA真正解决的不是建表技巧问题而是逼着你先把业务语言翻译成一套稳定语义再去设计数据表。2. REA建模的三件套资源、事件、代理到底怎么划分2.1 边界不清时怎么办先问三个问题任何一个业务对象拿过来先不要急着建表先问它三个问题它是不是一个需要长期计量和追踪经济价值的对象如果是归为资源。比如库存、现金、固定资产。它是不是一个在某个时间点发生的业务动作如果是归为事件。比如“出库”是动作“出库单”是记录这个动作的结果。它是不是一个参与业务并承担经济责任的主体如果是归为代理。比如采购员、供应商。通过这三个问题很多模糊概念会立刻清晰。举几个容易混淆的例子。“库存数量”是不是资源不是。库存数量是“库存资源”与“入库/出库事件”关系计算出来的结果你可以缓存它但它不是独立资源。“订单”是不是事件这要分两层看。如果订单只是业务审批凭证不直接引起资源增减那它是典型的流程单据更接近于REA里的承诺而不是经济事件。只有当订单真正被提交、发货或收款时才产生经济事件。我把订单建模成承诺把发货和收款建模成事件长期看非常顺手。“客户经理”是不是代理是。但当你在事件表里只存一个operator_id的时候其实丢失了代理在事件中的多种角色语义。同一个销售事件里销售人员、审批人、仓库管理员可能是三个不同代理职责也完全不同。2.2 事件的类型交换型事件与转换型事件事件看起来千变万化但在REA里通常可以分两大类。交换型事件资源在两个代理之间发生转移。典型的是销售、采购、收款、付款。销售的实质是“给客户交货”换回“客户给钱”采购正好反过来。交换型事件天然带有参与双方比如一笔收款事件必然涉及付款方客户和收款方企业。转换型事件企业内部的资源形态变化。典型的是领料、生产、加工。原材料经过生产事件变成了半成品或产成品执行者是内部代理不直接涉及外部交易对手。实际项目中把事件类型定义清楚后续做报表、做财务分析会轻松非常多。因为交换型事件和转换型事件对账口径不一样价值流方向也不一样。很多团队说“我们的业务事件很难建模”其实就是因为没有区分这两种性质。REA里还有一个重要的前置概念叫“承诺”。它不是已经发生的经济事件而是未来经济事件的预期比如采购订单、销售订单、仓库预留。在我们的库存系统里订单提交后、货物实际出库前是必须占用可用量的。这个阶段我不是去插入一条出库事件而是插入一条“承诺关系”用来预占资源。到了真正出库时再把承诺转成正式的经济事件。2.3 代理的粒度内部代理与外部代理都要单独建表我把代理拆成两个维度内部代理和外部代理。内部代理可以是员工、部门、岗位主要用来做责任追踪和绩效考核。外部代理可以是客户、供应商、承运商、经销商主要用来做交易对手分析、信用评估、对账。这里有一个非常常见的坑大家习惯在事件表上放一个“操作人ID”订单表放一个“客户ID”然后把它们当普通字段处理。短期没问题一旦要按人员、按客户去还原完整业务链路就会发现维度散落在各个表中很难复用。在REA设计里事件与代理是多对多关联。一个事件里可能同时出现两个代理谁发起的谁接收的。比如“采购入库”事件发起方是供应商接收方是仓库管理员。它们应该在关系表中成对出现而不是塞进两个冗余字段。我们后来把所有参与主体统一收编到代理表通过一张事件-代理关系表解决所有“谁参与了这件事”的查询。效果非常直接销售业绩统计、采购合规审查、客户价值分析全部变成对关系表的聚合查询。3. 用一个采购入库场景把REA落地成数据库表结构3.1 场景设定某公司采购一批原材料的完整链路为了把抽象概念落到实地我找一个最常见也最经典的场景拆开看采购原材料。这个业务链路大致是这样的采购员与供应商签订采购合同供应商发货仓库收货验货货物入库库存增加财务部门收到发票申请付款出纳付款成功银行账户减少。如果用传统表设计可能是采购订单表、收货单表、采购发票表、付款流水表。每张表各自独立业务链条只能靠外键和中间表人工串联。用REA视角重新划分之后会变得特别干净原材料是资源供应商是外部代理采购员和仓管员是内部代理收货入库是事件它使原材料资源增加付款也是事件它使银行存款减少、应付账款减少采购合同/采购订单是承诺它预占了资金和后续入货的可用性。原来看起来一堆零散单据现在都找到了自己在模型里的位置。3.2 核心表设计比普通流水表多出了哪些表以下是我在项目里提供的关键表结构模板你可以根据自己的业务裁剪。首先是资源表用于记录所有可计量、可追踪的资源对象CREATE TABLE resource ( id BIGINT PRIMARY KEY, resource_type VARCHAR(32) NOT NULL, -- raw_material, finished_goods, cash, account_receivable name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL, unit_price NUMERIC(18, 6), current_balance NUMERIC(18, 2), created_at TIMESTAMP NOT NULL );注意这里的current_balance不应该作为资源的主数据来源它只是一个可重建的快照字段后文我会专门讲。其次是代理表CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- internal, external role_type VARCHAR(32) NOT NULL, -- supplier, customer, warehouse_keeper, salesperson name VARCHAR(128) NOT NULL, organization_id BIGINT, created_at TIMESTAMP NOT NULL );然后是事件表。这是REA模型的枢纽CREATE TABLE economic_event ( id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- receive, issue, pay, collect, transfer occurred_at TIMESTAMP NOT NULL, -- 业务发生时间 recorded_at TIMESTAMP NOT NULL, -- 系统记录时间 description VARCHAR(512), created_by BIGINT ); CREATE INDEX idx_event_time ON economic_event (occurred_at);事件与资源的关系表用来描述每个事件让哪些资源增减了多少CREATE TABLE event_resource ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction VARCHAR(8) NOT NULL, -- in / out quantity NUMERIC(18, 4) NOT NULL, amount NUMERIC(18, 2) NOT NULL );事件与代理的关系表用来描述每个事件的参与主体和角色CREATE TABLE event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, agent_role VARCHAR(32) NOT NULL, -- initiator, receiver, approver, owner CONSTRAINT uq_event_agent UNIQUE (event_id, agent_id, agent_role) );如果你需要处理订单预留、预占库存、资金计划可以再加承诺表CREATE TABLE commitment ( id BIGINT PRIMARY KEY, commitment_type VARCHAR(32) NOT NULL, -- purchase_order, sales_order, reservation resource_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, planned_event_at TIMESTAMP NOT NULL, quantity NUMERIC(18, 4) NOT NULL, status VARCHAR(16) NOT NULL -- open, fulfilled, cancelled );这里最核心的变化是原业务流水表被拆成了“事件表资源关系表代理关系表”。你不再强行维护一张包含了订单、客户、供应商、金额、库存多个角色的宽表而是把业务事实拆成若干最小关系再通过关系表组合出任意视角。从查询角度看表变多了但每条查询都是面向关系的组合而不是面向复杂字段的过滤。这在数据量上来以后反而更好优化。3.3 一条真实采购流入库后财务如何从中得到借贷分录很多人会问一个问题REA模型跟财务总账到底什么关系事件表里没有借贷科目那财务怎么记账我的做法是REA事件是财务凭证的生成源头但不是财务凭证本身。系统会通过一套规则把经济事件翻译成借贷分录。比如“采购入库”这个事件涉及资源“原材料”增加代理是供应商和仓管员方向是in金额为10000。翻译规则就可以配置为原材料资产类增加记借方应付账款负债类增加记贷方。到了“付款”事件由外部代理供应商参与动用的资源是银行存款方向是out。翻译规则变成应付账款减少记借方银行存款减少记贷方。这套翻译逻辑放在一个“分录生成器”里定期扫描REA事件表按照事件类型与资源方向生成凭证。这样业务系统和财务系统通过事件自动衔接不需要业务人员手工在两边重复录一遍。用这种模式还有一个大优势当你的业务规则发生变化时比如税率调整、收入确认条件变化你只要修改翻译层配置不需要回改历史事件。历史事件数据永远是原始事实财务报表随时可以用新规则重新生成。这一点对审计尤其重要。4. REA是复式记账的翻版吗两种模型的衔接与取舍4.1 先理清复式记账的本质复式记账的核心是会计恒等式资产 负债 权益。每一笔交易至少要涉及两个科目并且借贷要平衡。这套机制用了这么多年在于它能保证财务报表严谨、完整。但复式记账有一个固有特点它把所有业务过程都压缩成了科目的增减变化。总账里的每一行只告诉你“哪个科目增加了多少”却很难回答“这笔收入来自哪个客户、哪个产品线、经过了几次审批、对应哪一次出库”。举个例子一笔销售回款在总账里是“银行存款增加、应收账款减少”。如果只看总账你根本不知道这个客户是老客户还是新客户不知道是哪个销售签下来的。这些信息不是没有而是被打散在各个单据系统里需要通过非常复杂的中间表才能串联。4.2 REA与复式记账的关系业务语义层与财务报告层分离我把REA和复式记账的关系理解为两个层级REA负责记录“业务事实”复式记账负责“生成财务报表”。它们是共存关系不是替代关系。REA相当于业务事件的全量底账复式记账相当于从这张底账中提炼出的规范报表。REA保留过程复式记账输出结论。举一个供应链系统里的例子。传统模式下采购、库存、应付、付款分别由不同部门不同的系统维护最终总账汇总。上线REA后所有部门共享同一个经济事件池库存的变化、银行存款的变化都从事件池生成部门之间的数据不再互相对不上。这样做的直接好处是口径统一。总账报表里来的每一个数字都可以向下穿透到具体的经济事件再拆到具体的资源和代理。财务人员不再需要拿着Excel去猜数据来源。为了更直观地对比我整理了一张表格维度流水表/复式记账REA模型记录粒度科目或单据经济事件查询能力适合按科目聚合适合按时间、代理、资源、事件类型任意组合审计能力依赖单据与凭证追踪事件链天然可追溯业务维度较少通常只有摘要资源、代理、事件关系明确实现复杂度较低初期建模成本较高对账能力依赖对账脚本以事件为主线对账数据回放不支持可根据事件重建任意时点状态当然REA不会也不能取代总账系统。财务合规上借贷平衡的账务记录是必须的。合理做法是让REA作为业务系统与总账之间的桥梁事件层负责语义报告层负责合规。4.3 从REA到扩展REA价值链与计划/执行分层REA本身可以扩展。我实践过程中最受用的是扩展REA的两个方向。第一个方向是价值链分析。如果把企业所有交换型事件和转换型事件链接起来从采购原材料到生产加工再到销售回款就形成了一条完整的经济链。你可以在任意节点计算资源增值也可以分析某个环节的成本与效率。这个能力对于供应链管理、成本分析、经营决策系统非常有价值。第二个方向是计划层与执行层分离。REA原本更多面向已经发生的经济事件但扩展REA引入了“计划层”用来表示未来可能发生的经济事件预期。在我的库存预占场景里“销售订单”就是计划层的承诺“实际发货”才是执行层的事件。两相对照可以清楚看到承诺兑现率、交付延迟、库存占用等指标。这一层关系很像你在日历上先写下一场会议计划到了时间再去参加执行的区别。计划层不产生真实资源增减但会在资源上形成“冻结量”影响可用量计算。5. 上线REA模型之后我踩过的四个坑5.1 坑一把所有“操作日志”都当事件导致表数据虚胖第一次建模时我犯过一个很天真的错误把用户的所有操作都当作经济事件写进事件表。订单审批、页面查看、状态流转、备注修改甚至导入文件也算事件。结果事件表膨胀得非常快而且大量事件的资源关联、代理关联字段都是空的。查询经济报表时还要过滤掉一堆无意义数据性能一路下滑。后来我才把概念重新收紧经济事件必须涉及资源增减或资源价值的实际变化。审批、流转、备注这类动作本质是流程状态不是经济事件。它们应该放进独立的审计日志表或流程表里。现在我在建模时会针对每个候选事件问一句这个动作是否引起资源的入、出、增值或减值如果答案是“否”它就是普通操作记录。5.2 坑二事件表没有采用“双时标”导致追溯对账困难事件最初设计时我只存了一个创建时间。后来业务方要求支持补录和延迟上报问题就来了。比如一批货是上个月底实际发出的但这个月月初才录入系统。如果报表看的是创建时间这个出库事件就会错误计入本月导致上月库存与本月期初库存对不上。正确设计是给每个经济事件保存两个时间业务发生时间和系统记录时间。查询时按哪个时间口径由业务规则决定。库存计算用业务发生时间系统审计用系统记录时间财务报表则以业务发生时间为准。这个双时标设计一开始做起来会觉得麻烦但越到后期越香。尤其是涉及多个系统之间同步事件时没有双时标几乎没办法解释数据漂移。5.3 坑三幂等控制与重复入账分布式系统里一个事件从上游发送到下游可能因为网络重试、消息重复消费、人工补推等原因被消费多次。如果在入库时不做幂等控制同一个经济事件会被记录两次库存虚增、应付虚增都是连锁反应。我们的解决方案是给每条事件增加一个业务唯一键通常是上游单据号加操作类型。事件表上建唯一索引插入前先查重。如果重复直接返回已有事件不再创建新的资源关系。有些团队会觉得“数据库唯一索引能挡”实际上不够。REA事件往往涉及多张关联表插入顺序不同可能产生死锁或部分脏数据。稳妥做法是先在一个“事件防重表”里占坑再写业务数据事务提交后再清理。这块不花时间后面对账会付出十倍代价。5.4 坑四资源表直接维护“当前数量”字段与事件明细互相矛盾为了查询省事我在资源表上加了一个current_balance字段每次事件发生后同步更新。看起来完美但如果某个事件被冲销、被重放、被补录这个字段就和明细对不上了。后来我强制自己接受一个原则资源表里的数量字段只是缓存不是数据源。一切增减明细以event_resource表为准。查询实时库存时要么按事件明细动态汇总要么通过物化视图定期刷新并在后台定时跑对账任务把缓存值与明细汇总值比对。另外冲销事件时不要直接删除原事件而是插入一条“反向事件”。比如出库记错了新增一条入库事件来冲回。这样审计链完整报表也能解释差异从哪来。6. 什么场景适合用REA选型边界与替代方案6.1 适合REA的领域REA并不是银弹但它特别适合这几类系统第一电商或零售中台。订单、库存、支付、退款链路天然就是一系列经济事件而且参与代理多、对账要求高、审计需求强。第二供应链与ERP。采购、生产、销售、库存需要全流程追踪同时还要做资源转换和价值链分析。第三财务与结算系统。如果你们正打算自研一套统一账务中台用REA事件层作为底层事实表再向上生成会计凭证是在一致性上比较靠谱的路径。第四数据中台。如果你需要把不同来源、不同口径的业务数据统一成一套可分析模型REA可以当作业务事实的标准语义层比直接建一堆明细宽表更有生命力。6.2 不适合REA的领域及替代方案反过来有些场景直接用REA会显得笨重。纯内容管理系统、简单To C应用不需要完整经济事件语义用传统的数据建模更灵活。如果公司已经有成熟的总账系统和财务共享中心也不需要再造一套REA更合适的是做接口对接把业务系统生成的凭证推送给总账。还有一个容易混淆的场景是“需要审计日志”。很多人觉得审计日志和REA很像但实际上审计日志只需要追加写入不要求资源增减和代理关系建模。这个时候事件溯源甚至简单的append-only表会比REA轻量得多。6.3 落地阶段的建议小步快跑最后给一个落地思路不要试图第一天就把所有业务表都改成REA模型。挑一条核心链路试跑比如从采购到付款或者从销售到回款要求团队把这条链路上所有既有操作列成清单逐个判断它们是不是经济事件、涉及哪些资源和代理。我强烈建议先做一张业务-REA映射表把原有业务表、字段、操作与REA要素对应起来。这张表既是团队沟通的词典也可以作为未来扩展的索引。我在某个项目中靠这张表把原本要开三次会的方案沟通缩短到了半天因为大家对着具体字段讨论比对着抽象概念争执高效得多。最后分享一个小偏好。如今我在做业务系统领域建模时都会先打开“事件字典”而不是数据库设计工具。把公司已有的业务操作整理成一张二维清单动作名称、是否涉及资源增减、涉及哪种类型的事件、参与代理是谁。这个动作成本极低但能逼着业务和技术把“到底什么是事件”说清楚。很多困扰已久的对账问题往往在这个阶段就露出了真正的答案。