news 2026/10/21 4:09:58

REA模型实战:用资源-事件-代理者重构订单结算系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
REA模型实战:用资源-事件-代理者重构订单结算系统

收拾旧系统这件事,最让我崩溃的往往不是并发写得烂、SQL写得乱,而是整个系统的“语言”出了问题。业务方说“我想看这单赚了多少钱”,研发打开库表,看到的全是“借:应收账款”“贷:主营业务收入”,然后回答“你等我会计同事翻译一下”。这种体验我相信做过订单、库存、结算系统的兄弟都不陌生。后来我在重构某内部订单系统的时候,被一个老前辈提醒了一句:你试着用 REA 模型重新画一遍领域模型,别老盯着借和贷。就是这句话,让我把整套系统的核心逻辑重写了一遍,收益非常大。

这篇文章我想把 REA 模型(Resources-Events-Agents,资源-事件-代理者)掰开揉碎讲清楚。它不是什么新框架、新语言,而是一套“看业务”的建模视角,特别适合在领域驱动设计、财务系统重构、订单库存一体化的场景里当你重新梳理核心模型的思考支架。无论你是后端开发、架构师,还是想优化系统设计的技术负责人,这篇文章应该都能给你一些可以落地的东西。

1. 借和贷并没有错,只是把业务语义洗掉了

1.1 一张借贷流水表背后被折叠的业务逻辑

绝大多数公司的核心交易系统,底层都有一张“分录流水表”,字段无非是记账日期、科目编码、借方金额、贷方金额、摘要、凭证号。这套东西的好处是会计学上百年的沉淀,借贷必相等,怎么都不会算错。但对于研发团队来说,这是灾难。

举个例子。用户下了一单,付了款,发了货,最后申请退款,我们退了 80% 的钱。在这个过程里,业务上清清楚楚发生了四个动作:下单、支付、发货、部分退款。可在借贷流水表里,这四个动作被压成了七八条分录,每条都是“借:银行存款,贷:预收账款”“借:应收账款,贷:主营业务收入”。一旦你想回答“这单的真实毛利是多少”,你需要在代码里把分录按科目重新拼装回业务动作,这个还原过程既容易出错,又极难维护。

我在重构某订单系统时做过一次统计,一张订单在借贷模型里平均会产生 9 到 14 条分录,而业务真正关心的动作只有 4 到 6 个。多出来的部分都是借贷规则派生出来的中间态。也就是说,借贷模型把“业务事件”这个最宝贵的信息给折叠了。

1.2 为什么很多团队选择硬编码而不是换模型

有人会问,那为什么不直接在系统里同时存一份业务事件表,再存一份借贷流水表?答案是大部分团队确实这么干了,但业务事件表往往设计得很潦草,字段就一个“类型”、一个“备注”,根本没有建模。时间一长,业务事件表退化成日志表,所有真实逻辑还是靠借贷流水上的硬编码判断来支撑。

这就是典型的“双轨不同步”。我见过最离谱的情况是:负责订单的同事不知道退款单里有一堆借贷分录,负责财务的同事不知道系统里还有一张业务事件表。两拨人维护着同一个系统的两个侧面,却互相认为对方才是“脏数据”的来源。

问题的本质不是借贷模型不好,而是领域层缺少一种“用业务语言表达业务”的建模方式。REA 要解决的,恰好是这个位置的问题。

2. 从咖啡店场景看懂资源、事件、代理者三个构件

REA 模型最早是在会计信息系统研究里被提出的,核心想法很简单:不要用“借/贷”这种记账符号去描述企业活动,改用“资源”“事件”“代理者”三样东西组合描述业务的真实经过。我一般喜欢用咖啡店来给新同事解释这套模型,今天我们也不妨从这个场景进入。

2.1 资源:能被交换、消耗、产出的东西

资源是系统里经济价值的载体。在咖啡店场景里,咖啡豆、牛奶、一次性纸杯、现金、微信零钱余额,甚至顾客买走的咖啡,都是资源。判断一个东西是不是资源,有一个很简单的标准:它能不能被消耗、被交换、被生产出来,而且它有存量概念。

很多人在建模时会混淆“资源”和“事件”。比如“库存”是资源,“入库”是事件;“账户余额”是资源,“支付”是事件。“资源”描述的是一个时点的状态,而“事件”描述的是一个时段内发生的变化。REA 的第一条建模规则就是——凡是表达“变化”的,都应该建模成事件;凡是表达“存量”的,都应该建模成资源。

2.2 事件:业务系统真正发生的原子事实

