system column十三Tech
← 返回业财专栏
ERP

业财通识32:事件驱动架构——用事件解耦业务模块

2026/4/1420 min read
ERPEDA事件驱动架构Saga业财一体化十三Tech

大家好,我是十三。

开篇:从聚合内部到聚合之间

上一篇文章我们聊了 DDD 战术设计中的聚合根、实体与值对象。其中一个核心结论还记得吗:聚合内部的数据修改,通过领域事件来驱动状态流转。订单状态从"待审核"变成"已确认",本质上就是一次领域事件的发布与消费。

但这里有一个自然而然的追问:聚合内部可以用事件通信,那聚合与聚合之间、模块与模块之间呢?

采购模块下完订单,库存模块怎么知道该准备收货?销售模块确认出库,财务模块怎么知道该开发票?如果每个模块都直接调用另一个模块的接口,系统很快就会变成一张谁也理不清的蜘蛛网。

今天这篇文章,我们就来回答这个问题。答案是事件驱动架构(Event-Driven Architecture,简称 EDA)。它是让各个业务模块既能独立运转、又能协同配合的关键设计思想。

EDA 解决什么问题

模块直接调用的代价

想象一下,如果没有 EDA,采购模块想让财务模块做暂估入账,会怎么做?

最直接的方式是:采购模块直接调用财务模块的接口,说"我这批货入库了,你记一下账"。这种方式在系统早期看起来很省事,但随着业务增长,问题会迅速暴露。

问题一:耦合越来越紧。 采购模块的代码里,必须知道财务模块有哪些接口、参数是什么、怎么调用。一旦财务模块改了接口,采购模块也得跟着改。

问题二:故障会传染。 财务模块如果临时宕机,采购模块的入库流程就可能卡住。明明货已经上架了,系统却提示"入库失败",因为财务那边没响应。

问题三:流程越拖越长。 一个入库动作,要同步等库存更新、财务记账、质检记录、通知采购员。原本几秒钟能完成的操作,因为串行等待,变得越来越慢。

问题四:扩展困难。 当业务壮大后,入库不仅要触发财务记账,还要通知质检系统、更新供应商评分、发送到货提醒。如果采购模块直接调用所有这些接口,每增加一个消费方,发布方的代码就要改一次。发布方和消费方紧紧绑在一起,谁也独立不了。

EDA 的业务定义

事件驱动架构(EDA) 是一种让模块之间通过"事件"来协作的设计方式。它的核心思想很简单:当一个模块完成了某件事,它不需要知道谁会关心这件事,只需要把"这件事发生了"广播出去。感兴趣的模块自己去听,听到了就做自己该做的事。

用一个通俗的类比:这就像公司门口的公告板

行政部门贴了一张通知"会议室 A 已预订"。销售部看到了,知道下午的路演有地方了;保洁部看到了,知道要提前去打扫;IT 部看到了,知道要去调试投影。行政部门不需要挨个打电话通知每个人,大家各自关注自己关心的信息即可。

在系统里,"公告板"就是消息队列事件总线,"通知"就是事件

ERP 中的典型事件清单

理解了 EDA 的基本思想后,我们来看看在 ERP 系统里,到底有哪些典型的事件在流转。

其实,回顾我们这个系列前面的文章,事件一直都在,只是我们之前没有用这个视角去审视它们。

在第 2 篇文章里,货物入库后触发了暂估入账。在第 3 篇文章里,发票校验通过后触发了应付账款的生成。在第 11 篇文章里,销售出库后触发了应收账款和收入确认。这些"触发"的本质,都是事件的发布与消费。

下面这张表,梳理了 ERP 中最常见的跨模块事件:

事件名 来源模块 消费模块 触发条件
采购订单已创建 采购模块 库存模块 PR 审批通过后生成 PO
库存已入库 库存模块 财务模块 质检合格、上架完成
发票已校验 财务模块 采购模块 三单匹配通过
付款已完成 资金模块 财务模块 银行回单确认
销售订单已确认 销售模块 库存模块 信用检查通过
商品已出库 库存模块 财务模块 拣货发运完成
发票已开具 财务模块 销售模块 应收凭证生成
收款已到账 资金模块 财务模块 银行流水匹配

