4

领域驱动设计:事件溯源架构简介

 1 year ago
source link: https://juejin.cn/post/7122768490863263781
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

事件溯源这个概念可能大家都有听说过。近期曾对事件溯源模式进行过相关的调研,以便去解决项目中关于历史数据追溯的需求。调研过程中也学到了一些相关的知识,就整理下来,和各位分享交流。

事件溯源架构通常由3种应用设计模式组成,分别是:事件驱动(Event Driven),事件溯源(Event Source)、CQRS。这三种应用设计模式常见于领域驱动设计(DDD)中,但它们本身是一种应用设计的思想,不仅仅局限于DDD,每一种模式都可以单独拿出来使用。

7fde469d29b442fba68b8f8e7abc9d4e~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp

Event Driven

在开发过程中,大家都经常使用到RocketMQ,其中的消息Message就可以认为是事件Event。事件驱动架构EDA 各位也都不陌生。本次就以事件驱动开始,来介绍事件溯源架构。

事件驱动是通过触发事件的方式,来进行服务间的通信,以达到服务解耦的目的。一般由三个部分组成:Event ProviderEvent RouterEvent Consumer

d6ab1666fc974e2fb4dbb16a811654e6~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp

事件驱动有两种处理模式:发布/订阅模式、事件流式处理模式。

  • 发布/订阅:发布订阅模式,是比较常见的事件驱动方式,由Provider发布事件,Consumer消费事件。事件在接收后,便无法重播,新加入的Consumer也看不见此事件。
  • 事件流式处理:Provider将事件经过排序后,持久化到存储中,Consumer可以读取该流的任何部分,新的Consumer可以随时加入,并可以重播事件。

其中事件流式处理模式中,将事件按顺序持久化,就稍稍带有事件溯源的意思了。不过事件溯源架构中,还是使用的发布/订阅的事件驱动模式。

Event Source

什么是事件溯源

事件溯源的定义

事件溯源是数据持久化的一种方式——对数据只做新增,不做修改和删除。在一些数据变更非常重要的业务场景,如财务、金融领域,可能会使用这种数据持久化方式。

和传统的面向状态的的数据持久化方式相比,事件溯源将每个引发数据变更的动作称之为事件,并把这种事件按照事情发生顺序存储起来。

与事件驱动的区别

事件溯源和事件驱动,都是以事件为本,事件是它们的核心组成部分,在使用事件驱动的时候也是将引发数据变更的动作称为事件,也会经常把事件按照顺序存储下来,所以有时候经常会把这两种模式混淆。

在此先明确下二者的区别:事件驱动是事件为基础,与其他服务边界进行通信。而事件溯源是一种数据持久化方式。

然后我们看下事件溯源的持久化方式,和其他的持久化方式有什么区别。

在涉及资金的领域,账户都是一个基础实体。用户可以进行开户操作,然后进行资金流转。此时会有一个账户余额,来表示该账户从一开始就发生的所有交易的总和。

假如张三开了一个账户,汇入10000¥,汇出1000¥,此后收入了1¥的利息。整个计算步骤如下:0¥ + 10000¥ - 1000¥ + 1¥ = 9001¥ 。我们有哪些方式,来持久化这种账户信息?

面向状态的持久化

面向状态的持久化,是指存储在数据库中的都是系统的当前状态,在进行CURD操作时,会使用新的数据,来替换旧的数据。在开发过程中,我们大多数场景都是使用的这种方式。

如果使用此方式来存储上述数据,我们会得到以下结果:

83a80d7d5cfa424eb54b71e4fcedecf4~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp

张三看到自己的账户余额,找到系统的客服,声称自己只有汇入,没有汇出。而系统里只有这一条余额数据,客服一时也不知道张三到底有多少余额。

面向历史的持久化

从各方面来说,数据变更历史都是有很大的价值的。为了不在CURD操作中丢失数据,便需要保留历史数据。通常有两种做法:

  1. 在Account表中引入一个Version字段,每一次数据变更旧数据不进行删除,新数据 Version = Version + 1,每次查询时,取Version最大的数据。
  2. 创建一个历史表Account_History,每一次数据变更,将变更前的数据保存在历史表中,Account表只保留最新的数据。

在保留历史数据后,我们可以得到以下结果:

b37121dc2fa84495a0a28b417a807b2c~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp

有了这些数据,张三就可以看到自己的余额变更历史了。但是张三又找到了客服,想咨询下为什么会多1¥。

