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

3,596 阅读18分钟

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

概述

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

Event Driven

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

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

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

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

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

Event Source

什么是事件溯源

事件溯源的定义

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

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

与事件驱动的区别

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

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

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

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

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

面向状态的持久化

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

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

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

面向历史的持久化

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

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

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

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

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

面向事件的持久化

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

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

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

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

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

事件溯源的优缺点

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

零数据丢失

在普通的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进行一个简单的介绍,并介绍在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、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

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

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