这张表还有一个规律:很多事件是一对多的。同一个"库存已入库"事件,财务模块做暂估入账,质检模块更新合格率,采购模块通知业务员。发布方只发一次,多个消费方各自独立处理。这是 EDA 相比直接调用的最大优势。

如果你在设计系统接口时感到困惑,可以先问自己:这个业务动作完成后,应该广播什么事件,哪些模块需要听到它?

以"库存已入库"事件为例。在第 7 篇文章里我们详细讨论过暂估入账——货物入库时发票还没到,财务需要先做暂估。这个流程的起点,就是库存模块发布了"库存已入库"事件,财务模块监听到后,判断"该入库单尚未关联发票",于是自动触发暂估入账。

如果没有这个事件机制,库存模块就必须直接调用财务模块的接口。一旦暂估规则变了(比如从"月末统一暂估"改成"入库即暂估"),两边的代码都要改。而有了事件机制,库存模块只管发事件,财务模块自己决定什么时候、用什么规则来处理。

事件溯源:记录变化,而非只记结果

讲完了事件怎么在模块间流转,我们再往深走一步:事件本身还能怎么被利用?

传统 CRUD 的局限

大多数系统记录数据的方式是 CRUD:创建、读取、更新、删除。用户的账户余额从 100 元变成 200 元,数据库里只存了最终值 200 元。中间经历了什么,系统不知道。

这种方式有两个隐患。

第一,无法回答"怎么变成这样的"。如果余额突然不对了,你只能看到结果错了,但看不到是哪一步操作导致的。是充值成功了但通知没发,还是重复扣款了,还是人工后台修改了?无从得知。

第二,无法回溯和重做。如果某次更新是错误的,想恢复到之前的状态,只能依赖数据库备份,粒度粗、成本高。

两者的差异可以用一张表概括:

对比项 传统 CRUD 事件溯源
存储内容 当前状态 状态变化的历史事件
查询速度 直接读取,快 需 replay 计算,慢
历史追溯 只能看快照 完整历史,随时回放
规则变更 需改写历史数据 只需换 replay 规则
实现复杂度 简单 需维护事件日志和快照

事件溯源的通俗理解

事件溯源(Event Sourcing) 的核心思想是:系统不存储"当前状态",而是存储"导致状态变化的每一个事件"。需要当前状态时,把所有相关事件按顺序 replay(重演)一遍,就能算出来。

用一个生活类比:传统 CRUD 就像只记了银行卡的当前余额。事件溯源则像保留了每一笔交易明细——工资入账 +8,000 元、超市消费 -320 元、转账收入 +500 元。余额不是存出来的,是算出来的。

在 ERP 系统里,事件溯源尤其有价值。

比如库存成本。在第 21 篇文章里我们讨论过,库存成本的计算方式有先进先出、加权平均、标准成本等多种方法。如果用事件溯源,系统只需记录每次入库和出库的数量与单价,需要成本时按规则实时计算即可。切换成本方法时,不需要改写历史数据,只需要换一种 replay 规则。

再比如订单状态。传统方式在数据库里存一个 status 字段:待审核、已确认、已发货、已完结。但如果订单被回退了,status 从"已发货"变回"已确认",系统就丢失了"曾经发货过"这个信息。用事件溯源,系统记录的是:订单创建、订单确认、商品出库、发货回退。无论状态怎么变,历史永远完整保留。

当然,事件溯源也有代价:查询当前状态需要计算,不能直接读一张表。工程上的折中方案通常是"事件日志 + 物化视图"——既保留完整的事件历史,又维护一张可随时查询的快照表。

最终一致性:为什么够用

事件驱动架构里有一个让很多初学者困惑的概念:最终一致性。我们先把它讲清楚。

什么叫最终一致

在传统的同步调用里,A 模块调用 B 模块,B 处理完返回结果,A 才能继续。这种方式保证的是强一致性:A 和 B 的状态在同一时刻绝对一致。

而在事件驱动架构里,A 发完事件就继续走自己的流程,B 可能晚几秒甚至几分钟才处理完。在这短暂的窗口期内,A 和 B 的状态是不一致的。但只要事件被可靠投递,B 最终一定会处理完,两边就会恢复一致。这就是最终一致性

为什么 ERP 不需要强一致

很多工程师第一次听到"不一致"会觉得不安:系统数据不一致,这还能用吗?