可以看到,从账户开户后,每一次数据变更,我们都有相应的记录。 但是或多或少,还是会丢失一些信息,比如事情发生场景的上下文信息。在现实场景,可能就是我们不知道用户做了什么操作,导致数据发生变更。

面向事件的持久化

Change history of entities can allow access to previous states, but ignores the meaning of those changes. ——Eric Evans

实体的变更历史允许我们访问它之前的状态,但是忽略了这些变更的含义。

通常情况下,我们可以结合其他上下文信息,去判断当前上下文中数据变更的含义。比如可以通过交易订单、交易流水、系统日志等方式,来判断是什么原因导致的账户余额变更。

而事件溯源也是一种解决方案,数据溯源本身具有零数据丢失特性

在事件溯源中,将实体的变更表示为一系列描述具体变化的事件,并将这些事件持久化。通过这些代表着系统变更事实的事件,可以随时获取数据当前的状态。

0b5d1848ba444b458e6bfbdd45a814ed~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp

事件溯源的优缺点

事件溯源的优点,主要是来源于事件的不变性,以及丰富的业务含义。

零数据丢失

在普通的CURD系统中,可能每次操作,都会造成一些数据的丢失:要么是新数据覆盖了旧数据;要么是丢失数据发生变更的上下文。

在事件溯源中,所有的操作都是基于事件去处理的。每一个事件都明确的代表着系统中的某一个操作,并且记录着这项操作所有相关的数据内容,可以清晰的从一个事件流中,看到一条数据的每一次变更,以及变更目的。

如:开户 -> 收款 -> 购买 -> 利息。

重播

事件是按照事情发生顺序,流式排列。我们可以将事件流重定位到某个具体的时间点,重新执行该事件流中的事件,从而进行业务重试、异常分析、测试验证等。

比如在进行测试的时候,我们走完了一个测试流程,下一次再进行回归的时候,只用重播一下相关的事件就行。

由于可以随时对已发生的事件进行重播,所以由事件触发的任何操作,都具备重试、重新构建的能力。

审计

我们有多种用于应对审计的方式,比如:

  1. 记录数据变更历史,或者是记录全量数据变更binlog。但是这种方式很难去发掘当时数据变更的场景。
  2. 记录用户操作日志。操作日志是能体现出用户具体行为,也包含上下文信息,不过我们一般只是对一些重要操作、重要字段变更,单独记录操作日志。并且由于操作日志只是数据变更的附属品,并不是数据变更本身,我们也很难保证二者的一致性。

而事件溯源将数据存储为一系列不可变的事件,并且带有丰富的上下文信息。这就提供了强大的审计功能。

在事件溯源中,异步优先,力求实现最少的同步交互,也带来了一系列的问题。

非强一致性

应用程序将事件添加到事件存储作为处理请求的结果、发布事件和事件使用者处理事件之间存在一定程度的延迟。 在此期间,描述实体的进一步更改的新事件可能已发生。

事件使用者有更严格的 幂等 要求

事件发布可能至少为一次,因此事件使用者必须是幂等的。 处理不好的话,可能会带来很多问题。

如何定义事件

事件代表已发生的、代表特定业务含义的事实。命名时通常使用“过去式”,如“已开户”AccountOpened

一个事件一般包含以下元素:

  • 事件ID:唯一的事件标识符。
  • 事件类型:事件的类型,如“开户事件”。
  • 事件流ID:事件流的唯一标识符,如账户ID。在DDD中,通常是一个领域上下文的聚合根。
  • 流位置:表示事件在流中的位置,递增编号即可,也可用主键ID代替。
  • 时间戳:表示事件发生的时间。
  • 事件体:通常存放处理该事件,所需要的上下文数据。

代码示例:

public interface Event {
  /**
* 事件ID
*/
Number getEventId();
  /**
* 事件流ID
*/
Number getStreamId();
  /**
* 事件类型
*/
EventTypeEnum getEventType();
  /**
* 事件发生时间
*/
LocalDateTime getWhen();
  /**
* 事件内容
*/
String getBody();
}
复制代码
public class AccountOpened implements Event, Serializable {

    private static final long serialVersionUID = 6092705941707975469L;
    private long eventId;
    private long streamId;
    private AccountOpenedInfo eventBody;
    private LocalDateTime when = LocalDateTime.now();
    private EventTypeEnum eventType = EventTypeEnum.ACCOUNT_OPENED;

    @Override
    public String eventBody() {
        return JSONUtil.toJSONString(eventBody);
    }

    @Override
    public Class<?> eventBodyCls() {
        return AccountOpenedInfo.class;
    }
}
复制代码

事件组成事件流

