
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 把我从“改字段-擦屁股-再改字段”的死循环里拽了出来它让我真正把系统当成一段持续发生的经济行为来建模而不是一堆孤立的数据库行。如果你也被库存、订单、对账这类问题折磨过不妨从一张简单的映射表开始把业务动作翻译成资源和事件再逐步走向事件溯源。这条路一开始要花不少时间去讨论和画图但它的收益是长期且真实的。