微服务架构设计模式-第六章
第6章 使用事件溯源开发业务逻辑
6.1 使用事件溯源开发业务逻辑概述
事件溯源是构建业务逻辑和持久化聚合的另一种选择。它将聚合以一系列事件的方式持久化保存。每个事件代表聚合的一次状态变化。应用程序通过重放(replaying)事件来重新创建聚合的当前状态。
事件溯源:使用一系列表示状态更改的领域事件来持久化聚合
好处:
- 保留了聚合的历史记录,对实现审计和监管非常有帮助
- 可靠地发布领域事件
弊端:
- 有一定的学习曲线,是一种完全不同的业务逻辑开发方式
6.1.1 传统持久化技术的问题

弊端:
- 对象与关系的阻抗失调(impedance mismatch)
- 缺乏聚合历史
- 实施审计功能将非常繁琐且容易出错
- 事件发布凌驾于业务逻辑之上
对象与关系的阻抗失调
关系型数据的表格结构模式,与领域模型及其复杂关系的图状结构之间,存在基本的概念不匹配问题。
缺乏聚合的历史
传统持久化的另一个限制是它只存储聚合的当前状态。聚合更新后,其先前的状态将丢失。如果应用程序必须保留聚合的历史记录(可能出于监管目的),那么开发人员必须自己实现此机制。实现该机制是非常耗时的一项工作,其中还会涉及复制那些必须与业务逻辑保持同步的代码。
实施审计功能将非常繁琐且容易出错
实施审计的挑战在于,除了这是一项耗时的工作之外,负责记录审计日志的代码可能会和业务逻辑代码发生偏离,从而导致各种错误。
事件发布是凌驾于业务逻辑之上
传统持久化的另一个限制是它通常不支持发布领域事件
6.1.2 什么是事件溯源
事件溯源是一种以事件为中心的技术,用于实现业务逻辑和聚合的持久化。聚合作为一系列事件存储在数据库中。每个事件代表聚合的状态变化。聚合的业务逻辑围绕生成和使用这些事件的要求而构建。
事件溯源通过事件来持久化聚合
事件溯源采用基于领域事件的概念来实现聚合的持久化,将每个聚合持久化为数据库中的一系列事件,我们称之为事件存储。

当应用程序创建或更新聚合时,它会将聚合发出的事件插入到events表中。应用程序通过从事件存储中检索并重放事件来加载聚合。具体包含三个步骤:
- 加载聚合的事件
- 使用其默认构造函数创建聚合实例
- 调用apply()方法遍历事件
Class aggregateClass = ...;
Aggregate aggregate = aggregateClass.newInstance();
for(Event event : events){
aggregate = aggregate.applyEvent(event);
}
// use aggregate..
事件代表状态的改变
使用事件溯源时,事件不再是可有可无的。包括创建在内的每一个聚合状态变化,都由领域事件表示。每当聚合的状态发生变化时,它必须发出一个事件。
而且,事件中必须包含聚合执行状态变化所需的数据。聚合的状态由构成聚合对象的字段值组成。(例如order shipped事件,包含很少的数据或几乎没有数据,只表示状态转换。apply()方法通过将order的状态字段更改为shipped来处理order shipped事件。但是,其他事件包含大量数据。例如orderCreated事件必须包含apply()方法初始化订单所需的所有数据,包括其订单项、付款信息、交付信息等。由于使用事件完成聚合的持久化,因此你不能够再发布只包含orderId的简化orderCreated事件)
聚合方法都和事件相关
业务逻辑通过调用聚合根上的命令方法(command method)来处理对聚合的更新请求。在传统的应用程序中,命令方法通常慧验证其参数,然后更新一个或多个聚合字段。基于事件溯源的应用程序中的命令方法则通过生成事件来起作用。

