看到“rea”这个标题,很多人的第一反应是:这会不会是哪个英文单词敲漏了几个字母?我一开始也这么猜。但把 REA 放到软件架构和业务建模的语境里,它其实是一个很值得讲透的概念:REA,也就是 Resource-Event-Agent,资源—事件—代理。凡是做过账务、积分、库存、供应链或数字资产系统的同学,大概率会在某个阶段碰到这类建模思路。它不只是一套绘图符号,更是一种“用事件记录业务事实”的思考方式,天然能支撑事件溯源、审计追踪、跨系统对账,以及向区块链这类不可变账本结构靠拢。
这篇文章不打算从学术论文讲起,而是用一套能直接落地的小型业务原型,把 REA 的核心实体、关系建法、事件规则、审计逻辑和常见坑位完整拆一遍。适合的对象不仅限于后端工程师;只要你的系统跟“钱、货、人、时间”这四件事打交道,看完之后应该都能提取出一张可以直接套用的建模草图。
1. 先把这个标题拆明白:REA到底解决什么问题
1.1 三个核心词把模型讲清楚
REA 表面上只有三个词,但每个词的含义都比字面看起来要重:
- 资源(Resource):指经济体里可以被识别、被估值、可以被交换的东西,现金、商品、积分、工时、设备、版权、服务配额,全都可以是资源。它不一定是实物,也可以是能力或权利。
- 事件(Event):指两个代理之间实际发生的经济事件,比如“收钱”“发货”“退券”“兑换积分”。事件是模型中唯一会被记录“发生时刻”的东西,是整个模型的锚点。
- 代理(Agent):指参与经济事件的个人或组织,比如顾客、供应商、内部采购员、客服、系统账户。代理负责发起动作,也为结果承担责任。
拿“在店里买一杯咖啡”举例:咖啡豆、现金、刷卡机里的额度是资源;顾客扫码付款是收钱事件,店员递出咖啡是交货事件;顾客和店铺是代理。如果业务只需要查“今天卖了多少杯”,用一张订单表就够了。但当你需要回答“这笔钱为什么进来”“退款时应该冲抵当时的哪个动作”“这批咖啡豆对应哪次采购事件”时,REA 的建模优势就体现出来了。
1.2 为什么会计模型会被搬进软件工程
REA 最早并不是工程界发明的。上世纪八十年代初,一位会计学者在自己的研究里提出了一套更语义化的会计模型,尝试替代传统复式记账对“科目”的单点记录。传统账务里,借贷双方各自记一个科目,数字平了就算对,但科目本身无法表达“这笔钱来自哪次销售行为、对应哪批库存”。REA 则不同,它不围绕“会计科目”建模,而是围绕“经济事件”建模:资源之间怎么流动、由谁参与、产生了什么权利和义务。
这套思想被搬进软件工程,核心原因是现代数字系统面对的问题变了:系统越来越强调可审计、可还原、可追溯。订单、发票、库存快照这类单据,本质是对业务事实的“结果投影”,一旦数据出错,你很难知道是哪个环节造成的。而 REA 把一切变成事件链,事件本身不可变、有语义、有参与方,于是每次审计或对账,都是在重放业务历史,而不是看眼前一张汇总表。
这就像拍照片和录视频的区别。传统表结构是照片,记录某个瞬间的状态;REA 是录像,记录状态是怎么一步步变成现在这样的。两者各有用途,但排查问题、追溯证据时,录像比照片可靠得多。
2. 核心细节:实体关系与表的打开方式
2.1 资源该如何建模
在 REA 里,资源常被新手弄混的是“资源类型”和“资源实例”。你卖的水杯是同一件商品,但每一只被卖出的水杯,最好是独立的资源实例,才能追踪到每个单独个体。现实系统可以折中:对同质化商品,只维护一个资源账户,但对高价值、可序列化资产,就必须拆到实例级别。
我建议资源表至少包含这几个字段:
| 字段 | 说明 | 示例 |
|---|---|---|
| resource_id | 资源唯一标识 | RES-CASH-001 |
| resource_type | 类型或 SKU 标记 | 现金 / 代金券 / 水杯 |
| unit | 计量单位 | 元 / 个 / 克 / 积分 |
| initial_quantity | 初始数量 | 系统初始化时注入 |
| status | 可用状态 | 启用 / 冻结 / 销毁 |
有一个很容易踩的坑:不要在资源表里直接放“current_owner_id”或“balance”。REA 强调,当前归属和余额应该由事件推导出来,而不是直接记录在资源上。如果你把余额写成字段,那么每一次退款、冲正都要去改同一个字段,并发冲突、账实不符的问题会瞬间放大。余额如果只是被推导出来的“投影”,它就可以随时重建,不容易被写坏。
2.2 事件为什么必须是双向的
REA 中的事件并非“任意动作”,它强调每个交换类经济事件都要有配对关系。一次销售,至少要生成两个事件:收款事件(现金流入)和交货事件(商品流出)。这是一套变形的复式记账:它不记录抽象科目“银行存款”和“主营业务收入”,而是记录“现金资源从顾客流向店铺”和“水杯资源从店铺流向顾客”。
所以设计事件表时,不要只放“事件类型、时间、关联单据”就了事,还要能表达每个事件带动的资源流向:
- inflow:谁获得了什么资源,增加多少;
- outflow:谁让渡了什么资源,减少多少。
比较好的做法是把流向拆成一张子表或明细表,事件主表只负责记录事件发生的主体信息,每一条事件至少挂两条资源流。支付和交货如果发生在不同时间,那就拆成两次事件,不要强行合并。早期我图省事,把支付和发货塞进同一行“订单支付表”里,结果后面做部分退款、部分发货时,整个对齐逻辑差点重写。后来改成事件拆分,逻辑就清爽很多。
2.3 代理、权利与义务
代理在 REA 里也不是简单的一个“用户 ID”。它需要区分内部代理和外部代理:内部代理是公司组织里的岗位,比如销售员、仓管员;外部代理是顾客、供应商、承运方。参与事件时,代理不仅执行动作,还会产生权利和义务。顾客付了钱,因此获得“领取商品的权利”;店铺收了钱,同时承担“兑付商品的义务”。这类权利和义务并不需要专门做一堆字段;只要通过“资源流 + 参与代理”的关系,在查询时把对应事件关联起来,就能随时推导。
设计代理表时,我的经验是尽量不要把“系统账号”直接当代理用。一个账号背后可能代表多个业务身份,服务商既要进货,也可能零售出货。把账号和代理分开,留一张映射表,将来做权限、做审计、做组织变更都会轻松很多。
3. 亲手设计一个小型 REA 原型
3.1 先选定一个可闭环的小场景
为了把概念落到代码层面,我们设计一个“代金券兑换系统”:某运营平台发行一批代金券,用户使用现金购买,购买后可以凭券兑换平台服务。整个链路涉及现金资源、代金券资源、服务权益资源,涉及购买、发放、兑换三个事件。选择这个场景,是因为它既包含同类资源之间的一次交换,又包含资源形态的转化,覆盖面足够。
我们先定义三个事件:
- 购买事件:用户支付 100 元现金,获得 1 张面值 100 元的代金券。
- 发放事件:系统(内部代理)将代金券发放给用户,平台服务池减少一张待发放券。
- 兑换事件:用户用 100 元代金券兑换价值 100 元的服务权益。
可以看到,现金、代金券、服务权益都是资源,但它们的生命周期不同。购买事件沟通现金与代金券,兑换事件沟通代金券与服务权益。中间那张代金券,既是购买事件的“产出”,又是兑换事件的“投入”,形成一条清晰的事件链。
3.2 定义表、字段与关系
按 REA 结构,我们可以拆出这几张核心表:
-- 代理表 create table agents ( agent_id varchar(32) primary key, agent_name varchar(64), agent_type varchar(16), -- internal / external status varchar(16) ); -- 资源表 create table resources ( resource_id varchar(32) primary key, resource_type varchar(32), resource_name varchar(64), unit varchar(16), initial_quantity decimal(20,4), status varchar(16) ); -- 事件主表 create table events ( event_id varchar(32) primary key, event_type varchar(32), occurred_at timestamp, description varchar(255) ); -- 事件参与关系表:事件和代理的关系 create table event_agents ( event_id varchar(32) references events(event_id), agent_id varchar(32) references agents(agent_id), role varchar(16) -- buyer / seller / issuer / redeemer ); -- 资源流明细表:表达资源流入与流出 create table event_flows ( flow_id varchar(32) primary key, event_id varchar(32) references events(event_id), resource_id varchar(32) references resources(resource_id), direction varchar(8), -- inflow / outflow quantity decimal(20,4), unit varchar(16) );这套结构最大的特点是:业务细节不散落在各种业务表里,而是统一沉淀在“事件 + 流”两层的通用结构上。将来增加新的业务类型,比如“退款”“转赠”“过期回收”,不需要再造一套新表,只要新增事件类型和对应的资源流规则。我实际做系统集成时,这种统一结构比几十张业务单据表好维护得多。
3.3 关键的校验规则与实现要点
有了表结构只是第一步,REA 模型的灵魂在于规则。最重要的是下面三条:
规则一:交换类事件必须同时存在 inflow 和 outflow。
一笔购买事件,如果没有现金流入,或者没有代金券流出,就应该判定为非法。代码上可以做成事件提交时校验,SQL 层面也可以做约束,但更稳妥的是放在业务服务层,因为有些流可能需要跨多个表才能汇总。
def validate_exchange_event(event_id): inflow_total = get_flow_sum(event_id, 'inflow') outflow_total = get_flow_sum(event_id, 'outflow') if inflow_total is None or outflow_total is None: raise ValueError('交换事件必须同时包含流入和流出') if abs(inflow_total - outflow_total) > 0.0001: raise ValueError('交换事件的资源价值应保持平衡')规则二:资源库存不允许被过度消耗。
每次提交 outflow 之前,要基于当前已入账事件推导出资源余额,而不是直接信任资源表里的某个冗余库存字段。计算逻辑可以写成:
select r.resource_id, coalesce(sum(case when f.direction = 'inflow' then f.quantity else 0 end), 0) - coalesce(sum(case when f.direction = 'outflow' then f.quantity else 0 end), 0) as balance from resources r left join event_flows f on f.resource_id = r.resource_id group by r.resource_id;这种做法在数据量大时可能慢,但它保证了正确性。性能问题可以用“事件日志 + 定期快照 + 增量投影”来解决,不要为了性能牺牲数据一致性。
规则三:事件必须有明确的参与代理。
孤儿事件是审计时最头疼的东西。提交事件时,必须保证 event_agents 至少有一条记录。每条资源流都要能回答“谁在什么时间,让什么资源发生了移动”。没有代理的事件,在追溯时就已经断了线索。
4. 应用场景:事件溯源、审计与系统分析
4.1 用 REA 搭建事件溯源骨架
事件溯源模式强调“把状态变化记录下来,而不是只保存最终状态”。REA 天然契合这一点:event 表就是事件日志,event_flows 表记录每次状态变化的资源影响。系统里并不需要单独维护“用户余额表”“库存表”作为唯一真相,这些余额只是从事件流投影出来的读取模型。
建议采用的事件溯源分层:
- 命令层:收到业务请求后,先做规则校验,再生成新的 Event。
- 事件存储层:将 Event 写入持久化日志,事件一旦写入不再修改。
- 投影层:异步消费事件,更新余额表、库存表、订单视图,供查询使用。
- 审计层:保留完整事件历史,随时可以反向重建任意时间点的快照。
这套分层做出来后,对账逻辑会特别简单:把两套系统的事件流拉到一起重放,哪个时间点出现不一致,就能精准定位到具体事件。不要迷信“引入消息队列自动同步”,先把事件结构和重放能力做对,同步只是细节。
4.2 审计追踪为什么偏爱“事件链”
传统表格里,库存数量和账户余额是最直观的,但它们只能回答“现在是多少”。REA 可以回答“为什么是现在这个数”。每一次业务动作都留下了不可抹去的事件记录,而且每条事件都带着参与代理和资源流向。这和很多地方正在推进的数字化审计要求高度匹配:审计不再靠抽查报表,而是直接观察业务事件链。
我在实际项目里有个经历:客户要追溯半年前某批冲正操作是谁做的、基于哪张原始凭证,当时的旧系统因为只存了“最终状态 + 操作人”,查了一个星期才拼出大概脉络。换成 REA 事件结构后,这类追溯变成了标准查询,从事件主表按代理和时间索引拉出来就能回答。
4.3 跨系统、多渠道对账时秒变“通用语言”
另一个很值得提的应用场景是系统集成。不同系统内部可能叫“订单”“流水”“台账”“凭证”,但这些本质上都是在描述经济事件。如果统一对外输出 REA 模型的事件格式,对方系统解析时就只需要理解一种标准语义:资源、事件、代理。我参与过的电商库存和一个外部结算系统对接,正是因为双方都按事件流交换数据,才把大量字段名的差异问题压到了很低的水平。
5. 踩过的坑与排查经验实录
5.1 常见误区速查表
| 误区 | 具体表现 | 建议做法 |
|---|---|---|
| 资源表里存余额 | 每次交易后直接 update 余额字段 | 余额只用事件流投影,必要时用缓存 |
| 把事件表和单据表混一起 | “订单表”既存商品信息又存支付信息 | 事件只描述“发生了什么”,单据做业务描述 |
| 忘记记录代理角色 | 查到事件却不知道是谁参与 | 每个事件都要有明确的参与代理和角色 |
| 把退款做成负数库存 | 将退货事件记成库存少一件再反向冲销 | 用独立的资源流方向表达退货 |
| 忽略事件顺序 | 不记录依赖关系,导致重放顺序错乱 | 事件增加对前置事件的引用,或统一时间序号 |
这些坑,我基本都踩过一轮。尤其是“资源表里存余额”,带来的教训最深。早期做积分系统时,我直接在每个用户积分字段上做加减,结果并发领奖和退券一叠加,出现了负数积分。后来改成纯事件流模型,再用 Redis 缓存积分余额,才彻底解决并发不一致。
5.2 设计中期做得越早越好的三件事
第一件,是“为资源定义严格的唯一业务键”。不要只依赖自增 ID。每类资源最好有可读的编码规则,比如 RES-COUPON-2025-000001,否则排查事件流时会很难受。第二件,是把“资源转换”单独设计成事件类型。比如用户拿一张 100 元券换两张 50 元券,本质上是一次转换事件,重新生成资源流,而不是简单修改旧资源数量。第三件,是预先定好“事件不可变”的团队守则。任何人不得修改已入账事件,即使业务上做冲正,也要新增一个逆事件来表示撤销,而不是物理删除或覆盖之前的数据。
5.3 性能优化的一条务实路径
事件模型查询确实没有“直接读余额字段”快,所以性能优化要分两步走:
- 第一层,保留事件日志作为唯一事实来源;
- 第二层,维护投影表(如 user_balance_snapshot、stock_summary),用监听事件的方式异步更新。
查询接口只读投影表,写接口只写事件,复核时定期从事件重放投影并与现有投影比对。这样既保留了 REA 的可追溯能力,又不会让用户查询变慢。我做过一个千万级事件量的系统,按月份做分区表后,事件写入和投影更新的性能都很稳定。
我在实际项目中感受最深的一点是:REA 不是给你多画几张 UML 图,而是逼你把“业务动作”放回中心位置。很多系统之所以后期改不动、对不上账,是因为数据模型一开始就围绕“单据快照”而不是“真实事件”来建。如果能在设计阶段多问一句“这条数据代表的是什么事件、涉及什么资源、由哪个代理发起”,大多数账实不符和追溯困难的问题根本不会出现。
如果只让我给一条建议,我会说:从今天开始,把系统中所有跟“变更”相关的更新操作,逐一转成不可变事件记录,再在事件流之上重新计算你需要的状态。这个过程不一定要推倒重来,可以先从最核心的账户或库存部分试点,跑通之后你会忍不住把其余模块也迁过来。REA 这个模型,值得在动手写表结构的前一晚先花半小时认真想想。