Skip to content

第14讲:事件如何驱动微服务设计与异步消息传递

从本课时开始,我将介绍另外一种类型的微服务,也就是事件驱动的微服务。相对于数据库驱动的微服务,事件驱动的微服务介绍比较少。当然,这并不是因为事件驱动的微服务自身有什么问题,而仅仅是架构设计上不同风格的选择。以编程语言的范式来进行类比,面向对象编程虽然是目前的主流,但是函数式编程仍然有其用武之地。从纯技术的角度来说,事件驱动的架构更适合于微服务架构的应用。不过,在实际开发中的技术选型需要综合多种因素来考虑,尤其是开发团队对技术的熟练程度。了解事件驱动设计的开发人员相对较少。本课时将对事件驱动的基本概念进行介绍。

事件

事件驱动设计中的核心概念是事件(Event),事件是现实世界中的概念,表示已经发生的状况。事件驱动指的是以事件的发布和处理来驱动应用的运行,它的方式符合我们在现实世界中的工作模式,通常都是在事件发生之后,再进行处理。现实世界中的事件多种多样,我们依靠大脑进行创造性的处理。

事件在软件系统的应用也由来已久,最典型的应用是在用户界面中。用户界面的实现通常会维护一个事件循环(Event Loop),当用户界面中的事件产生时,比如按钮点击和鼠标移动,事件的处理器会被调用。对事件的处理都在事件循环中完成,应用开发者只需要为感兴趣的事件添加处理器即可。在事件处理器中,除了正常的处理逻辑之外,还可以发布新的事件,从而触发对应的处理逻辑,产生级联的效果。

软件系统中的事件类型是有限的。每个事件都有一个类型和可选的载荷(Payload)对象。类型用来区分不同的事件,推荐以反转域名来作为事件类型的前缀,类似于 Java 中类的命名。事件类型的简单名称,一般使用名词加上动词被动语态的命名规则,名词是事件的目标对象类型,而动词则是事件所表示的动作。比如,表示乘客创建成功的事件类型是 PassengerCreatedEvent,Passenger 表明事件的目标对象是乘客,Created 则表明事件对应的动作是创建,完整的事件类型是 io.vividcode.happyride.passengerservice.PassengerCreatedEvent。

事件的载荷对象取决于事件的发布者,作为所发布事件的一部分,载荷对象的内容也需要考虑到事件处理器的需求。因为载荷对象中包含了处理事件所需的数据,载荷对象的最终格式是综合多方面考量的结果,载荷对象只需要包含足够多的信息即可。PassengerCreatedEvent 事件的载荷对象,可以包含乘客的全部信息,也可以仅包含乘客的 ID。

事件驱动设计

事件驱动的设计在单体应用中已经得到了应用。事件驱动的最大特点是把方法的调用、调用的执行和调用结果的获取,这 3 个动作进行了时间上的分离。在常见的方法调用中,方法的调用、调用的执行和调用结果的获取是同步进行的。调用者在发出调用请求之后,会等待方法调用的完成,并使用调用结果进行下一步操作。事件驱动把这 3 个动作从时间上进行了分离,变成了异步的操作。

以新乘客注册的场景为例,当乘客完成注册之后,应用需要执行一些初始化的工作。在乘客注册对应的 API 控制器方法中,在创建乘客对象并保存之后,可以直接调用相应的方法来完成初始化,等初始化完成之后,控制器方法才返回。这是典型的同步调用方式。

如果采用事件驱动的方式,在创建乘客对象并保存之后,可以发布一个 PassengerCreatedEvent 事件,当事件发布之后,控制器方法就可以返回。PassengerCreatedEvent 事件的处理器用来完成初始化工作。在引入了事件之后,乘客初始化的动作被分成了两步或三步:第一步是事件的发布,相当于发出方法调用的请求;第二步是事件的处理,由事件处理器来完成;除此之外,某些情况下还存在第三步,那就是事件处理结果的返回,这一步对应于同步调用的方法有返回值的情况。

事件驱动的另外一个好处是可以实现发布者-消费者(Publisher-Subscriber,PubSub)模式。在同步方法调用中,每一次调用只有一个接收者。在下面代码的 createPassenger 方法中,initPassenger1 和 initPassenger2 是初始化乘客对象的两个不同操作,createPassenger 方法和乘客初始化逻辑有紧密的耦合关系。如果以后要增加新的乘客初始化逻辑 initPassenger3 方法,则需要修改 createPassenger 方法来添加对 initPassenger3 方法的调用。