事件是整个 REA 模型的核心。守住一条底线:一条事件记录,必须能对应业务上一个不可再分的事实,带时间戳、带参与人、带影响到的资源。咖啡店里,顾客“支付 5 元”是一个事件,“把 5 元放进收款机”是同一个事件的资源侧描述,不应该拆成两个事件。

把事件定义到多细,非常考验建模功力。只定义“顾客消费”这一个事件,粒度太粗,你没法区分是支付咖啡还是购买糕点;但把“顾客用手指触碰收款机屏幕”定义成一个事件,粒度又太细,那不是业务事实,是操作日志。我自己的经验是:把“业务方会在对账/报表里单独查询”的那个动作当作事件的最小单位。业务方会说“我要查今天的每一笔退款”,但不会说“我要查今天每一笔退款审核操作”,那“退款”就是事件,“审核操作”就不是。

2.3 代理者:谁来参与,谁对事件负责

代理者就是参与到事件里的人或组织。咖啡店里,顾客是代理者,收银员是代理者,供货商也是代理者。注意,代理者并不总是“人”,也可以是系统、部门、门店等。关键是每个事件至少要有两个参与方,因为一个经济事件本质上是“一方交出一部分资源,另一方获得一部分资源”。

很多团队第一次建模 REA 时会忽略“参与方”这一项,只记录了“谁付了钱”,却漏掉“谁收了钱”。等到做绩效分析、门店对账的时候,才发现缺少收银员这个维度,只能从日志里硬抠,非常被动。

2.4 两个核心关联关系:资源流向与参与关系

理解了三个构件,REA 还有两条关系线把它们串起来。资源与事件之间,是“流量关系”(stockflow)。举个例子:支付事件导致“现金”资源流入(增加),同时导致“顾客钱包余额”资源流出(减少)。事件与代理者之间,是“参与关系”(participation)。顾客、收银员各自以不同身份参与同一笔支付事件。

这两条关系线是最容易被误解的。我见过有人画图时直接把“顾客”连到“咖啡”,然后说这是 REA 模型,这是不对的——顾客和顾客资源之间不能直接发生关系,必须通过事件中转。事件才是资源和代理者之间的桥梁,这也是 REA 模型天然适合事件溯源架构的根本原因。

3. 用REA把订单结算系统重新画一遍模型

概念讲再多,不如来一个完整的推导。下面我用一个我实际重构过影像的案例:某图书商城的订单结算系统,逐步展示如何从业务描述推导出 REA 领域模型。这个过程里我不会直接给出最终类图,而是把思考路径复现出来,你跟着走一遍,自己也能对别的系统建模。

3.1 从存量业务文档里圈出候选事件清单

第一步永远是列事件。把业务方日常挂在嘴边的动词都列出来:下单、支付、发货、收货、申请退款、确认退款、退回商品、补发商品、调价。然后逐个问:“这是不是一个不可再分的业务事实?”

下单其实可以拆成“提交订单”和“确认订单”,但项目里业务方只关心“订单确认后库存被锁定”,所以我把“提交订单”作为事件,“确认订单”被剥离出去当作状态机动作。支付可以拆“发起支付”“支付成功回调”“退款到账”,其中支付成功回调才是对账用的核心事件,其余两个是流程动作。最终我圈出了六个核心事件:下单、支付成功、发货、收货、退款申请、退款完成。

这一步的产出物很简单——一张表格,每行一个候选事件,备注字段写“业务方会在什么场景提到这件事”。如果某个动词业务方从来没单独查询过,就不要让它成为事件。

3.2 用三个问题确定资源与代理者边界

确定完事件,再倒推资源和代理者。我会固定用三个问题来问自己:

  • 这个事件让什么资源变多了或者变少了?
  • 如果存量资源选错了,报表会不会对不上?
  • 代理者维度是否足够支撑组织架构与权限边界?

在图书商城场景里,支付成功事件让“账面可用余额”增加,让“顾客钱包余额”减少,同时让“待支付订单”这个虚拟资源消失。发货事件让“库存数量”减少,让“在途订单”这个虚拟资源增加。收货事件让“在途订单”减少,让“顾客已收货订单”增加。这里有个容易踩的坑:不要把“订单状态”设计成资源。订单状态是多个事件累计产生的结果,是派生值,不是存量,将它建模成资源会导致事件流里出现大量垃圾事件。

代理者这边我确定了五个维度:顾客、门店、客服、仓库、系统。特别注意“系统”也要作为代理者记录,因为支付回调消息的发送方是支付网关,它不是人在操作,但它对事件负责。

