☰
订单系统重构:REA建模用资源、事件、主体还原业务真相
2026/10/21 13:26:38 网站建设 项目流程

年初接手一个跑了四年的订单系统,打开库表结构的时候我愣了一下:订单主表48个字段,光状态字段就分了7种语义,同一条记录既要表达支付、又要表达发货、还要表达售后。为了查清楚“这笔订单为什么变成这样”,只能去翻操作日志,靠时间轴拼凑真相。后来我们给这次重构起了一个代号:REA。起名的时候没什么讲究,但它后来被我们越用越正式——Resources、Events、Agents,资源、事件、主体。

REA最早是会计信息系统领域的一套建模思想,后来被企业架构和领域驱动设计圈子重新捡起来。说人话就是:不要先把数据库表设计出来,而是先把业务里“到底发生了什么”想清楚。每一个业务动作是一个事件,事件改变着资源,主体发起动作——模型贴着业务过程走,而不是贴着页面和报表走。我用它在订单、库存、账务三个系统上反复实践过,踩了不少坑。这篇文章就把那套思考方式、建模方法、落地细节和避坑记录一起交出来。

如果你正在做订单、库存、资金、会员积分这类强业务系统,或者你写实体类写到最后发现关系越来越拧巴、查状态只能靠猜,这篇文章适合你。哪怕你只是听说过CQRS和事件溯源、想找一个能落地的建模抓手,也可以从REA里获得一套完整的方法。

1. 先别建表:为什么一个48字段的订单表让我决定改用REA

1.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_id+version的唯一约束做乐观并发控制。同一个订单聚合并发追加事件时,只会有一个事务成功,另一个重试后重新读取当前版本再追加。聚合拆分上,我的习惯是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 = Map<string, 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的价值。

最后再留一条实操建议:找同事,拿手里最乱的那个业务模块,先把动词圈出来,把资源标出来,把今天散落在状态字段里的业务事实一个个挖出来,用笔写在白板上,不要急着建表。按四步走一遍,你大概率会得到一个比现在清晰得多的模型。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询