经验分享:采用事件溯源的误区(以及我们是如何避免的)


在过去一年左右的时间里,我们一直在构建一个具有事件源架构的新系统。事件溯源非常适合我们的需求,因为我们的组织希望保留系统管理的信息的准确历史记录,并对其进行欺诈检测(以及其他事项)进行分析。
然而,当我们开始时,我们之前都没有人建立过具有事件源架构的系统。尽管阅读了很多关于做什么和避免什么的建议,并且经历了其他项目的报告,但我们在设计中犯了一些重大错误。本文描述了我们出错的地方,希望其他人可以从我们的失败中吸取教训。

但这并不都是坏消息。我们能够轻松地从错误中恢复,这让我们感到惊讶。我还将描述允许我们轻松改变我们的架构的因素,希望其他人也可以从我们的成功中学习。

1.没有分离持久化事件历史记录与持续查看当前状态之间关系
应用程序根据其历史事件维护了实体当前状态的关系模型。事件与状态之间是通过“projection投射”实施,这本身不会是坏事,但是,我们让命令处理程序记录事件的同时,还让它更新关系模型来实现当前状态。这意味着(a)无法确保从记录的事件中重建实体状态,并且(b)管理关系模型的迁移是一个重要的开销,而应用程序正在快速变化。

当然,这错过了采用事件溯源的全部意义吗?

嗯,是。人们来自不同背景和技术偏好的项目。我们中的一些人确实注意到该架构与事件溯源文献所描述的不同,但没有立即做出反应。我们希望团队(包括我们自己)能够建立对事件源架构中固有的优势,劣势和权衡的直觉,而不是应用千篇一律的模式风格。而且我们不知道这种混合架构将如何发挥作用 - 它可能对我们所知道的一切都非常成功 - 所以我们不想仅仅基于从技术文章和会议会议中收集的理论上的理解来忽视这个想法。因此,我们继续走这条路,直到上述困难明显超过了收益。然后我们进行了技术回顾,其中我们检查了规范事件源架构和我们的架构之间的差异。结果是我们都理解了为什么规范事件溯源架构比我们的应用程序的当前设计更好,并同意改变其架构以匹配。

2.事件驱动和事件源架构之间的混淆
在事件驱动的体系结构中,组件响应于接收事件而执行,并发出事件以触发其他组件中的事件;而在事件源体系结构中,组件记录它们管理的实体发生的事件的历史记录,并根据与其相关的事件序列计算实体的状态。

我们在两者之间感到困惑,并且在历史记录中通过一个组件触发其他活动来记录事件(将两者混合在一起)。
我们意识到,我们必须让实体在读取事件时候进行分辨以便逐个作出反应,读取事件是为了了解过去发生的事情,这里我们犯了一个错误。

3.使用事件存储作为消息总线
我们向事件存储中添加了通知,因此服务可以订阅更新并使其投射到最新状态。这是馊主意!我们的事件存储这时开始被用作事件总线啦,用于组件之间的瞬时通信了,我们的历史记录实际上还包括与业务流程没有明确关系的技术事件。
我们注意到,我们必须从显示给用户的历史记录中过滤技术事件。我们的有关于技术事件例如“尝试使用IOException发送电子邮件失败”,用户并不关心,他们希望看到业务流程的历史。

文献将事件源和事件驱动的架构描述为正交,这绊倒了我们,我们逐渐认识到,明确区分触发活动的命令和代表过去发生事件的事件比命令/查询责任隔离更重要!尤其是在我们系统的适度规模和严格的一致性要求下。

“事件”一词是一个过度使用的术语,我们讨论了如何命名不同类型的事件以区分事件源历史的一部分,包括我们的活动的监视应用发出的通知类型的事件,可能会该触发其他一些活动,等等。在我们的新应用程序中,对于我们的事件溯源中记录的历史事件,我们使用专门术语:"业务流程事件"。

4.被最终的一致性所诱惑
最初,我们为事件存储提供了一个HTTP接口和用于读取和存储事件的应用程序组件。但是,这意味着客户端无法处理ACID事务中的事件,我们发现自己在应用程序中构建机制以保持一致性。

注意到我们的错误
幸运的是,在我们的设计决策影响了我们的实时系统的事件历史之前,我们在常规架构“ wizengamot ”中早期发现了这些错误。
我们决定用直接数据库连接和可序列化事务替换命令处理器和事件存储之间的HTTP使用。我们保留HTTP服务以遍历事件历史记录,但仅限于维护读取优化视图的外围服务,这些视图最终可以保持一致(每日报告,业务指标)。

我们决定停止使用来自事件存储的通知来触发事件,而是重新使用REST(特别是HATEOAS)以方便地在组件之间传递数据和控制。

我们决定不更新命令处理程序中实体当前状态的记录。相反,当从数据库加载实体时,应用程序将根据事件历史记录计算当前状态。应用程序仍然维护当前实体状态的“投射”,同时将投影视为读取缓存,用于优化加载实体,以便它不必在每个事务上加载所有实体的事件,并且选择当前活动实体的子集,这样就不必加载所有实体的所有事件。缓存中事件条目会过期失效:每个投射都是一组表和函数,根据传递的每个事件,在这个表中创建,更新和删除相应的数据行作为响应。

执行命令的逻辑现在看起来像:

  1. 将实体的最近状态加载到内存模型中
  2. 在写入事务中[list=1]
  3. 加载那些自最近投射到内存模型以来实体发生的事件
  4. 执行业务逻辑
  5. 记录执行命令产生的事件
  • 将内存状态保存为最新投射,前提是:如果它是从最近的事件创建的,而不是根据当前持久化投射的(持久状态可能已被并发命令更新)
    读取事务不记录事件,因此可以彼此并行运行并写入事务。

    我们决定更换关系模型,这需要在应用程序发展时进行大量迁移,使用从域模型序列化的JSON blob,当持久化状态与最新版本的应用程序不兼容时,可以自动丢弃和重建。感谢Postgres的JSONB列,我们仍然可以索引实体状态的属性并批量选择实体,而无需添加非规范化数据列进行过滤。

    该应用程序还保留了其他用途的投射(投射是重播事件到状态),这些用途的一致性要求不太严格。例如,我们定期更新后台报告的预测。

    重新设计系统架构
    我们担心系统架构的这种重大变化会对我们的交付时间表造成打击。但事实证明这非常简单。

    除了使用事件源外,该应用程序还具有端口和适配器(又名“六边形”)架构。加载实体的当前状态对于由Adapter类实现的Port接口后面的应用程序逻辑是隐藏的。我的同事Ivan Sanchez能够将应用程序切换到从事件历史记录中计算实体的当前状态,并在大约一小时内将持久实体状态视为读取缓存(如上所述)。然后团队取代了关系模型,这需要在应用程序同时前进发展时进行大量迁移,从域模型序列化JSON blob,当持久化状态与最新版本的应用程序不兼容时,可以自动丢弃和重建。这一变化是在当天结束时进行的。

    我们还在我们的持续部署管道中运行了广泛的功能测试。这些是为了利用Ports-and-Adapters架构编写的,这种架构我们称之为“域驱动测试”。它们根据用户需求和问题域中的概念捕获应用程序的功能行为,而无需参考应用程序技术基础结构的详细信息。它们可以针对域模型,内存,针对应用程序服务的HTTP接口,或通过浏览器,针对在开发人员工作站上运行的实例或部署到我们的云环境中的实例运行。

    功能测试有两个目的,当我们不得不对应用程序的体系结构进行重大更改时,这些目的得到了很好的回报。
    首先,它们迫使我们遵循Ports-and-Adapters架构。我们的测试无法参考应用程序技术基础的详细信息(HTTP,数据库,用户界面控件,HTML,JSON等)。如果我们通过在HTTP适配器层编写业务逻辑来违反架构限制,我们会收到预警,因为编写一个可以单独针对域模型运行的测试变得不可能。
    因此,对应用程序技术体系结构的更改严格地与其功能行为的定义和实现分开,当我们更改体系结构时,这两者都不需要更改。这使他们能够实现第二个目的:快速验证应用程序仍然执行与我们对其体系结构进行大量更改相同的用户可见行为。

    结论
    在实施系统时,你不可避免地会犯错误,特别是在采用团队不熟悉的新架构风格时。系统的架构必须解决您从这些错误中恢复的方式。
    在我们的案例中,使用端口和适配器的六边形架构风格,团队拥有丰富的经验,并将测试和部署基础架构视为系统架构的重要组成部分,这使我们能够采用事件源,我们对此完全不熟悉,并且随着系统的发展,我们会从误解中恢复过来。