3.3 最终的REA模型:事件表、资源表、代理表如何关联

最终落地的领域模型结构非常简洁。事件表economic_event是整个系统的核心表,它不直接存金额变化的科目,而是通过类型字段区分支付、发货等不同事件,再通过关联行把“资源增减明细”和“代理者参与明细”挂到事件下。

我给出一个骨架级的 Java 领域对象设计,仅供参考:

public abstract class EconomicEvent { Long eventId; String eventType; // PAYMENT, DELIVERY, REFUND... ZonedDateTime occurredAt; List<StockflowItem> flows; // 资源增减明细 List<Participation> participations; // 代理者参与明细 } public class StockflowItem { Resource resource; // 涉及哪个资源 ResourceDirection direction; // INFLOW 还是 OUTFLOW BigDecimal quantity; // 增减数量 BigDecimal unitValue; // 单价,用于算金额 } public class Participation { Agent agent; // 代理者:顾客、门店、仓库… String role; // PAYER, RECEIVER, OPERATOR... } public class Resource { Long resourceId; String resourceType; // CASH, INVENTORY, COUPON... String resourceName; }

这样设计之后,一条“支付成功”事件会这样落地:事件类型是PAYMENT,flows 里包含一条现金资源流入、一条顾客账户余额流出,participations 里包含顾客(付费方)与支付网关(代收方)。业务流程的变化一目了然,完全不需要借贷符号。

这里我特别提一点:不要把 unitValue 和总金额塞进同一个字段。REA 模型里,quantity 乘以 unitValue 等于总金额是业务规则,代码里应该显示表达。很多系统偷懒只存总金额,到后面做价格分析、折扣统计时,必须用另一个表反查单价,非常折腾。

4. 事件流怎么落库,余额怎么实时重算

4.1 REA 和事件溯源是天生一对

领域模型定了,接下来就是工程落地。REA 模型最理想的存储形态就是事件溯源:不保存“当前状态”,保存一串完整事件,当前状态随时可以从事件流里重放得到。把 REA 和事件溯源放在一起,原因很简单——REA 的“事件”和事件溯源的“事件”是同一个东西,都是不可变的、已发生的业务事实。

我见过有人为了事件溯源强行把系统切成基于命令的架构,结果终态数据和事件流双写,对不上账。其实 REA 模型不需要引入那么重的中间件。我在图书商城项目里用最普通的关系数据库就实现了事件溯源:一张economic_event表保存事件元数据,一张event_flow_item表保存资源流明细,一张event_participation表保存参与明细。

4.2 事件表与快照表:聚合根怎么选

事件溯源落地时,最关键的工程决策是聚合根的粒度。订单结算系统里,我以“订单号”作为聚合根,每个订单对应一组事件序列。查询某个订单当前的全部状态时,从库里加载该订单的全部事件,按顺序重放即可。

如果订单量大,每个订单事件有几十条,全量重放性能会很难看。我的做法是:每 100 个事件打一个快照。快照记录重放到该位置时的资源存量计算结果,新请求进来时,先加载最近快照,再重放快照之后的小于 100 条事件。实测下来,单个订单的状态查询耗时从几十毫秒降到个位数毫秒。快照表结构很简单:order_id, version, resource_snapshot_json, created_at。

下面给一个简化版的读取流程伪代码:

OrderSnapshot snap = loadLatestSnapshot(orderId); List<EconomicEvent> events = loadEventsAfterVersion(orderId, snap.version); OrderState state = reconstructFromSnapshot(snap); for (EconomicEvent e : events) { state.apply(e); } return state;

核心只在一句话上:状态不是保存出来的,是算出来的。这也是 REA 模型的思想在工程层面最直接的体现。

4.3 余额查询不走全表扫:分区、物化视图与账期

真实系统里,事件表的增长速度非常可怕。图书商城上线半年后,经济事件表就有三亿多行。为了避免查询爆炸,我做了两个层面的事情。

第一个层面是去除一次性事件。有一次我排查慢查询,发现最耗时的 SQL 是在event_flow_item上按资源 ID 查累计流出量。这个查询本身无法避免,但发现大部分聚合运算都在统计“某资源月末余额”,而这种查询其实可以走物化视图,每天定时汇总一次,比实时扫全表快得多。

第二个层面是分账期。业务上天然存在“本期发生”“上期结余”这种概念。我把事件表按照发生月份做成分区表,每次对账查询默认限定在当前月份分区,历史数据走按月物化汇总。分区字段就用occurred_at,既符合查询习惯,又能利用数据库的分区裁剪,成本极低。

5. 老账本翻新:存量借贷数据迁移 REA 的完整路径

如果你和我一样,是从一个已运行三五年、有大量历史借贷流水的老系统开始迁移,最关心的反而是:历史数据怎么办?我的答案是:不要追求一次性完全替换,采用“双轨并行 + 增量迁移 + 定期对账”的方式,让新旧系统共存一两个账期,直到确认无条件后再切换。

5.1 先别急着动表:借贷分录到经济事件的映射规则

迁移的第一步不是写脚本导数据,而是和业务方、财务同事一起定义映射规则。比如“借:应收账款,贷:主营业务收入”这条分录,在 REA 模型里其实对应一个“销售确认”事件,它让“应收账款”资源增加,同时让“主营业务收入”资源增加(如果你们建模了收入资源的话)。类似地,“借:银行存款,贷:应收账款”对应“收款”事件,它是两个资源之间的流量,不产生新事件。

映射规则看起来简单,但我在实际项目中翻过车:把“信用减值损失”这种调整分录也硬映射成事件,结果造成统计口径不一致。后来我把映射规则限定为三类——只处理实际经济事件的确认(销售、采购、收款、付款、领用、退回),凡是“会计估计变更”“减值计提”这类调整项,不进事件流,只在报表层手动处理。

5.2 存量数据转换,最怕“维度过早收缩”

设计历史数据迁移脚本时,有个高频问题:老系统里同一笔订单涉及多家门店、多个仓库,借贷流水却只记到一个成本中心上。直接把这张表当成事件源转换,迁移出来的 REA 事件天然少了一个代理者维度。

解决思路是:先找到“业务原始单据明细表”,以单据明细为转换入口,而不是直接以借贷流水为入口。老系统里订单表、发货单表、支付流水表肯定还都在,我们就从这些表反推经济事件,再拿借贷流水做校验。转换脚本大致流程是:一次性读取所有业务单据,按单据生成候选事件,然后用借贷流水做交叉验证,确认科目方向无误后写入新事件表。

我在迁移时用了一个关联查询:

-- 伪代码示意:从订单/支付流水表生成经济事件 INSERT INTO economic_event (event_type, occurred_at, order_id, ...) SELECT 'PAYMENT', pay.paid_at, o.order_id, ... FROM payment_log pay JOIN orders o ON o.order_id = pay.order_id LEFT JOIN economic_event ee ON ee.order_id = o.order_id AND ee.event_type = 'PAYMENT' WHERE ee.event_id IS NULL;

这个“left join 找缺失”的写法是迁移中最常用的防重技巧,每次跑批只增量处理未转换为事件的历史单据,保证幂等。

5.3 对账闭环:用“资源守恒”替代“借贷平衡”

新老系统并行期间,必须有一套双方都认可的对账逻辑。借贷模型的对账当然是借贷必相等,而 REA 模型的对账方式换了一种表达:任何资源的期初存量 + 本期流入 - 本期流出 = 期末存量。

这句话本质上和借贷平衡是一回事,但它更贴近业务。系统里写对账逻辑时,我直接用资源存量表和事件流水表做汇总比对。那段时间每个月跑一次对账,两边数字一致以后,我心里才算真正踏实。

6. 哪些坑我帮你踩过了:REA 的反模式与边界

任何建模思路都有自己的适应边界。REA 模型用了两年多,我遇到的最大坑有三类,这里一并列出,希望你别再走一遍。

6.1 事件粒度太细,把一次支付拆成一百个事件

有次评审代码,发现同事把“支付成功”拆成了“支付请求创建”“支付请求成功(同步)”“支付回调确认”“支付完成入账”四个事件。理由是需要区分每一小步的状态。我当时直接否了:业务方不会去查“支付请求创建”,他们只会查“用户付没付钱”。拆得太细,只会让事件流变成状态机日志,重放开销大,查询还复杂。

判断事件粒度的唯一标准还是那句话:业务方会不会单独对这件事做统计、对账或追溯。不会,就不要让它成为一个事件。

6.2 把派生状态设计成事件和资源

另一个容易犯的错来自“想让系统更灵活”。有人把“库存不足”也做成了一个事件,还想把“订单状态”建模成资源去查询。这些看起来是“灵活”,实际在 REA 里是毒瘤,因为它们不是经济事项,只是运算结果。事件流里混入派生状态,重放时会遇到极大的麻烦:你根本不知道库存不足是在哪一个业务环节算出来的,上下文已经丢了。

正确的做法是:派生状态一律不落事件流,从资源与事件的存量关系实时计算(必要时加物化视图)。事件流里只放“事实”,不放“结论”。

6.3 强合规场景不要完全抛弃借贷

必须说清楚,REA 模型适合做业务中台、订单结算、进销存这样的“业务域建模”,但它不能也不应该替代财务会计面向合规场景的总账模型。财务上需要凭证、需要科目、需要借贷平衡,那是法律与审计的要求。REA 模型可以把你从借贷分录里解放出来,让业务层用业务语言沟通,但最后到了总账环节,还是要有一套从事件聚合出借贷凭证的逻辑。

这两者不是互相替代的关系,而是上下游的关系:REA 事件层负责业务表达,借贷总账层负责合规输出。认清楚这个边界,你就不会在“要不要全公司都在用 REA 建模”这种问题上纠结太久。

REA 模型不是银弹,它是一套帮你把“业务语言”和“系统语言”对齐的思考方式。我实际用了两年下来最深的体会是:它最大的价值不是让代码变少,而是让业务方、产品、研发在评审需求时,能站在同一张图前讨论“这个事件到底要不要发生一次”,而不是各自对着字段列表猜。如果你正在为订单、结算、库存一类系统梳理核心模型,我建议你找一块白板,把你当前系统的核心流程,按“谁在什么时候把什么资源交给谁”重画一遍,你会发现很多纠缠不清的逻辑,瞬间就清晰了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 4:09:57

Kettle表输出卡死怎么办?从JDBC参数到锁等待的完整排查指南

哪个跑过 Kettle&#xff08;现在叫 Pentaho Data Integration&#xff09;的人&#xff0c;没在“表输入”和“表输出”这两个步骤上遇到过卡死&#xff1f;我印象很深的一次&#xff0c;某数据平台跑夜间同步任务&#xff0c;作业一启动就卡在表输出上&#xff0c;界面进度条…

作者头像 李华
网站建设 2026/10/21 4:09:46

Ubuntu 上安装 virt-manager:图形化工具轻松管理 KVM 虚拟机

在 Ubuntu 上折腾虚拟化的时候&#xff0c;virt-manager 是我绕不开的一个图形化工具。它把 QEMU/KVM 这套底层能力收拢到一个可交互的窗口里&#xff0c;创建、启动、看控制台、做快照&#xff0c;都不用在终端里敲一大堆参数&#xff1b;装好之后点几下就能把虚拟机跑起来。它…

作者头像 李华
网站建设 2026/10/21 4:09:40

Redis源码编译与生产配置深度指南

1. 为什么今天还要花时间重学Redis——从“能跑通”到“敢上线”的认知断层很多人第一次接触Redis&#xff0c;是在某个教程里敲下docker run -d -p 6379:6379 redis&#xff0c;然后用redis-cli连上去输入SET hello world&#xff0c;看到OK就以为“学会了”。我带过的某高校实…

作者头像 李华
网站建设 2026/10/21 4:09:11

Agent Skills实战:从技能设计到编排避坑,让大模型稳定干活

1. 从"会聊天"到"会干活"&#xff1a;Agent Skills 到底解决什么问题近半年我一直在折腾各类智能体项目&#xff0c;最深的感受是&#xff1a;模型本身的能力已经够强了&#xff0c;真正拉开体验差距的反而是那些"看起来不起眼"的技能定义。同样…

作者头像 李华
网站建设 2026/10/21 4:08:36

WOA优化LSTM超参数:多特征回归预测的自动调参实战

很多做时序预测和回归拟合的朋友应该都有这种体会&#xff1a;模型结果不理想&#xff0c;第一反应是换网络结构&#xff0c;折腾半天发现提升有限&#xff0c;回头一查&#xff0c;问题往往出在超参数上。LSTM的学习率、隐含层神经元数量、批大小这些参数&#xff0c;每个都对…

作者头像 李华
网站建设 2026/10/21 4:07:47

YOLOv10石油泄漏检测系统实战:从数据标注到UI界面部署全流程解析

石油泄漏检测这种项目&#xff0c;我在接手之前一直觉得就是个标准的“目标检测套壳”活&#xff0c;把YOLO模型跑起来、拖个界面上去&#xff0c;不就完事了。但真做完这个基于YOLOv10的检测系统之后&#xff0c;我才发现事情远没有这么简单。这类场景最大的坑不在模型结构&am…

作者头像 李华