ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

REA模型重构订单系统:从状态字段到事件事实

REA模型重构订单系统:从状态字段到事件事实

我最早接触 REA,是因为手上一套老订单系统实在没法改了。不是代码写得差,而是数据模型从第一天起就把"单据"当成了核心:订单表、订单明细表、出库单表、库存流水表、凭证分录表,业务规则一多,表之间开始互相矛盾。比如订单已经发货,客户又要求改地址;库存明明被预占了,财务那边却还认为商品已经卖出;负数库存、状态机失控、补单逻辑满天飞。后来我翻资料时看到 REA 这个概念,才意识到问题出在哪:我们一直在记录"当前长什么样",而 REA 要求记录"发生了什么"。这篇文章就围绕 REA(Resource-Event-Agent,资源-事件-代理)模型,分享我怎么用这套思路重新设计了一个订单履约系统的数据核心,以及实操过程中踩过的坑。如果你也在维护订单、库存、账务这类业务模型,被纷繁的状态字段折磨过,这篇文章值得读完。

1. 当"三范式"撑不住业务变化时,我转向了 REA

1.1 传统订单模型的病根:表结构在记录"结果状态"

先说我之前维护的那套系统。它的模型设计得很标准,订单头表(订单号、客户ID、状态、金额)、订单明细表(商品ID、数量、单价)、出货表、收款表、科目余额表。查询一个订单很简单,WHERE order_no = ?就能拿到所有历史明细。表面上业务逻辑很清晰,但问题出现在业务规则变化时。

举一个真实案例:原来系统规定"订单一旦出库,就不能修改收货人信息"。后来运营要求支持"出库后24小时内允许改地址"。为了实现这个需求,开发在订单状态机上加了两个状态,又给发货单增加了一个address_changed字段,然后写了一个补偿任务去更新物流接口。过了两个月,业务又提出"发货后已经打发票的订单不允许改地址",这时候状态机已经膨胀到二十多个状态,补偿逻辑从一个变成三个,最后连当初加状态的人自己都说不清楚某个字段在什么条件下应该被置为多少。

问题出在"订单状态"这个设计上。状态是一个结果,它把"发生了什么"全部压缩成一个enum字段。当你只保存"已发货",却不保存"货物在什么时间、由谁、以什么方式发给了谁",那么每增加一个业务规则,你都得重新解释"已发货"这个状态,最终必然要靠叠加字段和补偿逻辑来维持。

1.2 REA 的核心主张:把经营事实当作一等公民

REA 框架最早来自会计信息系统的建模研究,它把业务的"经济事实"作为模型中心,而不是把单据、状态作为中心。它认为一个业务系统中的核心对象不是"订单表",而是三类东西:

  • Resource(资源):有价值、能被交易改变的东西,比如商品、库存、服务、现金。
  • Event(事件):资源控制权发生转移的动作,比如发货、收货、支付、入库。
  • Agent(代理):参与事件的人或角色,比如客户、销售人员、仓库管理员。

这三者构成一个完整的"谁,在什么时间,通过什么动作,改变了什么资源"的陈述。把这句话拆开,你会发现传统订单表的所有字段,几乎都能对应到这三类对象上,只不过传统表是以"单据ID"为聚合根的,REA 是以"事件"为聚合根的。

那状态字段去哪了?状态成了一个投影(projection),可以从事件日志里随时算出来,而不是反过来成为主数据。这就是 REA 和传统模型最本质的区别:传统模型里"状态"是一个字段,REA 里"状态"是一段计算逻辑。

2. REA 三要素到底怎么划界:Resource、Event、Agent

2.1 资源:只有能被交易改变的东西才算资源

我第一次用 REA 建模时最困惑的就是"哪些东西能算 Resource"。是不是所有实体都可以叫资源?显然不是。REA 中有一个判断标准:资源必须能够被经济事件增加或减少,并且它的控制权可以在代理之间转移。

