☰
REA模型实战:用事件溯源替代状态字段,重构订单履约系统
2026/10/11 13:23:11 网站建设 项目流程

1. 为什么我放弃了传统的状态字段方案,转而研究“rea”

1.1 一次业务迭代引发的重构

先说个背景。我之前负责某公司的订单履约系统,早期设计特别简单,订单表里一个status字段走天下:0待支付,1已支付,2已发货,3已签收,4已取消。一开始跑得挺顺,直到产品提了一个需求:订单要支持“部分退款”“超时自动取消”“退货入库”“重新发货”等一整套组合状态。我当场就麻了。

改来改去,那个status字段变成了一个十几分支的状态机,每个分支还要搭配refund_status、return_status、warehouse_status这种辅助字段来补充信息。代码里充斥着“如果 status 是 2 且 refund_status 是 1 且 return_status 是 3”这种鬼条件。更麻烦的是,出问题的时候没法回答一个最简单的问题:这笔订单到底发生了什么?只能看到结果,看不到过程。直到我认真研究了“rea”——REA模型(Resource-Event-Agent,资源-事件-行动者),整个建模思路才彻底翻转。

这篇文章不是理论抄书,而是一次真实项目复盘。我会把 REA 从概念拆到表结构、从服务层落到一次完整的业务迭代,最后再把踩过的坑全部摆出来。适合那些对传统“状态字段 + CRUD”建模感到吃力,想换一种更接近业务本质的建模方式的同学参考。

1.2 REA到底在解决什么问题

REA 最早是会计信息系统领域提出来的一个企业建模框架。它的核心主张非常朴素:业务系统的职责不是保存“当前状态”,而是保存“发生过的事实”。任何一个有价值的经济活动,都可以拆成三个基本元素:

  • 资源(Resource):被交换、被消耗、被生产的有价值的东西,比如商品、现金、库存、工时。
  • 事件(Event):让资源发生变化的具体业务动作,比如销售、采购、支付、出库、盘点。
  • 行动者(Agent):参与事件的人或系统角色,比如客户、仓管员、财务审核人、支付回调。

传统表结构里,我们习惯给每个业务对象建一张表,然后不断往里加状态字段。订单表、库存表、账户表,各自独立,靠外键关联。这种设计的痛点在于:状态是“算出来的结果”,而不是“记录下来的事实”。一旦状态算错,或者状态之间出现矛盾,你根本不知道是哪一步出了问题。

REA 的思路刚好反过来:不保存状态,只保存事件。库存不足不是“库存字段变负数了”,而是“发生了一笔出库事件,扣减了 5 件库存,同时发生了一笔入库事件,增加了 3 件库存”;订单取消不是“status 改成已取消”,而是“发生了一笔取消事件,关联了原支付事件,资金作了一次反向调整”。

用一个生活类比:你去超市退货,收银员不会把收银小票上的记录擦掉再写一张新的,而是开一张红字冲销单。那张原始小票还在,只是多了一条新记录。REA 本质上就是这个思路:业务数据不可变,状态靠事件推导。

1.3 三个核心概念先建立直觉

很多资料把 REA 讲得特别玄乎,我看着也烦。我用自己的话重新讲一遍,你先把这三个概念在脑子里立住,后面全通了。

资源:简单说,就是“值得被追踪数量和价值的东西”。它要能被盘点、能计量单位。库存商品是资源,现金是资源,甚至“客户积分”也是资源。但注意——订单本身不是资源,它是一个业务过程的记录,承载了若干事件之间的关联。这地方最容易出错,很多人把订单表直接当成资源表,结果最后还是被状态机绑架。

事件:事件是 REA 的核心,也是很多人最难转过来的地方。判断一个业务动作是不是事件,就一个问题:这个动作是否导致了某个资源的增加或减少?有,就是事件;没有,就是流程节点。比如“创建订单”不是事件,因为它没有改变任何资源;“支付成功”是事件,因为资金资源增加了;“出库发货”是事件,因为库存资源减少了。事件是事实,一旦发生就不该被修改或删除,只允许追加“修正事件”。