多个相关的事件,组成事件流。

在传统的持久化方式下,一个账户对应数据库中的一条记录,我们可以根据账户的ID,去获取该账号的数据实体。

在事件溯源模式下,我们根据账户的ID,会获取到多个数据实体,每一个都是一个具体的事件。也就是说,代表单个账户的的整个事件集,共同有一个代表账户的唯一ID,我们将这个事件集看为一个流(Stream)

流是系统中单个对象的有序事件集合。每个事件在流中都有的位置(StreamPos),该位置通常由一个递增的数字表示,可以是递增的主键ID,也可以是版本号。

事件组成事件流后,通过流聚合,来获取对象的当前状态。步骤如下:

  1. 读取特定流的所有事件。
  2. 按出现顺序(按事件的流位置)升序排列它们。
  3. 构造实体类型的空对象(例如使用默认构造函数)。
  4. 将每个事件应用于实体。

EventSore

根据上面的介绍,我们知道了事件Event和流Stream,并且知道了它们的属性,所以我们可以轻易的在关系型数据库中建立数据实体模型,也就是streamTableeventTable,这两张表构成了EventStore,我们可以把系统中的所有的事件、流,存储到这个EventStore中。

事件溯源数据库

如果在事件溯源模式下,我们只有这一种写入需求,有什么其他的持久化方式吗?

如果在事件溯源模式下,我们只有这一种写入需求,不存在其他的数据模型。那么现有的数据库对于我们来说,功能是不是太全面了?然后在事件溯源中,有一个“投影” 的概念(下文介绍),传统数据库对这种“投影”的查询模式来说,支持的又稍显不足。

再次回顾一个概念:事件是不可变的并且是不断追加的,新的事件按照事实发生顺序追加到前一条事件后。这种追加写入的方式,又很类似传统数据库中的事务日志。所以,就有人提出了一个“拆分”数据库(“Unbundling DB”)的概念。简单来说,Unbundling DB就是将传统数据库中的“事务日志”流看做最重要的一部分,然后将传统数据库的其他功能进行拆分,拆分成可以灵活组合的模块化组件。

暂时无法在飞书文档外展示此内容

举个例子,Apache Kafka + Apache Samza 就可以看作一个事件存储数据库,Kafka用来存储追加写入的“事务日志”,Samza用来将这些“事务日志”物化成可供查询的视图。

事件存储数据库就是基于Unbundling DB的理念,针对追加写入、发布事件流进行优化的数据库,然后将这些事件“投影”到针对特定查询场景优化的读取模型中。已有的事件存储数据库如:Event Store DB

上文只介绍了事件溯源的写入方式,简单提到了“投影”,由于事件溯源天然和CQRS相契合,所以在此对CQRS进行一个简单的介绍,并介绍在CQRS + Event Source模式下,如何进行读取。

什么是CQRS

最早提出的是CQS(Command Query Separation 命令查询分离)的概念。它把方法区分为两种类型:

  1. Command:命令方法负责修改数据但不返回数据。
  2. Query:查询方法只返回数据,不对数据进行修改。

概念比较简单,就算避免一个方法既做写入,又做查询。大家现在写的方法,基本上都符合CQS的理念,在大家平常写的方法上融入命令模式,就是一个标准的CQS。

后续又在CQS的基础上提出了CQRS(Command and Query Responsibility Segregation 命令查询职责分离)的概念。CQS是方法的分离,CQRS是在方法分离的基础上,把一个模型分为两个:读模型、写模型。

CQS是应用开发过程中的开发建议,CQRS是一个应用设计模式。

几种CQRS模式

我们已经知道了CQRS模式本身已经把读写模型分离,然后根据是否将数据存储分离,引申出两种CQRS的架构模式:共享存储下的CQRS、分离存储下的CQRS。

共享存储/共享模型

传统开发过程中,我们可能下意识的把读写方法分离开,但是依然共享着模型。

共享存储/分离模型

共享数据存储,代码中分别针对写入和查询建立不同的模型,写模型只用于写入,读模型只用于查询。避免了共享模型下,为了满足查询需求,在模型中加入许多和写入无关的属性。

分离存储/分离模型

这种架构模型下,存储和模型都是分离的。

写存储和读存储可以是一样的:常见于读写分离,读从库,写主库,读从库时可以针对查询进行优化,比如联表查询。

写存储和读存储也可以是不一样的:写操作使用针对写入进行优化的数据存储设施,读操作使用针对查询进行优化的数据存储设施。

事件溯源下的CQRS

