
有朋友发给我一个项目标题rea。没有正文没有需求说明只有这三个字母。我盯着看了几分钟心想这不就是我在好几套进销存和信息管理系统里反复使用的那套建模方式吗Resource资源、Event事件、Agent参与者。如果你在画数据模型或者设计单据流的时候老是觉得“余额表一堆业务表”的套路放哪里都不太顺手那 REA 这个模式值得认真看一遍。它解决的是很多软件在成长过程中最容易被绕进去的问题到底依据什么来记录业务、如何追踪变化、怎样让不同模块共用同一套数据底座。我不会空谈理论我会以自己最近整理的一套库存销售模型为例从头拆掉“传统台账业务单据”的惯性设计换成以事件为唯一事实来源的模型再把表结构、计算口径和踩坑过程一并写出来。对正在做 ERP、订单系统、财务系统、进销存或任何需要业务与资金双重追溯的开发者来说这套思路可以直接抄作业。1. “REA”不是乱码资源、事件、参与者三要素拆解1.1 资源不是科目表也不是物料台账很多人在建模时第一反应是按照业务台账建资源表例如库存表、现金表、固定资产表。但在 REA 语境中资源指的是“经济实体期望获得或消耗的任何东西”范围比我们平时说的物料广得多。我举一个例子一套销售系统里商品是资源现金也是资源一套服务型系统里工时是资源专项服务成果也是资源甚至客户的“积分余额”也可以建模为一种资源。关键在于资源是必须能被计量、能参与进出流转的对象。我在实际项目里习惯给资源表加一个资源类型字段比如 INVENTORY / FINANCE / LABOR / POINT而不是把每个资源做成一张独立的物理表。好处很明显以后想加新的可交易对象时只要插入一条资源数据就不需要新增一个模块。真正容易翻车的地方在于资源不是“存量”它只是一个可以被事件引用的描述对象。比如库存 100 件这 100 件不应该是一个字段的“现值”而应该是历史事件流推导出来的结果。如果系统里有人直接修改了库存数字那 REA 模型就已经被污染了这也是后面核算账实不符的核心原因。换句话说资源表里放的是“它是什么”而不是“它现在有多少”。1.2 事件业务真相的唯一落点事件是整个 REA 模型最核心的要素。它不是界面上的按钮也不是打印出来的单据而是导致资源增加或减少的一次经济行为。例如“向客户发货”“收到供应商发票”“支付工资”“盘点调整”这些都是事件。一个事件通常至少关联两个方向的资源流一笔销售事件会减少库存这种资源同时增加应收账款或现金这种资源。库存被减少、应收账款被增加这两条资源变更都必须挂在同一个事件上业务才闭合。这种设计带来一个非常实用的好处以后做任何统计要么就是查资源的当前余额要么就是查事件的明细流水不会再出现“库存表一个数、财务表一个数、业务表又一个数”的三账对不上。事件还必须带有时间信息。我在设计时会给事件表独立的事件时间而不是完全依赖创建时间。理由很简单系统记录创建时间和业务实际发生时间往往不一致财务结账时最忌讳用后台上架时间倒推业务发生时间。如果做不到业务时间和系统时间双记录至少要在事件表上留一个可配置的备用策略。1.3 参与者人和组织的统一抽象参与者就是事件中的主体常见的有客户、供应商、员工、部门、法人组织甚至是一个自动流程。参与者模型里最容易被忽略的是“角色”。同一个客户在采购事件里是“买方”在退货事件里是“申请退货人”在售后事件里可能变成“受服务对象”。如果系统直接把客户信息一股脑塞进订单表后面做多维分析时就会非常痛苦。我的做法是事件表通过一张事件参与者关系表与参与者表关联关系表上保存参与者角色。这样参与者本身可以复用角色的多样性也能保留下来。这里面还有一个设计取舍参与者是否需要区分“外部参与者”和“内部参与者”。我建议保留一个参与者类型字段涉及客户、供应商等外部对象时再把对应的企业资料单表外键回填涉及员工、部门等内部对象时同样用类型区分。统一参与者的好处在权限和多组织场景下特别明显一个账号在多个公司主体下扮演不同角色只需要在关系表里记录角色职责不需要复制个人信息。2. 为什么传统账套模型在供应链场景里撑不住2.1 台账式模型的三个盲区很多早期系统采用的是科目余额表加业务单据的方式有一张库存余额表每发生一次出入库都更新余额有一张应收余额表每开一张发票就更新余额订单表只管业务过程与财务表只做一次性对账。看着简单实际维护成本很高。第一个盲区是“重复存储”。业务发出销售单库存模块减少库存应收模块增加应收财务模块又要写一份凭证。同一个事实被复制了三份只要任何一份因为异常没有执行系统内部就会产生差异而这种差异往往要等到月末盘点才能发现。第二个盲区是“过程不可还原”。余额表只反映最终数字不保留演算过程。比如月底突然发现库存少了两件系统里查不到是哪个事件把它减掉的想追溯就得靠日志和人的记忆力。第三个盲区是“模块壁垒”。库存、财务、采购各自维护一套主数据资源编码、对象标识都不同。跨模块对账的时候只能用名称判断是不是同一个东西一旦命名稍有出入就得人工介入。REA 由于用统一的事件关系来连接资源和参与者天然规避了这三类问题因为同一套资源基础数据和事件流可同时服务于多个模块。2.2 与三式记账思想的契合点这里顺带说一个容易被忽略的点REA 模型虽然出身于会计领域的数据建模但它并不强制要求每个事件都要写成借贷分录。它强调的是“双向资源流”和“事件之间存在经济上的因果关系”。比如一条生产领料事件在传统凭证里是借“生产成本”、贷“原材料”但在 REA 里只需要记录原材料减少、在产品增加。至于“生产成本”这个科目对应的是哪个抽象层次完全可以在报表生成时通过规则映射推导不需要在业务发生时硬编码进表里。因此当业务人员问“我们以后科目变了怎么办”时REA 的答案通常是底层事件不变顶层科目视图重配就可以了。这也是我越来越倾向用 REA 而不是凭证驱动的系统来承载进销存底层数据的原因。2.3 REA 能解决什么问题解决不了什么问题REA 很适合企业核心业务链路的重构多模块统一的账户体系、跨系统对账、审计追溯、跨组织合并。但 REA 并不适合做傻事比如把它当成现成的报表工具或规则引擎。REA 负责把数据记录成可靠的事实可依模板出发的分析维度仍然需要另行搭建视图层。也就是说REA 解决的是“数据不乱、来源可溯、口径一致”的问题至于最终核销时的动态定价规则、分摊优先级需要在服务层写策略。这点如果一开始就明白就不会掉进“把模型搞得很漂亮却做不出具体查询”的坑里。3. 从一个标题到一个可运行模型库存销售真实案例3.1 先梳理业务事件链再画表拿我最近处理的一套小工具系统举例它主要负责库存与销售。我不急着设计数据库先把业务事件链条列出来采购收货、采购退货、销售下单、销售发货、销售退货、收款、付款、盘点调整、报废处理。每个事件都对应资源变动然后事件才成为表。这张事件清单非常关键因为它决定了系统边界。销售下单这个事件如果会引起库存的“可用量预占”它会改变资源的事件份额如果不需要预占就只是创建订单但不发生资源移动。开发前确定这个事务边界会影响整个库存公式。我在这套系统里选择销售下单只创建销售事件状态不实际扣减库存销售发货才是真正的资源流出。这样既保留了下单时的业务留痕又不让事件数据库里出现“占用”虚拟数据后续统计简单很多。3.2 资源表、事件表、参与者表的物理设计在物理实现上我直接用三张核心表加两张关联表避免一开始就拆出无数张模块表。下面是表结构的核心片段表名前缀分别用 res、evt、agt、rl_ 区分资源、事件、参与者和关联关系。CREATE TABLE res_resource ( resource_id BIGINT PRIMARY KEY, resource_code VARCHAR(32) NOT NULL UNIQUE, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(16) NOT NULL, unit VARCHAR(16), status SMALLINT NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE evt_event ( event_id BIGINT PRIMARY KEY, event_no VARCHAR(40) NOT NULL UNIQUE, event_type VARCHAR(30) NOT NULL, event_time TIMESTAMP NOT NULL, business_date DATE NOT NULL, description VARCHAR(500), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE agt_agent ( agent_id BIGINT PRIMARY KEY, agent_code VARCHAR(32) NOT NULL UNIQUE, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(16) NOT NULL, status SMALLINT NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );然后是事件与资源的关系表。这张表是整个模型的落点必须放上资源方向、数量、单价、金额。CREATE TABLE rl_event_resource ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction SMALLINT NOT NULL, quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4) NOT NULL, amount DECIMAL(18,4) NOT NULL, remark VARCHAR(255), CONSTRAINT fk_evt_res_event FOREIGN KEY (event_id) REFERENCES evt_event(event_id), CONSTRAINT fk_evt_res_res FOREIGN KEY (resource_id) REFERENCES res_resource(resource_id) );事件与参与者的关系表也必须单独拆出来CREATE TABLE rl_event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role_code VARCHAR(32) NOT NULL, CONSTRAINT fk_evt_agt_event FOREIGN KEY (event_id) REFERENCES evt_event(event_id), CONSTRAINT fk_evt_agt_agent FOREIGN KEY (agent_id) REFERENCES agt_agent(agent_id) );这里强调一个细节金额amount到底是我直接用 quantity × unit_price 计算还是手工录入我的建议是在应用层把 amount 处理为“带方向”的可信值。因为现实中销售事件的数量和单价不一定都对应同一个单位可能存在折扣、含税价和本币折算直接在库里做简单乘法反而不如依赖既定业务口径更准确。3.3 库存余额和销售额的计算口径用这张三核心表推库存余额非常简单。由于 rl_event_resource 里 direction 用 1 表示流入资源-1 表示流出资源对一个资源当前库存余额直接按“流入-流出”汇总SELECT r.resource_code, r.resource_name, SUM(CASE WHEN rel.direction 1 THEN rel.quantity ELSE 0 END) AS in_qty, SUM(CASE WHEN rel.direction -1 THEN rel.quantity ELSE 0 END) AS out_qty, SUM(rel.direction * rel.quantity) AS stock_qty FROM res_resource r LEFT JOIN rl_event_resource rel ON r.resource_id rel.resource_id GROUP BY r.resource_code, r.resource_name;这个查询没有 WHERE event_time 条件代表的是“当前全量库存余额”。如果你想看某个月底的快照可以再引入截止日期子查询只统计该日期前发生的事件。REA 模式下这种账期快照天然可靠因为它其实就是一次带时间过滤的事件汇总。销售额更直观。销售发货事件的流出方向带着销售这款商品的资源记录筛选 event_type 和 direction就能得到销售额。应收款余额则用一个相反口径的事件资源记录汇总出来。两块收入来自同一张底层表却可以按不同角色分摊重算这是传统固定视图比较难做到的。3.4 在代码里怎么落地这套模型的约束物理表建好后代码里的关键不在于增删改查而在于业务节点的一致性控制。以采购收货为例应用层应该在一个数据库事务内完成三件事创建事件记录写入资源流入关系写入参与者关系。任何一步失败事务回滚否则会出现只有事件没有资源变更的脏数据。我的建议是用一个封装好的事件服务所有资源变动都走这个服务阻断任何直接修改资源表的入口。资源表本身不提供库存字段库存永远由事件关系表派生这样就算业务人员误操作也只会多出一条事件流水不会把余额数字改得对不上。这种“不存储余额、只存储事件流水”的思路我和同事在实际运维中已经碰坏过几次越到后期越值钱。4. 实操中容易踩的五个坑含排查方法4.1 资源字段误当成科目字段刚上手 REA 的时候最容易把 resource 表当科目表用。例如把“主营业务收入”作为一个资源商品也是一个资源再用一个事件把它俩连起来。听起来挺对但用起来很别扭因为收入其实是多个资源事件的汇总值不是一个可以单独“进”“出”的存量对象。我的修正办法是资源表只放可被移动、可被消耗、可被产生的具体对象像商品、现金、原材料、工时这类金额、收入、费用这类指标放到视图层或报表模型里去推导。若硬要放进资源会出现一个底层事件要关联几十个资源的情况可读性和性能都会被打折。4.2 事件和资源之间做了单向关联导致反向追溯断了有人设计关系表时只在事件表里记了一个 resource_id表示这个事件影响哪个资源。一旦发生销售退货你也想在原销售事件上追加负数行但原事件可能已经只关联了少量资源无法表达多个资源联动。正确处理是保留事件-资源中间表并带上 direction。每个事件可以有多行资源流每行资源流都能反向找到完整事件头。这样设计后反向追溯就是一条 SQL 的事拿资源 ID 去找 rl_event_resource再按 event_id 回到事件表。前后方向都能走通不需要额外建冗余列。4.3 事件使用同一个参与者去扮演多重角色第三个坑来自参与者关系。客户和业务员在销货事件里是两个不同参与角色如果开发图方便直接在事件表里放 buyer_id、seller_id 两个字段后续这事件想要关联审批人、仓库员时就得不断加字段。字段一多事件表就变成一个巨大宽表。我在实际模型里坚持使用角色码并在应用服务里对同一事件的同一角色做唯一性校验。这样既避免宽表也让查询维度可扩展。角色码表里存什么完全由业务动态配置调节不用频繁改表结构。4.4 汇总口径出现重复计算REA 虽然数据干净但报表查询写不好也会重复计算。典型情况是统计销售额时把销售订单事件和发货事件都算了一遍。因为销售下单事件没有扣库存只有销售发货事件才扣库存可一旦下单和发货同时记录了金额金额实际只应归属发货事件下单事件只是流程占位。我在报表层干脆约定所有金额类指标都挂在真正发生资源移动的事件上流程状态不直接参与财务汇总。这条约定能挡住至少 80% 的对不上账问题。4.5 并发场景下的乐观锁与幂等在库存系统里多次点击发货按钮可能让同一事件被重复创建。传统更新库存的做法容易扣减两次REA 做法也一样要防重。我会给事件号加唯一约束并且用事务加乐观锁在写资源流出前先锁定对应事件。实际操作中事件号一般在应用层生成并写入配合唯一索引可以把“重复提交”变成数据库层面的报错而不是业务层面的多了两条流水。幂等控制这一环千万别省我在一次压测里就因为没加唯一约束硬生生造出十几条重复的扣减流水盘点库存时差点找不到问题源头。5. 一些经验之谈和后续扩展5.1 先把事件链画对再把表结构写对如果要我给新人提一个最开始的建议我会说先静下心把事件链画全而不是急着建表。事件就是业务的边界事件链一旦画错后面的资源、参与者都会跟着错。我在设计这套系统时花了大概两周时间整理业务方口中那些零散的“入库”“出库”“退货”“调拨”到底哪个是事件哪个只是事件状态最终把事件清单压到十五个以内表结构反而比最初简单得多。5.2 用事件流水号代替语义复杂的字段事件表里的事件号不要用纯自增 ID 对外展示建议组合业务日期、业务类型和当天序号。比如 20250611-SO-00042看起来有点多余但在排查“某一天都发了哪些销售单”时效率非常高。同时自增主键仍然保留纯内部关联用对外单据引用用事件号。这两个 ID 各司其职能省掉很多不必要的沟通成本。5.3 再往后还能怎么扩展如果这套库存销售跑稳还可以沿着 REA 的思路继续做把员工考勤工时建模为时间资源把项目任务完成建模为交付事件把专项资金池建模为资金资源。REA 的魅力就在于它给出的是上层抽象你可以在同一个底座上装入库存、财务、人力、项目四套系统而不需要重新创造四种互不连通的表结构。我以前一直觉得业务系统之间的整合靠接口后来发现更重要的是大家有没有共用一套事实底座REA 恰恰就是这个底座的候选之一。这个话题聊到这里我的基本结论也已经很明确标题里那三个字母 rea看起来简单实际上就是一套值得用整篇项目实录来写的建模理念。你要是正在头疼多个业务模块之间口径不统一不妨从今天开始把自己手上的业务先翻译成资源、事件、参与者三张图再去考虑具体表设计。这个转换的初期不会特别快但等模型站住之后后面的财务报表、库存报表、审计线索都会省下大量修补成本。我个人在实际操作中最享受的就是看着一张底层事件表同时喂饱库存仪表盘和收入明细报表的那种踏实感。希望这篇把 REA 从标题落到实处的记录也能帮你少走几段弯路。