生成事件并应用(apply)事件的做法将导致对业务逻辑的重构。事件溯源将命令方法重构为2个或更多方法。第一个方法接收命令对象参数,该参数表示具体的请求,并确定需要执行哪些状态更改。它验证命令对象的参数,并且在不更改聚合状态的情况下,返回表示状态更改的事件列表。如果无法执行该命令,则此方法通常会引发异常。
事件溯源把更新聚合的一个方法分解为多个方法:process() 方法接收命令并返回事件列表;一个或多个apply() 方法,它们接收事件作为输入,并更新聚合。
创建聚合的步骤如下:
- 使用聚合的默认构造函数实例化聚合根
- 调用process()以生成新事件
- 遍历新生成的事件并调用 apply() 来更新聚合的状态
- 将新事件保存在事件存储库中
更新聚合的步骤如下:
- 从事件存储库加载聚合事件
- 使用其默认构造函数实例化聚合根
- 遍历加载的事件,并在聚合根上调用 apply() 方法
- 调用其 process() 方法以生成新事件
- 遍历新生成的事件并调用 apply() 来更新聚合的状态
- 将新事件保存在事件存储库中
6.1.3 使用乐观锁处理并发更新
事件存储库也可以使用乐观锁来处理并发更新。每个聚合实例都有一个与事件一起读取的版本号。当应用程序插入事件时,事件存储会验证版本是否未更改。一种简单的方法是使用事件数作为版本号。
6.1.4 事件溯源和发布事件
我们需要实现一种机制,将这些持久化保存的事件传递给所以感兴趣的消费者。第三章描述了几种不同的机制,例如轮询和事务日志拖尾,这些机制可以把插入到数据库中的消息作为事务一部分的对外发布。主要区别在于它将事件永久存储在events表中,而不是暂时保存。
使用轮询发布事件

如果事件存储在 events 表中,则事件发布方可以通过执行 select 语句轮询 events 表,以查找新事件,并将事件发布到消息代理。这种做法的挑战在于如何确定哪些事件是新事件。问题在于事务可以按照与生成事件不同的顺序提交。因此,事件发布方可能会意外跳过事件。
解决方法是向 events 表添加一个额外的列,以跟踪事件是否已发布。
-- 查询未发布的事件
select * from events where published = 0 order by event_id asc;
-- 将事件发布到消息代理
-- 将事件标记为已发布
update events set published = 1 where event_id in ()
使用事务日志拖尾技术来可靠地发布事件
许多成熟和复杂的事件存储库会使用事务日志拖尾技术发布时间。从数据库事务日志中读取插入 events 表的事件,并将它们发布到消息代理。
6.1.5 使用快照提升性能
在一个order聚合的生命周期中往往不会发生很多次状态转换,因此它只有少量事件。查询事件存储库,找到这些事件并重新构建 order 聚合是可行的。但是,长生命周期的聚合可能会有大量事件。例如,Account 聚合可能包含大量事件。随着时间的推移、加载、重放这些事件会变得越来越低效。
场景的解决方案是定期持久保存聚合状态的快照。

使用快照时,将从快照创建聚合实例,而不是使用其默认构造函数创建实例。如果聚合具有简单、易于串行化的结构,则快照可以使用JSON序列化。复杂结构的聚合可以使用Memento模式(备忘录设计模式)进行快照。
6.1.6 幂等方式的消息处理
基于关系型数据库事件存储库的幂等消息处理
把消息ID 插入processed_messages 表,作为插入 events 表的事件的事务的一部分。
基于非关系型数据库事件存储库的幂等消息处理
基于noSQL的事件存储库的事务模式往往功能有限,必须使用不同的机制来实现幂等消息处理。
解决方案:消息消费者把消息的ID存储在处理它时生成的事件中。它通过验证聚合的所有事件中是否包含该消息ID来做重复检测。
挑战:处理一些消息时可能不会生成任何事件。缺少事件意味着没有处理过消息的记录。随后重新传递给和重新处理相同的消息可能会导致错误的行为。
避免此问题的一种方法是始终发布事件。如何聚合不发出事件,则应用程序仅保存记录消息ID的伪事件。事件接收方必须忽略这些伪事件。
6.1.7 领域事件的演化
事件结构的演化
- 由一个或多个聚合组成
- 定义每个聚合发出的事件
- 定义事件的结构

