第一次在共享目录里看到“REA”这个缩写时,我还以为是哪位同事随手敲出来的占位符。直到某天从一份老设计文档里翻出一页手绘的表关系图,旁边写着“Resource-Event-Agent”,才意识到这个三字母组合不是什么冷门框架,而是一套能同时把订单、库存、应收应付串成一个闭环的数据建模思路。那之后我在模拟项目X里把REA整个过了一遍,做了套带订单、库存、收款的进销存Demo,也踩了不少坑。
这篇文章不打算复述教科书概念,而是把我实际落地时的判断、表结构、业务规则推导,以及事后复盘才发现的问题一起写出来。适合正在做进销存、ERP、财务系统或电商后台的开发、产品、架构师参考,尤其是面对那种“一个字段表示五六个业务状态”的老库,想从根上梳理数据模型的人。
1. REA是什么:三个字母背后的业务建模骨架
1.1 一张图看懂Resource、Event、Agent
REA一共就三个核心概念,名字很直白:
- Resource(资源):企业拥有或控制的经济资源,比如库存里的商品、现金、应收账款、固定资产。
- Event(事件):改变资源数量或状态的动作,比如一次销售、一次采购入库、一次客户付款。
- Agent(代理人):参与事件的人或组织,比如客户、供应商、销售员、仓管员。
光看定义还是有点抽象,拿“顾客进店买一瓶水”来说。水是资源,确定顾客把水拿走之前,它在库存表里是“数量1”的库存记录;销售动作是事件,它让水的库存数量减了一;收银员和顾客就是代理人,分别以“内部员工”和“客户”的身份参与了这个事件。如果是扫码支付,还会多出一个“现金/银行存款增加”的事件,让钱这个资源也产生了流入。
这套东西不要求你从会计科目切入,而是直接从业务动作切入。传统方式可能会先建一张订单表,加一个“状态”字段,从待支付到已支付到已发货一路更新;REA的视角则完全不同——它把“下单”和“收款”各自作为独立事件,资源只在事件发生时产生流入或流出。这样一来,数据里留下的不是状态机的痕迹,而是一串可追溯的业务动作。
1.2 与传统建模方式的差异在哪里
做业务系统的人对下面这种表已经很熟了:订单主表、订单明细、状态字段、支付流水。状态字段表面上很直观,可一旦流程复杂起来就麻烦了——同一个字段要表达十几种含义,回调时更新一下,异常时还要回滚,时间一长,没人说得清这条订单到底经历了什么。
REA不这么做。它的核心理念是“保存事实,不保存判断”。所谓事实,就是资源在哪里发生了流入或流出,是哪个代理人参与的,在什么时间点发生的。至于订单是已支付还是已取消,那是从多个事件的组合里推断出来的,不需要一个字段刻意去存。
| 项目 | 传统订单状态机 | REA事件建模 |
|---|---|---|
| 建模对象 | 单号、状态、金额字段 | 资源、事件、代理人 |
| 数据粒度 | 订单行级聚合 | 原始业务事件 |
| 审计能力 | 靠日志与快照 | 天然事件链 |
| 流程变更 | 状态机难以扩展 | 新增事件类型即可 |
| 财务对账 | 需要手工映射科目 | 事件可自动推导凭证 |
这里我多说一句,REA看起来像是会计领域的学术模型,但它解决的问题和现在做订单系统、库存系统的人遇到的问题是同一类。业务系统最怕的不是字段不够用,而是模型里没有“动作”的位置。只要库存、订单、财务在一套系统里,REA就是绕不开的思路。
2. 为什么系统设计绕不开REA这套思路
2.1 业务自然语言到数据结构的桥梁
我比较深的一个体会是,REA本质上是把业务人员口中的“语言”翻译成了数据结构。业务人员会说“昨天下了一批货,今天钱到账了”。这句话里,货是资源,下货是事件,到账是另一个事件,业务员是谁、客户是谁,都是代理人。REA让你能照着业务原话建表,而不是先设计出二十几个字段再来回头对需求。
传统ER建模的第一反应是画实体、列属性。但业务系统里最核心的往往不是实体本身,而是实体之间的“动作”。一个实体只有躺在表里不动,谈不上价值;只有“销售”“采购”“转账”这类动作发生,数据才开始有意义。REA把这些动作显式建模成事件表,动作为王。
我自己在某个进销存项目里试过两种建表方式。先按传统方式建了订单中心,结果发现要写十几个状态判断,每个新需求都要往订单表加字段。后来按REA重新划分,订单只是一个“发起”,真正做事的是库存流出事件、确认收货事件、付款事件。后期加退单、换货、退款都变成加新事件类型,不再动老表结构,清爽很多。
2.2 一线的三个判断:粒度、链条、双向关系
REA看起来只是三个名词,但落地时要定三件事。
第一是事件粒度。粒度太粗,比如把一整个订单生命周期当一个事件,会丢掉中间动作;粒度太细,比如把用户点击按钮都当事件,数据量会爆炸且没有业务意义。我的判断标准是“这个动作是否直接引起某个资源的数量或状态变化”。选对粒度,后面所有统计都顺。
第二是事件链。业务不是孤立的一次动作,而是有先后依赖的。销售事件发生后,才会有库存减少事件、应收增加事件、收款事件。REA鼓励把每个有因果关系的事件串成链,而不是用状态字段去记录“走到哪一步”。
第三是双向关系。资源从一个人手里流向另一个人,中间一定存在某种对价关系。卖出商品收到钱,这是双向的;采购入库付出去钱,也是双向的。REA把这种双向性称为“双联”,建模时必须能找到与每个流出事件对应的流入事件。找不到双联,业务就是不完整的。
2.3 一个现场例子:下单到结算的事件链
用一个最简单的流程把上面串起来。顾客下单,系统生成一个“销售事件”;库房确认,引发“库存流出事件”;财务确认应收,引发“应收事件”;顾客付款,引发“收款事件”。这四个事件各自关联不同的资源和代理人,但它们通过外键前后衔接。
在旧模型里,流程进度靠订单状态字段表达;在REA里,流程进度是看这些事件到底发生了几个。任何时刻,你都可以从事件表里查出一笔订单发生到了哪一步,也随时可以从事件反推业务状态。这种“状态即多个事件的投影”的思路,做审计、对账单时特别舒服。
3. 进销存Demo落地:从表结构到业务规则
3.1 先定边界:这个Demo要解决什么问题
我在模拟项目X里要做的系统边界很明确:管商品、管库存、管客户订单、管现金。具体要做的事情就是“商品进得去、出得来,钱收得到、对得上”。这个边界刚好是REA最擅长的领域。
在动手设计表之前,我先用一张纸把所有业务动作列了出来:商品建档、采购入库、销售出库、收款、退款、盘点调整。然后把每个动作翻译成REA事件,再标出每个事件涉及哪些资源和代理人。这个过程大概花了半天,但后面建表几乎没返工。
不用担心“资源”太抽象。在这个Demo里,资源只有四类:商品库存、现金、应收账款、应付账款。商品和现金是实体资源,应收和应付是虚拟资源,用来记录“已经欠下、尚未结清”的金额。
3.2 核心表结构与关键字段
REA落地最少需要五张表。先看资源表和事件表,这两个是基础。
-- 资源表 CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_code VARCHAR(32) NOT NULL, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- ITEM / CASH / AR / AP unit VARCHAR(16), is_active TINYINT DEFAULT 1 ); -- 事件表 CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- SALES / PURCHASE / RECEIPT / AR / AP event_time DATETIME NOT NULL, biz_no VARCHAR(64) NOT NULL, is_cancelled TINYINT DEFAULT 0, UNIQUE KEY uk_biz (event_type, biz_no) );为什么事件表必须加业务单号biz_no?因为在真实系统里,事件经常是外部系统触发的,比如电商订单号、支付流水号。没有唯一约束,回调重试时很容易插入重复事件,数据直接脏掉。is_cancelled用于红冲撤销,不要物理删除事件,保留原始痕迹对财务审计至关重要。
接下来是连接资源和事件的“流入流出表”,REA里叫stockflow,这是整个模型的核心:
-- 资源-事件流入流出表 CREATE TABLE stockflow ( stockflow_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction TINYINT NOT NULL, -- 1=流入, -1=流出 quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4), -- 事件发生时的单价快照 stockflow_no VARCHAR(64) );stockflow表就是整条事件链的枢纽。一个事件可以对应一行或多行stockflow。比如销售一笔订单包含水和可乐两种商品,那就生成两行stockflow,方向都是-1,资源不同。库存在事件发生时扣减,扣减的依据就是这两行记录。
最后是事件和代理人之间的参与关系表:
-- 事件-代理人参与表 CREATE TABLE participation ( participation_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role_code VARCHAR(32) NOT NULL -- CUSTOMER / SUPPLIER / SALER / CASHIER ); -- 代理人表 CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_code VARCHAR(32) NOT NULL, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL -- CUSTOMER / SUPPLIER / EMPLOYEE );agent这张表在开发初期常被忽略,后面报表要按销售员、按客户维度统计时,没有它就只能灰溜溜去订单表里凑数据。REA强迫你从一开始把“谁参与了事件”建模清楚,省的是后期的重构成本。
3.3 从事件派生业务动作
表结构建好后,核心逻辑就是从事件表推进业务。拿“销售出库”举例,某个销售事件发生时,事务里做三件事:往event表插一条销售事件记录;往stockflow表插N行流出记录;往participation表插“客户”和“销售员”各一行参与记录。
库存扣减的逻辑不写在事件插入的前置判断里,而是由事件本身触发。换句话说,库存数量是资源表的当前值,但它的每一次变化都能在一张stockflow表里找到对应的事件依据。这个方法比直接在库存快照表上做流水更干净,因为流水不必再单独维护“业务来源”。
应收和应付怎么处理?收款事件发生前,需要先有一笔“应收账款”资源被创建出来。我习惯把AR、AP也建模为虚拟资源,销售事件流出了商品,同时通过双联创建一个AR资源流入事件;后续收款事件再把AR资源流出,现金资源流入。这个设计等高阶对账时需要时,会特别香。
-- 查询客户A在某个时间段的商品销量 SELECT r.resource_name, SUM(CASE WHEN f.direction = -1 THEN f.quantity ELSE 0 END) AS sale_qty, SUM(CASE WHEN f.direction = -1 THEN f.quantity * f.unit_price ELSE 0 END) AS sale_amount FROM stockflow f JOIN resource r ON r.resource_id = f.resource_id JOIN event e ON e.event_id = f.event_id JOIN participation p ON p.event_id = e.event_id JOIN agent a ON a.agent_id = p.agent_id WHERE p.role_code = 'CUSTOMER' AND a.agent_name = '客户A' AND e.event_time BETWEEN :start_time AND :end_time GROUP BY r.resource_name;这个查询完全不用订单表,数据来源是事件表。出库是负向库存增加,收货是正向库存增加,数据语义一致。
3.4 查询与报表:为什么需要汇总层
有朋友看到事件表和stockflow表后第一反应是“这不就是个流水账吗,报表能跑得动吗?”确实,直接拿事件表做聚合,数据量一大就卡。我的做法是在事件表之上加汇总层——按天、按商品、按代理人事先聚合出库存和销售汇总表。事件表管“事实”,汇总表管“效率”。
汇总层的刷新时机要讲究。能在事件事务提交时同步刷的,尽量同步刷;不能同步的,用少量延迟的异步任务刷新。注意汇总表必须是“从事件表重新计算能够得到同样结果”的,杜绝手工改汇总表数据。这样即使某一批汇总数据出了问题,也能一键重算恢复。
报表查询是很多团队在REA建模后第一个翻车的地方,因为思路还停留在“订单表是事实表”的惯性上。实际上REA的事实表是事件和stockflow,所有报表都围绕这个事实层展开。刚开始大家会觉得别扭,但一旦形成习惯,加报表需求就变成加聚合查询,而不是改表结构。
4. 实践里的坑与排查实录
4.1 事件粒度失衡
第一次建模时,我差点把“用户登录”也当成事件,后来仔细一想不对:登录不直接引起资源流入或流出,它只是为后续事件提供代理人身份。事件必须与资源变动强关联,这是我判断粒度的第一准则。
另一个极端是粒度太粗。有一次把“整张订单”作为一个事件,结果订单里有十个商品,每个商品库存变动都要拆开处理,整单事件完全没法表达这种细粒度。后面改成“订单头是分组概念,订单行才对应事件”,才顺了。事件粒度本质上应该与“最小资源变动动作”对齐。
4.2 双向关系的并发一致性
REA强调双联,一定要把流出事件和流入事件配对。早期我在并发环境下吃过亏:两个事件同时发生时,如果配对逻辑写在应用层,容易出现一个流出事件被两个流入事件同时认领,导致数据错乱。
解决思路是把配对约束下沉到数据库。在stockflow或关联表上加唯一约束,保证一个流出事件只能与一个流入事件建立关联。业务上确实存在一次销售分多次收款的情况,那就在应收的虚拟资源上再分细粒度,让每次收款事件都对应一段独立AR资源。原则是“宁可虚拟资源细一点,不要配对关系含糊”。
4.3 性能瓶颈与聚合策略
事件表的数据只增不减,时间一长肯定膨胀。用时间分区表是必备操作,我习惯按月分区,分区键用event_time。统计三年期的数据时,如果每次都扫全表,再好的数据库也扛不住。
针对高频查询,再建一张“按事件类型+资源+日期”的预聚合表。这张表不存明细,只存每天每个资源通过哪种事件类型发生的总流入和总流出量。一次GROUP BY的成本被提前支付,报表查询跑起来就是单表扫描,非常快。
4.4 与财务科目体系的接口问题
REA建模的系统和财务总账系统对接时,最容易出问题。业务事件与会计科目不是一一对应,比如“销售事件”可能同时对应主营业务收入和应交税费,中间需要一张映射表,把“资源类型+事件类型+方向”翻译成财务借贷科目。
我在对接时把映射表做成可配置的,财务调整科目时不用改代码。映射表里至少要包含触发条件、会计科目、借贷方向、金额来源字段。这样REA事件流就能自动生成凭证或过渡分录,财务月结时直接拉事件对账,省去手工整理。
4.5 常见问题速查
| 问题 | 现象 | 排查思路 |
|---|---|---|
| 库存负数 | 商品没货但仍卖出 | 检查销售事件是否先于库存校验事件,把资源可用性校验前置 |
| 事件重复 | 回调重试插入重复记录 | 检查event表event_type+biz_no唯一键 |
| 应收多挂 | 一笔销售对应两笔应收 | 检查双联配对约束,按AR虚拟资源拆分 |
| 报表跑不动 | 聚合查询扫描全表 | 增加分区,建立预聚合汇总层 |
| 对账不平 | 事件流与财务科目不一致 | 检查资源事件类型到会计科目的映射表 |
4.5.1 把REA当方法论而非银弹
不是所有系统都要严格按REA建模。比如一个纯数据展示的系统,没有资源流动,硬套REA只会增加复杂度。REA适用的场景是业务动作密集、需要追踪责任链、且存在明确资源流转的系统。先用它理清思路,再决定要不要完全按它的表结构落地,才是理性的做法。
我在模拟项目X里也保留了折中设计:像商品基础档案、客户地址这类纯属性数据,仍然按普通ER模型建表;只有涉及资源变动的环节,才用事件和stockflow。两种模型并存完全没问题,REA管好“动”的部分,ER管好“静”的部分。
5. 再从REA看向现代架构
5.1 事件溯源与REA的惊人相似
做微服务和DDD的朋友对“事件溯源”应该不陌生,简单说就是不把系统当前状态直接存下来,而是把一系列导致当前状态的事件保存下来,需要时重放事件得到状态。仔细一看,这不就是REA吗?
REA早在上世纪九十年代初就把这套思想用于会计信息系统,比现在流行的事件溯源早了差不多二十年。它把“业务事实”和“当前状态”分离,事件表就是事件日志,资源表就是状态快照。理解了REA,再去看事件溯源架构,会发现很多原则完全相同——不可变事件、可重放、状态由事件推导。
5.2 微服务拆分时的REA边界提示
微服务划分服务边界时,REA的“资源-事件-代理人”三角也能提供启发。一个事件通常只属于一个领域服务,但会涉及多个资源和多个代理人。比如“支付成功”事件涉及资金资源、订单资源、客户和支付渠道,这些在物理上可能属于不同服务。
我在拆分时有个经验:让事件本身成为一个明确的服务间消息协议。服务之间不直接调用对方改数据,而是发布事件,后续服务订阅事件后在自己的资源表里更新。数据模型上仍然是REA,只是物理上被拆开了,逻辑上一眼还是同一个业务链。
6. 收尾前想说的几个实操体会
真要在项目里落地REA,我的建议是别一开始就全量改造老系统。先找一段业务关系不复杂的流程,比如“采购入库到付款结算”,用REA重新建模,跑通之后再逐步扩展到其他模块。数据迁移反而没有想象中难,因为REA模型里每一行库存变动都能对应回历史事件,银行流水、历史订单都能作为事件来源。
还有一点,REA表结构不是死的。有人会纠结“我的事件表要不要冗余客户名称”“资源表要不要冗余当前库存数量”,我的经验是要冗余,只是必须明确区分“事实字段”和“快照字段”。历史归档时,快照字段帮了大忙,不然时间一长,原始参与人改名后,事件链就断了。
梳理完REA之后再回头看传统数据库设计,会觉得很多“状态机僵化”的问题根本不是技术问题,而是初始建模时没有把资源、事件、代理人的关系画清楚。这个三要素模型,是可以写进团队建模规范里的。