看到"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,另一条是平台担保资金资源delta=+100;event_agent插两条记录,一个是agent=买家A、role=buyer,另一个是agent=平台、role=guarantor。所有记录加起来,资源流的净额等于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[]): Map<string, AccountState> { const result = new Map<string, 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改造,把账对平,再慢慢推广。这样既不会伤筋动骨,也能让人真正体会到"事件优先、状态其次"的建模方式到底有多稳。
看到"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,另一条是平台担保资金资源delta=+100;event_agent插两条记录,一个是agent=买家A、role=buyer,另一个是agent=平台、role=guarantor。所有记录加起来,资源流的净额等于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[]): Map<string, AccountState> { const result = new Map<string, 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改造,把账对平,再慢慢推广。这样既不会伤筋动骨,也能让人真正体会到"事件优先、状态其次"的建模方式到底有多稳。