这次要分享的项目,内部代号就叫 rea。先说清楚,它不是某个开源框架,也不是某个资格认证的缩写,而是我们在一套业务系统里完整落地了一遍的 REA 建模方法:Resource-Event-Agent,也就是资源-事件-代理者。今年我所在的团队接到某公司内部一个跨平台订单履约系统的建设任务,为了方便叙述,下面统一叫它模拟项目X。这个系统覆盖多端下单、库存预占、仓库发货、退款结算,需求几乎每月一变,尤其是订单状态和库存逻辑,每次一改就要连带改十几个判断分支,开发同学被折磨得够呛。后来我们决定换一种思路,把整个核心链路用 REA 重新建模,相当于把会计记账的思维搬进了系统设计里。这篇文章就是这次实践从思路到落地再到踩坑的完整复盘,适合后端开发、业务架构、技术负责人,以及那些整天被业务方追着改状态机的朋友。
1. 先把REA这套思路讲明白:资源、事件、代理者
1.1 一句话解释REA:业务就是资源在代理者之间流动
REA 模型拆到最底层,只回答三个问题:系统里有哪些有价值的东西(资源),哪些事真实发生过(事件),是谁让这些事发生(代理者)。所有业务模型,本质上都是“某个代理者发起某个事件,导致某些资源的增减或转移”。比如顾客下单,顾客是代理者,下单是事件,库存和预占额度是资源。顾客支付,支付渠道是代理者,支付成功是事件,资金账户是资源。仓库发货,仓管员是代理者,出库是事件,实物库存变成在途库存。
用生活化的例子来理解,就是别只看余额,要看流水。银行卡让你查余额,但银行系统的核心其实是每一笔借贷流水。只要流水不乱,余额随时可以重新算出来。REA 的底层逻辑就是这样:把“余额”这种可推导的状态当成投影,把“流水”这种不可磨灭的事实当成真正的源数据。工程上这对应事件溯源(Event Sourcing)的思路,两者的亲和度非常高。
1.2 传统CRUD建模为什么越到后面越别扭
大部分业务系统一开始都喜欢用 CRUD 的方式建表:订单表里放一个 status 字段,库存表里放一个 stock_qty 字段,账户表里放一个 balance 字段。业务简单的时候这套东西很好用,但一旦需求开始频繁变化,问题就来了。新增一种“预售”状态,意味着订单表要加状态、所有状态迁移校验要跟着加分支;新增一种“促销占用库存”,要么在库存表上加字段,要么再造一张关联表,久而久之表和字段越堆越多。
更麻烦的是,状态字段一旦和业务逻辑耦合在一起,就很容易出现“非法状态迁移”。比如 A 同学改了一个接口,把订单从“待发货”直接改成“已取消”,另一个功能还认为订单可以发货,等到对账才发现数据对不上。REA 的做法是把状态从持久化模型里去掉,只保存“发生了哪些事件”,所有状态都是根据事件流推导出来的投影。需求变化时,系统的核心事件流通常不用动,只需要增加一个新的事件类型,再补一个对应的投影逻辑。
1.3 REA和ER图、状态机的差别
我整理了一个对比表格,方便大家直接对照:
| 对比维度 | 传统ER建模 | 状态机建模 | REA建模 |
|---|---|---|---|
| 核心关注点 | 实体、属性和关系 | 状态与状态迁移 | 资源、事件、代理者 |
| 数据真相 | 当前表字段 | 当前状态字段 | 不可变事件流 |
| 需求变化应对 | 加字段、加表 | 加状态、加迁移分支 | 加事件类型和投影 |
| 审计与对账能力 | 较弱,得靠操作日志 | 较弱,状态覆盖问题多 | 天然具备完整审计轨迹 |
| 建模思维 | 名词思维 | 动词思维,但偏控制流 | 会计流水思维 |
不是说 REA 要取代 ER 和状态机,事实上后面落到数据库时,还是需要 ER 表和投影表。REA 真正解决的是“业务语义层”的认知问题,让团队先达成共识,再谈怎么建表和写代码。这个顺序一旦搞反,后面改模型就很痛苦。
2. 在订单履约系统里做REA建模:先识别三类元素
2.1 项目背景:一个被状态机逼疯的跨平台订单系统
模拟项目X的业务范围包括用户在前台下单、库存预占、仓库发货、退款、结算对账。最初的设计采用传统做法,订单表有七八个状态字段,库存表有总库存、锁定库存、可用库存,各种定时任务轮询修正数据。团队每天都在处理状态不同步的问题:库存锁了,订单却取消了;订单退款了,账户余额没回补;仓库存货发出去了,系统里还显示待发货。需求方提的最多的需求就是“加一个状态”“加一种流程”,整个系统越来越像一团打了死结的毛线。
我们决定用 REA 建模时,没有马上去改代码,而是先关在会议室里,把所有核心业务流程列出来,一个一个地用“谁在什么时间操作了什么资源”来做文本描述。这个阶段不建议打开 IDE,先花时间在白板上把三类元素找齐。
2.2 资源识别:有价值和可计量的东西才叫资源
资源是 REA 里最容易过度建模的元素。判断标准很简单:它必须是有价值、可计量、能够在事件里发生增减或转移的东西。不是所有名词都是资源,比如“订单”本身不是资源,它只是一组事件的聚合记录;比如“订单状态”更不是资源,它是投影字段。订单履约系统里,我们最终识别出的核心资源包括:
| 资源名称 | 资源类型 | 说明 |
|---|---|---|
| 商品库存 | 存量资源 | 仓库中可售的实物数量 |
| 预占库存额度 | 存量资源 | 已锁定给订单但尚未出库的数量 |
| 客户资金账户 | 存量资源 | 用于支付和退款的余额 |
| 优惠券额度 | 存量资源 | 可抵扣的金额或折扣权益 |
| 配送运力 | 存量资源 | 可发货的物流能力 |
注意,库存资源还可以继续拆。比如按仓库维度分拆到不同的资源实例,避免把所有仓库的库存放在同一个聚合里。拆细之后,后续做并发控制会轻松很多,这一点在后面专门展开。
2.3 代理者识别:搞清楚每个事件背后的责任主体
代理者可以是人,也可以是系统,甚至可以是一个自动化流程。在模拟项目X里,代理者包括:用户、客服、仓库管理员、结算系统、审批流引擎。识别代理者的目的在于明确责任边界:资源减少是由谁批准发生的,资源增加是由谁确认到账的。这个边界一旦模糊,后面做审计、权限控制、异常追溯都会很困难。
我曾经见过一个团队把“系统自动执行”当成没有代理者,于是在建模型时把事件写成了无主事件。实际落到代码里,定时任务触发的扣减缺少操作人,出了问题根本不知道是哪个环节干的。建议即使是定时任务,也要给一个系统代理者标识,比如 sys-scheduler,这样事件流的溯源能力才不会断。
2.4 事件识别:直接决定模型的表达力
识别事件要抓住“一次事实的发生”,而不是“一个操作步骤”。我推荐用这样的句式来写:在某个时间点,某个代理者针对某个资源做了某件事,导致资源发生了多少数量的增减。比如:
- 在某个时间点,用户U提交订单O,预占库存资源R共2件。
- 在某个时间点,支付渠道P确认订单O支付成功,客户资金账户余额减少99元。
- 在某个时间点,仓库管理员W确认订单O出库,商品库存资源减少2件,预占库存额度减少2件。
事件和操作的区别在于,事件是不可撤销的历史事实。一个按钮点击是操作,但只有它真正影响资源状态并留下痕迹时,才成为事件。模型里的事件,对应到代码就是领域事件,一般使用过去时命名,比如 InventoryPreDeducted、PaymentCompleted。这样可以时刻提醒自己和别人:事件是已经发生的,不能修改,只能补偿。
2.5 业务规则与前提条件
REA 里还有一个可选元素叫“经济契约”(Economic Contract),用大白话说就是事件发生的业务前提。库存不足时不能预占,金额未支付不能出库,退款金额不能超过原支付金额……这些规则在建模时最好显式写出来,落在事件校验层。它们不仅仅是业务文案,更是系统的一致性和资金安全边界。我们在模拟项目X里把每条规则编号,比如 R001“库存预占前必须校验可用库存大于等于预占数量”,后面代码和测试用例都直接引用编号,评审时沟通成本明显下降。
3. 从REA模型到可运行代码:事件溯源与投影
3.1 聚合划分:把事件当作聚合的边界
模型梳理清楚之后,真正写代码之前,最关键的决策是聚合怎么划。REA 天然适合把“围绕一类资源产生的事件序列”作为一个聚合边界。比如库存预占这个业务能力,一组事件都针对同一个库存资源实例,我们就把这个库存资源实例作为聚合根,所有预占、释放、出库事件都通过这个聚合来落地。
这样划分的好处是,不同资源之间的耦合被显式拆开了。订单和库存不再是一张大表里的两个字段,而是各自独立演进,通过领域事件协作。订单聚合关注订单生命周期,库存聚合关注库存数量的增减,结算聚合关注资金账务。订单取消后,它不直接去改库存,而是发布一个 OrderCancelled 事件,库存聚合消费这个事件后执行预占额度释放。
3.2 持久化设计:事件表是源真相,投影表是查询视图
继续沿用 REA 的思路,数据库里最重要的表不再是订单表本身,而是事件表。事件表只做插入,不做修改和删除,记录事件ID、事件类型、事件时间、资源类型、资源ID、代理者ID、事件载荷。为了查询效率,我们同时维护投影表,比如库存投影表保存当前可用库存、预占库存、版本号。投影表是普通的关系表,可以随意更新,系统的源真相依旧在事件表。
一个库存预占事件的结构用 Java 表达大概是这样:
public record InventoryPreDeductedEvent( String eventId, String orderNo, Long warehouseId, Long productId, Long qty, String agentId, LocalDateTime occurredAt, String ruleViolationMessage ) { }投影更新逻辑要放在事件消费方,并且保证幂等:
public class InventoryProjection { private Long warehouseId; private Long productId; private Long availableQty; private Long preDeductQty; private Long version; public void apply(InventoryPreDeductedEvent event) { if (this.availableQty < event.qty()) { throw new InsufficientInventoryException( "库存不足,仓库=" + warehouseId + ",商品=" + productId ); } this.availableQty -= event.qty(); this.preDeductQty += event.qty(); this.version++; } }看到这里很多人会问:投影表更新失败或者事件重复消费怎么办?所以必须引入幂等机制,可以用事件ID作为唯一键,处理前先查重。实际项目里我建议把事件写入和投影更新放进同一个本地事务,或者使用事务性发件箱模式,防止事件发出了但投影没更新,或者投影更新了但事件重复发出的情况。
3.3 并发与一致性控制
REA 模型下资源余额字段是热点数据,并发预占很容易超卖。库存空闲数量查询和扣减之间有间隙,必须通过数据库行锁或乐观锁保护。我们用的是乐观锁版本号方案,更新语句大概长这样:
UPDATE inventory_projection SET available_qty = available_qty - ?, pre_deduct_qty = pre_deduct_qty + ?, version = version + 1 WHERE warehouse_id = ? AND product_id = ? AND available_qty >= ? AND version = ?;如果更新影响行数为 0,说明版本冲突或者库存不足,直接返回重试或报错。用这种方式,即使多个请求同时预占,数据库的行级锁也能保证只有一个请求更新成功。要注意的是,REA 模型里事件如果已经写入,但投影更新失败,不能简单丢弃,否则事件流和真实资源状态就对不上了。合理做法是让投影成为可重建的视图,定期从事件表重新扫描并修正,同时用对账任务兜底。
3.4 跨聚合协作:用领域事件而不是直接改别人的表
订单聚合和库存聚合分开了,退款时订单要释放库存、要回补资金,这是三个聚合的协作。我们采用的方式是:订单聚合发布退款申请事件,库存聚合和资金聚合分别消费。每个聚合各自守卫自己的资源余额,而不是由某个服务一次性去修改多张表。这个设计在单体应用里看起来多绕了几步,但对后续拆分微服务、扩展团队非常有帮助。
代价是短期内代码链路变长,需要建立良好的事件命名和消息队列基础设施。如果团队规模很小、业务也不复杂,我不建议一上来就全部事件化,可以先从最痛的库存和订单链路开始,其他模块继续用传统 CRUD,边界通过防腐层隔离。
4. 常见问题与踩坑实录:哪些地方最容易被带偏
4.1 把状态当成资源,把操作当成事件
新手最容易在两种地方翻车。第一种是把状态当成资源,比如“订单状态”根本不是有独立价值的东西,它只是根据事件流算出来的投影;同理“支付状态”也不是资源,支付成功事件才是资源变化事实。第二种是把操作当成事件,前端每点一次按钮都生成一个事件,结果是事件表变成了操作日志,完全失去了业务语义。
我的判断口诀是:问自己这个字段删除之后,还能不能根据事件流重新算出同样的结果?如果答案是能,那它就不应该作为源数据模型里的核心实体,而是投影字段。事件也不宜过细,应该以业务审计单元为准。一个接口动作可以被拆成多个事件,但如果这些事件在业务上不能独立成立,就不要拆。
4.2 事件表爆炸式增长:从冷热分离到投影重建
事件溯源模式最现实的问题是事件表会越来越大。模拟项目X运行两个月后,单日事件量接近百万条,历史订单查询越来越慢。我们的处理方式分三层:第一,事件表按时间分区,老分区做归档;第二,投影表承担日常查询,避免每次读实时计算全量历史;第三,增加每日定时重建投影任务,把事件表作为唯一源真相,只保留最近七天的项目录用于纠偏。
投影重建任务在运行时需要短暂占用资源,建议放在低峰期。我们曾经在生产环境直接跑全量重建,导致数据库连接池被打满,后来改成按业务批次扫描、小批量提交,才稳定下来。这类问题不是 REA 本身带来的,而是任何事件驱动架构在量大之后都要面对的工程问题。
4.3 聚合过大导致锁冲突加剧
如果所有库存都放在一个聚合里,热卖商品的并发预占会导致同一行数据反复锁等待,系统吞吐量上不去。REA 模型允许把资源拆细,同一商品可以按仓库、按批次维度分成多个资源实例。这样预占事件落在不同行上,锁冲突自然下降。我们当时还做了一个库存分桶的改造,将同一商品同一仓库的库存按数量分成若干桶,每个桶独立预占,明显缓解了热点行问题。
但分桶也会带来新问题:一个订单可能需要同时占用多个桶,资源预占和释放都得更复杂。如果业务量没有到瓶颈,不建议一上来就做分桶,先保持简单,用数据库行锁扛一段时间,再根据监控决定是否拆。
4.4 与现有报表和查询系统的兼容问题
事件表和投影表的结构与普通业务表差异很大,已有的报表、经营分析系统可能都是按订单主表来查询的。我们遇到的情况是,运营团队依赖“订单表里的状态字段”拉数,迁移之后报表查询全乱了。解决方案是保留一张面向查询的订单快照表,由投影更新时同步维护。也就是说,REA 模型在业务链路里做源真相,对外查询层仍然有传统的关系表,只是这张表被明确标记为“可重建投影”。这个落地方式和纯事件溯源的严格形式略有区别,但对现有系统的冲击最小,团队也更容易接受。
4.5 问题速查表
| 症状 | 可能原因 | 对策 |
|---|---|---|
| 数据库出现超卖 | 可用库存校验与扣减不是原子操作 | 使用乐观锁或行锁,校验和扣减放在同一条 SQL 里 |
| 同一事件被重复处理 | 消费方没有幂等机制 | 事件ID唯一索引,处理前查重或利用数据库唯一约束 |
| 订单状态和库存锁不一致 | 事件消费存在延时或丢失 | 引入事务性发件箱,并增加对账定时任务 |
| 事件表查询慢 | 未分区且无归档 | 按时间分区、冷热分离、投影表承载查询 |
| 模型频繁变化 | 事件粒度或资源边界划分不合理 | 回到白板重新梳理,先改模型再改代码 |
| 锁冲突严重 | 热点资源聚合过大 | 考虑按仓库/批次拆分资源,必要时库存分桶 |
5. 可以直接照抄的落地清单
5.1 团队建模工作坊怎么开最有效率
我强烈建议不要一个架构师埋着头画模型,然后让开发照做。REA 建模的产出不只是图,而是整个团队对业务语义的统一理解。我们按这个节奏操作:第一步,拉上产品、后端、测试,把核心业务场景写成用户故事;第二步,逐条场景问“这个操作涉及哪个资源,由哪个代理者发起,产生了什么事件”,全部写到白板上;第三步,把重复的资源、事件进行合并和去重;第四步,标出业务前提条件;第五步,评审资源增减关系和代理者职责边界。
这个过程看起来很像头脑风暴,但核心约束只有一条:任何一句业务描述里如果找不到资源、事件、代理者三要素,说明这个描述还没被理解透。连续三轮这样练下来,开发同学再面对业务方的“加一个状态”需求时,第一反应会自动变成“这是新增事件还是新投影”,这个转变才是 REA 实践最大的收获。
5.2 工具不需要太高端
白板和便签纸就够起步。要形成文档的话,推荐用表格先记录三类清单,然后用文本形式描述事件流,最后再用可视化工具画图。先不要急着引入复杂的建模平台,REA 的关键在思维过程,工具只是承载。团队异地协作时,可以直接用在线协作文档,不同颜色区分资源、事件、代理者,效果也不错。
建模结果建议沉淀到仓库里,和代码放在一起,每次需求变更都更新对应的 REA 事件流描述。否则三个月后模型又和代码脱节,白费功夫。
5.3 建议的最小落地范围
如果团队第一次接触 REA,我不建议一下子把公司所有系统都推倒重来。选一个业务痛点最集中的模块做实验,比如订单加库存,或者营销加结算。模拟项目X就是从库存预占和订单履约开始试点的,已经有明显的收益后,再逐步推广到资金结算、售后、对账。每扩展一个模块,都要先补充对应的资源和事件定义,确保事件命名的风格统一,比如过去时态、包含事件ID、事件时间、代理者ID。
5.4 和传统三层架构的关系
很多朋友担心 REA 会导致工程结构完全重写。其实不是这样,传统三层架构仍然可以保留,只是 Service 层不再是直接改状态的工具人,而是先构造领域事件、校验业务规则、追加事件,再更新投影。Controller、Repository、数据库连接池这些东西都不变,变化集中在领域层的表达方式。所以改造是渐进式的,不是一次推倒重建。
踩过几次坑之后,我现在对这套实践最深的体会是:REA 最大的价值不是那张画得漂亮的模型图,而是逼着团队把“谁在什么时间操作了什么资源”这个问题彻底说清楚。模拟项目X后来接到一个全新的促销占用需求,放在以前,订单状态机要加状态、加迁移分支、加各种页面判断,改动量肉眼可见地大。而这次我们只定义了一个新的促销资源和一个库存占用事件,再补一个投影,核心链路基本没有动。下一步我打算把资金结算和财务对账也按同样的思路完整接进来,让每一笔流水都能从业务源头追溯到最终账本。如果你也正在被状态机和余额表折磨,我建议先别急着写代码,拿一张草稿纸,把资源、事件、代理者画一遍,这个动作本身可能就帮你省下好几天的返工时间。