这篇文章是实现一个基于 CQRS 和事件溯源原则的应用程序,描述这个过程的方式,我相信分享我面临的挑战和问题可能对一些人有用。特别是如果你正在开始自己的旅程。
业务背景
项目的背景与空中交通管理(ATM)领域相关。我们为一个 ANSP(航空导航服务提供商)设计了一个解决方案,负责控制特定地理区域。这个应用程序的目标很简单:计算并持久化飞行数据。流程大致如下。
在飞机穿越其领空之前的几个小时,ANSP会收到来自 Eurocontrol 的信息,这个组织负责管理整个欧洲的航空交通。这些信息包含计划数据,如飞机类型、起飞地点、目的地、请求的航路等。一旦飞机到达了 ANSP 的 AOR(责任区域,ANSP负责控制和监控航班的区域),我们就可以从各种来源接收输入:航迹更新(飞行当前位置是什么)、修改当前航路的请求、由轨迹预测系统触发的事件、来自冲突检测系统的警报等。
虽然我们可能需要同时处理多个潜在的并发请求,但在吞吐量方面,与 PayPal 或 Netflix 没法相提并论。
尽管如此,该应用程序是安全关键环境的一部分。在发生关键故障的情况下,我们不会损失金钱或客户,但我们可能会失去人的生命。因此,实现一个可靠、响应迅速且具有弹性的系统,以保证数据一致性和完整性,显然是首要任务。
CQRS、事件溯源
这两种模式实际上都相当容易理解。
CQRS
CQRS(命令查询责任分离)是一种将写入(命令)和读取(查询)分离的方式。这意味着我们可以有一个数据库来管理写入部分。而读取部分(也称为视图或投影)是从写入部分派生出来的,可以由一个或多个数据库来管理(取决于我们的用例)。大多数情况下,读取部分是异步计算的,这意味着两个部分并不严格一致。我们稍后会回到这一点。
CQRS 背后的想法之一是数据库很难同时高效地处理读写操作。这可能取决于软件供应商的选择、应用的数据库调优等。例如,Apache Cassandra 在持久化数据方面效率很高,而 Elasticsearch 对搜索非常出色。使用 CQRS 真的是利用解决方案的优势的一种方式。
此外,我们还可以决定处理不同的数据模型。再次强调,这取决于需求。例如,在报告视图上使用的模型,另一个在写入部分持久化期间效率高的非规范化模型等。
关于视图,我们可能决定实现一些与消费者无关的视图(例如公开特定的业务对象),或者一些专门针对消费者的视图。
事件溯源
根据 Martin Fowler 对事件溯源的定义:
确保应用状态的所有更改都存储为事件序列
这意味着我们不存储对象的状态。相反,我们存储影响其状态的所有事件。然后,要检索对象状态,我们必须读取与该对象相关的不同事件,并逐个应用它们。
CQRS + 事件溯源
这两种模式经常被组合在一起。在 CQRS 之上应用事件溯源意味着将每个事件持久化在我们应用程序的写入部分。然后,读取部分是从事件序列派生出来的。
在我看来,实现 CQRS 时并不需要事件溯源。然而,反之则不一定成立。
事实上,对于大多数用例,在实现事件溯源时需要 CQRS,因为我们可能希望以 O(1) 的时间复杂度检索状态,而不必计算 n 种不同的事件。一个例外是简单的审计日志用例。在这里,我们不需要管理视图(也不需要状态),因为我们只对检索日志序列感兴趣。
领域驱动设计
领域驱动设计(DDD)是一种处理与领域模型相关的软件复杂性的方法。它由 Eric Evans 在 2004 年的《Domain-Driven Design: Tackling Complexity in the Heart of Software》一书中引入。
我们不会介绍所有不同的概念,但如果你对此不熟悉,我强烈建议你去看一看。不过,我们将只介绍在 CQRS/事件溯源应用程序环境中有用的概念。
DDD 带来的第一个概念是聚合(Aggregate)。聚合是一组领域对象,从数据变更角度来看,它们被视为一个单元。在聚合内部的事务必须保持原子性。
与此同时,聚合通过不变式来强制执行自己的数据一致性和完整性。不变式就是一个规则,无论如何变化,它都必须保持为真。例如,标准终端到达航线(STAR,基本上是着陆前的预定义航线)始终与给定机场相关联。一个不变式必须强制执行这样一个规则:目的机场不能在没有更改 STAR 的情况下被修改,并且这个 STAR 与该机场是有效的。
此外,作为聚合的外观对象(处理输入并将业务逻辑委托给子对象的对象)被称为聚合根。
关于组成聚合的对象,我们需要区分实体和值对象。实体是具有标识的对象,它不是由其属性定义的。一个人的年龄会随着时间的推移而改变,但他/她仍然是同一个人。另一方面,值对象仅由其属性定义。不同城市的地址是不同的地址。前者是可变的,而后者是不可变的。此外,实体可以有自己的生命周期。例如,一个航班首先准备起飞,然后是空中飞行,最后着陆。
在模型定义中,实体应尽可能简单,并专注于其标识和其生命周期。在 CQRS/事件溯源应用程序的上下文中,实体是一个关键元素,因为大多数情况下,在聚合内进行的更改是基于它们的生命周期。例如,至关重要的是确保每个实体实现了一个函数,用于确定它是否与另一个实体实例相等。这可以通过比较标识符或一组相关属性来实现,从而保证了一个标识。
既然我们已经了解了实体的概念,让我们回到不变式。为了定义它们,我们使用了受 BDD(行为驱动开发)格式启发的语言:
Given [entity] at state [state]
When [event] occurs
We shall [rules]
领域驱动设计(DDD)是一种处理与领域模型相关的软件复杂性的方法。它由 Eric Evans 在 2004 年的《Domain-Driven Design: Tackling Complexity in the Heart of Software》一书中引入。
我们不会介绍所有不同的概念,但如果你对此不熟悉,我强烈建议你去看一看。不过,我们将只介绍在 CQRS/事件溯源应用程序环境中有用的概念。
DDD 带来的第一个概念是聚合(Aggregate)。聚合是一组领域对象,从数据变更角度来看,它们被视为一个单元。在聚合内部的事务必须保持原子性。
与此同时,聚合通过不变式来强制执行自己的数据一致性和完整性。不变式就是一个规则,无论如何变化,它都必须保持为真。例如,标准终端到达航线(STAR,基本上是着陆前的预定义航线)始终与给定机场相关联。一个不变式必须强制执行这样一个规则:目的机场不能在没有更改 STAR 的情况下被修改,并且这个 STAR 与该机场是有效的。
此外,作为聚合的外观对象(处理输入并将业务逻辑委托给子对象的对象)被称为聚合根。
关于组成聚合的对象,我们需要区分实体和值对象。实体是具有标识的对象,它不是由其属性定义的。一个人的年龄会随着时间的推移而改变,但他/她仍然是同一个人。另一方面,值对象仅由其属性定义。不同城市的地址是不同的地址。前者是可变的,而后者是不可变的。此外,实体可以有自己的生命周期。例如,一个航班首先准备起飞,然后是空中飞行,最后着陆。
在模型定义中,实体应尽可能简单,并专注于其标识和其生命周期。在 CQRS/事件溯源应用程序的上下文中,实体是一个关键元素,因为大多数情况下,在聚合内进行的更改是基于它们的生命周期。例如,至关重要的是确保每个实体实现了一个函数,用于确定它是否与另一个实体实例相等。这可以通过比较标识符或一组相关属性来实现,从而保证了一个标识。
既然我们已经了解了实体的概念,让我们回到不变式。为了定义它们,我们使用了受 BDD(行为驱动开发)格式启发的语言:
应用程序设计
简而言之,应用程序接收命令并发布内部事件。这些事件被持久化到事件存储中,并发布给处理程序,这些处理程序负责更新视图。我们还可以决定在视图之上实现一个服务层(称为读处理程序)。
现在,让我们详细看看不同的场景。
聚合创建
命令处理程序接收一个 CreateFlight 命令,并在领域存储库中检查实例是否存在。这个领域存储库管理聚合实例。它首先在缓存中进行检查,如果对象不存在,则会在事件存储中进行检查。事件存储是一个用于持久化事件序列的数据库。我会稍后详细说明我认为一个好的事件存储是什么。在这种情况下,事件存储仍然为空,因此存储库不会返回任何内容。
命令处理程序负责触发不变式。在出现失败的情况下,我们可以同步抛出异常来指示业务问题。否则,命令处理程序将发布一个或多个事件到事件总线。事件的数量取决于内部数据模型的粒度。在我们的场景中,我们假设发布了一个单一的 FlightCreated 事件。
在此事件上触发的第一个组件是领域处理程序。这个组件负责根据实现的逻辑更新领域聚合。通常,逻辑被委托给聚合根(充当外观,但也可以将底层逻辑委托给子域对象)。请记住,聚合必须始终保持一致,并且还必须通过验证不变式来强制执行数据完整性。
如果处理程序成功(未引发业务错误),则事件将被持久化到事件存储中,并且缓存将使用最新的聚合实例进行更新。
然后,触发视图处理程序来更新其对应的视图。就像在普通的发布-订阅模式中一样,视图可以只订阅它感兴趣的事件。也许在我们的情况下,视图 2 是唯一对 FlightCreated 事件感兴趣的视图。
聚合更新
第二种情景是更新现有的聚合。在接收到 UpdateFlight 命令时,命令处理程序会请求存储库返回最新的聚合实例(如果有的话)。
如果实例已经在缓存中,则无需与事件存储交互。否则,存储库将触发所谓的重新装载过程。
这个过程是根据存储的事件序列计算聚合实例的当前状态的一种方式。从事件存储中检索的每个事件(比如 FlightCreated、DepartureUpdated 和 ArrivalUpdated)都会被发布到事件总线。第一个领域处理程序触发 FlightCreated 时会实例化一个新的聚合(根据事件本身提供的信息,在内存中创建一个新的对象实例)。然后其他领域处理程序(由 DepartureUpdated 和 ArrivalUpdated 事件触发)将更新刚刚创建的聚合实例。最终,我们能够根据存储的事件计算出状态。
一旦计算出状态,对象实例就会被放入缓存并返回给命令处理程序。然后,其余的流程与聚合创建情景相同。
关于重新装载过程还有一件事需要补充。如果一个聚合不在缓存中,而我们为一个特定的聚合实例存储了 1000 个事件,那么会花费很长时间来计算其状态。有一个已知的缓解措施叫做快照。
我们可以决定在每 n 个事件中持久化聚合的当前状态作为一个快照。这个快照也会包含在事件存储中的位置。然后,重新装载过程将简单地从最新的快照开始,并从指定的位置继续。快照还可以根据其他策略类型创建(如果重新装载时间超过某个阈值等)。
如何处理事件?
我想再回顾一下我们对命令和事件的区分。首先,有必要区分内部事件和外部事件。外部事件是由另一个应用程序产生的,而内部事件是由我们的应用程序生成的(基于外部命令)。
我们就如何在我们的应用程序中技术性地处理外部事件进行了一场有趣的辩论。我的意思是,真正的事件指的是已经在过去发生的事情(比如雷达轨迹)。
实际上有两种可能的处理方法:
- 第一种方法是将事件视为命令。这意味着我们必须首先通过一个命令处理程序,验证不变式,然后生成一个内部事件。
- 第二种方法是绕过命令处理程序,直接将事件持久化到事件存储中。毕竟,如果我们谈论的是一个真实事件,那么验证不变式等操作实际上是没有什么用的。然而,检查事件的语法仍然很重要,以确保我们不会污染事件存储。
如果我们选择第二个选项,可能会有兴趣在聚合重新装载期间实现规则。
让我们举一个雷达轨迹发布飞行位置的例子。如果生产者无法保证消息的顺序,我们还可以持久化一个时间戳(由生产者生成),并以这种方式计算状态:
if event.date > latestEventDate { // Compute the state
latestEventDate = event.date} else { // Discard the event}
这个规则将确保状态仅基于最新生成的事件。这意味着持久化一个事件不一定会影响当前状态。
在第一种方法中,在持久化事件之前会实现这样的规则。
事件模型
在事件存储中持久化的事件是否需要创建一个统一的模型?在我看来,答案是否定的(至少大部分情况下是)。
首先,因为我们可能希望随着时间推移持久化不同的模型版本。在这种情况下,我们必须实现一种策略,将一个模型版本的事件映射到另一个模型版本。
我想用一个具体的例子来说明另一个好处。假设一个应用程序接收来自系统 A 和系统 B 的事件。这两个系统基于各自的数据模型发布飞行事件。如果我们创建一个通用数据模型 C,我们需要在持久化事件之前将 A 转换为 C 和 B 转换为 C。然而,在项目的某个阶段,我们只对来自 A 和 B 的某些信息感兴趣。这意味着 C 只是 A 和 B 的一个子集。
但是如果以后我们需要对应用程序进行一些改进,并管理来自 A 和 B 的额外元素怎么办?因为事件是使用 C 格式持久化的,所以这些元素就会被简单地丢失。另一方面,如果我们决定持久化 A 和 B 格式,我们可以简单地对命令处理程序进行一些改进,以管理这些元素。
最终一致性
理论
最终一致性是由 CQRS(大多数情况下)引入的一个概念。理解其影响和后果非常重要。
首先,值得一提的是有不同的一致性级别。
最终一致性是一个模型,我们可以确保数据会被复制(从 CQRS 应用程序的写入部分到读取部分)。问题在于我们无法确切保证何时复制完成。这会受到各种因素的影响,比如整体吞吐量、网络延迟等。这是最弱的一致性形式,但提供了最低的延迟。
在 CQRS 应用程序中应用最终一致性意味着在某个时刻,写入部分可能与读取部分不同步。
相反地,我们可以找到强一致性模型。除非我们在分布式系统中使用相同的数据库来管理读取和写入,或者我们通过使用两阶段提交向恶魔出卖了我们的灵魂,否则在分布式系统中我们不应该达到这种一致性级别。
最接近的实现方法是,如果我们有两个不同的数据库,那就在单个线程中管理所有操作。这个线程将负责将数据持久化到写入数据库和读取数据库(们)。一个线程还可以专门用于单个聚合实例,并按顺序处理传入的命令。然而,如果在同步视图时发生瞬态错误,会有什么影响?我们需要补偿其他视图和 CQRS 应用程序的写入部分吗?我们需要实现错误重试循环吗?我们需要通过暂停命令处理程序来停止新的传入事件,应用断路器模式吗?解决显然会发生的瞬态错误是很重要的(凡是可能出错的地方迟早会出错)。
在最终一致性和强一致性两种一致性模型之间,我们可以找到许多不同的模型:因果一致性、顺序一致性等。举例来说,客户端单调一致性模型仅在会话(应用程序或服务实例)内保证强一致性。因此,实现 CQRS 应用程序并不只是在最终一致性和强一致性之间做出选择。
我个人的观点是:由于我们几乎无法保证强一致性,让我们尽可能地接受最终一致性。然而,前提是要精确理解其对系统其余部分的影响。
例子
让我们看一个我在项目中遇到的具体例子。
其中一个挑战是管理每架飞机的唯一标识符。我们不得不处理来自外部系统(公司外部)的事件,这些系统中的标识符并不相同。对于一个通道,标识符是一个复合标识符(出发机场 + 出发时间 + 飞机标识符 + 到达机场),而另一个通道则发送每架飞机的唯一标识符(但第一个通道不知道)。我们的目标是管理我们自己的唯一标识符(称为 GUFI,即全局唯一飞行标识符),并确保每个事件都对应于正确的 GUFI。
最简单的解决方案是确保每个传入的事件都在我们应用程序的特定视图中进行查找,以关联相应的 GUFI。但如果这个视图是最终一致的呢?在最坏的情况下,我们可能会有与同一飞行相关的事件,但使用不同的 GUFIs 进行存储(相信我,这是一个问题)。
一个解决方案可能是将这个 GUFI 的管理委托给另一个强一致性的服务。
在一次问答环节中,Greg Young 提供了另一个解决方案。我们可以实现一种缓冲区,其中只包含我们应用程序处理的 n 个最新事件。如果视图中不包含我们正在寻找的数据,我们必须在这个缓冲区中检查,以确保它不是刚刚在视图之前接收到的。n 越大,减轻写入和读取之间的这种不一致性窗口的机会就越大。
这个缓冲区可以使用像 Hazelcast、Redis 等解决方案进行分布式处理,也可以局部于应用程序实例。在后一种情况下,我们可能需要实现一个分片机制,使用哈希函数将相关对象的事件始终分发到相同的应用程序实例(最好是使用一种一致性哈希函数,以便轻松扩展)。
并发管理
几个月前我已经创建了一篇文章,描述了使用事件源管理并发更新的好处。
简而言之,拥有事件存储可能会帮助我们找到比悲观或乐观方法更聪明的解决方案来处理并发更新。
此外,在数据模型中应用正确的粒度也是项目成功的关键。
选择事件存储
我们可以决定使用任何类型的数据库来持久化事件序列。然而,最优解往往是为事件源构建的解决方案。
例如,隔离一个聚合实例是必须考虑的事情。假设所有事件都存储在一个单一表中。这个表会随着时间不断增长,在聚合重建时,我们将不得不过滤与一个特定聚合实例相关的事件。重建一个聚合的时间将取决于持久化的事件总数,即使其中一些事件与我们感兴趣的实例无关。一个好的解决方案可能是为每个聚合实例拥有一个表/存储桶,以隔离事件。我们称这个概念为流(stream)。一个流总是与一个聚合实例相关联(在大多数用例中)。
以下是我们考虑选择事件存储时的要求:
写入:
- 恒定的写入延迟:无论流的大小如何,持久化事件的延迟都必须保持恒定
- 原子性:可以在单个事务中追加多个事件
- TTL 管理:根据创建日期自动丢弃事件
- 无模式:可以存储多种事件类型和版本
读取:
- 按写入顺序读取事件
- 从特定序列号读取(因为快照)
- 在给定流中保持恒定的读取性能,不受其他流的影响
- 图形用户界面(GUI)
- 缓存管理
并发:
- 乐观并发模型
- 幂等性管理
产品监控
解决方案支持
安全性:
- 加密(传输)
- 身份验证
- 授权管理
扩展性
备份
每个上下文都是独特的,我相信你会有自己的要求,但这至少可能是一个起点。
结论
CQRS 和事件源并非魔法。在开始你的旅程之前,理解这两种模式的许多影响至关重要。否则,在技术和功能层面都很容易造成彻底的混乱。
然而,一旦你对约束和缺点有了明确的理解,CQRS 和/或事件源可能是许多问题的很好解决方案。