☰
REA 领域建模实战:事件溯源思路重构订单库存系统
2026/10/21 6:17:45 网站建设 项目流程

1. 项目概述:代号 "rea" 到底要解决什么问题

这个项目的代号就叫rea,我在仓库里建的第一个目录名也是它。名字不是随手敲的,它正好是 REA(Resources-Events-Agents,资源-事件-主体)这个领域建模理论的缩写。起因很朴素:我们手头一个订单与库存系统,业务规则越堆越多,订单状态字段从最早 4 个膨胀到 11 个,各种"特例"逻辑散落在 service 层、状态机、定时任务和报表脚本里。隔一个月接手的人,光是搞清楚"发货中""已出库""待签收"三者区别就要花半天。

我当时的判断是:这个系统的病根不在代码,而在模型。传统以"订单表"为中心的设计,把"事实"和"单据"混在同一张表里,看起来开发快,后面全是债。所以我想试一把新的(其实是老的)思路:用 REA 本体模型把订单域重新梳理一遍,再落地为可运行的表结构和服务。这篇东西,就是这次重构的完整复盘——REA 是什么、它和传统建表差在哪、具体怎么做、我踩了哪些坑。

先说清楚这篇适合谁:如果你在做交易、库存、账务这类"钱货人"打转的领域,想找一个比满屏状态字段更靠谱的建模参考,可以仔细看;如果你只是想要一个增删改查脚手架,这篇帮不上你。

1.1 传统订单表设计为什么撑不到第五年

传统设计有多普遍?orders 表放 id、customer_id、total_amount、status;order_items 一行行地放商品;payments 记录付款;shipments 记录物流。有这个骨架的项目我在不同团队见了不下五个。前两年开发效率确实高,新增一个"改地址"接口,只要在 orders 上加个字段,再来个 update 就完事,产品提需求毫无压力。

可第三年开始出问题。第一个扛不住的是 status。业务方永远会提出新的"中间状态",于是从 pending / paid / shipped / done 一路加到"已冻结、待退款、部分发货、拦截中"。状态之间还互相交叉:能不能退?能不能改地址?能不能取消?每加一个状态,就要把所有状态机分支翻一遍,改到后来没人敢动那块代码。

第二个扛不住的是对账。月底财务要核对"销售总额"和"实收金额"。传统表里销售总额从 orders 里 sum,实收金额从 payments 里 sum,两边口径不一致,订单被取消、部分退款时更是对不上。为什么?因为模型里根本没有表达"哪笔款对应哪次发货"这个语义。你写了 payments.order_id 关联,但一个订单拆成多次付款、多次发货,关系一多就是乱的。

第三个扛不住的是历史。运营改错了一个订单状态,update 一下,旧值没了。客户问"为什么我那单被取消",系统给不出解释。所有发生过的痕迹都像被抹掉一样。

这些问题的共同根源,是把"事件"和"单据"混为一谈,用可变状态去记录不可变的事实。REA 恰恰从一开始就把这两件事拆得干干净净。

1.2 REA 是什么,我为什么选它

REA 最早是 1982 年由一位会计信息系统领域的研究者提出的,初衷很激进:它想用一套语义模型替代复式记账法,作为企业信息系统的骨架。它的核心思想一句话就能说清——业务系统的本质不是记账,而是一群主体之间不断发生的经济事件,这些事件改变资源。记账,只是这件事的一个投影。

复式记账记录的是"借现金、贷收入",它回答了"钱少了还是多了",但不能回答"这笔钱是因为卖出了哪件货、货给了谁、哪个客户付的款"。REA 就不一样,它把每次交换拆成资源、事件、主体三个要素,用它们之间的关系描述全部业务事实。

我选它,是因为我们系统每天大量处理的正是"客户付钱、仓库发货、发生退货、退款"这类标准交换活动。REA 给了一个稳定的画框:只要你先把资源盘清楚、把事件定义清楚、把参与的人理清楚,后面不管加多少业务规则,都不会动到骨架。

