大家好,我是十三。
开篇:从聚合内部到聚合之间
上一篇文章我们聊了 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.InboundCompleted、Finance.InvoiceVerified、Sales.OrderConfirmed。这种命名方式让消费方一眼就知道事件来自哪个领域、代表什么动作。
correlationId 是跨模块追踪的关键。从采购订单创建开始,系统生成一个 correlationId,之后每一个环节发布的事件都带着同一个 ID。当流程出现问题时,用这个 ID 就能串联起所有相关事件,快速定位问题发生在哪一步。
payload 只放事实,不放结论。事件里应该写"入库了 1000 件",而不是写"应该触发暂估"。具体怎么响应事件,由消费模块自己决定。这样发布方和消费方才能保持解耦。
事件设计的三个原则
除了字段结构,事件的内容设计也有讲究。
原则一:事件描述事实,不发出命令。 事件名应该是"库存已入库",而不是"请财务记账"。前者描述事实,可以被任意消费方按自己的逻辑处理;后者是命令,把发布方和消费方绑死了。
原则二:事件要完整,但不要过度冗余。 payload 里应包含消费方处理所需的全部关键信息,避免消费方再回头查数据库。但也不能把所有字段都塞进去,否则事件体积膨胀,传输效率会下降。
原则三:事件一旦发布,不可变。 事件代表已经发生的业务事实,事实不能改写。如果发现之前发的事件有误,正确做法是发布一个新事件来修正,而不是修改已发出的事件。这和会计里"红字冲销"的思路是一致的。
总结
今天我们聊了事件驱动架构在 ERP 系统中的应用。
EDA 的核心价值不是技术炫技,而是用事件解耦模块,让每个业务域可以独立演进。采购模块不需要知道财务模块怎么记账,库存模块不需要知道销售模块怎么定价。大家通过事件公告板各取所需。
事件溯源让系统拥有了完整的历史记忆,不再只存储最终结果。最终一致性是对业务现实的尊重,绝大多数跨模块协作不需要实时同步。 Saga 模式给长流程提供了失败时的补偿机制,让复杂业务流程既能往前走,也能在出错时体面地回退。
如果你正在设计或改造一套业财系统,我建议从一张事件目录表开始:列出每个核心业务动作完成后应该发布什么事件,哪些模块会消费它。这张表本身就是模块边界的最好注脚。
下一篇文章,我们将进入《进销存与业财一体化》系列的另一个重要主题:主数据管理。当各个模块通过事件协作时,它们如何对"同一个客户、同一个商品"达成共识?这就是主数据管理要回答的问题。
往期回顾
- 业财通识31:DDD 战术设计——聚合根、实体与值对象
- 业财通识25:业财对账——打通业务与财务的最后一公里
- 业财通识24:银行对账——银行流水与账本的逐笔核对
- 业财通识23:应付对账——管住每一笔该付的钱
- 业财通识22:应收对账——确保每一笔钱都收回来
- 业财通识21:库存成本计价——FIFO、加权平均与标准成本
- 业财通识20:安全库存——科学补货的数学基础
- 业财通识19:可用库存——为什么账上有货却不能卖
- 业财通识18:调拨——多仓协同的物流调度
- 业财通识17:盘点——账实一致的最后防线
- 业财通识16:出库——从销售发货到领料消耗
- 业财通识15:入库——四种场景下的库存增加
- 业财通识14:应收账款——从开票到回款的风险管控
- 业财通识13:价格策略——多维定价与动态调整
- 业财通识12:一个客户,为什么会有三条记录?——CRM 作为主数据底座的三层模型
- 业财通识11:从开票到收款,企业如何收回每一分钱?
- 业财通识10:当货物发出,系统里发生了什么?
- 业财通识09:订单确认前,系统如何防止坏账风险?
- 业财通识08:企业赚钱的第一步,从"潜在客户"到"销售合同"
- 业财通识07:业财难点之"暂估入账"与冲销
- 业财通识06:什么是采购在途?它对库存预测的价值
- 业财通识05:商品世界的基石——SPU与SKU
- 业财通识04:万事俱备,如何优雅地"打款"给供应商?
- 业财通识03:收到供应商账单,能直接付款吗?
- 业财通识02:当货物上门,系统里发生了什么?
- 业财通识01:企业花钱的第一步,从"购物清单"到"法律合同"
关于十三Tech
资深服务端研发工程师、架构师、AI 编程实践者。
专注分享真实的技术实践经验,持续记录企业系统、架构设计与 AI 编程实践。