本文将深入探讨事件溯源、CQRS(命令查询责任分离)、CDC(变更数据捕获)和 Outbox 模式。将清晰地阐述这些解决方案的价值。此外,还将详细解释两种不同的设计,并分析它们的优缺点。
那么,为什么所有这些解决方案都很重要呢?它们很重要,因为许多团队正在构建微服务并将数据分布在多个数据存储中。一个微服务系统可能涉及关系数据库、对象存储、内存缓存,甚至可搜索的数据索引。数据很容易丢失、不同步甚至损坏,从而对关键任务系统造成灾难性后果。
对于许多组织来说,有助于避免这些严重问题的解决方案至关重要。不幸的是,许多重要的解决方案有些难以理解;事件溯源、CQRS、CDC 和 Outbox 也不例外。请将这些解决方案视为学习和理解如何将其应用于您特定用例的机会。
正如您将在本文结尾处发现的那样,我将建议其中四个解决方案中有三个具有很高的价值,而另一个应尽可能避免使用,除非在极少数情况下。本文提供的建议应根据您的具体需求进行评估,因为在某些情况下,这四个解决方案都不适合。
响应式系统
快速回顾一下,事件溯源和变更数据捕获是可用于构建响应式分布式系统(即微服务)的解决方案。微服务应通过弹性、可伸缩性来应对不断变化的环境(即云)。实现这些能力的关键在于消息驱动和事件驱动。想了解更多,我建议您阅读《响应式宣言》。
图 1. 根据《响应式宣言》,响应式系统的属性
事件溯源和变更数据捕获的共同目标
本文介绍的两个核心解决方案是事件溯源和变更数据捕获。在正式介绍这两个解决方案之前,需要知道它们服务于相似的目标,即:
-
将一个数据存储指定为特定数据集的全局事实来源
-
以一系列事件(也称为日志或事务日志)的形式提供过去和现在的应用程序状态的表示
-
提供一个日志,可以根据需要重放事件,以重建或刷新状态
事件溯源使用其自身的日志作为事实来源,而变更数据捕获依赖于底层数据库事务日志作为事实来源。这一差异对软件的设计和实现有着重大影响,将在本文稍后介绍。
领域事件 vs. 变更事件
在深入研究之前,区分事件溯源和变更数据捕获所关心的事件类型很重要。
-
领域事件 — 应用程序生成的、属于您的业务域的显式事件。这些事件通常用过去时表示,例如 OrderPlaced(订单已放置)或 ItemShipped(物品已发货)。这些事件是事件溯源的主要关注点。
-
变更事件 — 从数据库事务日志生成的事件,指示发生了何种状态转换。这些事件是变更数据捕获所关心的。
领域事件和变更事件没有关联,除非某个变更事件碰巧包含一个领域事件,这是稍后将在文章中介绍的 Outbox 模式的前提。
既然我们已经确定了事件溯源和变更数据捕获的一些共同点,就可以更深入地探讨了。
事件溯源
事件溯源是一种解决方案,它允许软件将其状态维护为领域事件的日志。因此,取走整个日志就代表了应用程序的当前状态。拥有此日志还能够方便地审计历史记录,并能够“时光倒流”并重现由先前状态引起的问题。
事件溯源实现通常具有这些特征:
-
从应用程序业务逻辑生成的领域事件将为您的应用程序添加新状态
-
应用程序的状态通过一个追加式事件日志(日志)更新,该日志通常是不可变的
-
日志被认为是应用程序生命周期的事实来源
-
日志可重放,以便在任何时间点重建应用程序的状态
-
日志按 ID 对领域事件进行分组,以捕获对象的当前状态(DDD 术语中的 Aggregate)
图 2. 事件溯源具体化对象的表示
此外,事件溯源实现通常具有这些特征:
-
用于日志的快照机制,以加快重建应用程序状态的速度
-
根据需要(通常是为了合规性原因)从日志中删除事件的机制
-
事件分发 API,可用于分发应用程序状态
-
缺乏强一致性系统通常存在的事务保证
-
向后兼容性机制,用于处理日志中不断变化的事件格式
-
用于备份和恢复日志(应用程序的事实来源)的机制
事件溯源模仿数据库的工作方式,但发生在应用程序级别。根据图 2,该图可以更新以表示具有大致相同设计的数据库,如图 3 所示。
图 3. 数据库事务具体化表的表示
当我们深入探讨事件溯源和变更数据捕获如何相互比较时,图 2 和图 3 之间的比较将变得更加相关。
变更数据捕获
变更数据捕获 (CDC) 是一种解决方案,它从数据库事务日志(或等效机制)捕获变更事件,并将这些事件转发给下游消费者。CDC 最终允许应用程序状态被外部化并与外部数据同步。
变更数据捕获实现通常具有这些特征:
-
读取数据库事务日志的外部进程,旨在从这些事务中具体化变更事件
-
变更事件作为消息转发给下游消费者
如您所见,CDC 是一个相对简单的概念,范围非常狭窄。它只是将数据库的事务日志外部化为发送给感兴趣的消费者的事件流。
图 4. 变更数据捕获实现选项
CDC 也为您提供了事件消费方式的灵活性。根据图 4:
-
选项 1 是一个独立的 CDC 进程,用于从事务日志捕获事件并将其转发到消息代理
-
选项 2 是一个嵌入式 CDC 客户端,它将事件直接发送到应用程序
-
选项 A 是另一个将 CDC 事件直接持久化到数据存储的连接器
-
选项 B 通过消息代理将事件转发给消费应用程序
最后,CDC 实现通常具有这些特征:
-
使用持久化的消息代理,以至少一次的交付保证将事件转发给所有消费者
-
能够重放来自数据存储事务日志和/或消息代理的事件,只要事件被持久化
CDC 在多种用例中都非常灵活且适应性强。CDC 的早期采用者选择选项 1/A,但随着 CDC 的势头增长,选项 1/B 和选项 2 也越来越受欢迎。
使用 CDC 实现 Outbox 模式
Outbox 模式的主要目标是确保应用程序状态(存储在表中)的更新和相应领域事件的发布在单个事务中完成。这涉及到在数据库中创建一个 Outbox 表,在事务中收集这些领域事件。对领域事件及其通过 Outbox 的传播进行事务保证对于跨系统的数据一致性很重要。
事务完成后,领域事件将被 CDC 连接器捕获,并通过可靠的消息代理转发给感兴趣的消费者(参见图 5)。然后,这些消费者可以使用领域事件来具体化自己的聚合(参见上面关于事件溯源的部分)。
图 5. 使用 CDC 实现的 Outbox 模式(2 个选项)
Outbox 的目的是从应用程序中抽象出来,因为它只是一个短暂的传出事件数据存储,不供应用程序读取或查询。事实上,Outbox 中的领域事件可能在插入后立即被删除!
事件溯源日志 vs. Outbox
现在我们可以仔细看看事件溯源日志和 CDC 与 Outbox 在设计上的重叠。通过比较日志的属性和 Outbox 表,相似之处就变得很清楚了。Aggregate(再次来自 DDD)是 Outbox 和事件溯源中数据存储和消费的核心。
以下是事件溯源日志和 Outbox 之间存在的共同属性:
-
事件 ID — 事件本身的唯一标识符,可用于幂等消费者的去重
-
Aggregate ID — 用于分区相关事件的唯一标识符;这些事件构成了 Aggregate 的状态
-
Aggregate 类型 — Aggregate 的类型,可用于将事件仅路由到感兴趣的消费者
-
序列/时间戳 — 用于排序事件以提供排序保证的方法
-
消息负载 — 包含要交换的事件数据,格式应为下游消费者可读
Outbox 表和事件溯源日志在数据格式上基本相同。主要区别在于,事件溯源日志旨在成为永久且不可变的领域事件存储,而 Outbox 则旨在高度短暂,仅作为捕获领域事件并将其转发给下游消费者的变更事件的落地区域。
命令查询责任分离
命令查询责任分离模式,简称 CQRS,通常与事件溯源相关联。但是,事件溯源并非使用 CQRS 的必要条件。例如,CQRS 模式也可以使用 Outbox 模式来实现。
那么 CQRS 到底是什么?它是一种创建数据替代表示(称为 projection,即视图)的模式,其主要目的是为了创建只读、可查询的视图。对于同一数据集,可能存在多个供不同客户端使用的视图。
CQRS 的命令方面适用于处理动作(Commands)并最终生成领域事件的应用程序,这些领域事件可用于为 projection 创建状态。这也是 CQRS 经常与事件溯源关联的原因之一。
CQRS 与事件溯源搭配良好的另一个原因是因为日志本身是不可查询的。在事件溯源系统中查询数据的唯一可行方法是通过 projections。请注意,这些 projections 是最终一致的。这带来了灵活性,但也带来了复杂性,并偏离了开发者可能熟悉的强一致性视图。
图 6. 事件溯源与 CQRS 的表示
图 7. 使用消息代理的事件溯源与 CQRS 的表示
正如您在图 6 和图 7 中所见,这是基于事件溯源的 CQRS 模式的两种截然不同的解释,但最终结果是相同的:源自事件的可查询数据 projection。
如前所述,CQRS 也可以与 Outbox 模式搭配使用,如图 8 所示。这种设计的优势在于应用程序数据库内部仍保持强一致性,而 CQRS projections 保持最终一致性。
图 8. Outbox 模式与 CQRS 的表示
内部处理领域事件
虽然本文非常侧重于跨系统分发数据,但将领域事件用于应用程序内部同样重要。出于各种原因,内部处理领域事件是必要的,包括在事件源自的同一个微服务上下文中执行业务逻辑。这是构建事件驱动应用程序的常见做法。
无论使用事件溯源还是 CDC,内部处理领域事件都需要一个分发机制来在内存中传递事件。一些例子包括 Vert.x EventBus、Akka Actor System 或 Spring Application Events。在 Outbox 模式的情况下,事件将在初始 Outbox 事务成功完成后才会被分发。
属性比较
本文信息量很大,因此总结一下到目前为止所介绍内容的表格可能会有所帮助。
| Attribute | 事件溯源 | CDC | CDC + Outbox | CQRS |
|---|---|---|---|---|
目的 | 在包含领域事件的日志中捕获状态。 | 从事务日志导出变更事件。 | 通过 CDC 从 Outbox 导出领域事件。 | 使用领域事件生成数据 projection。 |
事件类型 | 领域事件 | 变更事件 | 嵌入在变更事件中的领域事件 | 领域事件 |
事实来源 | 日志 | 事务日志 | 事务日志 | 取决于实现 |
边界 | 应用程序 | 系统 | 系统(CDC)应用程序(Outbox) | 应用程序或系统 |
一致性模型 | 不适用(仅写入日志) | 强一致(表),最终一致(变更事件捕获) | 强一致(Outbox),最终一致(变更事件捕获) | 最终一致 |
可重放性 | 是 | 是 | 是 | 取决于实现 |
事件溯源 + CQRS 的优缺点
现在我们对事件溯源和 CQRS 有了更好的理解,让我们来检查一下事件溯源与 CQRS 搭配使用的一些优缺点。这些优缺点考虑了当前可用的实现以及我自己和其他专业人士在构建分布式系统方面的经验和记录。
事件溯源与 CQRS 结合的优点
-
日志易于访问,便于审计
-
通常对向日志进行大量写入操作性能良好
-
日志可分片以处理大量数据(取决于数据存储)的可能性
事件溯源与 CQRS 结合的缺点
-
所有数据都是最终一致的;强一致性数据要求不适用于事件溯源和 CQRS
-
无法读取自己写入日志的数据(从查询角度看)
-
关于日志和事件溯源架构的长期维护问题
-
需要为错误情况编写大量补偿操作的代码
-
没有真正的事务保证来解决双重写入缺陷(稍后将介绍)
-
需要考虑向后兼容性或迁移旧数据,因为事件格式会发生变化
-
需要考虑对日志进行快照以及相关的注意事项
-
具有事件溯源和 CQRS 经验的开发人员人才库几乎不存在
-
事件溯源的用例有限,限制了适用性
事件溯源和 CQRS 的双重写入风险
事件溯源的一个问题是,如果应用程序出现错误,CQRS projections 可能无法更新。这可能导致数据丢失,不幸的是,如果不将适当的补偿操作内置到应用程序本身中,可能很难恢复这些数据。这增加了开发人员的额外代码和复杂性,而且容易出错。例如,一个变通方法是跟踪一个与事件溯源日志相关的读取偏移量,以便在错误时能够重放,重新处理领域事件并刷新 CQRS projections。
出现此错误可能性的根本原因是,写入日志和 CQRS projections 之间缺乏事务。这就是所谓的“双重写入”,它极大地增加了出错的风险。图 9 展示了这种双重写入缺陷。
图 9. 事件溯源和 CQRS 缺乏事务完整性
即使添加消息代理,如图 7 所示,也无法解决双重写入问题。使用该设计,您仍然将消息写入消息代理,并可能出现错误。
双重写入缺陷只是事件溯源与 CQRS 结合工作中的一些挑战之一。此外,以日志作为事实来源的长期维护和 Day 2 影响会随着时间的推移增加您应用程序的风险。事件溯源也是大多数工程师不熟悉的范式,很容易做出错误的假设或糟糕的设计选择,最终可能导致您系统部分部分的重构。
鉴于事件溯源与 CQRS 结合的优缺点,建议在确定此设计之前先寻求替代方案。您的用例可能适合事件溯源,但 CDC 也可能满足需求。
用于 CDC 和 Outbox 的 Debezium
Debezium 是红帽公司支持的一个开源 CDC 项目,近年来逐渐受到欢迎。最近,Debezium 通过 Quarkus Java 微服务运行时的一个扩展,增加了对 Outbox 模式的全面支持。
Debezium、Quarkus 和 Outbox 提供了一个全面的解决方案,该方案避免了双重写入缺陷,与事件溯源解决方案相比,对于普通开发团队来说通常是更实用的解决方案。
图 10. Outbox 模式与 CQRS 的错误处理
CDC + Outbox 与 Debezium 结合的优点
-
事实来源保留在应用程序数据库表和事务日志中
-
事务保证和可靠的消息传递大大降低了数据丢失或损坏的可能性
-
灵活的解决方案,可集成到原型微服务架构中
-
更简单的设计,易于长期维护
-
可以读取自己写入的数据
-
应用程序数据库内强一致性的机会;系统其余部分的最终一致性
CDC + Outbox 与 Debezium 结合的缺点
-
通过读取事务日志以及通过消息代理可能存在额外的延迟;可能需要进行调优以最大程度地减少延迟
-
Quarkus 虽然很棒,但目前是现成的 Outbox API 的唯一选择;如果需要,您也可以自己实现
结论
构建分布式系统(即使是微服务)也可能非常具有挑战性。这就是为什么像事件溯源这样的新颖解决方案具有吸引力。然而,使用 Debezium 的 CDC 和 Outbox 通常是事件溯源的更好替代方案,并且还可以与 CQRS 模式兼容。虽然事件溯源在某些用例中可能仍有价值,但我鼓励您首先尝试 Debezium 和 Outbox。
延伸阅读
博客和文章
关于 Debezium
Debezium 是一个开源的分布式平台,可以将现有数据库转变为事件流,使应用程序能够几乎即时地看到并响应数据库中已提交的每个行级更改。Debezium 构建在 Kafka 之上,并提供了 Kafka Connect 兼容的连接器,用于监控特定的数据库管理系统。Debezium 将数据更改的历史记录在 Kafka 日志中,这样您的应用程序可以随时停止和重新启动,并可以轻松地消费在未运行时错过的所有事件,确保所有事件都被正确且完整地处理。Debezium 在 Apache 许可证 2.0 下是 开源 的。