2. 三大核心概念与关系拆解

2.1 资源:先分清"值钱的东西"再谈别的

REA 里的资源,含义比口语里的"库存"宽得多。它指任何对组织有价值、可以被交换、生产或消耗的东西:实物商品、现金、应收账款、服务工时、积分余额、优惠券,全是资源。

建模时要注意的关键区分是:资源是"池子",不是"流水"。一张商品表,如果只用来描述"这个东西叫什么、价格多少",那它还不算 REA 意义上的资源;只有当你准备记录"这个池子的数量因为哪次事件变化了",它才真正成为资源。换句话说,资源必须能挂上"存量-流量"语义。

我在项目里给资源定的落地约束就三条:必须有唯一标识;必须能定义计量单位;必须能挂接流入和流出事件。不能在这三点上对齐的东西,先不建模成资源,等业务出现明确交换语义再说。比如"客户等级"这种只有属性没有流量的对象,我一开始没放进资源表,后来发现它其实有"积分获取、积分消费"的流量,才补进来。先小后大,比一开始贪多嚼不烂好。

2.2 事件:REA 里的主角不是单据而是"交换动作"

事件是 REA 模型里最动脑筋的部分。一个经济事件,就是一次引起资源变化的动作:收到客户付款、库存因为出库而减少、退款导致现金减少,这些都是事件。

我花了不少时间消化一个观念:单据不是核心,事件才是。订单、发货单、物流单、对账单,全部是给事件"拍的照片"——它们是对已发生事实的书面记录,可以被补录、更正、合并,但事件本身不可再分、不可篡改。报表、对账、审计,都应该围绕事件来。

事件还要区分"承诺"和"执行"两层。客户下单,是一个承诺事件:它承诺将来会有一笔交换发生,但它并没有真的改变任何资源。真正改变资源的是后续的发货、收款、退款——这些才叫经济事件。我在建模时给每种事件定义了类型,并用两个维度约束:它改变哪些资源?方向是流入还是流出?至于订单状态,在 REA 的框架里只是若干承诺/执行事件的投影,不是一个需要硬编码的字段。

2.3 主体:内部主体与外部主体各管各的责任

主体是参与事件的人或组织。外部主体如客户、供应商,内部主体如销售员、仓管员、客服。为什么要单独把主体拎出来,而不和资源放一起?因为同样一张客户表,在订单眼里是"下单人",在回款眼里是"付款方",职责不同。如果没有独立的"主体"概念,就会像传统表一样把客户语义搅进所有单据里。

REA 里主体与资源之间还有一种"控制"关系。比如在途库存归仓库控制,现金归财务控制。建模到这一步时你会发现,权限、责任、审批流都能在模型上找到位置——这不是额外功能,而是资源-事件-主体关系的自然延伸。

2.4 三类关键关系与建模输出物

把三种对象串起来的是三类关系:

  • 流量关系(Stockflow):事件与资源之间的联系。一个出库事件可以有多条资源流出明细;一条流量明细必须属于某个事件。
  • 二元性关系(Duality):两个事件配对成一次交换。典型如收款事件与发货事件互为二元,一进一出。这是 REA 模型里最漂亮也最容易被忽略的部分。
  • 参与/控制关系(Participation/Control):主体如何关联到事件与资源。

这三类关系直接决定了表结构长什么样。我在建模时输出的"领域清单",其实就是四张表加一堆映射关系:资源表、事件表、主体表、流量关系表(明细)、事件配对表。下一节就是把这张图落成真实验证过的代码。

3. 动手实践:用 REA 给电商订单域建模

光讲概念等于没讲。这节我从零开始,把一个最小电商场景按 REA 的思路落到表结构和代码。这段内容完全可以照着抄进你自己项目的脚手架里,再按业务替换。

