第一次在项目代号表里看到“rea”这三个小写字母时,我以为是某个内部工具随手起的简写。直到后来在一个进销存重构项目里连续翻了三天旧代码,才意识到它对应的是一个相当经典的分析框架——Resource、Event、Agent,也就是资源、事件、代理。这套模型的思路非常直接:先别急着想借什么、贷什么,也别急着设计“状态字段”,而是只回答一个最朴素的问题——“谁,在什么时间,做了什么事,这件事让什么资源发生了什么变化”。这个提问方式,对做业务系统、报表、数据迁移审计的人都非常有效,尤其适合那些总被财务口径缠住、一改需求就推倒重来的项目。
它到底能做什么?最直接的效果是把“业务流水”和“财务记账”解耦:底层先保证记录的是真实发生的事件,上层再用这些事件去派生出各种报表和对账结果。这意味着你不需要在项目一开始就把所有科目、所有状态都枚举完备,只要把资源、事件、代理三类对象识别清楚,后续接更多业务的时候基本不用大改表结构。适合谁看?后端开发、系统架构师、业务分析师、实施顾问,还有想摸清业务系统底层逻辑的产品经理,都值得花一顿饭的时间认真读一遍。
1. 为什么 REA 值得每个业务系统设计者重新学一遍
1.1 资源、事件、代理到底对应什么
用最生活化的例子解释一下。你在厨房做饭,食材和调料是资源,洗菜、切菜、炒菜是事件,你自己和帮你打下手的朋友是代理。如果只记一句“今天晚饭花了30元”,后面没人知道你几点炒的菜、用了哪袋面粉、是谁负责放盐。而 REA 式的记录方式是:某人在某个时间,把某种资源从一个状态变成了另一个状态,同时参与了这件事的代理都有谁。
在业务系统里,资源不只是库存商品,还包括现金、应收款、积分、服务时长、办公资产等一切能被企业控制并且有经济价值的东西;事件是采购、销售、入库、出库、收货、付款、领用、盘点这些真正改变资源数量或状态的行为;代理就是参与这些事件的内部员工和外部伙伴,比如采购员、销售员、库管、供应商、客户。三者各有各的定位,不能混着用,后面我会专门讲混用的坑。
REA 这个名字最早是从会计信息系统领域沉淀出来的,但它很快就不只局限在财务软件里。原因是它把“记账”还原成了“记事实”。在传统 ERP 里,财务凭证只是事实的一次编码输出,而 REA 尝试保存事实本身。事实是什么,记录的就是什么,不需要为后续某种报表提前裁剪字段。
1.2 传统借贷模型的三个拧巴点
传统复式记账的思路是“有借必有贷,借贷必相等”。这条规则用来保证账目平衡非常好用,但把它直接当作业务系统的抽象底座,会出现三个很现实的拧巴点。
第一个拧巴是事实裁剪。一条销售凭证可能长这样:借应收账款,贷主营业务收入。已经够完整吗?不够。客户是谁?销售员是谁?卖的是哪个批次?当时的单价是多少?折扣是审批过的吗?退回风险怎么暴露?这些问题如果不强制写进凭证备注,后面做数据分析时全得靠猜。凭证里塞不下太多的业务语义,这是结构性问题。
第二个拧巴是科目表刚性。科目表一旦上线,增加一个业务类型往往意味着要加科目、调映射、改报表。而业务系统真正需要的是快速支持新业务,比如“以前只卖商品,现在要卖服务”“以前线下收款,现在加积分抵扣”。如果每次都动底层会计科目,开发和实施成本会迅速失控。
第三个拧巴是查账靠猜。传统模型里,余额是一个被持续更新的存量字段,但你很难回答“这个余额到底由哪些事件累加得到”。一旦某个环节漏记或重复入账,总量看起来是对的,内部明细却是乱的。正因为这些原因,工程实践里越来越多的团队开始把业务事实层独立出来,REA 正是这一层最经典的结构之一。
1.3 REA 解决什么问题,谁最该学
REA 能解决的问题,可以概括成三句话。
第一,恢复审计轨迹。任何一个库存数量或现金余额的变化,都能追到具体事件、具体参与代理、具体时间点。审计人员问“这部分库存哪来的”,你可以从事件表里拉出对应的采购入库、批次、供应商,而不是只扔过去一张报表。
第二,支撑多系统协同。REA 里记录的是标准业务事实,不绑定某家系统的科目表。A 系统产生“入库”事件,B 系统订阅这个事件后可以直接生成自己的库存视图或会计视图。业务事实作为共享语言,系统间扯皮的机会就少很多。
第三,让扩展变得便宜。如果团队后续要加“设备租赁”业务,只要识别出租设备是资源、出租和归还两个事件、内部管理员和外部客户是代理,再挂到同一套事件表里就行。表结构不用推倒重来,新业务只是旧模型的一个新实例。
谁最该学?做业务系统后端的人最该学,因为你们每天都在跟实体、状态机、流水表打交道;做数据分析和报表的人也建议学,理解事件源后写统计口径会稳很多;实施顾问和产品经理学了以后,和客户聊需求时也能更快抓住“业务核心到底是什么”。
2. 资源、事件、代理:把业务对象和关系的细节全部揉开
2.1 三类实体:把业务对象各就各位
设计一张新系统时,最令团队头疼的不是怎么写代码,而是“这个东西到底算什么”。用 REA 能强制团队把业务对象往三个筐里放。
| 类型 | 英文 | 定义 | 典型例子 |
|---|---|---|---|
| 资源 | Resource | 被组织控制、有价值、参与经营活动的对象 | 库存商品、现金、应收账款、服务工时、积分 |
| 事件 | Event | 在特定时点发生的、改变资源数量或状态的行为 | 采购入库、销售出库、收款、付款、盘点、领用 |
| 代理 | Agent | 事件的参与者,可以是组织内部或个人,也可以是外部组织 | 采购员、销售员、库管、供应商、客户、财务 |
放资源筐的对象,最大的特点是“被事件改变”。库存会因入库变多、因出库变少,现金会因收款变多、因付款变少。放事件筐的对象,核心是“发生在某一时刻的动作”,比如“2025年6月1日上午10点完成收货”。放代理筐的对象,要有明确标识能被追溯,“采购员张三”“供应商某某公司”都可以是代理。
很多同学容易把“订单”当成资源,这不太准确。订单是一种承诺,表示未来可能发生资源变动,它更像事件的前置状态,而不是正在被消耗的资源。同理,“状态”也不该当成资源,“已发货”只是事件发生后的结果描述,可以用事件推导出来。
2.2 关系拆解:资源如何被事件改变,事件如何连接代理
REA 的价值不只在于实体分类,更在于实体之间那几类稳定关系。我在项目里主要关注四种。
第一种是资源到事件的控制关系。一个事件会让一个或多个资源产生数量增减。比如“销售出库”事件让库存商品减少,让应收账款增加。建模时需要在事件和资源之间拉一条线,并记录“方向、数量、单价、发生时间”。
第二种是事件到事件的交换或转化关系。交换关系典型场景是:一方付出资源,另一方获得资源。销售和收款,一个是货物流出,一个是现金流入,它们并不是同一件事,但逻辑上配成一对。转化关系典型场景是生产:投入原料,经过加工事件,产出成品。订单和实际出入库之间可以用“父事件”关联,比如采购订单是收货入库的父事件,这样就能把承诺和实际对应起来。
第三种是代理到事件的参与关系。一个事件至少要有一个负责执行的内部代理,比如库管;通常会有一个外部代理,比如供应商或客户。如果某个事件谁都挂不上,说明这个业务识别还没到位。
第四种是代理到资源的间接控制关系。代理不直接操作资源表,而是通过参与事件来间接影响资源。这层隔离很重要,它保证了“资源不能凭空变多或变少”,所有变化都有来源、有人负责。
2.3 建模时最容易踩的三个概念坑
概念坑往往比代码 bug 更隐蔽。第一个坑是“把单据当成事件本身”。入库单是事件的载体和凭据,库管在系统里点“确认入库”才产生了事件。如果模型里只建了单据表,没建事件记录,后面想知道“货物是什么时候真实到位的”就得靠单据状态或操作日志,又绕又不可靠。
第二个坑是“把状态当成资源”。比如库存余额,它是由一堆出入库事件算出来的结果。如果你把“当前库存 100 件”直接当成资源字段存下来,每次出入库都去更新这个字段,一旦漏更新或并发写,宿主数据和事实就不一致了。资源描述的应该是库存本身,而不是库存的某个瞬时快照值。
第三个坑是“把余额当成事实”。我在一个项目里见过一张余额流水表,里面每条记录记的是“当天结束时还有多少库存”。看起来方便,但过去的任意一天发生了什么变动,只能靠反推。REA 要求回到事件层面:昨天的结存等于事件发生时的初始值,加上昨天所有变动事件的代数和。这才是可以长期信任的数据结构。
3. 从 REA 图到表结构:一个最小进销存系统的完整落地
3.1 先定业务边界:一个最小但完整的采购销售场景
光讲概念容易飘,直接用一个典型场景走一遍。假设某公司经营电子产品贸易,从供应商采购商品,放入仓库,再卖给客户。业务环节包含:采购订单、到货入库、采购付款、销售订单、发货出库、客户收款。
建模第一步不是写字段,而是先列事件链。事件链有两种角色:订单属于承诺事件,表示未来要发生什么;入库、出库、付款、收款属于实际事件,表示真实发生了什么。
- 采购订单:采购员发起,面向供应商,承诺未来收货。
- 收货入库:库管确认,供应商交付,库存商品增加。
- 采购付款:财务执行,现金减少,供应商收到款。
- 销售订单:销售员发起,面向客户,承诺未来发货。
- 发货出库:库管确认,交付给客户,库存商品减少。
- 客户收款:财务执行,现金增加,来自客户付款。
资源在这里有两个:库存商品(按 SKU 细分)和现金(按币种或账户细分)。代理有四个角色:供应商、客户、内部经办人、财务。这样整理下来,整个模型的骨架就出来了。
3.2 画出REA事件链并设计表结构
把上边的分析落到关系型数据库,我常用六张表来承载,先给一套可以直接跑的建表参考。
CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_type VARCHAR(20) NOT NULL, agent_name VARCHAR(255) NOT NULL ); CREATE TABLE resource ( id BIGINT PRIMARY KEY, resource_type VARCHAR(20) NOT NULL, resource_name VARCHAR(255) NOT NULL, unit VARCHAR(20) ); CREATE TABLE event ( id BIGINT PRIMARY KEY, event_type VARCHAR(40) NOT NULL, event_kind VARCHAR(10) NOT NULL, occurred_at TIMESTAMP NOT NULL, reference_no VARCHAR(64), parent_event_id BIGINT NULL, note VARCHAR(500) ); CREATE TABLE event_resource ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4) ); CREATE TABLE event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(40) NOT NULL );解释一下几个容易被忽略的设计点。event_kind字段用来区分承诺事件和实际事件,英文可以存COMMIT或EXEC,查询余额时只统计EXEC。quantity统一按正负号表达方向,正数表示资源增加,负数表示资源减少,这样 SUM 起来非常清爽。unit_price是业务发生时的价格快照,避免未来调价影响历史事实。parent_event_id把实际事件挂到承诺事件上,例如收货入库挂到采购订单,收款挂到销售订单,便于追踪“谁承诺了什么、实际兑现了什么”。
这种建模方式比较接近业务语义层,真正做产品时通常还会加索引、外键、审批状态字段,但核心骨架不变。有人会问,SKU 表、科目表去哪了?它们可以作为resource表的扩展属性,用关联表挂到 resource_id 上,不必一开始就把所有细节塞进主表。
3.3 如何用SQL还原业务事实和库存余额
表结构落地后,最关键的是用查询还原业务事实。先算每种资源的即时库存余额。这里直接使用所有EXEC事件的带符号数量聚合。
SELECT r.resource_name, COALESCE(SUM(er.quantity), 0) AS current_balance FROM resource r LEFT JOIN event_resource er ON r.id = er.resource_id LEFT JOIN event e ON er.event_id = e.id WHERE e.event_kind = 'EXEC' AND e.occurred_at <= CURRENT_TIMESTAMP GROUP BY r.id, r.resource_name;我建议把“余额”定义成“截至某一时点的事件增量之和”,而不是维护一个可能被并发写错的库存表。这样做的好处是,任何历史时点的库存都可重算,只要把时间条件重新设定就行。如果查出负数,说明系统里有出库事件找不到对应入库,或者有负库存发生,问题能第一时间暴露。
再看一个常见需求:查某客户的历史交易。要同时关联事件、资源、代理,才能还原出“客户从什么时候开始,到底买了哪些东西,单价数量分别是什么”。
SELECT e.occurred_at, e.event_type, r.resource_name, er.quantity, er.unit_price, a.agent_name AS operator FROM event e JOIN event_resource er ON e.id = er.event_id JOIN resource r ON er.resource_id = r.id JOIN event_agent ea ON e.id = ea.event_id JOIN agent a ON ea.agent_id = a.id WHERE ea.role IN ('CUSTOMER', 'SALESPERSON') AND a.agent_name = '某客户' ORDER BY e.occurred_at DESC;这个查询好理解,也很适合拿去给审计人员演示。只要事件表里留有原始参照号和父事件,他们一眼就能看出每笔业务闭环是完整的。
第三个实用场景是按天做入库、出库统计。把事件时间落在“日”维度,把正数数量算为入、负数算为出,然后汇总。
SELECT CAST(e.occurred_at AS DATE) AS biz_day, r.resource_name, SUM(CASE WHEN er.quantity > 0 THEN er.quantity ELSE 0 END) AS in_qty, SUM(CASE WHEN er.quantity < 0 THEN ABS(er.quantity) ELSE 0 END) AS out_qty FROM event e JOIN event_resource er ON e.id = er.event_id JOIN resource r ON er.resource_id = r.id WHERE e.event_kind = 'EXEC' GROUP BY CAST(e.occurred_at AS DATE), r.resource_name ORDER BY biz_day DESC;如果用的是带DATE_TRUNC的数据库,可以把CAST(e.occurred_at AS DATE)换成DATE_TRUNC('day', e.occurred_at),效果一样。
3.4 一套能直接迁移的建模检查清单
每次开始一个新的 REA 建模,我都会按下面的清单过一遍,基本不会漏业务环节。
- 列出业务全景,至少把“采购、库存、销售、资金”四条主线找出来。
- 识别资源:哪些对象有经济价值并且会被事件改变数量或状态。
- 识别事件:每个资源变化背后,对应的动作和发生时点是什么。
- 识别代理:每个事件至少挂一个内部代理和一个外部代理。
- 区分承诺与实际:订单、合同是承诺,出入库、收付款是实际。
- 确认事件顺序:同一个业务闭环里,事件之间是否有先后依赖。
- 定好事实粒度:一个事件代表一次真实动作,不要一次塞多个 SKU 之外还塞一堆无关状态。
这套清单不需要写代码,只需要在画板和评审会上多花半天时间。它最大的价值是让团队在动手建表之前,先统一对业务核心概念的理解。
4. 高频踩坑记录:REA 建模中的常见问题与自查方法
4.1 高频问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 新业务一加就改表 | 没有把业务事件抽象成统一模式 | 回到三类实体重新归类,别让某个业务独占特殊表 |
| 发货数量总对不上 | 发货事件没有严格挂在对应的销售订单父事件上 | 用parent_event_id闭环追踪承诺与执行 |
| 调价后历史报表变了 | 价格存在了资源主表里 | 把价格快照放到event_resource.unit_price |
| 退货、折扣没法表示 | 只在出库事件上改数量 | 把退货、折扣拆成独立事件,保留原始方向 |
| 会计科目和业务事实冲突 | 尝试用 REA 直接替代总账 | REA 做业务事实上游,会计科目做下游视图 |
这五类问题我基本每个项目都遇到过。重点说下退货和折扣。以前在一个项目里,为了省事,库存出库 10 件后客户退了 2 件,开发直接在原事件上把数量改成 8。结果产品人员再也说不清“到底原单出过多少、退过多少”,审计更是一脸茫然。正确做法是保留出库事件,结构不变,再建一个退货入库事件,数量是正的 2 件,挂在同一个销售订单父事件下。整个业务流才清晰。
折扣也一样,不建议在总价上直接减一笔,而是把“商品销售”和“给予折扣”建模成两个有引用关系的事件,或者至少放到事件明细里留痕。这和传统财务里“原价、折让”分开记账是一个道理,但 REA 会让这件事更自然。
4.2 三招自查模型是否健康
经验多了以后,我总结出三招快速检查手段,每次评审模型都用得上。
第一招,问资源变动有没有事件。随便拿一个资源,比如“库存商品”,往下追最近的每一次数量变化,是不是都能对应到一条EXEC事件。如果发现某个变化没有任何事件支撑,大概率是有手工改数据或者系统外逻辑漏了记录。这正是数据不干净的根源。
第二招,问事件有没有内外代理。一个采购入库事件如果只挂了采购员,没挂供应商,审计时就没法回答“库存是哪家供应商送来的”;一个销售出库事件如果只挂了库管,没挂客户,客户维度分析就全部失真。没有完整代理的事件,只能叫半截事件。
第三招,尝试用一句话复述业务。随便挑一条事件记录,对着数据说“谁,在什么时间,做了什么事件,使得哪个资源增加了或减少了多少”。能说出来,模型基本健康;说不出来,就说明事实漏记了一环。这句提问模板可以直接迁移到产品需求评审里,比讨论状态字段有用得多。
4.3 和会计科目共存的一线经验
有人会担心 REA 是不是要推翻财务总账。我在真实项目中得到的经验是:不要这么干。财务总账有严格准则,直接替换风险很大。更好的方式是让 REA 成为业务事实层,会计凭证作为它的下游产物。
每次发生一个实际事件,系统可以生成一张对应的记账凭证,比如库存增加的同时贷记应付账款。事件表保留原始业务事实,凭证表保留会计口径。二者之间建立唯一的event_id关联。审计要查业务凭证,可以从 REA 事件出发;财务要出报表,从凭证表出发。两条链路互相对得上。
我还试过更激进的做法:先把所有的采购、销售、收付款都落到 REA 事件表里,然后再写一个轻量 ETL,把事件转换成总账分录。结果非常稳定,因为事件表里的事实完整,凭证生成规则不管怎么改,都不会缺原始数据。反而是最初直接设计凭证表,经常遇到“金额能对上但明细对不上”的尴尬局面。
5. 两个让我少加班的建模习惯
5.1 先画事件链,再写字段
以前做系统设计,习惯是先打开建表工具,想着“客户表要哪些字段,订单表要哪些字段”,顺着字段走,结果业务越做越复杂,字段越加越乱。后来改成先画事件链,在纸上从“承诺事件”到“实际事件”,把全链路排出来,再回头设计表结构。这样有个明显的好处:核心流转一眼可见,字段只是事件和资源的属性说明,不再是设计的主线。
比如做采购付款,如果先画事件链,自然会意识到收款和付款分别牵扯到两个代理、两个资源方向,而不是在一个“收支流水表”里加一堆又像收又像付的字段。事件链画好了,代码写起来反而更快。
5.2 把状态改成“最近一个事件”
很多系统喜欢给业务对象挂一个状态字段,比如订单状态:待支付、已支付、已发货、已完成。看起来直观,但状态之间可能漏掉中间态,产品经理天天加新状态,代码也不得不跟着改。
REA 给我的启发是,状态可以由事件推导出来。订单是否已支付,只看有没有对应的“支付成功”事件;是否已发货,只看有没有对应的“发货出库”事件。不需要为“已发货”单独维护一个布尔字段,只需要查询事件表。如果担心性能,可以把“最近一次事件”缓存列加在订单主表上,但它的更新必须由事件产生,而不是人工手工改。
这个习惯帮我省了很多不必要的状态同步问题。每次新需求说要加“已取消”“已退款”“已部分发货”,我先问一句:这是不是一个新事件类型?如果是,就新增一个事件类型,然后把“状态”作为派生视图重新计算。系统结构没乱,需求响应也快了。
说到底,rea 这套方法论不是一个银弹,但它是目前我在业务系统设计里见过的最接近“表达业务真相”的语言。下次遇到改不下去的老系统,不妨先别急着加字段,试着把现有业务往资源、事件、代理三个筐里重放一遍。很多看起来无解的口径之争,往往当场就能少掉一半。