资讯动态

REA建模实战:如何用事件序列替代订单状态字段

发布时间:2026/10/22 19:24:29 来源:尧图企业网站定制
前阵子接手了模拟项目X一个零售分销系统的后端代码量不算大但我在第一周几乎全耗在“状态”上了。订单表有9个状态位库存表和流水表各管各的退款售后的逻辑在服务层散落成十几个if分支。每次产品提需求第一反应不是评估业务而是先开会对状态这单现在在哪个状态还能不能改改完要同步几张表说实话这是很多业务系统的通病。直到后来我用REA把领域模型重新过了一遍才意识到问题不是“表设计得不够好”而是我一直用画表的方式去表达业务流转根本没把“发生了什么”讲清楚。REA就三个字母Resource、Event、Agent即资源、事件、代理。它最早出现在会计信息系统领域的研究文献里是一种把业务建模成“谁动了什么资源”的领域建模思想。这篇文章我会用模拟项目X的改造过程完整展示REA如何建模、如何落到表和接口上以及我是怎么踩坑的。适合正在写订单、库存、账务系统的后端同学也适合数据建模和数据架构的同行。1. 一个订单系统怎么让我重新认识了rea这三个字母1.1 状态字段为什么会成为系统的慢性病我接手模拟项目X时先做了一次“状态盘点”。订单主表里躺着主状态待支付、已支付、已发货、已完成、已取消旁边还挂着支付状态、发货状态、售后状态、积分状态、库存锁定状态。每一个字段单独看都说得通组合起来没人能说清。典型场景一笔订单“已发货”之后用户发起退款退款成功。这时候我应该把订单主状态改成“已退款”还是保留“已发货”支付状态要不要改成“已退款”库存锁定状态要不要解锁同一行数据里状态字段之间存在大量隐式依赖但这些依赖从来没有被代码表达过只存在于老员工的记忆里。这就引出了核心问题UPDATE会不断抹掉历史。状态字段只保存“当前结果”不保存“过程”。一旦系统出现异常比如支付回调重复、库存扣减超卖、退款和发货并发你根本不知道这笔单到底经历了什么只能靠日志和猜。日志又没有统一格式翻起来极其痛苦。1.2 第一次读到REA时我重新理解了“发生了什么”我是在准备重构方案、翻会计信息系统相关材料时看到REA的。原意来自会计信息系统领域的研究文献核心内容其实就一句话把业务建模成资源、事件、代理三者之间的关系。翻译成人话就是——业务不是一组表而是一连串事件每个事件会改变一个或多个资源每个事件都有一个或多个代理参与。我用最简单的例子理解它顾客买了一个杯子100元。传统建模订单表加一行商品库存减1用户账户扣100元。REA建模发生了一个“销售事件”这个销售事件让“商品库存”资源减少1件让“银行存款”资源增加100元参与这个事件的是顾客外部代理和收银员内部代理。第一眼觉得它很啰嗦。明明一个订单表能表达完的事非要拆成事件、资源、代理三套。但仔细想想它把“结果”换成了“过程”。所有我们日常维护的订单状态、支付状态、发货状态本质上都是被压缩过的事件快照。与其维护一堆快照之间的组合逻辑不如把事件本身记下来需要什么状态时从事件时间线里推导或缓存计算。1.3 为什么这些内容值得单独写一篇文章市面上讲领域驱动设计的文章很多但专门把REA讲透、还配合落地的很少。我梳理了三个值得写的原因第一REA能解决“需求改动总在打补丁”的根因。很多系统之所以改一处崩三处是因为数据模型里没把业务规则表达出来规则全散落在service层。第二很多人听说过Event Sourcing、CQRS但完整实施太重单子上线压力大的时候很难推进。REA提供了一个更轻的切入角度不需要改造消息中间件不需要重写查询层只要把表结构按“事件优先”重排就能获得事件溯源的大部分好处。第三模型搭建从纸上看起来很简单一旦真上手处处是坑。资源粒度怎么划分、事件和流水什么关系、用不用快照、状态字段到底能不能删干净这些问题不实际做一遍根本意识不到。2. REA建模不是画实体图而是把业务讲成“谁动了什么资源”2.1 三个字母的语义比“实体”更接近业务事实REA里的Resource不是所有业务对象都叫资源。它特指“会被事件改变且有经济意义”的东西。开篇先把范围切开避免一上来就把用户表、订单表全都硬塞进资源。我在项目中把资源分成三类商品类目比如某个SKU是资源类型但不是资源实例。资源实例比如某个批次的库存、某个客户账户里的余额是真正会被事件增减的对象。电子化权益比如积分、优惠券余额本质也是资源只是没有实物载体。Event在REA里是动词是资源变化发生的瞬间。销售出库、采购入库、收款、付款、退款、积分发放都是事件。它强调的是“发生”不是“记录”。代理Agent则是参与事件的个人或组织我习惯进一步拆成内部代理客服、仓管、财务和外部代理客户、供应商、物流承运商。2.2 状态机思维和REA思维最本质的差异传统ER设计里最典型的动作是“更新状态”。订单一旦创建就要不断维护状态位去表征它的生命周期。状态机的问题不是状态本身而是状态和业务事实之间隔了一层你可能知道它当前是“已支付”但并不知道它是怎么变成已支付的中间有没有异常。REA把这些全部转化为事件序列。订单不是一个需要被反复UPDATE的对象而是一组事件的聚合结果。状态不再是字段而是最近一个关键事件发生后的推演值。举一个对照表应该能一眼看出差异视角传统ER设计REA建模核心对象用户、订单、商品资源、事件、代理一次购买INSERT订单、UPDATE库存、UPDATE余额记录销售事件事件行记 库存-1 / 现金100怎么知道当前状态读状态字段根据事件时间线推导或读缓存快照如何做回滚更新原记录或删除追加一个反向事件审计能力靠操作日志补结构本身自带审计链这个对比也是我后来在项目中反复向团队强调的状态字段解决的是“我要什么”事件序列解决的是“发生了什么”。大部分业务系统后者比前者重要得多。2.3 REA与Event Sourcing、CQRS的关系很多人第一次听到REA会联想到Event Sourcing两者有关系但不是一回事。事件溯源是持久化模式把状态变更以事件形式存储当前状态由事件流回放得到REA是领域建模视角它定义的是“哪些东西值得被记录为业务事件”。REA的事件有明确的经济含义不是任意操作都能算。我实际落地时采用了一个组合领域语义用REA定义清楚持久化采用事件表加资源快照表不引入完整CQRS。事件表保证事实完整资源快照表保证列表查询够快。这样既控制了重构范围又能享受REA带来的审计和可追溯性。3. 模拟项目X的改造从状态字段地狱到REA领域模型3.1 项目背景模拟项目X最初的问题清单模拟项目X是一个零售分销后端业务覆盖采购入库、销售出库、客户下单支付、退款、积分发放。表面上功能都正常但维护成本已经高到让人头疼9个状态字段组合出几十种业务状态权限过滤和列表筛选条件极其复杂。订单流水、库存流水、资金流水各自独立查一笔钱从哪里来到哪里去要拼三张表。库存锁定和实际扣减分离超卖全靠数据库行锁硬扛。退款时的状态跳转规则没有任何一个人能完整说清楚。这些问题单独拆开看都能局部优化但根子都在模型层系统想表达的东西太多了数据模型却只有一个“当前状态”。3.2 第一轮动作把状态字段一个接一个删掉我没有急着写新表而是先把订单相关的全部状态字段列成一张表逐个拆解它“其实是什么”。拆解结果非常重要我贴出来给大家参考状态字段表面含义真正的事实待支付订单还没付款还没有支付成功事件已支付款项已经收到最近一次收款事件成功已发货仓库已发货最近一次发货事件完成已收货客户确认收货最近一次收货事件完成已退款退款完成存在退款/退货事件已取消订单关闭存在取消事件所有状态字段都能被事件时间线取代。我保留了一个“汇总状态”字段但明确要求它只能用于列表筛选和展示核心业务逻辑一律禁止依赖它。那个字段就是事件序列的物化缓存不是业务事实本身。3.3 列三张清单资源、事件、代理旧表梳理完我开始列三张关键清单。资源清单把系统里所有“会被谁改变的东西”列出来资源类型资源实例谁增加/减少商品类目某SKU的某批次库存采购入库销售出库-现金企业结算账户收款退款-客户预存款客户账户余额充值消费-积分客户积分账户发放使用-事件清单以动词命名每一行必须回答“它改变了哪类资源”事件类型触发场景影响的资源变化采购入库供应商送货库存销售出库客户下单并付款库存-收款支付成功现金客户预存款-发货仓库发货库存位置/状态变化退货退款售后退款现金-库存积分发放下单完成积分积分使用积分抵现积分-代理清单相对简单内部代理有客服、仓管、财务外部代理有客户、供应商、物流承运商。需要注意的是同一个事件可以涉及多个代理销售事件至少包含客户和客服两个代理支付事件一般包含客户和财务。3.4 重组后的模型事件表成为核心枢纽清单列完之后模型结构就自然浮现了。核心不再是一个携带状态的订单表而是一张事件表外加事件行表和资源表。业务上需要反复使用的“订单号”作为外部标识挂在销售事件上方便和第三方对账。新的模型关系是Agent代理发起 Event事件Event 改变 Resource资源。事件表是唯一的事实来源资源表是它的物化投影。订单的完整生命周期就是一组按时间排序的事件序列。这一步做完后之前那些无解的并发决策突然清晰了发货和退款并发不再需要复杂的互斥状态系统只需要按事件的时间顺序去判断退款事件发生在发货前还是发货后对应的会计处理完全不同。4. 表和代码长什么样REA落地的三条主链路与一致性设计4.1 落地的表结构设计我给出一个简化但不失真的大纲级表结构大家可以根据实际业务扩展-- 代理表 CREATE TABLE agent_party ( agent_id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- CUSTOMER / SUPPLIER / STAFF external_id VARCHAR(64), display_name VARCHAR(128) ); -- 经济资源表 CREATE TABLE economic_resource ( resource_id BIGINT PRIMARY KEY, resource_type VARCHAR(32) NOT NULL, -- PRODUCT_BATCH / CASH / POINT category_code VARCHAR(64), quantity NUMERIC(18, 3), updated_event_id BIGINT ); -- 事件表 CREATE TABLE event_entry ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- SALE / RECEIPT / SHIPMENT / RETURN... occurred_at TIMESTAMP NOT NULL, agent_id BIGINT NOT NULL REFERENCES agent_party(agent_id), business_ref VARCHAR(64) -- 订单号等外部业务标识 ); -- 事件行表记录每个事件对资源的具体增减 CREATE TABLE event_entry_line ( line_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES event_entry(event_id), resource_id BIGINT NOT NULL REFERENCES economic_resource(resource_id), direction SMALLINT NOT NULL, -- 1 增加 / -1 减少 quantity NUMERIC(18, 3), amount NUMERIC(18, 2) );拆成事件表和事件行表是有原因的一个事件经常同时影响多个资源。销售事件要减少库存、增加现金退款事件要减少现金、增加库存。如果只允许一行要么被迫拆成多个事件要么把字段冗余到无法维护事件行表完美解决了这个问题。4.2 主链路一下单并扣库存下单链路是REA改造中改动最大的部分伪代码如下def create_sale_order(customer_id, sku_id, qty): with db.transaction(): # 1. 插入销售事件代理为客户 event_id insert_event( event_typeSALE, agent_idcustomer_id, business_refgenerate_order_no() ) # 2. 写入事件行商品库存批次出库 resource allocate_inventory(sku_id, qty) insert_event_line( event_idevent_id, resource_idresource.resource_id, direction-1, quantityqty ) # 3. 更新资源快照 resource.apply_delta(-qty) save(resource)这里有个顺序讲究必须先插入事件和事件行再更新资源快照。事件是事实快照是派生物。如果快照更新失败整个事务回滚即可如果某天发现快照和事件对不上还能重放事件流重新计算快照。传统方案是先锁库存、再扣库存、再写流水流水经常被当成“辅助记录”来用导致流水和库存表偶尔不一致。REA翻转了优先级写事件是主操作扣库存只是它的派生结果。4.3 主链路二支付与应收应付支付在REA里不是给订单打一个“已支付”标记而是一个独立的收款事件。这个收款事件至少影响两个资源企业现金账户增加实收金额。客户预存款余额或应收账款的贷方相应减少。同一个事件需要插入两行event_entry_line。如果客户使用预存款支付那么资源变化是“客户预存款减少、企业现金增加”两行记录方向相反但金额一致。如果客户使用第三方支付那就简化成“客户应付减少、企业现金增加”。这个设计非常方便财务对账。财务关心的“这笔钱到底是货款还是退款”在旧系统里只能靠字段猜在新模型中直接看事件类型即可。多笔订单合并支付也能表达一个收款事件指向多个销售事件的业务引用就行。4.4 主链路三退款与库存回滚退款回滚是我踩坑最多的部分。旧系统里退款通常要做一次反向UPDATE把订单状态从“已发货”改成“已退款”再把库存加回来。稍有不慎就会把原始记录改得面目全非。REA的设计原则很简单回滚不靠DELETE也不靠修改原事件而是追加一个“串退货退款”事件事件行方向与销售相反。原销售事件保持原样它永远记录“曾经卖出一件商品”退款事件记录“后来退回一件商品”。两者都留在账本里差额一眼就能算出来。业务上需要计算“某订单还欠多少钱”只需要对该订单关联的所有销售和退款事件做一次累计。如果只显示了当前的聚合结果审计时反而看不到完整历史。4.5 事务与一致性的实际取舍事件和快照之间的一致性我选择了“同事务内同步更新”方案。也就是事件、事件行、资源快照在一个数据库事务里提交避免中间状态暴露给查询。如果未来事件量变大可以退化成“事件表先行写入资源快照异步刷新”。但异步方案要额外处理快照延迟期间的查询一致性还需要引入重算任务。对于当前这个量级的零售系统同步更新更省心。另一个重要细节不要让事件写入和快照更新分布在两个不同的服务或两个独立事务里。分布式系统里只有“本地消息表”模式才能真正保证最终一致性普通双写一定会在故障时出现对不上的数据。如果对这点没把握宁可先做同步事务。5. 用了一阵之后REA的价值和代价都藏在这些细节里5.1 实实在在的三个好处第一个好处是审计能力。现在任何一个业务事实都能完整回答“谁、在什么时候、动用了哪个资源、动了多少”。之前最头疼的售后对账现在一条SQL就能列出某订单关联的全部事件时间线。第二个好处是状态机消失了。订单表上那9个状态字段删掉了7个剩下的2个仅用于列表展示。原来需要手工维护的状态流转规则变成了“判断事件顺序是否合法”这一条简单约束。if分支数量降了一个量级。第三个好处是跨域口径统一。订单、库存、财务都在讲同一套事件语言销售、收款、发货、退货。需求沟通不再鸡同鸭讲产品说“退款”大家就知道要新增一个退款事件而不是去某张表改状态。5.2 代价也不能假装不存在REA不是没有成本的。最明显的问题是查询变复杂了。以前“查询今天的已支付订单”是一个字段条件现在需要先确认支付事件的时间范围再聚合。如果不加物化视图列表接口响应时间会很难看。另一个代价是事件表持续增长。所有业务事实都往一张表里写量级起来后要么按月分区要么定期归档。归档时要注意如果某些资源实例还在生命周期里快照必须保留完整。更隐性的成本是建模门槛。资源、事件、代理的拆解如果在团队内没有形成共识很容易变成“伪REA”表结构照抄了但团队每个人对事件的理解都不一样代码照样乱。5.3 适用边界什么项目别硬上REA我吃过“所有系统都想用REA”的亏所以项目复盘后我列了一张适用边界表适合REA的场景不建议硬上REA的场景交易系统订单、支付、退款内容管理系统库存、供应链纯主数据管理后台财务、计费、账务简单CRUD功能有审计合规要求的系统原型Demo和短期活动判断标准其实就一句话如果业务的核心是“资源在多个主体之间流动和变化”REA有价值如果核心只是“一堆记录要被增删改查”REA只会拖慢进度。6. 复盘那些建模一开始没想清楚、后来差点回不了头的设计6.1 坑一把资源类目当成资源实例项目初期我把商品SKU直接当成了经济资源。结果建模到库存批次时发现同一个SKU明明有多个批次每个批次的生产日期、失效日期、锁定状态都不同。如果资源是SKU就无法表达“某批次过期需要冻结”这种场景。后来的修正方案是SKU属于资源类目具体某个批次才是资源实例。销售事件关联到批次。这个改动让库存报表的准确度提升了不止一个档次。6.2 坑二事件表被用成了巨型操作日志有段时间团队为了省事把任何操作都往事件表里插包括“更新用户昵称”“修改收货地址”这种完全不涉及资源增减的动作。结果事件类型爆炸事件行表大量空行查询性能骤降。REA的事件必须能回答“改变了什么资源”。如果一条事件记录没有任何资源增减它就不属于业务事实而应该走普通操作日志通道放到和REA事件完全不同的存储里。6.3 坑三为了REA而REA引入一堆未来才用得到的资源设计新模块时有人提出要加入“营销资源”“渠道资源”理由是以后肯定用得到。但过去半年没有任何事件真实地增减过它们。这些资源的存在让模型变得臃肿团队讨论业务时经常在无意义的实体上较劲。资源是被事件真实改变的不是被头脑风暴想出来的。如果当前没有任何事件会改变它就先不要建模等真实需求出现时再加。6.4 如果再让我选一次我会先做这三件事第一先用过去三个月的真实订单数据把模型回放一遍。不要只在白板上画模型拿真实数据验证“从事件能否重建出所有业务状态”这个验证能发现80%的设计漏洞。第二模型确定后马上画一张“订单生命周期事件表”按时间列出事件序列并在旁边标注每个事件希望派生出的业务状态。这张表就是团队关于状态的口径共识。第三给资源、事件、代理各建一份登记册作为团队的公共词典。所有人都使用同一套词汇表不再出现“销售流水”“订单流水”“出库单”这种同义不同名的情况。现在接手任何新项目我都会先问三个问题它最核心的资源是什么会有什么事件去改变这些资源又是哪些代理在发起这些事件如果答不上来后面的表设计大概率是在赌运气。REA不是什么银弹它只是把“业务事实”放回模型的核心位置让代码配合事实而不是让事实迁就代码。如果你也在被订单状态机和散落的流水表折磨建议找条简单的业务链路试一晚——把状态字段删掉换成事件试试你会有和我一样的感受。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