3.1 场景梳理与领域清单

假设一个最小闭环:客户在商城下单购买实体商品,支持先款后货,支持七天无理由退货退款。就这么多规则,够用了。

我先做的是"资源盘点"。这个场景里值钱且会发生流入流出的东西有:商品库存(实物资源)、平台账户里的钱(现金资源)。客户的预存款如果要做钱包功能,也需要,但这里先不做。这时我不会建"客户等级"这种东西,因为它没有直接的流入流出。

接着做"事件清单"。我列出的经济事件只有五个:

  • 下单(承诺事件,不产生资源流量)
  • 收款(现金流入)
  • 发货(库存流出)
  • 退货入仓(库存流入)
  • 退款(现金流出)

对,就这么少。一开始容易手滑把"订单审核通过""运单签收"也定义成事件,我全砍了——它们要么是承诺状态的变更,要么是流程上的提示点,没有实际的资源变化。

主体也简单:客户(外部),平台(内部,承担收款和发货),仓库操作员(内部)。

最后把关系画出来:收款事件与发货事件配成一对二元性;退货入仓与退款配成一对新的二元性。整个模型收敛得非常干净。

3.2 落地为表结构:SQLAlchemy 建模示例

建模语言我选了 Python + SQLAlchemy,不是因为 Python 多适合交易系统,而是因为团队当时的技术栈就是它。真正重要的是这些表的关系骨架,换成 Java、Go 或者纯 SQL,长一个样。

from sqlalchemy import Column, Integer, String, Numeric, DateTime, ForeignKey from sqlalchemy.orm import declarative_base, relationship from datetime import datetime Base = declarative_base() class Agent(Base): """主体:谁参与了事件""" __tablename__ = "agents" id = Column(Integer, primary_key=True) name = Column(String(100), nullable=False) agent_type = Column(String(20), nullable=False) # EXTERNAL / INTERNAL class EconomicResource(Base): """资源:有价值的池子""" __tablename__ = "economic_resources" id = Column(Integer, primary_key=True) name = Column(String(100), nullable=False) resource_type = Column(String(20), nullable=False) # INVENTORY / CASH unit = Column(String(20), nullable=False) class EconomicEvent(Base): """经济事件:引起资源变化的动作""" __tablename__ = "economic_events" id = Column(Integer, primary_key=True) event_type = Column(String(30), nullable=False) # PAYMENT_RECEIVED / SALE occurred_at = Column(DateTime, default=datetime.utcnow) description = Column(String(255)) agent_id = Column(Integer, ForeignKey("agents.id")) agent = relationship("Agent") class Stockflow(Base): """流量关系:事件 对 资源 的入/出明细""" __tablename__ = "stockflows" id = Column(Integer, primary_key=True) event_id = Column(Integer, ForeignKey("economic_events.id")) resource_id = Column(Integer, ForeignKey("economic_resources.id")) direction = Column(String(10), nullable=False) # IN / OUT quantity = Column(Numeric(14, 2), nullable=False) event = relationship("EconomicEvent") resource = relationship("EconomicResource") class EventDuality(Base): """二元性:两个事件配对成一次交换""" __tablename__ = "event_dualities" id = Column(Integer, primary_key=True) increment_event_id = Column(Integer, ForeignKey("economic_events.id")) decrement_event_id = Column(Integer, ForeignKey("economic_events.id"))

这套结构的设计取舍我逐个说下。事件表刻意不存"余额变动前/后"字段,因为余额是从事件流水推导出来的投影,存了反而容易时序不一致。流量表每个事件至少一条、可以多条,这正好对应"一次收款对应十种商品出库"的实务。事件配对表用单独表而不是在事件上加外键,是为了支持一个事件与多个事件配对(比如一次收款分两次发货),这种多对多在实践中特别常见。

3.3 关键流程怎么流转:下单、收款、发货、退款