通过向上转换(upcasting)来管理结构的变化
数据库结构变化(flyway)
处理非向后兼容的更改:从事件存储库加载事件时执行转换。将各个事件从旧版本更新为新版本。
6.1.8 事件溯源的好处
- 可靠地发布领域事件
- 保留聚合的历史
- 最大限度地避免对象与关系的 "阻抗失调" 问题
- 为开发者提供一个 "时光机"
6.1.9 事件溯源的弊端
- 这类编程模式有一定的学习曲线
- 基于消息的传递的应用程序的复杂性
- 处理事件的演化有一定难度
- 删除数据存在一定难度
- 每个用户有一个加密密钥,存储在单独的数据库表库中。当用户申请删除时,将加密密钥删除,用户的个人信息也被有效删除
- 查询事件存储库非常有挑战性
6.2 实现事件存储库
专用事件存储库:
Event Store (EventStoreDB)
Event Store,通常指其核心产品 EventStoreDB,是一个专门为事件溯源(Event Sourcing)而设计的开源流式数据库。与传统数据库不同,它将数据存储为一系列不可变的事件流,使得审计、回溯和系统重建变得非常自然。它本身是一个独立的数据库产品,为构建事件驱动系统提供了最底层的持久化存储支持。
Lagom
Lagom 是一个反应式微服务框架,其名字源自瑞典语,意为“恰到好处”。它由 Lightbend 公司(Akka 和 Play Framework 的创造者)开发,旨在帮助开发者构建具有弹性和可扩展性的微服务系统。Lagom 本身是一个开发框架,它集成了事件溯源和 CQRS 模式,但同时也提供了对传统持久化方式的支持。
Axon Framework
Axon Framework 是一个专注于事件溯源和 CQRS(命令查询职责分离) 模式的 Java 框架。它提供了一整套构建块,帮助开发者创建可扩展、可维护的事件驱动微服务系统。Axon 在 Java 生态系统中非常流行,并且是少数长期保持 Apache 2.0 开源许可的框架之一。它同样是一个开发框架,为应用层提供了设计模式和基础设施支持。
- 官网: https://www.axoniq.io
- GitHub (Axon Framework):
Eventuate
Eventuate 是一个用于开发事务性微服务的平台,同样基于事件溯源和 CQRS 模式。它的主要特点是帮助解决微服务架构中棘手的分布式数据一致性问题,而无需使用两阶段提交(2PC)这种复杂的分布式事务方案。Eventuate 也是一个开发平台/框架,为处理跨服务的最终一致性提供了解决方案。
- 官网: https://eventuate.io
- GitHub (展示页): https://github.com/api-evangelist/eventuate (注:此链接为项目展示页,并非官方主仓库)
总结表格
| 名称 | 网址 |
|---|---|
| Event Store (EventStoreDB) | 官网: https://www.eventstore.com GitHub: https://github.com/EventStore/EventStore |
| Lagom | 官网: https://www.lagomframework.com GitHub: https://github.com/lagom/lagom |
| Axon Framework | 官网: https://www.axoniq.io GitHub (新): https://github.com/AxonIQ/AxonFramework |
| Eventuate | 官网: https://eventuate.io GitHub (展示页): https://github.com/api-evangelist/eventuate |
6.2.1 Eventuate Local事件存储库的工作原理
6.2.2 Eventuate的Java客户端框架
6.3 同时使用Saga和事件溯源
6.3.1 使用事件溯源实现协同式Saga
把事件溯源和协同式Saga相结合会产生很好的协同作用。事件溯源代码提供了Saga所需的机制,包括基于消息传递的进程间通信、消息去重,以及原子化状态更新和消息发送。尽管简单,协同式Saga也有一些弊端。
使用事件进行Saga协同的问题在于,在这样的场景下,事件体现出了双重目的。事件溯源使用事件来表示状态更改,但是使用事件实现Saga协同,需要聚合即使没有状态更改也必须发出事件。例如,如果更新聚合会违反业务规则,则聚合必须发出事件以报告错误。更糟糕的问题是当Saga参与方无法创建聚合时。没有会发布错误事件的聚合。
由于存在这些问题,最好使用编排式来实现复杂的Saga。
6.3.2 创建编排式Saga
Saga编排器由服务的方法创建。服务的方法,例如OrderService.createOrder(),会执行两项操作:创建或更新聚合,并创建Saga编排器。该服务必须以保证方法的第一个和第二个操作会在同一个事务的范围内完成,也就是说,如果第一个操作执行成功了,必须确保第二个操作也要执行成功。服务如何确保这一点,取决于它使用的事件存储库的类型。
当关系型数据库作为事件存储库时,应该如何创建Saga编排器
在同一个ACID事务中更新事件存储库并创建saga编排器。
当非关系型数据库作为事件存储库时,应该如何创建Saga编排器
无法以原子方式更新事件存储库并创建saga编排器。服务必须具有一个事件处理程序,该事件处理程序将创建saga编排器拉响应聚合发出的领域事件。
编写创建Saga编排器的事件处理程序时,要注意的一个问题是它必须处理重复事件。至少一次消息传递意味着可以多次调用创建Saga的事件处理程序。确保只创建一个Saga实例非常重要。
一种直接的方法是从事件的唯一属性中导出Saga的ID。有几种不同的选择。一种是使用发出事件的聚合的ID作为Saga的ID。这适用于为响应聚合创建事件而创建的Saga。
另一种选择是使用事件ID作为SagaID。因为事件ID是唯一的,所以这将保证SagaID是唯一的。如果事件是重复的,则事件处理程序尝试创建Saga将失败,因为该ID已存在。当给定聚合实例存在同一Saga的多个实例时,此方法很有用。