比如"商品"是资源,因为库存可以被入库增加、被出库减少;"客户"不是资源,因为客户并没有在交易中发生控制权转移。但因为客户可以"被分配"给销售员,实际上客户这个实体更多是作为代理存在的。"订单"本身呢?订单不是资源,它是一份契约,表示"未来会发生某个事件"的承诺。REA 里有一种扩展叫 Commitment(承诺),表示未来将要发生的事件。订单就是承诺的集合:承诺发货、承诺付款。

所以在设计时我会为每张表问一句:这个表的记录,是被事件改变的,还是参与事件的?前者是资源,后者是代理或承诺,这个边界一开始划不清,后面全是坑。

资源清单的典型例子:

资源对应事件(流入)对应事件(流出)
库存商品采购入库发货出库
现金客户付款供应商付款
应收账款销售完成客户付款
服务工时工单完成客户确认

2.2 事件:用"动词"记账,而不是用"状态"记账

REA 中最重要的对象是事件。事件必须是一个"动词",比如Ship、Receive、Pay、Return,而不是一个形容词。原因是动词能表达动作发生的时间、动作的双方、动作影响的资源,而形容词只表达结果。

举例来说,你可能会在订单表里写status = 'PAID'。但"已支付"并不能回答:支付时间是什么?支付了多少钱?支付的资金从哪个账户来?以什么支付方式?这些信息要么分散在多个表,要么已经被覆盖掉了。REA 要求你记录一个PaymentReceived事件,它是不可变的事实,包含时间、金额、方式、代理,只能追加,不能修改。

我在设计事件表时有一个原则:事件字段里只记录"无法再拆解的原始事实",不记录任何计算产物。比如数量、单价、金额三者中,金额是可以由数量乘单价算出来的,但往往折扣、误差、优惠券让这个计算变得不再平凡。我的做法是保留数量、单价、折扣率等原始字段,把计算后的应收金额留给投影层去算。这个原则在后面帮我避免了一次重大返工。

2.3 代理:谁对资源负责

Agent 代表个人或组织,可以是内部的仓储人员、销售人员,也可以是外部的客户、供应商。REA 强调 Agent 的原因在于"责任"。一个GoodsShipped事件,如果不记录发货人、承运人、收货人,那么后续发生丢货时你无法定位责任人。传统模型中往往只有"制单人""审核人"这种操作日志字段,没有真正的"代理"概念。

在设计时,我会把 Agent 分成内部和外部两类。内部代理通常与组织架构相关,比如仓库、门店、部门;外部代理通常是客户、供应商、物流商。它们都引用同一个 party 表,用角色字段区分。这样设计能保证"一个人既是客户又是供应商"的场景不至于建两份数据。

2.4 三种关系:存量-流量、双入复式、控制权转移

REA 模型中的关系不是随便连的,核心有三种:

  • 存量-流量关系:事件导致资源的流入或流出,比如"发货"导致"库存"减少,这是两个事件之间的经济因果关系。
  • 双入复式关系:一个经济事件通常会导致两个资源的变化,比如"销售"这个事件同时使库存减少(流出)和应收账款增加(流入),这有点像复式记账的借贷平衡。
  • 控制权转移关系:在 Agent 和 Resource 之间建立"谁拥有/谁负责"的联系。

这三种关系把事件两两地连接起来,形成一张事实网络。这张网络最大的好处是可追溯性:你可以从"现金增加"这个亿事件一路回溯到"销售"事件,再回溯到"发货"事件,整个业务链条没有任何断点。

3. 在订单系统里落地 REA:实体设计、事件表与可跑的代码骨架

3.1 从需求到 REA 模型的一个完整例子

假设我们做一个简化版电商履约系统,需要覆盖从下单、支付、发货到入库的流程。按照 REA 建模,我不会先建"订单表",而是先列事件:

  1. OrderCommitted:客户提交订单,这时还没有资源变动,只是承诺。
  2. PaymentReceived:客户付款,现金增加,应收账款减少(如果分期则多次)。
  3. GoodsShipped:仓库发货,库存减少。
  4. GoodsDelivered:客户确认收货,物流责任解除。
  5. GoodsReturned:客户退货,库存增加,退款产生。
  6. StockRestocked:采购入库,库存增加。

