相较于单纯将数据的当前状态存储在域内,利用附加专用存储资源记录指向该数据的全部操作显然更具指导意义。这里的存储资源充当记录系统,且可用于域对象的确切实现。通过这种方式,我们能够通过回避数据模型与业务域同步以简化复杂域中的各类任务,同时提升性能水平、可扩展性以及响应能力。此外,这种方式还能够实现事务数据一致性,且保留完整的审计追踪与历史记录信息,以备后续执行补偿性操作。
背景与问题
大多数应用程序都需要使用数据,而最为典型的使用方式在于随用户使用对数据状态进行更新,从而保持其始终处于最新。举例来说,在传统的创建、读取、更新与删除(简称 CRUD)模式当中,典型的数据流程为自存储机制内读取数据、对数据进行修改、利用新值对数据当前状态进行更新——更新方式通常采用需要锁定数据的事务。
CRUD 方法存在以下局限:
- CRUD 系统会直接针对数据存储区执行更新操作,这可能会降低性能水平与响应速度,同时因处理资源开销的存在可限制可扩展能力。
- 在存在大量并发用户的协作域中,单一数据项上的更新操作可能导致数据更新冲突。
- 除非在独立日志内存在负责记录各项操作信息的额外审计算法,否则历史记录会遭遇丢失。
感兴趣的朋友可以点击此处参阅《CRUD:您能否承受得了》一文中提出的更多 CRUD 局限性论点。
解决方案
事件驱动模式定义了一种新的事件序列驱动型数据操作处理方法,其中每一事件都被记录在仅附加存储机制内。应用程序代码向事件存储发送一系列事件以强制描述指向数据的各项操作,并借此实现记录保留。每个事件代表一组数据变更(例如 AddedItemToOrder)。
这些事件长期驻留在事件存储之内并作为与数据当前状态相关的记录系统存在(即权威数据源)。事件存储通常会发布这些事件以面向消费方交付通知,并可据此进行处理。举例来说,消费方可以将事件中的操作应用于其它系统任务,或者用以执行完成该操作所必需的其它相关操作。需要注意的是,这部分生成事件的应用程序代码与描述各事件的系统并不存在耦合关系。
作为事件存储所发布事件的典型使用方式,我们通常需要维护应用程序对各实体作出变更时的物化视图,并可将其与外部系统相集成。举例来说,一套系统可以保留一份用于填充 UI 内各部分之客户命令的视图。随着该应用程序添加更多新命令,对命令内的条目进行添加或移除,或者添加其它发布信息,这些用于描述对应变更的事件可接受处理并用于更新这份物化视图。
另外,应用程序可以随时读取事件的历史记录,并对同该实体相关的所有事件进行回放与消费,借此重新实现该实体的当前状态。我们可以在处理请求时建立域对象或者经由一套计划内任务实现这样的效果,意味着目标实体的状态可作为物化视图进行存储以支持表示层。
此图显示了这一模式的总体原理,其中包括部分用于创建物化视图、整合事件与外部应用程序及系统以及回放事件以建立特定实体当前状态预测等事件流的相关选项。
事件驱动模式拥有以下突出优势。
事件本身拥有恒定属性,且可利用仅附加操作实现存储。需要对事件进行初始化的用户界面、工作流或者流程能够继续执行,而事件处理任务则在后台完成。这种方式符合事务处理期间无争用原则的要求,可显著提高应用程序性能及可扩展性——特别是对于表示层或者用户界面而言。
事件属于负责描述所发生操作的简单对象,其可与任意相关数据相结合以描述事件表现出的活动。事件不会直接对数据存储进行更新。其进行简单记录以备时间合适时进行进一步处理。这种方式能够显示简化实现与管理流程。
事件通常能够极大帮助域专家的日常工作,特别是考虑到对象关系阻抗不匹配往往会生成大量难以理解的复杂数据库表。表属于负责表示系统(而非所发生事件)当前状态的人造结构。
事件驱动机制有助于防止因并发更新导致的冲突问题,因为其能够避免对数据存储中的对象进行直接更新。然而,域模型在设计当中仍需要考虑到可能因请求引发的状态不一致问题。
仅附加事件存储机制还提供审计追踪能力,可通过事件随时回放以监控指向数据存储的操作,将当前状态重新生成为物化视图或预测,从而协助实现系统测试与调试。另外,要求使用补偿性事件以撤销变更的要求则带来可供检索的变更历史记录——这一点在直接存储当前状态的模式当中显然无法实现。事件列表亦可用于分析应用程序性能并检测用户活动趋势,或者获取其它具有现实意义的业务信息。
事件存储负责提供事件,而任务则根据这些事件进行响应式执行。将任务从事件当中解耦出来能够提升灵活性与可扩展性。任务能够获取事件类型与事件数据,但无法获得触发该事件的具体操作。另外,可由多项任务同时处理单一事件。这意味着我们能够轻松将事件存储提供的事件同与之相关的监听服务及系统加以集成。然而,事件驱动类事件往往处于较低层级,我们可能需要生成特定集成事件加以替代。
事件驱动通常会与 CQRS 模式相结合,即在事件响应当中执行数据管理任务并立足存储事件进行视图物化。
问题与注意事项
在决定如何使用此种模式时,请考虑以下几项要点。
这套系统只有在创建物化视力或者通过回放事件生成数据预测时才会实现最终一致性。由于存在请求处理、事件发布以及事件消费等过程,因此应用程序添加事件与事件存储之间存在一定延后。在此期间,描述实体后续变更的新事件可能仍在持续抵达事件存储。
备注
关于最终一致性相关信息,请点击此处参阅《数据一致性入门》。
事件存储属于信息的最终来源,因此事件数据永远不应进行更新。对实体加以更新以撤销变更的惟一方式在于向事件存储添加一项补偿事件。如果需要在迁移等阶段对该持久事件的格式(而非数据本身)进行变更,则很难将现有事件的新版本添加至事件存储当中。大家可能需要对全部事件进行变更以完成迭代——只有这样才能确保各事件皆与新格式相兼容 ; 或者添加使用新格式的新事件。大家可以考虑在事件模式的各个版本上添加版本戳以同时保留新旧两种事件格式。
多线程应用程序与多应用程序实例可能会将事件存储在事件存储之内。事件存储内的事件一致性非常重要,这会影响到特定实体的事件顺序(实体所发生的变更顺序会影响到其当前状态)。为每个事件添加时间戳有助于避免此类问题。另一种常见操作在于利用包含增量标识符的请求对各个事件进行注释。如果有两项操作同时尝试为同一实体添加事件,则事件存储可拒绝其中与现有实体标识符及事件标识符相匹配的事件。
并不存在 SQL 查询这类可用于读取事件并获取信息的标准化方案或者现有机制。提取此类数据的惟一方法在于使用将事件标识符作为标准的事件流。事件 ID 通常映射至各独立实体。实体的当前状态仅可通过回放全部与之相关的事件,并与该实体初始状态相比较的方式进行检查。
每个事件流的长度会对系统的管理与更新造成影响。如果流长度过大,则应考虑以特定间隔创建快照——例如将指定数量的事件作为间隔依据。大家可通过此快照并回放时间点后所发生的一切事件获取实体的当前状态。欲了解更多与数据快照创建相关的细节信息,请参阅 Martin Fowler 企业应用架构网站上的快照与主 - 从快照副本资料。
尽管事件驱动机制能够最大程度降低数据更新冲突的发生机率,但应用程序仍然需要有能力应对最终一致性机制以及事务缺少所带来的不一致性问题。举例来说,就在某一指示库存量减少的事件被发送至数据存储端的同时,可能出现一项指向某条目的订单——意味着可能需要建议客户回退订单以协调这两项操作。
事件发布可能发生“至少一次”,因此该事件的消费方必须为幂等。如果该事件被处理多次,则绝不可重复使用事件中描述的更新状态。举例来说,如果某一消费方的多个实例始终聚合某实体的属性——例如订单总量,则必须要在订单下达成功时方可进行聚合增量。虽然这并不属于事件驱动机制的关键性特征,但在实践使用当中却非常常见且至关重要。
何时使用此模式
我们建议大家在以下场景中使用这一模式:
- 希望获取数据意图、目的或者原因时。举例来说,您可以将指向某消费方实体的变更提取为特定事件类型序列,包括 Moved home、Closed account 或者 Deceased 等。
- 需要尽可能或者彻底避免并发数据更新引起的冲突时。
- 希望记录所发生事件,且能够通过回放实现系统恢复、变更回滚或者保留历史及审计日志时。举例来说,如果某一任务涉及多个步骤,您可能需要执行相关操作以不愿更新,并稍后回放部分步骤以确保数据回归一致状态。
- 事件使用属于应用程序运作的自然特性,且需要尽可能减少额外开发或者实现工作时。
- 需要将数据的输入或更新流程从应用这些操作的任务当中解耦出来时。这种作法能够提升 UI 性能,或者在事件发生时将各事件分发至其它监听程序处以完成操作。举例来说,将工资单系统与费用提交网站相结合,确保事件存储提供的响应结果能够反映出供网站本身及工资单系统使用的数据更新。
- 希望具备充足灵活性以变更物化模式格式,并在要求变更时调整实体数据格式时 ; 或者与 CQRS 结合使用并需要适应读取模式或者数据显示视图时。
- 配合 CQRS 使用且在读取模式更新时可接受最终一致性时 ; 或者可接受由事件流内实体与数据流化所带来的性能影响时。
此模式并不适用于以下场景:
- 业务逻辑较少甚至不存在业务逻辑的系统、小型或者简单域,或者天然适用于传统 CRUD 数据管理机制的非域系统。
- 需要保持数据视图一致性及实时更新的系统。
- 不需要实现审计追踪、历史及回滚与回放操作的系统。
- 底层数据更新冲突率极低的系统。例如那些主要负责添加数据而非更新数据的系统。
示例
会议管理系统需要追踪会议预约的完成次数,以便在潜在参与者尝试加入时查看是否还有空余座位。此类系统至少能够通过以下两种方式存储会议的总体预订量:
- 系统可将与订阅总量相关的信息在预订信息数据库内存储为独立实体。在发生预订生成或者撤销时,该系统可酌情增加或者减少这一数字。这种方法在理论上非常简单,但如果大量与会者在短时间内同时尝试预订座位,则可能造成可扩展性问题。这种情况往往出现在预约周期的最后一天之内。
- 系统可将预订与撤销信息作为事件存储在事件存储之内。其随后可通过回放这些事件完成座位数量计算。这种方法凭借着事件恒定特性而拥有更出色的可扩展性。系统仅需要从事件存储内或者事件存储的附加数据处读取数据。与预订及撤销相关的事件信息永远不再修改。
以下图表展示了如何利用事件驱动机制实现会议管理系统当中的座位预留子系统。
保留两个座位的具体操作流程如下:
- 用户界面发送命令为两位与会者保留座位。此命令由独立的命令处理程序负责处理。其中一部分逻辑从用户界面内解耦出来,且负责处理命令发布的实际请求。
- 通过查询预订与撤销事件聚合全部与同会议座位保留相关的内容信息。将此聚合命名为 SeatAvailability,将其容纳于一套域模型内,并公开用于查询及修改聚合内数据的方法。
- 亦可考虑利用其它一些采用快照的优化方案(您无需查询并回放完整的事件列表即可获取该聚合的当前状态),同时在内存中保留该聚合的缓存副本。
- 命令处理程序调用由该域模型提供的方法以进行预留。
- SeatAvailability 聚合记录一个包含全部已预留座位数量的事件。在该聚合下一次应用事件时,所有预留量皆可用于计算剩余的座位数字。
- 系统向事件存储当中的事件列表添加新事件。
如果用户撤销预留座位,系统将采取类似的流程——不同之处在于利用命令处理程序发布命令以生成座位撤销事件,并将其添加至事件存储内。
除了提供更理想的可扩展性,使用事件存储还能够为会议的预订与取消工作提供完整的历史记录或审计追踪素材。事件存储中的事件得到准确记录。另外,不必以任何其它方式持久保留聚合,因为系统能够轻松进行事件回放并将状态恢复至任意时间点。
您可以点击此处了解更多与事件驱动相关的示例。
相关模式与指南
以下模式与指南资料同样可帮助您更好地理解事件驱动机制:
- 命令与查询责任拆分(简称 CQRS)模式。其中负责为 CQRS 实现方案提供永久信息源的写入存储通常基于事件驱动模式实现。此链接介绍了如何利用独立接口实现数据更新,从而将应用程序内的数据读取同更新数据操作区分开来。
- 物化视图模式。此事件驱动型系统中使用的数据存储通常并不适用于高效查询。相反,这是一种以定期间隔或者在数据变更时生成预填充视图的常用方法。
- 补偿转换模式。事件驱动存储内的现有数据并不会更新,而是通过添加新实体的方式将实体状态转换为新数值。要撤销变更则需要采用补偿性实体,因为这种模式无法轻松恢复至原有变更状态。此链接介绍了如何撤销此前操作所执行的工作。
- 数据一致性入门。在配合独立读取存储或者物化视图使用事件驱动机制时,读取数据无法立即实现一致性,而仅可实现最终一致性。此链接汇总了保持分布式数据一致性方面的各类问题。
- 数据分区指南。在利用事件驱动机制改进可扩展性、减少争用并优化性能时,我们通常需要对数据进行分区。此链接描述了如何将数据拆分为多个离散分区,以及其它可能由此引发的问题。
- Greg Young 撰写的博文《为什么要使用事件驱动机制?》。
查看 **** 原文链接: Event Sourcing pattern
评论