☰
REA模式详解:用资源、事件、主体重构业务数据模型
2026/10/14 13:03:47 网站建设 项目流程

最早看到 REA 这三个字母,我第一反应是 React 的简写。后来在做一个库存对账系统的重构时,才对它有了真正的认识:REA 是 Resources-Events-Agents 的缩写,意思是资源、事件、主体。这套模式看起来像会计记账的思维框架,但放在现代业务系统设计里,比常见的“订单表 + 库存表 + 流水表”那一套要耐用很多。如果你做过订单、库存、财务或供应链系统,并且被各种状态字段和手工对账折磨过,这篇内容值得停下来看看。

1. 为什么我决定把这套思路单独拎出来讲

1.1 传统业务表结构越改越乱的根源

做过几年业务系统的人应该都有一个共同感受:一开始设计表结构时非常清爽,业务方提需求,我就在主表上加一个status字段,比如订单表从“已创建”到“已支付”到“已发货”到“已完成”。等流程继续扩展,再出现“已退款”“部分退款”“已取消”“已关闭”,然后就得加更多状态位,甚至用state_machine表去维护状态流转规则。前期开发很快,后期维护却越来越痛苦。

这种传统的 CRUD 设计本质上是把“当前状态”作为唯一真相存储下来,但状态是怎么来的、经过哪些环节、谁在什么时间改了状态,都被覆盖掉了。想追溯只能靠操作日志,而日志大多只记了“谁改了字段”,没有记录业务层面的资源增减。做对账的时候就要写几十行 SQL 去关联订单、支付流水、库存流水,稍微有一个单子对不上,整个晚上就耗在数据比对里了。

我当时接手的那套库存系统就是这样:库存汇总表记录了“当前库存数量”,库存流水表记录了每一次变动,但两张表的更新不在同一个事务里,经常出现汇总表数量和流水累计数量不一致。拿着明细去找原因,又发现流水记录居然没有记录“谁操作的”和“这个操作对应哪个上游业务单”。这种系统不是不能修,而是每修一次就得多加一个补偿逻辑,修到最后没人敢动。

1.2 REA 的核心:不存状态,存事件

REA 模式的思路完全不一样。它不把“当前状态”当作第一公民,而是把“业务事件”当作核心。每一个业务行为都被记录成一个带时间戳、带参与主体、带资源增减的事件,当前状态则可以通过回放事件计算出来,或者另建一个汇总表作为缓存。这么说可能有点抽象,我用一个采购流程来对比。

老式设计:purchase_order表记录订单头,purchase_item记录商品明细,stock表直接改库存数量,payment表记录付款。每张表之间靠外键关联,谁先更新谁后更新靠代码决定,逻辑分散在服务层。

REA 设计:会先把“采购入库”这个动作作为事件落在event表里,然后事件明细里记录资源(商品、现金)的增减,主体(采购员、供应商、仓库管理)分别关联到这个事件上。最终“库存数量”和“应付账款”都只是事件计算出来的结果。如果对账,直接按时间维度把事件聚合一遍,所有数据都来自同一个事实来源。

1.3 这套思路从哪里来

REA 最早是会计信息系统领域里的一个语义建模框架,取的是 Resources(资源)、Events(事件)、Agents(主体)三个英文单词的首字母。会计学本身有一句很古老的话:任何一笔交易都涉及“有资源流入、有资源流出、有人负责”。比如现金购买库存,现金减少、库存增加,采购员发起了动作,供应商交付了资源。REA 把这个原理抽象成了通用的软件建模方法。

这个背景也决定了它的天然优势:审计性好、可追溯性强、不容易出现状态分散导致的数据不一致。放在今天的微服务、事件驱动架构里,这套思路几乎可以和事件溯源无缝对接,所以我觉得它值得重新被讲一遍。

2. 核心细节拆解:资源、事件、主体的边界

2.1 资源:系统里真正有价值的东西

一说到资源,很多人会理解成“数据库里的一条记录”,这就偏了。REA 里的资源必须是“在经济上有价值的对象或服务”,可以是商品、现金、原材料、固定资产,甚至可以是某种服务工时。关键特征是有数量或者金额属性,可以被交换、消耗、生产。