java
Passenger createPassenger() {
  Passenger passenger = new Passenger();
  initPassenger1(passenger);
  initPassenger2(passenger);
  return passenger;
}

如果采用事件驱动的做法,那么 createPassenger 方法只是发布了一个 PassengerCreatedEvent 事件,而 PassengerCreatedEvent 事件可以有多个处理器。当需要增加新的乘客初始化逻辑时,只需要添加新的处理器即可,createPassenger 方法并不需要进行修改。

使用了事件之后,一个简单的同步方法调用,被切分成 2 或 3 段时间上独立的操作。这无疑增加了实现的复杂度,但是减少了调用者和被调用者之间的耦合度。在大部分时候,同步方法调用就足够了。用户界面适合于事件驱动的方式,是由用户界面的交互模式所决定的。

单体应用中使用事件驱动,大部分时候是为了实现 PubSub 模式,实现该模式需要事件总线(Event Bus)的支持,在 Java 中,我们可以用 Guava 中的 EventBus 实现。Spring 框架也有自己的事件系统。下面代码展示了 Guava 的 EventBus 的用法。ChangedEvent 是事件类型,EventHandler 是事件处理器,其中的 @Subscribe 注解声明了处理事件的方法。EventBus 类的 post 方法用来发布事件。

java
public class EventBusSample {

  public static void main(String[] args) {
    EventBus eventBus = new EventBus();
    eventBus.register(new EventHandler());
    eventBus.post(new ChangedEvent());
  }

  private static class ChangedEvent {

  }

  private static class EventHandler {
    @Subscribe
    public void onChanged(ChangedEvent event) {
      System.out.println(event);
    }
  }
}

事件发布和处理可以有不同的策略。一种策略是事件按照发布的顺序被依次处理,在一个事件被处理之前,不会进行下一个事件的处理;另外一种策略是事件的处理是并发进行的,事件的处理顺序与发布顺序不一定相同。可以根据需要来选择适合的策略。

单体应用中的事件发布和处理的实现相对简单,因为发布者和处理器都在同一个 JVM 中,不需要考虑事件在不同 JVM 之间的传递和序列化的问题。分布式系统中的事件发布和处理则是另外一个复杂的问题。

事件驱动的微服务

在单体应用中,事件驱动方式的流行度并不高,这主要是因为直接调用不仅简单易用,性能也更好。在微服务架构的应用中,微服务之间的交互使用的是跨进程 API 调用。同步调用其他微服务的 API 并不是简单的事情,需要考虑到被调用的微服务可能出错的情况。同步的微服务 API 调用,要求被调用者在调用发生时是可用的状态,如果被调用者当前不可用,则需要进行重试或进入到错误处理逻辑;如果调用最终失败,则被调用者并不知道请求的存在。

以上面代码中的 createPassenger 方法为例,initPassenger2 方法的实现是调用另外一个微服务的 API,当 createPassenger 方法被调用时,如果该微服务不可用,则调用会失败;当该微服务变得可用时,并不会知道曾经有尝试进行的调用。

由于同步的微服务 API 调用的性能下降了很多,相对于事件发布来说,性能这个优势已经没有了,事件驱动在其他方面的优势得到了体现。同样是 createPassenger 方法的例子,如果方法的实现是发布 PassengerCreatedEvent 事件,只要事件被成功发布,事件就会被持久化,其他的微服务如果添加了对该事件的处理器,那么即便在事件发布时,该微服务不可用也没有影响。当微服务变得可用时,仍然会处理该事件。当考虑到微服务可能失败的情况,使用事件驱动的设计就成了一个很好的选择。

在微服务架构的应用中,如果使用事件驱动的设计,则需要进行消息传递。

消息传递

在微服务架构的应用中,事件的发布和处理需要消息中间件的支持,典型的产品是消息代理(Message Broker)。消息代理负责消息的验证、转换和路由,它负责协调不同应用之间的消息传递,从而降低了不同应用之间的耦合度。消息代理的功能围绕消息展开,对于每一个接收到的消息,消息代理可以进行不同的处理,包括消息持久化、有保证的消息传递和事务管理等。市面上的消息代理实现非常多,开源实现和商业产品都有,常见的选择有 Apache ActiveMQ、Apache Kafka、RabbitMQ、IBM MQ、Amazon MQ和Microsoft Azure Service Bus 等。示例应用使用的是 Apache Kafka。

Apache Kafka 介绍

Apache Kafka 是一个分布式流平台。Kafka 可以发布和订阅记录流,并提供记录流的持久化存储。

