事件驱动与 Actor 模型

 

一 什么是事件驱动架构

Event-driven architecture (EDA) is a software architecture paradigm promoting the production, detection, consumption of, and reaction to events. – – by Wikipedia

1.1 什么是事件

首先明确下,什么是事件:事件是已经发生的事实,并且不可变。就程序设计领域来说,是指系统硬件或软件的状态出现任何重大改变。

事件与事件通知不同,后者是指系统发送的消息或通知,用于告知系统的其他部分有相应的事件发生。而事件的来源可能是内部也可能是外部原因。事件可以来自用户(例如点击鼠标或按键)、外部源(例如传感器输出)或系统(例如加载程序)。

例如:“创建订单”是个指令,而“订单已创建”就是个事件,订单的信息、订单创建的时间,在这个事件发生后就无法被修改。

1.2 事件驱动架构

事件驱动架构(EDA)是一种软件架构范式,其定义了一个设计和实现一个应用系统的方法学,在这个系统里事件可传输于松散耦合的组件和服务之间。

在传统的架构体系下,编程思维可以说是过程驱动的,一个指令触发了一段逻辑,这其中可能包含了几个不同的方法行为,它们之间按照过程顺序来编排、嵌套。看起来似乎很清晰,但是很容易写出长长的“面条”代码。

而事件驱动架构则强调了事件的产生与消费,将逻辑的过程转换为事件之间的环环相扣。

alt text

就如上图所展示的,如果采用事件驱动架构,订单服务就只负责订单的创建,然后发出“订单已创建”的事件,交由 EventBus 路由,转发给关心这个事件的各个监听者。

1.3 事件驱动架构工作原理

事件驱动架构由事件发起者和事件使用者组成。事件的发起者会检测或感知事件,并以消息的形式来表示事件。它并不知道事件的使用者或事件引起的结果。 检测到事件后,系统会通过事件通道从事件发起者传输给事件使用者,而事件处理平台则会在该通道中以异步方式处理事件。事件发生时,需要通知事件使用者。他们可能会处理事件,也可能只是受事件的影响。

事件处理平台将对事件做出正确响应,并将活动下发给相应的事件使用者。通过这种下发活动,我们就可以看到事件的结果。

1.4 事件驱动架构模型

事件驱动架构模式包含了两种主要的拓扑结构:中介(Mediator)拓扑结构和代理(Broker)拓扑结构。

Mediator拓扑结构

Mediator通常在你需要在事件内使用一个核心中介分配、协调多个步骤间的关系、执行顺序时使用; alt text

事件中介负责分配、协调初始事件中的各个待执行步骤,事件中介需要为每一个初始事件中的步骤发送一个 特定的待处理事件到事件通道中,触发事件处理器接收和处理该待处理事件。这里需要注意的是:事件、中介没有真正参与到对初始事件必须处理的业务逻辑的实现之中;相反,事件中介只是知道初始事件中有哪些步骤需要被处理。

事件中介通过事件通道将与初始事件每一个执行步骤相关联的特定待处理事件传递给事件处理器。尽管我们通常在待处理事件能被多个事件处理器处理时才会在中介拓扑结构中使用消息主题,但事件通道仍可以是消息队列或消息主题。(但需要注意的是,尽管在使用消息主题 时待处理事件能被多个事件处理器处理,但由于接收到的待处理事件各异,所以对其处理的操作也各不相同)

为了能顺利处理待处理事件,事件处理器组件中包含了应用的业务逻辑。此外,事件处理器作为事件驱动架构中的组件,不依赖于其他组件,独立运作,高度解耦,在应用或系统中完成特定的任务。当事件处理器需要处理的事件从细粒度(例如:计算订单的营业税)变为粗粒度(例如:处理一项保险索赔事务),必须要注意的是:一般来说,每一个事件处理器组件都只完成一项唯一的业务工作,并且事件处理器在完成其特定的业务工作时不能依赖其他事件处理器。

Broker拓扑结构

代理拓扑结构则不同与Mediator,在Broker拓扑中,消息流在接收事件时分布在事件处理器之间。主要用于业务的链式加工。

事件流在代理拓 扑结构中通过一个轻量的消息代理(例如:ActiveMQ, HornetQ,等等……)将消息串联成链状,分发至事件处理器组件中进行处理。 代理拓扑结构大致如下图,如你所见:

alt text

在这其中没有一个核心的事件中介组件控制和分发初始事件;相反,每一个事件处理器只负责处理一个事件,并向外发送一个事件,以标明其刚刚执行的动作。

二 为什么事件驱动

