REA建模实践指南:用资源、事件、代理重构业务系统 看到rea这个题目我第一反应是可能有人想写React但少打了一个ct也可能是随手起的名字。真要按网络热词去搜确实能搜出一堆不相干的结果。但在做业务系统设计和架构落地的人眼里REA这三个字母有一个非常明确的指向Resource、Event、Agent也就是资源、事件、代理。它源自会计信息系统领域的业务建模思想后来被大量引入企业应用架构、事件溯源和数据建模中。这篇文章不讨论React也不猜人名专门聊REA这个建模思路把它讲清楚、讲透并且用一个完整的案例演示怎么落地。适合后端工程师、领域建模新手、架构师以及任何想从改状态字段的泥潭里跳出来的人。1. 从rea说起先搞清楚它到底想表达什么1.1 标题歧义与我对它的取舍一个只有三个字母的标题在不同人群眼里完全是不同的东西。前端同学会想到React英语使用者会觉得像个人名玩梗的可能联想到某句谐音但做后端建模的人看到REA这三个字母脑子里冒出来的就是一套完整的企业业务建模框架。我之所以选择从REA本体切入是因为在真实的项目里资源—事件—代理这套三元模型能直接解决那些让团队反复扯皮的问题订单状态该不该有、余额表该不该留、对账为什么总是对不上。很多业务系统做到后期代码复杂度最大的来源不是功能多而是状态太多。每个业务对象都挂了一堆状态字段每个状态都有无数种流转可能改一个字段要牵连十几个方法。REA恰恰提供了另一条路不保存当前状态只记录发生了什么。只要把这件事想通你会发现原来复杂的需求描述可以被拆成非常稳定的三元组而不随页面和接口变化不断漂移。1.2 REA本体最核心的概念先记住九个字在动手设计任何表结构之前我建议你先记住一句话谁让什么资源在一次事件中发生了增减。Resource指的是被交换、被消耗、被生产的对象比如商品、钱、工时、积分、库存Event指的是让资源发生变化的那次动作比如销售、采购、退货、调拨Agent则是参与动作的个人或部门可以是买家、卖家、库管、平台运营甚至可以是机器和设备。为什么这三个概念就够用了因为任何业务无论看起来多复杂最后都能落到某个代理在某个事件中让某些资源发生了增减。银行记账从来不会只记一个余额而会逐笔记流水余额只是流水汇总出来的结果。REA就是把这个朴素的道理推广到任意业务域。我建议你先别管术语遇到任何业务时只问三个问题这里有什么资源在变化发生了什么事件导致它变化是谁参与了这个事件能把这三个问题答清楚REA的骨架就已经搭起来了。2. 为什么选REA它解决了CRUD建模解决不了的问题2.1 传统CRUD建模的痛点状态字段一改历史就丢了大多数业务系统第一版建模都长这样一张order表上面有status字段待付款改成已支付再改成已完成谁改的、什么时候改的、为什么改全都被覆盖掉了。如果需要追踪订单从创建到发货经历了哪些步骤只能去翻应用日志而日志往往只记录操作人不记录当时的业务上下文。如果状态流转逻辑写了好几个分支业务规则一变整个方法的复杂度直接爆炸最后没人敢动那段代码。用生活类比一下你一直只记录手机里存款的余额不记账目明细等月底要核对每一笔钱去哪了就会寸步难行。CRUD模型表面上是围着订单转本质上是围着状态的最终结果转过程是隐形的。最麻烦的是一旦上线一段时间后产品经理过来说我想看到这个订单从创建到现在每一步的时间点和操作人你会发现自己只能从日志里挖或者干脆对着空气说做不到。这种问题不是加一个字段就能解决的而是建模视角的问题。2.2 REA的优势事件是唯一事实状态只是投影REA的思路完全不同订单本身不是一个被改来改去的状态容器而是一连串事件的聚合结果。买家下单是一个事件支付是一个事件发货是一个事件确认收货又是一个事件。数据库只会追加这些事件不会去UPDATE一个状态字段要得到当前订单状态就按顺序把事件重放一遍最后推导出来。这样做有两个立竿见影的好处一是每个业务决策都有据可查天然满足审计和对账二是将来如果要改变业务规则不必改历史数据只需要改重放和投影的逻辑历史事实仍然保持完整。我在实际项目中最大的感受是REA模型把事实和结论分开了。事件是事实状态是结论。事实发生之后就不能再变结论则可以根据最新需求随时重新推导。一个业务系统只要有可靠的事件流无论将来要做新的统计报表、接入新的风控策略还是做BI分析都能从事件流中重新运算而不用去祈求当时要是多存一个字段就好了。3. 核心实操用REA给一个典型业务系统建模3.1 场景设定从零开始搭一个某集市系统为了把建模过程讲明白我用一个常见的模拟项目做例子这是一个个人二手交易平台就叫某集市系统。业务看起来不复杂卖家发布商品买家浏览并下单买家付款到平台担保账户卖家发货买家确认收货后平台再把钱结算给卖家期间还可能有取消订单和退款。很多人的第一直觉是先建user、product、order三张表order上挂一堆状态。比如先给order加一个status枚举付款改一次发货改一次收货再改一次退款又要改一次。这个过程在开发前期很爽因为CRUD写起来快。但等项目跑起来财务要求对账、客服要查这笔订单为什么卡在发货环节三天没动、运营要分析转化漏斗你就会发现原来的表结构根本回答不了这些问题。所以我们换一种方式用REA把业务拆成一组清晰的经济事件发布商品、下单占用库存、买家支付、卖家发货、买家签收、平台结算、退款。每件事都是一条独立且不可修改的审计记录。3.2 识别人、货、事资源、事件、代理的完整推导过程先说资源。在这个场景里至少有四类资源商品库存、买家账户余额、平台担保资金、卖家待结算款项。注意订单本身不是资源它是事件的投影结果而担保资金是一个被事件驱动出来的资源池不是自然语言里的余额那么简单。再说事件发布商品增加可售库存下单占用库存支付事件让资金从买家余额流入平台担保资金发货事件不影响资金但会把物流状态推进到下一环确认收货事件让资金从担保资金流出、进入卖家待结算退款事件则让资金反向流动。最后是代理买家、卖家、平台运营、物流承运商每一方都要在事件里记录角色。有一条我反复强调的原则一条事件必须保证资源的流入等于流出并且至少要关联两个代理。拿支付事件来说资金从买家减少100元流入平台担保资金增加100元资源总量不变代理是买家和平台。如果只记录买家少了100元而不记录平台多了100元那账永远都对不上。这一条在建模初期最容易忽略也是后面数据对不上的主要来源。可以把它理解成物理里的能量守恒事件本身不创造资源只让资源在不同账户和库位之间转移。3.3 落地到表设计与事件存储直接看结构建模最终要落到存储。REA最简化的落地方案是三类表事件表、事件资源流水表、事件代理关系表。事件表存每次发生的事件本身定义如下CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, occurred_at TIMESTAMP NOT NULL, correlation_id VARCHAR(128), -- 业务唯一键用于幂等 schema_version SMALLINT NOT NULL DEFAULT 1, payload JSONB ); CREATE TABLE event_resource ( event_id BIGINT NOT NULL, resource_type VARCHAR(64) NOT NULL, resource_id BIGINT NOT NULL, delta_amount DECIMAL(12,2) NOT NULL, PRIMARY KEY (event_id, resource_type, resource_id) ); CREATE TABLE event_agent ( event_id BIGINT NOT NULL, agent_type VARCHAR(64) NOT NULL, agent_id BIGINT NOT NULL, role_code VARCHAR(32) NOT NULL, PRIMARY KEY (event_id, agent_type, agent_id, role_code) );看到这个结构你可能会想怎么还要两张关联表不重吗这地方恰恰是REA模型最值钱的部分。一张event表只有发生了什么资源增减流水单独记录什么资源变了多少代理表单独记录谁参与了哪个角色。三段信息解耦后面对账、出报表、做风控全都能从这三张表里重新算出来不用再依赖业务方写的统计接口。以买家A支付100元购买卖家B的商品这个事件为例实际插入的数据是这样的event表插一条PAYMENT事件event_resource插两条记录一条是买家余额资源delta-100另一条是平台担保资金资源delta100event_agent插两条记录一个是agent买家A、rolebuyer另一个是agent平台、roleguarantor。所有记录加起来资源流的净额等于0。这个净额等于0的校验我建议直接写到写入事务里而不是靠事后对账来发现。每一条流水如果不守恒当时就应该报错。另外还有一个容易踩的坑correlation_id这个字段一定要设计好。它承载的是业务链路的唯一标识比如支付单号。没有它网络重试、消息队列重复投递时同一个支付事件会被插入两次账就会莫名不对。加上唯一索引配合先查再插或者数据库层面的insert ignore能省掉后期大量麻烦。3.4 建模时最常见的三个误区状态当资源、事件拆过度、代理不区分第一不要把订单状态当成资源。订单状态不是一个可增减的资源它是事件流推出来的投影值。如果你在event_resource里加一条状态变化的资源记录整个模型会变得混乱审计也失去意义。第二不要把一次业务交易拆成无数个细碎事件。事件拆分的标准是是否需要独立审计、是否需要单独对账。比如支付和发货两个动作相差很长时间、由不同代理完成必须拆成两个事件但同一个动作里多个商品项的明细建议放在同一事件的payload里不必每个商品单独建事件否则光是一个订单就能生成几十条记录查询成本高且没有额外价值。第三不要忽视内部代理和外部代理的区别。买家、卖家是外部代理平台运营是内部代理。区分它们是为了权限和合规内部代理发起的调整操作在审计上要标得更明显比如运营人工退款和用户主动退款必须分属不同事件类型否则财务复核时会炸锅。边界划分的总原则是一个业务动作如果需要被追责、被核算就值得成为一个事件如果只是一个计算步骤就别拆。多问一句这件事如果说不清会不会出事答案通常会变得很清楚。4. 进阶扩展REA与领域驱动设计、事件溯源建模的结合4.1 把REA映射到DDD聚合、限界上下文、读模型如果你们项目已经在用领域驱动设计REA可以很好地和它配合。DDD里的聚合是一个事务一致性的边界而REA中的事件正好可以作为聚合根公共接口的触发来源。比如支付上下文负责发出PaymentMade事件订单上下文并不直接修改订单状态而是订阅这个事件把订单的读模型推进到已支付。这样写的好处是每个限界上下文只关心自己负责的资源增减不用跨表去改别人的状态。聚合划分方面我的建议是保持小聚合Order是一个聚合Payment是另一个聚合Inventory是第三个聚合。不要把从下单到结算的一整条链路塞进同一个聚合否则事务锁范围会随业务量膨胀最终拖垮系统。从另一个角度看REA实际上能帮DDD做建模如果一个候选实体不是资源、不是事件、也不是代理那你就要谨慎考虑它是否真的适合放进领域模型。这种三个维度筛选实体的方式能把DDD建模过程中经常出现的模糊地带大幅压缩。4.2 REA加上事件溯源只有追加的事件没有可改的状态事件溯源是REA的自然延伸存储层只保留事件不保存当前状态当前状态由投影函数重算得到。用一个TypeScript的例子来说明假设我们有一个支付事件type PaymentMade { eventId: string; buyerId: string; sellerId: string; amountInCents: number; occurredAt: string; }; type AccountState { agentId: string; balanceInCents: number; }; function projectAccounts(events: PaymentMade[]): Mapstring, AccountState { const result new Mapstring, AccountState(); for (const e of events) { const buyer result.get(e.buyerId) ?? { agentId: e.buyerId, balanceInCents: 0 }; buyer.balanceInCents - e.amountInCents; result.set(e.buyerId, buyer); const platform result.get(platform) ?? { agentId: platform, balanceInCents: 0 }; platform.balanceInCents e.amountInCents; result.set(platform, platform); } return result; }这段代码看起来简单但它背后有一个重要原则余额不是存出来的是算出来的。系统每次启动时从事件表的起点重放所有事件或者在晚上做一次快照再把快照之后的新事件增量投影一遍。这样一旦发现当前余额不对不需要改生产库只需检查事件投影逻辑重新投影即可修复。我个人的实践经验是在模拟项目里用这种方式改造后排查数据问题的时间明显缩短因为真相始终只有一个来源不存在账上余额和流水明细互相矛盾的情况。事件溯源对存储的要求就是只追加、不修改、不删除所有旧数据都是不可变的历史。这种不可变带来的好处是任何时间点上的系统状态都能被精确还原这几乎是审计需求最理想的答案。4.3 读模型要不要缓存状态表也不是完全没用这里要澄清一个容易误解的点REA并不反对你建order_current_status这种状态表它反对的是把状态表当成权威事实。实际项目里读取要比写入频繁得多如果每次下单后都要把过去一年的事件全部重放一遍才能看到订单状态那系统早就崩了。所以标准做法是把状态表降级为读模型或投影事件是权威事实状态表只是缓存。事件到达时更新投影查询时直接读投影。需要修改投影逻辑时只需要把事件重放到新的投影表旧的状态表可以停用。这个事件为源、状态为缓存的心智模型是REA落地时最值得体会的部分。刚开始你可能会不太习惯总觉得多了一步操作但当需求方要求把已完成的订单再按新规则统计一遍时你会发现只要重写投影逻辑然后重放几分钟就能输出一套全新报表而传统改表结构的方式可能要加班一晚上。5. 实操中的踩坑记录与排查速查表5.1 踩坑案例把库位和商品混成一个资源有一次我在模拟项目里给一个仓库系统做REA建模最初直接把商品当作唯一的库存资源。结果一遇到同一款商品从A库调到B库的业务就不知道该怎么记了是记商品减少还是增加无论怎么记资源总量都可能被误扣调拨单在财务看来也变得莫名其妙。后来才意识到这里真正发生变化的资源是A库位的库存和B库位的库存而不是抽象的商品本身。正确的做法是把每个库位当成独立的资源用两个事件分别记录A库位减少、B库位增加如果只是一个调拨指令则可以把减少和增加放在同一个调拨事件的两条资源流水里。这个教训让我记住一句话资源边界选错了后面所有对账都会跟着错资源边界选对了剩下只是机械的记录。反过来想这也是REA建模最有挑战的地方Resource的定义不能太粗也不能太细。太粗业务语义丢失太细表数量爆炸。我的经验是先和业务方对齐这个东西在财务上有没有独立的核算意义如果有就是一个独立资源。5.2 实践中常见问题速查表下面这张表是我在多个项目里积累的排查思路遇到类似问题时可以直接按这个顺序查。现象排查思路解决方向事件重放后净额不为0检查是否有事件重复插入或者资源增减没有成对出现写入事务增加净额校验为事件增加唯一业务键并发下单导致库存超卖投影读模型更新冲突对资源增减做乐观锁或行级锁保证同一资源串行更新查询余额越来越慢事件数量过大每次都要重放全量建立持久化投影表或定期生成快照做增量重放业务新增资源类型旧事件里没有对应资源字段通过schema_version做版本兼容新事件才带新字段同一业务动作被拆成多个事件状态反复横跳业务方与开发对事件边界理解不一致回看事件拆分原则优先保证可审计、可对账不为实现方便拆分除了表里的问题还有一个经常被忽略的事不要把REA当成银弹。对于内部工具、原型验证、一次性的数据处理脚本传统CRUD就是最快最好的方案。REA适合的是那些需要长期演进、需要审计对账、需要支持复杂查询的正式业务模块。小项目强行上事件溯源只会让简单问题复杂化。5.3 一点实操体会与建议最后分享一点最真实的感觉。我第一次尝试把系统中的几个状态字段改成REA事件时第一反应是代码变多了查询变复杂了好像没有必要。但坚持做完一个功能模块后感受完全变了和业务方对需求时不再纠结订单状态什么时候从A变到B而是讨论发生了什么事件、谁参与、资源怎么流动。业务方对这种表达方式接受度极高因为他们在现实里本来就是这么看待业务的。如果你也想尝试我建议不要一上来重构整个系统而是挑一个状态字段最少但审计要求最高的模块比如优惠券发放或资金变动先做一轮REA改造把账对平再慢慢推广。这样既不会伤筋动骨也能让人真正体会到事件优先、状态其次的建模方式到底有多稳。