对应的资源有:库存商品、现金、应收账款;对应的代理有:客户、仓库管理员、财务人员。然后我再看哪些事件之间存在双入关系:PaymentReceived同时增加现金、减少应收;GoodsShipped同时减少库存、增加客户在途资产(通常省略,用物流单表示)。

3.2 事件表与聚合设计

事件表不需要为每种事件建一张表,一张通用的事件日志表足够。关键字段是event_type、aggregate_id、aggregate_type、version、payload,其中payload存 JSON 或者序列化的领域事件对象。

这里我要强调一个容易被忽略的点:aggregate_id不能简单等于订单ID,因为 REA 中事件跨了几个聚合。比如发货事件属于库存聚合,但它也关联订单号。我采用的方式是给事件表加一个context_id,例如order_no或shipment_no,用于业务追踪,而不把它当作唯一的聚合根。

事件类的代码骨架(Kotlin 风格):

interface DomainEvent { val eventId: String val occurredAt: Instant } data class GoodsShipped( override val eventId: String, override val occurredAt: Instant, val shipmentNo: String, val orderNo: String, val productId: String, val quantity: BigDecimal, val operatorId: String ) : DomainEvent data class PaymentReceived( override val eventId: String, override val occurredAt: Instant, val orderNo: String, val amount: BigDecimal, val paymentMethod: String, val receivedBy: String ) : DomainEvent

写入事件时使用追加模式,不允许更新、删除:

fun recordEvent(event: DomainEvent) { // 1. 检查 eventId 是否重复(幂等) // 2. insert into events(event_id, aggregate_id, event_type, payload, occurred_at) // 3. 更新对应聚合的 version 字段 }

3.3 用事务保证事件与聚合状态的一致性

这里有个实际问题:事件写了,聚合状态没更新,或者反过来,都会造成数据不一致。我的做法是把"事件写入"和"聚合状态的更新"放在同一个数据库事务里。聚合状态其实就是一个投影表,比如inventory_status,记录了商品ID、仓库ID、当前库存数量。当GoodsShipped事件写入时,同时执行:

UPDATE inventory_status SET quantity = quantity - ? WHERE product_id = ? AND warehouse_id = ? AND quantity >= ?;

如果影响行数为0,说明库存不足,整个事务回滚。这样库存余额就永远不会为负(前提是你允许负库存——这个问题后面单独讲)。这个设计把 REA 的业务规则变成了数据库的原子操作,比先写事件再异步更新投影的方案要稳得多。

4. 把 REA 和事件溯源打通:事件日志、投影与一致性校验

4.1 为什么 REA 天然适合事件溯源

REA 强调事件为业务事实,事件溯源强调事件为存储事实,两者在精神上完全一致。那一开始我为什么不直接上事件溯源框架,而是先用 REA 做模型设计?因为 REA 提供了事件溯源缺少的语义约束:它明确告诉你哪些事件产生哪些资源变化、事件之间怎样对应,框架本身不关心这个。

用了 REA 之后,每一条业务操作都能对应到至少一个事件,事件日志就自然成为系统的"唯一事实源"。而订单状态、库存余额、客户欠款等一切查询视图,都是从事件日志投影出来的读模型。这样设计带来的好处是:

  • 历史可重建:随时可以把事件日志重放一遍,得到任意时刻的状态。
  • 业务可审计:所有变更都有据可查,符合财务级别的审计要求。
  • 修改规则不影响历史:规则变化时,只需要修改投影逻辑,然后重放历史事件。

4.2 投影层的具体实现:库存余额和订单状态

投影其实就是一个"从事件流到状态表"的归约函数。我写了一个最简版本:

CREATE VIEW inventory_balance AS SELECT product_id, warehouse_id, sum(CASE WHEN event_type = 'GOODS_SHIPPED' THEN -quantity WHEN event_type = 'STOCK_RESTOCKED' THEN quantity WHEN event_type = 'GOODS_RETURNED' THEN quantity ELSE 0 END) AS balance FROM events GROUP BY product_id, warehouse_id;

这只是示例,生产环境不会真的用视图去聚合,性能不够,我一般会维护一张物化投影表,并在事件事务里同步更新。但视图是最容易理解逻辑的方式。

订单状态同样可以做投影。status = PAID的语义就是"存在一个 PaymentReceived 事件,且没有对应的 PaymentRefunded 事件"。当需求变成"部分退款也算已支付"时,你只需要修改投影规则,不需要迁移任何历史事件数据。

4.3 一致性校验规则:把不变量写进系统

REA 的优势还在于把业务不变量显式化。我总结了三个必须校验的规则:

  • 资源存量非负:库存小于零就应该拒绝发货。
  • 事件间的双入平衡:例如销售事件必须同时增加应收和减少库存,缺少任一侧都要报警。
  • 代理权限校验:仓库管理员不能发起付款事件。

这些规则最好在写入事件之前校验,作为代码里明确调用的 invariants:

fun ensureInventorySufficient(productId: String, quantity: BigDecimal) { val balance = inventoryProjector.getBalance(productId) require(balance >= quantity) { "库存不足: product=$productId, balance=$balance, requested=$quantity" } }

写不变量时要小心不要引入对时间的依赖。比如校验"这个订单已经付过款"不能只看投影表,因为投影可能延迟。正确做法是在写入事件的事务里直接查事件表,或者用数据库唯一约束保证同一订单不会有两个全额支付事件。

5. 实践四个月后,我总结的 REA 落地要点和坑

5.1 坑:把"草稿状态"当成事件,结果事件日志变成了状态机

这是我踩过最深的坑。最初设计时,我把"订单已创建""订单已审核"都记录成事件,表面上符合 REA 要求,但"已创建"和"已审核"并没有改变任何资源控制权,它们只是状态流转的中间节点。REA 里诚实的事件必须造成资源增减或控制权转移。最终我把这些"中间状态"从事件日志中剥离,转而在聚合状态表里保存草稿状态,只有真正影响资源的事件才进入事件日志。事件日志顿时从每天十万条降到几千条,查询也快了。

5.2 坑:把计算规则写进事件,规则一变历史全错

有段时间我在 PaymentReceived 事件里直接存了"含税金额",后来税率从 13% 调整到 9%,历史事件里的含税金额全都不正确了,因为原始税额字段并没有保留。教训是:事件字段只保留原始输入,例如订单行的单价、数量、折扣类型、外部活动ID,任何可推导出来的数据一律在投影时计算。这确实会让投影逻辑复杂一些,但历史事件从此永远不会作废。

5.3 跨聚合事务:REA 不是分布式事务的银弹

REA 模型把发货、支付、入库分成了不同的事件聚合,这带来了一个现实问题:一个业务流程往往横跨多个聚合,比如"下单->支付->发货"涉及订单聚合、财务聚合和库存聚合。传统单库事务可以做到强一致,但一旦拆成微服务,REA 并不能保证跨服务的原子性。

所以如果你的系统涉及跨服务事件,我建议给每个聚合再增加一个"流程关联ID",并使用本地事务加 Saga 来保证最终一致。也就是说,REA 负责领域建模,Saga 负责跨域协调,两者相辅相成。

5.4 团队协作:术语统一比代码更重要

REA 模型要求团队用一套统一的业务语言描述事件。我们团队早期没有统一术语,有人写出库,有人写发货,还有人写减少库存,结果代码里的事件类型五花八门。后来用了一个事件词表,规定所有事件统一采用"动词+名词"的过去式形式,例如GoodsShipped、PaymentReceived、StockRestocked。人不能靠记忆,得靠词表约束代码评审和模型评审。这是我经过几个月实践后最想提前告诉你的一点。

以上这些经验,是我在真正切换到 REA 之后才慢慢体会到的。它不算什么新潮技术,但它让我对业务建模的理解从"表结构驱动"变成了"事实驱动",也让我意识到,与其维护越来越多的状态字段,不如回归问题的本源:多花点心思把每一次真实发生的业务动作记录下来。

RELATED READING

延伸阅读

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