1. 为什么我在重构进销存系统时,决定放弃传统的借贷记账模型
先说说背景吧。前两年我接手了一个某公司的进销存系统重构项目,老系统用的是经典的借贷记账模式,库存表、流水表、应收应付表各管一摊,表面上业务跑得挺顺,可真要动它的时候问题全冒出来了。库存盘点对不上账要反查半年流水,销售退货要同时改三张表,财务那边要的报表和业务这边记的口径完全是两回事。那段时间我天天在数据和业务部门之间当翻译,来回解释"为什么库存明明显示有货,财务却说这笔采购还没入账"。
后来我偶然翻到一篇关于会计信息系统建模的老论文,接触到了 REA 模型——Resource、Event、Agent 三个单词的首字母。这个模型是上个世纪八十年代提出的,本意是重构会计信息系统的数据模型,但越研究越发现,它其实是通用性极强的业务建模框架。简单说,REA 不再以"借"和"贷"为最小单位,而是认为企业里所有经济活动都可以拆成三件事:资源(Resource)在流动,事件(Event)发生了,参与者(Agent)在操作。库存、资金、订单、人员、流程,全部可以映射到这个框架里。
我花了大概三周时间把老系统的核心模块用 REA 的思路重新建模,然后自己写了个简易 Demo 验证可行性,效果出乎意料地好。它解决了我之前最头疼的几件事:库存流水和财务数据终于能对上了,退货、冲销这类逆向业务不再需要写一堆特例代码,而且整个数据模型变得异常干净。这篇文章就把我这段改造经历和踩过的坑原原本本写出来,内容包括 REA 的三要素如何理解、怎么从实际业务出发建模、落库和代码实现时要注意什么。无论你是要设计新系统,还是正在为老系统的数据混乱发愁,这篇都值得你花十分钟读完。
2. REA 三要素拆解:资源、事件与参与者到底在说什么
2.1 Resource(资源):不只是库存,还包括钱、服务和无形资产
很多人第一次接触 REA 时会把 Resource 理解成"库存商品",这不算错,但太局限了。REA 里的 Resource 定义是:一切有价值的、能被企业控制并参与经济交换的东西。它可以是实物,比如仓库里的手机、原材料;也可以是非实物,比如银行存款、应收账款、专利授权;甚至可以是服务,比如一次技术咨询、一个月的云服务器租用。
判断一个东西能不能算 Resource,有两个关键标准。第一是稀缺性——空气不算资源,因为没人需要为它做经济决策;但干净的瓶装水算,因为需要采购、储存、销售。第二是参与经济事件的能力——资源必须能在一进一出的事件中发生转移或消耗。比如"员工技能"在企业内部有价值,但它不直接参与对外经济事件,所以通常不建模为 Resource。
这里有个初学者容易绕晕的地方:现金也是 Resource,而且是特殊的 Resource。现金既可以是采购事件的输出资源(付出去),也可以是销售事件的输入资源(收回来)。用 REA 建模时,现金账户和库存商品是同一层级的实体,没有像传统会计那样分成"资产类科目"和"收入费用类科目"。这恰恰是 REA 的优势——它天然统一了业务视角和财务视角,因为无论现金还是商品,它们在事件中的角色都是"被交换的东西"。
2.2 Event(事件):业务动作的原子单位,也是整个模型的枢纽
Event 是 REA 模型的灵魂。它代表一个在特定时间点发生的、不可再分解的经济行为,比如"2024年6月1日 10:23:05 销售了一台手机"。和传统系统的"流水记录"不同,REA 的 Event 不仅仅记录发生了什么,还承担着连接资源和参与者的枢纽作用——每个 Event 至少关联一个 Resource 和两个 Agent。
为什么强调"不可再分解"?我举个例子。老系统里一条"销售订单"记录,可能同时包含下单时间、商品明细、客户信息、销售员、仓库出库时间、开票状态,字段多的能有三四十个。但在 REA 视角下,"销售"其实可以被拆成两个事件:承诺事件(客户下单承诺购买)、执行事件(实际交付商品)。这两个事件发生的时间、涉及的资源、参与的 Agent 都不一样,强行合成一条记录会导致很多字段在某段时间内为空,也为后续的数据统计埋下隐患。
Event 还有个重要属性:方向。对资源而言,事件分为流入(inflow)和流出(outflow)。一笔采购入库是库存资源的流入事件,同时是现金资源的流出事件;一笔销售出库是库存资源的流出事件,同时是现金或应收账款的流入事件。这个"双向流"的视角是整个 REA 模型的核心机制,后面讲数据一致性的时候你会看到它的威力。
2.3 Agent(参与者):人和组织,以及它们的"内部人"和"外部人"之分
Agent 是发起或参与事件的个人、组织或其他主体。它解决的是传统系统中最常被忽视的问题:谁对这个事件负责?谁从这次交换中获益或受损?
REA 把 Agent 分为两类:内部 Agent 和外部 Agent。内部 Agent 是公司自己的员工或部门,比如销售员、仓库管理员、采购部;外部 Agent 是客户、供应商、银行等交易对手方。这个区分不是拍脑袋,而是直接影响权限设计和审计追溯。比如销售事件要关联两个 Agent:一个是外部客户(谁买了),一个是内部销售员(谁卖的)。这样日后如果想查"某个销售员的业绩",可以直接通过事件表按内部 Agent 过滤;如果想查"某个客户的完整购买历史",按外部 Agent 过滤即可。
这里值得多说一句:REA 事件中至少关联两个 Agent,这是模型的基本约束。如果你发现某个 Event 只关联了一个 Agent,那大概率是建模有问题。比如"仓库盘点"这个操作,看起来只涉及内部人员,但实际上盘点的结果是库存数量的修正,它同时关联了"仓库管理员"(执行人)和"库存"这个资源对应的"责任人"(通常是仓储部门)。单 Agent 事件往往意味着你漏掉了某种责任关系。
| 三要素 | 定义 | 典型示例 | 容易搞错的点 |
|---|---|---|---|
| Resource | 有价值的、可参与交换的实体或权益 | 库存商品、现金、应收账款、服务 | 把"员工"当资源,实际员工是 Agent |
| Event | 发生在某一时点的经济行为 | 销售、采购、收款、付款、领料 | 把"订单"当一个事件,实际包含承诺与执行两个事件 |
| Agent | 参与经济事件的人员或组织 | 客户、供应商、销售员、仓库管理员 | 只关联一个 Agent,漏掉内部/外部角色 |
把这三个要素理解透了,REA 建模就有了地基。接下来我用一个完整案例演示怎么把这些概念串起来。
3. 从订单到入库:一个完整采购流程的 REA 建模全过程
3.1 为什么从"业务叙事"开始而不是从"表结构"开始
很多技术同事建模时习惯先想数据库表,订单表、明细表、客户表……这没错,但容易陷入一个思维定式:把现有的界面和报表当需求本身。REA 的建模方式是从"业务叙事"开始的——先把一笔真实发生的经济业务用自然语言讲一遍,然后从中抽取 Resource、Event、Agent。
我当时用的一个典型案例是采购流程,叙事是这样的:"某公司向供应商下单采购100台显示器,单价800元。供应商发货后,仓库验收合格入库。财务收到发票后,在账期内付款。"
听上去很简单对吧?但这段话里已经包含了两个 Event,而传统系统通常只建模成一条"采购入库单"。我们拆开看:
- Event 1(采购承诺):某公司(内部 Agent:采购员)向供应商(外部 Agent)发出订单,承诺购买 100 台显示器。此时还没有资源流动,只有双方的权利义务关系。
- Event 2(收货入库):供应商交付 100 台显示器,仓库管理员验收后入库。这里是关键——库存资源流入,同时形成了对供应商的应付款义务。
- Event 3(付款):财务在账期内向供应商支付货款。这个事件让现金资源流出,同时清偿了之前形成的应付款。
所以这个看似简单的采购流程,在 REA 中至少拆成三个事件。每个事件都有明确的时间点、资源流方向和参与者,跟"借贷记账"一比,逻辑清晰度完全不在一个层级。
3.2 给每个事件画"资源流"和"参与者线"
建模型画图的时候,我不建议上来就用专业建模工具,一张纸一支笔就够。把每个 Event 写在中间,左边画流入或流出的资源,右边画参与的内部 Agent 和外部 Agent。画完之后你会得到一个类似"星形"结构的东西,每个 Event 都像一个卫星,资源和 Agent 是绕着它转的行星。
还拿采购入库这个 Event 举例:
库存商品(Resource,流入) ↑ 收货入库(Event) ↙ ↘ 仓库管理员 供应商 (内部 Agent) (外部 Agent)再看付款这个 Event:
银行存款(Resource,流出) ↑ 付款(Event) ↙ ↘ 财务人员 供应商 (内部 Agent)(外部 Agent)画几个之后你会自然发现:库存资源同时出现在"收货入库"(流入)和"销售出库"(流出)两个事件中,现金资源同时出现在"收款"(流入)和"付款"(流出)两个事件中。这种共享关系就是 REA 模型能够保证数据一致性的根源——同一资源的所有流动都挂在同一个实体的记录上,不会像传统系统那样库存表和财务账各记各的。
3.3 承诺事件与执行事件的配对:REA 的一致性约束
前面提到了承诺事件(订单)和执行事件(入库/出库),它们之间的配对关系是 REA 建模最容易忽略也最重要的部分。
在 REA 语义中,每一个执行事件都应该能找到对应的承诺事件作为"起因";反过来,承诺事件最终应该被执行事件"兑现"。比如"收货入库"这个执行事件,它的起因是"采购订单"这个承诺事件;"销售出库"执行事件,起因是"销售订单"承诺事件。
为什么要强调这种配对?因为它解决了老系统里一个经典问题:订单状态到底是什么?在传统表中,"订单状态"是个枚举字段,可能是待审核、已发货、已完成、已取消……每次业务逻辑变动就要加枚举值,维护起来很痛苦。在 REA 中,不需要订单状态字段。你只要看"销售订单"事件和"销售出库"事件是否形成了配对,就能判断这张单跑到哪一步了;看是否有对应的"收款"事件,就能判断是否完成了闭环。状态不是存出来的,是实时算出来的。
这个思想我觉得是 REA 最值钱的地方——它把业务流程从"状态机"变成了"事件链条"。状态机是静态的、需要预定义所有可能路径的;事件链条是动态的、可以随时扩展新事件类型的。业务流程调整时,只需增加新的事件类型和配对规则,原有的数据完全不用动。
4. 落库与查询:REA 模型在数据库设计里长什么样
4.1 从模型到表的映射规则
模型画清楚了,数据库表结构就是顺理成章的事。每个 Resource、Event、Agent 都是一张主表,关系和约束则通过外键和关联表体现。我在 Demo 里用的简化结构是这样的:
以销售为例,核心表包括:
- resource:资源主表,存库存商品、现金等。字段就四个——id、name、type(库存/现金/应收/应付)、current_quantity。
- agent:参与者主表,存客户、供应商、员工。字段:id、name、type(内部/外部)。
- event:事件主表,存所有经济事件。字段:id、event_type(采购订单/收货/销售出库/收款/付款)、occurred_at、description。
- stockflow:资源流关联表,连接 Event 和 Resource。字段:id、event_id、resource_id、direction(in/out)、quantity、unit_price。
- custody:参与关联表,连接 Event 和 Agent。字段:id、event_id、agent_id、role_type(内部/外部)。
这套结构最巧妙的地方在哪?你不需要为"销售出库表"和"采购入库表"分别建表,所有事件都在 event 表里,通过 event_type 区分;所有资源流动都在 stockflow 表里,通过 direction 区分流向。新增一种业务事件(比如"盘盈""赠送""报废"),不需要改表结构,只需要加一个 event_type 的值。这对后期扩展来说省了太多事。
4.2 用一句 SQL 完成"既查库存又查资金"的联合分析
传统系统里"查出库记录"和"查现金流水"是两条完全独立的查询路径。REA 里它们统一了。我想看某类商品的完整流转历史,可以从 resource 表出发,通过 stockflow 关联到所有相关事件,再通过 custody 关联到参与者。一条链子全拉出来。
举一个实际查询的例子。假设我要查"过去30天内所有涉及'显示器'这个资源的流入流出事件,并带上对应的操作人和交易对手方",SQL 大概是这个思路(用伪代码语义描述):
SELECT e.id, e.event_type, e.occurred_at, sf.direction, sf.quantity, sf.unit_price, ia.name AS internal_agent, ea.name AS external_agent FROM resource r JOIN stockflow sf ON sf.resource_id = r.id JOIN event e ON e.id = sf.event_id LEFT JOIN custody ci ON ci.event_id = e.id AND ci.role_type = '内部' LEFT JOIN agent ia ON ia.id = ci.agent_id LEFT JOIN custody ce ON ce.event_id = e.id AND ce.role_type = '外部' LEFT JOIN agent ea ON ea.id = ce.agent_id WHERE r.name = '显示器' AND e.occurred_at >= CURRENT_DATE - INTERVAL '30 days' ORDER BY e.occurred_at;这类查询在传统多表结构里要 UNION 好几个查询结果,REA 里一条就能拉通。我和团队里同事实测过,一次联合审计查询的 SQL 行数从原来的 60 多行减少到不到 20 行,而且逻辑更直白。数据库索引优化也简单,主要给 event_id、resource_id、agent_id 建好索引就行。
4.3 代码层建模时和领域驱动设计(DDD)的结合经验
如果你熟悉领域驱动设计,会发现 REA 和 DDD 有不少呼应:Resource 可以对应聚合根,Event 对应领域事件,Agent 对应领域服务或用户上下文。我自己实践下来的建议是:不要把 REA 生硬地套进 DDD 的战术模式里,而是让 REA 负责"业务模型的骨干",DDD 负责"应用服务的细节"。
具体来说,我在 Demo 中的做法是:把 Resource、Event、Agent 作为核心领域实体,用 Repository 模式持久化;而具体业务操作(比如"创建销售订单")做成应用服务,服务内部依次创建承诺事件、校验库存可用性、登记预期资源流。这样既保留了 REA 的一致性和审计性,又不牺牲 DDD 带来的业务封装和测试便利。
5. 改造过程中的三次"翻车"与对应修复方案
5.1 翻车一:把"应收账款"误当成 Resource 导致查询爆炸
第一次建模销售流程时,我把"应收账款"直接建成一个 Resource,想着它确实是企业的一项资产,财务上也是这么体现的。结果跑通后查询销售历史时,每条销售记录都被拆成"库存流向客户"和"应收款流向公司"两条 stockflow,数据量直接翻倍,而且很多报表口径对不上了。
后来我才反应过来:应收账款不是资源本身,而是"销售出库"这个事件尚未被"收款"事件配对时的中间状态。在 REA 中,不需要单独建一个"应收账款"资源,只需要通过查询"销售出库事件是否已关联收款事件"来推导出当前应收余额。想通这一点后,表结构和查询都简化了一大截。
这个教训给我一个启发:判断一个东西是否是 Resource,要看它是否直接参与经济交换并被事件消耗或产生。"应收账款"是对过去事件的记录,不是未来事件的输入或输出,所以它不该是 Resource,而是事件配对状态的投影。
5.2 翻车二:忽略承诺事件,导致"在途订单"无法统计
有一版模型我只建了执行事件——采购收货、销售出库——当时想着"订单只不过是个中间过程,最终以实物流动为准"。结果业务方提了一个需求:要统计"已下单但尚未到货的采购订单金额"。这个数据在传统系统里是一个字段就能查出来的,但在我的表结构里根本没有"订单"这个概念,完全无从下手。
修复方案是补充承诺事件:在采购流程中新增"采购下单"事件,在销售流程中新增"销售下单"事件,并与对应的执行事件建立配对关系。有了承诺事件,"在途订单""待发货订单"这类统计就变成了一个简单的配对查询:找出有承诺事件但对应执行事件还的事件。
由此我也认识到,REA 建模时承诺事件不能省。它承担的是传统系统里"单据头"的职责,但比单据头更纯粹——它只关注"约定"本身,不混入任何执行信息。
5.3 翻车三:没有设置唯一性约束,导致重复事件污染数据
Demo 阶段数据量小,没想过重复事件的问题。后来接入测试环境的真实业务流量时,由于接口重试机制,同一次收货操作被提交了两次,导致库存资源和应付款都翻了一倍。传统表因为有"单号唯一"约束,天然免疫这种问题;我的 event 表没有类似约束,只能靠代码逻辑防重,结果没防住。
解决思路是在 event 表加入业务唯一键。怎么定义唯一性?单个事件天然有发生时点,加上事件类型、关联资源和参与者的组合,基本可以构成唯一约束。我最终给 event 表加了一个 generated_business_key 字段,值由"事件类型 + 关联资源ID + 关联外部AgentID + 发生时点"哈希生成,数据库层面加唯一索引。这样一来,重复提交直接被数据库拒绝,不需要在应用层费力判断。
这个坑给所有准备上 REA 的人提个醒:模型再漂亮,落地时还是要补传统的完整性约束。REA 不是银弹,它提供的是业务语义的清晰性,但工程防腐还得靠数据库的强约束。
6. 存量系统迁移到 REA 的路径和建议
6.1 先搭"事件档案"再拆旧表:避免一步到位的迁移风险
如果你也想把老系统改造成 REA 模型,我强烈建议不要搞"大爆炸式"迁移——把所有旧表一次性映射到新表,风险太大,回滚也难。我当时的做法是分三步走:
第一步,建立事件档案表。把老系统里的核心流水(订单流水、出入库流水、收付款流水)导入到新的 event 表,并根据流向补全 stockflow 记录。这一步不要求数据完美,只求"能追溯"。
第二步,并行运行。新模型和老系统同时跑,核心查询和报表两边出数据,逐一对账。对不上的地方一般是两类:一是老系统的字段含义不清晰导致映射错误,二是老系统的某些业务操作本来就不是事件(比如状态修改)。
第三步,等对账稳定后再切换。通常跑两周左右,两边数据完全一致了,再通知业务方停用老系统界面。
6.2 老数据映射时的三个"翻译"技巧
历史数据迁移时,最难的其实是把老表字段"翻译"成 REA 语义。我总结出三个实用的翻译技巧:
第一个技巧是"按事件拆字段"。比如老系统的"出入库单"里既有商品信息又有库房信息,翻译时不要试图保留所有字段,而是只抽取事件相关的核心属性——什么时候发生、涉及什么资源、流入还是流出、参与者是谁。其余信息可以通过这些要素推导,不必迁移。
第二个技巧是"把状态字段变成事件存在性判断"。老表的"订单状态=已完成"对应到 REA 就是"销售出库事件是否存在且已配对收款事件"。迁移时不需要保留状态字段,只需要保证配对关系完整。
第三个技巧是"宁可丢细节不要丢关联"。老系统的很多附属字段,比如"备注""经办人电话",迁移时可以直接暂时丢弃或放到 event 表的 description 字段里。关联关系必须保证完整,因为这是日后审计和追溯的命脉。
6.3 多长时间能见效?我实测的时间线
很多朋友问我:REA 改造到底值不值得投入?从我自己的项目经验看,投入产出比最高的节点是改造完成后的第三个月往后。第一个月基本在建模和迁移,第二个月在对账和修数据,第三个月开始,业务方会陆续提一些新需求——比如"我要看某个客户所有品类的现金流情况""我要按事件来源统计部门绩效"——这些需求在传统模型下要写大量的临时 SQL,而 REA 模型下改改过滤条件就能出结果。
如果你正在犹豫要不要上 REA,我的建议是先在一个核心链路上试点建模(比如采购到付款、或者销售到收款),跑通后再决定是否全量铺开。REA 不是一种"银弹",它是一种思维方式的转变。这个转变一旦完成,你会发现业务系统的数据模型可以做到极简,而且无论业务怎么变,模型骨架都稳如磐石。