行动者:事件必须由人或系统发起。客户发起支付,仓管员发起出库,定时任务发起超时关单,支付渠道回调发起支付确认——这些都是行动者。行动者不一定是“自然人”,系统角色、自动化脚本、外部系统接口都算。把行动者单独建模,最大的好处是事后审计很清晰:每一步动作都知道源头是谁。

这三个概念之间是这样的关系:行动者参与事件,事件影响资源。听上去像句废话,但你把这句话落到每一张表、每一行代码、每一个接口上,会发现自己以前的很多设计都是在跟这句话对着干。

2. REA建模的核心细节与业务识别方法

2.1 资源的识别:库存、订单、账户,哪些才是真资源

我见过不少团队引入 REA 后第一个崩溃的环节,就是在“识别资源”上卡住。列了一堆东西:订单、商品、赠品、优惠券、余额、积分、运费、手续费……什么都想往资源里装,结果建模出来比状态机还复杂。

我的经验是三个筛选条件:

  • 能不能被独立计量。库存可以按“件”数,余额可以按“分”算,但订单不能。订单是多个资源变化的合集,不是一个可计量的独立对象。
  • 有没有生命周期中的数量增减。优惠券如果只存一张状态(未使用/已使用),不需要记录多次增减,那它其实更适合被当作事件的一个属性,而不是独立资源。
  • 是否会被盘点和对账。财务要对账资金账户,仓库要对账实物库存,客服要对账售后单。这些对象有价值追踪需求,才是资源。

举几个常见误判:

  • 商品资料(名称、规格、条码)不是资源,它是资源的描述信息。资源是“库存”,商品资料只是给库存贴的标签。
  • 订单不是资源,它是几个事件连接起来形成的一个完整业务流。你需要的是“订单号”这个业务编号,用它串联多个事件。
  • 账户余额可以是资源,但要拆开看。用户的钱包余额、平台的营销余额、待结算金额,如果是独立核算主体,就分别建模为不同资源。

一句话总结:先问“财务和仓库会不会为这个东西单独做账”,会,才是资源。

2.2 事件的划分:一张订单该拆成几个事件

事件划分是 REA 建模里颗粒度最讲究的环节。划分太粗,失去过程还原能力;划分太细,表膨胀得吓人,查询也麻烦。

我自己定的标准:一个原子业务动作,导致一个资源发生一次方向明确的数量增减,就算一个事件。按这个标准拆一次完整的电商订单:

  • 用户提交订单:不是事件(没有资源变化),但如果系统做了“库存预占”,那么“库存预占”是一个事件,它把available库存减少、把frozen库存增加,资源还是库存,数量变化方向是“可用转冻结”。
  • 支付成功:一个事件,资金资源增加,应收资源减少。
  • 仓库发货:一个事件,库存资源从“在仓”变成“在途”,同时冻结库存释放。
  • 用户签收:一个事件,库存资源从“在途”变成“已售出/出库终态”。
  • 申请退款:不是事件,只是个流程入口;真正的事件是“退款成功”,资金资源减少,售后资源增加。

这里要特别强调两点。

第一,事件命名必须是“动词的完成态”,比如“支付成功”“出库完成”“退款到账”,而不是“支付中”“待发货”。因为 REA 存的是事实,不是假设。待发货这种中间态可以用投影表去表达,但底层事件流里只有完成态。

第二,一个事件只能影响一个方向的一类资源。别写一个“订单完成”事件,然后同时改库存、加积分、扣优惠券。要拆成“出库完成”事件、“积分发放”事件、“优惠券核销”事件。因为这三个资源的增减方向、责任行动者、对账对象都不一样,绑在一起会让后续的审计和重放逻辑变得极其痛苦。

2.3 行动者的边界:人与系统的职责分离

行动者建模看起来简单,实际上坑特别深。最常见的问题是把“系统”和“人”混在一个枚举里,导致事后审计时搞不清楚到底是用户操作的还是程序自动处理的。

