ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

REA建模实战:用资源-事件-参与方构建可追溯订单系统

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 最好的地方不是它多先进,而是它逼着你把业务想清楚再动手——这种"先把交换画出来,再让代码跟着语义走"的习惯,可能比框架本身更值钱。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进