事件溯源和CQRS实施一年总结
Teiva Harsanyi分享了其一年在飞机航空交通管理这个关键重要领域的EventSourcing事件溯源和CQRS实施经验,阐述其在实施过程中面临的挑战和问题。
业务环境
该项目的背景是与空中交通管理(ATM)这个业务领域有关。我们为空中航行管理服务提供商ANSP设计了一套解决方案,负责控制特定的地理区域。该应用程序的目标很简单:计算并保存飞行数据。这个过程大致如下。
在飞机到达ANSP管理区域的几个小时前,ANSP会从欧洲空中交通管理局收到欧洲空中交通管理组织提供的信息。该信息包含计划的数据,例如飞机类型,出发地,目的地,要求的路线等。一旦飞机到达ANSP(负责区域,ANSP负责控制和监控航班的区域)的AOR,我们可以接收来自各种来源的输入:跟踪更新(当前航班位置是什么),修改当前航线的请求,由运动轨迹预测系统触发的事件,来自冲突检测系统的警报等。
尽管我们必须同时处理多个并发请求,但吞吐量方面,它与Paypal或Netflix无法相提并论。
该应用程序是非常注重安全的,因为如果发生严重故障,我们可能不会赔钱给客户,但是可能会失去生命。因此,实施一个可靠,负责任和有弹性的系统来保证数据的一致性/完整性显然是首要任务。
CQRS,事件溯源
这两种模式实际上都很容易理解。
1. CQRS
CQRS(命令查询责任分离)是一种分离写入(命令)和读取(查询)的方式。这意味着我们可以专门有一个数据库用来管理写入部分。查询和读部分(也称为视图或投影)虽然源自写入部分,但是由另外一个或多个数据库(取决于我们的用例)进行专门管理。大多数情况下,读部分的查询是异步计算的,这意味着两部分都不是严格一致的。
CQRS背后的想法之一是:必须承认单单依靠一个数据库几乎不可能同时有效管理读取和写入两种操作(banq注:如果你对这个假设前提无感,那么可能说明你经验值不够。)。为了侧重读操作和写操作,可以选择不同的软件供应商,对应用的数据库进行调整等措施。例如,Apache Cassandra在保存/写入数据方面是有效的,而Elasticsearch非常适合搜索的。使用CQRS实际上是一种利用解决方案优势而不是依赖唯一单一数据库的方法。
此外,我们也可能决定处理不同的数据模型。这当然取决于需求。例如,在报告视图的上下文中管理使用一个模型,在写入部分的持久阶段则使用另一套有效的非规范化模型等。
关于这些观点,我们可能会决定实施一些与消费者无关的举措(例如揭露特定的商业对象),或者一些针对消费者的特定商业对象。
2. 事件溯源
根据Martin Fowler对事件溯源定义:
确保对应用程序状态的所有更改动作都存储为一系列事件
这意味着我们不存储对象的状态,相反,我们存储影响其状态的所有事件((banq注:状态类似存量,类似账户余额;事件类似流量,类似导致账户余额变动一系列转账事件))。然后,为了检索一个对象状态,我们必须读取与这个对象相关的不同事件,并逐一应用它们。(banq注:账户余额实时计算,不是直接读取某个数据库的字段)
3. CQRS +事件采购
这两种模式经常组合在一起,在CQRS之上应用事件溯源意味着将每个事件都保存在应用程序的写入部分,然后读取部分从事件系列中实时计算派生得到。
有时,我们实施CQRS时好像不需要事件溯源。
事实上,对于大多数用例来说,当我们实现事件溯源时,CQRS是必需的(banq注:每次查询时实时计算是复杂的,属于o(n)),我们可能只需O(1)计算程度直接从数据库状态字段中查询检索,而不必每次都计算n个不同的事件。当然,也有例外是简单审计日志的用例。在这里,我们不需要管理视图(或状态),因为我们只想检索一系列日志。
4. 领域驱动设计
领域驱动设计(DDD)是一种处理与领域模型相关的软件复杂性的方法。它在2004年由Eric Evans在“ 领域驱动设计:解决软件核心中的复杂性”一书中介绍。
我们不会介绍所有不同的概念,但如果您不熟悉它,我会强烈建议您查看它。尽管如此,我们只是要介绍在CQRS /事件溯源应用程序中DDD里面有用的概念。
DDD带来的第一个概念是聚合。聚合是一组领域对象,它们被认为是关于多个数据更改的一个最小单位。聚合内的交易必须保持原子(banq注:聚合天然是事务的,只有事务的才是聚合)。
同时,聚合使用不变性来强制自己的数据一致性/完整性。不变性是简单的规则,无论需求如何变化,总有几个部分时刻团结在一起,要么同时变,要么同时不变。例如,STAR(标准航站楼到达航线,基本上是着陆前的预定航线)始终与一个特定机场相连,这种相连关系是不变的。不变性约束是:如果未更改STAR,就不能更改目的地机场,并且该STAR始终对该机场有效。
此外,有一个对象充当聚合的总管(处理输入并将业务逻辑委托给子对象,facade模式)称为聚合根。
聚合是由一群对象构成的,关于构成聚合的这些对象,我们需要区分实体和值对象。一个实体是一个具有标识的对象,它不是由它的属性定义的,一个人在一段时间内会有不同的年龄,但他/她仍然是同一个人。另一方面,值对象完全由其属性定义。不同城市的地址是不同的地址。实体是可变的,而值对象是不可变的。此外,一个实体可以有自己的生命周期。例如,一架航班首先准备出发,空降(飞行),然后降落。
在模型定义中,实体应该尽可能简单,并且关注其标识和生命周期。在CQRS /事件采购应用程序的环境中,实体是一个关键因素,因为聚合中所做的更改大多数时间都是根据其生命周期完成的。确保每个实体都实现一个函数方法用来确定它是否等于另一个实体实例是至关重要的。它可以通过比较一个标识符或一组保证标识(主键)的相关属性来完成。
现在我们已经知道了实体的概念,让我们回到不变性上。为了定义它们,我们使用了一种受BDD(行为驱动开发)格式启发的语言:
假定[实体]在[状态]
如果当[事件]时
我们应该[实现规则]
我真的觉得这是非常有效的。主要是因为这很容易被业务人员理解。
最后但并非最不重要的是,DDD也带来了有界上下文的概念。基本上,我们不需要管理一个大的复杂模型,我们可以在不同的上下文中用明确的边界来分割它。我已经在我的文章中提到过这个概念。为什么规范数据模型是反模式?
当我们必须设计一个视图时,我们可以应用有界上下文的概念。如前所述,视图可以是特定于消费者的(因为我们需要实现低延迟或由于其他原因)或者对于多个消费者来说是共同的。
在后一种情况下,我们必须考虑暴露的数据模型。它是整个公司的全球性和共享模式,还是某个特定环境中的东西,就像一个给定的功能域一样?
如果是共享模式,我们需要记住变更时对消费者的影响。这可以通过在视图顶部应用服务层来缓解,但我个人赞成直接将视图场景化。例如,在模型更改的情况下,我们可以使原始视图暴露前一个模型,并创建另一个视图来显示新模型。
5. 命令与事件
在事件溯源架构中,区分命令和事件很重要。一个命令代表一个意图(用CreateCustomer这样的现在时来代表一个命令),而事件表示一个事实,已经发生的事情(用CustomerUpdated这样的过去时代表一个事件)。
作为我的项目的一个具体例子,一个事件可能是接收指示当前飞机位置的雷达轨迹。系统很难拒绝这样的事件,因为它已经发生了(它究竟什么时候发生,可能取决于延迟等各种因素)。
另一方面,想要修改飞行轨迹的飞行控制指令是命令。这是一个用户意图,不同于之前的事实,它必须由我们的应用程序验证是否可以执行(关键是它还未被真正执行)。
大多数情况下,一个命令被设计为一个同步交互,一个事件被设计为一个异步交互。但并非所有情况下。
记住数据所有权的概念也很重要。让我们设想两个系统A和B之间交换客户Customer数据的简单交互。如果A产生一个异步CustomerUpdated消息,该消息被B捕获的,但是B被认为是客户对象Customer的所有者(在客户Customer生命周期的当前阶段),所以,B可能有权拒绝该变更,因为B才是客户对象Customer所有者。即使由A发出的事件消息看起来像一个领域事件,但最终它只是让B系统执行的一个命令而已(banq注:CustomerUpdated是A的输出事件,但是是B的输入命令UpdateCustomer,B可以拒绝执行这个命令,因为B是Customer数据的拥有者)。
实施
该设计基于Axon框架。我不会再提到这个框架,因为这篇文章是基于技术无关的设计讨论。但是,如果您正在Java环境中实施应用程序,我强烈建议您查看它。在我看来,Axon Framework对于实施CQRS / Event Sourcing应用程序非常棒。
我们来看看内部应用程序设计:
简而言之,应用程序接收命令并发布内部事件。这些事件被保存在事件存储器中并发布给相应的处理程序,对应的事件响应处理程序负责更新视图。我们也可能决定在视图之上实现一个服务层(称为读处理程序)。
现在,让我们详细看看不同的情况。
1. 创建聚合
命令处理程序接收一个CreateFlight命令并检查域存储库中是否存在实例。该域存储库管理各种聚合实例。它首先检查缓存,如果对象不存在,它将检查事件存储库。事件存储库是一个保存一系列事件的数据库。我们稍后将会看到在我看来什么是一个好的事件存储数据库。在这种情况下,事件存储库仍然是空的(因为是创建聚合阶段,世界开始之初为空),因此存储库不会返回任何内容。
命令处理程序负责触发不变性(事务),如果发生故障,我们可以同步返回指示业务问题的异常。否则,命令处理程序会将一个或多个事件发布到事件总线。事件的数量取决于内部数据模型粒度的用例。在我们的场景中,我们将假设发布一个FlightCreated事件。
此事件触发的第一个组件是域处理程序。该组件负责根据实施的逻辑更新领域聚合。一般来说,逻辑委托给聚合根(作为一个门面facade,但也可能它委托底层逻辑给到子域对象)。请记住,聚合必须始终保持一致,并且还必须通过验证不变性来强制执行数据完整性(banq注:事务机制在这里通过业务的交易保证)。
如果处理程序成功(未引发业务错误),则事件将保存在事件存储库中,并使用最新的聚合实例更新缓存。
然后,视图处理程序被触发以更新其对应的视图。就像在一个普通的发布 - 订阅模式中一样,一个视图只能订阅它感兴趣的事件。也许在我们的例子中,视图2是唯一对FlightCreated事件感兴趣的。
2.汇总更新
第二种情况是现有聚合的更新。在接收到UpdateFlight命令时,命令处理程序会要求存储库返回最新的聚合实例(如果有的话)。
如果实例被缓存,则不需要与事件存储进行交互。否则,存储库将触发所谓的补液过程(给缓存补充该聚合)。
此过程是根据存储的事件序列计算聚合实例的当前状态的一种方法。事件存储中检索到的每个事件(比如FlightCreated,DepartureUpdated和ArrivalUpdated)都会在事件总线中发布。由FlightCreated触发的第一个域处理程序实例化一个新的聚合(基于来自事件本身的信息在内存中创建一个新的对象实例)。然后其他域处理程序(由DepartureUpdated和ArrivalUpdated事件触发)将更新新创建的聚合实例。最后,我们可以根据存储的事件来计算状态。
一旦状态被计算出来,对象实例就被放入缓存并返回给命令处理程序。然后,该过程的其余部分与集合创建方案中的相同。
关于补液过程需要补充一点。如果聚合没有被缓存,并且我们为一个特定的聚合实例存储了1000个事件会怎么样?显然,计算状态需要很长时间。快照方式是一种缓解措施。
我们可以决定将根据每n个事件计算当前的聚合状态保存为快照。该快照还将包含事件存储中的位置。然后,补液过程将简单地以最新的快照为开始并且将从所指定的位置继续播放。还可以根据其他策略类型创建快照(如果补液时间超过某个阈值等)。
3. 如何处理事件?
我想回顾一下我们在命令和事件之间的区别。首先,区分内部事件和外部事件是值得的。外部事件由另一个应用程序产生,而内部事件由我们的应用程序产生(基于外部命令)。
我们就如何从技术上管理到达应用程序的外部事件展开了一场有趣的辩论。我的意思是一个真实的事件,就是过去已经发生的事情(像雷达轨迹)。
确实有两种可能的方法:
第一种方法是将事件视为命令。这意味着我们必须首先通过命令处理程序,验证不变式,然后生成内部事件。
第二种方法是绕过命令处理程序并直接将事件存储在事件存储中。毕竟,如果我们谈论的是一个真实事件,确认不变量实际上是无用的。但是,检查事件语法以确保我们不会污染事件存储仍然很重要。
如果我们采用第二种方法,在总体补液过程中实施规则可能会很有趣。
我们举一个雷达跟踪路线的例子。在生产者不能保证消息顺序的情况下,我们也可以持久化一个时间戳(由生产者生成)并按照这种方式计算状态:
if event.date > latestEventDate {
// Compute the state
latestEventDate = event.date
} else {
// Discard the event
}
这条规则将确保状态只基于最近发生的事件计算得到。这意味着持久一个事件并不一定意味着会影响目前的状态。
在第一种方法中,这种规则将在持久保存事件之前执行。
4.事件模型
是否有必要为事件存储中保存的事件专门创建一个对象模型(一个事件对应一个类,对应一个数据表)?在我看来,答案是否定的(至少大部分时间是这样)。
首先,因为我们可能想在不同的时间持久不同的事件版本。在这种情况下,我们必须实施策略,将事件从一个模型版本映射到另一个模型版本。
我想用一个具体的例子来说明好处。让我们考虑一个应用程序接收来自系统A和系统B的事件。两个系统都基于他们自己的数据模型发布航班事件。如果我们创建一个通用数据模型C(事件的数据表),我们需要在保持事件之前将A转换为C,将B转换为C. 然而,在项目的某个阶段,我们只对A和B的一些信息感兴趣。这意味着C只是A和B的子集。
但是如果以后我们需要对应用程序进行一些改进并管理A和B中的其他元素?因为这些事件是使用C格式保存的,所以这些元素直接就丢失了。另一方面,如果我们决定持久A和B格式,我们可以简单地对命令处理程序进行一些改进以管理这些元素。
5.最终的一致性
理论定义:
最终的一致性是CQRS带来的一个概念(大部分时间)。了解后果和影响非常重要。
首先,需要说说不同的一致性级别。
最终一致性是我们可以确保数据将被复制(从写入到CQRS应用程序的读取部分)的模型。问题是我们不能确切地保证时间。这个时间将受到诸如整体吞吐量,网络延迟等各种因素的影响。这是一致性最弱的形式,但它提供了最低的延迟。
在CQRS应用程序上应用最终的一致性意味着在某一时刻写入部分可能与读取部分不同步。
相反,我们找到了强一致性模型。除非我们使用相同的数据库来管理读写,或者我们通过使用两阶段提交将我们的灵魂卖给魔鬼(banq注:屈服于2PC等现有的解决方案),否则我们不应该在分布式系统中实施这种一致性级别。
如果我们有两个不同的数据库,最接近2PC事务的实现是在单个线程中管理所有内容(数据修改)。该线程负责将数据保存在写数据库和读数据库上。一个线程也可以专用于一个聚合实例,并按顺序管理传入的命令。但是,如果在同步视图时出现瞬间错误,会有什么影响?我们是否需要补偿或回退CQRS应用程序的其他视图和写入部分?我们是否还需要实现一个错误重试循环?我们是否需要通过断路器模式实现暂停命令,让处理程序来停止新的传入事件?处理发生的瞬态错误是非常重要的(任何可能出错的东西都会出错)。
在两个一致性模型(最终和强一致性)之间,我们可以找到许多不同的模型:因果一致性,顺序一致性等。例如,客户端单调monotonic一致性模型只保证每个会话(应用程序或服务实例)具有较强的一致性。因此,实施CQRS应用程序不仅仅是最终和强大一致性之间的两个极端选择,中间有灰色选择。
我的意见如下:由于我们难以保证强一致性,我们尽可能地接受最终的一致性。但是,前提是要准确理解对系统其他部分的影响。
案例
我们来看看我在项目中遇到的一个具体例子。
其中一个挑战是管理每个航班的唯一标识符。我们必须处理来自外部系统(公司外部)的事件,而在这些事件中没有使用我们的唯一标识符。对于一个跑道,标识符是一个组合(由离开机场+出发时间+飞机标识符+到达机场组合而成),而另一个跑道每个航班发送一个唯一标识符(但不知道哪个第一个跑道)。目标是管理我们自己的唯一标识符(称为全球唯一标识符的GUFI),并确保每个事件都对应于正确的GUFI。
最简单的解决方案是确保每个传入事件都在我们的应用程序的特定视图中进行查找以关联相应的GUFI。但如果这个视图是最终一致的呢?在最糟糕的情况下,我们可能会遇到与同一航班相关的事件,但使用不同的GUFI标识符存储(相信我这是一个问题)。
一种解决方案可能是将GUFI的管理委派给另一个服务,这是非常一致的。
Greg Young在Q/A会议期间提供了另一种解决方案。我们可以实现一个只包含由我们的应用程序处理的N个最后事件的缓存区。如果视图不包含我们正在寻找的数据,我们必须检查这个缓存区,以确保它之前没有收到。n越大,在写入和读取站点之间减轻这种不一致窗口的机会就越大。
该缓存区可以使用Hazelcast,Redis等解决方案进行分发,也可以是应用程序实例的本地缓冲区。在后一种情况下,我们可能必须实现一个分片机制,以便将与同一个对象相关的事件始终向同一个应用程序实例分发,例如使用哈希函数(最好是一致的哈希函数来轻松向外扩展)。
相关推荐
qiuzhiming0 2016-07-20
evolone 2018-03-01
exzhulw 2018-12-13
肖舸NoSQLt架构 2018-03-01
幸运小侯子 2019-07-01
MrIronHand 2018-12-13
gggwfn 2016-08-09
CrazyNotes 2012-08-09
小菜猿 2019-06-28
dananhai 2012-03-02
wangtua 2018-08-09
84457708 2018-11-01