结构定了之后,业务流转就变成"写事件 + 派生状态"。我把四个流程各写了一遍,作为验收场景。

下单:写入一条"下单"承诺事件,不产生任何流量,同时把客户、商品、数量记录下来。库存这时候不能扣。很多人在这里容易手痒想预占库存,REA 的答案很干脆:承诺事件不碰存量池子。真要表达"已预留",另建一个独立的"预占资源"建模,而不是直接改现有库存的可用数。

收款:客户付款后,写一条收款事件,并插入一条 direction=IN 的流量,指向"平台现金"资源。这笔钱是从哪个客户的账户来,通过事件上的 agent 关联即可。注意我并没有在"订单"表上 update 一个 paid 字段——因为语义上不是"订单变成已支付",而是"发生了一次针对某预定交换的收款事件"。

发货:仓库发货后,写一条发货事件,插入 direction=OUT 的流量,扣减库存资源。此时在 EventDuality 里把这条发货事件和上面的收款事件配对。配这对是核心工作——它就是"一手交钱、一手交货"的模型化。

退款退货:客户申请退货,实际退回后,写一条"退货入仓"事件,库存流入;退款打款后,写一条"退款"事件,平台现金流出;再用一条新的 EventDuality 把这两件事配对。整个流程没有更新任何一行历史事件——旧事件永远留在原地,新的补偿事件把它对冲掉。

走完这四个流程后我做了一次简单的"证据链验证":任意时间点,把现金资源的所有 IN 事件扣掉所有 OUT 事件,应该等于账户余额;商品资源同理。这一步如果能对上,模型就是自洽的。

4. 实现过程中的关键决策与踩坑记录

理论归理论,真落下去坑一个接一个。我把这个项目里踩得最狠的几个坑,连同最终的决策写在这节,希望你能少走一遍。

4.1 不要把"承诺"和"事件"混在一起

我第一次建模时就犯了这个错:为了图省事,把"下单"直接写成了扣减库存的销售事件,结果对账全部乱套——多个订单未付款,库存却提前被扣没了,月底一盘点全在查"哪笔订单该扣而未扣"。

事后复盘:订单状态机里的"待付款""待发货""已完成"本质是承诺事件与执行事件相互作用的投影。如果你把这些状态当成事件本身去记录,事件表会被状态变更刷满,真正的流量细节淹没在噪音里,得不偿失。我的最终决策是:承诺阶段只建承诺事件,状态由下游派生;只有资源真的流动了,才建经济事件。

4.2 历史不可篡改:事件追加而不是更新

用 REA 建完骨架后,最大的习惯转变是"不要 update"。我见过太多维护得糟糕的系统,把"改错单"直接 update 掉,历史证据全没了。

我的约束很硬:经济事件表不允许 UPDATE 和 DELETE,只允许 INSERT。要修正业务错误,就新增一个方向相反的补偿事件。这个决策在审计、退款、纠纷处理时帮了很大忙。代价是表会一直涨,所以我给事件表按月份做了分区;同时应用层禁止裸 update——宁可多写一个补偿事件,也不碰旧行。

4.3 报表查询变慢:预聚合的取舍

REA 模型关系清晰,代价是"查一张报表要 JOIN 四五张表"。实践中,"销售毛利"要同时关联销售事件、成本流出、收款事件,第一次跑报表接口直接超时。

我的解决方案分两层。第一层是物化视图,把常用统计口径固化到数据库层,比如按日汇总每个商品卖了多少、每笔收款对应哪次发货;第二层是预聚合表,每次事件写入后在同一个事务里更新余额快照。这样做让写入多一层负担,但换来报表从分钟级到毫秒级。取舍标准我总结成一条:主体业务链路(下单/发货/退款)走事件源表保证事实准确;统计分析走预聚合层保证查询效率;两边用同一事务保证绝对一致。

4.4 并发扣减库存的边界控制

