2026/10/18 7:00:52

REA建模思想实战:资源-事件-代理模型重构订单库存系统

REA建模思想实战:资源-事件-代理模型重构订单库存系统 REA 这个词我第一次看到时心里想的是“这又是哪个新中间件”后来一查才发现它是一套在业务系统里非常值得认真研究的建模思想全称 Resource-Event-Agent翻译过来就是资源-事件-代理。这篇笔记就来聊聊我完整落地过一遍 REA 框架的实战记录从它到底解决什么问题、核心概念怎么区分到具体怎么把一套订单加库存系统改造成 REA 结构最后把实操中踩过的坑和排查经验一并整理出来。适合正在做订单、库存、供应链、财务对账或者积分系统重构的后端开发、架构师也适合被“订单状态字段越堆越乱”折磨到怀疑人生的同学。1. REA 到底想解决建模里的哪些问题1.1 传统表结构建模越做越重的三个信号我见过太多系统是这样一步一步走向失控的第一个信号订单表字段开始爆炸。下单时间、支付时间、发货时间、确认收货时间、退款状态、发票状态、优惠券明细、赠品信息……全都塞在订单主表里。看起来好像一张表就够用了实际上每次要加一个新状态就要跟着改一堆历史数据、检查一堆兼容逻辑一个不小心就把线上订单状态搞坏了。第二个信号对账靠人肉拉数据拼。财务月底要核对库存金额、应收应付业务说自己按订单跑了财务说自己按流水算了两边数字对不上。原因很简单系统里根本没有一个统一的事件账本钱和货被拆散在不同的业务表里面只能靠人写临时 SQL 去拉、去导、去手工比对。第三个信号业务规则的执行逻辑散落在各处。你今天在订单 Service 里改库存扣减逻辑明天发现采购入库那边又用了一套完全不一样的扣减规则后天发现盘点调整又是第三套。不是代码写得差是底层模型压根没有达成共识导致大家各做各的。这三大信号本质上都是同一个根因我们在用“单据视角”建表却希望这套模型能支持“事实追溯”。单据只是业务的呈现形式真正应该留在系统里的是业务事实本身。1.2 把“凭证驱动”换成“事件驱动”不妨用快递举个例子。快递到了哪儿、中转了几次你不会只往包裹上写一个“最后位置”字段而是每到一个节点就扫描一条记录。查包裹在哪儿看最新一条物流记录就行出问题要追责看完整时间线就行。订单系统也是一样的道理。传统做法是在订单上维护 status 字段从待支付改成已支付、已支付改成已发货每次修改都是覆盖历史。等到哪天你想知道“这个订单在 3 月 15 号到底处于什么状态”你发现状态早就被覆盖了根本查不回来。REA 的核心转变就是把“状态覆盖”改成“事件追加”。每次业务动作都作为一条不可变的事件记录保存下来当前状态可以随时从事件流推导出来历史也能随时回溯。做对账的时候全部以事件流为准绳钱和货的来龙去脉清清楚楚。1.3 哪些业务适合 REA哪些没必要REA 不是一个万能银弹它天生适合那些“有价值流动、需要审计追溯、涉及多方参与”的场景。适合用 REA不太需要 REA电商订单、库存、进销存纯内容管理系统、博客、CMS供应链与多级仓储简单 CRUD 展示型应用财务应收应付、资金账户静态报表平台积分、优惠券、会员点数体系无状态接口聚合服务选型逻辑很简单如果业务从头到尾都只是“单表增删改查”没有跨表价值流转也没有审计要求那 REA 只会给你增加额外复杂度。但如果你的系统每天要处理库存、资金、积分这类“会变来变去还要说得清为什么变”的数据REA 的价值会随着业务复杂度的提升越来越明显。2. 资源、事件、代理三个核心概念怎么用2.1 资源能衡量价值、能被业务流转的存量资源不是说“数据库里的一张表”它有明确特征可以被消耗、产出、交换而且能衡量价值。举个例子电商系统里商品库存是资源入一箱货库存增加卖一件货库存减少现金和应收应付是资源每笔交易的资金流动会改变它们的余额积分、优惠券、服务时长、带宽流量包这类虚拟资产同样是非常典型的资源。判断一个东西到底算不算资源我有个简洁标准如果它的数量会增加或减少而且业务上要求你必须能回答“为什么增加、为什么减少、谁触发的”它就应该建模成资源。反之比如订单编号、商品名称这些只是属性描述不是资源。这里特别提醒一个坑不要什么字段都往资源里塞。资源只保存“存量状态”和“基础属性”像成本分摊规则、审批流程配置这些业务规则不能作为资源字段堆进去否则又会退化回普通的宽表设计。2.2 事件不可变、可追溯、有明确时间点事件是整套 REA 模型里最重要的概念也是我落地时体会最深的部分。事件表示业务发生瞬间的一个事实。采购入库是一次事件销售出库是一次事件积分兑换是一次事件库存盘点调整也是一次事件。事件必须包含这几个要素什么时间发生哪些资源流入哪些资源流出哪些代理参与事件类型是什么事件一旦写入就不能修改、不能删除。如果业务操作有错误比如入库数量录错了正确的做法不是去 UPDATE 那条事件记录而是新增一条“冲正事件”把错误影响抵消掉再把正确数量入账。这样看事件流的时候能看到错误发生与修正的全过程审计非常舒服。我第一次做这种设计时会有点不习惯因为以前习惯了“改状态”现在改成“追加事实”。但这其实反而让代码逻辑更清晰所有写操作都是 append不需要处理复杂的更新逻辑。2.3 代理参与主体要区分角色不要无脑建“用户表”代理是参与事件的各类主体。最常见的有内部员工、外部客户、供应商还有系统本身。但代理不等于用户表。我在项目里犯过一个典型的错以为代理就是“用户”那张表。后来发现同一个用户可能同时是内部采购审批人、也可能是供应商对接人甚至在某些场景下还需要虚拟代理比如“系统自动巡检”。所以落地时代理要拆成两层代理实体用来标识“谁”对应真实的账号、公司或系统参与角色用来标识“以什么身份参与”比如买家、卖家、审批人、经办人。一个事件和一个代理之间是多对多关系。某个销售出库事件里客户是买方、销售员是经办人、仓管员是发货执行人这些都是同一条事件上的不同角色。2.4 “谁-何时-何物”的关系连线规则把三要素串起来会得到几条非常稳定的规则我每次建模都会拿它来做自检没有任何资源会凭空增加一定有某个事件让它流入没有任何资源会凭空减少一定有某个事件让它流出任何事件都必须有至少一个代理参与资金类和实物类资源的流入流出必须双向配对也就是价值守恒。拿一条销售出库来说事件流入资源流出资源参与代理销售出库应收账款/现金增加库存商品减少客户、销售、仓管采购入库库存商品增加应付账款增加供应商、采购员、仓管你看这不是“随手记一笔库存的变化”而是把业务动作从源头到结果连成了一闭环。任何一笔库存增加都能追溯到对应的采购入库事件任何一笔资金流动也都能追溯到对应的业务事件。这套规则做顺了以后对账就是按事件流直接展开不用再人肉拼数据。3. 一个订单库存系统的 REA 落地实录3.1 先梳理业务事件别急着建表我改造某一个电商后台的时候第一步不是画表而是拉着业务把关键动作一条一条列出来。这里特别关键一定要先把“业务上有哪些事实发生”想清楚再谈表怎么建。当时整理出来的核心事件清单大致如下事件类型说明涉及资源变化采购入库商品到货库存增加库存流入应付账务对应变化销售出库客户下单并发货库存减少库存流出应收账务对应变化库存盘点调整实盘和系统不一致手动修正库存流入或流出退货入库客户退回商品库存流入退款/红冲对应金额拒收取消订单取消需要恢复库存库存回流资金冲销这一步做完你就发现业务看起来五花八门但落到 REA 模型里无非就是不同事件类型去改变不同资源的方向和数量。这个认知确立以后后面建表和写代码都变得特别顺。3.2 检查事件之间的双向约束和守恒关系列完事件清单我做了第二件事拿“双向约束”去校验每个事件有没有配对好。比如采购入库这个事件它不能只看作“库存商品增加了”还要看到“应付账款增加了”。否则库存这边加了一百箱货财务那边却不知道要付多少钱月底一查账资金流和货物流就对不上。销售出库同理库存减少只是其中一侧另一侧是应收账款或者现金增加。退货入库则是相反方向的镜像操作。我当时画了一张很简单的对照表把每个事件的两侧资源变化都列清楚事件资源侧 A资源侧 B配对逻辑采购入库库存 100应付 100货到即欠款销售出库库存 -2应收 2货出即债权退货入库库存 1应收 -1货回则退债现金收款现金 2应收 -2钱到则消债这步的价值在于业务规则还没写代码就已经被结构化地推演过一遍很多后续开发阶段才会发现的口径问题在建模阶段就能被挡掉。3.3 数据库表怎么设计建模确认后我把表结构设计成下面这套。这里需要说明的是这是基于常见实践整理出的通用落地方案实际项目可以按需增加字段和索引。资源表CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- inventory / cash / receivable / points measure_unit VARCHAR(16) NOT NULL, -- 计量单位箱、件、元、分 description VARCHAR(256), created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL );代理表CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL, -- internal / customer / supplier / system contact_info VARCHAR(128), status TINYINT NOT NULL DEFAULT 1, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL );事件表只做追加不更新不删除CREATE TABLE business_event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, -- purchase_in / sale_out / return_in ... occurred_at DATETIME NOT NULL, description VARCHAR(256), request_id VARCHAR(64) NOT NULL, -- 幂等键防重复提交 created_time DATETIME NOT NULL, UNIQUE KEY uk_request_id (request_id) );事件与资源的关联明细表CREATE TABLE event_resource_item ( item_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction VARCHAR(8) NOT NULL, -- in / out quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4) NULL, remark VARCHAR(128), KEY idx_event_id (event_id), KEY idx_resource_id (resource_id) );事件与代理的关联明细表CREATE TABLE event_agent_item ( item_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role_type VARCHAR(32) NOT NULL, -- buyer / seller / auditor / operator KEY idx_event_id (event_id), KEY idx_agent_id (agent_id) );这几张表的配合方式是业务发生一笔动作业务事件写一条主记录再通过明细表挂上它影响的多个资源、参与的多个代理。所有查询都可以从事件主键出发把完整链路捞出来。3.4 服务层代码如何保证一致性表结构定了之后最核心的实现就是写事件落库和资源余额更新的服务。我用的伪代码是这样的实际项目可以用任意语言照这套逻辑去写class InventoryServiceImpl: tx_manager.transaction def record_inventory_event(self, event_type, occurred_at, agent_ids, resource_id, direction, quantity, request_id, description): # 1. 幂等校验同一个 request_id 不能重复入账 if self.event_repo.exists_by_request_id(request_id): self.logger.warning(duplicated request: %s, request_id) return False # 2. 保存事件主记录 event_id self.event_repo.insert( event_typeevent_type, occurred_atoccurred_at, descriptiondescription, request_idrequest_id, ) # 3. 保存事件-资源明细 self.event_resource_repo.insert( event_idevent_id, resource_idresource_id, directiondirection, quantityquantity, ) # 4. 保存事件-代理明细 for agent_id, role_type in agent_ids: self.event_agent_repo.insert( event_idevent_id, agent_idagent_id, role_typerole_type, ) # 5. 更新资源余额 balance self.balance_repo.lock_for_update(resource_id) if direction in: balance.quantity balance.quantity quantity elif direction out: if balance.quantity quantity: raise InsufficientStockError(resource_id) balance.quantity balance.quantity - quantity self.balance_repo.update(balance) return True这里最关键的一点是步骤 5 里的lock_for_update。库存扣减是典型的并发敏感操作必须在事务里对目标资源余额做行级锁否则两个线程同时卖最后一件商品就可能会出现超卖。另外注意事件明细表和资源余额表是在同一个事务里提交的。这样能保证要么事件和余额一起成功要么一起回滚不会出现“事件记了但库存没减”或者“库存减了但事件没记”这类脏数据。3.5 展示层怎么做查询有人担心事件驱动模型查询会变慢因为每次要看当前库存都要去扫事件流。实际落地根本不用这么干。我的做法是维护一套“最新余额表”也就是物化视图或者定时聚合任务。资源主表里始终保存当前可用数量事件流保存每一次变化的完整轨迹。平时业务查询走余额表十亿级事件量也不怕审计和对账的时候才去翻事件明细那本来就不追求毫秒级性能。这套设计本质上是把“写模型”和“读模型”分开写侧是追加流水读侧是查询余量。比传统的“主表更新状态 流水表存历史”要清晰得多因为无论是写还是读各自走一条最舒服的路径。4. 常见问题与排坑实录4.1 事件粒度到底该细到什么程度落地 REA 时最常被问的问题就是一个下单动作到底要拆成几个事件拆太细比如把“点击下单”也记录成事件系统会被海量无意义事件淹没真正要追溯业务时反而找不着重点。拆太粗比如把 “下单支付发货” 全部塞进一个事件里那中间任何一步失败这条事件到底算成功还是失败就成了说不清的事。我习惯的粒度标准是只有“真正改变资源状态”的动作才值得记成事件。点击下单本身不改变库存和资金所以不用建事件但确认支付会让资金增加所以支付成功必须建事件出库会让库存减少所以发货出库也必须独立建事件。4.2 库存可用数、在途数、预占数怎么处理REA 模型下的资源余额只代表“当前时点的存量”但实际业务还经常需要区分可用库存、在途库存、预占库存。这些怎么办我给出的解决方式是不同口径建不同的资源账户而不是在一个资源余额表上硬加一堆状态字段。比如同一件商品可以拆成“可用库存”和“预占库存”两个资源账户。下单时从可用库存扣减、转入预占库存发货时再从预占库存扣减同时记一条出库事件。这样每个口径的余额都是独立的查数清清楚楚不会出现“可用数到底是负数还是预占没扣掉”这种糊涂账。4.3 金额、成本价要不要全部建模成资源资金和库存是自古以来的对账难题所以金额相关的东西最容易触发 REA 建模冲动。但我实际做下来的体会是不是所有金额都要建模成独立资源否则资源表会膨胀得让你哭出来。一个务实的方案是事件明细上直接保存单价和金额字段只有“真正独立的资金账户”才建模成资源。应收应付这种需要长期跟踪余额的账户值得用资源建模而单笔订单里的商品销售金额只要跟着事件明细记录就足够了。判断逻辑依然是那条老标准这个金额需不需要回答“当前还剩多少”如果不需要直接作为事件属性保存就可以。4.4 老系统改造时历史数据怎么迁移这是所有改造项目最头疼的一环REA 也不例外。如果你试图把过去三年的所有订单全部分解成事件再重新入账工作量会大到项目直接夭折。正确做法是区分处理对于改造时间点之前的历史数据生成一条“期初初始化事件”直接把当时的库存余额、应收应付余额一次性写入资源余额表作为起点快照对于改造时间点之后的增量数据严格按照事件驱动方式入账。这套“快照 增量”的策略既能保证历史数据可追溯又能把迁移成本控制在一个可接受范围内。我实际落地时发现账实相符的关键不在于历史是否拆得足够细而在于期初快照选取得准不准。4.5 排坑小贴士最后把我踩过几次坑之后总结出来的几条实操经验写在这里每一条都是血泪教训幂等键一定要做。REA 事件只追加不更新一旦重复提交库存余额和资金余额就会被无辜多记一次。事件表里加上唯一 request_id幂等校验放在事务最前面能挡住绝大多数重复请求。时间字段全部用 UTC 存储。业务显示层再转本地时区。如果数据库混着本地时间和 UTC 存月底对账时你会被时区问题折磨疯掉。事件表和余额表的更新必须同一个事务。没有例外。拆成两个事务的话一定会遇到“事件记录成功但余额更新失败”的中间态。库存扣减一定要锁定余额行。不用悲观锁或者分布式锁高并发下超卖就是一句话的事。定期跑一次对账任务。让系统每天晚上自动把当天所有事件流按资源维度汇总和余额表做一次比对。有差异就报警不要等到月底才发现问题。这套 REA 模型我后来也在别的业务模块里试过包括积分账户、卡券核销、渠道佣金结算基本思路完全通用。真正理解它的精髓之后你会发现所谓架构设计很多时候不是代码写得有多炫而是把业务的因果关系在数据模型层面摆得足够正、足够稳。