You need to look at CQRS not as being the main thing. CQRS was a product of its time and meant to be a stepping stone towards the ideas of Event Sourcing. ——Greg Young

你需要把 CQRS 看成不是主要的东西。CQRS 是当时的产物,意在成为事件溯源理念的垫脚石。

上面提到了分离存储/分离模型的CQRS架构模式,再回顾下事件存储数据库,就能明白为何CQRS和Event Source天生契合。事件存储数据库,就是针对仅追加写入和发布事件流进行优化,将这些事件“投影”到针对特定查询场景优化的读取模型中。

投影Projection),也可称为视图模型或查询模型,可以理解为是不同视角的对象的表示,也是“具体化视图模式”的一种体现。通常代表将写入的事件流,转化成读/写模型的逻辑。

例如,我们通过处理事件流,将处理后的结果存储到关系型数据库上,然后去查询该数据库上的数据,此时的关系型数据库上的数据就是一个投影。并且投影是无状态的,无论对投影做任何处理,都不会影响到原始数据。

投影是廉价的,可随时创建、随时销毁、随时重构。

在系统设计过程中,我们一般第一步会进行数据建模,按照三范式或者某种适合业务处理的方式,建立一系列的表。这些表建立后,就很难进行比较大的表结构变更,特别当业务比较复杂、数据量比较大的时候,改变表结构的代价是很大的。

然后在进行功能开发过程中,我们会去把数据按照业务方的需求展示出来,如果碰到比较复杂的查询场景,我们要么去联表查询,要么一张一张的表去查过去。有时候为了满足查询需求,我们会在一些表上加入冗余字段,越加越多,最终让一张核心业务表变成拥有很多冗余字段的大表。

而CQRS + EventSource,则很好的解决了这种问题。它可以随意创建新的读取模型,其所创建的表都是为查询服务的,可以专门生成一张含有所有查询结果集的表,以支持高效查询。如果关系型数据库不满足查询需求,比如慢SQL或者全文搜索,这时候就可以在Elastic Search上构建一个新的投影,以支持查询。

随着DDD、CQRS、事件溯源概念的流行,也陆续出现了一些开发框架,比如Axon、Cola等。在此也进行简单的介绍。

Axon

Axon 框架的程序遵循基于领域驱动设计(DDD)、命令查询责任隔离 (CQRS)、事件驱动架构(Event Driven Architecture,EDA)、事件溯源(ES)。这些模式的结合,使基于 Axon 的应用程序更加健壮、适应性更强。

Axon在结合 DDD 和 CQRS 后,将应用程序划分为组件,每个组件都有自己专一的职责,要么提供有关应用程序状态的信息,要么更改应用程序的状态。Axon典型架构如下:

在Axon中,消息对象(Messaging Object)是Axon 的核心概念,其将消息对象大致分为3类:

  1. 命令: 代表写操作,会影响应用程序状态。命令被路由到单个组件处理,并返回处理结果。
  2. 查询: 代表读操作,不会影响应用程序状态。根据调度策略,查询可能同时被路由到一个或多个目的地。
  3. 事件: 表示发生相关事情的通知。事件被发布到任何感兴趣的组件。

Axon通过事件溯源模式,对事件消息进行处理,以便提供可靠的审计追踪以及构建视图模型。

COLA

COLA (Clean Object-Oriented and Layered Architecture)表示“面向整洁对象的分层架构”,和Axon这种成熟的开发框架相比,COLA更像是一个DDD模式下,应用分层的另一种方式,比如DDD中的分层架构、六边形架构等。

COLA基于其应用分层理念,提供了应用创建的脚手架,并提供了一些通用组件。在COLA 4.0中,将COLA分成两个部分:COLA框架、COLA组件。

  1. COLA架构:关注应用架构的定义和构建,提升应用质量。
  2. COLA组件:提供应用开发所需要的可复用组件,提升研发效率。

这里我们提到了事件溯源架构的三种应用设计模式,Event Driven 用于服务间交互、Event Source 用于事件持久化、CQRS用于读模型查询,相互配合。

除此之外应用程序架构方式和设计模式有很多,我们在应用设计、开发过程中,可能很难去真正落地,不过我们可以领会到这些架构方式、设计模式的核心思想。在平常开发过程中,能够结合其中的一些思想,为某些问题提供更好的解决方案,更好的为业务服务。

再附上《Azure 应用程序体系结构基本信息指南》,供大家参考。

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

微信扫码发现职位&投递简历

52e17995b6db417e8d343c6f21bf8ec6~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp

官网投递job.toutiao.com/s/FyL7DRg


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK