事件驱动架构简介
什么是事件驱动架构
事件驱动架构(Event Driven Architecture,EDA)是一种软件设计模式,它围绕着事件的生成、检测和响应来构建系统。在这种架构中,组件之间不是直接调用彼此的功能,而是通过发布和订阅事件来通信。
事件驱动系统旨在捕获、沟通和处理解耦服务之间的事件。这意味着,系统可以保持异步,同时仍共享信息和完成任务。
许多现代应用设计都是由事件驱动的,例如必须实时利用客户数据的客户互动框架。事件驱动应用可以用任何一种编程语言来创建,因为事件驱动本身是一种编程方法,而不是一种编程语言。事件驱动架构支持最小程度的耦合,因此非常适合现代分布式应用架构。
EDA 采用松散耦合方式,因为事件发起者并不知道哪个事件使用者在监听事件,而且事件也不知道其所产生的后续结果。
EDA通常的拓扑结构
事件驱动架构是一种基于异步处理的架构风格,通过高度解耦的事件处理器来触发和响应系统中发生的事件。大多数事件驱动架构由以下组件组成:一个事件处理器、一个主动事件、一个处理事件和一个事件通道。这些组件及其关系如图所示。
事件处理器(通常称为服务)是事件驱动架构中的主要部署单元。它可以以不同的粒度存在,从一个单一目的函数(例如订单验证)到一个庞大而复杂的流程(例如金融交易执行或结算)。事件处理器能够触发异步事件,并对被触发的异步事件作出响应。在大多数情况下,事件处理器同时具备这两个功能。
初始事件通常来自于主系统外部,并启动某种异步工作流程或过程。举例来说,下订单、购买苹果股票、在拍卖中对特定物品进行竞标、提出保险索赔等都属于初始事件。在大多数情况下,只有一个服务接收到初始事件,然后开始一系列处理该初始事件相关联的其他事件链条,但并非必须如此。例如,在在线拍卖中对物品进行竞标(即初始事件),可能会被Bid Capture服务和Bid Tracker服务同时捕捉到。
当某个服务的状态发生变化并且该服务向系统中其他部分广播了这个状态改变时,就会生成一个处理事件(今天通常称为派生事件)。触发事件和处理事件之间是一对多的关系 - 一个触发事件通常会产生许多不同的内部处理事件。例如,在工作流程中,下订单的触发事件可能会导致订单已下达的处理事件、应用付款的处理事件、库存更新的处理时间等等。请注意,触发事件通常以名词-动词格式表示,而处理时间通常以动词-名词格式表示。
事件通道是物理消息传递工具(如队列或主题),用于存储触发的事件并将这些触发的时间交付给响应这些时间的服务。在大多数情况下,触发时间使用点对点通道使用队列或消息传递服务,而处理时间则通常使用发布-订阅通道使用主题或通知服务。
事件驱动的案例
订单处理系统的EDA案例
为了观察所有这些组件如何在一个完整的事件驱动架构中协同工作,考虑一下图所示的例子:一个顾客希望订购一本书。在这种情况下,触发事件将是“下订单”。该触发事件被订单放置服务接收,然后该服务为书籍进行订购。订单放置服务通过处理“已下订单”事件向系统中其他部分广播其所执行的操作。
请注意,在此示例中,当订单放置服务触发“已下订单”事件时,并不知道哪些其他服务(如果有)会对此事件做出响应。这说明了事件驱动架构具有高度解耦、非确定性的特点。
继续上述例子,请注意图中有三个不同的服务对“已下订单”事件作出响应:支付服务、库存管理服务和通知服务。这些服务执行相应的业务功能,并通过其他处理事件向系统中其他部分广播它们所执行的操作。
在这个例子中,需要特别注意的是,通知服务通过生成一个NotifiedCustomer处理事件来宣传自己所做的事情,但其他服务并不关心或响应该事件。那么为什么要触发一个没有人关心的事件呢?答案是出于架构可扩展性考虑。通过触发该事件,通知服务提供了未来可能有其他服务响应的钩子(例如通知跟踪服务),而无需对系统进行任何其他修改。因此,在事件驱动架构中,一个重要原则是始终让服务广告其状态变化(即采取了什么行动),无论其他服务是否对该事件作出响应。如果没有其他服务关心该事件,则该事件将从主题中消失(或根据使用的消息技术保存以供将来处理)。
著名项目的EDA运用
Epoll
epoll 是现代 Linux 系统中实现事件驱动编程的底层核心机制之一。
虽然事件驱动架构(EDA)通常指宏观的软件设计模式,但 epoll 是在操作系统内核层面,提供了一种高效的、低延迟的 I/O 事件通知机制,这使得上层的应用可以轻松地构建出高性能的事件驱动服务。
epoll 是如何运用事件驱动的?
要理解 epoll,我们首先要了解传统的 I/O 模型。
传统 I/O 轮询(Polling)的缺点
在 epoll 出现之前,Linux 系统主要使用 select() 和 poll() 这两个系统调用来处理大量的并发 I/O。它们的共同缺点是:
- 轮询: 应用程序必须不断地“询问”内核,检查每一个文件描述符(如网络套接字)是否已经准备好读或写。
- 低效: 当需要监听的文件描述符数量(比如成千上万个网络连接)非常庞大时,每次调用 select() 或 poll() 都需要将整个文件描述符列表从用户空间拷贝到内核空间,然后内核再遍历所有这些文件描述符来检查状态。这个过程的开销非常大,性能会随着连接数的增加而线性下降(O(n) 复杂度)。
epoll 的事件驱动机制
epoll 解决了上述问题,它将轮询模型转变为事件通知模型。它的核心思想是注册-等待-通知:
- 注册
(epoll_ctl)
: 应用程序不再需要每次都传递所有文件描述符列表。它只需要调用epoll_ctl()
,将感兴趣的文件描述符(如新建立的网络连接)以及要监听的事件类型(如可读事件 EPOLLIN)一次性注册到内核的 epoll 实例中。这个 epoll 实例在内核中维护了一个高效的数据结构(通常是红黑树)来管理这些文件描述符。 - 等待
(epoll_wait)
: 应用程序调用epoll_wait()
进入阻塞状态,等待事件发生。此时,线程会被挂起,不会消耗 CPU 资源。 - 通知
(Kernel Notification)
: 当内核检测到注册的文件描述符上发生了 I/O 事件时(例如,有新的数据到达),它会唤醒正在epoll_wait()
的线程,并返回一个已经准备好的文件描述符列表。应用程序只需要遍历这个小得多的列表,处理相应的事件即可。
这种机制的优点是:
- 高性能:
epoll_wait()
的时间复杂度是 O(1),它只返回已就绪的事件,而不是遍历所有文件描述符。这使得 epoll 在处理成千上万个并发连接时,性能表现依然非常出色。 - 零拷贝: 文件描述符列表的注册和通知过程都在内核空间完成,避免了大量的数据在用户空间和内核空间之间来回拷贝。
Nginx
Nginx之所以能够处理高并发,核心在于其事件驱动架构。它与传统的、为每个请求创建新进程或线程的服务器模型截然不同。
传统模型的弊端
在传统的服务器模型中,如 Apache,当你访问一个网页时:
- 服务器接收到请求。
- 它为这个请求创建一个新的进程或线程。
- 这个进程/线程负责处理所有与该请求相关的任务,例如从磁盘读取文件或与数据库交互。
- 当这个请求的所有任务完成后,这个进程/线程才会被释放。
这种“一对一”的模式在并发量很小时工作得很好。但当并发量剧增时,服务器会创建大量的进程或线程,导致内存消耗过大和上下文切换开销(CPU 在不同任务间切换的成本)急剧增加,最终使服务器性能迅速下降甚至崩溃。
Nginx 的事件驱动模型
Nginx 采取了完全不同的策略,它基于事件驱动和异步非阻塞 I/O。
进程模型: Nginx 启动时,会有一个主进程(master)和一个或多个工作进程(worker)。
- 主进程负责管理工作进程,例如启动、监控和配置重载。
- 真正处理请求的是工作进程。每个工作进程都是一个独立的进程,但它们内部的运行模式是事件驱动的。
事件循环: 每个工作进程都维护一个事件循环(event loop)。这个事件循环会不断地检查是否有新的事件发生。
- 这里的“事件”是指与 I/O 相关的所有活动,比如“有新的客户端连接”、“客户端发送了数据”、“可以向客户端发送数据了”等。
非阻塞 I/O: 当一个工作进程接收到一个请求后,它不会像传统模型那样等待(阻塞)数据传输。相反,它会:
- 注册一个“事件”(例如,“等待客户端发送数据”)。
- 然后立即转去处理其他任务,比如处理其他客户端的请求。
- 当它注册的事件真正发生时(例如,客户端发送了数据),操作系统会通过 epoll 或类似机制通知 Nginx 的工作进程。
- 工作进程收到通知后,才会回到之前那个任务,继续处理。
这种模式的核心思想是:“当 I/O 任务正在进行时,我不会傻傻地等待,而是去做其他有意义的事情。”
Redis
Redis 也采用了单线程结合事件循环的方式来处理客户端请求,避免了传统多线程模型中的大量上下文切换开销
单线程模型:为何不使用多线程?
大多数高性能数据库都采用多线程或多进程来处理并发请求,但 Redis 选择了一条不同的路。
优点: 单线程模型避免了线程创建、销毁和上下文切换的开销。更重要的是,它完全消除了加锁的需要,因为没有多个线程同时访问共享数据结构,这大大简化了代码逻辑,提升了执行效率。
挑战: 单线程意味着如果一个操作耗时过长,整个服务器都会被阻塞。因此,Redis 的所有操作都必须是非阻塞的,而且执行速度极快。
事件循环(Event Loop)的核心作用
为了在单线程模型下处理并发 I/O,Redis 引入了事件循环。这个事件循环是 Redis 服务器的主循环,它不断地、快速地执行以下任务:
I/O 多路复用: 这是事件驱动架构的基础。Redis 使用 epoll(在 Linux 上)、kqueue(在 macOS 和 BSD 上)或 select/poll(在旧系统上)等机制,来高效地监听多个文件描述符(即客户端连接)。它不是轮询地检查每个连接,而是让操作系统内核来通知它“哪个文件描述符上发生了事件”。
事件处理: 当内核通知有事件发生时(例如,某个客户端发送了新命令,或者可以向某个客户端写入响应了),事件循环会调用预先注册好的事件处理器(handler)来处理这些事件。
任务执行: 事件处理器执行具体的命令,例如 SET key value 或 GET key。由于 Redis 的所有核心命令都设计得非常快,这个过程通常在微秒级别完成。
循环: 处理完当前事件后,事件循环会立即回到第一步,继续等待和处理下一个事件。
Redis 事件驱动的流程 一个典型的客户端请求在 Redis 中的处理流程如下:
- 接收连接: 新的客户端连接请求被事件循环通过 I/O 多路复用机制检测到,并接受连接。
- 等待命令: 客户端连接对应的文件描述符被注册到事件循环中,等待可读事件(即客户端发送命令)。
- 处理命令: 当客户端发送命令时,I/O 多路复用机制会通知事件循环。事件循环读取并解析命令,并将其交给相应的命令处理器。
- 执行命令: 命令处理器在单线程中快速执行命令(例如,在内存中进行哈希表查找)。
- 发送响应: 命令执行完毕后,Redis 将响应数据准备好,然后将对应的文件描述符注册为可写事件。
- 写入响应: 当文件描述符可写时,事件循环将响应数据发送回客户端。
什么场景适合事件驱动架构
适合EDA的场景
简而言之,事件驱动架构是在需要高性能、高可扩展性和高容错性的系统选择中具备重要地位的一种架构。然而,除了这些超级功能外,还有其他原因可以考虑采用这种架构风格。
被动处理系统
如果您的业务处理方式是对系统内外发生的事件做出反应(而不仅仅是响应用户请求),那么这就是一个值得考虑的优秀架构风格。请听取您业务利益相关者的意见-他们是否使用“事件”、“触发器”和“对某事做出反应”的词汇?如果确实如此,那么很可能您面临的业务问题与该架构风格相匹配。此外,请自问-我是在响应用户请求还是对用户所作操作作出反应?这些都是用来确定业务问题是否与该架构风格相匹配的重要问题。
复杂工作流
当您面临难以建模复杂、非确定性工作流程时,事件驱动架构也将成为一个不错的选择。几十年来,开发人员一直试图通过建立复杂决策树来概述复杂工作流程中每个可能结果,并屡次失败于此繁琐任务。像这样的系统有时被归类为CEP(复杂事件处理),并在事件驱动体系结构中进行本地管理。
不适合EDA的场景
同步请求场景
如果大部分的处理都是基于请求的,那么你不应该考虑这种架构风格。基于请求的处理是指用户从数据库中获取数据(例如客户资料)或对系统中的实体进行基本CRUD操作(创建、读取、更新、删除)。此外,如果大部分的处理需要同步执行,即用户必须等待特定请求完成后才能继续,那么事件驱动架构很可能不适合你。
难以保证数据一致性
由于事件驱动架构中的所有处理最终都是一致的,因此对于需要高水平数据一致性的业务问题来说,这并非一个理想的架构风格。在事件驱动架构中,几乎没有或者很少保证处理何时发生,因此如果您期望某个特定时间点存在某些数据,请寻找其他能够确保数据一致性的架构风格,例如基于服务的架构。
流程控制难度高
另一个放弃事件驱动架构并考虑其他架构风格的原因是在需要对事件的工作流程和时间控制时,管理异步事件处理变得非常困难。例如,想象一下协调以下场景的复杂情况:在触发事件C之前,必须完成事件A和事件B的处理,并且在等待Event C
完成之后,Event D
和Event E
必须等待Event C
开始处理之前启动。要成功应对这种混乱局面最好使用编排式服务导向架构或编排式微服务进行复杂协调。
错误处理复杂
错误处理也是使团队远离事件驱动架构的复杂性的另一个原因。由于通常没有中央工作流程编排器或控制器,在出现错误时,该服务尝试修复错误。此外,在该事件的工作流程中可能已经发生了其他操作,因为所有操作都是异步进行的。例如,假设订单放置服务为客户订购书籍触发了OrderPlaced件。通知服务、支付服务和库存服务同时响应该件。然而,假设通知和支付服务都响应并完成其处理,但当接收到该件时库存服务抛出错误,因为没有更多可用的书籍。现在如何解决?客户已经被通知并且他们的信用卡已经被扣款,但没有更多可供发送给客户的书籍了。是否应撤销付款?是否应向客户发送另一条通知?还是只需等待有更多库存?哪个服务执行所有这些错误处理逻辑呢?错误处理确实是事情驱动架构中更复杂的方面之一。