REA建模实战:用资源-事件-参与方构建可追溯订单系统
第一次听到 REA 这个词很多人会先愣一下像某个前端框架的缩写又像某种新语言的代号。它其实是 Resource-Event-Agent 的缩写翻译过来是资源—事件—参与方。这原本是早期会计信息系统研究者提出的一套业务建模框架却在今天的一线软件设计里被越来越多人翻出来用。这篇文章我想把它拆开讲透包括它到底解决什么问题、三个核心概念该怎么理解、以及如何在一个真实的订单系统里落地。适合谁看后端开发、架构师、数据组的同学还有正在为订单表越堆越乱、对账全靠人肉而头痛的团队。我会尽量把概念、SQL、踩过的坑一起给齐看完可以直接照着试。1. REA是什么被低估的系统级建模思路1.1 一句话版本与核心思想REA 的核心思想非常朴素把任何一笔业务交换看成一方交出资源、一方接收资源、由谁参与这三个基本要素的组合。举个例子。你在便利店买了一瓶水你交出的是现金便利店交出的是这瓶水参与方是你和收银员。在 REA 里现金和矿泉水都是资源你交钱和对方给货这两个动作是事件你和收银员则是参与方。就这么简单。但把这句话做进系统里很多团队会发现自己一直以来的建模方式其实是有问题的。常见的做法是订单表里直接存订单金额、商品表里直接存库存数量看起来没什么不对可一旦涉及退款、调价、多渠道销售、财务对账这种把余额当字段存的模型就会开始漏风。REA 恰恰是解决这类问题的一种替代思路不存余额存事件不从状态推导金额而是从流水推导状态。1.2 为什么很多团队直到对账才发现问题我见过不少电商团队初期都是订单表 商品库存字段 售后状态这套模型。日订单几百单的时候一切正常等规模上来问题一个接一个。典型症状是后台报表的销售额和财务那边的数字对不上退货单改了几次状态之后金额乱掉库存负数的商品越来越多谁也说不清是哪笔操作导致的。最要命的还不是数据出错而是出了问题之后你找不到依据——因为历史记录里根本没有留下当时发生了什么事件的完整证据只有被覆盖掉的最后状态。REA 的建模方式天然避开了这个坑。它把发生了什么当作系统的核心事实余额和报表都只是对事实的推导结果。改动事件就是事实本身不会被 UPDATE 覆盖对账时查的是事件流而不是某个被改来改去的状态字段。这套思路的价值在做过的几个订单/库存类项目里感受是越来越深。2. 三个核心概念拆解资源、事件、参与方2.1 资源不是一张表而是一类语义在 REA 里资源是一切有价值的、能够在交换中被转移的东西。钱是资源商品是资源服务、积分、优惠券、应收账款通通可以建模为资源。这里有个容易搞混的点很多人以为资源就是数据库里的产品表或资金账户表。REA 的语义比表更宽——一个资源可以对应多个产品 SKU也可以是某个账户里的现金余额甚至可以是一个抽象的应收款池。我在实际建模时通常把资源分成两类一类是实物或资金型资源比如库存商品、现金另一类是权益型资源比如应收账款、预收账款。前者是看得见摸得着的数量后者体现的是一种债权关系。区分它们很重要因为权益型资源天然需要一个配对事件来结算比如应收账款最终要配一笔收款事件来冲掉。2.2 事件是业务变化的唯一事实来源事件是 REA 模型的中心。每一笔真正让资源数量发生变化的行为都算事件销售出货、采购入库、收款、付款、退款、调拨、盘盈盘亏。一个关键原则是事件必须是已经发生的、不可篡改的事实。订单创建不是事件因为这时候货还没动、钱也没动只有出库动作发生了才产生库存减少的事件只有收到回款才产生资金增加的事件。这也是 REA 和传统记账最本质的区别。传统流水账记录的是借和贷这种人为抽象REA 记录的是业务本身。你不需要理解借贷分录也能读懂一个 REA 事件某年某月某日卖出一件 SKU_A 商品库存减一外部客户张三内部销售员李四。这句话本身就是数据模型。2.3 参与方让每笔账都有责任人参与方指事件中涉及的人或组织通常分成内部参与方和外部参与方顾客、供应商是外部参与方销售员、仓管员、出纳是内部参与方。听起来这只是加两个字段的事但它的价值是让每一笔资源变动都可追溯到一个具体的角色。做审计时能问谁经手的做绩效时可以问哪个销售带来多少成交做风控时可以问这个客户是否频繁发起异常退款。如果没有参与方这个维度业务数据就是一堆没有主体的数字后面想做用户维度的分析全都得重新接数。我在设计表结构时习惯把参与方独立成一张主体表统一管理内部员工和外部客户而不是分别塞进订单表和操作人字段里。这样后续无论是权限模型还是数据看板都能用同一个主体 ID 贯通。3. 从零建模一个订单系统的 REA 落地3.1 识别经济交换先画出钱和货的流向拿到一个业务场景第一步不是建表而是画出钱和货的流向。这里以某模拟电商项目订单库存结算模块为例核心交换就是两条线货的流向库存减少 —— 客户手里钱的流向客户付款 —— 资金账户增加在 REA 里一次完整的经济交换由两个互为镜像的事件组成一方资源减少比如库存减少配一方资源增加比如资金增加这个配对关系叫 duality也就是交换双生性。一进一出成对出现才构成一笔完整的业务。所以建模的第一个产出物是一张交换清单比如事件名称影响的资源方向外部参与方内部参与方销售出库库存商品减少客户销售员收款现金/应收款增加客户出纳采购入库库存商品增加供应商采购员付款现金减少供应商出纳这张表看起来简单但它是整个系统设计的地基。很多团队跳过了这一步直接冲进代码里写 CRUD结果等财务介入的时候模型已经没法自圆其说了。3.2 落地结构设计与 SQL 参考概念模型清楚之后逻辑结构就顺理成章。我一般用四类表来承载resource资源表agent参与方表economic_event经济事件表duality_pair交换配对表下面是一套通用的关系型 DDL不绑定具体数据库产品标准 SQL 可以直接改造成项目里的建表脚本create table agent ( agent_id bigint primary key, agent_name varchar(64) not null, agent_type varchar(16) not null -- internal / external ); create table resource ( resource_id bigint primary key, resource_name varchar(64) not null, resource_type varchar(16) not null -- inventory / cash / receivable ); create table economic_event ( event_id bigint primary key, event_type varchar(32) not null, -- SALES_OUT, RECEIPT, PURCHASE_IN, DISBURSEMENT... occurred_at timestamp not null, resource_id bigint not null references resource(resource_id), direction smallint not null, -- 1 表示资源增加-1 表示资源减少 quantity numeric(18,4) not null, amount numeric(18,2) not null, internal_agent_id bigint references agent(agent_id), external_agent_id bigint references agent(agent_id) ); create table duality_pair ( pair_id bigint primary key, decrement_event_id bigint not null references economic_event(event_id), increment_event_id bigint not null references economic_event(event_id), paired_at timestamp not null );有人会问为什么事件表里要放 resource_id 而不是把资源直接嵌进事件因为同一类事件可能影响不同类型的资源比如退款既可能是资金资源减少也可能是积分资源减少。把资源的语义抽出来事件表才能保持单一职责后面扩展新资源类型时不会改动表结构。direction 字段我建议直接存 1 和 -1不要存入/出这种字符串。数值字段在聚合计算时非常方便sum(direction * quantity)一行就能算出净变化。这是很小但很实用的设计决策。3.3 余额怎么算从事件推导而不是存数字REA 落地后任何资源的当前余额都不需要单独维护直接从事件表聚合即可select resource_id, sum(direction * quantity) as balance from economic_event group by resource_id;这条 SQL 就是库存、现金余额的统一口径。相比在商品表里维护一个库存字段它的优势是任何一次库存变动都对应一行事件记录负数余额可以被 SUM 直接暴露出来历史任何时点的余额都能通过加时间条件重新计算。当然纯聚合在大数据量下会有性能压力。我的做法是保留两张表事件表管真实性和可追溯性汇总表管查询性能。汇总表通过定时任务或者消息异步刷新比如每小时重算一次当前库存快照丢给报表查询。这个快照只是缓存即使它坏了也能从事件表全量重建这就叫可恢复的推导状态。应收账款的口径更典型。它等于所有销售出库事件里尚未配对收款事件的金额之和select sum(e.amount) from economic_event e where e.direction -1 and e.event_type SALES_OUT and not exists ( select 1 from duality_pair d where d.decrement_event_id e.event_id );注意这里完全没有应收款状态字段额度全部由配对关系推导。数据自洽的来源不是更新状态而是新增配对记录。这套逻辑做下来月末财务对账基本上不用再手动微调。3.4 承诺层订单和预约的合理位置新手最容易问的一个问题是订单到底算什么按 REA 的严格定义订单只是一个承诺承诺未来会发生一次经济交换。货没动、钱没到之前不应该产生库存或资金事件。所以我在模型里会增加一个可选层次commitment承诺表用来承接订单、预订单、采购申请这类数据。承诺表和事件表分开语义上彻底清楚订单表表达我们承诺卖出 N 件商品出库事件表达承诺已经实际履行了一部分这两者之间通过履约关系关联。一个订单可能拆成多次发货每次发货都产生一条出库事件都关联到同一个承诺。这样设计带来两个直接好处。第一未发货订单不会再被误算进库存余额第二取消订单只是取消承诺不影响历史事件。对业务而言用户下单和库存减少本来就是两件事硬塞在一张表里才是问题的根源。4. 常见问题与排查技巧实录4.1 把订单当成事件是大多数人的第一个坑我接手过一个模拟项目团队早期把订单表当事件表用下单时直接扣库存取消订单时又去把库存加回来中间还穿插着状态机各种流转。结果就是同一笔订单的状态字段被反复 UPDATE出问题时压根找不到是哪一步逻辑把库存扣错了。REA 的思路下这个问题被结构性地消灭了库存只认事件不认状态。下单只是创建承诺出库/收货才是事件。取消订单如果还没出库只需要结束承诺如果已经出库那就要单独创建一笔退货入库事件来反向修正。这个调整看起来多写了几行代码省掉的却是无数次对账时的拍脑袋。4.2 退款、调整与冲销如何正确做更正事件模型里最忌讳的是直接修改历史事件。一旦某笔退款改错了后面的聚合结果全都会错而且没有痕迹。正确的做法是反向补单原事件保留另建一条反向事件完成冲销。比如卖出去的商品因质量问题退回就新增一条方向为 1库存增加的事件然后在 duality_pair 里跟原销售出库事件配对。这样一来历史销售流水完整保留当前库存余额也得到修正还能天然统计出退货率。写代码的时候要强制留一个字段记录冲销来源比如reverse_of_event_id用来标识这条反向事件是针对哪一笔原始事件的。没有这个字段事件流虽然不可篡改却很难做追溯分析。这是我在实战中补上的小设计很基础但非常有用。4.3 存量快照、历史对账与余额精度事件模型下历史对账是优雅的想算某一天结束时的库存加一个occurred_at 当天结束的条件重新聚合即可。但业务上不建议反复做全表聚合尤其是上线初期数据量不大时还可以数据量一上来就会拖垮主库。我的建议是分三层处理日常查询走物化快照表只维护当前余额。月度账期结束生成一份只读的账期快照锁定当时各资源余额。异常对账时直接从事件表重算并和快照比对差异通常意味着设备商事件遗漏或重复记账。还有一点要提醒金额字段统一用高精度数值类型不要用浮点。库存数量、单价、折扣率这类字段宁可表宽一点也不能出现 0.1 0.2 对不上的情况。涉及钱的系统精度问题就是信任何题。4.4 常见问题速查表问题场景现象REA 排查方向库存负数商品可售数量为负是否存在未配对的出库事件或入库事件漏记销售额与财务不符报表金额对不上是否把承诺当事件退款是否漏建反向事件对账差异只在月末出现月底差异更大检查账期快照切点是否有跨界事件归属错误审核线索缺失无法还原某笔变更是否直接 UPDATE 了历史事件表而非新增事件应收款不平应收账款余额长期异常用未配对 SQL 找出遗漏收款事件排查原则其实就一条永远先怀疑事件是否完整再怀疑聚合是否正确。事件流完整任何数字都能自证。5. 边界判断REA 不适合的场景与个人取舍讲了很多 REA 的好处也得说清楚它不万能。如果业务只是纯内容型系统、没有资源交换和对账诉求硬套 REA 就是过度设计。判断标准很简单业务里是否存在一方出去、一方进来的经济交换是否存在需要追溯历史责任的诉求。两者都答否普通 CRUD 就够了。REA 和现在流行的领域驱动设计、事件溯源也不冲突。事件溯源本质是基础设施层面的以事件为事实来源REA 则是业务语义层面的用资源—事件—参与方来组织这些事件。两者可以叠加使用事件溯源管技术形态REA 管业务建模。另外一个人感受很深的点是REA 的引入不是数据库层面的改造而是产品逻辑的改造。它要求需求和研发一起重新审视业务流程把订单状态这种含糊概念拆成承诺—事件—配对三个清晰概念。这个前期投入一般会花掉一两天讨论但换来的是后续数月甚至数年的省心。提示如果团队第一次尝试建议挑一个小范围比如只做库存余额重构先试点而不要一口气把所有账务模块全部搬过去。跑通一个闭环团队自然能体会到事件推导状态比状态字段流转稳在哪里。我做过的项目里凡是坚持用事件语义来处理资金和库存的最后基本都没再被对账这件事折磨过。反而那些一开始图省事、把余额直接存在字段里的每次版本迭代都要战战兢兢。对我来说REA 最好的地方不是它多先进而是它逼着你把业务想清楚再动手——这种先把交换画出来再让代码跟着语义走的习惯可能比框架本身更值钱。