事件溯源这个概念可能大家都有听说过。近期曾对事件溯源模式进行过相关的调研,以便去解决项目中关于历史数据追溯的需求。调研过程中也学到了一些相关的知识,就整理下来,和各位分享交流。
概述
事件溯源架构通常由3种应用设计模式组成,分别是:事件驱动(Event Driven),事件溯源(Event Source)、CQRS。这三种应用设计模式常见于领域驱动设计(DDD)中,但它们本身是一种应用设计的思想,不仅仅局限于DDD,每一种模式都可以单独拿出来使用。
Event Driven
在开发过程中,大家都经常使用到RocketMQ
,其中的消息Message
就可以认为是事件Event
。事件驱动架构EDA
各位也都不陌生。本次就以事件驱动开始,来介绍事件溯源架构。
事件驱动是通过触发事件的方式,来进行服务间的通信,以达到服务解耦的目的。一般由三个部分组成:Event Provider
、Event Router
、Event Consumer
。
事件驱动有两种处理模式:发布/订阅模式、事件流式处理模式。
- 发布/订阅:发布订阅模式,是比较常见的事件驱动方式,由Provider发布事件,Consumer消费事件。事件在接收后,便无法重播,新加入的Consumer也看不见此事件。
- 事件流式处理:Provider将事件经过排序后,持久化到存储中,Consumer可以读取该流的任何部分,新的Consumer可以随时加入,并可以重播事件。
其中事件流式处理模式中,将事件按顺序持久化,就稍稍带有事件溯源的意思了。不过事件溯源架构中,还是使用的发布/订阅的事件驱动模式。
Event Source
什么是事件溯源
事件溯源的定义
事件溯源是数据持久化的一种方式——对数据只做新增,不做修改和删除。在一些数据变更非常重要的业务场景,如财务、金融领域,可能会使用这种数据持久化方式。
和传统的面向状态的的数据持久化方式相比,事件溯源将每个引发数据变更的动作称之为事件,并把这种事件按照事情发生顺序存储起来。
与事件驱动的区别
事件溯源和事件驱动,都是以事件为本,事件是它们的核心组成部分,在使用事件驱动的时候也是将引发数据变更的动作称为事件,也会经常把事件按照顺序存储下来,所以有时候经常会把这两种模式混淆。
在此先明确下二者的区别:事件驱动是事件为基础,与其他服务边界进行通信。而事件溯源是一种数据持久化方式。
然后我们看下事件溯源的持久化方式,和其他的持久化方式有什么区别。
在涉及资金的领域,账户都是一个基础实体。用户可以进行开户操作,然后进行资金流转。此时会有一个账户余额,来表示该账户从一开始就发生的所有交易的总和。
假如张三开了一个账户,汇入10000¥,汇出1000¥,此后收入了1¥的利息。整个计算步骤如下:0¥ + 10000¥ - 1000¥ + 1¥ = 9001¥ 。我们有哪些方式,来持久化这种账户信息?
面向状态的持久化
面向状态的持久化,是指存储在数据库中的都是系统的当前状态,在进行CURD操作时,会使用新的数据,来替换旧的数据。在开发过程中,我们大多数场景都是使用的这种方式。
如果使用此方式来存储上述数据,我们会得到以下结果:
张三看到自己的账户余额,找到系统的客服,声称自己只有汇入,没有汇出。而系统里只有这一条余额数据,客服一时也不知道张三到底有多少余额。
面向历史的持久化
从各方面来说,数据变更历史都是有很大的价值的。为了不在CURD操作中丢失数据,便需要保留历史数据。通常有两种做法:
- 在Account表中引入一个Version字段,每一次数据变更旧数据不进行删除,新数据
Version = Version + 1
,每次查询时,取Version最大的数据。 - 创建一个历史表Account_History,每一次数据变更,将变更前的数据保存在历史表中,Account表只保留最新的数据。
在保留历史数据后,我们可以得到以下结果:
有了这些数据,张三就可以看到自己的余额变更历史了。但是张三又找到了客服,想咨询下为什么会多1¥。
可以看到,从账户开户后,每一次数据变更,我们都有相应的记录。 但是或多或少,还是会丢失一些信息,比如事情发生场景的上下文信息。在现实场景,可能就是我们不知道用户做了什么操作,导致数据发生变更。
面向事件的持久化
Change history of entities can allow access to previous states, but ignores the meaning of those changes. ——Eric Evans
实体的变更历史允许我们访问它之前的状态,但是忽略了这些变更的含义。
通常情况下,我们可以结合其他上下文信息,去判断当前上下文中数据变更的含义。比如可以通过交易订单、交易流水、系统日志等方式,来判断是什么原因导致的账户余额变更。
而事件溯源也是一种解决方案,数据溯源本身具有零数据丢失特性。
在事件溯源中,将实体的变更表示为一系列描述具体变化的事件,并将这些事件持久化。通过这些代表着系统变更事实的事件,可以随时获取数据当前的状态。
事件溯源的优缺点
事件溯源的优点,主要是来源于事件的不变性,以及丰富的业务含义。
零数据丢失
在普通的CURD系统中,可能每次操作,都会造成一些数据的丢失:要么是新数据覆盖了旧数据;要么是丢失数据发生变更的上下文。
在事件溯源中,所有的操作都是基于事件去处理的。每一个事件都明确的代表着系统中的某一个操作,并且记录着这项操作所有相关的数据内容,可以清晰的从一个事件流中,看到一条数据的每一次变更,以及变更目的。
如:开户 -> 收款 -> 购买 -> 利息。
重播
事件是按照事情发生顺序,流式排列。我们可以将事件流重定位到某个具体的时间点,重新执行该事件流中的事件,从而进行业务重试、异常分析、测试验证等。
比如在进行测试的时候,我们走完了一个测试流程,下一次再进行回归的时候,只用重播一下相关的事件就行。
由于可以随时对已发生的事件进行重播,所以由事件触发的任何操作,都具备重试、重新构建的能力。
审计
我们有多种用于应对审计的方式,比如:
- 记录数据变更历史,或者是记录全量数据变更binlog。但是这种方式很难去发掘当时数据变更的场景。
- 记录用户操作日志。操作日志是能体现出用户具体行为,也包含上下文信息,不过我们一般只是对一些重要操作、重要字段变更,单独记录操作日志。并且由于操作日志只是数据变更的附属品,并不是数据变更本身,我们也很难保证二者的一致性。
而事件溯源将数据存储为一系列不可变的事件,并且带有丰富的上下文信息。这就提供了强大的审计功能。
在事件溯源中,异步优先,力求实现最少的同步交互,也带来了一系列的问题。
非强一致性
应用程序将事件添加到事件存储作为处理请求的结果、发布事件和事件使用者处理事件之间存在一定程度的延迟。 在此期间,描述实体的进一步更改的新事件可能已发生。
事件使用者有更严格的 幂等 要求
事件发布可能至少为一次,因此事件使用者必须是幂等的。 处理不好的话,可能会带来很多问题。
如何定义事件
事件代表已发生的、代表特定业务含义的事实。命名时通常使用“过去式”,如“已开户”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,也可以是版本号。
事件组成事件流后,通过流聚合,来获取对象的当前状态。步骤如下:
- 读取特定流的所有事件。
- 按出现顺序(按事件的流位置)升序排列它们。
- 构造实体类型的空对象(例如使用默认构造函数)。
- 将每个事件应用于实体。
事件存储
EventSore
根据上面的介绍,我们知道了事件Event
和流Stream
,并且知道了它们的属性,所以我们可以轻易的在关系型数据库中建立数据实体模型,也就是streamTable
和eventTable
,这两张表构成了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 命令查询分离)的概念。它把方法区分为两种类型:
- Command:命令方法负责修改数据但不返回数据。
- 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类:
- 命令: 代表写操作,会影响应用程序状态。命令被路由到单个组件处理,并返回处理结果。
- 查询: 代表读操作,不会影响应用程序状态。根据调度策略,查询可能同时被路由到一个或多个目的地。
- 事件: 表示发生相关事情的通知。事件被发布到任何感兴趣的组件。
Axon通过事件溯源模式,对事件消息进行处理,以便提供可靠的审计追踪以及构建视图模型。
COLA
COLA (Clean Object-Oriented and Layered Architecture)表示“面向整洁对象的分层架构”,和Axon这种成熟的开发框架相比,COLA更像是一个DDD模式下,应用分层的另一种方式,比如DDD中的分层架构、六边形架构等。
COLA基于其应用分层理念,提供了应用创建的脚手架,并提供了一些通用组件。在COLA 4.0中,将COLA分成两个部分:COLA框架、COLA组件。
- COLA架构:关注应用架构的定义和构建,提升应用质量。
- COLA组件:提供应用开发所需要的可复用组件,提升研发效率。
总结
这里我们提到了事件溯源架构的三种应用设计模式,Event Driven 用于服务间交互、Event Source 用于事件持久化、CQRS用于读模型查询,相互配合。
除此之外应用程序架构方式和设计模式有很多,我们在应用设计、开发过程中,可能很难去真正落地,不过我们可以领会到这些架构方式、设计模式的核心思想。在平常开发过程中,能够结合其中的一些思想,为某些问题提供更好的解决方案,更好的为业务服务。
再附上《Azure 应用程序体系结构基本信息指南》,供大家参考。
加入我们
我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
微信扫码发现职位&投递简历