只要是在服务通讯领域内,在选型时还要考虑如下特性:

排序:是否可以保证特定的顺序交付; 事务:生产者或消费者是否可以参与分布式事务; 持久化:数据如何被持久化,以及是否可以重放数据; 订阅过滤:是否拥有根据Tag或其他字段做订阅过滤的能力; At – least -once(最少交付一次),At-most-once(最多交付一次),Exactly-once (精确交付)。

2.1 服务间解耦

设计系统的组织……受到约束,产生的设计是这些组织的沟通结构的副本。—— Melvin Conway,“How Do Committees Invent?”(1968 年 4 月)

这句话被称为康威定律,它表明一个团队将根据其组织的沟通结构来构建产品。业务沟通结构将人员组织成团队,这些团队通常生产由他们的团队边界限定的产品。实现沟通结构提供了对给定产品的子领域数据模型的访问,但是较弱的数据通信能力限制了其对其他产品的访问。

数据沟通结构在组织设计和构建产品的过程中扮演着关键角色,但对于许多组织来说这种结构是长期缺失的

所以事件驱动方法提供了一种替代一个事件留的数据沟通结构,一个事件流的数据沟通结构从数据访问场景中解耦了数据的生产和所有。服务之间不再通过一个“请求–响应”API 发生联系,而是通过在事件流里定义的事件数据发生联系。生产者的职责仅限于生产定义良好的数据到它们各自的事件流中。

单一事件来源

事件流里的每个事件都是事实的一个状态,所有这些状态合起来构成了单一事实来源——这是组织内所有系统相互通信的基础。沟通结构的好坏取决于其信息的真实性,因此组织采用事件流叙述作为单一事实来源是至关重要的。

采用事件驱动,将全局的事件标准化,确定每个事件的来源是单一的。

消费者执行自己的建模与查询

数据访问和建模需求被完全转移到了消费者一方,每个消费者需要从源事件流中获取他们自己的事件副本。任何查询复杂度也会从数据所有者的实现沟通结构转移到消费者的实现沟通结构。消费者全权负责来自多个事件流的数据混合、特定的查询功能,或者其他特定业务的实现逻辑。生产者和消费者都不必为了数据通信方式而提供查询机制、数据传输机制、API(应用编程接口)和跨团队的服务。它们现在的责任仅限于解决当前的界限上下文的需求。

服务间解耦

数据的生产和所有就变得完全解耦了。这提供了系统架构中长期缺失的正式的数据沟通结构,并且更好地遵守了“高内聚,低耦合”的界限上下文原则。应用程序现在可以访问原本很难通过点到点连接获得的数据了。新的服务可以简单地从典型事件流中获得所需的数据,创建它们自己的模型和状态,并执行任何必要的业务功能,而无须依赖与其他服务的直接点到点连接或 API。这就释放了组织在任何产品中更高效地使用海量数据的潜力,甚至可以以独特而强大的方式整合来自不同产品的数据。

2.2 事件溯源

零数据丢失

在普通的CURD系统中,可能每次操作,都会造成一些数据的丢失:要么是新数据覆盖了旧数据;要么是丢失数据发生变更的上下文。 在事件溯源中,所有的操作都是基于事件去处理的。每一个事件都明确的代表着系统中的某一个操作,并且记录着这项操作所有相关的数据内容,可以清晰的从一个事件流中,看到一条数据的每一次变更,以及变更目的。 如:开户 -> 收款 -> 购买 -> 利息。

重播

事件是按照事情发生顺序,流式排列。我们可以将事件流重定位到某个具体的时间点,重新执行该事件流中的事件,从而进行业务重试、异常分析、测试验证等。 比如在进行测试的时候,我们走完了一个测试流程,下一次再进行回归的时候,只用重播一下相关的事件就行。 由于可以随时对已发生的事件进行重播,所以由事件触发的任何操作,都具备重试、重新构建的能力。

审计

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

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

三 如何实现事件驱动

3.1 模式与约束

基于事件各个服务间的逻辑如下图:

alt text

微服务 1 从事件流 A 中消费并转换数据,然后生成结果到事件流 B。微服务 2 和微服务 3 都从事件流 B 中消费。微服务 2 严格充当一名消费者的角色,并提供了REST API,使得数据可以被同步访问。同时,微服务 3 根据其界限上下文需求执行自己的转换并输出到事件流 C。新的微服务和事件流可以根据需要添加到业务拓扑中,通过事件流进行异步耦合。

在事件驱动架构中,核心约束如下:

单一生产者 与 保持事件的单一用途 每个事件流有且只有一个生产微服务。这个微服务是生成到流中的每个事件的所有者。这使得可以通过系统追踪数据的流转,而令任何给定的事件都具有权威的事实来源

分区 事件流可以被划分为单独的子流,子流的数量根据生产者和消费者的需要而变化。这种划分机制允许一个消费者的多个实例并行处理每个子流,从而实现更大的吞吐量。请注意,队列不需要分区,但出于性能考虑,对它们进行分区可能很有用。

严格有序 事件流分区中的数据是严格有序的,并且其提供给客户端的顺序跟它原来发布到事件流中的顺序完全一样。

不变性 一旦发布,所有事件数据就是完全不可变的。事件发布之后没有任何机制可以对其数据进行修改,只能通过发布一个带有更新数据的新事件来修改之前的数据。

索引 事件在写入事件流时被分配了一个索引。消费者可以用它来管理数据消费,因为它们可以指定从哪个偏移量开始读取数据。消费者的当前索引和尾部索引之差就是消费滞后程度。这个指标的用处在于当其数值高时可以增加消费者数量,当其数值低时可以减少消费者数量。此外,它还能够用于唤起 FaaS 逻辑。

可无限保留 事件流必须能够在无限长的时间内保留事件。这个属性是维持事件流状态的基础。

可重放 事件流必须是可重放的,这样任何消费者都可以读取它需要的任何数据。这为单一的事实来源提供了基础,也是微服务之间进行状态通信的基础。

3.2 通信与契约

事件数据作为长期的且与实现无关的数据存储方式,也是服务间的通信方式。因此,事件的生产者和消费者对数据的含义有共同的理解是很重要的。

3.2.1 事件定义

对于事件的定义,如下:(当前采用Protobuf描述)

syntax = "proto3";
import "google/protobuf/any.proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/descriptor.proto";
package mycompany;
    
// 事件实体
message Event {
    // 事件ID,唯一的事件标识符
    uint64 event_id = 1;
    // 事件的类型,如“用户注册”, 全局唯一
    string event_type = 2;
    // 事件流ID,事件流的唯一标识符,多个相关的事件,组成事件流。在DDD中,通常是一个领域上下文的聚合根
    uint64 event_stream_id = 3;
    // 表示事件在流中的位置,递增编号即可,也可用主键ID代替
    uint64 sequence = 4;
    // 时间戳
    timestamp event_timestamp = 5;
    // 消息体序列化类型
    BodyType body_type = 6;
    // 保留字段序号
    reserved 7, 8, 10 to max;
    // 消息体
    google.protobuf.Any body = 9;
}

3.2.2 事件注册逻辑

为更好的全局管控事件,需要对事件schema显性管理,为数据的精确定义,包括名称、类型、默认值和文档,为事件的生产者和消费者提供了清晰的信息。

事件注册表是一种服务,它允许生产者注册他们用来编写事件的 schema。这提供了几个明显的好处:

  • 事件 schema 不需要与事件一起传输。可以使用简单的占位符 ID,显著减少带宽的使用;
  • schema 注册表为获取事件的 schema 提供了唯一参考;
  • schema 支持数据发现,特别是全文本搜索。

事件注册服务的逻辑如下: alt text

3.3 Actor模型设计

3.3.1 核心概念

Actor模型由Hewitt、Bishop和Steiger在1973年通过论文《A Universal Modular Actor Formalism for Artificial Intelligence》提出,是一个创新的并发、分布式计算和编程模型。该模型的理念是“万物皆Actor”,即以Actor作为最基本的功能单元,且需要遵循以下几个基本规则:

  • 所有的计算都是在Actor中执行的。
  • Actor之间只能通过消息进行通信,且消息是不可变的。
  • Actor串行处理并响应消息。当一个Actor响应消息时,它可以进行下列操作:
    • 更改状态或行为;
    • 发送有限数量的消息给其他Actor;
    • 创建有限数量的子Actor。

alt text

Actor 的基础就是消息传递,一个 Actor 可以认为是一个基本的计算单元,它能接收消息并基于其执行运算,它也可以发送消息给其他 Actor。Actors 之间相互隔离,它们之间并不共享内存。

Actor 本身封装了状态和行为,在进行并发编程时,Actor 只需要关注消息和它本身。而消息是一个不可变对象,所以 Actor 不需要去关注锁和内存原子性等一系列多线程常见的问题。