我建议最少拆四种行动者类型:

  • 外部行动者:客户、供应商、合作伙伴,通常是业务对端。
  • 内部行动者:运营、仓管、客服、财务审核人,通常是内部员工角色。
  • 系统自动化角色:定时任务、消息队列消费者、接口回调模块,它们不是人,但会发起事件。
  • 外部系统身份:支付渠道、物流平台、税务接口,它们的回调也是一种“行动”,要单独记录来源。

落到表结构上,行动者表不要只放一个name字段,至少要有agent_type和agent_code。比如支付回调触发的“支付成功”事件,行动者记录的是EXTERNAL_SYSTEM类型的“某支付平台”,而不是订单用户;用户申请的“退款”事件,行动者才是订单用户本人。

我自己踩过一个具体例子:订单超时自动关闭功能,最初把行动者默认写成“系统”,但没有区分是“哪个模块的系统”。后来排查线上问题时发现,既有调度任务关的,也有用户手动取消的,日志里都是“系统”,只能靠时间去猜。从那以后,所有自动化任务的行动者编码都带上了任务标识,比如scheduler:order-timeout-cancel。

行动者建模带来的另外一个好处是:你天然得到了一个“某人/某系统做了什么事”的审计视图,这在应对财务对账和客服仲裁时价值极高。

2.4 REA与ER模型和数据库表的对应关系

理论讲完,必须落到表结构上,不然没法用。REA 的两个核心关联:“事件影响资源”和“行动者参与事件”,正好对应数据库里的两张关联表。

我一般用这么一套五表结构:

REA概念数据库设计具体例子
资源(Resource)资源表,存储当前可计量的数量、单位、属性product_inventory:商品 ID、总库存、可用库存、冻结库存
行动者(Agent)行动者表,存储人/系统/外部系统的身份agent:编码、类型、名称、所属组织
事件(Event)事件主表,存储“什么时间发生了什么事实”event:编号、类型、发生时间、业务单据号、备注
事件-资源关联关联表,记录资源增减方向和数量event_resource:事件 ID、资源 ID、增减类型、数量
事件-行动者关联关联表,记录谁发起了、谁执行了event_agent:事件 ID、行动者 ID、参与角色

事件主表是事实源,理论上是只追加的。但实践里我不会直接禁止 UPDATE,因为系统修复或者人工纠错确实会发生。我的做法是:业务数据事件表严禁 UPDATE 关键字段,如果错了,就新增一条“更正事件”。这个纪律一定要从第一天就定下来,不然事件溯源就名存实亡了。

资源表在 REA 里是最有意思的表,它严格来说不是一个“主数据表”,而是一个投影表(Projection)——它存的是“根据事件流推导出来的当前状态”。比如库存表里的可用库存数,其实不应该被直接修改,而应该由“入库事件 + 出库事件 + 冻结事件 + 解冻事件”重放出来。当然,为了性能我们不会每次实时重放全量事件,而是让资源表在事件落库后同步更新,但它的语义是投影,不是事实。

3. 实操落地:以一个订单履约系统为例的完整建模

3.1 业务场景与建模目标

我在公司用这个思路重构了一个模拟项目 X——某跨平台订单履约系统的库存与资金联动模块。场景很简单:一个订单要经历“下单预占库存 → 支付成功 → 仓库出库 → 客户签收”这个主流程,同时支持“退款退库存”和“超时释放库存”。

为什么选这个场景?因为它同时牵扯到两个最敏感的资源:库存和资金。凡是碰钱和碰库存的系统,对账需求极其强烈,而传统的“状态字段 + 直接改数值”方案在这种场景下几乎必出事:库存多扣了查不到,资金重复退了你也不知道。

建模目标定三条:

  • 任意一笔订单的历史轨迹都能完整还原。
  • 库存和资金的任何变化,都有对应事件可追。
  • 支付回调重复、库存扣减并发这些问题,从架构上规避而不是靠代码临时加锁补救。

3.2 事件流梳理与逐事件说明

建模前先不急着建表,先把业务动作列成一张表格。这一步花的时间越久,后续写代码越省事。我当时列的版本:

事件类型影响的资源数量方向行动者触发条件
库存预占库存(可用→冻结)可用减、冻结增用户提交订单下单成功
库存解冻库存(冻结→可用)冻结减、可用增定时任务超时未支付
支付成功资金、应收资金增、应收减支付渠道回调支付渠道确认
出库完成库存(冻结→在途)冻结减、在途增仓管员/出库系统仓库发货扫描
签收完成库存(在途→已售)在途减、已售增物流系统回调客户签收
退款成功资金、售后资金减、售后增财务审核人退款审核通过

这六个事件覆盖了订单主流程全部变更点。注意“申请退款”和“退款成功”我刻意做了区分:申请只是业务动作,不一定导致资源变化;“退款成功”才真正改变资金。把这两者分开,你在统计“待退款金额”和“已退款金额”时就不会再打架了。

这里还要补一个细节:每个事件都要有唯一的“业务幂等号”。比如支付成功事件的幂等号就是“支付渠道流水号”,退款成功事件的幂等号就是“退款单号”。没有幂等号,事件表就会被重复回调撑爆。

3.3 数据库表结构设计与关键字段

基于上面的事件流,我设计了一套简化版本的表结构,你用 MySQL 也能直接建。重点是看字段设计思路,别照抄。

资源表resource:

CREATE TABLE `resource` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `resource_type` VARCHAR(32) NOT NULL COMMENT '资源类型:库存/资金/应收/售后', `resource_code` VARCHAR(64) NOT NULL COMMENT '资源编码:SKU_ID或账户ID', `total_qty` DECIMAL(20,4) NOT NULL DEFAULT 0 COMMENT '总量', `available_qty` DECIMAL(20,4) NOT NULL DEFAULT 0 COMMENT '可用量', `frozen_qty` DECIMAL(20,4) NOT NULL DEFAULT 0 COMMENT '冻结量', `version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', UNIQUE KEY `uk_resource_type_code` (`resource_type`, `resource_code`) );

注意我把库存拆成了available_qty和frozen_qty。这是库存建模里特别重要的一步——如果只有一个总库存字段,你没法表达“被预占但还没出库”的那部分。冻结量就是“事件发生后、资源状态变化中的中间投影”,它让系统在不同阶段回答“现在还能卖多少”这个问题时,有一个靠谱依据。

事件表event:

CREATE TABLE `event` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `event_type` VARCHAR(48) NOT NULL COMMENT '事件类型:PAYMENT_SUCCESS/STOCK_OUTBOUND等', `biz_no` VARCHAR(64) NOT NULL COMMENT '业务单据号:订单号/退款单号', `occurred_at` DATETIME NOT NULL COMMENT '业务发生时间', `biz_idempotent_no` VARCHAR(128) NOT NULL COMMENT '业务幂等号:支付流水号等', `payload` JSON COMMENT '扩展数据,存事件上下文', UNIQUE KEY `uk_event_type_idem` (`event_type`, `biz_idempotent_no`) );

这个表的uk_event_type_idem唯一索引是防重复的命脉。支付回调即使同一笔流水推十次,也只会插入一条成功事件。

事件-资源关联表event_resource:

CREATE TABLE `event_resource` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `event_id` BIGINT NOT NULL, `resource_id` BIGINT NOT NULL COMMENT '资源ID', `change_type` VARCHAR(16) NOT NULL COMMENT '变化方向:INCREASE/DECREASE/TRANSFER', `from_state` VARCHAR(16) COMMENT '来源状态:AVAILABLE/FROZEN/IN_TRANSIT', `to_state` VARCHAR(16) COMMENT '目标状态:FROZEN/IN_TRANSIT/SOLD', `qty` DECIMAL(20,4) NOT NULL COMMENT '变化数量', KEY `idx_event_id` (`event_id`), KEY `idx_resource_id` (`resource_id`) );

这种设计的巧妙之处在于:一个事件可以关联多行资源变化。比如“库存预占”事件,一行记录可用库存减少,另一行记录冻结库存增加。这完全还原了业务语义,又比状态机里的frozen_qty字段凭空变更要可追溯得多。

行动者关联表event_agent比较简单,记录事件ID、行动者ID和参与角色即可,不再贴 SQL。有一点提醒:如果同一个事件有多个行动者参与,比如“出库完成”既有关联的仓管员扫码、又有系统自动记录,那就拆成两行,分别是“执行人”和“触发系统”。

3.4 服务层如何按REA语义组织

表结构设计好后,服务层不能还是老一套的“在 Controller 里直接 UPDATE 字段”,而是要做一次语义升级。每次业务操作,本质上分三步走:

第一步,生成事件。业务动作发生时,先构造事件对象,确认事件类型、行动者、资源变化明细、幂等号。

第二步,校验并落事件。检查幂等号是否已存在,然后插入事件主表和两张关联表,整个过程用本地事务保证一致性。

第三步,更新资源投影。根据事件里的资源变化明细,同步更新资源表的可用量、冻结量等字段。这一步是用乐观锁版本号控制的,避免并发冲突。

我拿“下单预占库存”这个动作举个伪代码示例:

def reserve_stock(order, user): begin_transaction() if event_exists("STOCK_RESERVED", order.payment_id): return DuplicateEvent() event = create_event("STOCK_RESERVED", biz_no=order.order_no, idempotent_no=order.payment_id) resource_stock = get_resource("INVENTORY", order.sku_id) affect_resource(event, resource_stock, change_type="TRANSFER", from_state="AVAILABLE", to_state="FROZEN", qty=order.qty) involve_agent(event, user.id, role="CUSTOMER") insert_event_with_locking(event) commit_transaction() refresh_resource_projection()

核心就是:改资源永远是被事件驱动的,而不是被接口直接驱动的。以前那种UPDATE inventory SET qty = qty - 5 WHERE sku_id = xxx直接改表的写法,在 REA 里是完全禁止的。因为你改完以后,没有任何证据链条来回答“这个 5 是从哪来的”。

4. 常见问题与排查技巧实录

4.1 库存扣减与幂等:REA如何从根上解决重复扣减

传统方案处理“用户点击支付,支付渠道回调,回调重试”的场景,基本套路是:先判断订单状态是不是已支付,是就 return,不是就执行扣库存、加余额、改状态。这个方案在并发量低的时候没问题,但一旦回调重试和超时重试同时发生,两套逻辑都会跑到“判断订单状态”的地方,状态还没改成已支付,结果双双执行了扣库存。

REA 的做法把这个竞争条件彻底绕开了。因为扣库存不是“改订单状态 + 改库存字段”两个独立动作,而是“插入一条带唯一幂等号的事件 + 同步更新投影”。即使两个请求同时到达,数据库的唯一索引也会保证只有一个能插入成功,另一个会命中DuplicateEvent分支直接返回。

我实际部署过这个方案后体会特别深:以前老是担心缓存失效导致库存超卖,现在完全不用操这个心,因为超卖这件事被换了一个问题——变成了“资源投影更新时并发导致负数怎么办”。而那是一个纯粹的数据库乐观锁问题,有标准解法,不需要在业务代码里到处撒锁。

4.2 事件表膨胀:历史数据怎么处理

这是很多人没动手就先担心的问题:“事件表只增不改,永远只追加,那表不是无限膨胀吗?”说实话,是。但解决这个问题是工程上很成熟的事情,不用自己吓自己。

我的方案是三层:

  • 热数据:保留最近 6 个月的事件,放在主库,提供在线查询。
  • 温数据:6 个月到 2 年之间的事件,按月分区归档,仍在同一个库但可以走归档分区,查询频率低。
  • 冷数据:超过 2 年的,转存到离线数仓或对象存储,业务侧基本不查,要查走数据平台。

事件表本身按occurred_at做月度分区,是必备操作。另外,在线查询经常走biz_no(订单号),所以事件表除了幂等唯一索引,还要加一个biz_no的普通索引,不然客服查一张订单的历史轨迹会等到怀疑人生。

我还做过一个优化:在事件主表里冗余一个resource_change_summaryJSON 字段,把“这个事件影响了几个资源、每个资源增减多少”直接塞进去。这样客服查轨迹时,只查事件表就够了,不需要 JOIN 两张关联表,查询路径短了很多。

4.3 REA与DDD、事件溯源的搭配实践

聊 REA 很容易绕不开“事件溯源”和“领域驱动设计”。我个人的实践结论是:三者的关系不是谁替代谁,而是不同层面的事。

事件溯源(Event Sourcing)是一种数据持久化模式,它的核心是“用事件流作为真相源,状态只是事件的投影”。REA 正好提供了事件流的业务语义规范——哪些事件是有效的、事件要带哪些属性、资源和行动者怎么关联。所以你可以把 REA 看作事件溯源在业务建模层的“蓝图”。

DDD 则更关注“聚合”和“边界”。我实际用的方式是:一个订单聚合负责处理所有与订单相关的事件(预占、支付、出库、签收),库存资源则单独建一个库存聚合,因为库存的变更不止来自订单,还来自采购、盘点、调拨。两个聚合之间不直接调用对方的方法,而是通过发送“领域事件”异步协作。这样既守住了 DDD 的聚合边界,又没丢掉 REA 的事实记录。

有一类问题两者都有解,但实践上要小心重复建模:比如库存资源表,在 DDD 里是库存聚合的聚合根,在 REA 里是资源的投影表。这两者其实是一张表——你要么把它当作聚合根去承载业务规则,要么把它当作投影表去响应事件,不要同时让两种模式都直接操作它,否则会出现两个写入源头,数据一致性就崩了。

4.4 迁移踩坑与团队协作记录

最后分享几个踩过的真实坑,都是文档里不会写、但一定会遇到的事。

坑一:全员 REA,过度设计。一开始我很兴奋,恨不得把用户表、商品表、配置表全都事件化。结果发现,只有当“资源有强对账需求、事件流有多步骤还原需求”时,REA 才有明显优势。像配置表这种改了就改了的,硬套事件纯属自找麻烦。后来我定了一条边界:财务和仓库要跟你对账的表,才值得用 REA;纯粹的业务字典和流程记录,用 CRUD 就行。

坑二:事件不可变原则被打破。线上跑了两周,有同事为了修数据,直接写了条 UPDATE 改了event_resource.qty。结果所有下游对账全乱了。事后我加了数据库权限:业务账号对事件表只有 INSERT 和 SELECT,没有 UPDATE 和 DELETE。要纠错,必须走“更正事件”流程。

坑三:行动者和触发方没分清。前面已经提过,再强调一次。如果一条退款事件里,既分不清是“用户申请的”还是“客服代操作的”,那后面一旦出现纠纷,你根本无法定位是谁的责任。建模时就要把“发起人”和“执行人”拆成两个参与角色,而不是笼统地存一个操作人。

坑四:团队共识成本比想象中高。REA 不是一个“你画个 ER 图大家就懂”的模型。它要求研发、产品、测试、财务、仓储多角色讨论“什么是资源、什么是事件”。我建议在项目启动前安排一次建模工作坊,让仓库负责人和财务负责人也参加,现场把事件清单对齐。这一步不省,越早做成本越低。

我个人实操下来的体会是:切换到“rea”这个思维以后,最大的变化不是表结构变复杂了,而是业务语言变了。以前研发和财务吵架,一个说“我库存是这么算的”,一个说“你算得不对”。现在双方拿着同样一份事件清单,指着某一条“出库事件”说,问题就出在这。这种对齐的价值,比任何表设计技巧都重要。

如果你也想在现有系统里尝试,我不建议一次性全量重构。选一个对账敏感、状态逻辑最乱的模块,比如库存或售后退款,单独跑三个月,看事件还原和审计排查是不是真的比以前省事,再决定要不要铺开。最后再分享一个小技巧:每次评审新需求,让产品经理先把业务动作填成“事件-资源-行动者”三行表,这个动作本身就能劝退一半不成熟的伪需求。

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

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

立即咨询