判断一个东西是不是资源,我常用一个土办法:把系统里所有名词拿出来,问一个问题“这个对象会不会因为一次业务行为而发生变化?”比如“客户”不会因为一次采购而变化(虽然客户信息会更新,但变化是属性变化,不是资源的增减),所以客户不是资源。“商品库存”会随入库增加、随出库减少,所以它是资源。“订单额”会随下单增加、随退款减少,它也是可度量的资源。

这里容易踩的坑是把“单据”或者“状态”当成资源。订单、出库单、发票这些属于“事件的组合体”或者“业务文档”,不是资源本身。如果建模时把它们当成资源,就会在事件表里引入大量与“资产增减”无关的数据,导致后续聚合逻辑混乱。

2.2 事件:时间轴上的事实,而不是状态

事件在 REA 里代表一个发生在特定时间点的业务行为,是资源和主体之间的桥梁。比如“销售出库”是一个事件,它把商品资源从仓库转移到客户手里;同时它会让库存减少、应收账款增加。“采购入库”也是一个事件,它让原材料增加、应付账款增加。

事件最基本的特征是不可变性。一旦发生了,就不能直接 UPDATE 或 DELETE,只能通过新增一个反方向事件来“冲销”。这一点和会计里的“红字冲销”很像。很多传统表设计喜欢把“误操作”直接删除流水,这在 REA 模型里是大忌。正确的做法是新增一个“入库冲销”事件,让原事件保留,新的对冲事件与之关联。

还有一个小细节:事件必须有明确的时间戳,最好还有来源标识(比如上游系统单据号、终端 IP),这样审计追查的时候才能知道每一笔事件是怎么来的。

2.3 主体:谁参与了,谁该负责

主体可以是个人、部门、公司,甚至是一个系统角色。REA 里一个事件通常连接两个以上的主体:一个是发起方,一个是接收方。比如“销售出库”事件,发起方是销售人员,接收方是客户;“采购入库”事件,发起方是采购人员,接收方是供应商,但实际操作时还需要仓库管理员确认。

主体和资源之间必须通过事件关联,不能直接让主体持有资源。这也是很多关系模型容易出错的地方:给客户表加一个balance字段,直接记录欠多少钱。这在 REA 里是不允许的,因为余额是“事件累计的结果”,应该通过查询事件聚合出来。主体表只需要保留组织、角色、联系方式等相对静态的信息。

2.4 最小完整的 REA 结构

一个最小完整的 REA 业务模式可以用一句话概括:一个事件,让至少一个主体把一种资源转移给另一个主体,或者改变一种资源的属性。落到数据库上,通常会有四类表:资源表、主体表、事件表、事件明细表。事件表和事件明细表是一对多关系,明细表记录资源增减数量及影响方向。

举个例子:“用现金购买商品”这个事件,事件表记录“采购入库”本身;事件明细表里会有两条记录,一条是“现金 -1000”,一条是“库存商品 +10”。如果把“现金”和“库存商品”看作资源,那么这次事件就同时影响两种资源,而且一增一减是平衡的。

理解了这个“一增一减”的平衡关系,很多业务逻辑都能被统一起来。无论是采购、销售、调拨、赠送、报废,都可以抽象成资源在事件中的增减,不再需要为每种业务类型单独写一套状态机。

3. 实操过程:从传统库存表重构到 REA

3.1 一个贴近现实的场景

假设某公司有一套仓库管理系统,目前只有一张库存流水表stock_log,字段包括log_id、sku_id、quantity_change、log_time、remark,没有任何主体信息。业务上经常出现两个问题:一是库存汇总数和流水明细对不上,二是仓库管理员和采购员互相甩锅,因为不知道某次出库是哪个环节导致的。

我们目标很明确:在保留现有业务接口的前提下,把底层的数据模型改造成 REA 风格,让每一笔变动都记录资源、事件、主体三个维度,最后能支持自动对账和完整的审计追溯。

3.2 先重新梳理业务概念

我先不动代码,而是带着业务方把“现有业务名词”重新过了一遍。这一步非常重要,比直接建表更重要。

  • 资源:仓库里的“库存商品”、公司账户里的“应付金额”,以及“采购预算”这类可以量化的东西。
  • 事件:采购入库、销售出库、库存报废、库内调拨、盘点调整。
  • 主体:采购员、销售员、仓库管理员、供应商、客户、物流公司。