6.3.3 实现基于事件溯源的Saga参与方
命令式消息的幂等处理
要解决的第一个问题是基于事件溯源的Saga参与方如何检测和丢弃重复消息,以实现命令式消息的幂等处理。幸运的是,使用前面描述的幂等消息处理机制解决这个问题很容易。Saga参与方在处理消息时生成的事件中记录消息ID。在更新聚合之前,Saga参与方通过在事件中查找消息ID来验证它之前是否处理过该消息。
以原子方式发生回复消息
要解决的第二个问题是基于事件溯源的Saga参与方如何以原子方式发送回复消息。原则上,一个Saga编排器可以订阅聚合所发出的事件,但这种方法存在两个问题。首先,Saga命令可能不会实际上改变聚合的状态。在这种情况下,聚合不会发出事件,因此不会向Saga编排器发送回复消息。第二个问题是这种方法需要Saga编排器区别处理使用事件溯源的Saga参与方与不使用事件溯源的Saga参与方。这是因为要想接收领域事件,除了自己的回复通道之外,Saga编排器还必须订阅聚合的事件通道。
一个更好的方法是让Saga参与方继续向Saga编排器的回复通道发送回复消息。但Saga参与方不是直接发送回复消息,而是使用如下两步过程:
- 当Saga命令处理程序创建或更新聚合时,它会安排将SagaReplyRequested伪事件与聚合发出的实际事件一起保存在事件存储库中。
- SagaReplyRequested伪事件的事件处理程序使用事件中包含的数据构造回复消息,然后将其写入Saga编排器的回复通道。
6.3.4 实现基于事件溯源的Saga编排器
实现saga编排器必须解决以下三个关键设计问题:
- 如何持久化一个saga编排器?
- 如何以原子方式更改编排器的状态并发送命令式消息?
- 如何确保saga编排器只处理一次回复消息?
使用事件溯源持久化saga编排器
一个Saga编排器的生命周期非常简单。首先,它被创建,然后它被更新,用以响应来自Saga参与方的回复。因此,我们可以使用以下事件持久化Saga
- SagaOrchestratorCreated: Saga编排器已创建。
- SagaOrchestratorUpdated::Saga编排器已更新。
可靠地发生命令式消息
关键是持久化SagaCommandEvent,它表示要发送的命令。然后,事件处理程序订阅SagaCommandEvents并将每个命令式消息发送到适当的通道。
Saga编排器使用两步过程发送命令:
- 一个Saga编排器为它想要发送的每个命令发出一个SagaCommandEvent。
SagaCommandEvent包含发送命令所需的所有数据,例如目标通道和命令对象。这些事件
存储在事件存储库中。 - 事件处理程序处理这些SagaCommandEvents并将命令式消息发送到目标消息通道。这种两步法可确保命令至少发送一次。
由于事件存储库保证至少一次成功的消息传递,因此可能会使用同一事件多次调用事件处理程序。这将导致SagaCommandEvents的事件处理程序发送重复的命令式消息。幸运的是,Saga参与方可以使用以下机制轻松检测并丢弃重复命令。保证唯一的SagaCommandEvent的ID被用作命令式消息的ID。因此,重复的消息将具有相同的ID。接收重复命令式消息的Saga参与者将使用前面描述的机制丢弃它。
确保只处理一次回复消息
Saga编排器还需要检测并丢弃重复的回复消息,它可以使用前面描述的机制来完成。编排器将回复消息的ID存储在处理回复时发出的事件中。然后,它可以轻松地确定消息是否重复。第7章 在微服务架构中实现查询

浙公网安备 33010602011771号