旧系统重构复盘:用REA建模与事件溯源让账目可解释 1. 接手一个大半年前就该重构的旧系统我为什么把项目代号定为 rea内部代号 rea 的项目听起来像某个随口起的英文缩写实际上它是 REA 建模思想的一套落地尝试。REA 全称 Resource-Event-Agent翻译过来就是“资源-事件-代理”这不是某个开源框架也不是某个中间件而是一种把业务现实抽象成领域模型的思路。我这次要重构的是一家企业内部的进销存加财务核算模块原来那套三张表也就是订单、库存、资金各自为战的记账方式已经让对账成了玄学。先说一个很典型的场景。仓库今天发出 100 件商品物流系统显示发货成功但业务系统里的库存结存数少了 98 件财务系统里对应的应收金额又因为在途损耗打了折扣三个系统的报表拉出来各说各话。老同事们为了这 2 件差异查了整整一个下午最后发现是一张退货单在某个节点被覆盖写入了。这种问题不是某个人的操作失误而是模型本身没有把“发生了什么”这件事讲清楚。数据库里塞满了“订单、出库单、入库单、应收单”这类单据但单据之间靠外键关联字段层层冗余一个状态字段可能在十几个地方被更新任何一个地方漏同步后面的数据就全歪了。我在做技术选型的时候本来想继续沿用老的“单据中心”思路把它缝缝补补再撑几年。但越看越觉得问题的根源是建模视角错了。单据中心模型关注的是“这张单子现在处于什么状态”本质上是一个静态快照而业务本身是连续的、可回溯的从订货、到货、入库、售卖、出库、收款每一个环节都改变了某种资源的存量或归属。所以我决定不再用“订单”“入库单”这类业务单据作为核心实体而是用 REA 的视角把业务拆成“资源、事件、代理”三样东西让每笔业务都变成一条可审计、可重放的事件流。下面这篇文章就是这次重构过程的完整复盘。1.1 让三套账对不上的不是 bug是建模视角传统业务系统里订单、库存、资金是三套相对独立的子模块每个模块都有自己的主表和状态机。比如销售模块维护一张销售订单表订单里有商品、数量、单价、金额、状态库存模块维护一张库存流水表记录每次出入库财务模块再维护应收应付和凭证。它们之间靠什么连接呢靠各种各样的“单据编号”和“业务类型”字段。这种设计在数据量小、业务简单的时候没问题但一旦有退货、换货、折价、预收、冲销这些操作问题就来了。订单改了状态库存那边没人通知财务那边不知道于是三个模块的字段开始互相矛盾。更麻烦的是当两个模块需要同时更新时事务边界很难画。老系统里一次“销售出库”操作可能要更新订单表、库存表、流水表、应收表四处更新分属四个服务用分布式事务硬扛结果就是性能差、容错差还经常出现部分成功部分失败。REA 把“单据”这个概念弱化把“事件”变成唯一可靠的事实来源。所谓事件就是一次真实发生的业务行为它一定改变了某种资源也一定涉及某些代理。比如“发了一笔货”是一个事件“收到一笔钱”是另一个事件“把仓库里的商品盘点数量修正”也是一个事件。每个事件产生一条记录这条记录自带时间、参与方、影响资源、数量变化其他模块需要数据时只从事件里推导而不是从另一个模块的“状态字段”里拿。这个思路和应用开发里常见的事件溯源很像但 REA 更像是一套领域语言它帮你回答“哪些业务行为值得建模成事件”“资源该怎么分类”“参与方该承担什么角色”。它不规定你用什么存储、什么消息队列它是一种建模框架和事件溯源配合起来非常顺手即使不用事件溯源只用普通关系型数据库也能收获大部分收益。1.2 rea 的目标不是追求新潮而是找回账目的可解释性在做 rea 项目之前我受邀帮好几家类似规模的企业看过同样的问题。几乎所有团队的第一反应都是要不要上个更智能的对账工具要不要引入主数据管理平台这些当然有用但都属于治标。对账工具再强也只是在矛盾发生之后做调和主数据再统一也改变不了交易发生时没有留下完整轨迹的缺陷。rea 项目的目标就三条。第一任何一笔业务都能回放出“资源从哪来、到哪去、变多少”的完整链路第二库存、资金、订单的计算结果必须能从同一份事件数据中独立推导互不依赖第三遇到规则调整或历史差错时可以在不改动历史事件的前提下通过新增补偿事件完成修正。这三点做到之后对账就不再是核验两个模块的瞬间状态是否一致而是验证两条事件链的末端输出是否匹配差异在哪一个环节产生一目了然。这套思路看起来很适配任何企业级系统但真正落地的时候细节比想象复杂得多。我花了大概两周时间做建模又花了四周把核心链路跑通中间踩了很多坑包括资源粒度怎么定、事件和代理的权限边界怎么处理、补偿事件会不会把事情越改越乱。下面就从建模开始一步步展开。2. 资源、事件、代理到底怎么拆我在 rea 里用的建模标尺在开始写代码以前我和团队成员反复对齐了一个问题什么样的事实才配称为“事件”这个判断直接决定了系统的复杂度和可追溯性。如果随便一个状态改变都建事件系统会被闲杂字段塞满如果漏掉关键操作又回到老系统那种信息黑洞的状态。我最后定下的规则有三个第一事件必须直接改变某个资源的数量、价值或归属第二事件必须有一个可追溯的时间点和操作代理第三事件发生之后系统的状态就应该因它而永久变化哪怕这个变化后来被另一个事件推翻原事件本身也不能消失。2.1 三要素的划分重点在“资源”的粒度资源在 REA 里指经济资源也就是企业能支配的、有价值的东西。它不只是库存商品还包括仓库容量、现金、银行存款、应收账款、应付账款、固定资产、会员积分甚至某种使用权。建模时最容易犯的错是把资源定义得太粗或太细。太粗比如把“所有库存商品”当成一个资源就没法回答“某个批次的具体商品还剩多少”太细比如按“商品批次仓位效期”建资源资源表会膨胀到几十万行每次事件都要锁定大量资源行性能很难看。在 rea 项目里我采用的粒度是“资源维度分组”商品主数据一个维度、库存组织一个维度、批次效期一个维度但它们不是各自独立的资源实例而是组合成一个资源标识。也就是说表结构里会有一张资源定义表记录资源类型和基础属性再有一张资源实例与维度值的关联表。这样既保留了按维度查询的能力又不会让资源表的行数失控。再往下事件分为资源流入事件和资源流出事件。流入包括采购入库、生产完工入库、退货入库、库存盘盈、收到退款流出包括销售出库、生产领料、报废、盘亏、支付退款。每个事件至少要关联一个流入或流出的资源流资源流里记录商品标识、数量、单价、金额。这样每一行资源流都能回答“这笔事件让哪个资源增加了多少”或“减少了多少”。2.2 代理不是用户表的别名代理指参与事件的人、部门或组织角色。它可以是客户、供应商、仓库管理员、销售员、承运商、门店。最需要区分的是“自然人”和“组织角色”。同一个员工张三既可能是仓管员发起出库也可能是销售员发起订单取消这两个动作如果不分角色审计时就说不清权限边界。我在 rea 里设了一张代理表包含代理类型和关联的业务对象标识另外设了一张代理角色表把代理和角色做多对多关联。事件表里记录的是“代理”和“角色”两个字段而不是简单的一个操作人 ID。这个设计一开始看起来有点重但在权限管理、业绩归属、审计追溯的时候非常好用。比如要查“所有由仓管角色发起的非正常出库事件”一条 SQL 就能过滤出来而不用从操作日志里猜。2.3 用 REA 视角重看一次完整销售履约一目了然的资源流下面用 rea 里最典型的一条链路——商品从采购到销售回款——来演示资源、事件、代理如何协作。我不画复杂的架构图用文字和表格把这个闭环理清楚。宏观上一条完整链路包含采购事件从供应商请求入库资源流为“商品资源 库存资源”代理为采购员和供应商。入库事件仓库确认收货资源流入仓库实收数量可能和订单有差异。库存调整事件商品在库产生损耗或盘亏资源减少代理为仓管员。销售出库事件客户订购并确认发货商品资源流出同时产生应收款资源。收款事件收到客户付款现金资源流入应收资源减少。每一环都是事件每一环都有参与代理和资源变化。传统系统里这些信息散落在订单表、出入库表、应收表中在 rea 里它们变成了一张统一事件表加若干资源流子表。想要知道“某一时刻某商品有多少可用库存”就从这个商品所有库存流入事件和流出事件的差里去算。业务行为事件类型流入资源流出资源主要代理采购下单契约登记无无采购员、供应商到货入库库存流入商品库存无仓管员、供应商仓库盘点库存修正商品库存可选仓管员销售确认契约登记无无销售员、客户出库发货库存流出无商品库存仓管员、物流商客户付款资金流入现金应收账款财务、客户货款核销资金流出无现金财务这张表强化了“契约登记”和“资源变更事件”的区别。很多人会把“订单确认”当成库存变动的依据其实订单只是一个约定真正的库存变化发生在出库发货的那一刻。在 rea 模型里这一步分离是体系的核心书面约定与事实发生分开建模模型不会因为一条未发货的订单就提前扣减库存。3. 从模型到代码rea 核心领域的落地结构和关键实现模型说得再漂亮也要落到字段和接口上。我在“模拟项目X”里没有使用重量级框架只用了纯 Java 加 Spring Boot领域层完全独立不依赖任何基础设施代码。这样做的原因是想让 REA 的核心规则可以和存储、接口、报表解耦后续即使换数据库或换消息中间件领域逻辑也不受影响。3.1 五个最小实体定义领域里最关键的五张实体表是事件主表、资源流子表、代理表、资源定义表、资源维度关联表。事件主表是唯一的事实来源资源流子表挂在事件下每个资源流记录一种资源的变化量。代理表和资源定义表则提供维度和参与者上下问。用 Java 表达的最小实体大概是这个样子Entity Table(name rea_event) public class ReaEvent { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long eventId; private String eventType; // 枚举采购入库、销售出库、盘点、收款、调整 private LocalDateTime occurredAt; // 业务发生时间 private LocalDateTime recordedAt; // 系统记账时间 private String agentCode; // 主代理 private String roleCode; // 代理在该事件中承担的角色 OneToMany(mappedBy event) private ListReaResourceFlow flows new ArrayList(); }Entity Table(name rea_resource_flow) public class ReaResourceFlow { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long flowId; private String resourceCode; // 资源标识 private BigDecimal quantity; // 变动数量正数流入、负数流出 private BigDecimal unitPrice; // 单价 private BigDecimal amount; // 金额 private Long warehouseId; // 维度仓库 private Long batchNo; // 维度批次 private String reversalFlowId; // 如果本行是冲销记录原流水ID }这种结构最直观的好处是业务逻辑只需要围绕“记录事件”和“根据事件计算资源余量”两个核心入口展开不再有几十个状态字段需要同步维护。在“模拟项目X”的数据库里我删掉了原来十几张单据表换成了上面这套结构整体表数量反而减少了不少。3.2 事件记录服务防止库存不足与重复提交的核心实现事件记录服务的核心是两件事校验数量和保证幂等。校验数量听起来简单实际操作起来要很小心。REA 的校验对象不是“某个商品当前库存是否大于零”而是“在指定资源维度组合上事件发生后的预计余量是否不小于安全库存和负库存上限”。这个维度组合在代码里的含义是同一商品、同一仓库、同一批次才算同一个资源。Transactional public Long recordOutboundEvent(ReaEvent event) { if (eventRepository.existsByExternalBizNo(event.getExternalBizNo())) { throw new DuplicateEventException(这条外部业务单已经记录过事件); } ListReaResourceFlow flows event.getFlows(); for (ReaResourceFlow flow : flows) { BigDecimal currentQty stockService.getCurrentQuantity( flow.getResourceCode(), flow.getWarehouseId(), flow.getBatchNo() ); BigDecimal after currentQty.add(flow.getQuantity()); if (after.compareTo(flow.getMinLimit()) 0) { throw new InsufficientResourceException(资源余量不足以支撑本次流出); } } eventRepository.save(event); for (ReaResourceFlow flow : flows) { stockService.changeQuantity(flow, true); } return event.getEventId(); }这里最大坑点是“先查再扣”的并发问题。如果是单机部署用事务锁就能解决但在多实例部署时必须依靠数据库行锁或乐观锁。我对资源流子表加了 unique 约束对 event 表加了外部业务单号的唯一索引同时在库存更新语句里做了条件更新确保两笔并发事件不会同时扣减同一份库存。上线压测时并发量在 500 每秒的情况下没有出现超卖这套方案在模拟项目X上是稳的。3.3 我为什么坚持不直接用“事件溯源性存储”很多朋友看到 REA 和事件流就会想到事件溯源、CQRS 甚至最终一致性的完整体系。我在 rea 项目里没有一步到位而是先用关系型数据库存事件理由有两个第一团队之前没有事件溯源的经验强制引入会带来很大的认知和运维成本第二REA 带来的核心收益主要是模型的清晰度和数据可追踪性这些用普通表加唯一约束已经能获得八成。如果后续业务量真的变大要按事件重放结算再引入专门的事件存储或消息队列也不晚。表结构里我特意保留了 event_id 的自增连续性和 occurred_at/recorded_at 两个时间字段将来如果要迁移到可靠的事件总线这些字段可以直接映射不会出现数据断层。4. 资源量、资金量和业务量的事件链联动我把对账变成推导rea 最让我满意的地方是把过去需要跑批对账的工作变成了一套纯推导逻辑。没有对账程序只要事件完整任何两个模块的结存差异都能精确指向某一笔事件。4.1 双时点设计业务发生时间和系统记账时间不能混在老系统里每个单据只有一个“创建时间”可这个时间既代表业务发生时刻也代表写入数据库的时刻。问题是业务发生和系统录入往往有延迟比如仓库晚上补录白天的发货单或者财务系统在月末批量补录上一周的收款。如果每天按时间跑库存快照口径就会乱掉。在 rea 里我强制所有事件同时保存 occurred_at 和 recorded_at。业务报表、库存快照、财务日结全部按 occurred_at 来计算而审计追踪、系统排障、追溯某个操作什么时候录入则按 recorded_at 来查。这套双时间设计在最初看起来很繁琐但在月末结账、季度盘点、税务核查时发挥了巨大作用。以前对账要反复证明“这张单子到底是哪天发生的”现在只要看 occurred_at 一个字段就够了。4.2 从事件推导库存、应收、确认收入一条独立可跑的计算链路基于事件表我写了三个独立计算视图互不强依赖库存视按商品、仓库、批次聚合所有资源流数量算出实时库存。收入确认视根据“销售出库事件”的金额确认应确认的收入不依赖订单状态。应收视图根据“销售出库事件”的应收金额减去“收款事件”的核销金额算出客户还欠多少。这三个视图的代码几乎一模一样核心就是一个“按维度分组、按事件时间过滤、汇总资源流数量金额”的 SQL 或流式计算。任何修正操作比如补一笔折扣、补一笔退款都通过新增事件实现而不是改动历史事件。这样财务、销售、仓库看数据时只要同意“事件是唯一事实”就不用再搞三个口径的报表。我还给每笔资源流加了“冲销标识”字段也就是当某笔事件要回滚时我不会删掉原事件而是新增一笔方向相反的资源流并在这行标记上关联的原 flowId。这样当财务做审计时可以顺着 flowId 从“冲销流”一路追到“原始流”整个过程完全透明。4.3 最容易忽略的补偿事件边界冲销到什么程度才不会影响统计冲销不是简单的“加个负数行”就完事。举个例子某客户买了一箱货系统记录销售出库事件库存减少、应收增加。后来客户退了一半我用一笔负方向资源流做部分冲销这时候如果另一套报表是用“销售出库事件的流水行数量”来算销售数量就会把退货也算进销售导致虚增。因此我在所有和业务统计相关的视图里都必须把“是否关联了冲销流”作为一个全局过滤条件。所有正向资源流必须排除“被冲销”或“有冲销子记录”的行才能进入统计口径。一开始我只在库存视图里做了这个过滤漏了销售统计视图结果上线第三天就出现了“销售额虚高”的误报后来补了很多规则才理顺。5. 踩过的坑和最终迭代出来的实践清单如果只让我说一个 rea 项目和其他重构最大的区别那就是它逼着团队重新思考了“什么才算终极真相”。旧系统把终极真相分散在十几张单据状态里rea 把终极真相集中在事件流里。这个改变不仅影响代码更影响日常协作和排障方式。5.1 事件表会不会变成性能瓶颈我的取舍方案很多同事一开始担心把所有业务都记到一张 event 表里将来行数会很大。当时我给的估计是一年约 2000 万行事件主表加上资源流明细表约 4000 万行。这个量级在关系数据库里并不可怕但要保证稳定查询必须做三件事按月份分区、按资源维度建联合索引、把只读报表库和大事务库分离。我把实际负责写事件流的核心服务库和负责查询统计的只读库分开主库只处理事件记录报表库通过订阅或定时同步的方式拉取数据并建宽表。这样一来主库的事务压力很小查询库的 SQL 再怎么复杂也不影响线上业务。5.2 代理与权限的纠缠同一个用户在不同事件里拥有不同角色rea 的代理表刚上线时被权限系统开发同事吐槽过“过度设计”。但后来做“数据可见范围”时代理表帮了大忙。以前权限系统只能控制“用户可以操作哪些菜单”现在可以直接控制“用户可以查看哪些代理角色对应的事件”。例如审计员只能看收货和调账事件销售主管只能看不含成本价格的销售出库事件不需要在菜单上做各种奇怪的前端判断。代理表加上角色字段天然构成了一条权限过滤链路。5.3 快照和重放什么时候需要真正跑一遍历史事件REA 不强制要求事件重放但为了应对“某个历史规则变了”的场景我在 rea 里做了一个轻量重放器。它会选择一个截止时间点把该时点之前的所有事件重新应用到资源维度上计算一个新的快照。当时有个真实需求是“把三年前的某批商品的单位成本核算规则改掉重新计算当时毛利”这种需求在旧系统里几乎是不可能完成的任务因为所有状态已经被固化。在 rea 里我只需要在重放器里加载新规则从某个历史快照开始重放新毛利结果就能很快跑出来而且可以逐笔核对。当然重放不是无条件的。我限制重放器只能覆盖特定资源类型不能全局重放否则一次误操作可能覆盖大量上下文。实际使用中重放器也只在季度结算和特殊审计时用平时业务系统坚持“事件不变、只能新增补偿”的原则。5.4 给准备转 REA 的团队的四个提醒如果看完上面的复盘你也想在下一套系统里尝试 REA我给出四个操作性提醒先做业务词汇表再做字段表。把资源、事件、代理的边界用业务语言写清和业务方达成一致后再动手设计数据库否则事件类型会越建越多。区分“契约”与“事实”。采购订单、销售合同是契约不是事件。契约可以变可以废但事件一旦发生就是事实。建模时千万不要拿订单主表当事实表。把所有事件的写入入口收敛到单一服务。尽量不要让多个模块各自写 event 表否则很难保证事件类型的统一定义和幂等控制。对“冲销”设业务审批权限。冲销事件可以修正错误也可以被恶意利用。在 rea 里冲销事件必须关联原事件并且只能由具备专门角色的代理执行。我自己在 rea 项目里最大的体会是重构之后最显著的变化也许不在技术指标上而在团队日常讨论问题时的用词上。大家不再说“查一下订单状态”而是说“去事件流里看看这笔资源是怎么变的”不再争论“这个数据应该以库存为准还是财务为准”而是说“这需要补一笔什么事件才能对齐”。这种领域语言的改变才是 REA 真正沉淀下来的资产。后续如果要把 rea 推广到更多业务线我打算先从主数据收敛和事件类型标准化开始再考虑引入完整的可追溯分析平台。目前这套模型在“模拟项目X”的进销存、库存管理、财务核算三个核心域已经稳定运行后面准备扩展的订单中心也沿用了同一组资源流接口迁移成本比我预想低得多。