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是采购收货事件 ⇄ 付款事件收货时企业欠供应商钱付款时还掉。销售出库事件 ⇄ 收款事件出库时客户欠企业钱收款时冲抵。这两组关系一建立整条“库存-往来-资金”链路就闭合了。后续无论要算毛利、账龄还是库存成本都能从事件关系里抽取出可靠的数据流。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(frozenTrue) class Resource: id: int code: str resource_type: str name: str unit: str dataclass(frozenTrue) 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(frozenTrue) class EventAgent: event_id: str agent_id: int agent_role: str direction: str注意这里Event我用frozenTrue意思是事件一旦创建所有属性只读。这非常关键事件表不是“可编辑表单”它是业务事实的固化记录。如果你允许后续修改已发生事件的金额整个证据链就会断裂。核心服务层只暴露“执行事件”的方法不暴露“修改事件”的方法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_idduality_source_event_id, target_event_idevent.event_id) self.projector.apply(event)这里没有update、没有delete。事件写错了怎么办再加一个“更正事件”例如退货事件冲减原销售事件的影响这在审计上无懈可击。业务人员需要适应“错了反冲不抹掉”的操作习惯但带来的数据可信度提升是值得的。4.2 事件溯源与物化视图REA的天然亲和伙伴是事件溯源Event Sourcing。当所有事实都以事件存储当前库存余额、应收余额、资金余额都可以从事件流推导。但这不代表每次查询都要重放全部事件性能会扛不住。我的做法是事件表作为唯一事实源永不修改、永不删除。额外维护一组投影表也就是资源和余额的“物化视图”比如resource_stock表存储每个资源的最新存量。事件写入后在同一事务内更新投影表如果历史事件需要重建投影就从事件流按顺序重放一次。这样设计写入路径有一个轻量计算查询路径则是简单的“读最近值”性能与传统表结构没有差别。招人接手时看到模型会感叹“库存居然能这么干净”因为任何余额都可以用一段脚本回放验证几分钟内能对平历史账。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事件图。画完之后你会发现很多让业务和开发扯皮的问题在建模阶段就已经消失了。
阅读完成 · 觉得有帮助?