梳理完之后可以很清楚地看到,原来的stock_log只记录了quantity_change,却丢失了“这个变化由哪个事件产生”“对应哪个主体”这些信息。要补全这些信息,就得把字段补到事件和事件明细中去。

3.3 表结构设计与建表 SQL

我设计的表结构如下,这里用比较通用的 SQL 语法表达,方便你在各种关系型数据库里迁移。

-- 资源表:保存所有可量化、可交换的业务对象 CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_code VARCHAR(64) UNIQUE NOT NULL, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- e.g. GOODS / CASH / SERVICE unit VARCHAR(32), is_active BOOLEAN DEFAULT TRUE ); -- 主体表:人、部门、组织 CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_code VARCHAR(64) UNIQUE NOT NULL, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL, -- e.g. EMPLOYEE / SUPPLIER / CUSTOMER / DEPARTMENT contact_info VARCHAR(256) ); -- 事件表:记录每一次业务动作,只追加,不修改 CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_time TIMESTAMP NOT NULL, event_type VARCHAR(32) NOT NULL, -- e.g. PURCHASE_IN / SALE_OUT / TRANSFER / ADJUST source_agent_id BIGINT NOT NULL, -- 发起主体 target_agent_id BIGINT NOT NULL, -- 接收或受让主体 remark VARCHAR(512), FOREIGN KEY (source_agent_id) REFERENCES agent(agent_id), FOREIGN KEY (target_agent_id) REFERENCES agent(agent_id) ); -- 事件明细表:记录每个事件涉及到的资源增减 CREATE TABLE event_item ( item_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, quantity_delta DECIMAL(18, 4) NOT NULL, -- 正数代表增加,负数代表减少 unit_price DECIMAL(18, 4), amount_delta DECIMAL(18, 4), batch_no VARCHAR(64), -- 可选,用于批次追溯 FOREIGN KEY (event_id) REFERENCES event(event_id), FOREIGN KEY (resource_id) REFERENCES resource(resource_id) );

这里最关键的是quantity_delta字段。我见过不少团队用“数量方向字段”来做,比如direction等于IN或OUT,然后数量永远为正。这么做并不是不行,但在做聚合统计时,SUM(quantity_delta)的效率远高于SUM(CASE WHEN direction='IN' THEN quantity ELSE -quantity END)。所以直接让数量带正负号,是天坑少走一步的关键。

3.4 核心查询:实时库存余额

改造之后,“当前库存”不再需要单独维护一张库存汇总表,直接聚合事件明细即可:

SELECT r.resource_code, r.resource_name, SUM(ei.quantity_delta) AS current_stock FROM event_item ei JOIN resource r ON ei.resource_id = r.resource_id JOIN event e ON ei.event_id = e.event_id WHERE r.resource_type = 'GOODS' GROUP BY r.resource_code, r.resource_name HAVING SUM(ei.quantity_delta) <> 0;

这条查询跑在所有历史事件上,因此一定能和流水明细对得上。如果对账出现问题,问题只会出在“是否漏记了事件”,而不会出在“汇总表更新顺序错误”。

当然,全量聚合在数据量大的时候性能会下降,所以生产环境需要配合快照表或物化视图。常见做法是每晚跑一次全量增量,生成resource_balance_snapshot表,记录resource_id, balance, snapshot_time。查询时先读最近的快照,再累加快照之后的新事件,这样既保证准确,又不会每查一次都全表扫描。

3.5 审计追踪其实很自然

以前做审计追踪,要拼多个日志表。现在是反过来的:只要指定一个事件event_id,就能查到这个事件是谁发起的、谁接收的、有哪些资源参与、数量变化是多少、上游单据号是什么。比如要查某次出库的完整链路,SQL 大概是这样:

SELECT e.event_time, e.event_type, src.agent_name AS source_agent, tgt.agent_name AS target_agent, r.resource_name, ei.quantity_delta, ei.batch_no, e.remark FROM event e JOIN agent src ON e.source_agent_id = src.agent_id JOIN agent tgt ON e.target_agent_id = tgt.agent_id JOIN event_item ei ON ei.event_id = e.event_id JOIN resource r ON ei.resource_id = r.resource_id WHERE e.event_id = ?;

这个查询写起来很直观,不需要额外加操作日志表。所有跟这次业务行为相关的信息都在同一套模型里。

4. 常见问题与排查技巧实录

4.1 资源的粒度到底怎么定

