首页 / 资讯中心 / 文章详情

REA建模模式:资源-事件-代理三元组驱动DDD与事件溯源落地

REA建模模式:资源-事件-代理三元组驱动DDD与事件溯源落地 ★ FEATURED ARTICLE
最近在盘点某系列业务系统的领域模型时我又把“REA”这个建模模式完整地跑了一遍。很多人第一次看到 REA 会以为是某个框架或者中间件其实它是 Resource-Event-Agent 的缩写也就是资源、事件、代理三元素组成的业务建模方法。它解决的核心问题就是业务系统中那些“账户余额怎么变”“库存什么时候减”“什么角色触发什么动作”纠缠在一起的建模难题。这套方法特别适合正在做领域驱动设计、事件溯源、或者重构复杂交易系统的同学也适合那些已经写了不少 CRUD 代码、想提升建模能力的人。这篇文章不会空谈理论我会从概念拆解、DDD 结合点、电商订单实例、代码存储落地一直写到真实踩坑记录手把手把 REA 的用法讲透。1. REA 模式到底改了什么资源、事件、代理三元组1.1 把业务看成“资源在事件里流转”先不急着上概念。想象一个普通购物流程商品从货架上被取走送到买家手里买家的钱从账户划到平台账户。你会怎么描述这件事习惯做法会说“库存减一、订单状态变已发货、账户余额增加”。这其实是一堆“状态变化结果”。而 REA 的视角完全不同它会说这里发生了两个经济事件——“发货”和“支付”每个事件里都有东西在变化这个被变化的东西就是资源也就是库存商品和资金谁让这件事发生的、谁参与了这个变化就是代理比如买家、卖家、支付渠道。这里有个很关键的点事件不是“状态变化后的通知”而是业务事实本身。资源也不等于数据库表里的某一行而是能够被计量、可被转移、具有经济含义的对象。代理则承担“人/系统/组织”这类参与者的角色。三者放在一起业务模型就从“状态的堆叠”变成了“事件驱动的流转”。这种视角转变带来的直接好处是需求分析时不用先拍脑袋定表结构而是先顺着业务时间线把事件找全资源自然会浮现出来聚合边界也会跟着清晰。我在实际项目里体会最深的一点是只要事件梳理得足够完整后面写领域服务基本就是在“翻译”模型而不是创造逻辑。1.2 和传统借贷记账相比REA 更适合做领域模型底座很多业务系统最终都要记账最典型的就是借贷记账法借方和贷方科目、凭证。它不是不好而是它服务的对象是“财务报告”不是“业务过程”。比如一笔订单退款财务上要记“退款凭证”但业务上可能经历了“创建退款单—审批—原路退回—冻结解冻”等一系列过程。如果用借贷记账做领域模型很容易把所有逻辑都塞进“凭证”对象里聚合边界模糊、业务规则散落。REA 在这里相当于把“事件、资源、代理”作为更基础的表达单元借贷凭证反而可以由这些单元推导出来一个支付事件发生后资金资源在两个代理之间转移凭证就是一种投影。换句话说借贷记账是实现层的一种视图REA 是建模层的一种框架。带着这个理解去设计系统你会发现领域层不用再写一堆和财务强耦合的代码而是先保证经济事件被完整记录。我自己的体会是用 REA 建模的项目后期最不用担心的就是“对账”。因为账根本不是“算”出来的而是从事件流中投影出来的天然具备可追溯性。凡是最后需要审计、追溯的业务REA 模型都能省掉大量解释成本。2. 为什么在 DDD 落地时REA 是很好的聚合边界推导工具2.1 事件风暴产出的“事件”刚好可以映射到 REA 事件DDD 的实践中事件风暴是特别常用的方法一群人把领域事件贴在墙上从命令开始一路推到事件。但很多人做完事件风暴就卡住了——事件是有了可怎么划分聚合按业务直觉拍还是按数据库外键关系看这两种做法都容易导致聚合越来越大最后变成一个“万能聚合”。REA 给了一个非常具体的答案。一个业务事件必然关联“谁”(Agent)对“什么”(Resource)做了“什么动作”(Event)。这个组合本身就是聚合的天然边界。以“发货”事件为例它涉及的资源是“库存”代理是“仓库操作员”事件是“库存出库”那么“库存流水商品批次仓库操作记录”就应该被放在同一个一致性边界里而不是散落在不同服务里。实际上一旦你开始用这个思路推导事件风暴时会自动过滤掉那些无关的中间状态——比如“打印了面单”这种不直接改变资源、不涉及经济含义的动作就不应该成为核心领域事件。REA 更像是事件风暴的一个过滤器帮你留下真正需要建模的业务事实。做事件风暴时如果现场争论不休我就会建议大家回到一个问题这个事件流入或流出了什么资源只要答不上来就说明它很可能不是经济事件。2.2 从“余额驱动”到“事件驱动”的建模选型很多交易系统早期设计都会走“余额驱动”路线用户表里放一个 balance 字段下单满减、退款直接加减余额。这套方案写起来最快但一旦业务复杂起来就到处是坑并发扣款要加锁、退款要小心不能超退、不同账户类型逻辑互相穿插。下面这个对比可以很直观地看出差异维度余额驱动设计事件驱动设计REA余额字段用户表/账户表里直接维护从事件流投影得到不直接存储账务一致性依赖数据库锁或分布式锁依赖聚合内的版本号和事件顺序业务可追溯性只能看到最终状态能看到完整的事件链条新增业务逻辑的成本需要重新梳理余额变更点新增事件类型投影模型扩展即可遇到并发时的问题容易超扣、超发通过版本号冲突检测事件驱动做法则是不维护余额只维护事件流。余额作为一个“派生状态”在任何需要显示的场合由事件流计算或由预生成的快照提供。REA 在这里恰好提供了事件的分类学每一种经济事件对应哪些资源的增减、涉及哪些代理、能派生出什么结果。它让你在建模时就想清楚balance 字段该出现在哪个聚合里还是根本不应该存在。当然这也意味着你要接受一点某些查询不能直接 select 一个字段而是要通过事件投影得到。好在现在事件溯源和 CQRS 已经很成熟这个代价大部分情况下是可以接受的。如果项目里实在不想引入事件溯源框架也可以退一步只在资金和库存这类核心资源上用 REA外围报表继续用普通表。这属于落地策略问题不影响模型本身的价值。3. 电商订单场景下的 REA 建模实例一步步画给你看3.1 场景确认多参与方、多资源、多事件先说建模目标。这是我在某电商类模拟项目中整理出来的场景不涉及具体公司平台上有买家、卖家、平台运营三种主要角色核心业务包括商品上架、下单、支付、发货、确认收货、退货退款。涉及的资金不是简单的现金还包括余额、积分抵扣、优惠券。这种业务如果用传统订单表加状态机来做订单聚合会越来越大最后变成“上帝类”。REA 建模的第一步不是画表而是顺着时间线把所有经济事件列一遍。我列出来的核心事件大致有提交订单、支付成功、积分抵扣生效、发货完成、确认收货、发起退货、退货完成、退款到账。这个清单的价值在于每一个事件都同时涉及资源和代理。比如“退款到账”不是简单的订单状态变化它涉及资金资源从平台账户流回买家账户代理包括买家、平台、支付渠道。后续派生对账单、核算收入、统计库存损失都是从这些事件出发。我建议你在做同类建模时也先拿白板把所有事件列出来哪怕重复、哪怕看起来像状态枚举都要写上去后面再筛。3.2 资源与资金科目的识别设计资源是 REA 里最容易搞错的一层。我总结了一个原则只要能计量、能转移、会被事件改变的值对象才值得建模为资源纯配置类的数据、状态标记不应该硬塞进资源。在这个电商场景里我最终划出了三类资源资源类型具体资源参与的事件示例备注实物类商品库存发货完成、退货完成按 SKU 维度计量资金类平台账户可用资金支付成功、退款到账账面资金不是用户余额权益类用户有效余额/积分支付成功积分抵扣、退款到账混合了积分与余额值得一提的是优惠券我没有建模成资源因为它是“承诺”而不是“资产”本身不可转移、不可多次计价。把它建模成事件上的约束条件更合适比如“支付事件满足券条件时资金资源增量减少”。这一条是踩过坑之后才想明白的。一开始我把优惠券也塞进资源结果每次核销、过期、退券都要处理复杂的资源流转聚合边界完全乱了后来才意识到它只是个约束条件不是被流转的对象。3.3 核心聚合推导与领域事件划分有了资源和事件聚合的推导就顺理成章。我的做法是以“事件 被它改变的资源”组成小聚合代理通过身份标识引用而非嵌进聚合内。得到的核心聚合大体是订单聚合承载提交订单、确认收货包含订单项和配送信息这里的“事件”是订单生命周期事件但它不直接修改资金。支付交易聚合承载支付成功、退款到账包含支付流水、退款流水资源是资金账户变动。库存履行聚合承载发货完成、退货完成包含库存流水和批次信息。每个聚合的边界内事件和资源是一一对应的。比如“库存履行聚合”内只有库存相关事件支付相关事件不会闯进来。这样带来的好处是领域规则不再散落在 service 方法里。要约束“退货完成必须先有发货完成”直接在“库存履行聚合”里检查前置事件是否存在就行而不是在 controller 里加 if。实际建模时你会遇到“一个事件同时影响两个聚合”的情况比如支付成功同时改变了平台资金和订单状态。处理方式很简单把“支付成功”作为源聚合支付交易聚合的事实通过领域事件通知订单聚合去确认状态而不是让一个聚合同时持有两份资源。这是 DDD 里很经典的跨聚合协作方式REA 让这种划分不再靠直觉而是有据可依。3.4 业务规则落地的检查清单聚合划分好后真正的难点在业务规则。我用 REA 建模时会把规则拆成三类分别落在不同位置避免一锅炖。第一类是前置事件规则比如“支付成功事件必须早于发货成功事件”。这类规则放在聚合内部的业务方法开始时校验可以基于已存储事件的查询来判断。第二类是资源守恒规则比如“退款金额不能超过可退金额”。这类规则要对比当前资源可用量与事件值在聚合内通过资源流水累计判断。第三类是跨聚合的最终一致性规则比如“支付交易聚合确认支付成功以后订单聚合才能流转到待发货”。这类规则用领域事件发布与订阅来驱动不做强事务。实际操作时我建议你用一张清单表把三类规则逐条列出来标注归属聚合和触发时机比直接在代码里补校验要清晰得多。这个清单在评审时也能让其他同事快速理解业务约束。我见过不少项目的领域模型本身没问题但业务规则散落在 service、controller、甚至 SQL 脚本里最后没人说得清规则到底在哪维护。用这张清单至少在评审会议上能少吵很多架。4. 从模型到代码聚合、事件存储与并发扣减的落地实践4.1 聚合实现与仓储设计从模型落到代码最关键的决策是聚合大小的控制。REA 的聚合天然偏“小”——每个事件配对应资源聚合即可不要试图做一个“单据聚合”把订单、支付、库存都包进去。我在代码里用了类似这样的结构订单聚合里只允许存在订单相关的实体支付交易聚合持有支付流水列表库存履行聚合持有库存流水列表。仓储设计上每个聚合对应一个仓储接口聚合内的事件统一追加存储。需要说明的是仓储的持久化不是直接写业务表而是把领域事件按序追加到事件表中。读取时通过聚合根加载全部事件再逐步 apply 还原状态。另一个做法是事件表加“当前状态快照”辅助我项目里是事件表 每聚合一份快照表事件用于审计和重建快照用于加速读取。这里有个容易忽略的细节事件表必须保留事件的全局顺序标识比如单调自增的版本号或者雪花 ID用来做并发冲突检测。否则多个请求同时写同一聚合时你无法判断谁的版本更新。我一开始图简单只用业务时间字段排序结果两个并发请求提交事件时顺序颠倒库存数据直接错乱。后来老老实实把 version 字段加上再没出过这类问题。4.2 事件表设计一眼看懂的字段结构直接给一个我在实践中精简过的表结构字段不多但都能对上 REA 三元组。字段说明对应 REAevent_id唯一事件 ID幂等判断的关键Eventaggregate_type / aggregate_id聚合类型与聚合根 ID归属上下文event_type具体业务事件类型Event 类型resource_ref资源引用比如库存 SKU、资金账户 IDResourceagent_ref代理引用比如买家用户 ID、平台账户 IDAgentoccurred_at业务发生时间不是落库时间Event 时间payload事件载荷存放金额、数量、幂等键等扩展信息version聚合内版本号乐观锁用并发控制字段里的 resource_ref 和 agent_ref 我特意强调它们保存的是引用而不是冗余数据。如果你直接把代理名称、资源名称都塞进事件表后续改名、迁移会非常痛苦。要查询展示信息时通过引用去关联主数据而不是事件表里再造一份。记住事件表是事实记录不是查询宽表。这个表可以按月份归档或分表因为事件只会追加、不会修改天然适合顺序写。归档时需要注意重建状态的事件顺序必须完整保留不能只保留聚合快照而丢掉明细事件。我见过有人为了省空间把一个月前的事件全部删除结果对账时发现数据对不上又花了几倍的时间从备份里恢复得不偿失。4.3 并发扣减、积分抵扣这类典型场景怎么处理并发扣减库存是 REA 落地时最容易被拿来“考验”的场景。我的处理方式是在库存履行聚合的“扣减库存”方法上使用乐观锁事件表的 version 字段作为更新条件。伪代码如下def deduct_inventory(aggregate_id, sku_id, quantity, expected_version): events load_events(aggregate_id) if events.version ! expected_version: raise ConflictException(版本冲突请刷新后重试) available calc_available_stock(events, sku_id) if available quantity: raise InsufficientStockException(库存不足) append_event(InventoryOut, {sku_id, quantity}) save_events(aggregate_id, events.version 1)这里我特意没有先更新库存表而是先算“可用量”再追加事件。因为这个场景下库存状态完全可以从事件流中还原根本没有独立的“库存值”需要先锁。很多人会顺手在库存表上加悲观锁那其实是把 REA 又退化回行锁方案了。积分抵扣其实也是同一条路把“积分账户”当资源把“积分抵扣事件”追加到支付交易聚合或独立的账户聚合里。抵扣时校验积分余额成功后资源事件流就多了一条记录积分余额在这个事件之后自动减少。退款的“返还积分”就是追加一条反向的积分增加事件两者通过关联的事件 ID 建立追溯链。这套逻辑看起来多写了一层事件但换来的好处是所有账户变动都有据可查不会再出现“用户说积分少了程序却说没有扣过”这种扯皮情况。4.4 事件溯源与 CQRS 结合时的取舍REA 给了事件一个清晰的语义模型但要不要上完整的事件溯源框架是另一个问题。我在项目里采用的是“半事件溯源”核心资金和库存聚合使用事件流驱动状态其余外围展示数据用普通表和定时投影。选择这个折中方案的直接原因是完整事件溯源的前期成本和团队理解成本都不低。如果核心交易链路日均事件量不大没必要一上来就引入重量级框架但你已经有了 REA 模型事件表、事件流这些基础结构是现成的后期要升级也能平滑过渡。我这里有一个很实用的经验先让查询走事件投影而不是一开始就同步写冗余宽表。用 CQRS 的思想读写分离把订单列表页需要的数据通过投影机制生成。这样事件流是唯一的真相源读模型随便改不会污染写模型。等读模型稳定了再引入专门的投影框架也不迟。顺序反过来的话很容易出现“写模型改个逻辑读模型忘了同步两边数据不一致”的窘境。5. 我踩过的坑和给你的快速上手清单5.1 坑一为了 REA 而 REA把配置和数据全建模成资源第一次完整用 REA 建模时我把商品分类、开关配置、甚至页面展示规则都想办法套进资源/事件/代理框架里。结果模型变得异常复杂聚合数量翻倍开发效率反而下降。后来才明白REA 只适合描述“经济事件”以及被事件影响的可计量资源。配置类、元数据类的东西用普通领域对象就好不要强行建模。识别标准其实很简单你手里的数据会不会被业务事件改变会不会作为某个事件的流入或流出项两个问题都是否就不要进 REA 模型。这个标准后来我在评审每个新聚合时都会带着问一遍能少建不少无用模型评审效率也高了很多。5.2 坑二代理建模过于抽象导致可读性崩塌REA 理论里 Agent 可以分层、可以组合建模时容易走极端。我曾把所有代理都收敛到一个通用 Actor 表里再靠 type 区分买家和卖家。最后代码里全是 actorId查询时还要反查类型业务语义完全丢失。正确做法是把代理拆成多个角色限界类型例如 BuyerId、SellerId、PlatformAccountId即使在同一个聚合内可以直接用值对象表示。这样代码可读性更强编译期就能发现用错角色的问题。DDD 里的限界上下文在这里非常重要代理角色必须在自己的上下文内命名切勿全局统一。5.3 坑三事件重复投递导致资金重复入账有段时间线上出现了重复退款排查后发现是消息重试机制把“退款到账”事件投了两次而业务处理没有做幂等。REA 模式里这个问题更加隐蔽因为事件表里的事件看起来都合理实际上同一事件出现两次就是错误。后来我在所有事件处理入口统一增加了幂等校验利用事件表中的唯一键约束来保证同一事件只能追加一次。具体做法是通过 event_id 建立唯一索引插入冲突就直接跳过同时在业务方法执行前先查事件是否存在。这个规则要提前设计好否则一旦线上出现重复事件事后清洗数据的成本特别高。最好在建表第一天就把唯一索引加上后面补会比一开始加麻烦很多。5.4 避坑清单新项目用 REA 建模的六个步骤给刚开始接触 REA 的读者一个可以直接照抄的清单先做事件风暴把业务动作按时间线列出来不要管表结构。筛选经济事件过滤掉不涉及资源变化的状态标记。为每个事件标出资源流入、资源流出和参与的代理角色。按“事件 受影响的资源”划分聚合代理只引用不内嵌。把前置规则、资源守恒规则、跨聚合规则分类落到聚合方法、事件处理器和订阅器中。设计事件表和版本号先跑通一条核心链路再逐步扩展。这个顺序看起来朴素但每一步都把下一步的可执行性放在了眼前。尤其第 1 步和第 2 步之间很多人会省略筛选直接把所有领域事件都留着最后聚合边界必然混乱。我这里反复强调筛选就是想告诉你们事件不是越多越好有价值的“经济事件”才是 REA 的素材。最后再分享一个小技巧。每次新建一个业务模块时我会把 REA 三元组画在一张小纸条上贴在显示器边上对着需求一条条问这个需求会产生什么事件改变什么资源由哪个代理发起如果问到最后一个问题发现没有代理那大概率说明你还没找到真正的业务入口建模要重来。这套方法我用了三年代码越写越清晰迭代越做越省力。如果你也在做领域模型设计强烈建议下次事件风暴时把 REA 拿出来对照一遍。
阅读完成 · 觉得有帮助?
咨询建站