改造成 REA 后,库存扣减从"UPDATE 某个字段"变成了"INSERT 一条流量事件 + 更新库存池"。高并发下单场景下,库存负数问题冒出来了——两个人同时抢最后一件货,两个事件都插进去了。

排查之后发现,问题不在 REA 本身,而在于把"资源存量的计算"放在了写入事件之后,跨度太大。最终把"扣减检查"和"写入事件"收敛到同一个数据库事务里,同时在资源表上做条件更新(扣减时带上余额校验),再通过事件表的幂等键防止重复提交。线上压测后超卖问题归零。这里的心得是:事件溯源再正统,也只是一个架构选择,最终要靠事务边界和并发控制在数据库层兜底。

5. 常见问题速查与复盘心得

5.1 常见问题速查表

问题现象根因我的处理办法
退货后库存数量对不上退货没有被当成独立事件把"退货入仓"建模为库存流入事件,与退款事件配对
月底对账两边差不少钱销售金额和实收口径不一致统一围绕"经济事件"出报表,不直接读订单表
查询报表很慢事件-资源-主体多层 JOIN物化视图 + 预聚合表,事务内更新
高并发出现负库存检查与写入不在同一事务边界条件更新 + 幂等键 + 事务内扣减
误删历史事件开发图方便直接 DELETE禁止裸更新/删除,只允许 INSERT 补偿事件

5.2 这套模型适合什么场景、不适合什么场景

REA 不是万能银弹。我梳理了它的适用边界。

适合的场景:凡是有明确的资源交换、货和钱互相对应、需要审计和历史追溯的业务——电商订单、库存进销存、支付清结算、供应链协同,都极其合适。在这些场景下,REA 能帮你把一个天天改需求的交易域,收敛成一张几十年不太会变的领域骨架。

不适合的场景:没有资源流量的"内容型"业务,比如 CMS、社交动态,硬套 REA 纯属给自己找麻烦;纯展示、纯记录型系统也不需要"事件-资源-主体"这种强结构。判断标准很简单:如果你说起业务时,句子里动词都是"推荐、展示、通知",那 REA 帮不上忙;如果动词是"收、付、发、退、扣",REA 就是为你准备的。

5.3 与 DDD、事件溯源的关系

做这套建模后,我发现 REA 和现在更流行的 DDD、事件溯源能配合得很好,但它们不是一回事。DDD 把系统按业务边界切分成聚合、限界上下文;REA 提供的是另一维度的骨干结构。你在 DDD 里设计聚合时,可以直接把一个"收款+发货"的二元事件对作为一个聚合边界;做事件溯源时,REA 里的经济事件天然就是领域事件,append-only 的特性也与事件溯源吻合。

我项目里的最终形态是:"REA 事件表"作为唯一的事实源,订单投影表、余额表、报表预聚合表全部从事件派生。相当于用 REA 给出了领域事件的目录结构,用 DDD 给出了模块划分,两边互不冲突,反而互补。

5.4 一点真实的个人感悟

这次项目做完,我最深的感受不是"REA 多高级",而是它逼着所有人换了一个问法。以前产品说"订单要加一个新状态",我和产品对着状态列表争论;现在大家先聊的是"这次业务里,什么资源要流动、谁参与、和哪次事件配对"。一次需求的讨论周期从半天压缩到一个小时,口径也统一了。

如果要在结尾分享一个最值得被抄走的小技巧,我会说:动手前先花两小时把业务里所有的"资源"盘点清楚,并给每个资源问一遍"它会发生什么样的流入和流出"。这一步做扎实了,后面的模型无论如何都不会歪到没法收拾。

我踩过坑之后才明白,很多系统烂不是开发不够努力,而是从一开始就没有一套经得起追问的骨架。REA 给了你一套可追问的骨架。这也是我把这个项目代号留给rea的原因——希望自己以后每次看到这个目录,都能想起"先问事实、再摆资源、最后定主体"这个顺序。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询