业务系统里的数据对不上账,往往不是程序员的锅,也不是财务的锅,而是从一开始建模时就把"资源的变化"和"资源的状态"混在了一张表里。我最早接触到 REA(Resource-Event-Agent)这套建模思想,就是因为一次供应链库存盘点事故:账面还有 128 箱货,仓库里却只有 97 箱,采购说入库单没问题,仓储说发货单没问题,财务说付款凭证没问题,谁都没改过数据,可总量就是凭空少了 31 箱。后来我把所有单据按时间逐条摊开才发现,有一批退货在系统里只记录了"应当退"的状态,没有生成"实际退入"的事件,库存数字自然就静默丢失了一截。
如果你也在维护订单、库存、财务、供应链这类强数据一致性的系统,这篇文章值得花十分钟读完。我会围绕 REA 的三原语——资源、事件、代理——从一次库存对账事故讲起,走一遍采购和销售场景,再把 REA 如何推导出会计视角、如何映射到接口与事件流、以及落地时最容易踩的坑,全部摊开来说。这不是一篇学术综述,是我把 REA 用在真实业务建模里的实操记录和反思。
1. 账实不符的根子,往往不在账,而在事件被丢弃了
1.1 一次库存盘点引发的深夜头脑风暴
某天晚上我接到某公司运维同事的电话,说月度盘点做不平。ERP 里显示仓库 A 的 SKU 剩余 128 箱,实物盘点数是 97 箱,差异 31 箱。常规操作是先看这段时间的入库单、出库单、退货单、报废单,把所有单据流水加总,发现账面变更记录加起来和 128 箱这个数字完全吻合——这就诡异了:数字对得上,实物却对不上。
于是我开始怀疑不是"数字算错",而是"模型丢东西"。翻数据库里的库存表,发现它是典型的快照表:一个产品一行,列里有quantity、last_updated_at。每次盘点、入库、出库,程序直接对当前行的数量做加减,然后把旧的值覆盖掉。也就是说,历史记录只存在于操作日志里,日志又只记录了"把 128 改成了 137"这种结果,没有记录是哪张单据、哪个环节、哪个操作人带来的这次变化。有一张退货单在审批流里被退回修改,发起端又重新生成了一张,两单的创建时间和修改时间错位,最终只有一单进入了库存更新逻辑,另一单静静地躺在待办列表里,从没变成"事件"。
那一晚我意识到:所谓账实不符,本质上是业务动作没有被完整记录下来。所有业务系统都在采集"单据",但单据不等于"事实"。REA 解决的就是这件事——先把事实抽象成资源、事件、代理三个原语,再让所有业务动作落成不可丢失的事件流,最终状态全部由事件计算出来,而不是被反复原地修改。
1.2 状态是计算出来的,不是存储出来的
很多开发者听到"事件驱动"就想到 Kafka、想到消息队列,但 REA 和那种"异步通知"完全是两码事。REA 的核心主张很朴素:任何一个真实的业务动作,都应该以事件的形式存在;而资源当前的状态,只是对这些事件做投影计算的结果。
举个例子,库存余额 128 箱这件事,不应该被视为"一个事实",而应该被视为"一系列事件的总和":
- 采购收货事件:+150 箱
- 销售出库事件:-20 箱
- 报废事件:-5 箱
- 退货入库事件:+3 箱
把所有的事件增量求和,才是库存余额。如果事件表里少了那条退货入库,余额就会凭空少 3 箱。这和"把余额存在字段里"最大的区别是:字段可以被随意覆盖,事件只能追加,不能删除。追加式的记录方式天然保留了审计线索,也让账实差异有账可查。
我当时在系统里做了一个很小的改造:不再直接 update 库存表的quantity字段,而是先插一条库存事件,再通过触发器或者应用层汇总算出最新数量。这个改动看上去只是多了一张表,实际效果却让所有人的行为发生了变化——任何库存变动都必须先有"事件",才能反映到"状态"。想绕过单据直接改库存的人,连一条最小路径都找不到。
1.3 REA 三原语到底是什么意思
在展开场景之前,先把三个术语彻底说透,否则后面容易混。
资源(Resource)不是数据库里任意一张业务表,它特指"企业拥有或控制的、具有经济价值并且可以在交换中被消耗、转换或产出的对象":库存商品、现金、原材料、服务工时、知识产权,甚至信用额度都算。资源的特点是它有自己的生命周期,也必然有"进"和"出"两个方向。
事件(Event)是指导致资源变化的真实业务动作,比如收货、发货、付款、领料、报废。它必须包括时间、参与者、影响到的资源、数量的增减。事件是 REA 模型的"主角",它不描述"应该发生什么",只描述"实际发生了什么"。
代理(Agent)指参与事件的个人、企业或系统,既包括组织内部的人(采购员、仓管员、财务),也包括外部对象(供应商、客户、物流公司)。代理和事件的关联用来回答"谁对这个动作负责"。
这三个原语不是并列的业务表,而是业务逻辑的三根支柱。任何业务叙事,最后都能落成"某个代理在某个时间,执行了某个事件,改变了某个资源"的结构。下面我用一单真实采购流程,把这个拆解走一遍。
2. 用 REA 拆采购流程:从请购到付款,每一环都是事件
2.1 先写"业务叙事",再谈表结构
很多团队做数据建模,一上来就画 ER 图,把"订单表""订单明细表""商品表""客户表"摆出来,然后开始加外键。REA 的路径是反过来的:先写出完整的业务叙事,找出哪些环节真的改变了资源,然后再落成表。
以一次采购为例,完整叙事是这样的:
- 某生产部门提出请购需求,要采购 500 个某型号零件;
- 采购员联系供应商,供应商给出报价;
- 双方确认后,创建采购订单;
- 仓库收到实货,完成质检,执行入库;
- 财务收到发票,根据合同条款打款给供应商。
这里面哪些是 REA 意义上的"事件"?请购动作本身不改变库存,也不消耗现金,它是一个承诺,不是事件;供应商报价是承诺;创建采购订单是承诺;只有"入库"和"付款"是两个真正让资源发生变化的执行事件——入库让原材料库存增加,付款让银行存款减少。
这个区分非常重要。很多系统把"创建订单"当成一个高权重的动作,给它设计一大堆状态,却把"入库"和"付款"当成简单的接口调用藏在角落里。REA 提醒我们:订单本身只是承诺,承诺的价值在于"它很可能导向执行事件";但只有执行事件才能可靠地被计量和审计。如果模型里全是承诺没有执行,那么整个系统即使界面上花团锦簇,底层的资源账也是一团乱麻。
2.2 事件之间的三组关系:对偶、转换、流程
把单个事件识别出来之后,还要梳理事件之间的关系。REA 里最重要的三种关系:
- 对偶关系(Duality):一个事件的"流出"总对应另一个事件的"流入"。收了供应商的货,未来就要付款;付了款,就换取未来的收货款。资金和货品互为对偶。
- 转换关系(Conversion):一组资源投入之后变成另一组资源产出。原材料投入生产,产出成品;工时和设备的消耗,转换成服务。
- 流程关系(Flow):资源从一方流到另一方,从外部代理流向内部代理,或者从内部流向外部。
这些关系听起来抽象,落到系统设计里有很具体的用处。比如做库存账时,录入"采购入库"事件,系统就应该知道它和一个"应付"承诺是成对的;要不要生成财务应付凭证,不用靠业务代码硬编码,而是由"这个事件属于哪类对偶关系"推导出来。这也是 REA 被会计信息系统研究者看中的原因之一——它把业务行为和财务核算统一在同一套事实流里。
2.3 一张可落地的 REA 表结构长什么样
下面这张表结构是我在一个模拟项目里实际用过的版本,不包含公司敏感字段,只保留骨架:
-- 资源表:被计量和跟踪对象 CREATE TABLE resource ( id BIGINT PRIMARY KEY, code VARCHAR(64) UNIQUE, name VARCHAR(128), category VARCHAR(64), -- 原材料/成品/现金/服务等 unit VARCHAR(16), -- 箱/个/元/工时 created_at TIMESTAMP NOT NULL ); -- 代理表:内部人和外部机构 CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_type VARCHAR(32), -- employee / supplier / customer name VARCHAR(128), external_ref VARCHAR(64), -- 外部编号 created_at TIMESTAMP NOT NULL ); -- 事件表:所有真实业务动作 CREATE TABLE event ( id BIGINT PRIMARY KEY, event_type VARCHAR(64), -- receive / issue / pay / consume occurred_at TIMESTAMP NOT NULL, resource_id BIGINT NOT NULL REFERENCES resource(id), quantity DECIMAL(18,4), direction SMALLINT, -- 1 入库/流入, -1 出库/流出 internal_agent_id BIGINT NOT NULL REFERENCES agent(id), external_agent_id BIGINT REFERENCES agent(id), source_doc_no VARCHAR(64), -- 原始单号,便于溯源 created_at TIMESTAMP NOT NULL ); CREATE INDEX idx_event_resource_time ON event(resource_id, occurred_at);这个结构没有订单表,没有付款单表,没有入库单表。它们的职责被两个地方分担了:原始单据编号放在source_doc_no里用于溯源,单据状态和审批流放在一个独立的流程状态表里,不再和资源账耦合。这样做的直接收益是:任何一张单据在审批流里卡住,都不会污染资源事件流;只有真正发生的那一笔,才会插入 event 表。
在我参与的模拟项目里,这个设计让"盘点差异"问题一下变得容易排查:只要拎出一个资源 ID,按时间把 event 表全列出来,任何数量的变动都能定位到具体的单号、代理、时间,比对着日志猜数据准确得多。
3. REA 天然带"生成账本"能力:为什么不用硬写借贷分录
3.1 一个动作在资源上撬动两次变化
传统复式记账里,每笔交易要同时记借方和贷方,保证借贷平衡。REA 的底层逻辑其实更简单:每一对"对偶事件"天然构成一次完整的资源交换,一边进资源,一边出资源,两边各自记录,最终加总时自动平衡。
还是说采购:入库事件让"库存原材料"增加 500 套,付款事件让"银行存款"减少 12 万。从会计视角看,这是一组分录;从 REA 视角看,这是一个"入库事件"和一个"付款事件"通过对偶关系绑定在一起。系统不需要手工填借贷,只需要保证两类事件的 quantity 和金额都进入各自资源维度的事件流,那么"某段时间我们到底进了多少货、付出了多少钱"这些报表数据,全部可以由事件流自动投影出来。
这个思路尤其适合做管理会计的多维度分析。传统总账往往只有科目维度,想看"这个仓库某个月的收发存明细、每个供应商贡献了多少、每张订单消耗了多少资源",需要额外做很多科目辅助核算;而 REA 的事件表天然带着资源、代理、时间、数量四个维度,任何管理报表只是按不同维度聚合事件,不需要反查一堆凭证。
3.2 从事件流推"余额"的三条规则
在我重构某库存系统的时候,曾经只用三条规则就替代了一整套运维手工调账:
- 资源余额 = Σ(入库方向事件数量) - Σ(出库方向事件数量)。这里的入库和出库方向用
direction=1/-1表示。 - 往来余额 = Σ(外部代理关联的流入事件金额) - Σ(流出事件金额)。给供应商欠多少钱、客户欠我们多少钱,不需要维护应收应付子账,只要按
external_agent_id聚合付款和收货事件。 - 所有余额必须在事件层可重算。任何余额字段都只是缓存,如果缓存和数据源不一致,以事件流重新计算为准。
你可能会担心性能问题:余额每次都从头算当然不行,所以实际工程里我们仍然会在资源表上保留current_balance字段,但它的更新必须满足"事件插入成功后,用增量方式累加",而不是随意覆写。这个字段的地位从"事实"降级成了"缓存",一旦出现差异,可以直接从事件流重算,不再需要人去猜哪个值才是对的。
3.3 但 REA 不替你做税务和法定报表
这里必须泼一盆冷水。REA 能把"业务事实"和"管理核算"统一得非常漂亮,但它不打算取代财务软件里的法定总账、税务申报、凭证审核这些严肃流程。财务体系不只是借贷平衡,还有会计期间、结转、税务科目、审计调整一堆规则。
我见到的成熟做法是:业务侧用 REA 事件流记录事实,财务侧在期末按规则把事件流"翻译"成标准凭证。翻译过程是规则化、可追溯的,等于是给业务系统一张干净的底层事实表,又给财务系统留出了配置空间。翻译这事反而是 REA 模型的附加产物——因为事实表足够干净,所以转换规则可以写得很机械,不需要人肉判断。
4. REA 落地最容易被卡住的三个技术点
4.1 承诺与执行:不能只建模"实际发生"的那一半
我第一次做 REA 建模时犯过一个错:把模型只建了"执行事件",把采购订单、销售报价这些"承诺"都丢进流程引擎里,不进核心模型。结果业务方问了一个问题:"我们想统计这个季度有多少订单已经承诺、但还没有交货,怎么办?"
因为承诺没有进入事件模型,这个查询只能靠流程表的状态字段强行拼,而流程状态又经常不如实更新。后来我改为把"承诺"也建模成事件,只是用event_type区分开:
purchase_order_create:承诺接收资源;sales_order_create:承诺交付资源;receive:实际接收资源;deliver:实际交付资源。
承诺事件和执行事件通过"订单号"或者"对偶关系"关联,查询"待执行承诺 = 承诺事件数量 - 对应执行事件数量"就非常直接。这样设计,既保留流程控制需要的状态,又把承诺纳入统一的事实流,算是 REA 在工程实践里必须补的一课。
4.2 代理的粒度:员工、部门、供应商到底建模到哪一层
代理建模太粗,统计不到人;太细,会产生海量主数据。我的经验是分两层:
- 第一层是经济责任主体:供应商、客户、公司内部员工,承担资源交换责任,进入事件表。
- 第二层是组织维度:部门、项目、成本中心,属于分析角度,不作为事件的强制外键。
举个例子:仓管员张三办理收货,事件表里internal_agent_id指向张三的员工代理;但"这批货归华东仓还是华南仓管",这是资源本身的归属属性,不是事件属性。把这两个维度分开,可以避免在事件表里加十几个 FK 导致建模变成"无底洞"。很多团队问我"REA 是不是否定部门级管理",其实不是,它只是建议把**"谁负责的"和"从哪里管"**拆清楚。
4.3 事件时间和系统时间的双轨错位
这是四个坑里最隐蔽的一个。业务事件的发生时间(比如司机实际签收时间)和系统记录时间(服务器收到数据的时间)经常不一致,如果只用created_at做时序,月末对账时一定会碰到"跨月单据"。我的习惯是事件表里留两个时间字段:
occurred_at:业务事实发生时间,由调用方按业务规则传入;created_at:系统记录时间,由数据库默认值生成。
所有涉及财务期间的统计,以occurred_at为准;所有涉及系统追溯和排障,以created_at为准。如果业务方说"昨天已经签收了,现在才补录",事件表应该记occurred_at=昨天,而不是为了简单粗暴地把occurred_at设成当前时间。否则月度报表永远在做"基于错误时间的迟到修正"。
5. 把 REA 翻译成接口和事件流:一个库存扣减案例
5.1 设计接口时先问"这是事件还是状态查询"
很多人在对外提供 API 时,习惯性地把"改库存"设计成一个命令接口,比如POST /inventory/deduction,传入商品 ID 和数量,返回成功或失败。REA 会建议你换一个角度:调用方正在做的是一个"出库事件",接口应该接收事件的完整事实,而不是接收一个"改库存"的指令。
这两者的区别在实际问题里非常明显。指令式接口把"库存怎么变"的决定权放在调用方手里,调用方需要自己保证幂等、需要自己处理并发;事件式接口把"库存余额如何计算"留给系统内部规则,调用方只需要回答三个问题:谁做的、做了什么、发生在什么时候。
5.2 库存扣减接口的改造前与改造后
改造前的写法类似这样:
@app.post("/inventory/deduction") def deduct(product_id: str, qty: int): item = inventory.get(product_id) if item.quantity < qty: raise HTTPException(400, "库存不足") item.quantity -= qty inventory.save(item) # 直接覆盖 return {"success": True, "balance": item.quantity}这段代码的问题不在于正确性,而在于它把"判断库存是否足够"和"记录事实"合并成了一个原子操作;一旦后续需要审计"谁在哪个时间扣了多少",只能去翻操作日志,日志里还没有事件原貌。
改造后的思路是把"出库事件"作为唯一入口:
@app.post("/inventory/events") def create_event(event_payload: dict): # 1. 校验事件是否重复:同一单据号不允许重复插入 if event_exists(event_payload["source_doc_no"]): return {"success": False, "reason": "duplicate_doc"} # 2. 写入事件记录 event_id = insert_event( event_type=event_payload["type"], # outbound / inbound / scrap resource_id=event_payload["resource"], quantity=event_payload["qty"], direction=event_payload["direction"], internal_agent=event_payload["operator"], external_agent=event_payload.get("partner"), source_doc_no=event_payload["source_doc_no"] ) # 3. 异步或同步地刷新资源余额缓存 refresh_resource_balance(event_payload["resource"]) return {"success": True, "event_id": event_id}改造后,"库存不足"的判断逻辑并没有消失,它被放进了"生成出库事件之前的前置校验"里。但关键差别在于:在校验失败的情况下,系统不会产生任何 Modify 操作,也不会把库存字段搞成不确定状态;在校验成功后,系统记录的是一个不可变的事件,余额只是这个事件流的投影。
5.3 权限模型也可以从代理关系推导出来
REA 的一个隐藏红利是:权限设计可以直接建立在代理关系上。你不需要单独维护"谁能扣库存"的 RBAC 权限矩阵,而是可以通过问"该内部代理是否与某类事件存在授权关系"来判断:
- 仓管员 A 的授权范围 = 他能够创建"出库/入库"事件;
- 采购员 B 的授权范围 = 他能够创建"采购承诺"事件;
- 财务 C 的授权范围 = 他能够创建"付款"事件和"收款"事件。
这让权限控制和业务事件流共享同一套事实来源:谁拥有创建某类事件的权利,谁就在执行该业务动作;谁执行了什么事件,审计记录里自然就有。取消权限,本质上是取消"该代理对某类事件的操作权",而不是在某张权限表里删一行。这个思路在粒度和可解释性上都要优于传统菜单权限模型。
6. 三个让我走偏后又绕回来的教训
6.1 别为了"统一"而强行消灭流程状态
我一开始对 REA 过于理想化,想把所有业务都建模成事件,凡是有状态变化的都写成事件表,结果业务流程里的"待审批""已退回""审批中"这些状态也被我塞了进来。这个做法很快翻车:审批状态是流程控制的元数据,不是经济资源的增减;把它塞进资源事件表,导致事件表里出现大量数量为零、方向为零的"事件",查询和分析都无法区分"资源变动"与"流程变化"。
后来我在根事件表之外,单独保留了一张流程状态表,记录单据审批流、节点操作、退回原因。事件表只记录涉及资源增减的事实,流程状态表只记录控制信息。两者通过source_doc_no关联。这样既没丢掉 REA 的严谨性,也没有让建模变得宗教化。
6.2 REA 的"资源"不等于业务数据表里的每一行
有同事问我:那我们的用户表、订单表、权限表是不是都要改成资源?不是。REA 的"资源"特别强调经济价值和可计量属性。用户本身不是资源,用户购买的会员时长才是资源;订单不是资源,订单标的的商品和对应款项才是资源。建模时先划清"哪些对象会经历增减、会被交换和计量",这才是资源的候选集。那些只有生命周期但没有数量属性的对象,保持普通主数据表即可,强行套 REA 只会增加理解成本。
6.3 REA 和事件溯源(Event Sourcing)不是一回事
事件溯源是一种存储技术:系统状态完全由事件日志重放得到,不保存单独的快照。REA 是一种领域建模视角:它告诉你应该识别哪些事件、哪些资源、哪些代理。二者可以结合——REA 事件表用事件溯源技术存储,会得到极好的审计性和可重放性;但 REA 并不强制要求你上 Kafka、上专用事件库、上复杂的 CQRS。哪怕只是在一张 MySQL 表里做追加插入,只要建模时能把资源、事件、代理识别清楚,REA 的收益就已经能够落地。
6.4 我现在的分步落地方式:先选关键资源,再理事件,后做快照
经过几个项目的反复试错,我现在落地 REA 的顺序比较固定:
- 挑出 3 到 5 个核心资源,通常是库存商品、现金、应收账款/应付账款、产能工时这一类有明确数量的对象;
- 把这几个资源的所有业务动作全部列成事件候选清单,并和业务方逐条确认"什么才算真正发生";
- 针对每个资源,设计事件表,暂不建任何余额字段;
- 等事件流积累一段时间、查询报表全部能由事件聚合得到之后,才引入余额缓存字段做性能优化。
这套顺序看起来保守,但对新引入 REA 的团队非常友好。它不会一上来就把整个系统推翻重来,而是先从最容易出账实差异的领域切入,让团队在真实场景里体会到"事件可追、账目可算"的好处,之后再逐步推广。某次改造后,业务方最直观的感受是:"原来每天对账要两小时,现在只要十分钟,而且不再有说不清的差异。"这就是 REA 对业务系统最大的价值——它让事实变得可追溯、可复算、可审计。
最后分享一个个人心得:真正确认你对 REA 理解到位、可以用上它的时刻,不是你能背出"资源-事件-代理"三个词,而是当你拿到一张残缺的旧业务单据,能立刻指出"这里缺的是一个事件,而不是一个字段"。建模的工作,本质上是把隐藏的事实还原出来。能做到这一点,REA 就不用再靠谁推荐它了。