其实,在真实业务里,绝大多数场景接受最终一致就足够了。因为业务本身就不是"瞬时完成"的。

举个例子。在第 4 篇文章里,我们讨论了付款流程。发票校验通过后,系统生成应付账款,然后进入付款申请审批,审批通过后银行转账,最后银行返回回单确认付款完成。这个流程从"发票校验通过"到"付款完成",本身就可能跨度几天。在这几天里,"应付账款"和"银行存款"的状态天然就是不同步的,业务完全接受这种延迟。

再举一个更贴近日常的例子。在第 10 篇文章里,我们讨论过销售出库。仓库扫描完最后一箱货点击"确认出库",业务系统的库存数量立刻减少。但财务系统生成主营业务成本凭证、更新库存商品科目,通常是在日终批处理时统一完成。在这之间的几个小时里,两边的库存金额不一致,这是完全正常的业务节奏。

再比如第 25 篇文章讨论的业财对账。业务系统和财务系统的数据本来就不可能在每一毫秒都对齐——业务操作先发生,财务凭证后生成,这是正常的工作节奏。对账的目的,就是在一定周期后检查两边是否一致,而不是强求实时一致。

所以最终一致性不是"退而求其次",而是对业务现实的忠实反映。真正需要强一致的场景其实很少,比如库存扣减防止超卖、银行转账避免重复。这些场景通常发生在单个聚合内部,用数据库事务就能保证。跨模块的协作,最终一致是更自然、更可靠的选择。

Saga 模式:长事务的编排艺术

当一个大流程需要多个模块按顺序配合,而且每个步骤都可能失败时,我们就需要一种机制来管理这个"长事务"。Saga 模式 就是为此而生的。

Saga 的业务定义

Saga 是把一个长流程拆成多个本地事务(每个模块内部的事务),每个本地事务完成后发布事件,触发下一个本地事务。如果某一步失败了, Saga 会执行补偿操作,把前面已经完成的步骤撤销,确保整体流程要么全部成功,要么回到初始状态。

用通俗的话讲: Saga 就像一场精心编排的接力赛。第一棒跑完后把接力棒交给第二棒,第二棒交给第三棒。如果第三棒摔倒了,裁判会通知前面的人:请回到起跑线,这次成绩作废。

P2P 采购到付款的 Saga 示例

让我们以本系列最熟悉的 P2P 流程为例,看看 Saga 是怎么工作的。

这个流程在第 2、3、4、7 篇文章里分别讲过各个环节。现在我们把它们串成一个完整的 Saga:

graph LR
    A[采购入库] --> B[暂估入账]
    B --> C[发票校验]
    C --> D[冲销暂估]
    D --> E[正式入账]
    E --> F[付款完成]

第一步:采购入库。 仓库确认货物上架,库存模块完成本地事务,发布"库存已入库"事件。

第二步:暂估入账。 财务模块监听事件,检查发票是否已到。如果未到,生成暂估凭证。完成后发布"暂估已入账"事件。

第三步:发票校验。 供应商发票到达后,财务模块执行三单匹配(第 3 篇文章的核心逻辑)。匹配通过后发布"发票已校验"事件。

第四步:冲销暂估。 财务模块监听"发票已校验"事件,查找对应的暂估记录,生成红字冲销凭证。完成后发布"暂估已冲销"事件。

第五步:正式入账。 根据发票金额生成正式应付账款凭证。完成后发布"应付账款已确认"事件。

第六步:付款完成。 资金模块按账期执行付款,银行回单确认后,核销应付账款,发布"付款已完成"事件。

补偿机制:当某一步失败时

Saga 的精髓不在于"顺序执行",而在于"失败时怎么处理"。

假设第三步"发票校验"失败了——三单匹配发现数量对不上。此时前两步已经成功了:库存已经入库,暂估已经入账。 Saga 不能简单地把整个流程标记为"失败",因为库存不可能"退回去",暂估凭证也不能留在账上不管。

这时需要触发补偿操作

  • 暂估入账的补偿:暂时不需要撤销,因为暂估是合理的(货确实到了),只是发票有问题。等采购员和供应商协商一致后,重新触发发票校验即可。
  • 如果需要撤销:可以生成红字冲销凭证,把暂估撤销。库存本身不需要补偿,因为货物确实在仓库里。

