做后端和架构的同学,应该都对这种场景不陌生。业务方丢过来一句话:“我们要上一个积分系统,买东西得积分,积分能抵现。”你第一反应是建表:用户表、订单表、积分表、流水表。等上线三个月,财务说要对账,你发现积分流水里只有加减,没有“这笔积分来自哪笔订单、对应哪次消费、被谁消耗”,问题一下子全出来了。今天聊的REA模型,就是专门用来解决这类建模问题的。REA全称是Resource-Event-Agent,资源-事件-代理,是一套面向经营活动的领域建模框架,不绑定任何框架和语言。这篇文章我会用一个积分结算系统的完整建模过程,讲清楚REA是什么、怎么建模、怎么落到存储和查询,最后把实际踩过的坑一次说清。适合正在做DDD落地、事件溯源,以及账户、库存、积分类系统的开发者参考。
1. REA模型认知:为什么业务建模绕不开它
1.1 三个要素和一个中心关系
REA模型的三个核心词汇,展开来说并不复杂。
Resource(资源),凡是“有价值、可以衡量、能在业务中流动”的东西,都可以归入这个范畴。现金、库存商品、积分、优惠券、产能、维修工单中的工时,都是资源。判断标准很直接:这个东西在业务里会不会被增加、减少、转移?如果会,它就是Resource,而不是什么普通实体或字典表。很多同学建模时把积分当成“用户的属性”,直接给用户表加一个point字段,这恰恰是问题的起点。积分本质上是一种可流转、可消耗、有归属的资产,应该被当成Resource来建模。
Event(事件),是发生在资源上的经济活动。用户支付、商品出库、积分发放、优惠券核销,都属于Event。它记录的是“发生了什么”,而不是“现在是什么状态”。平时做CRUD的时候,我们习惯把账号状态从待支付改成已支付,这是状态的覆盖;而在REA里,我们记录的是“谁在什么时间做了什么动作”,这个动作不会因为业务状态变化而被抹掉。
Agent(代理),是参与经济活动的人、系统角色或组织。用户、供应商、平台、渠道商,甚至一个负责积分过期任务的定时程序,都可以是Agent。在REA模型里,Agent不是简单的标签,而是资源流向的归属方。每一笔资源变动,都必须能回答“这个资源从哪个Agent流出,流入哪个Agent”。
三者的关系是:一个Event至少连接两个Agent,同时连接流入和流出两类Resource。为什么必须有双方?因为任何一笔经济活动要成立,必然存在一方资源减少、另一方资源增加。如果模型中只看到一个Agent,那说明你记录的很可能不是事实,而是某个操作的结果快照。
1.2 用一杯咖啡看懂REA
用一个最生活化的例子来拆解,比如“你花20元在咖啡店买一杯咖啡”。
传统建模会怎么写?订单表记录商品、金额、订单状态,支付表记录支付结果,库存表扣减库存。看起来逻辑完整,但对账时就会遇到一个经典麻烦:订单显示已支付,财务的银行流水里却没有这笔钱,或者支付成功但订单状态没被更新,你说不清是哪一步发生的、责任在哪一方。
用REA模型,这次交易要被拆成两个独立的经济Event。
第一个Event是顾客付款。此时支付钱包这类Resource从顾客名下流出20元,流入咖啡店的收款账户;涉及到的Agent是顾客和咖啡店。第二个Event是咖啡交付。咖啡豆或已制作好的咖啡成品,从咖啡店的库存Resource中流出1杯,转入顾客名下;涉及到的Agent同样是顾客和咖啡店。
这里一定会有人问:一笔交易为什么要拆成两个Event?因为付款和交付本来就是两个独立事实,它们可能不是同一时刻发生。顾客观看菜单后可能先付款再取货,也可能先取走咖啡后补付钱,更可能付款后商家倒闭跑路,根本拿不到咖啡。如果把两者揉在一个订单Event里,后续处理退款、掉单对账、库存损耗时就会非常痛苦。拆开之后,“顾客付了钱但没拿到咖啡”可以定位到第一个Event已完成、第二个Event未发生;“拿到咖啡但没扣库存”也可以从Event里直接查出来。
这背后正是复式记账的思路:每一笔资源的流入,必然对应另一笔资源的流出,两边都要有据可查。REA把这种会计思想搬到了软件建模层面,很多看似复杂的业务,按这个逻辑梳理后,核心关系会非常干净。
等等,有人会问:“我开发的是一个管理后台,不是财务软件,有必要这么较真吗?”确实不是所有系统都必要,但凡是涉及资金、库存、积分、兑换码这类“资产型”数据的模块,如果没有这一层事实记录,将来对账、审计、排查数据异常时,代价会成倍增加。我见过太多系统因为早期省了这一步,后期专门开发“审计模块”“对账系统”,结果还是对不齐,最后只能硬编码各种补丁。
1.3 传统建模与REA建模的对照
把传统CRUD式的建模和REA建模放在一起对比,区别会更直观。
| 对比维度 | 传统建模 | REA建模 |
|---|---|---|
| 核心数据 | 状态字段,比如订单状态 | 不可变的经济事实流 |
| 审计能力 | 依赖操作日志,经常缺失环节 | 事件天然全链路,可回溯 |
| 当前余额/库存 | 直接查字段值 | 通过投影计算,可从事件推导 |
| 业务扩展 | 需要改表结构、加字段 | 新增事件类型,旧事件不动 |
| 幂等保障 | 依赖状态校验和锁 | 依赖事件的业务唯一性 |
| 财务对账 | 另行开发对账逻辑 | 事件本身就是账本 |
传统表结构像给系统拍了一张又一张快照,我们能看到的永远是当前状态;REA更像一段完整录像,任何时间点的业务都可以被还原。数据库字段里那个“订单状态=已完成”,更像一张抓拍照片;而REA事件流里的“下单、支付、出仓、签收”是连续的事实序列。对需要追溯责任的业务来说,录像比照片可靠得多。
还不止如此。传统建模中,当业务需求增加“积分可以跨订单合并使用”的时候,你要么给积分表加一大堆关联字段,要么再写一套合并逻辑。但在REA里,积分合并本质上是一笔资源重组Event:把两个积分账户的资源结余转移到一个新账户,事件不改变旧积分账户的事实,只是做一个新事件。这种扩展方式对新老逻辑都比较温和。
1.4 REA与DDD、事件溯源的真实关系
DDD领域驱动设计这么多年都很火,但很多团队落地时卡在同一个问题上:通用语言怎么定义?聚合根怎么划分?领域事件到底应该记录哪些内容?REA恰好给了一套可落地的词法规范。
DDD的“领域事件”,在REA里对应的是真正的经济Event,而不是系统的每一步操作。比如“用户点击了积分明细页”这种操作不算Event,因为它不涉及任何Resource的流入流出;“用户用100积分兑换了一张5元优惠券”才是Event,因为它涉及两个Agent(用户和平台)、两类Resource(积分和优惠券)。如果你的团队在做事件风暴时总是吵不清楚哪些是领域事件,引入REA的“双Agent、双Resource”校验,一下子就能过滤掉大量噪音。
事件溯源(Event Sourcing)落地时,最常见的问题也是事件库乱。有些团队把“状态变更”都当成事件存进去,结果事件流里鱼龙混杂,重放时根本还原不出业务。REA的约束正好解决了这个问题:事件必须关联资源、必须关联双向代理、必须描述经济活动。不符合这三条的,就不应该进事件库。
所以REA不是DDD的替代品,也不是事件溯源框架的前置依赖,而是它们之间的粘合剂。我个人的习惯是:先用REA梳理领域词表,再做DDD的聚合设计,最后用事件溯源来落地。顺序对了,后面会顺很多。
2. 实操建模:用REA给积分结算系统搭骨架
2.1 从一个不过分的模拟项目开始
为了把REA落地过程讲得具体,我假设自己正在为某社区团购平台设计一套结算模块,业务规则一共有四条:用户可以用微信充值获得平台余额;用户在商城下单时用余额支付,同时按支付金额的一定比例获得积分;用户可以用积分兑换优惠券;优惠券在下一次购买时核销。需要注意的是,充值渠道和商品提供方是外部角色,平台本身也需要作为Agent进入模型。
拿到这个需求,第一步不是写代码,也不是画架构图,而是列事件清单。我把所有可能发生的经济活动写下来:用户发起充值、充值成功到账、用户下单、支付成功、订单完成出库、积分发放、积分兑换优惠券、优惠券核销。事件清单不需要一次就完美,后续随业务梳理不断补充,但第一版必须包含业务方最关心的几件事,尤其是钱和商品的流转。
第二步,对每个事件确认它涉及的Resource和Agent。这里直接给出我梳理后的关系表,这个表本质上会成为后面领域模型和数据库设计的基础:
| 事件 | 流入Resource | 流出Resource | 涉及Agent |
|---|---|---|---|
| 充值成功 | 余额(用户) | 外部资金(平台) | 用户、平台 |
| 支付成功(下单) | 商品货权(用户) | 余额(用户) | 用户、平台 |
| 订单完成出库 | 商品库存(平台) | 商品在库(平台) | 平台、供应商 |
| 积分发放 | 积分(用户) | 积分池(平台) | 用户、平台 |
| 积分兑换优惠券 | 优惠券(用户) | 积分(用户) | 用户、平台 |
| 优惠券核销 | 优惠券核销记录(平台) | 优惠券(用户) | 用户、平台 |
注意,这里“商品货权(用户)”与“商品在库(平台)”是两个不同的Resource。REA模型鼓励把货权和库存分开看待,因为用户下单后,货权虽然已经转移给用户,但商品仍然在平台仓管里,直到出库才减少库存。很多库存对不上的问题,正是因为把货权转移和库存减少混为一谈。这个点,在实际做电商系统时非常重要。
第三步,确定聚合边界。我的方案是:把“经济事件”作为独立的不可变聚合根;每个Agent的每个Resource类型对应一个资源账户聚合,比如UserAccount聚合里包含用户的余额账户和积分账户;平台的资产品类账户单独再建一个聚合。事件聚合负责产生事实,账户聚合负责校验和投影,两者职责分离,不混在一起。
2.2 领域模型代码骨架:事件与账户分离
按照上面的设计,核心代码可以拆成三层。Resource层比较简单,它包含一个ResourceType和一个数量字段。Event层承担事实记录,必须不可变。Agent层负责资源账户的承载。
我用接近Java的伪代码把它写出来:
public class ResourceAccount { private AgentId agentId; private ResourceType resourceType; private long amount; // 以最小单位存储,比如“分” private int version; public void apply(EconomicEvent event) { // 先校验,后变更 } } public class EconomicEvent { private EventId eventId; private EventType eventType; private Instant occurredAt; private List<Entry> entries; public static class Entry { private AgentId agentId; private ResourceType resourceType; private long amount; // 流入记正数,流出记负数 private EntryDirection direction; } } public class Agent { private AgentId agentId; private AgentType agentType; private String name; }这里最核心的是EconomicEvent中的Entry列表。一个事件至少要有两条Entry,一条表示某Agent的某个Resource增加,另一条表示另一个Agent的某个Resource减少。两条Entry之间满足金额守恒。比如充值成功事件,一条Entry是用户余额+10000分,另一条Entry是平台外部资金-10000分。两条金额加起来为0,这个事件的完整性就初步得到保证。
金额守恒看起来像会计学概念,但它对系统质量的作用非常直接。在传统模型里,一个不经意的小Bug可能导致用户余额凭空多出1分钱;而在REA模型里,事件本身自检就能发现不平衡,测试用例也更容易写。我经常说,REA等于把会计的“借贷平衡”内置到了业务系统的核心数据结构里。
当然,代码只是骨架,真正能不能落地,还取决于底层存储和并发控制,这部分放到第3章细说。
2.3 为什么资源单位必须是最小单位
这个坑我踩过,必须单独拿出来讲。早期做一个优惠券计算功能时,我用浮点数表示折扣,比如0.88折,结果计算过程中出现了0.30000000000004这类连自己都看不懂的数字,后面所有对账全部错位。这不是REA特有的问题,而是所有涉及金额、积分、数量类业务都容易犯的错。
在REA建模中,所有Resource的数量都必须选择整数类型,以最小不可分割单位存储。金额以“分”存储,积分以1个为单位,库存以“件”为单位。打折、汇率换算这些计算,放到入账之前处理,一旦进入Resource账户,全部是整数。
这个设计同时解决了两个问题:第一,浮点数误差从源头消失;第二,审计时看到的每一笔事件,都是完整的业务事实,不会出现三条小数记账记录加起来不等于一条原始记录的情况。
另外,Resource的流出方向,我建议用direction字段而不是简单的正负号。正负号只有两个方向,但业务里还有“冻结”“解冻”“冲正”等状态,只用正负号表达很容易把数量与状态混淆。库存不足时,如果系统错误地产生一个负库存,光看数据根本判断不出异常;但如果direction明确标记为“冻结失败”,异常就能被精准定位。
2.4 事件不可变与冲正机制
很多同学第一次接触REA时会问:如果事件写错了怎么办?用户的余额被我用一个错误事件扣掉了,难道不能改掉那条事件吗?
答案是:原始事件永远不能改,只能新增一个“冲正事件”来纠正。比如充值事件本来是100元,结果系统录入成了1000元,你不能去修改那条充值事件,而是要生成一条“充值冲正”事件,把多出的900元从用户余额里扣回来,并关联到被冲正的原事件ID。
这个规则看上去死板,却保住了整个系统的底线。一旦事件可修改,之前所有基于事件的审计、重放、对账逻辑都会失去信任基础。金融系统里为什么要求“记账凭证不可涂改”?因为凭证一旦可改,账本就没有意义了。REA在这个层面就是数字化时代的记账凭证。
事件不可变的另一个好处是,可以在多系统之间可靠地同步数据。下游系统只需要按事件ID增量拉取,不需要关心上游是否修改过数据;即使下游处理失败重新消费,幂等性也可以通过事件唯一ID保证。这是事件驱动架构中一个很实用的基础设计。
3. 从模型到落地:事件存储、快照与查询投影
3.1 事件存储表结构怎么设计
模型最终要落到存储。在数据量不大时,完全可以用普通关系型数据库来实现事件存储,而不必一上来就引入专门的事件溯源中间件。我用PostgreSQL风格的表结构来说明,但换成MySQL也成立。
核心就两张表:一张保存事件流元数据,一张保存每个具体事件的数据。
CREATE TABLE event_streams ( id BIGSERIAL PRIMARY KEY, aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, version INT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (aggregate_type, aggregate_id, version) ); CREATE TABLE events ( id BIGSERIAL PRIMARY KEY, stream_id BIGINT NOT NULL REFERENCES event_streams(id), version INT NOT NULL, event_type VARCHAR(128) NOT NULL, event_id VARCHAR(64) NOT NULL UNIQUE, payload JSONB NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() );这里的event_streams表代表一个聚合的版本序列。version字段是递增的整数,每次追加新事件时,必须指定基于哪个version写入。如果两个并发请求都用version=3去追加下一个事件,数据库的唯一约束就会让其中一个失败,冲突在源头被拦下来。
events表的event_id是这个事件的业务唯一ID,不是自增主键。比如支付成功事件的事件ID就是支付流水号,充值冲正事件的事件ID就是冲正单号。加上唯一约束后,即使消费者重复投递消息,数据库也会把重复事件挡在外面,这是幂等性的最后一道保险。
我见过不少团队把事件和业务数据混在一起,事件表里既存原始事实,又存当时算出来的余额,结果重放时数据结构混乱。记得原则:Event只存事实数据,比如数量、单价、关联订单号;所有可推导的余额、库存、状态全部放投影,不要放事件。
3.2 快照策略与应用恢复
事件存储解决了事实保存,但性能上不能无限依赖“从头重放所有事件”。假设一个用户账户有10万条积分流水,每次加载账户状态都要重放10万次,系统必然扛不住。这时候需要快照。
快照就是聚合在某个版本时刻的完整存档。恢复时,只从最近的快照开始重放后续事件,而不是从零开始。比如用户账户已经发生10万条事件,系统每100条生成一个快照,那么恢复时最多重放100条,性能得到了数量级提升。
快照的生成策略要兼顾实时性和成本。我常用的方案是:在追加事件的事务里判断当前版本号是否达到“上次快照版本+100”,如果达到了,就在同一事务里把聚合状态序列化后写入快照表。快照也可以配合定时任务做,但要注意版本对齐,避免快照版本与事件版本不一致导致重放重复或缺失。
CREATE TABLE snapshots ( stream_id BIGINT NOT NULL REFERENCES event_streams(id), version INT NOT NULL, state JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (stream_id, version) );读取聚合状态时,先取版本号最大的快照,再加载该版本之后的所有事件,顺序重放得到最新状态。快照作为优化手段,不能取代事件本身;如果快照损坏,最多重建一个空聚合重放全量事件,不影响数据正确性。
3.3 查询投影:别拿事件库当查询库
事件库和普通业务库的查询特性完全不一样。事件表的写入是追加式,没有任何update,拿它来做“查询用户当前余额”这类高频业务请求,非常不适合。正确做法是建立查询投影,用一份独立的读模型来服务查询。
还是说积分系统的例子。我可以建一张账户余额读模型表,专门用来支撑业务查询:
CREATE TABLE account_balance_read_model ( user_id VARCHAR(64) PRIMARY KEY, balance_amount BIGINT NOT NULL, total_points BIGINT NOT NULL, coupon_count INT NOT NULL, updated_at TIMESTAMPTZ NOT NULL );这张表的数据不直接写入业务逻辑,而是由事件订阅者异步更新。充值成功、支付成功、积分兑换等事件发布后,订阅者收到消息,重新计算该用户的最新余额和积分,然后更新表中的记录。查询接口只读这张表,响应速度很快。
这个写模型与读模型分离的思路,本质上就是CQRS。REA和CQRS天然合拍:REA负责把写侧变成一串不可变的事实,CQRS负责把读侧变成面向业务场景的投影。在写侧,我们强调一致性、完整性和审计能力;在读侧,我们强调查询性能和响应速度。两者互不拖累。
需要注意的是,投影更新存在“最终一致”窗口:事件写入后到读模型更新完成前,用户可能看到旧数据。对大多数积分、余额业务来说这个时间差可以接受;如果业务要求强一致,可以在同一个事务里同时更新事件表和投影表,代价是写入延迟升高。我刚落地REA时,只顾着保证事件一致性,忘了设计读模型,结果上线后接口响应慢得没法看,后来补了投影表才解决问题。
3.4 幂等、并发控制与重试
事件驱动系统里的幂等性,远比单体CRUD里需要考虑得多。同一个支付成功事件,可能因为消息中间件重投、下游服务重启等原因被处理两次。如果没有幂等保护,用户的300元可能被入账两次,积分翻倍,优惠券重复发放。
幂等的第一道防线,在事件唯一键上。我用支付回调里的平台流水号作为eventId,数据库对eventId加唯一索引,重复插入必然失败。第二道防线,在账户聚合的版本号上。每个账户记录当前version,更新时必须携带期望version,如果version对不上,说明有其他事件已经先入账,当前请求需要重新加载最新状态再合并计算。
这里给出一个典型的写入流程伪代码:
public void processPayment(PaymentReceivedEvent event) { for (int retry = 0; retry < 3; retry++) { AgentAccount account = accountRepository.load(userId); if (eventStore.exists(event.getEventId())) { return; // 已经处理过,直接忽略 } try { account.apply(event); accountRepository.save(account, account.getVersion()); eventStore.append(event); return; } catch (VersionConflictException ex) { // 说明有并发事件先写入,重新加载重试 } } throw new RecoverableException("concurrent payment processing"); }这个写法里,事件唯一键保证了重复消息不会入账,版本冲突保证了并发更新不会互相覆盖。我实际测试过,即使两个线程同时收到同一事件,最终也只有一个线程能成功写入,另一个会在下一轮重试时发现事件已存在并退出。这套机制简单可靠,比分布式锁轻量很多。
4. 复盘避坑:REA落地时最常踩的6个坑
4.1 坑一:把派生数据混进事件里
这是REA建模中最典型的错误,几乎每个初学的人都会犯。有人设计“用户支付”事件时,除了记录支付金额、订单号,还会加一个字段叫“支付后余额”,目的是查询方便。结果事件溯源重放到最后一笔时,发现余额和真实余额差了10元。为什么?因为早期的某条事件里的“支付后余额”算错了,而这个错误在之后的N条事件里被当成基础数据继续叠加。
正确做法是事件只记录业务事实:支付了多少钱、来自哪个订单、发生于何时。用户支付后的余额是多少,由投影计算得出,不能写进事件。把这个原则记住,你的事件流就能永远保持纯净和可重放。
4.2 坑二:漏掉系统自动任务的Agent身份
有一类特殊Agent特别容易被遗漏——系统本身。比如平台规定“90天未使用的积分自动过期”,这个定时任务每天扫描积分账户并执行过期操作。很多建模者觉得它不是人,就不给它Agent身份。结果积分过期后,业务审计想查“谁的积分被清了、为什么被清”时,事件流里只有一个资源减少记录,没有任何Agent承担责任。
正确做法是把“积分过期服务”或者“系统管理Agent”纳入模型,给它分配一个AgentId。事件里的双向Agent就变成了“用户”和“系统管理Agent”。这样审计时才能回答:这笔积分是用户主动兑换掉,还是被规则自动清理的,规则归属哪个系统服务。
4.3 坑三:滥用浮点数处理金额和积分
这个坑前面已经提过,但值得放进避坑清单里再次强调。任何Resource的入账和出账,都使用以最小单位为基准的整数。打折、汇率、税率等计算全部放在事件入账之前,计算结果必须四舍五入到最小单位后再进入事件。如果某天计算结果出现0.1个积分,业务上要么向下取整,要么单独生成一个“零头积分”的Resource,绝不能让一个小数出现在账户流水里。
4.4 坑四:事件表膨胀,查询性能失控
事件存储的量级和普通业务表完全不一样。一个用户一年可能产生上千条积分流水,一个平台一年就是上亿条。如果不做任何优化,任何一次加载聚合状态都要扫描几十万条数据,系统会卡到没法用。
解决方案有分层:第一层是快照,把上千条事件压缩到最多回放100条;第二层是归档,把超过3个月的旧事件归档到冷存储,业务查询走投影表,只在审计、对账、纠纷处理时查冷数据;第三层,投影表本身也要定期清理和压缩,避免重复投影数据越来越多。记住一条:事件库是“真相”,投影模型才是“读接口”;不要让用户请求直接亲吻事件库。
4.5 坑五:团队不认可新模型,方案被喷成过度设计
REA落地最大的阻力往往不是技术,而是团队协作。业务方会觉得你绕了一大圈,明明一张订单表就能解决的问题,你非要拆成几个事件和账户;开发同事会觉得这套模型复杂,要学新的概念,还要写一套以前没写过的代码。
我的建议是不要试图一次性全面铺开。先挑一个最痛的点,比如财务月结算对不上账、积分明细查不清,用REA做一个影子模块,和旧系统并行跑一段时间,用报表比对新旧模型的结果。当数据证明旧模型有缺失、新模型能精准还原后,团队自然会接受。技术方案存在的最终目的是解决问题,不是证明谁更高级。如果想靠“这个模型很经典很权威”来说服团队,阻力只会更大。
4.6 坑六:把REA当成万能银弹
最后一条坑,是反过来走向极端:什么系统都往REA上套。一个简单的用户配置管理系统,只有查询和修改,既不涉及资源流转,也不需要审计对账,硬套REA只会增加无意义的复杂度。判断是否该用REA的标准很简单:业务里是否存在多方资源流转、是否存在审计追溯诉求、当前状态是否依赖历史事实推导?如果三个都否,普通CRUD可能就够用了。
我自己的经验是,REA最适合的场景是“用户持有资产型数据”的业务:钱包、积分、优惠券、库存、配额。这类业务中的数据不仅是一个字段,更是一种有生命周期、有归属方、可流转的资产。判断清楚再上,模型会给你带来巨大回报;判断不清楚硬上,就会变成过度设计。
最后把六个坑整理成速查表,方便排查时对照:
| 坑位 | 问题表现 | 解决思路 |
|---|---|---|
| 派生入事件 | 重放结果与真实余额不一致 | 事件只记录原始事实 |
| 漏掉系统Agent | 自动过期等操作无责任方 | 补建系统Agent |
| 浮点数量 | 计算误差导致对账失败 | 全部使用整数最小单位 |
| 查询撞事件库 | 响应越来越慢 | 用快照+投影+归档 |
| 团队阻力 | 方案被质疑 | 影子运行,用数据说话 |
| 过度设计 | 简单CRUD也套REA | 先评估业务是否匹配 |
写在最后一个实用体会。我最早接触REA时,也觉得一个订单要拆成好几个事件,实在太绕。直到后来一次对账,财务要我说清楚“某用户1月5日20点03分用余额买了一箱牛奶,之后余额去哪了、积分发了多少、是否存在资金漏洞”,传统模型里的订单状态和流水表根本理不清,是REA帮助我从事件流里逐条还原,把资金链路完整讲了出来。从那以后,凡是涉及钱、库存、积分这类模块,我都会先问自己一句:我建的是状态,还是事实?如果只能给一条建议,那就是在建模第一周,先用Excel把所有事件、资源流入流出、Agent列成一张大表,把关系彻底捋顺了再写代码。这个笨办法,比画100张架构图都有效。