REA(Resource-Event-Agent,资源-事件-代理)这个缩写,第一眼看上去不像项目名,倒像是某个会计学冷门概念。我最近把一个内部业务系统的重构项目命名为REA,并不是为了追新词,而是我们真的用这条建模路径,把一团乱麻似的状态机给理清楚了。如果你平时被订单状态、库存流水、对账逻辑折磨得够呛,或者正在设计一个需要强审计、可回放的业务模块,这篇就是我这次重构的完整复盘,从概念拆解到代码落地,再到踩坑实录,都可以直接参考。
1. 为什么选REA:从一团乱麻到三根支柱
1.1 旧模型哪里出了问题
开始动手之前,项目组被一个老订单履约模块搞得非常难受。那个模块的核心表是orders,一眼望去几十个字段,其中光status就经历了从pending、paid、shipped、signed、refunding、refunded、cancelled到closed的演变,后来还因为业务需要加了sub_status和return_status。表面上是字段,实际上是把一个完整业务过程硬塞进一行记录里。
这种设计最直接的后果就是:状态流转规则越来越复杂。比如“已支付”之后能不能转“退款中”?“已发货”能不能直接“取消”?每个组合都要写判断,每个判断后面可能还跟着一堆补偿动作。一旦漏掉一个分支,线上就会冒出那个经典问题——“订单状态卡住了”。更麻烦的是,每次排查问题都要人去猜:这条订单为什么变成这样?中间经过了哪些动作?这些动作是谁在什么时间操作的?旧表里根本没有可靠的审计信息。
这个问题不是某一行的职责没写好,而是整个建模方式站错了位置。我们用“当前状态”作为核心事实,等于把过程拍扁成了快照,过程里真正有价值的轨迹全部丢掉了。
1.2 REA的三段式到底在说什么
REA模型最早是会计信息系统领域提出的一个业务建模框架,核心是把业务世界拆成三种最基本的事物:
- Resource(资源):业务中有价值、可被计量、可被交换的对象,比如商品、现金、服务时长、积分、库存。
- Event(事件):与资源相关的活动记录,它是“发生了什么”的事实,比如一笔销售、一次付款、一次退货。
- Agent(代理):参与资源交换的人或角色,可以是客户、销售员、仓库管理员,也可以是系统本身。
一个合格的REA事件,一定存在资源的流入或流出,并且至少要关联两个代理:一个给出资源,一个接收资源。比如“收款”这个事件,它的资源是“现金”,从客户代理流入到平台代理;“发货”事件,资源是“库存商品”,从仓库代理流向客户代理。
我当时在项目里选择了这个模型作为重构地基,主要原因有三点:第一,REA天然是面向过程的,事件不可覆盖,任何业务变化都通过追加新事件表达;第二,它自带会计守恒约束,资源进出可以核对,对账逻辑变得非常直接;第三,它的概念极少,业务和技术人员之间沟通时不会出现几十个名词相互打架。
1.3 和传统ER建模、事件溯源的边界
有朋友问我:“REA和事件溯源有什么区别?是不是同一个东西?”这里要明确,事件溯源是一种技术实现模式,它把聚合的每一个状态变更都保存为事件,回放事件可以得到当前状态。REA则是一种业务建模思想,它规定你应该记录哪些事件、事件的结构是什么、资源如何流动。
你可以用事件溯源技术去实现REA模型,也可以把REA模型建立在普通关系表之上。我这次就是先用关系表落地,事件以行记录的方式保存,后续再考虑是否引入专门的事件存储。总之,REA回答的是“业务世界如何拆解”,事件溯源回答的是“状态变更如何存储”,两者可以配合,但不是一回事。
2. 资源、事件、代理:把概念磨成工具
2.1 资源:你能数的东西
资源的定义要比表面看上去更严格。它必须能被数量化、能被价值化,而且与某个具体的业务主体相关。我见过一些建模尝试把“订单”本身当作资源,这是需要谨慎的。订单更像是一份业务契约,它不是被交换的对象,而是发生在资源交换之前或之后的文档凭据。如果你把订单当成资源,模型会出现两个矛盾:订单的数量怎么算?订单被谁拥有?这会让后续所有库存和价值核对失去锚点。
真正合适的资源包括现金、商品、电子卡券、工单工时、品牌积分等。判断标准很简单:这个东西能不能“从A转移到B”?如果能,它就是资源;如果不能,它可能是一个流程节点或者一份记录。资源在系统里通常对应一张资源表,表里不记录流水,只记录当前存量。所有增减都来自事件表,资源表本身只是一个可查询的快照。
2.2 事件:唯一不可变的事实
事件是整个REA建模里最重要、也最容易出错的地方。事件必须满足两个特征:不可变和守恒。
不可变意味着一件事一旦发生,就不允许修改或删除。如果发现录入错误,只能追加一个“冲正事件”来抵消。这一点和业务审计强相关。许多业务系统出事就出在“把状态直接update了”,事后无法证明当时到底发生了什么。
守恒意味着一个事件至少要关联一个流出资源和一个流入资源,或者明确标记某个资源发生变化,并且这种变化必须对应经济意义上的交换。比如“仓库内部搬货”只是一个物流操作,它不改变资源的总量,也没有代理之间的资源交换,因此在REA严格建模里不应当作为一个经济事件。但你可以把它记录为操作日志,供流程追溯。
我实际落地时会把事件分类:经济事件(Economic Event)和业务流程事件(Operational Event)。经济事件进入事件主表,遵循守恒约束;业务流程事件比如“用户提交订单”“客服审核通过”,单独存到流程日志表,不参与资源核算。如果没有这个区分,很容易为了追求“一切皆事件”而把模型搞重。
2.3 代理:谁对这件事负责
代理承担责任认定和权限控制两个职责。一个事件如果没有代理,就无法回答“谁干的”“谁应该为这个行为负责”。代理可以是人、岗位、部门,也可以是外部单位。在系统设计里,代理通常会落到用户表或参与方表中,事件表和代理表形成多对多关系。
特别容易遗漏的是外部代理。比如售后服务中的客户,如果只在订单表里存一个customer_id,没有把客户显式建模为代理,后续做对账和审计时,你就无法干净地统计“有哪些资源从平台流向客户”。我在这轮重构里把客户、供应商、仓库、门店全部统一成participant的概念,再通过参与方类型来区分,这样所有事件都能统一关联到参与方ID,查询路径非常干净。
2.4 关系规则:所有事件都必须守恒
REA之所以能帮助减少状态混乱,关键就在守恒这条铁律。一个事件发生时,流出资源总量必须等于流入资源总量。拿销售举例:客户付款100元,同时平台付出库存商品价值100元;如果中间有折扣,则需要设计折扣资源或调整单价,但最终守恒关系不能被破坏。这种约束可以通过数据库事务或业务校验来实现。
实际做的时候,我们会在写事件的服务里放一个“校验资源流向”的逻辑。比如“退款”事件,它可能是现金从平台流向客户,但对应的商品可能没有实际回流,这时就必须引入一张退款原因和权利单据,保证模型的完整性。这些规则会强迫你把模糊业务看清楚,而不是用状态机一笔带过。
3. 落地实操:用REA重构订单履约模块
3.1 先圈定业务范围,别一口气全重写
我接手的是一个订单履约模块,范围包括下单、支付回调、接单、发货、客户签收、退款申请、退货入库、退款打款。一开始有几名同事提议“把用户账户体系也一起改了”,被我按住了。REA重构的复杂度主要在事件梳理和资源流转,如果范围铺得太大,一次改造涉及多个团队,协调成本会高到项目直接夭折。
最终我们只圈定了与商品和现金流相关的部分,其他的保持老接口不动。重构的策略也不是一次性替换,而是新老逻辑并行跑一个月,对账一致后再切流量。这种保守方式虽然慢,但安全性最有保障。
3.2 梳理业务事件流
梳理事件流的顺序很重要。我用的方法是先列动作,再判断哪些是经济事件,哪些只是流程日志。
具体步骤分四步:
- 把模块所有后端接口和状态变更点列成一张动作清单。
- 对每个动作问一句:它改变了什么资源的数量或所有权?
- 如果改变了,定义为经济事件;如果没有,定义为流程日志。
- 为每个经济事件补充代理关系,即流出方是谁、流入方是谁。
订单模块最终整理出来的经济事件如下:
| 业务动作 | 事件类型 | 流出资源 | 流入资源 | 参与代理 |
|---|---|---|---|---|
| 支付成功 | PaymentReceived | 客户现金 | 平台资金 | 客户、平台 |
| 仓库发货 | GoodsShipped | 平台库存商品 | 在途商品(可视为客户资源) | 仓库、客户 |
| 客户签收 | GoodsSigned | 在途商品 | 客户库存商品 | 客户、平台 |
| 申请退款 | RefundRequested | 无资源流动,流程日志 | 无 | 客户、客服 |
| 退款打款 | RefundIssued | 平台资金 | 客户现金 | 平台、客户 |
| 退货入库 | GoodsReturned | 客户库存商品 | 平台库存商品 | 客户、仓库 |
你会发现“提交订单”这件事没有出现在经济事件表里,因为它没有资源流动。我只是把它记录为流程日志。这个区分在后来的对账中大大简化了逻辑,因为经济事件表里的每一行,都能参与库存或资金的计算。
3.3 设计事件结构和数据表
事件表是整套模型的核心。我保留了事件主表和事件明细表分开的结构,主表存公共字段,明细表存不同事件类型的特有字段。这样写的建表语句就像这样:
CREATE TABLE economic_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_type VARCHAR(64) NOT NULL, business_id VARCHAR(64) NOT NULL UNIQUE, occurred_at TIMESTAMP NOT NULL, resource_channel VARCHAR(32) NOT NULL, remark VARCHAR(255) ); CREATE TABLE economic_event_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id BIGINT NOT NULL, resource_type VARCHAR(64) NOT NULL, resource_id BIGINT NOT NULL, from_agent_id BIGINT, to_agent_id BIGINT, quantity DECIMAL(20, 4) NOT NULL, unit_amount DECIMAL(20, 4) NOT NULL, total_amount DECIMAL(20, 4) NOT NULL, KEY idx_resource (resource_type, resource_id), KEY idx_agent (from_agent_id), KEY idx_agent_to (to_agent_id) );business_id对应业务系统里的唯一业务单号,比如支付流水号或发货单号。设置唯一约束可以防止重复写入。economic_event_item里每个事件至少两条明细:一条是流出资源的负向记录,一条是流入资源的正向记录。我们在应用层约束两条记录的total_amount加和必须为0,以此满足守恒。
资源表本身是另一张当前总量表,每当事件写入成功,就会同步更新对应资源的存量。这里的“同步”不是马上读事件回放,而是利用同一个数据库事务更新资源快照,保证读取性能。
3.4 服务层如何保证一致性
事件写入和资源更新必须在一个事务里完成。我当时遇到过一个问题:如果先写事件、再更新资源,中间出现异常,就会造成事件存在但资源没扣,或者资源扣了但事件丢失。最后采用的方式是:在同一个事务里,先插入事件明细,再执行资源快照更新,两者任何一步失败都会整体回滚。
服务层代码简化后类似这样:
@Transactional public void receivePayment(PaymentCommand cmd) { // 1. 校验支付回调是否重复处理 if (eventRepository.existsByBusinessId(cmd.paymentId())) { throw new DuplicateEventException(cmd.paymentId()); } // 2. 创建经济事件 double-entry EconomicEvent event = EconomicEvent.create(cmd.paymentId(), "PaymentReceived", now()); event.addItem(ResourceType.CASH, cmd.platformAccountId(), cmd.fromCustomerId(), cmd.platformAgentId(), BigDecimal.ONE, cmd.amount()); event.addItem(ResourceType.PLATFORM_FUND, cmd.platformAccountId(), cmd.platformAgentId(), cmd.toPlatformId(), BigDecimal.ONE, cmd.amount().negate()); // 3. 更新资源快照 resourceLedger.delta(cmd.fromCustomerId(), ResourceType.CASH, cmd.amount().negate()); resourceLedger.delta(cmd.platformAgentId(), ResourceType.PLATFORM_FUND, cmd.amount()); // 4. 保存事件 eventRepository.save(event); }这段代码里,客户现金资源减掉金额,平台资金资源增加相同金额,事件表中同时记录两条明细,等于把每一笔业务动作变成一条可审计、可对账的流水。比起直接修改订单状态,这样的做法更接近金融系统的记账逻辑。
3.5 读模型与查询如何搭建
事件表写入之后,业务查询不能每次都去扫描事件明细,那样性能会很差。我的做法是建立若干投影表,比如order_summary、account_balance、inventory_snapshot。这些表由事件处理消费者异步更新,或者在同一事务内同步更新,取决于业务对实时性的要求。
订单列表查询时,不再依赖旧表里的status魔术字段,而是根据最新事件的类型和顺序推导出订单状态。例如:如果存在PaymentReceived且没有RefundIssued,则订单可显示为“已支付”;如果存在GoodsShipped且没有GoodsSigned,则显示为“待签收”。这种推导逻辑集中在一个OrderStateProjection类里,方便维护。
这种方式带来的额外收益是,任何状态的异常都变得“有据可查”。以前排查“这笔订单到底发生了什么”,只能去看几个字段;现在直接按订单号查事件表,所有轨迹一目了然。
4. 常见问题与排查技巧实录
4.1 事件粒度:把“保存草稿”也算事件?
我们最早设计时,有同事提议把“用户点击保存”也算事件。我明确否定了。事件粒度过粗会丢失业务语义,粒度过细则会把系统淹没在海量噪音里。事件粒度应该以“业务上不可再分、且能引起资源变动的动作”为准。
“保存草稿”不改变任何资源,只是在构造一个未来可能使用的数据,本质上属于CRUD操作。如果把它定义为事件,后续回放事件时会有大量无意义记录,对账和查询都会变得异常缓慢。正确做法是区分“业务草稿”和“正式业务动作”。草稿可以存在普通业务表里,只有正式提交并触发了资源变动,才写入事件表。
4.2 资源与事件混淆:订单到底属于谁
很多团队学REA时会遇到一个坎:订单到底是不是资源?从业务直觉看,订单有价值;但从模型看,订单只是“销售过程”的记录载体。真正的资源是商品、现金、库存余额,不是订单本身。
如果把订单当作资源,事件就会变成“订单从无到有”,这让资源数量无法会计核查。我在某次评审中看到过类似设计,最后发现问题出在“订单表同时被当作事件表和资源表使用”,导致很多统计互相矛盾。正确做法是:订单表只保存业务流程属性,比如下单时间、备注、发票信息;真正的经济资源流,全部通过事件表里的商品行和资金行表达。
4.3 代理缺失:外部参与者漏掉之后
重跑数据时我们发现,早期有一批退款事件没有记录from_agent_id和to_agent_id,导致无法按参与方维度做资金核对。排查原因,是旧系统里“申请人”字段允许为空,历史数据没做迁移。后来我强制规定:所有经济事件必须关联到至少两个代理,外部代理如果还没有在参与方表中注册,也要先创建出来。
这一步不只是为了审计合规,更是在做权限控制时非常重要。例如“仓库管理员发货”事件,如果只记录了一个代理而没有记录对应的仓库归属,后续要按仓库维度核算库存,就只能通过商品SKU去反推,不仅慢,还容易错。
4.4 跨服务事务:Outbox模式的引入
订单模块涉及支付服务和库存服务,跨两个数据库。一开始我想用本地事务直接调用两个服务的接口,但在线上出现了一次支付回调处理成功、库存扣减未生效的问题,导致资金和实物对不上。后来引入了Outbox模式:事件先写入本地事务表,再通过异步任务发布到消息队列,下游库存服务消费消息后扣减库存并回写处理结果。
Outbox有一个关键点:消息消费端必须做幂等。支付回调可能重复投递,库存扣减也允许重试,所以我在库存服务里以businessId做唯一约束,重复消费直接跳过。这个改造之后,跨服务的不一致问题从每周出现几次骤降到几乎没有。
4.5 事件字段怎么演进
事件表一旦写入,就不允许修改字段值,但事件结构本身需要随业务演进。比如前半年退货事件只有退款金额,后来新增了“退货运费承担方”字段。我们的做法是:
- 事件表增加一个
version字段,老事件版本为1,新事件版本为2。 - 新增字段允许为空,或者有默认值,保证老事件仍然可以被序列化。
- 编写事件映射器时,对不同版本分别做处理,而不是直接修改老事件的反序列化逻辑。
这有点类似API版本兼容的思路。如果你在项目里用事件溯源存储,还会遇到事件类重命名、事件属性类型变化等问题。我的经验是永远不要删除已发布的事件字段,哪怕它暂时没用了。你想象一下,几年后审计人员拿着一个业务流水来问“当时的运费是谁承担的”,如果字段已经清空,这个答案就永远找不回来了。
5. 实操心得:REA适合什么,不适合什么
5.1 适合强审计、多参与方、状态纠缠的业务
经过这次折腾,我最大的判断依据是:如果一个业务模块经常被“状态对不上”或者“无法解释过去发生了什么”这类问题困扰,REA会是非常合适的底牌。它不会让业务代码变少,但会把复杂关系变得可推导、可验证、可审计。
典型的适用场景包括订单履约、资金流水、库存流转、会员积分、结算账单。这些领域天然有资源流动,参与者清晰,事后追溯要求高。REA在这些场景里就像给业务装了一个“黑匣子”,无论过程多曲折,都能从事件流里还原真相。
5.2 不适合简单CRUD、暂时性草稿、性能极度敏感的路径
REA不是银弹。如果你做的只是一个简单内容管理页面,用户改一个标题,保存一个草稿,完全没有资源的双向流动,硬套REA只会增加复杂度。再比如高性能写入路径,每个请求都涉及多表校验和资源守恒检查,延迟和吞吐会有一些代价,需要权衡。
我见过最糟糕的套用是为了“纯洁”把日志型数据也搞成双Entry,最后数据量膨胀了几倍,查询性能直线下降。所以落地REA之前,先想清楚这个模块是否真的需要“可审计的资源交换过程”。不需要,就老老实实CRUD。
5.3 落地过程中的三个实用建议
第一,不要在代码里硬编码代理类型。把客户、仓库、供应商统一放到参与方模型里,后面加角色时就不需要改事件表结构。第二,事件表和资源快照表必须在同一个事务边界内维护,否则你一定会遇到“事件写了,资源没动”的灵异问题。第三,尽早建立事件查询工具。哪怕只是一个按订单ID查事件时间线的内部页面,都会让排障效率提升一大截。
我这次做到的只是一小步,没有盲从任何框架和理论,只是把REA当作思考工具来用。市场上没有哪个框架能直接解决业务语义的混乱,真正值钱的是你知道该记录什么、为什么记录、如何把它变成代码。REA给了我一套语法,而我用它写出了这个模块自己的故事。