1. “rea”项目是怎么来的:被余额表折腾之后的反思
我给这个重构项目起的代号是 rea,全称是 Resource-Event-Agent。起因很俗:我在维护一个订单和库存系统,每天晚上对账都像拆炸弹——库存表的数字、流水表里的记录、财务那边的应收余额,三套账永远差着几块钱或几件货。追根溯源的时候,每个人都只看到一张张被 UPDATE 过无数次的“最新状态表”,没人说得清这些数字到底是被哪些动作、在哪个时间点、被哪个角色改出来的。
传统 CRUD 的做法是把余额当成“真相”。库存表里剩 10 件,就是 10 件;订单金额累计多少,就是多少。但余额是一个计算结果,不是一个事实。你把余额当事实存下来,就等于把历史过程全部抹掉,只留下一个快照。快照一旦在并发请求下被覆盖,或者更新顺序出了偏差,整个系统的可信度就开始崩塌。我在旧项目里遇到的三类问题,基本都可以归到这个根因上:
- 并发扣减导致库存变负:两个请求同时读到库存剩 5 件,各自扣 3 件,不做行锁和事务隔离的话,最终可能变成 -1。加了锁又引来一堆死锁和性能问题。
- 对账靠“翻流水”:流水表确实有记录,但流水只存了“影响了哪个余额字段”,没有完整表达这笔业务的来龙去脉。比如退款单触发库存回补和金额回冲,两笔流水之间没有强关联,一到月底就要写一堆临时脚本去匹配。
- 状态字段越来越多:订单表上挂着订单状态、支付状态、发货状态、退款状态,十几个布尔字段互相依赖,任何一次改动都要理一遍组合合法性,十分痛苦。
某次技术交流中有人提到“REA”这个建模框架,说它是会计领域用来描述经济资源流转的。一开始我没当回事,后来静下心去了解才发现,它的核心思想恰恰是我缺少的:别急着记录“还剩多少”,先记录“发生了什么”。所谓 REA,就是让每个业务动作都表达为三个要素的组合——资源(Resource)、事件(Event)、代理(Agent)。资源是业务关心的东西,事件是资源的变化,代理是参与变化的人或系统。任何业务交换,都是两个或两个以上经济事件的配对,比如“客户付款”和“商家发货”是一对,付款让现金流入,发货让库存流出。
以这种视角重新看订单系统,很多原来靠锁和补偿逻辑硬撑的地方,突然就有了一个统一的表达容器。我决定不再把余额表当作设计起点,而是把所有核心业务动作都建模为“事件流”,余额只作为事件流的投影存在。这个重构项目,就是我后来叫它 rea 的那套订单库存服务。它不是某个开源框架的名字,是我自己写的实践代码,核心只有几百行,却把我从天天处理脏数据的循环里救了出来。
1.1 事件溯源和 REA 是同一枚硬币的两面
在接触 REA 之前,我已经知道事件溯源(Event Sourcing)的优势:不修改历史,只追加新事件,当前状态可以从事件流重新计算。但事件溯源本身有个很大的问题——它只管“以事件作为事实来源”,却没说清楚什么样的事件才算完整。很多团队做事件溯源,最后把事件表做成了另一张流水表,依然存不出业务的全貌,本质上还是 CRUD 思维。
REA 恰好补齐了这个空缺:它不仅规定“事件是唯一可修改的事实”,还告诉你每个事件应该关联哪些资源、由哪些代理触发、和哪些其他事件配对。举个例子,用普通的“订单已支付”事件,你可能只记录“支付金额 100 元,关联订单号 123”。但用 REA 来表达同一个动作,它至少应该是:
事件:支付确认 资源流入:现金(+100 元) 资源流出:客户应付义务(-100 元) 代理:客户、支付渠道 关联事件:订单创建、发货执行多出来的这些信息不是摆设,它们构成了完整的因果链。以后任何人问“这 100 元去了哪里”,不需要翻五六张表,直接沿着事件关联关系就能走完整条链路。从开发者的角度看,REA 就是事件溯源的一种领域建模规范。两者结合,既能享受追加式存储的审计优势,又能避免事件越存越乱。
1.2 从“改状态”变成“记录动作”的分水岭
老代码里一个典型的支付回调处理函数,大概是这样的:找到订单,判断当前状态是否允许支付,更新订单状态为“已支付”,更新账户余额,然后返回。整个过程依赖数据库事务保证一致性,看起来没毛病,但每次新增一种业务动作,比如“部分退款”“改收货地址”“赠品补发”,都要去改同一个订单状态机,状态组合以指数级膨胀,改到后面没人敢动。
切到 REA 视角后,我给自己立了一条硬规矩:任何状态字段都不允许被业务代码直接修改,只能通过追加事件来间接改变。一个状态字段背后必须有且仅有一组事件流可以推导出它。例如订单状态“已支付”,它的含义不是一张表里的一个字段,而是“支付确认”事件存在于该订单的事件流中。想判断订单能不能发货,就去查有没有对应的支付确认事件,而不是查一个布尔字段。
这条规矩带来的最大改变是:每个业务动作变成了一个纯粹的函数——输入当前事件流 + 新命令,输出一个新事件。不需要去加锁揉状态,因为事件本身是只追加的,天然免疫并发覆盖。我用数百行代码重构掉原先一整套订单状态机之后,原来天天担心的并发扣减问题变成了一个小众话题,只需要在事件 ID 生成和幂等上做文章就行。
2. 把订单域翻译成资源-事件-代理三要素的关键决策
REA 听起来简单,真正把业务映射到三要素上的时候,第一步就卡住了。我花了整整两周反复开会、画图、推倒重来,最后沉淀出一套可以复用的翻译流程。最核心的是要搞清楚三件事:哪些东西是资源,哪些动作是事件,哪些角色是代理。
资源(Resource)是业务系统里“有价值且需要追踪数量变化”的对象。在订单场景里,库存、现金、应收款、应付款、优惠券、积分,这些都是资源。但要注意,有些东西看起来像资源,实际不是。比如“订单”本身,它更像一个聚合壳或者业务上下文,而不是资源;订单里的商品明细才是库存资源的引用。“地址”也不是资源,它是订单的属性,没有数量语义,不会因业务动作进出。
事件(Event)是资源数量发生变化的那一刻。我会把事件先按资源流方向分三类:
- 流入事件:资源进入系统。例如客户支付现金、采购到货、退货回库。
- 流出事件:资源离开系统。例如发放货物、消耗积分、支付退款。
- 交换事件:一个动作同时触发多种资源流入和流出,典型的就是“销售”:现金流入、库存流出、应付义务增加。
代理(Agent)是事件的参与方。在订单域里,客户、客服、仓储员、支付网关、风控系统,都可以是代理。代理的粒度需要自己拿捏,太粗就丢失审计价值,太细则数据量爆炸。我的原则是:至少要区分“内部代理”和“外部代理”,外部代理必须记录可追溯标识,内部代理记录到操作人员或系统服务名。
2.1 先画业务动作,再倒推资源与代理
我刚上手时犯了一个典型错误,一上来就列表格:库存是资源,订单是事件,客户是代理。结果画出来的模型完全没法用。正确的顺序应该是反过来的:先列出所有业务动作,再为每个动作找它影响到的资源、参与者和配对事件。
具体分四步走。第一步,和业务方一起把“会改变钱财或实物数量”的操作全部列出来,不用管技术实现。比如:用户下单、用户付款、商家发货、用户收货、用户申请退款、商家同意退款、仓库退货入库、运营发优惠券、优惠券过期。第二步,把每个动作拆成“谁对什么资源做了什么”。第三步,找出资源在不同动作之间的配对关系,比如“付款”和“发货”天然是一对交换,“退款”和“退货”又是一对。第四步,把这些关系落成表,再开始设计代码结构。
拿“用户下单”举例。表面上看它只是创建了一个订单记录,但按 REA 的视角,下单动作实际产生的是两个事件:一个“顾客表达购买意向”的事件,它锁定了一部分库存;另一个“创建应付义务”的事件,它记录了顾客欠了商城一笔钱。很多系统把“锁定库存”和“生成订单”放在同一个事务里做,但建模时把它们视为两个相互关联的经济事件会更清晰,因为后续“取消订单”只需要撤销“锁定库存”这个事件,而不需要反着修改一条订单记录。
这里我要强调一个经验:不要在第一步就纠结“订单到底属于资源还是事件”这种哲学问题,而是把所有围绕订单的状态和流转都先映射成事件。哲学会让团队陷入无休止的争论,而动作清单不会。你真正要关心的是业务上不可拆分的原子动作是什么,资源的定义会随着动作清单的演进而逐渐清晰。
2.2 库存扣减到底算“交换”还是“消耗”
在映射过程中,最容易吵起来的是资源流方向。典型问题:用户付款后系统扣减库存,这个“扣减”算什么?有人说是消耗,有人说要跟现金流入配对成交换。后来我总结出一个简单判断标准:问一个问题——这个资源减少后,系统中是否会有另一种资源相应增加?如果有,就是交换;如果没有,就是消耗。
用户付款让商城现金类资源增加,同时客户应付义务减少,这是交换。出库发货让库存资源减少,同时客户实收商品增加(商品归客户所有),这也是交换。但“优惠券过期”就只有优惠券余额减少,没有其他资源增加,这是消耗。分清这两者很重要,因为交换天然需要有来有回,一旦你发现某个交换只有一个方向的资源变化,多半是漏掉了一个隐藏资源,或者这个动作本身不完整。
这个判断标准在后来派上了大用场。一次业务方提出“无货取消订单要给用户补偿金”,我用 REA 模型一推演,立刻发现“取消订单”不能只产生一个负向库存事件,还需要产生一个“应付补偿金”的资源流入事件,否则补偿金没有来源。放到旧代码里,这种事通常又是加一个状态字段、再加一个定时任务去扫,而在 rea 项目里,它只是新增一种事件类型,并定义好它与原订单事件的关联关系。
2.3 用一张映射表锁住模型边界
确定好思路后,我把整个订单域做了一张映射表,这张表成了后续开发的基础契约。表头很简单:业务动作、事件类型、资源流入、资源流出、代理、配对事件。
| 业务动作 | 事件类型 | 资源流入 | 资源流出 | 代理 | 配对事件 |
|---|---|---|---|---|---|
| 用户提交订单 | 订单已表达 | 购买承诺 | 库存锁定 | 客户、商城 | 支付确认 |
| 用户完成支付 | 支付已确认 | 现金 | 客户应付义务 | 客户、支付渠道 | 订单已表达 |
| 仓库发货 | 发货已登记 | 客户对应收货权益 | 库存 | 仓库系统、承运商 | 支付已确认 |
| 客户退货入库 | 退货已入库 | 库存 | 实收货品权益 | 仓库系统、客户 | 退款确认 |
| 财务退款 | 退款已确认 | 客户应收款 | 现金 | 财务系统 | 退货已入库 |
这张表不是画完就完事的,它直接决定了事件存储的结构。我把每个事件设计成一张独立的表或者一组独立的记录,携带资源变化字段和代理字段。所谓“存储”,在 rea 项目里其实就是把这些事件按顺序追加到一个事件日志中,其余的一切都从这张日志推导出来。资源当前数量不再是一张需要锁的表,而是一个可以从事件日志按时间聚合出来的视图。
3. 用 REA 指导代码实现:聚合、命令与事件最终一致
模型画得再漂亮,代码写不出来也是白搭。rea 项目里我采用了一个精简的事件溯源架构,整体分三层:命令层、聚合层、投影层。命令层负责校验业务规则并产生事件;聚合层负责维护某个业务聚合的事件流;投影层订阅事件并构建出用于查询的读模型。
在选型时我纠结过要不要用现成的框架,比如那些重量级事件溯源库。最后决定自己写。原因有两个:一是项目核心逻辑不复杂,用框架反而要被框架的规则牵制;二是 REA 需要的事件结构比较特殊——事件要携带资源流入流出的结构化信息,现成框架往往是通用 JSON 格式,灵活性高但约束低,容易写歪。自己写的话,几百行代码就能覆盖核心链路,还能针对项目的具体场景做定制。
3.1 聚合根的选择:REA 帮忙避免“上帝聚合”
事件溯源里面最容易犯的错,是把一个聚合做得过大,导致所有事件都要经过同一个聚合根,并发能力直接被拉垮。REA 模型对聚合边界的划分很有启发:一个聚合应该对应“一组有因果关联的经济事件及其资源”。
在 rea 项目里,我没有把整个订单做成一个超级聚合,而是划分成三个聚合:订单聚合(负责订单创建、表达购买意向)、支付聚合(负责支付确认、退款确认)、库存聚合(负责锁定、出库、退货入库)。这三个聚合通过事件进行协作,各自独立追加事件,不共享同一个锁。比如“支付确认”事件由支付聚合产生并发布到事件总线,库存聚合订阅后产生“出库可执行”事件,然后才允许执行后续的库存操作。
这样划分之后,每个聚合内的事件流都很短,冲突概率极低。高频操作是“查询库存余量”,这个查询走的是投影读模型,根本不触碰聚合,所以并发压力被隔离在非常小的写路径上。
聚合代码的核心是一个 apply 函数,把事件应用到一个只存在于内存的聚合状态上。以库存聚合为例:
type InventoryState = { available: number; locked: number; }; function applyInventoryEvent(state: InventoryState, event: InventoryEvent): InventoryState { switch (event.type) { case 'StockLocked': return { ...state, locked: state.locked + event.quantity }; case 'StockReleased': return { ...state, locked: state.locked - event.quantity }; case 'StockShipped': return { available: state.available - event.quantity, locked: state.locked - event.quantity, }; case 'StockReturned': return { ...state, available: state.available + event.quantity }; default: return state; } }这个函数是无状态的纯函数,给定任意历史事件序列,都能算出当前状态。库存余量、锁定数量这些概念都不是持久化字段,而是遍历事件流得到的结果。为了性能,我会给每个聚合维护一个最新快照,同时只保留快照之后的新事件,加快恢复速度。
3.2 命令与事件分离:业务规则校验发生在事件产生前
前面的事件类型是“已经发生的事情”,而命令(Command)是“想要发生的事情”。在代码里,业务流程是:收到命令 -> 聚合加载事件流 -> 执行规则校验 -> 产生新事件 -> 追加存储 -> 发布订阅。
以“扣减库存发货”命令为例:
async function handleShipCommand(orderId: string, items: { sku: string; quantity: number }[]) { const inventory = await inventoryRepository.load(orderId); const errors = []; for (const item of items) { const state = inventory.getState(); if (state.available - item.quantity < 0) { errors.push(`${item.sku} 库存不足`); } } if (errors.length > 0) { throw new InventoryShortageError(errors); } const event = { type: 'StockShipped', orderId, sku: items[0].sku, quantity: items.reduce((sum, i) => sum + i.quantity, 0), agent: { type: 'warehouse', id: 'system' }, timestamp: new Date().toISOString(), }; await inventoryRepository.append(event); }这个流程的核心原则是:校验和成事分离。校验逻辑可以任意复杂,但它不写任何状态;真正写状态的只有追加事件这一步。事件一旦被追加,就是不可变事实。业务规则在决策时已经执行完毕,事件发生时不需要再做二次校验,这能让系统行为变得非常可预测。假如后来业务规则变了,比如允许超卖两个,那也只是改命令层的校验逻辑,历史事件一律不动,只影响之后的事件生成方式。
3.3 读模型投影:把“余额”降级为普通视图
在 rea 项目里,我彻底改变了读数据库的设计。过去库存余量和订单金额是主表字段,现在它们变成了投影表的字段。投影是一个订阅事件流并生成可查询视图的过程,使用专门的消费组来保证至少一次交付。
我维护了三类投影:库存余量投影、订单状态投影、财务应收应付投影。每个投影都是独立演进的小服务或模块,订阅自己关心的事件。比如财务投影只关心支付确认、退款确认、应付义务创建这类事件;库存投影只关心锁定、释放、出库、退货入库等事件。
投影的更新不一定要在同一事务里完成,可以允许短暂的不一致。电商这种场景,查询读到 100ms 前的库存是完全可以接受的;真正要紧的是保证事件追加不丢,所以我在事件存储层做了落盘确认,投影层则可以重新拉取任意历史事件重建。这是 REA 加事件溯源最爽的一点:任何一个读模型坏了,删掉重建就行,不需要任何数据迁移。
4. 在 rea 项目里反复踩过的坑:逆事件、并发和审计
实践到第三周,我天真地以为把事件都建好就万事大吉,毕竟不用写 UPDATE 了嘛。结果第一波联调就给了我一记闷棍。这里把最疼的几个坑记录下来,希望后来人少走弯路。
4.1 逆事件不是订单取消,而是“资源逆向流动”
第一个坑是关于取消订单。原以为“取消订单”就是产生一个“订单取消”事件,然后把库存加回去。听起来很简单,但实现后会发生一个诡异的问题:如果用户在一次购买流程里反复“下单-取消-下单-取消”,库存状态本身没问题,但审计查询时看到的是四条事件记录被交叉匹配,根本没办法迅速判断到底哪次取消对应哪次下单。原因是我把“取消”事件设计成了一个孤立的动作,而不是逆事件。
在 REA 模型里,逆事件不是简单地对某个字段取反,而是一组和原事件方向相反的资源流动。比如“支付确认”事件的逆事件是“退款确认”,它会同时产生“现金流出”和“应收款增加”;“库存锁定”的逆事件是“库存释放”,它不是产生库存增加,而是让锁定状态解除;“库存出库”的逆事件是“退货入库”。逆事件必须携带对原事件的引用,这样系统才能把因果链串起来。
我在事件表里增加了一个字段reverseOfEventId,每个逆事件都必须指明它撤销的是哪个事件。这个字段是审计的灵魂。出现问题时,可以直接在界面上从一个正向事件跳到它的逆事件,整个链条一目了然。没有这个引用,事件溯源和普通流水账没有本质区别。
4.2 并发下的重复事件:幂等永远不能靠“运气”
事件溯源不需要行锁,因为它只有插入没有更新,但是插入本身还是可能重复。比如支付网关回调超时后重试,同一个支付确认事件被提交两次,如果不做幂等保护,库存会被扣两次,现金会被记两次。
我的做法是在事件表上建一个唯一索引,索引字段就是“聚合 ID + 事件类型 + 业务幂等键”。业务幂等键通常从外部请求里拿,比如支付回调里的交易号、第三方渠道的请求 ID。如果重复提交,数据库层会直接拒绝第二次插入,应用层捕获唯一索引冲突后返回“已处理”。光有这一层还不够,聚合加载事件流的时候也需要把幂等键记录进内存状态,在命令校验阶段就提前拦截,避免做无谓的计算。
幂等处理的一个细节是:不要只在事件层做幂等,投影层也要考虑重复投递。事件总线的至少一次投递机制,会导致投影任务重复执行。我在投影任务里也维护了已处理事件 ID 的偏移表,并且每个投影更新都是可重入的(重新应用同一事件不会产生重复累加)。
4.3 审计式查询:任何状态都能回答“为什么”
老系统里想回答“为什么这个订单被取消了”,通常要去翻操作日志,而操作日志不一定和业务数据在同一个事务里,经常出现数据对不上的情况。在 rea 项目的架构下,这个问题迎刃而解:业务状态就是事件流的结果,想知道为什么,直接把该聚合的所有事件列出来就行。
我给事件日志做了两层索引。一层是按聚合 ID 聚合,满足常规业务查询;另一层是按代理 ID 聚合,满足运营和审计人员的“这个客服今天对哪些订单做过操作”之类的问题。由于事件本质是不可变的,审计结果具备原生的法律效力:历史永远无法被篡改,最多来一个逆事件对冲。
有一次我们收到一个客诉,用户说退款金额不对。我在事件流里一查就发现,他实际有两笔不同订单的退款流水被当时的客服手动合并到一张工单里,根源是旧系统根本没有表达出“应收款转移”的资源概念。这个问题的修复在 rea 项目里很简单:新增一个“退款归属转移”事件类型,在两笔退款之间建立关联,而不是再去修改早已入账的金额字段。这类问题的修复杂志感,是重构这套模型带给我的最大获得感。
5. rea 的实测结论与下一步还能拿去做什么
到这里,rea 项目已经稳定运行了一段时间。我需要诚实地说,它不是银弹,也不能把团队从所有烂代码里救出来。但它确实大幅改变了我处理业务逻辑的方式。下面把实测效果和边界条件都写出来,供你判断自己是否适合走这条路。
5.1 三个可量化的收益:可观测性、对账时间和模型稳定性
第一个收益是可观测性。过去排查线上问题要看日志、看数据库、看缓存,三个地方的信息要人工串联。现在所有核心业务动作都沉淀在事件流里,排查问题时只需要按聚合 ID 拉出一整条事件历史,程序上都发生了什么一目了然。对账也不再需要写临时脚本去清洗数据,直接执行一次投影重建,把重建结果和业务系统对一遍,十分钟基本能完成旧系统一个晚上的工作量。
第二个收益是对账时间。旧系统每次月底对账都要跑批量任务,期间不能做其他数据库操作,因为 SQL 查询极慢。现在对账不是去扫余额表,而是重新算事件流,由于事件表是追加式写入,所有建好的投影都可以并行算,最后按聚合 ID 做归并,计算时间从几十个小时降到几十分钟。当然这不是万能公式,我为了达到这个效果,特意在投影层做了按时间分区的设计,并不是所有团队都能简单复制。
第三个收益是模型稳定性。REA 最妙的地方在于它给业务分析人员和技术开发人员提供了同一种沟通语言。以前产品提需求,开发要翻译成表和字段;现在产品说“这个操作会影响哪个资源”,开发只需要确认资源类型,事件结构基本能复用。项目开发中期,新增了“批量发放满减券”和“优惠券跨订单分摊”两个需求,模型层面几乎没有改动,只是各加了一种新的事件类型和一个新投影,这种从容感在旧代码里是不存在的。
5.2 什么场景下 REA 会过度设计
也不是所有项目都应该用 REA。如果你的系统核心逻辑就是“改一条记录”,比如内容管理系统把一篇文章从“草稿”改成“已发布”,没有资源增减、没有多方代理参与,那 REA 就是过度设计。此时引入事件溯源只会增加复杂度,写代码慢、修 bug 慢、还要为它单独做一套监控和排查工具。
另外,如果团队尚不具备函数式思维或对事件驱动不熟悉,强行上 REA 会陷入“为了建模而建模”的困境。我在重构的过程中深刻体会到,REA 的模型约束力来自团队的认真遵守。如果一个模块偷偷绕过事件存储区直接改投影表,整套体系的一致性就瞬间被破坏,而且因为事件溯源往往缺少即时约束,这种破坏比普通 CRUD 污染更隐蔽。小团队要上 REA,务必先在单一业务域内做深度验证,不要一上来就全局铺开。
使用 REA 还需要预判你的业务是否真的有“经济资源流转”属性。我最初的设想是把它用到会员积分系统里,后来发现积分业务对实时性要求不高,直接用普通数据库表反而更好。真正能发挥 REA 价值的地方,是那些“钱货两讫”的电商交易、供应链库存、财务核算、资产登记类场景。这些场景的共同点是有明确的流入流出、有多个参与方、并且历史无法随随便便改写。
5.3 后续规划:把业务政策迁出代码
rea 项目当前的事件生成是在代码里硬编码的,比如“订单金额超过 100 元时免运费”,这个规则就嵌在命令处理函数里。下一步我打算把它抽成基于 REA 事实的规则引擎:事件流里的资源数据作为事实输入,规则引擎决定是否产出一个新的 REA 事件。这样业务人员可以配置规则,开发人员只负责事件的正常追加和投影更新。这种做法的前提是事件流的数据结构足够稳定,REA 的资源-事件-代理框架恰好提供了这个稳定性。
我在这个项目里最大的体会是:多数系统的复杂度不是来自技术,而是来自对业务本身的认知混乱。REA 把我从“改字段-擦屁股-再改字段”的死循环里拽了出来,它让我真正把系统当成一段持续发生的经济行为来建模,而不是一堆孤立的数据库行。如果你也被库存、订单、对账这类问题折磨过,不妨从一张简单的映射表开始,把业务动作翻译成资源和事件,再逐步走向事件溯源。这条路一开始要花不少时间去讨论和画图,但它的收益是长期且真实的。