首页 / 资讯中心 / 文章详情

订单系统重构:REA建模用资源、事件、主体还原业务真相

订单系统重构:REA建模用资源、事件、主体还原业务真相 ★ FEATURED ARTICLE
年初接手一个跑了四年的订单系统打开库表结构的时候我愣了一下订单主表48个字段光状态字段就分了7种语义同一条记录既要表达支付、又要表达发货、还要表达售后。为了查清楚“这笔订单为什么变成这样”只能去翻操作日志靠时间轴拼凑真相。后来我们给这次重构起了一个代号REA。起名的时候没什么讲究但它后来被我们越用越正式——Resources、Events、Agents资源、事件、主体。REA最早是会计信息系统领域的一套建模思想后来被企业架构和领域驱动设计圈子重新捡起来。说人话就是不要先把数据库表设计出来而是先把业务里“到底发生了什么”想清楚。每一个业务动作是一个事件事件改变着资源主体发起动作——模型贴着业务过程走而不是贴着页面和报表走。我用它在订单、库存、账务三个系统上反复实践过踩了不少坑。这篇文章就把那套思考方式、建模方法、落地细节和避坑记录一起交出来。如果你正在做订单、库存、资金、会员积分这类强业务系统或者你写实体类写到最后发现关系越来越拧巴、查状态只能靠猜这篇文章适合你。哪怕你只是听说过CQRS和事件溯源、想找一个能落地的建模抓手也可以从REA里获得一套完整的方法。1. 先别建表为什么一个48字段的订单表让我决定改用REA1.1 传统类目驱动的ER模型问题出在哪常规的建模流程是这样的先梳理业务里有哪些实体再画关系图然后开始建表。订单、用户、商品、发票、退款单一张张铺开最后在每个表上塞一个“状态”字段作为当前结论。这套做法本身没有错但它有一个结构性的盲区业务世界里几乎所有关键信息都是过程而传统ER模型擅长记录的是结果。你看到订单状态是“已发货”却不知道它在过去两天里被仓库退回、被客服拦截、发货地址被改过三次。最后一次状态更新以后之前的过程就被覆盖掉了。用一个生活类比冰箱门上贴着一张便签写着“里面有牛奶一盒、鸡蛋三颗”。这不是库存管理因为你看不见这些食物是什么时候放进来的、谁放的、保质期怎么算。业务系统的道理一样。订单状态、库存余额、账户余额本质上都是“过程留下的结果”如果建模的第一天就把结果当成唯一的真相后面每一次追溯都会变得无比痛苦。我见过太多团队在状态字段上打补丁加一个status_history表、加modify_time、加audit_log最后这些表和操作日志之间又没有可靠关联反而成了新的维护负担。REA要做的就是把记录的对象从“结果”切回“过程”。1.2 REA就三件事资源、事件、主体REA把业务系统的概念严格划分成三类。资源是经济价值的载体——商品、现金、库存、优惠券、服务工单都算事件是发生在某个时刻的业务事实——客户下单、仓库发货、财务收款、用户核销必须是已经发生的事主体是行动的发起者——客户、销售员、仓管员、财务人员内部外部都算。三类概念之间的关系只有一句主体参与事件事件改变资源。就这么简单但它能解决一个很深的建模问题——你不再需要绞尽脑汁为每个页面、每个状态设计字段你只需要回答业务动作是什么动了什么资源谁发起的。这里要特别强调REA里的事件不是技术层面的操作日志。系统日志是给运维排查用的记录的是“系统发生了什么”比如某接口被调用了、某条SQL执行失败REA的事件是给业务对账用的记录的是“业务发生了什么”比如某个客户在12月1日10点下单买了两件商品。这两者能在日志系统里重叠但在建模时必须区分开。如果混淆了你的事件表里就会混进“用户点击了按钮”“定时任务跑完了”这类噪声业务模型立刻失去解释力。1.3 同一个订单域两种建模结果对比维度传统订单模型REA订单模型核心载体orders / order_items 表events 事件流状态表达orders.status 一个字段扛所有语义OrderPlaced / PaymentReceived / GoodsShipped 等事件序列历史追溯靠操作日志和审计字段反推事件流天然可回放状态变更方式UPDATE 一行旧值被覆盖INSERT 一条新事件历史不可变对账基础各表各算口径不一资源增减守恒可统一校验从表格能看出来关键差异不在“建了几张表”而在“谁是一等公民”。传统模型里order状态和balance金额是主数据REA模型里事件是主数据状态和金额都是从事件推导出来的投影。这个转变听起来是理念上的但落到代码上会带来非常实在的变化改需求时不用再讨论“要不要加一个状态值”而是讨论“要不要加一种事件”排查问题时不用再翻日志重构时间线而是直接把事件流按时间排出来问题一目了然。2. 核心概念逐项吃透资源、事件、主体以及存量流量思维2.1 资源必须能参与经济交换而不是数据库里任意一张表资源是REA里最容易理解错的概念。有同事问我订单号算不算资源客户地址算不算我说在REA里都不算。判断标准有两条第一它必须是经济交换或经济转换的对象能被获取、消耗、转让或者加工成另一种资源第二它的存在不依赖某个技术系统。商品可以被卖出现金可以被支付库存可以被消耗积分可以被兑换——这些是资源。订单号是信息系统里的标识符用完不会减少不参与价值流转所以它不在资源层。如果你建模时发现把数据字典里的表名一一搬进了资源清单那大概率是把资源与业务凭证混为一谈了。资源本身的粒度也需要拿捏。以商品为例具体到“某一箱货”还是抽象到“某一款SKU的商品描述”模型结果完全不同。仓储场景里一件商品可能有批次、库位、保质期资源粒度过粗会导致批次追溯失败而在销售场景里同一个SKU的所有可售库存往往可以合并处理粒度过细会让事件暴涨。我的经验是先满足最紧的追溯要求来定资源粒度再考虑是否需要合并视图。如果一个业务必须回答“这批货是哪一批次的”那资源就必须落到批次粒度否则后期任何补丁都补不回来。2.2 事件记录业务里真正“发生了什么”事件是整个REA模型的灵魂也是最难建模的一环。每个事件应当对应一次不可再分的业务事实某客户在12月1日10点下单某仓管员在12月2日9点发出20号订单里的两件商品。事件里至少要有四类信息事件ID、发生时间、涉及资源和数量、参与主体。为什么强调“不可再分”因为一旦把两个动作揉进一个事件中间状态就永久丢失了。比如把“下单支付”算成一个事件那你就永远回答不了“已下单未付款”的客户有多少。这个问题的后果不是统计麻烦而是所有依赖中间状态的业务流程——催付、订单取消、库存保留——全部失去数据基础。事件还有一个容易被忽视的性质不可变性。已经发生的事件不应该被修改或删除修正业务错误的方式是追加一个新事件。比如财务发现一笔收款金额录错了正确做法不是UPDATE那条PaymentReceived而是先追加一条PaymentCorrected注明原事件ID和正确金额再把原事件标记为被冲正。这样做的好处是审计时可以完整还原“从错误到修正”的全程。我在实际项目中见过不少团队为了省事直接改历史事件结果后来每一次对账、每一轮审计都会因此翻车这个教训一定要记住。2.3 主体客户、员工、仓库管理者谁有权做什么主体回答的是“谁干的、谁能干”。它不是简单的人员档案表而是把操作职责、权限与业务事实绑定在一起。在REA模型里主体分内部和外部客户、供应商是外部主体销售员、仓管员、财务人员是内部主体。一个业务事件必须能说清楚由谁发起、谁审批、谁执行这条主体链也是未来审计和权限控制的基础。交付系统时可以画出“主体-事件”矩阵仓管员可以创建GoodsShipped但没有权利创建PaymentReceived客服可以创建ReturnApproved但没有权利创建库存调拨。把权限挂在主体与事件的矩阵上比挂在菜单按钮上更接近业务本质。主体本身也可以在事件流里演变成资源这是REA比较有趣的地方。比如员工离职后他在职期间发起的事件不能因为人员状态变更而消失这些事件仍然保留归宿。这也是为什么主体信息需要独立维护一份基线数据而不能散落在各个业务表里。实际项目中我习惯把主体分为agent表和agent_role关联主体ID长期稳定角色可以随时调整。这样即使组织架构变了历史事件里的主体ID依然能索引到正确的人不会因为人员转岗导致历史数据“找不到责任人”。2.4 存量与流量账户余额、库存数量只是事件流的投影理解REA最关键的一把钥匙是把业务数据分成存量和流量。账户余额是存量存取款是流量库存数量是存量入库出库是流量积分余额是存量获得和兑换是流量。传统表结构把存量当一等公民每次业务操作都UPDATE一行记录REA把流量当一等公民每次操作都追加一条事件存量只是事件流经过某种计算后得到的投影projection。用Flow和Stock这两个词概括就是流量产生存量存量是流量的累积结果而不是独立维护的数字。把存量改成投影之后最大的好处是任何时点的余额或库存都可以通过回放事件得到。月末对账不需要信任某张余额表直接把当月所有事件累加一遍和账面对比对不上就说明事件链路有缺口。这套思路和复式记账、事件溯源天然同构复式记账的每一笔分录是事件科目余额是投影事件溯源的每一步领域事件是事实系统状态是投影。可以说REA为这类技术方案提供了一个业务侧的建模质量标准——不是“我要用事件溯源所以先建个event表”而是“业务本来就该按事件来建模事件存储只是顺理成章的实现”。3. 实操落地从业务用例到REA模型再到可运行代码3.1 四步建模法先圈事件再补资源最后挂主体我实操时把建模过程固化成四步。第一步圈动词。把业务语言里的动作全部列出来下单、改单、付款、发货、收货、退货、盘点、调拨、报损、结算、开票、冲正……这些动词九成会演变成事件。第二步找资源。对每个动词问一句这个动作让什么资源变多或者变少现金、库存、应收、积分、优惠券找到资源变化才说明这是个真正的领域事件如果找不到资源这大概率只是操作动作不是业务事实。第三步挂主体。区分内部主体和外部主体明确谁有权触发这类事件。第四步做守恒检查。任何事件都要能说清楚资源从哪里流向哪里、数量金额能不能平。平不了的事件后面一定会变成对账事故的源头。下面这张表是我做库存项目时整理的事件候选表的一部分模板可以直接拿去用动词涉及资源资源方向主体备注客户下单可售库存承诺占用客户不扣实物库存仓库发货实物库存实际减少仓管员触发物流单财务收款货币资金/应收资金增加、应收减少财务可关联订单编号客户退货实物库存实际增加客服客户必须关联原销售事件盘点实物库存差异调整盘点员需双人复核这套表最值钱的地方不是格式而是逼着团队把“业务动词”和“资源变化”一一对应。很多时候产品经理描述的需求含糊不清比如“支持取消订单”你真去建模会发现取消订单到底让库存释放、让支付退回、让积分追回如果是三个动作那它就是三个事件。把聚合的业务动作拆成细粒度事件是REA建模最有价值也最花时间的一步。3.2 用“下单-收款-发货”完整走一遍映射拿“下单-收款-发货”这条最普通的主链路来看传统里orders表一个状态字段的流转在REA里会被拆成一组事件。客户下单产生OrderPlaced事件资源层表现为“商品承诺量增加”真实库存暂不变化财务收款产生PaymentReceived事件货币资金资源增加应收账款资源减少仓库发货产生GoodsShipped事件实物库存流出商品的所有权转移给客户。这三类事件之间有明确的先后约束没有OrderPlaced就不可能有GoodsShipped没有PaymentReceived或账期授信发货就不应该被允许。这些约束不是散落在业务代码的一堆if里而是由事件流本身的合法性体现的。这套模型的直接收益是当出现“已发货未收款”“已付款未发货”“超卖”这些业务问题时你不用再去翻不同表的更新顺序只需把同一订单的事件流按时间排出来缺口立刻现形。完整REA理论里还有一层承诺commitment与实际actual的区分订单是对未来资源的承诺发货和收款才是实际变动。落地时第一版可以不做完整承诺层把下单当作承诺事件发货、收款当作实际事件两者在报表层对账即可。承诺层的好处是支持更复杂的履约计划代价是建模复杂度明显上升建议等项目跑通后按需引入。3.3 DDD聚合与事件表设计参考落地实现时我推荐把事件存储作为业务主库关系表只承担主数据和投影。下面是经过两个项目验证的最小表结构。event_store表是核心stream_id用来把同一笔订单的所有事件串起来event_type标明事件类型payload用JSONB存业务细节version做乐观锁防止并发追加冲突。resource_position表保存当前资源快照比如某SKU的实物库存或某客户的积分余额本质上是投影结果。agent表维护内部和外部主体基线agent_role表处理组织架构和角色变化。CREATE TABLE event_store ( id BIGSERIAL PRIMARY KEY, stream_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, payload JSONB NOT NULL, version INT NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT now(), UNIQUE (stream_id, version) );事件表写入时用stream_idversion的唯一约束做乐观并发控制。同一个订单聚合并发追加事件时只会有一个事务成功另一个重试后重新读取当前版本再追加。聚合拆分上我的习惯是Order作为一个聚合根OrderPlaced、PaymentReceived、GoodsShipped都挂在同一条stream上库存出入库事件挂在SKU聚合上用sku_id作为stream_id。跨聚合的一致性不要用分布式事务硬扛而是靠写事件前的前置校验加补偿动作来完成。比如发货前先校验订单已支付校验通过才创建GoodsShipped事件如果后续物流信息没有更新再通过查询事件流定位是哪一环断了。很多团队在要不要引入事件溯源框架之间纠结。我的意见是第一版完全可以不上框架手工维护event_store表和投影函数反而能让团队更透彻地理解模型原理。等事件类型和项目边界稳定之后再引入成熟的事件溯源框架或消息中间件表结构上的迁移成本非常低。不要一上来就追求重型设施REA的核心价值在于建模不在技术选型。3.4 投影、快照与读写模型分离转向读取端之前必须解决一个性能问题直接从海量事件回访所有历史事件太慢。解决方案就是快照。快照的本质是把“截至某个版本时的投影结果”存下来重建时从最近快照开始累加增量事件。我实践中的节奏是每个订单聚合每50个事件打一次快照库存类资源每1000条变动打一次快照。快照和事件必须写在同一个事务里否则重建时可能出现重复或丢失。快照表可以简单设计成snapshot_id、stream_id、version、state_json、created_at。type ProjectedStock Mapstring, number; function projectStock(events: DomainEvent[], fromVersion: number, startState: ProjectedStock): ProjectedStock { const state new Map(startState); for (const event of events) { if (event.version fromVersion) continue; switch (event.type) { case GoodsShipped: state.set(event.sku, state.get(event.sku)! - event.quantity); break; case GoodsReceived: state.set(event.sku, state.get(event.sku)! event.quantity); break; } } return state; }投影函数必须是一个纯函数同样的事件序列和起始状态永远得到同样的结果。这样就能随时从任意版本重建当时的库存快照排查问题时非常好用。查询页面不应直接查event_store而是查投影表这就是读写模型分离。写入端只追加事件读取端通过投影提供订单列表、库存明细和财务报表天然形成CQRS的形态。代价是要额外维护一批投影更新任务。我通常用本地事务内的outbox模式事件表和outbox表一起提交再通过消费位点把事件推给投影服务按stream_id顺序更新投影表。这样实时查询、报表、数据对账都各用各的读模型互不干扰。4. 踩坑记录与排查实录4.1 建模过度抽象把所有概念都当资源第一个坑是刚接触REA时最常见的什么都往资源上套。团队里曾有开发把订单号建成资源理由是“它在流程中被生成和传递”还有人把仓库编号、优惠券模板、订单编号都归成资源模型瞬间膨胀了一倍。结果查询和统计时资源投影表被贴上各种无关属性检索效率反而下降。我后来把判断标准贴在了每个建模会议的墙上资源必须代表有经济价值、能被交换或转换的对象不是所有名词都值得建模成资源。标识符、编号、标签、配置项在其他层管理就好硬塞进资源层只会让模型失去解释力。其实资源层也真的需要适度抽象。比如在促销场景里“优惠券”可以被兑换、被核销它是资源但“优惠券模板”不是模板复制一百次也不会少。规则的边界在于模板是生产资源的模具而资源本身是参与流转的价值载体。建模时画一条线模具放配置产品放资源。这样团队讨论时就有共同语言不会为一个名词吵半天。4.2 事件粒度失控重建结果对不上账第二个坑是事件粒度问题。我们曾经把“客服修改收货地址”和“仓库按新地址重新发货”合并为一个事件理由是两者在系统里几乎同时完成。直到物流时效分析怎么都对不上拆开事件才发现改地址和重新发货之间实际隔了8个小时而且有一次地址改了但仓库根本没重新发货。从此我定了一条规则事件粒度必须对应一个完整的业务动作两个动作之间可能隔时间或者可能只发生其中一个那它们就是两个事件合并在一起就等于丢失中间状态。改需求时宁可多拆事件也不要图省事做一个“大事件”。如果已经踩了事件粒度过粗的坑补救的方式也简单新建一个细分事件类型未来事件按新粒度写入再对近三个月的历史事件做一次回溯拆分生成补偿事件。不要尝试修改旧事件旧事件保持原样新增一个拆分事件把语义补全。这套补偿机制在事件模型里可以安全运行这也是它比UPDATE大法优雅的地方。4.3 REA和复式记账的边界别混为一谈很多资料把REA和复式记账当成一回事这是个误区。复式记账是会计上的“凭证-科目-借贷”体系解决的是账务平衡和审计追踪REA是业务建模语言解决的是业务事实的表达。用REA建模后你可以只做单式事件流比如一个纯库存系统没人规定必须套复式记账反过来也可以从REA事件推导出借贷分录。我们做财务模块时的分工是业务域用REA建模财务域在投影层架一层复式分录两边通过事件ID关联。比如一条PaymentReceived事件在复式记账层自动生成“借货币资金贷应收账款”的分录两边永远对得上不会各记各的账。另一个相关坑是直接在REA事件表上强行套复式记账的借贷字段。那样做等于给两种不同抽象级别的东西焊死在一起后续改业务规则时借贷分录逻辑会和事件创建逻辑互相纠缠。正确姿势是事件层保持中性只在财务投影层生成分录。4.4 报表查询太慢往往是投影没做好有一次报表查询超时我差点让DBA去调数据库配置冷静下来排查后发现查询是直接扫描当月所有事件再在应用层过滤统计。几十万条事件当然慢。问题不在事件存储而是读取路径没有设计好。把实时查询改成每日增量任务维护日报投影后查询从扫描几十万条事件变成读一行汇总用时从天级别降到毫秒级。排查这种问题时正确的顺序是先看投影表有没有被增量更新、消费位点是否落后再怀疑数据库性能。盲目的加索引、加缓存都只是治标。再分享一个真实的疑难案例某月库存报表差12件商品怎么都查不出来。最后把当月的所有库存事件和上一月快照逐条对比发现有一批退货事件没有关联原销售订单号导致退货入库和后续的再出库事件在投影时被归到了不同的资源主体下一进一出没有抵消。修复方法是给退货事件补上original_order_id字段再对历史事件做一次补偿投影。这类问题在传统模型里通常表现为“库存永远对不上但不知道从哪一天开始差的”而事件模型的价值在于你能把时间线拉出来精确定位到某一天、某一批事件然后一次性修复。4.5 什么业务值得用REA什么业务别硬用最后给个判断建议。如果这个业务的正确性可以容忍“算错一分钱、库存差一箱”那REA对你就是负优化因为事件模型的成熟度要求、投影维护成本、读写分离复杂度都会抵消它带来的好处。如果系统崩溃后必须能从流水重建真实业务状态比如资金、库存、会员资产、订单履约那REA模型几乎是在帮你避免未来无数个对账和审计的夜晚。CMS内容管理、纯CRUD后台、报表展示系统都不适合。试点也很关键第一个项目别拿整个核心链路开刀选积分、退货单、优惠券这类边缘模块跑通“事件存储投影”的管道再逐步外推。补充一个更实用的判断方式下一次产品提需求时如果第一个念头是“这个要改哪张表的哪个字段”说明你还在数据结构层面思考如果第一个念头是“这个要新增哪种业务事件”说明你已经具备REA的建模直觉。前者不是错但在强业务系统里后者的维护成本明显更低。我第一次完整应用REA是在一个退货系统上。当时团队为“退货单到底算不算订单的一种状态”争论了两周用REA建模之后这个问题直接消失了退货不是状态而是一组事件——ReturnRequested、ReturnApproved、GoodsReturned、RefundIssued。事件一拆角色和规则都清楚了。后来的需求迭代速度明显变快产品经理再提需求大家讨论的不是“要不要加状态字段”而是“要加哪种事件”这个变化比任何性能报表都更让我确信REA的价值。最后再留一条实操建议找同事拿手里最乱的那个业务模块先把动词圈出来把资源标出来把今天散落在状态字段里的业务事实一个个挖出来用笔写在白板上不要急着建表。按四步走一遍你大概率会得到一个比现在清晰得多的模型。
阅读完成 · 觉得有帮助?
咨询建站