☰
REA建模实战:用资源-事件-代理构建可追溯的订单库存财务系统
2026/10/21 4:06:27 网站建设 项目流程

REA这个缩写,我第一次看到时还以为是某个框架的缩略名,后来在给一套订单系统做领域建模时,才真正认识它:Resource-Event-Agent,资源-事件-代理。这不是什么新潮的微服务方法论,而是一个从会计信息系统领域走出来的经典建模范式,解决的是“业务发生了什么、谁参与、资源如何变化”这一连串最本源的问题。如果你正在做ERP、进销存、财务核算或供应链类的系统,又苦于传统“单据+分录+余额表”拍脑袋设计带来的追溯混乱,那这篇文章应该能帮上忙。我会用一套模拟的家电销售场景,把REA从概念到落地完整拆一遍。

1. 先说清楚REA到底是什么

1.1 一次只解决一个核心问题

传统业务系统的建模,最常犯的毛病是“看到什么表就建什么表”。销售单来了建销售单表,收款单来了建收款单表,库存少了就加一个库存调整表。开头挺爽,等报表需求一到就傻眼了:销售毛利要关联进价,进价又藏在采购单里,采购单又跟付款单对不上,一套数查下来要跨七八张表,口径还跟财务对不齐。

REA的思路恰恰相反。它不问你“需要保存哪些单据”,而是先问你:“企业里到底有哪些经济资源?这些资源发生了哪些事件?谁参与了这些事件?”这三问一落地,单据表自然就演化成了“事件表”,资源余额变成“可推导的结果”,科目凭证变成“事件的镜像”。简单说,REA是一种以业务事件为核心、让数据天然可追溯的建模范式。

我第一次把REA套到项目上时,最大的感受是:以前建模是从“表单”出发,现在建模是从“事实”出发。表单会变,事实不会。换管理层、换业务流程时,事件表结构往往能原样保留,只是可能增加新事件类型,这一点在后期的系统演进里价值极大。

1.2 REA三要素拆解

REA的核心就三个概念,但每一个都很“重”。

资源(Resource):企业拥有或控制的有价值对象,可以是库存商品、现金、应收账款、服务时长,甚至某种无形权益。资源有两个关键属性:标识和计量单位。这里的“计量”不只是数量,还包括金额,后面做账务处理时才能无缝衔接。

事件(Event):改变资源控制权或存量的业务行为,常见的有采购收货、销售出库、收款、付款、退货。事件是REA模型的心脏——它是“时间上的一个点”,必须记录发生时刻、涉及资源、数量、金额和参与方。

代理(Agent):参与事件的个人或组织。任何一个经济事件都至少涉及两个代理:一方给出资源,另一方接收资源。这两方在同一个事件里是不能缺位的,否则后面做权限追踪、责任认定时就会断链。

除了三个要素,REA里还有几个常见的关系:存量-流转(stock-flow),描述事件如何使资源库存变化;双边性(duality),描述成对事件如何互相补足,例如“销售出库”和“收款”是一对;参与关系(participation),描述代理以什么角色参与事件。三个要素和这几组关系,就组成了整个REA图形。

实际建模时,很多人只记要素,忘掉事件之间的duality链路,结果系统做出来每条事件各自为政,追溯还是断的。要先画事件关系,再定表结构,顺序不能反。

2. 为什么传统建模在这里会踩坑

2.1 借贷记账法只是一个“结果视图”

做财务系统的人都知道,传统会计用借贷分录记录经济业务,这套体系有四百多年的成熟度,但它本身是一个“结果级”模型。

举个例子,传统账套里有一张记账凭证,借方是“应收账款—某客户”,贷方是“主营业务收入”。从这笔分录里,你能知道金额、知道客户名称,但你很难直接回答:这批货是哪个销售员卖出去的?对应哪一个销售订单?出库单号是多少?仓库在哪?商品批次是什么?这些问题全都散落在ERP的不同业务模块里,报表要取数就只能“漂洋过海”去关联。

REA模式把“事件”提到比“凭证”更高的层级。凭证只是事件在会计口径下的投影。一个销售出库事件发生时,真实世界的状态变化是:库存商品减少、客户获得了商品、公司形成了对客户的收款权利。至于记账凭证怎么编,是会计制度的事,模型不应该被分录格式绑架。这也是我后来非常偏爱REA的原因:它把“业务事实”和“会计表达”分开,业务部门和财务部门各取所需,而不是为一个数据结构扯皮。

2.2 REA通道是“证据链”

再深入一点,REA更能打的地方在于它天然形成一条证据链。

我接手过一个库存混乱的历史项目,问题出在“库存调整表”上:业务员发现盘点不准,就直接在系统里改库存数量,改完没有任何单据流,财务月底看不到为什么库存变了,审计更查不动。这就是没有事件模型导致的脏数据。

用REA的思路,任何库存变化都必须由事件驱动:入库是采购收货事件,出库是销售发货事件,盘点差异是盘点事件,报损是报损事件。状态可以变,但事件不可删。这样一来,任何时候你想知道“库存为什么变成这个数”,都能沿着事件流找到源头。哪怕业务员填错了,也能通过新增一个“更正事件”来修正,而不是抹掉历史。

这一条是我认为REA最有实用价值的部分。它不只是建模理论,更是一套审计与数据治理的骨架。

3. 实操:设计一套销售-库存-收款域模型

3.1 场景设定与实体识别

我选用一个综合模拟场景来说明:某家用电器销售企业,经营空调、冰箱等商品,客户分批发和零售,供应商定期供货。我们要建模的是“采购入库→销售出库→收付款”这条完整链路。

先按REA三要素走一遍:

  • 资源:库存商品(空调、冰箱等);银行存款;应收账款(对客户的收款权利)。
  • 事件:采购收货事件(增加库存商品)、销售出库事件(减少库存商品,产生应收)、收款事件(客户付款,增加银行存款,冲减应收)、付款事件(付给供应商,减少银行存款)。
  • 代理:客户、供应商、销售员、仓管员、财务人员。

识别完要素后,再梳理事件关系。最重要的两组Duality是:

  1. 采购收货事件 ⇄ 付款事件:收货时企业欠供应商钱,付款时还掉。
  2. 销售出库事件 ⇄ 收款事件:出库时客户欠企业钱,收款时冲抵。

这两组关系一建立,整条“库存-往来-资金”链路就闭合了。后续无论要算毛利、账龄,还是库存成本,都能从事件关系里抽取出可靠的数据流。

3.2 表结构怎么定

基于上面梳理,落地为表结构可以这样设计。

资源表保留资源的静态与动态信息:

CREATE TABLE resource_definition ( id BIGINT PRIMARY KEY, resource_code VARCHAR(32) NOT NULL, resource_type VARCHAR(16) NOT NULL, -- STOCK_ITEM: 库存商品; CASH_ACCOUNT: 资金账户; RECEIVABLE: 应收 name VARCHAR(64), unit VARCHAR(16), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE resource_stock ( id BIGINT PRIMARY KEY, resource_id BIGINT NOT NULL REFERENCES resource_definition(id), quantity NUMERIC(20,4) NOT NULL DEFAULT 0, amount NUMERIC(20,2) NOT NULL DEFAULT 0, updated_at TIMESTAMP );

事件表是核心,记录每一笔事实:

CREATE TABLE business_event ( id BIGINT PRIMARY KEY, event_no VARCHAR(64) NOT NULL, event_type VARCHAR(32) NOT NULL, -- PURCHASE_IN, SALE_OUT, RECEIPT, PAYMENT occurred_at TIMESTAMP NOT NULL, resource_id BIGINT NOT NULL, quantity NUMERIC(20,4) NOT NULL DEFAULT 0, unit_price NUMERIC(20,4), amount NUMERIC(20,2) NOT NULL, billing_doc_id VARCHAR(64), -- 关联业务单据号 note VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

再维护事件与代理的关系。

CREATE TABLE event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES business_event(id), agent_id BIGINT NOT NULL, agent_role VARCHAR(16) NOT NULL, -- CUSTOMER/SUPPLIER/SALESPERSON/WAREHOUSE_KEEPER direction VARCHAR(8) NOT NULL -- IN: 资源流入该代理; OUT: 资源流出该代理 );

还有一组duality关联表,把成对事件连起来:

CREATE TABLE event_duality ( id BIGINT PRIMARY KEY, source_event_id BIGINT NOT NULL REFERENCES business_event(id), target_event_id BIGINT NOT NULL REFERENCES business_event(id), duality_type VARCHAR(32) NOT NULL -- SALE_TO_RECEIPT / PURCHASE_TO_PAYMENT );

这套表看起来不复杂,但“事件独立、关系显式、代理可查”这三点让后续的追溯能力非常强。实际使用时,我一般还加一个projection层,专门给报表查询用,而不是让业务报表直接join事件表,后面会详细说。

3.3 事件模式和状态机的关系

聊到这里必须补一句:REA和传统状态机并不冲突。订单的“待付款→已付款→已发货→已完成”这类状态流转,描述的是一个聚合对象的生命周期;而REA描述的是资源层面的经济事件事实。两者一个是“过程视角”,一个是“事实视角”。

我个人的实践是:订单状态机保留,但作为开销最低的进程控制;真正用于库存、应收、资金核算的数据,一律以REA事件为准。订单状态可能被业务规则临时改动,但事件表一旦写入,不修改、不删除。两条线各司其职,系统的灵活性和严谨性可以同时拿到。

4. 实现落地:代码与存储细节

4.1 领域模型代码要点

不管用什么语言实现,REA的领域模型都应当围绕“事件的不可变性”来设计。我用一段Python来表达核心思路,其他语言类比即可。

from dataclasses import dataclass from datetime import datetime from enum import Enum class EventType(Enum): PURCHASE_IN = "PURCHASE_IN" SALE_OUT = "SALE_OUT" RECEIPT = "RECEIPT" PAYMENT = "PAYMENT" @dataclass(frozen=True) class Resource: id: int code: str resource_type: str name: str unit: str @dataclass(frozen=True) class Event: event_id: str event_no: str event_type: EventType occurred_at: datetime resource_id: int quantity: float unit_price: float amount: float billing_doc_id: str note: str = "" @dataclass(frozen=True) class EventAgent: event_id: str agent_id: int agent_role: str direction: str

注意这里Event我用frozen=True,意思是事件一旦创建,所有属性只读。这非常关键:事件表不是“可编辑表单”,它是业务事实的固化记录。如果你允许后续修改已发生事件的金额,整个证据链就会断裂。

核心服务层只暴露“执行事件”的方法,不暴露“修改事件”的方法:

class EventService: def record_event(self, event: Event, agents: list[EventAgent], duality_source_event_id: str = None) -> None: # 1. 校验资源与代理合法性 self._validate(event, agents) # 2. 写入事件表 # 3. 写入 event_agent 表 # 4. 若有 duality 关联,写入 event_duality 表 # 5. 在同一个事务里更新资源投影表(库存/余额) with self.tx.begin(): self.event_repo.save(event) for agent in agents: self.event_agent_repo.save(agent) if duality_source_event_id: self.duality_repo.save(source_event_id=duality_source_event_id, target_event_id=event.event_id) self.projector.apply(event)

这里没有update、没有delete。事件写错了怎么办?再加一个“更正事件”,例如退货事件冲减原销售事件的影响,这在审计上无懈可击。业务人员需要适应“错了反冲,不抹掉”的操作习惯,但带来的数据可信度提升是值得的。

4.2 事件溯源与物化视图

REA的天然亲和伙伴是事件溯源(Event Sourcing)。当所有事实都以事件存储,当前库存余额、应收余额、资金余额都可以从事件流推导。但这不代表每次查询都要重放全部事件,性能会扛不住。我的做法是:

  1. 事件表作为唯一事实源,永不修改、永不删除。
  2. 额外维护一组投影表(也就是资源和余额的“物化视图”),比如resource_stock表存储每个资源的最新存量。
  3. 事件写入后,在同一事务内更新投影表;如果历史事件需要重建投影,就从事件流按顺序重放一次。

这样设计,写入路径有一个轻量计算,查询路径则是简单的“读最近值”,性能与传统表结构没有差别。招人接手时,看到模型会感叹“库存居然能这么干净”,因为任何余额都可以用一段脚本回放验证,几分钟内能对平历史账。

4.3 事务边界与并发处理

使用REA时,事务边界和普通CRUD系统不太一样。事件写入必须原子性地更新以下内容:事件本身、代理关系、资源投影、duality关联。任何一步失败,整个事件都不能落库,否则会出现“事件存在但库存没减”的脏状态。

并发场景也要提前设计。例如两个销售员几乎同时卖同一款空调,事件表可以并发写入,但投影表(库存)的更新必须串行化。实践上我偏好乐观锁:投影表加版本号,更新时WHERE version = ?,失败则重试或报库存不足。事件编号本身生成方式也建议用数据库序列或分布式ID,保证唯一与有序。

并发更新最容易忽略的坑是“先查库存再扣减”这个非原子操作。必须把“查询+扣减”合并进一个带锁或带条件的SQL,例如:

UPDATE resource_stock SET quantity = quantity - :sale_qty, updated_at = NOW() WHERE resource_id = :resource_id AND quantity >= :sale_qty;

影响行数为0就说明库存不足,这比“先select后update”可靠太多。

5. 常见问题与避坑实录

5.1 误区一:把REA当成ER工具用

“REA不过是画个ER图嘛”——这是最常见的误解。如果只是把三要素画成表,然后照旧业务逻辑满地写,REA的优势一点发挥不出来。REA的最大约束是:业务操作必须通过事件表达,资源状态永远由事件驱动。

举例来说,用户要做一个“销售订单作废”功能。传统写法是改订单状态、回滚库存。REA的写法是新增一个“取消销售事件”,并且生成一条负数量的出库事件将库存加回来。过程和结果系统里都留存着原因和操作者,日后审计一查便知。

5.2 误区二:过度抽象,事件表变成垃圾桶

另一个反面教材,是恨不得把“用户登录”也画成一个经济事件,结果事件表里混杂了业务事实与系统日志,反而失去意义。REA的事件指的是影响经济资源或资源控制权的事件。登录行为不产生资源增减,不该进事件表。

事件表如果塞入无关数据,查询变慢,语义混淆,证据链丧失严肃性。我建议按领域聚合拆分事件表,或者至少用明确的event_type区分业务域。一个单体仓库内可以混杂多种事件,但不要让系统噪音混进来。

5.3 常见问题速查表

问题现象根因分析对症解法
库存台账与事件流水对不上有人直接改了库存表,未生成事件禁止手工update投影表,全部走记录事件入口
应收余额出现负数收款事件未关联到具体销售事件,双边链断裂强制event_duality关联,校验销售金额覆盖
历史报表口径变了,数据无法回溯投影表被覆写,事件类型不完整保留不可变事件表,新增口径用事件重算投影
同一商品并发出售超卖库存检查与扣减非原子使用带条件UPDATE或行锁,禁止事务外先查后改
事件表无限膨胀,查询变慢未区分事实表与投影表事件表定期归档,常用查询走投影表
月末财务对账差异业务模块与会计科目各自为政从事件表生成分录,账务作为事件投影

5.4 我的几条独家实操心得

  • 先画Duality链,再三要素填空。我在设计新业务域时,先在白板上画出“哪个事件产生了债权,哪个核销债权”,把这根链条理清,再往里填资源和代理,效率高很多。
  • 事件编号、清单号、单据号要三权分立。事件编号是系统主键,清单号是源系统的流水号,单据号是业务展示号。不要混用,否则在对接外部系统或数据迁移时会吃大亏。
  • 保留价格快照。事件表里的unit_price、amount必须是事件发生时的实价,不允许后来去关联价格表反查。价格表会调价,历史事件不会变。
  • 给投影重建脚本做好回归测试。每次发版前跑一遍“事件流重放→对比投影表”的校验,能提前发现事件类型遗漏或累加逻辑错误。这个脚本我视为系统的底线保障。

回到最初的话题——为什么我会对这个缩写印象这么深?因为REA真正帮我摆脱了“库存是库存、资金是资金、应收是应收”这种数据孤岛思维。一个销售出库事件,同时牵动库存、应收、毛利和销售员绩效,全部数据可以从一条事件流串联出来。现在带新人设计业务模块时,我的第一要求永远是:“先把这个业务的经济事件画出来,再谈表结构。”这句话听起来简单,落到实处,系统的可追溯性和稳定性会远超那些只盯着单据字段建模的做法。

如果你正在做一个和订单、库存、财务相关的系统,并且已经受够了“改一个状态牵一发动全身”的日子,可以照着这篇文章的思路先画一版REA事件图。画完之后你会发现,很多让业务和开发扯皮的问题,在建模阶段就已经消失了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询