2026/10/21 4:46:48

REA建模:用资源-事件-代理构建可追溯的业务系统

REA建模:用资源-事件-代理构建可追溯的业务系统 看到“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-001resource_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 这个模型值得在动手写表结构的前一晚先花半小时认真想想。