再设想另一个失败场景:第五步"正式入账"完成后,第六步"付款"因银行账户余额不足而失败。前五步已经完成, Saga 不能撤销正式入账,因为应付账款已确认。正确的处理方式是:标记付款为"失败",通知资金部,补足余额后重新触发。这不是技术回滚,而是业务流程上的后续跟进。

Saga 的设计原则是:不是每一步都需要补偿,而是每一步都要想清楚"如果失败,前面已完成的步骤怎么办"

事件 Schema 设计

最后,我们来聊聊事件本身应该怎么设计。一个随意的事件格式,会让消费模块难以解析,也会让后续排查问题变得困难。

标准事件结构

一个规范的事件,至少应包含以下字段:

// 标准 ERP 事件结构
const event = {
  // 事件身份
  eventType: "Inventory.InboundCompleted",  // 事件类型,用领域.动作命名
  eventId: "evt-20260424-001",              // 全局唯一事件 ID
  eventTime: "2026-04-24T10:30:00+08:00",   // 事件发生时间

  // 事件来源
  source: "inventory-service",              // 发布事件的模块
  sourceEntity: "warehouse_entry",          // 来源实体类型
  sourceEntityId: "GRN-20260424-128",       // 来源实体 ID

  // 业务负载
  payload: {
    sku: "SKU-LIPSTICK-A001",
    quantity: 1000,
    warehouseCode: "WH-SH-01",
    poReference: "PO-20260420-056"
  },

  // 追踪信息
  correlationId: "corr-p2p-20260420-056",   // 业务链路追踪 ID
  version: "1.0"                            // 事件格式版本
};

这个结构有几点值得注意。

eventType 用"领域.动作"命名。比如 Inventory.InboundCompletedFinance.InvoiceVerifiedSales.OrderConfirmed。这种命名方式让消费方一眼就知道事件来自哪个领域、代表什么动作。

correlationId 是跨模块追踪的关键。从采购订单创建开始,系统生成一个 correlationId,之后每一个环节发布的事件都带着同一个 ID。当流程出现问题时,用这个 ID 就能串联起所有相关事件,快速定位问题发生在哪一步。

payload 只放事实,不放结论。事件里应该写"入库了 1000 件",而不是写"应该触发暂估"。具体怎么响应事件,由消费模块自己决定。这样发布方和消费方才能保持解耦。

事件设计的三个原则

除了字段结构,事件的内容设计也有讲究。

原则一:事件描述事实,不发出命令。 事件名应该是"库存已入库",而不是"请财务记账"。前者描述事实,可以被任意消费方按自己的逻辑处理;后者是命令,把发布方和消费方绑死了。

原则二:事件要完整,但不要过度冗余。 payload 里应包含消费方处理所需的全部关键信息,避免消费方再回头查数据库。但也不能把所有字段都塞进去,否则事件体积膨胀,传输效率会下降。

原则三:事件一旦发布,不可变。 事件代表已经发生的业务事实,事实不能改写。如果发现之前发的事件有误,正确做法是发布一个新事件来修正,而不是修改已发出的事件。这和会计里"红字冲销"的思路是一致的。

总结

今天我们聊了事件驱动架构在 ERP 系统中的应用。

EDA 的核心价值不是技术炫技,而是用事件解耦模块,让每个业务域可以独立演进。采购模块不需要知道财务模块怎么记账,库存模块不需要知道销售模块怎么定价。大家通过事件公告板各取所需。

事件溯源让系统拥有了完整的历史记忆,不再只存储最终结果。最终一致性是对业务现实的尊重,绝大多数跨模块协作不需要实时同步。 Saga 模式给长流程提供了失败时的补偿机制,让复杂业务流程既能往前走,也能在出错时体面地回退。

如果你正在设计或改造一套业财系统,我建议从一张事件目录表开始:列出每个核心业务动作完成后应该发布什么事件,哪些模块会消费它。这张表本身就是模块边界的最好注脚。

下一篇文章,我们将进入《进销存与业财一体化》系列的另一个重要主题:主数据管理。当各个模块通过事件协作时,它们如何对"同一个客户、同一个商品"达成共识?这就是主数据管理要回答的问题。


往期回顾


关于十三Tech

资深服务端研发工程师、架构师、AI 编程实践者。
专注分享真实的技术实践经验,持续记录企业系统、架构设计与 AI 编程实践。