REA事件建模实战:用Resource-Event-Agent重构订单与库存系统
我第一次认真研究REA不是在看理论文章的时候而是在一个订单系统改到想拍桌子的时候。那时候线上订单的状态字段越来越多待支付、已支付、待发货、已发货、已签收、售后中每一个状态背后还挂着一堆标志位和流水记录搞得数据库表越来越大业务方每次提新需求我都得先翻半天代码才能说清楚“这张表里的数据到底是怎么来的”。后来一位老同事提醒了一句“你别老盯着状态建模试试按事件建模看看REA。”那是我第一次把REA这三个字母当成一个正经的东西对待。结果花了大概两周读资料、做实验又把手头项目一部分重构成事件风格之后很多之前头疼的问题自动就消失了。这篇文章就把我这一路踩过、试过、验证过的东西完整讲一遍适合正在做订单、库存、财务这类“有资源流转、有参与方、要审计追溯”系统的同学参考。如果你只是做内容管理或单纯记录型的后端也可以看看至少能帮你判断这套思路到底适不适合你。1. REA的核心思想为什么建模视角要从账本转向业务事件1.1 Resource、Event、Agent这三个词到底在说什么REA全称是Resource-Event-Agent最早来自会计信息系统领域的一套业务建模框架。现在大家聊它更多是把里面的思维方式挪到软件系统设计里尤其是那种“东西从哪来、到哪去、谁经手”的领域。三个词拆开看Resource是经济资源也就是系统里被认为有价值、需要被追踪的东西。比如电商里的可售商品、仓库里的库存、用户账户里的余额、积分、优惠券都属于资源。注意这里的资源不一定是物理实体虚拟资产一样算关键特征是“有价值、可增减、能归属”。Event是改变资源的经济事件。比如商品入库、订单提交、支付成功、发货出库、退款、售后回库这些动作会让某类资源变多或者变少甚至只是“承诺未来会变多或变少”。REA里事件才是整个模型的中心它不是记账凭证的附属品而是真正的第一公民。Agent是参与事件的代理。包括内部代理和外部代理。内部代理是系统所在组织里执行事件的人或系统比如仓管员、财务审核人员、订单处理机器人外部代理则是参与交易的对手方比如客户、供应商、支付渠道、物流承运商。一个事件可以涉及多个代理但关键在于谁对结果负责、谁参与了资源转移。可以类比成一个仓库的日常来记Resource是货架上的商品Event是“从卡车上卸下来”“从货架搬到打包台”“装进快递箱”Agent就是那个指挥搬货的人。如果你只盯着货架上的数字你最多知道“现在有多少货”但你不知道这些数字是怎么一步步变的。REA的做法是把“每一次搬运”都记录下来货架数字只是搬运历史自然推出来的结果。1.2 让整个模型能跑起来的三对关键关系光有这三类概念还远远不够REA真正值钱的是实体之间的关系。定义好这三对关系模型就已经覆盖了绝大多数业务语义。第一对是stockflow翻译过来是“存量-流动”关系描述事件和资源之间的关系。一个事件会让一个资源的存量增加或减少。比如支付成功现金余额增加同时用户的钱包余额减少发货出库库存减少。判断一个模型是不是REA最简单的检查就是每个事件有没有至少关联一个“流入资源”或者“流出资源”。如果一个事件既不改变任何资源的数量也没产生新的资源那它大概率不是经济事件只是内部操作日志。第二对是duality中文常叫“双向性”或“博弈性”。它描述事件与事件之间的配对关系。几乎每个真实业务事件都会有一个与之对应的“反向事件”。比如一笔销售订单对应一次收款一次发货对应一笔应收款或货物的流出一笔采购入库对应一次付款。这对关系把业务链串起来了从“承诺”“承接”到“实际履行”再到“对账核销”。第三对是control描述事件和代理之间的关系。核心价值是权责分离。例如一个事件需要至少一个内部代理谁来办一个外部代理和谁办。这样审计的时候就可以清晰地回答“这笔操作是谁发起的、谁执行的、谁批准的、对方是谁”。没有control关系事件就只是一条数据谈不上业务责任。从工程角度理解这三对关系其实就是实体之间的外键约束方向事件的资源增减指向哪些资源事件的配对应指向哪些其他事件事件关联哪些代理角色。代码上完全可以通过事件表和资源ID、代理ID表达出来。1.3 和传统借贷记账法、普通ER模型到底差在哪我见过不少人在第一次接触REA时问这不就是记账吗不就是把借和贷换成Event吗实际上差别还挺大。传统会计模型是账户为中心的每一个会计科目有期初余额、借方发生额、贷方发生额、期末余额最后落到资产负债表和损益表。它的核心目标是“报告财务状况”所以它关心的是余额是否正确、科目分类是否准确。REA则完全放弃了账户这个概念它只记录什么资源、因为什么事件、在哪些代理之间发生了转移。余额不是表里的字段而是事件流聚合出来的一个视图。普通ER建模也容易和REA混在一起。ER模型的核心是“有哪些实体、哪些属性、哪些关系”它服务于具体需求可达性比如我要查订单列表就建订单表要查库存就建库存表。REA则强制你先回答业务语义上的问题一个动作是不是经济事件影响了什么资源谁参与了。它比ER模型多了一层“业务真话层”保证了你建的每一张表背后都有业务解释。我用一个表格简单对比一下大家感受更直接维度传统借贷记账法普通ER模型REA事件建模建模起点会计科目和账户需求驱动的表结构经济事件数据核心账户余额各业务实体状态事件记录回答“余额哪来的”凭证追溯很难回答只能靠写逻辑事件流重放即可新增业务类型新增科目、调整分录规则新增表和状态枚举新增事件类型和资源对非财务人员友好度低需要懂借贷中依赖需求梳理功底高强调业务直觉2. 用一个电商场景把REA落到地面上2.1 先设定一个足够真实的业务场景理论讲再多不如拿一个具体场景走一遍。我挑一个非常典型的B2C电商订单系统核心需求是下单、支付、库存扣减、发货、售后、对账这一整条链路。这类系统最常见的坑是订单状态字段越加越多状态机越来越复杂每次新增一个售后类型都要动订单主表加枚举再加上第三方支付回调产生不同的流水记录两个系统之间的对账经常对不上。用REA重推这个场景最大的变化是我不再先想“订单表长什么样”而是先想“哪些资源被哪些事件改变、谁参与”。在这个模型里系统涉及的主要资源包括可售商品目录、仓库库存、客户钱包余额、积分账户、优惠券账户。注意商品目录其实是资源的静态描述不是资源本身。库存才是真正的资源它会被入库、出库、盘点等事件改变。这个区分很重要我见过很多系统把商品和库存混在一张表里导致每次看到的需求都分不清是改商品信息还是改库存数量。2.2 第二步把所有可参与的经济事件列成清单建模型前一个下午值得专门花掉把所有业务动作过一遍用“是否改变资源、是否有代理参与、是否有业务意义”这把尺子筛一遍。这个电商场景里我列出来的事件清单大概是创建订单、支付成功、支付退款、库存锁定、库存扣减、库存释放、发货出库、客户签收、退货申请、退货入库、盘点调整。这些全部是事件。诸如“用户点击了商品详情页”“用户刷新了购物车”这种就不该进事件表它们不改变资源只是行为日志扔去数据仓库就行。事件和事件之间的duality关系也需要在清单阶段标记。支付成功对应订单的应付金额库存扣减对应订单里的商品数量发货出库对应订单的发货动作退货入库对应退货申请。把这些配对关系标清楚后面的对账逻辑基本上就是看配对的双方是否都存在。2.3 第三步给每个事件挂上明确的资源增减和代理角色事件定好了资源也定了接下来就是为每个事件补全两条关键外链资源流向和代理归属。拿“支付成功”事件举个例子流入资源是平台的待结算资金账户流出资源是客户的第三方支付账户或银行卡账户内部代理是支付系统机器人外部代理是用户和支付渠道。这样一条事件记录配合时间戳本身就构成了一张不可篡改的凭证。再拿“发货出库”事件来举一反三流出资源是仓库库存流入资源可能没有但会有对应的“已发货包裹追踪资源”产生如果做包裹维度管理的话内部代理是仓管员或WMS系统外部代理是物流承运商。duality上它对应的是“销售订单的履约责任”也就是订单承诺被实际执行。2.4 整个模型成型后的阅读体验当我把这些实体和关系都填到一张大画布上之后模型读起来非常直观客户外部Agent创建订单Event订单承诺未来会支付同时产生待履约的订单责任支付成功Event把现金类资源从客户钱包拨到平台账户发货Event把库存资源从仓库拨给物流承运商签收事件最终完成整个资源从商家到客户的转移闭环。而且这套模型不会让“订单状态”消失订单状态只是这些事件在特定时刻的投影。待支付就是“创建订单事件已发生但支付成功事件尚未发生”已完成就是“订单履约和支付事件全部闭环”。状态不是一个需要维护的字段而是可以从事件流里随时推导出来的。我为这个场景整理了一个小型事件映射表方便落地时对照事件流入资源流出资源内部代理外部代理创建订单订单应收责任无订单系统客户支付成功平台资金账户客户钱包支付系统支付渠道、客户发货出库已发运责任仓库库存仓管员物流承运商客户签收无已发运责任物流系统客户退货入库仓库库存无仓管员客户盘点调整仓库库存增加或减少仓库库存减少或增加盘点员无3. 从模型到代码事件溯源风格的落地方案3.1 为什么实体表和事件表必须分开设计刚接触REA时最容易犯的一个错是把事件当普通的流水表业务更新资源字段的时候顺带往事件表插一条记录结果事件表和资源表耦合在一起出了问题两边对不上。原因是资源实体表承担了两种完全不同的职责一是记录“当前状态”二是记录“状态变化的证据”。REA的工程化核心就是拆开这两个职责。事件表只存不可变的事实资源当前状态表只是基于事件的缓存投影。事件表是数据源资源表只是派生视图。这样设计有几个直接好处。第一任何余额、库存、积分数值出问题都可以通过重放事件来还原到任意时间点不需要去“猜”数据怎么变成这样的。第二审计需求永远是查事件不会有人问你要余额的历史而是问“这段时间这个资源怎么波动的”。第三业务规则演进的时候你不需要为了兼容历史状态去写一大堆状态迁移逻辑直接加一种新的事件类型就行。3.2 一张简化的事件表结构能跑起来实际落地时事件表的第一版我常建议这样建CREATE TABLE economic_event ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(64) NOT NULL, occurred_at TIMESTAMP NOT NULL, agent_internal_id BIGINT NOT NULL, agent_external_id BIGINT, resource_in_id BIGINT NOT NULL, resource_out_id BIGINT, quantity NUMERIC(20, 4) NOT NULL DEFAULT 0, ref_event_id BIGINT, payload JSONB, created_at TIMESTAMP NOT NULL DEFAULT now() ); CREATE INDEX idx_event_resource ON economic_event (resource_in_id, occurred_at); CREATE INDEX idx_event_type ON economic_event (event_type);这里有几个字段值得展开说。resource_in_id表示流入的资源IDresource_out_id表示流出的资源IDquantity表示数量正负都在quantity里定义但具体方向由resource_in和resource_out已经定死了所以不需要额外的“正负号”。ref_event_id用来放duality关系比如支付事件引用创建订单事件发货事件引用库存扣减事件退货入库引用退货申请事件。payload字段是我后来才真正理解其价值的。开始我总想给每种事件类型都建一张专用表不同事件字段差异太大要么改表结构要么干脆全塞一个payload字段里。现在我的建议是公共字段时间、代理、资源、数量抽出来建普通列作为查询和统计的入口差异化字段全部丢进payload的JSONB让业务扩容不需要频繁动表结构。3.3 用事件流重建资源和账户余额事件表存好之后当前余额就是重放的结果。写一个简单函数演示一下思路def rebuild_resource_quantity(resource_id, events): quantity 0 for ev in sorted(events, keylambda e: e[occurred_at]): if ev[resource_in_id] resource_id: quantity ev[quantity] if ev[resource_out_id] resource_id: quantity - ev[quantity] if ev[event_type] adjust: quantity ev[payload][adjusted_quantity] return quantity这只是一个示意实际系统里当然不能每次都全量重放所有事件。做法是给资源表维护一个“快照版本号”定期把截至某个版本的结果固化下来重放时只需要从最近一次快照之后的事件开始算。这个快照整体思路和数据库的WAL加checkpoint一套逻辑很像本质都是“全量重放太慢定期打点做增量”。同时事件表里的id本身就是幂等键。每次对接上游回调时让对端把业务流水号作为事件id写入一旦重复插入就会因为主键冲突被拦掉。这个环节一定要在接入第一天就做不然后面补数据会补齐到崩溃。3.4 审计和对账为什么自动变简单我在做重构之前对账一直是手工写脚本匹配订单状态、支付流水和库存变更记录不同表之间的时间字段和历史快照很难对齐。用REA之后对账变成了一个清晰的三步操作拉取我方某段时间内的economic_event拉取对端比如支付渠道给我方回调的流水按照事件id和金额逐条做全外连接。比如支付链路我方应有“支付成功”事件对方应有支付成功回调两者用事件id关联上缺失的任何一边都说明有问题。库存链路也一样发货事件应该对应WMS的出库事件两边一比对就能把差异定位到具体的某个事件上。3.5 落地时真正容易踩的坑第一件要留神的是“事件不可变”这件事绝不能被破坏。我见过有同事为了修数据直接UPDATE事件表结果业务上要求查历史金额的时候对不上了。正确做法是“反向补偿事件”也就是再补一条调整事件把错的东西冲回来任何时刻都不要去修改已经写入的事件。第二件是事件字段的语义演进。你定义的event_type字段含义不能随着版本偷偷变比如把一个原本是“pay_success”的事件改成“refund”就离谱。新增字段走payload新增类型走event_type新增旧事件永远保持它发生时的原貌。第三件是性能问题。事件表会像滚雪球一样快速膨胀这时候查询条件设计就要想清楚。不要试图把所有维度都建索引只要保证按资源、时间、事件类型这三个核心维度能高效过滤就够了。真要做复杂分析就把事件流清洗后打到分析库让OLTP保持干净高效。4. 真实项目里绕不开的几个边界辨析4.1 库存到底算资源还是事件结果这个问题几乎每次讨论REA都会有人问。库存本身当然是一个资源因为你总得追踪“现在仓库里还有多少货”。但仔细想一下库存的当前数量从来不是一个直接写入的值它是所有“入库”“出库”“盘点”事件求和的产物。那些把库存作为字段维护的系统最终都会面临并发和一致性头疼两个订单同时扣库存一个更新成功一个更新失败甚至出现负数。REA视角下“锁定”“扣减”“释放”都是事件并发控制发生在“向事件表插入一条事件”这个过程上任何资源数量的变化都表现为事件的累积。你可能需要一个库存快照表来支持高性能查询但它的值一定是事件流派生的而不是被人直接改来改去的。另外还有一个细节盘点调整在我眼里算“修正认知”事件而不是“物理资源真的变了”。账面库存和实际库存有差异时盘点是让模型重新对齐现实所以调整事件里记录的是目标数量而不是变动量对应的资源在重建时要走覆盖逻辑。4.2 事件拆到什么粒度才合适刚开始设计事件表经常会陷入两个极端一种是把所有小动作都拆成事件比如把用户点击一下就变成事件另一种是把一长串业务动作揉成一个大事件比如把“下单到支付到发货”全部归纳成一个order事件。两种都不合适。我现在的衡量标准只有一个这个动作是否被业务人员认为是“一笔完整的经济交易”。业务人员能看懂、能背靠背对账的动作才配叫事件。用户点击购物车“加入”不是一个经济事件因为它没有资源转移和代理参与而下单就是一个事件因为它产生了订单承诺是需要被审计的。另一种可操作的方法是只要某个资源数量发生了实质增减它背后就必然至少有一个事件如果你能回答出“这个变化是哪个动作造成的”那个动作就是事件的粒度。这个标准基本能帮你筛掉八成边界模糊。4.3 外部第三方代理怎么建模支付渠道、物流公司、供应商这些外部代理在REA里都是Agent但很多人在建模时会犹豫要不要单建外部代理表。我的建议是内部代理和外部代理在逻辑上可以合并成一张agent表用一个角色字段区分内外和具体角色不需要为了内外差异建两张结构一样的表。真正需要关注的是外部代理的身份识别。内部代理一般都能拿到一个稳定员工号或系统账号但外部代理你只能拿到第三方的账户标识比如支付渠道的商户订单号、物流运单号、供应商编码。落地时要把这些外部标识存到agent的标识字段里并且做好和实际业务号的映射不然对账时连对方的单号都对不上。4.4 REA和DDD中聚合的关系能不能直接画等号我经常被问到DDD里我们已经有了聚合、实体、值对象还需要REA吗我的观点是REA是概念建模框架DDD是代码落地时的战术方法论它们可以结合但不等同。REA帮你把业务事件、资源、参与方梳理清楚DDD帮你把这些概念切分成一个个高内聚的聚合并在代码中表达业务不变式。但有一个容易踩的坑不要因为REA里每个事件都关联代理和资源就把事件、资源、代理全部塞进一个巨大聚合里。聚合边界要按照业务不变式来切比如订单聚合包含订单项和预留库存支付聚合包含支付事件和资金资源它们之间用事件ID异步联动而不是强行做成一个大对象。5. 什么场景适合REA什么场景别硬上5.1 真正适合REA的地方其实是这些判断一个系统适不适合REA我一般先问三个问题。第一业务里是否有多个角色参与同一批资源的流转第二是否需要对资源变化的历史非常较真比如几年后还能说清楚每一笔出入第三业务规则是不是经常变比如今天支付方式明天退款方式后天积分抵扣电商、供应链、采购、财务、会员积分、预约挂号资源这类系统几乎都是REA的舒适区。它们核心痛点在于多参与方、多事件源、状态之间容易出现不一致而REA把一致性问题的焦点收缩到了“事件是否成功落库”这一件事情上大大降低了整体复杂度。5.2 不适合硬上REA的场景也别勉强有些系统用REA反而是给自己找麻烦。典型的就是纯内容管理、管理后台的简单CRUD、以及以OLAP报表为主的系统。这类系统里没有明显的资源转移和经济事件强行把“文章发布”“标签修改”“菜单调整”建模成事件最后只是增加理解成本收益几乎为零。另一个要慎用的场景是超高并发、对吞吐量极其敏感的交易链路。REA的事件持久化本身并不比普通表慢多少但如果你要把整个交易过程全部事件化并且每次查询都要依赖聚合建投影系统的吞吐和延迟就会受到影响。这时候更适合的做法是核心交易链路用紧凑的事务表REA风格的事件记录异步落到分析侧做审计和追溯两边各司其职。5.3 一次没必要的REA改造给我留下的教训前两年我做过一个内容管理系统的改造业务方觉得现有后台太乱希望能梳理清楚内容从编辑到发布再到下线的整个过程。我花了很大力气按照REA的方式建模给“草稿创建”“提交审核”“审核通过”“发布”“下线”都定义为事件还硬给内容资产定义了资源的增减。结果做完了才发现这个场景的核心根本不是资源流转而是审批状态和权限流转。内容资产不涉及数量增减每个事件里根本没有实质的stockflow关系Agent也只是审批人整个模型看着整齐但代码改起来比原来的状态机还麻烦。那次之后我给自己立了条规矩先用两页纸把“资源、事件、代理”模型画出来如果画不出像样的资源流动和duality配对就果断放弃REA老老实实按状态机或普通CRUD去做。5.4 选型判断清单我把这套判断标准整理成一个清单方便拿项目直接套是否有明确的经济或业务资源库存、余额、积分、预约资源、实物资产资源的每次变化是否都由可以识别的外部触发动作导致是否存在多参与方之间的资源转移且需要明确责任是否有对账、审计、历史追溯的要求业务是否会频繁增加新的业务动作类型如果这五项里超过三项都是“是”那REA大概率能帮到你如果超过三项都是“否”我建议你还是用传统的表结构加状态机设计没必要增加思维护成本。用到今天我最大的体会是REA并不是什么银弹它更像一套“逼你先想清楚业务本质”的约束规则。很多项目之所以越做越乱往往不是代码写得不好而是建模时根本没有回答“资源是什么、事件有哪些、责任归谁”这三个基础问题。REA的价值就在于它会用一个很朴素的问题反复拷问你这笔数据到底是凭哪次业务动作进来的想清楚这个很多设计难题会自己消失。最后再分享一个建议如果团队第一次接触REA千万别一上来就重构整个核心系统。先挑一条比较独立的业务线比如积分账户或者优惠券体系用事件表重做一遍跑通之后再逐步扩展。等事件表、快照表、补偿流程这套基础设施稳定了你再回头去看订单、库存这些复杂链路会发现之前那些绕来绕去的状态流转其实早就该用REA来写了。