
如果你在业务系统里写过几年订单、库存、财务这类模块大概率见过一张订单表里塞满状态字段的场面支付状态、发货状态、退款状态、审核状态……每个字段背后都藏着一段分支逻辑可表结构本身却把整个业务过程彻底压扁了。我第一次认真研究REAResource-Event-Agent就是在一套订单履约系统被状态字段搞到头疼的时候。这篇文章不打算堆理论我会从一次真实的销售建模出发把REA到底是什么、怎么拆业务、怎么落库、有哪些坑讲透尤其适合正在做领域建模、交易系统、供应链系统或者被DDD落地困扰的开发者。REA三个字母听起来像某个学术缩写实际拆开是Resource资源、Event事件、Agent代理它是一种把业务系统理解成资源流动的建模视角。刚接触的人最容易犯的错是把它当成另一种画ER图的技巧画来画去还是在表达对象和对象的关系。真正用对REA之后你反而会重新审视一个问题业务系统到底该存状态还是该存事件下面我把这层逻辑逐步拆开。1. REA建模的核心命题业务系统里到底该存状态还是存事件1.1 传统建模的困局状态字段把业务过程压扁了先看一个特别常见的订单表设计。某公司有一套订单履约系统最初的设计完全以订单为中心order表存订单头order_item存明细订单上放着status字段从CREATED、PAID、SHIPPED一路流转到COMPLETED再加上refund_status、settlement_status之类的辅助状态。表结构一眼看过去很直观开发同学也习惯了反正下游接口来了就改状态。可问题会随着业务扩展逐渐暴露。第一状态之间的转移规则散落在Service代码里没有一张图能说清楚从A状态能不能到B状态。第二业务审计极其痛苦要查这个订单的支付动作具体什么时候发生的操作人是谁只能在操作日志里翻字符串日志一旦没写全就彻底丢证据。第三状态字段之间还会互相矛盾比如一笔订单主状态显示PAID退款状态却显示REFUNDED数据库里看不出这两者是什么关系只能靠代码补丁去约束。我印象最深的一次线上事故就是因为订单状态和库存扣减状态没有对齐。下单时先把主单状态置为PAID再异步去扣库存结果库存服务响应超时主单已经到了已支付状态仓库那边却一直没有锁定资源。事后排查发现支付成功和扣减库存成功根本不是同一笔业务事实硬塞进一张表的两个字段里必然会出现时间差和解释歧义。这个经历让我开始怀疑状态字段是不是从一开始就用错了方向。1.2 三个字母的含义资源、事件、代理REA的出发点非常朴素业务世界由事件驱动事件会改变资源的数量或归属而事件必然由代理发起或参与。用一家咖啡店的场景来理解资源Resource一切有价值、可被交换的东西。咖啡豆、做好的美式、现金、储值卡余额都算资源。事件Event资源被获取、被转移、被转化、被消耗的动作。卖出咖啡是事件进货咖啡豆是事件顾客充值也是事件。代理Agent参与事件的人或组织。顾客、店员、供应商、门店都是代理。这个三元组最大的价值是逼着你去回答一个问题业务里每一次有意义的动作它动了什么资源谁发起的这个动作还牵动哪些代理传统ER建模会先问有哪些实体REA建模会先问有哪些资源在流转哪些事件在改变资源谁在用资源做交易——提问顺序不同设计出来的系统结构也完全不同。我以前一直觉得REA和自己平时做订单系统没区别直到用这个视角重新看销售场景才发现传统订单表里那些所谓的订单状态本质上其实是多个事件叠加后的结果投影。支付事件发生之后你看到了已支付出库事件发生之后你看到了已发货每个状态背后都对应着一件已经发生过的事实。既然事实本身已经能描述业务为什么还要额外存一个容易失真、容易冲突的状态字段呢1.3 一句话核心业务不是对象快照而是一串事件流ER模型适合描述对象当前的快照比如订单现在是什么状态、客户现在有多少积分。REA适合描述对象经历的流比如这个订单经历过下单事件、支付事件、出库事件每个事件都留下了不可篡改的证据。把视角从快照切换到事件流表结构会发生一个关键转变不再有臃肿的状态字段取而代之的是一张张事件表状态变成通过事件计算出来的派生结果。打个比方账户余额如果只存一个数字你永远说不清这个数字是怎么来的如果把每一笔存取款都记录成事件余额随时可以重算。REA就是这个思路在企业业务层面的推广不只记账本应该这样订单、库存、销售、采购其实都适合这样。2. 一次订单履约系统的REA建模全过程2.1 业务边界与建模目标我以某公司一套模拟项目X的订单履约系统为例。业务模型其实很常见客户在小程序下单购买商品系统锁定库存确认订单客户完成支付仓库发货财务后续做结算对账。最初这套系统的表结构走的是经典订单表路线状态字段膨胀问题已经压不住了。重构时我给自己定下目标不引入特别重的中间件先从数据建模层面把业务事实和业务状态分开存储让后续的业务审计、库存对账、财务核算都能基于同一套事实记录。建模前先梳理业务边界核心资源是商品、商品库存份额、客户资金核心事件是客户下单、客户支付、仓库出货、财务确认收款代理至少有客户、店铺、仓库管理员、财务人员。这里的财务确认收款容易被忽略但实际做对账时它非常关键因为支付渠道回传支付成功和财务侧确认资金到账并不一定是同一个语义。2.2 从订单表到事件表的映射拆解传统订单模型里一张order表几乎承担所有职责order_item管明细payment管支付流水stock_log管库存变动。REA视角下这几张表各自归属于不同的事件和资源传统表REA视角说明order销售事件 代理参与订单头不是一张对象表而是一串销售事件的事实记录order_item销售事件与商品资源的关联每个明细行代表商品资源被减少这个经济减量payment收款事件客户资金被减少商家资金被增加stock_log库存出库事件商品库存份额被锁定、被扣减status字段派生状态通过事件是否存在、事件之间的时序关系计算得到这个映射过程最关键的认知反转是订单不再是一个独立的事物而是若干事件围绕着客户资金和商品资源发生后的聚合投影。下单时生成一条sales_event支付时生成一条payment_event发货时生成一条shipment_event它们通过order_no这类业务编号关联到同一个订单上下文。订单状态不再被显式写入只有当你同时读取这些事件时才推断出当前处于什么阶段。我当时和团队确认了一个原则事件表永远是插入追加的不允许修改如果要修正业务错误必须再写一条事件撤销记录而不是UPDATE原来的事件。这条约束一开始会被质疑因为发生错误时开发者的第一反应就是改数据。但坚持下来后续的财务对账和审计效率提高了一个层级。2.3 用REA价值链把下单-发货-收款串起来单有一个事件还不够REA建模的妙处在于事件和事件会连成一条价值链value chain。销售业务至少有三个事件销售事件、发货事件、收款事件。这几个事件在资源层面是相互依赖的客户下单是一个经济增量所在的事件货品出库是一个经济减量所在的事件两者必须配对出现整个业务链才完整。具体到数据表达我用的约定是每个经济事件至少关联两个资源行——一个increment一个decrement数量用正负号区分。客户支付事件中客户资金资源是-100商家资金资源是100发货事件中本店铺商品库存资源是-1在途商品资源是1。这里有个很容易绕晕的地方看起来同一笔交易发生了两个加资源和两个减资源其实那是因为资源实体不同必须分别记录。库存从仓位移到客户手里不是简单扣掉一件商品而是商品资源在不同归属维度之间的转移。这件事传统订单表完全说不清因为order_item里只会存下单时的商品快照根本不会表达商品从哪个仓储节点流向哪个客户的路径。把价值链画出来之后团队里第一次有人感叹原来订单系统不只是管一笔单子的状态而是在管理一套资源在每个时间点的流向。这个认知统一之后后续增加新的业务动作比如退货、换货、退款就不再需要额外扩展状态机只需要注册新的事件类型再定义它和资源、代理之间的关系。3. 实际建模容易踩的三类坑附判断标准3.1 误区一事件表里还是在流转状态第一个坑最隐蔽。很多团队看了REA的教程回去照样建了event表但表里加了个event_status字段还是PENDING、SUCCESS、FAILED那套。这等于披着事件的外衣内里仍然是状态机思维。怎么判断自己是不是踩了这个坑看一条记录被写入之后还会不会UPDATE。REA要求事件是不可变事实如果事件表里存在频繁更新的状态说明你其实把过程进度和业务事件混在了一起。真正意义上的状态变更应该体现为新事件的出现而不是旧事件的修改。比如支付回调超时不该把sale_event改成超时状态而是重新记录一条支付通知超时的辅助事件让后续判断基于事实列表。我自己的判断标准很简单每当你想给事件表加一个表示结果的字段先停下来想——这个结果是哪件事发生之后才能知道的如果答案是另一件事发生了那就应该通过另一条事件的落库来描述而不是修改当前事件。3.2 误区二资源维度不全链路上出现缺口第二个坑是资源定义得太粗。我见过一个团队做REA建模只把商品定义成资源结果发现全部事件都关联到同一类资源上业务链条根本扯不开。实际上库存份额、在途数量、可用余额、冻结资金、积分、折扣券这些都是资源甚至虚拟库存额度也是一种资源只不过它的数量由规则约束。资源维度没分开会出现一个典型症状想统计某个客户名下的应收款时发现所有资金类事件全挂在订单上查不出应收账款这个概念。资源建模的颗粒度决定了系统能不能回答业务问题。我的建议是建模初期宁可多列出几个候选资源再逐个确认它是否参与经济事件、是否有归属代理、是否可被计量。另外资源不能只看正的存量还要看可用额度和已占用额度这类复合资源。库存系统里典型例子库存表里显示有100件实际上被预占的订单已经锁定了30件可用只有70件。没有把可用库存建模成一种可由事件增减的资源线上就会频繁出现超卖。3.3 误区三事件颗粒度不是过大就是过碎第三个坑是事件怎么切分。切得太粗会把多个不同的业务事实塞进一个事件导致后续没法区分切得太细又会产生大量无业务含义的记录系统被噪声淹掉。举一个吃过亏的场景一开始我把订单创建和支付完成合并成一个销售事件因为站在客户视角下单即支付很顺。可后来财务要求区分未支付订单量和已支付订单量我得从同一张表里靠字段去猜非常痛苦。后来把事件拆成sale_event和payment_event两个问题立刻清晰。反过来如果把用户每次点击结算按钮都当成一个事件也会制造大量没有经济含义的垃圾记录给查询和审计平添负担。判断标准我总结为一个事件必须对应一个不可再分割的经济行为它一次性改变资源并且有明确的代理参与。无法回答它改变了哪个资源的行为就不应该固化为业务事件最多只是系统操作日志。能回答这个问题但需要拆开才能说明白就别偷懒合并。3.4 建模自检清单我在每次REA建模评审时都会过一遍这张清单检查项通过标准事件是否追加不更新所有核心事件表无UPDATE操作有撤销事件机制资源是否有归属维度明确资源属于哪个代理区分可用/冻结/在途维度事件是否双向关联资源每个经济事件都有经济增量和经济减量代理是否内外都记录每个事件至少关联内部代理和外部代理状态是否为派生概念系统里无存储订单状态的字段状态由事件推出4. REA落地表结构、事务边界与查询还原4.1 最小表结构示例从一个最小可运行的场景出发我列了四张核心表和一张关联表。资源表、代理表、事件表和两个关联表按这个基础可以扩展出绝大多数业务场景。资源表存储可量化的资源定义CREATE TABLE resource_entity ( id BIGINT PRIMARY KEY, resource_code VARCHAR(64) NOT NULL, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- STOCK / MONEY / POINT unit VARCHAR(32) ); CREATE TABLE agent_entity ( id BIGINT PRIMARY KEY, agent_code VARCHAR(64) NOT NULL, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL -- CUSTOMER / SHOP / WAREHOUSE );事件主表只存时间、类型和来源单据号不存业务结果状态CREATE TABLE business_event ( id BIGINT PRIMARY KEY, event_no VARCHAR(64) NOT NULL, event_type VARCHAR(32) NOT NULL, -- SALE / PAYMENT / SHIPMENT occurred_at DATETIME NOT NULL, source_no VARCHAR(64), external_agent_id BIGINT NOT NULL, internal_agent_id BIGINT NOT NULL );关键是事件资源关联表它承载事件改变了哪些资源CREATE TABLE event_resource_line ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, quantity DECIMAL(18,4) NOT NULL, -- 正数表示资源增加, 负数表示资源减少 direction VARCHAR(8) NOT NULL -- IN / OUT );quantity用正负号表达方向是我实践后觉得最省事的做法。有人会把增量和减量拆成两列查询时反而要到处判断空值统一用正负号反而清晰。4.2 写入事件时的处理逻辑落库时核心事务边界是一个业务事件 多行资源增减必须同时成功或同时失败。以客户支付事件为例伪代码如下Transactional public void recordPayment(PaymentCommand cmd) { BusinessEvent event new BusinessEvent(); event.setEventNo(genNo()); event.setEventType(PAYMENT); event.setOccurredAt(now()); event.setExternalAgentId(cmd.getCustomerId()); event.setInternalAgentId(cmd.getShopId()); eventDao.insert(event); // 客户资金减少, 商家资金增加 eventResourceLineDao.insert(buildLine(event.getId(), cmd.getCustomerMoneyResourceId(), cmd.getAmount().negate(), OUT)); eventResourceLineDao.insert(buildLine(event.getId(), cmd.getShopMoneyResourceId(), cmd.getAmount(), IN)); // 账外登记, 方便对账定位 paymentTraceDao.insert(cmd.toTrace()); }这里务必注意事务里穿插远程调用是大忌。最开始我写过一个版本在事务里先调支付网关再落事件网关一抖动事务就回滚数据倒是不错了但接口动不动就超时。后来调整为先落一个PENDING的本地事件标记网关回调成功后再追加一条资金变更事件失败则追加一条失败事件把外部系统交互排除出核心事务。4.3 把事件还原回业务视图事件是底层事实面向产品界面时仍然要展示订单列表订单详情。我的做法是提供一个查询服务用JOIN把事件流投影成视图模型SELECT e.source_no AS order_no, MAX(CASE WHEN e.event_type SALE THEN e.occurred_at END) AS order_time, MAX(CASE WHEN e.event_type PAYMENT THEN e.occurred_at END) AS pay_time, MAX(CASE WHEN e.event_type SHIPMENT THEN e.occurred_at END) AS ship_time FROM business_event e WHERE e.source_no ? GROUP BY e.source_no;这张查询结果和传统订单查询几乎没有区别但数据来源完全变成了事实投影。线上要排查问题时能直接回答这个订单为什么处于已支付未发货——因为存在PAYMENT事件但不存在SHIPMENT事件。整个排查链路不再依赖状态字段是否被正确更新而是依赖事件是否真实发生可靠性高很多。如果担心每次都去聚合查询太慢完全可以维护一张只读的查询投影表在事件写入后异步刷新。这个投影表本质是缓存丢失了也可以从事件流重建与REA的事实为源思想并不冲突。5. 从REA往外走与事件溯源、DDD和财务系统之间的关系5.1 REA是事件溯源在产业侧的同门兄弟接触过事件溯源Event Sourcing的读者会发现REA和它的思想几乎同源事件是不可变事实状态由事件推导系统长期保存完整事件流。REA其实是把这一思想从技术框架层上移到了业务建模层它不管具体用什么中间件也不管事件是否存在专门的Store里只关心你在业务层面如何识别资源和事件。事件溯源是技术实现手段REA是业务分析方法两者可以独立使用也可以组合。实操中我把REA建模作为事件风暴Event Storming的前置步骤。团队在讨论领域事件时经常各说各话一旦先用REA把资源、代理、经济事件识别出来再去做事件风暴边界会清晰很多。REA能解决事件风暴里最常见的问题到底哪条事件值得建模哪个只是操作日志。5.2 DDD战术建模下REA聚合成什么样做DDD的人常问REA模型里的聚合根是谁我的实践是聚合边界的核心落在事件事件涉及的一组资源变动上。一个业务事件和它对应的资源增减记录应该属于同一个聚合因为资源的一致性约束强依赖这个边界。代理本身可以单独成聚合因为代理的状态变更和业务事件弱耦合资源定义也可以单独维护但资源余额的变动必须跟着事件走。具体到代码PaymentEvent这个聚合根包含clientMoneyDecrement和shopMoneyIncrement两个值对象两个值对象共同保证总量守恒。这种建模方式比传统Service层里手动管理多表事务要优雅得多因为业务不变量被牢牢限制在聚合边界内天然规避了不同事务模块之间的一致性冲突。5.3 财务视角复式记账本就是一张REA表财务系统可能是REA最如鱼得水的领域。复式记账的核心是每笔账至少涉及两个账户一借一贷总量守恒。而REA里的每个经济事件恰好要求至少一个资源增加和一个资源减少两边都有代理。本质上借贷分录就是REA模型经过会计规则约束之后的一种具体形式。所以当你做资金结算、对账系统时用REA去承载总账、应收、应付这类领域模型会比纯ER建模自然得多。应收账款不是一张表里的字段而是由销售事件已发生、收款事件尚未发生推出来的派生概念。上线这种模型后财务对账几乎可以直接基于事件流计算不再依赖定期导表比对。5.4 我现在建模时的判断清单与个人体会做了几个项目的REA改造之后我沉淀了一套自己的建模动作。拿到新业务领域我固定四个步骤第一步只找资源和资源间的流动通道第二步列出所有改变资源数量或归属的原子动作形成候选事件第三步给每个事件标出外部代理和内部代理第四步把事件按时间顺序接成价值链检查链上有没有断点。四步走完领域模型的骨架已经七七八八了。最后说一点个人体会。REA并不适合所有类型的系统比如纯粹的CMS内容管理、配置管理硬套REA反而徒增复杂度。但只要业务里存在交易、库存、资金、分账、履约这些东西REA几乎都能用上而且越早介入后续的返工越少。如果你也正在被订单状态机和库存对账折磨建议先从一张废弃的订单表入手试着用事件流把它的业务路径重新描述一遍。你很可能会有一种感觉原来业务系统可以这样长不需要靠状态字段硬撑着。