所以 Actor 是由状态(State)、行为(Behavior)和邮箱(MailBox,可以认为是一个消息队列)三部分组成:

  • 状态(State):Actor 中的状态指 Actor 对象的变量信息,状态由 Actor 自己管理,避免了并发环境下的锁和内存原子性等问题。
  • 行为(Behavior):Actor内部定义的一组计算逻辑(如函数),用于处理接收到的消息以及改变状态数据。
  • 邮箱(Mailbox):可以视为与接收方Actor关联的FIFO消息队列。由于Actor串行处理消息,发送方发来的来不及处理的消息会存入邮箱中,接收方再从邮箱逐条获取pending的消息。(当然,一个Actor既可以是发送方也可以是接收方)

实现一个纯物理的Actor模型比较重,在微软的Dapr中,使用了 “Virtual Actor”。 Virtual actor 是状态和逻辑的单位,它:

  • 可以通过 id 唯一标识
  • 是单线程的
  • 可以在内存中或持久化 - 它的生命周期由框架管理

3.3.2 核心模块

首先,针对Actor模型系统(ActorSystem),需要有一个系统级的工程类,主要有以下功能:

  • 管理调度服务
  • 配置相关参数
  • 日志功能

Actor组织逻辑如下: alt text

通过Actor系统(ActorSystem)来管理所有Actor,每个JVM实例内只有一个ActorSystem。当ActorSystem启动时,默认有3个守护(guardian)Actor:

  • /root:根守护Actor,如同文件系统中的根,最先被创建,最后被销毁;
  • /system:系统守护Actor,基于Actor框架构建的系统级模块会在该路径下创建子Actor;
  • /user:用户守护Actor,业务逻辑过程中创建的Actor都会位于这个路径下。当调用ActorSystem.actorOf()方法时,会在/user下-直接创建;而当调用某Actor的ActorContext.actorOf()方法时,会在该Actor下创建子Actor。

Actor的层次结构同时也是监督(supervision)机制的基础。当一个Actor失败时,它会通知其父Actor采取相应的动作(如直接恢复、重启、停止或者将失败信息继续向高层传递)。下图示出一个Actor的完整生命周期: alt text

重要模块:

  • ActorRef 在使用 system.actorOf() 创建 Actor 时,其实返回的是一个 ActorRef 对象。 ActorRef 可以看做是 Actor 的引用,是一个 Actor 的不可变,可序列化的句柄(handle),它可能不在本地或同一个 ActorSystem 中,它是实现网络空间位置透明性的关键设计。 ActorRef 最重要功能是支持向它所代表的 Actor 发送消息: ref.sendEvent(event);

  • Dispatcher 和 MailBox 在ActorSystem 和 ActorRef 创建时,Dispatcher 和 MailBox同时被创建。 ActorRef 将消息处理能力委派给 Dispatcher,Dispatcher 从 ActorRef 中获取消息并传递给 MailBox,Dispatcher 封装了一个线程池,之后在线程池中执行 MailBox。

happens before问题 :

由于当前Actor模型依然适用Java技术栈实现。针对Java内存模型来分析处理happens before问题原则。 JSR-133使用happens-before的概念来阐述操作之间的内存可见性,其中主要包括:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

针对以上问题有2个约束:

  • 确保事件消息为immutable
  • 确保actor在处理下一个事件时,对于上一个事件对actor内部状态的改动是可见的。

具体保证原子性与有序性逻辑如下: alt text

3.3.3 流程

基于上面的一些概念,我们可以 Actor 的处理流程归纳如下:

  • 创建 ActorSystem
  • 通过 ActorSystem 创建 ActorRef,并将消息发送到 ActorRef
  • ActorRef 将消息传递到 Dispatcher中
  • Dispatcher 依次的将消息发送到 Actor 邮箱中
  • Dispatcher 将邮箱推送至一个线程中
  • 邮箱取出一条消息并委派给 Actor 的 receive 方法

简略流程图如下: alt text

3.4 request and response 实现

针对当前服务端环境下,绝大部分服务都是“请求-响应”模式的。结合外部系统来看,整体逻辑如下图所示: alt text

而在服务内部,基于事件驱动的逻辑如下: alt text

3.5 测试

测试事件驱动型微服务的一大优点是它们非常模块化,非常容易进行单元测试。

服务的输入由事件流或来自“请求–响应”API 的请求所提供。状态被物化到服务自己的独立的状态存储,输出事件则被写入服务的输出流中。微服务的“小”和“目标明确”的特性使得它们比大而复杂的服务更易于测试。这种微服务要移动的部件较少,处理 I/O 和状态的方法相对标准,并且有很多机会与其他微服务复用测试工具。本章涵盖测试原则和策略,包括单元测试、集成测试和性能测试。

四 参考资料