Kafka 把记录流组织成主题(Topic),在发布记录时需要指定主题,每个主题在 Kafka 集群上对应一个分片的日志。每个分片都是包含记录的一个有序的、不可变的序列,新的记录被添加到序列的末尾,分片中的每个记录都有一个递增的序号作为其标识符,该序号称为记录的偏移量(Offset)。Kafka 会存储所有发布的记录,直到记录的保存时间超过设置的数据留存时间。

下图是 Kafka 中主题的示意图,图中的主题一共有 3 个分片,每个分片中的小矩形,都表示一个记录,矩形中的数字是记录的偏移量,分片最右边的虚线边框是当前的写入位置。

Kafka 包含了 5 个核心 API,如下表所示。

生产者负责发布记录到选定的主题上。在发布时,生产者负责为记录选择所在的分片,每个主题都可以有多个消费者,消费者以分组的形式来组织,每个消费者以标签的形式来表明所在的分组。对于每个主题中发布的记录,该记录会被发送到每个订阅了该主题的消费者分组中的其中一个消费者,消费者分组可以实现不同的记录处理场景。如果所有的消费者都属于同一个分组,那么记录会在所有的消费者中以负载均衡的方式处理;如果所有的消费者都属于各自独立的分组,那么记录会被广播到所有的消费者。

除了这两种极端场景外,通常的情况是使用从业务需要上进行区分的少量分组,每个分组中包含一定数量的消费者来保证处理速度和进行故障恢复。每个分组中的消费者数量不能超过分片的数量。

消费者只需要维护在分片中的当前偏移量即可,这个偏移量是当前的读取位置,通常的做法是递增该偏移量来顺序读取记录。在需要的时候,还可以把偏移量设置为之前的值来重新处理一些记录,或是跳过一些记录。

消息传递的保证性

在消息传递时,一个需要考虑的重要问题是消息传递的保证性,也就是生产者发布的消息,是否一定可以被消费者接收到。一共有 3 种不同的保证性,如下表所示。

看到上表中的保证性,第一反应是应该使用有且仅有一次的保证性,因为这符合我们对消息传递的预期。不过在一个分布式系统中提供有且仅有一次的保证性,所带来的代价很高,我们需要的是根据实际的需要选择最合适的保证性。比如,如果生产者发布的消息是收集的性能指标数据,那么至多一次的保证性已经足够。丢失一些性能指标数据并不是什么大问题,性能指标数据产生的速度很快,新的数据会迅速产生并替代旧数据。

Kafka 对于不同的保证性都提供了一定的支持,对于有且仅有一次的保证性,在 Kafka 的主题之间传送和处理消息时,可以使用事务性生产者和消费者。Kafka 的流处理 API 提供的也是有且仅有一次的保证性。

如果生产者在发布记录时出现网络错误,生产者并不能确定记录是否被成功提交。当生产者进行重试时,有可能造成记录的重复;在消费者处理记录时,需要保存当前的读取位置。如果当前消费者出错,新的消费者可以从上次读取的位置开始继续进行处理。有两种不同的处理策略。

第一种策略是消费者首先读取记录,然后处理记录,最后保存读取位置。如果消费者在处理完记录之后,在保存读取位置之前出错,新的消费者还会从上次的旧位置开始读取,会造成记录的重复处理。这种策略对应的是至少一次的保证性。

第二种策略是消费者首先读取记录,然后保存读取位置,最后处理记录。如果消费者在保存读取位置之后,在处理记录之前出错,读取的记录实际上并没有被处理,新的消费者会从新位置开始读取。这种策略对应的是至多一次的保证性。

Kafka 默认的是至少一次的保证性。如果需要实现至多一次的保证性,则需要禁用生产者的重试,同时消费者使用上述第二种策略。

Kafka 负责保证同一个分片中记录的顺序性,也就是说记录会严格按照被发布的顺序来消费,这一点对于事件驱动的微服务来说非常重要。如果一个用户创建了订单,然后取消了该订单,对应的两个事件必须按照同样的顺序来处理。如果订单取消的事件先被处理,那么该事件可能会被忽略,而订单最终完成了创建。这显然是不正确的。

总结

事件驱动是微服务架构应用的另外一种设计风格,把同步的微服务 API 调用转换成异步的事件发布和处理。本课时对事件驱动设计进行了介绍,包括它在微服务架构中的应用。示例应用使用 Apache Kafka 作为消息传递的代理,本课时也介绍了 Kafka 的基本概念。