如果你也在设计业务系统,REA 这三个字母值得你多花十分钟认真看一下。REA 是 Resource 资源、Event 事件、Agent 代理的缩写,它不是我定义的新框架,也不是某个开源库,而是一种把企业里各种单据、流程、权限全部映射到三类实体上的领域建模视角。最近我把一个中台项目按 REA 重新梳理了一遍,订单、库存、收付款这些曾经互相打架的模块,第一次在同一个数据模型里闭环了。这篇就把拆解过程、建模规则、表结构落法和踩坑实录完整写出来,适合后端开发、业务架构师,以及所有正在被进销存、订单、财务系统折磨的人。
1. 整体设计思路拆解
1.1 为什么业务系统总是改一崩三
传统做业务系统,大多数团队的第一反应是照界面建表:前端有个订单列表,就建一张 order 表;财务要开票,就建一张 invoice 表;仓库要管货,再建一张 stock 表。这种做法的短期效率很高,但业务一复杂就会露出马脚。订单表要记录订单状态,于是不断加状态字段;订单状态多了以后,订单和发货单、退款单之间又要同步状态;最后所有表都变成一张巨型状态机,谁也不敢动。
REA 模型的思路完全不同。它先把业务系统剥到只剩三样东西:谁(Agent)在什么时间(Event)让什么资源(Resource)发生了变化。订单不是一个实体,而是下单这个事件产生的结果;发货也不是一个独立单据,而是库存资源减少、物流资源交付的过程。相比“表驱动”建模,REA 更接近业务的真实语义,天然具备可追溯、可对账、可审计的能力。
1.2 从一张订单看三类实体怎么落地
我用一个最普通的电商购物场景说明。你在网上买一台手机,账面上发生了什么?
- 手机本身是资源,它有库存数量、有库存价值,可以被销售事件消耗。
- 你付的款项也是资源,可以是支付宝余额、微信余额或者银行账户资金。
- “用户下单”“用户付款”“仓库发货”都是事件,每个事件都在某个时刻改变了资源的状态。
- 用户、店铺客服、仓库操作员、供应商,这些参与事件的角色都是代理。
这里最关键的区别在于:订单不是手机,也不是钱,它是连接资源和代理之间一系列事件的业务凭证。很多团队把订单表当成建模终点,就是因为把凭证和事实混为一谈。凭证可以被驳回、作废、修改,但事件只要发生了,就应该是一条不可变的事实。用 REA 建模,本质是把“人为填表单”的过程,改写成“系统记录事实”的过程。
1.3 这套抽象带来了什么实际收益
REA 建模的最大收益不是理论正确,而是让业务系统具备了可演进性。具体来说有四条:
- 新增业务动作时,不需要给每张表单建新表。无论是新增“转仓”还是“盘点”,本质上都是让库存资源发生增减的事件,往事件表里加一种类型即可。
- 权限和风控更好做。因为每个事件都有明确的发起代理和接收代理,可以统一做操作留痕、额度控制、敏感资源校验。
- 财务对账变成一条查询语句。库存、应收、应付、资金余额都可以从事件事实中实时计算出来,不需要每张表单独维护一份汇总数字。
- 跨系统数据能对齐。供应链系统和财务系统使用同一套资源-事件定义,各系统只需要传事件流,不需要互相同步对方的业务表。
从传统 CRUD 视角切换到 REA 视角,刚开始会觉得“我好像少了很多表”,实际运作起来才会发现,少的都是重复的状态字段和同步接口。
| 维度 | 传统表驱动建模 | REA 建模 |
|---|---|---|
| 核心对象 | 业务单据(订单、发票、出库单) | 资源、事件、代理 |
| 数据特征 | 状态可变,依赖更新 | 事件只增不改,状态可推导 |
| 扩展方式 | 加字段、加表、加状态机 | 加事件类型、加资源类型 |
| 对账难度 | 多表联查,口径混乱 | 一张事件表按条件汇总 |
| 审计能力 | 依赖操作日志补记 | 事件本身即审计日志 |
2. 核心细节解析与实操要点
2.1 三个要素到底怎么识别
REA 虽然只有三个实体,但很多人在第一个环节就卡住:某个业务对象到底是资源、事件还是代理?
资源有几个明显特征:它是业务所关心的、有价值的事物;它会被事件影响数量或状态;它要能被计量、要么有库存量、要么有账面金额。商品是资源,现金、应收款、积分、合同额也是资源。需要注意,不要急着把“客户余额”设计成资源表上的一个字段,它本质上是由多个收款事件、退款事件和消费事件聚合出来的“推导资源”。
事件的特征是:发生在某个时间点;和至少一个资源的增减直接相关;记录了数量、金额、单价等事实。下单这个动作在 REA 里通常属于“承诺事件”,而真正改变资源的是付款、发货这类“执行事件”。设计数据库时,我会把承诺事件和执行事件都落在事件表里,但用 event_type 区分,避免把没发生资源变动的承诺写进库存口径。
代理是最宽泛的角色:人、公司、部门、系统节点都可以是代理。同一个主体在不同事件里可以担任不同代理。比如同一个客户,在“销售事件”里是接收方,在“退货事件”里是发起方。不要把“代理”和“用户”表简单画等号,要按业务角色建模,否则后续做权限、分账、佣金核算时会非常别扭。
2.2 七条关系别乱连
REA 模型有三类实体,但实体之间的联系远比实体本身重要。最常用的关系主要有七种,我整理成实操时重点核对的关系:
- 资源参与事件:一个事件会影响一个或多个资源,比如“销售发货”同时减少库存商品资源、增加发出商品资源或应收款资源。
- 事件与事件互逆:一个事件消耗资源,必然有另一个事件补充或接收资源,这被称为经济二元性。比如发货事件对应收款事件,采购入库事件对应付款事件。
- 代理参与事件:每个事件都有发起方和接收方,缺少任何一个方向,将来审计都会缺一条关键链路。
- 资源归属于代理:商品在哪个代理的管辖范围内,资金余额属于哪个客户。
- 代理之间关系:客户归属哪个销售、供应商由哪个采购员负责。
- 资源与资源之间:比如品类上下级、计量单位换算、汇率折算关系。
- 计划与执行之间:比如销售合同总量对应多次发货事件,生产计划对应多次领料事件。
这层关系里最容易忽略的是“经济二元性”。很多系统只记录了收款,没记录对应的发货,导致财务说款项到账了,仓库说货没走,两边口径永远对不上。实际上只需要在事件表里设计一个对应的关联事件 ID 或关联业务引用,就能把互为因果的两个事件串成一条完整链条。
2.3 四要素字段:数量、金额、时间、代理
我在真实系统里为每个事件表保留四个核心字段,无论业务怎么扩展,这四个字段不会变。
首先是数量,记录资源变动的规模;其次是金额,记录资源变化的价值;然后是时间,统一使用带时区的时间戳,所有业务日期、操作日期、财务日期都从原始时间推导;最后是代理,用 from_agent_id 和 to_agent_id 分别表示事件的两个方向。
这四个字段最大的作用,是让“报表口径”变成确定性的查询,而不是依赖开发猜。比如财务要本月收入,不再需要去解析订单状态、发票状态、退款状态,只需要把事件表里“确认收入”类型的事件、时间范围、数量与金额汇总出来。只要事件被正确写入,所有下游看数都能从同一张表取数,这就是 REA 对报表团队最大的善意。
3. 实操过程与核心环节实现
3.1 用 SQL 落地基础表结构
我这里以 PostgreSQL 为例给出一个极简但能跑通闭环的落表方案。先建代理表和资源表。
CREATE TABLE agent ( id bigserial PRIMARY KEY, agent_code varchar(64) NOT NULL UNIQUE, agent_type varchar(20) NOT NULL, -- internal / external name varchar(200) NOT NULL, ext jsonb ); CREATE TABLE resource ( id bigserial PRIMARY KEY, res_code varchar(64) NOT NULL UNIQUE, res_type varchar(30) NOT NULL, -- goods / cash / receivable name varchar(200) NOT NULL, unit varchar(20) NOT NULL, current_qty numeric(18,6) NOT NULL DEFAULT 0 );接着是事件表,所有真正改变资源的事实都记在这张表里。
CREATE TABLE event_record ( id bigserial PRIMARY KEY, event_no varchar(64) NOT NULL UNIQUE, event_type varchar(30) NOT NULL, resource_id bigint NOT NULL REFERENCES resource(id), from_agent_id bigint REFERENCES agent(id), to_agent_id bigint REFERENCES agent(id), direction varchar(10) NOT NULL, -- in / out quantity numeric(18,6) NOT NULL, amount numeric(18,2) NOT NULL, event_time timestamptz NOT NULL, biz_ref varchar(128), related_event_id bigint REFERENCES event_record(id) ); CREATE INDEX idx_event_resource_time ON event_record(resource_id, event_time); CREATE INDEX idx_event_biz_ref ON event_record(biz_ref);很多朋友看到这里会问:库存数不是单独维护在 resource 表里吗?为什么事件表还要记录 quantity?答案是,resource 里的 current_qty 只是冗余的“当前值”,真正的历史事实全部靠 event_record 推导。这样即使 current_qty 因为并发更新出错,也可以用sum(quantity)重新重建出一个准确的库存数。
3.2 一单业务里的完整事件链
我以一次标准销售流程说明事件链如何形成。假设代理商 A 向公司购买 10 台设备,每台 2000 元,先发货后付款。真实的业务事实链是:
- 发货事件:资源“成品库存”减少 10,方向 out,from_agent_id 是发货仓对应的内部代理,to_agent_id 是代理商。
- 应收事件:资源“应收账款”增加 20000,方向 in,from_agent_id 是代理商,to_agent_id 是公司。这一步用来抵消库存减少带来的资产变化,保证资产负债表平衡。
- 收款事件:资源“银行账户”增加 20000,方向 in,from_agent_id 是代理商,to_agent_id 是公司;同时资源“应收账款”减少 20000,方向 out。
对应到代码,核心是把多条事件记录放进同一个数据库事务。
BEGIN; INSERT INTO event_record ( event_no, event_type, resource_id, from_agent_id, to_agent_id, direction, quantity, amount, event_time, biz_ref ) VALUES ( 'EV-20250110-0001', 'SALE_OUT', 11, 1002, 2001, 'out', 10, 20000, now(), 'ORD-20250110-001' ); INSERT INTO event_record ( event_no, event_type, resource_id, from_agent_id, to_agent_id, direction, quantity, amount, event_time, biz_ref ) VALUES ( 'EV-20250110-0002', 'AR_CREATE', 22, 2001, 1001, 'in', 1, 20000, now(), 'ORD-20250110-001' ); UPDATE resource SET current_qty = current_qty - 10 WHERE id = 11; COMMIT;这个事务里,前两条 INSERT 是历史事实,UPDATE 只是缓存当前库存。如果后续发生退款,不需要修改原始事件,只需要新增一条红字冲销事件,把库存加回去、应收款减回去。这样做的好处是所有历史轨迹不会被篡改,审计时只看 event_record 就能复原当天任何一笔操作。
3.3 对账和查询直接变成数学题
REA 模型真正爽快的地方是对账。以前查“某代理商还欠多少钱”,要先关联订单表、发票表、收款表、退款表,中途还要处理状态字段不等于某个值的过滤条件。现在只需要:
SELECT resource_id, sum(CASE WHEN direction = 'in' THEN quantity ELSE 0 END) AS total_in, sum(CASE WHEN direction = 'out' THEN quantity ELSE 0 END) AS total_out FROM event_record WHERE from_agent_id = 2001 OR to_agent_id = 2001 GROUP BY resource_id;查库存也一样,不需要打开库存表,直接把商品资源的所有入库事件、出库事件、盘盈盘亏事件加总,再叠加期初数,就是实时库存。把这条规则固化以后,连“财务库存”和“实物库存”都能用同一套事件流,只是筛选条件不同。
4. 常见问题与排查技巧实录
4.1 事件类型越来越多,会不会失控
一开始做 REA 表,很容易每发现一个新业务动作就加一个 event_type,半年后事件类型超过一百个,表结构没崩,但逻辑代码已经没有人看得懂。我的习惯是先锁定核心事件清单,再扩展边缘事件。核心事件围绕着“资源增减”这个事实,比如采购入库、销售出库、盘点调整、付款、收款、领用、归还。边缘事件比如审批、驳回、修改备注,不应该进事件表,它们更适合放进单独的流程日志表。要时刻问自己:这个动作真的改变了某个资源的价值或数量吗?如果没改变,它就不是经济事件。
4.2 重复事件的幂等怎么处理
真实系统里,消息队列重试、用户重复点击、第三方回调重复通知都会导致同一事件被写入两次。REA 的事件表天然吃幂等这一套,办法就是在 event_no 上建唯一索引,并且这个 event_no 不是自增 ID,而是由业务语义生成,比如订单号加事件类型加序号。写入时如果发现 event_no 已存在,就跳过,而不是报错。我第一次上线时没有严格要求外部系统传幂等键,结果有一次支付回调重复消费,应收账款多了两倍。从那以后,所有事件入口都强制要求调用方传 event_no,拿不出业务幂等键的直接拒绝。
4.3 商品和资源的历史价格怎么保留
非常经典的问题:你把商品价格放在 resource 表里,那一个月前的订单金额怎么还原?答案是,凡是在业务发生时刻被引用的属性,都必须在事件表里做事实快照。我通常把单价、税率、折扣率、商品名称快照放到事件表的 JSONB 扩展字段里,严格遵循“事件只记录过程事实,资源表只记录当前状态”的原则。这样查历史订单时,看到的单价就是当时成交的单价,不会因为后来调价而改变。
4.4 审单驳回、冲销到底怎么处理
REA 模型不建议删除原始事件,也不建议在原事件上加状态字段标记“已作废”。我的做法是新增一条冲销事件,方向和原事件相反,数量相同,金额可以相同或根据退款金额调整,再通过 related_event_id 指向原事件。比如一批货发了 10 台,后来退回 2 台,就做一笔“销售退回”事件,数量方向为 in,金额 4000,关联原销售事件。这样做的好处是,任何汇总查询都不需要担心“原纪录是否有效”,因为冲销事件会自然把净额减掉。
4.5 事件表太大,查询变慢怎么办
事件表只增不改,数据量一定会涨得很快。不要急着上分布式数据库,先考虑三层方案。第一层,按时间分区表,月初自动创建新分区,查询条件必须带时间范围。第二层,对需要高频查询的资源、代理、业务引用建索引,尽量让索引覆盖过滤条件。第三层,把“当前库存”“当前应收”“当前余额”这类高频读值做成汇总表,由事件写入后异步更新。也就是说,事件表保留最有权威的明细,汇总表只承担性能加速,两边对不上时以事件表重建汇总。
5. 领域适配与进一步扩展
5.1 REA 适合什么类型的系统
我用 REA 建模用得最顺手的是物资、资金、权益高度强调闭环的业务。库存系统、供应链系统、订单中心、结算系统、积分系统、财务核算系统,这些业务天然围绕“钱的进出”“物的进出”展开,REA 几乎是对着它们量身设计的。
但也不是所有系统都适合。内容管理系统、纯信息发布平台、推荐系统,这些业务没有明确的资源增减和双向代理关系,硬套 REA 只会增加理解成本。判断标准非常简单:如果业务里有“余额”“库存”“账面价值”这类需要加减的东西,REA 就有价值;如果只是记录一条条展示信息,直接建业务表就好。
5.2 和 DDD、事件溯源怎么配合
如果你正在做领域驱动设计,REA 简直可以用作聚合划分的参考线。一个聚合里放哪些实体?就看哪些资源会被同一组事件影响。订单聚合可以只关心“订单承诺事件”以及对应的“付款执行事件”;库存聚合关心“出库事件”和“入库事件”。领域事件天然对应 REA 事件,只是命名上从系统视角改为业务视角。两者组合使用,中间层的事件表还可以作为领域事件落库的存储介质,下游消费者通过订阅事件表生成自己的读模型,基本就是事件溯源加 CQRS 的雏形。
5.3 把 REA 延伸到数据中台
在数据中台场景里,REA 也可以作为一个口径统一工具。各业务线虽然系统不同,但只要各系统按“资源-事件-代理”上报数据,中台就能用统一的算子计算指标。比如渠道佣金、客户余额、供应链库存,全部抽象成“某个代理引起了某个资源的数量变化”,数据血缘和指标定义都会变得清楚。实践中我们就把订单中心的销售事件、支付中心的收款事件、积分中心的发放事件都汇总到一张事件大宽表,所有报表从我这里取数,基本做到同源同口径,再也不用担心各业务线各自报数。
如果让我给一条最实在的建议,我会说:不要等系统被状态字段拖垮了才回头。下一次做订单、库存、结算相关需求时,先拿一张纸,把所有业务动作按资源、事件、代理三列画出来,把每次资源增减都当成一条事实去记录。画完你会发现,那些曾经让你焦头烂额的对账、审计、口径问题,大多数根本不需要额外设计,它们只是事件明细的聚合结果而已。