这是建模时问得最多的一个问题。“库存商品”可以按照 SKU 粒度拆,也可以按照批次粒度拆,甚至按照单件序列号粒度拆。我的建议是:资源粒度由业务对“独立控制”的要求决定。如果业务需要追踪效期、批次、序列号,就把每个批次作为独立资源;如果只关心整体库存数字,就按 SKU 做一类资源。

举例来说,某食品仓库需要记录不同批次的过期时间,如果只按 SKU 建一个资源,批次信息就无处安放。这时可以把“批次”作为资源主键,比如resource_code用SKU-20240101-A这种格式,一个批次一条资源记录。事件明细里的batch_no字段也可以同时保留,形成双保险。

4.2 事件方向搞反了怎么办

事件明细里用正负数表达增减,但方向到底谁说了算?我的经验是:以“资源流入系统”的视角来定正负。入库、采购、盘盈是正数;出库、销售、盘亏是负数。这样可以确保库存余额是SUM(quantity_delta)。如果从财务视角看,现金类资源有时会相反,比如收款是现金增加,付款是现金减少,但正负号依然符合这个规则。

如果你把某次出库的负数写成了正数,不要直接 UPDATE。正确做法是新增一个“出库冲销”事件,把数量设为负数,再重新做一次正确的出库事件。这样才能保留审计痕迹,也不违反事件不可变性。

4.3 事件和业务单据的对应关系怎么处理

实际系统中,一个业务单据可能对应多个事件。比如一个“采购订单”可以分多次到货,每一次到货都是一个独立的“采购入库”事件;一次“销售订单”可能经历多次部分发货,每一次发货都是一个“销售出库”事件。

处理这种一对多关系时,我不会把业务单据 ID 放到事件表里,而是建议建一张关联表event_business_rel,记录business_type、business_id、event_id。这样事件表保持纯净,上游单据类型变化时不用改事件表结构。

4.4 事件表越来越大,性能扛不住怎么办

事件只追加的特性会让数据量快速增长。解决办法主要有三个,按优先级排序:

  • 按时间分区:比如按月分区,老数据归档到冷存储,查询默认只扫最近几个月。
  • 快照表:每天生成资源余额快照,日常高频查询走快照,深度分析才扫全量事件。
  • 汇总表:针对固定维度(比如“按天”“按 SKU”)做增量聚合,需要时直接查汇总结果。

我个人的习惯是:一张事件表加一张日汇总表。日汇总表按event_date、resource_id做主键,每天用定时任务从事件表聚合生成。如果某天需要追溯细节,再回事件表明细查。这种结构可以应付千万级到亿级事件数据,不需要一上来就上重型中间件。

4.5 常见误区速查表

误区后果建议
把订单状态当成主数据状态多导致 if/else 满天飞把状态还原成事件序列
删除或修改历史事件丢审计线索,对账不平用冲销事件处理错误
不记录主体信息无法定位责任人事件表必须带 source_agent_id 和 target_agent_id
单独维护库存汇总且无快照汇总表与明细表不一致汇总表只作为缓存,从事件计算
把资源粒度设得过大批次、序列号信息丢失按业务控制要求拆分资源
事件表缺少业务来源标识无法回溯上游单据增加关联表或来源单号字段

5. 我个人踩过几次坑之后的体会

REA 模式真正适合的场景是那些“需要回答钱和物从哪里来、到哪里去”的系统。如果只是做一个简单的内容管理系统,强行套 REA 反而会增加额外的表和数据量。但只要你做的系统牵涉到库存、资金、对账、审计,那这套思路值得提前引入,而不是等项目上线后再补事件溯源。

我最开始也觉得“资源、事件、主体”这套概念有点学院派,真正实操时才发现,它最大的价值不是提供一套新框架,而是逼着我把业务里的每一个动作都描述清楚:谁在什么时间,动了什么资源,产生了什么影响。顺着这个思路建出来的系统,后续加需求、做核对、追踪异常,都会轻松很多。

如果允许我把 REA 用到下一个项目里,我一定会从第一版就开始按这个模型建表,而不是等状态字段叠到几十层之后再痛苦重构。先不用急着做出大规模事件溯源或复杂聚合,只要把“事件不可变”和“资源增减明细化”坚持住,就已经比大多数传统 CRUD 系统前进了一大步。

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

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

立即咨询