在深入探讨函数式编程世界之前,让我们先打好基础,这样才能构建可扩展且可靠的分布式系统。
在本章中,我们将探索软件设计与架构,特别是分析事件驱动架构(EDA)和面向服务架构(SOA)等方案的优缺点。
等我们了解了 EDA、CQRS 和事件溯源之后,下次需要设计系统时,就能够做出有根据的决策了。
1.1 引言
事件驱动架构是一种软件架构范式,它强调事件的产生、检测、消费以及对事件的响应。
所谓事件,可以定义为应用程序状态的一个重要变化,也就是某件“已经发生的事情”。从实际角度来看,事件代表着过去已经发生的动作,其他组件无法改变这个事实,只能对其做出响应。
举个例子,UserEvent.SignedIn 表示某个用户已登录系统,消费该事件的组件可以基于此做一些处理,比如增加当前在线用户数等。
负责产生这些事件的被称为生产者(或发布者)。这里的一个关键点是,生产者并不知道消费者的存在,反之亦然。另一个核心组件是事件通道(或消息总线),生产者将消息写入通道,消费者则从中读取消息。
图 1.1 以简化的方式展示了一个典型的事件驱动应用。
事件驱动架构可以基于发布-订阅消息范式或事件流的方式实现,这其中可以结合简单事件处理(Simple Event Processing)、事件流处理(ESP)、复杂事件处理(CEP)或在线事件处理(OLEP)等多种形式。本书主要聚焦于前者(发布-订阅消息范式)。
1.1.1 它解决了哪些问题?
为了更好地理解事件驱动架构是否能够解决我们的实际问题,我们不妨将其与传统的单体应用进行对比。这里以用户登录功能为例,这一功能大致涉及以下几个步骤:
- 接收包含用户凭证的 HTTP 请求;
- 从数据库读取用户信息,校验凭证;
- 在数据库中记录用户的登录时间戳;
- 增加当前在线用户数;
- 通知注册设备有用户登录行为;
- 返回 HTTP 响应。
即便在这样一个最小化的示例中,也存在许多潜在的出错点。假如我们认为最关键的功能是用户能够顺利登录系统,那么第 3、4、5 步其实都不是“硬性要求”,即便这些环节失败,系统依然可以处理登录请求。
我们还可以进一步比较:事件驱动微服务和基于 HTTP 通信的微服务有何不同?比如后者,如果登录操作成功但通知设备失败,应该给用户返回什么 HTTP 状态码?是返回 200(OK)然后让第 5 步后续重试?还是即使登录已成功也返回 500(内部服务器错误)?
事件驱动微服务的做法则完全不同。事件成为事实的唯一来源(source of truth),不再依赖同步的 HTTP 通信。
我们应当努力设计职责清晰、可扩展且具备容错能力的服务,而这些正是微服务的两大优势。
为了解耦这些功能,登录服务可以变成 UserEvent.SignedIn 的生产者,这也正是图 1.2 所展示的架构设计思路。
通过这种“扇出”设计,其他功能模块就可以通过监听该事件来实现自身的业务逻辑,从而天然具备并行处理的能力。此外,这种架构也让我们能够针对系统的不同部分分别进行优化和扩展。
举例来说,如果“向用户设备发送通知”变成了一项开销较大的操作,我们可以将其单独拆分为一个独立的服务,并为这个服务分配更多资源。
继续这个解耦的例子,拆分后的组件包括如下三个:
- 登录日志服务:接收
UserEvent.SignedIn事件,并记录新的登录时间戳; - 在线用户服务:接收同样的事件,并增加当前在线用户数;
- 通知服务:接收同样的事件,并向已注册设备发送通知。
一切的起点就是将“生产者”与“消费者”进行解耦,这会带来巨大的好处,后文我们还会进一步讨论。另一个显著优势是,所有通信都可以采用异步方式进行。
接下来,我们可以尝试用 Scala 3 的枚举来建模 UserEvent.SignedIn:
enum UserEvent:
def eventId: EventId
def userId: UserId
def createdAt: Timestamp
case SignedIn(
eventId: EventId,
userId: UserId,
device: UserDevice,
createdAt: Timestamp
)
在事件驱动应用中,追踪事件 ID 以唯一标识每个事件,以及记录事件时间戳用于可观测性,都是非常重要的设计原则。在这个模型中,我们还把 UserId 设为必填项,因为任何 UserEvent 都应该具备用户身份。另外,UserEvent.SignedIn 还包含了用于登录的设备信息。
我们也可以基于此设计更多“衍生事件”,在事件流中不断丰富信息。比如“通知服务”需要从数据库读取用户已注册的设备信息,用于通知登录行为;如果检测到新设备登录,还可以发布一个 UserEvent.RegisterDevice 事件,由专门的设备注册服务来消费和处理(只写入数据库)。
此外,我们还可以增加一个 CorrelationId,用来把属于同一次业务操作的多个系统事件串联起来,方便在跨服务链路里追踪。
1.1.2 何时使用?何时不适用?
事件驱动架构在业界已经被广泛验证,微软、AWS 等大公司都在实践这一架构。
不过,即便大厂极力推荐,作为软件工程师,我们在设计系统时,仍然要充分权衡各种优缺点,评估具体的架构方案是否真的能解决实际业务需求。
回到前面的单体应用例子,它的功能需求就是:
- 接收用户凭证的 HTTP 请求;
- 从数据库读取用户信息校验凭证;
- 在数据库中记录登录时间戳;
- 增加当前在线用户数;
- 通知已注册设备登录行为;
- 返回 HTTP 响应。
这些只是“功能性需求”。但在系统设计时,我们还必须关注“非功能性需求”(NFR),从而选择合适的架构。
比如说,如果我们的系统是为客户设计的,通常都会有一个服务级别协议(SLA),其中会规定最低处理吞吐量(如每秒请求数)、最大容许的系统宕机时间(可用性)等等。
当我们谈论“可用性”“可靠性”“可扩展性”等时,实际上就是在讨论系统的“质量属性”。
所以,如果你的单体应用能满足业务目标,并且 SLA 也完全覆盖,贸然引入事件驱动架构反而会适得其反,因为开发、运维和资源消耗都上升了,这些投入可能更适合用在开发真正的业务功能上。
但当 SLA 要求变得苛刻,比如要求高可用(99.9% 以上的在线率),那么就可能需要系统具备更好的可扩展性和容错能力,而功能解耦就是达成这些目标的重要手段。
此外,“可审计性”和“可观测性”也是采用 EDA 的强大理由。事件可以通过多种方式被观测和审计,比如采用事件溯源模式(详见 CQRS/ES 章节)。
1.2 微服务架构
微服务(也叫 service,服务)指的是一种可以独立部署和独立扩展的功能单元。
在微服务架构中(它是面向服务架构 SOA 的一种变体),各个服务之间可以通过 HTTP、Web Socket 或者像 AMQP 这样的消息协议进行通信。
回到我们之前的例子,这并不意味着登录日志功能(Sign-in logger)必须实现为一个独立的服务。它也可以是登录服务(sign-in service)内部的一个部分,只要它能监听 UserEvent.SignedIn 事件即可,这样实际上就把它从登录的关键链路中解耦出来了。这种模式也被称为 “listen-to-yourself”(自监听模式)。
实际开发中,这意味着一个服务内部可以同时拥有多个生产者和消费者,这一主题在本书中还会多次讨论。
再来看登录服务的流程:
- 接收包含用户凭证的 HTTP 请求;
- 从数据库读取用户信息,校验凭证;
- 发送
UserEvent.SignedIn事件(如果凭证无效则不发送); - 返回 HTTP 响应。
与此同时,服务还会并发地:
- 监听
UserEvent.SignedIn事件; - 把用户的登录时间戳持久化到数据库。
这种功能解耦的方式让我们在需要的时候可以方便地将某些功能独立出来,变成新的服务。例如,假如有一天需要扩展处理登录请求的 HTTP 服务以应对高并发,而其他功能都不用扩展,就非常容易实现。
第 3 步在其他场景下也可以有所不同,比如我们可以在登录失败时发出一个 UserEvent.SignInFailed 事件,带上失败详情,用于后续的数据分析。
此外,通过将 UserEvent.SignedIn 事件发布到通道,可以很容易地基于该事件快速接入新的功能模块,实现系统的可插拔性。
另外两个功能模块——在线用户统计和通知——也可以用同样的模式实现:
在线用户服务
- 监听
UserEvent.SignedIn事件; - 增加当前在线用户数。
通知服务
- 监听
UserEvent.SignedIn事件; - 通知用户设备有新登录。
这些功能可以合并在一个服务里实现,也可以根据实际需求拆成独立服务。
因此,除了功能解耦之外,事件驱动架构和微服务架构还为我们带来了可扩展性、容错性、可观测性和灵活性。
如果采用单体架构,一旦出现性能瓶颈(比如数据库响应变慢、HTTP 请求压力过大,或者两者同时发生),即使不断增加资源,也很难跟上业务增长的步伐。
而如果把各个功能拆分为独立的服务,遇到瓶颈时只需单独扩展相关服务即可,从长远来看,这种方式既高效又节省成本。
1.2.1 可扩展性
独立的服务可以根据需要进行横向扩展(也称为水平扩展)。
一个很好的例子是热门活动(比如世界杯)门票平台的上线,系统在短时间内会被大量请求“冲垮”(顺带一提,这类平台经常崩溃,所以我们可以猜测他们并没有采用 EDA 架构)。在某些系统中,经常会出现“高峰时段”,成千上万甚至上百万用户同时访问系统。
如果我们把职责拆分到多个小而独立的服务中,就可以只针对特定功能进行扩展,这在云环境下还可以节省成本。
关于可扩展性,我们会在第 6 章和第 7 章详细分析每个服务时深入讨论。
1.2.2 容错性
微服务同样实现了容错能力,也就是说,系统即使在部分功能出现故障时,仍然可以对外提供服务。
比如在用户登录的例子中,如果负责统计在线用户数的服务因为临时网络故障无法使用,系统依然能够正常处理登录流程。虽然 UI 可能会短暂显示错误的在线人数,但只要服务恢复,一切数据都会最终一致。
这种特性叫做“最终一致性”,我们会在下一章详细讨论(见“最终一致性 vs 强一致性”)。
1.2.3 可观测性
事件驱动架构还可以具备极高的可观测性。如果系统中所有事件都通过一个中心事件通道流转,我们就可以轻松监控每一个事件。
这让系统具备“可审计性”,而这通常是银行或金融类服务的硬性要求。
第 8 章我们会专门讨论可观测性。
有时我们甚至需要通过事件流来重建应用的状态,EDA 系统也可以做到,而且不会影响到其他服务。
1.2.4 灵活性
将大多数功能解耦后,我们可以在“自监听模式”(listen-to-yourself)和微服务模式之间自由选择,让系统变得更灵活。
这两种模式都会在我们第 6 章即将开发的系统中用到。
1.3 CQRS/ES
事件溯源(Event sourcing)是一种只追加的日志,用于记录过去已经发生的事实。
—— Greg Young
CQRS 的全称是 Command Query Responsibility Segregation(命令查询职责分离),它是“命令-查询分离原则”的一种具体实现形式,这一术语由 Greg Young 大约在 2006 年提出。CQRS 鼓励我们将应用程序分为“写操作”和“读操作”两个部分,这样做可以带来诸多显著的好处。
我们之前已经了解到,事件反映的是系统中已经发生的事情。因此,如果我们能够将每一个事件按照发生的顺序持久化到存储中,那么通过重放这些事件,我们就可以重建应用当前的状态。这本质上就是事件溯源(Event Sourcing, ES)的思想。
图 1.3 展示了一个 CQRS/ES 应用的示例,查询(query)和命令(command)既可以来自 UI,也可以由其他服务产生。
命令处理器(command handler)负责写操作,而查询处理器(query handler)则负责读操作。可以看到,命令处理器会产出事件,这些事件被持久化到事件存储中,同时还会生成基于当前应用状态的投影(projection)。
虽然事件溯源这个概念是在 21 世纪初提出的,但其实并不新鲜。会计师们一直都在做事件溯源!他们通过向只追加的账本(journal)不断添加已完成的交易,来保证账目的准确(如果你是会计,请原谅我这样简化的说法)。
以一个需要访问数据库、展示用户资料和其他信息的典型应用为例,读操作的次数通常远多于写操作,因此,将应用拆分为读写两侧会更加合理。
1.3.1 命令(Commands)
继续以用户登录为例,我们可以这样建模命令:
enum UserCommand:
def id: CommandId
def cid: CorrelationId
def createdAt: Timestamp
case SignIn(
id: CommandId,
cid: CorrelationId,
username: UserName,
password: UserPassword,
device: UserDevice,
createdAt: Timestamp
)
case Register(
id: CommandId,
cid: CorrelationId,
username: UserName,
password: UserPassword,
email: Email,
device: UserDevice,
createdAt: Timestamp
)
UserCommand.SignIn 如果顺利执行,最终会产生 UserEvent.SignedIn 事件。如果登录失败,则可能会产生如 InvalidSignInAttempted(例如多次输错密码)这样的事件。注意,一个命令可能会产生 0 个、1 个或多个事件。
再举个例子,UserCommand.Register 代表用户注册命令。它可能产生如 UserEvent.Registered 或 UserEvent.NotRegistered(比如用户名已被占用)等事件。
请注意,命令都带有唯一的 ID 和时间戳,这样可以帮助我们分析系统中消息的流动,也便于可观测性。还有一个 CorrelationId,用来在跨服务链路中唯一标识一次业务操作。例如,UserCommand.Register 和随后产生的 UserEvent.Registered 会共享同一个 CorrelationId,这样一来可以追踪分布式系统中的一次完整操作,尤其是在出现问题时非常有用。
而且,这并不止步于此。其他服务也可以对 UserEvent 进行响应,进而产生新的事件、通知或告警,这些消息同样会共享同一个 CorrelationId。
大多数 CQRS 应用都设有专门的命令处理器,负责消费命令、处理业务、最终产出事件。服务也可以按照它们处理的命令和事件来进行分组,比如专门有一个用户服务处理 UserCommand 并产出 UserEvent。
1.3.2 查询(Queries)
用户查询可能来自 UI(比如用户希望查看自己的资料和其他信息),也可能来自其他服务。
enum UserQuery:
def id: QueryId
def createdAt: Timestamp
case GetProfile(
id: QueryId,
userId: UserId,
userToken: UserToken,
createdAt: Timestamp
)
这些查询会由查询处理器处理,查询处理器负责应用的读侧。由于查询仅做读取、不会改变应用状态,因此不会产生任何事件。
与命令类似,查询同样有唯一的 ID 和时间戳。在某些场景下也可以考虑引入 CorrelationId,但因为查询不会产出事件,所以大多数情况下并不需要。
本书涉及的系统会让 HTTP 层直接充当查询处理器,而不是让“查询”类型的消息在整个系统中流转。
1.3.3 读与写(Reads & Writes)
将读写职责分离也能带来更好的数据库访问控制。比如服务 A 只能用只读组件,服务 B 则是唯一有写权限的服务。
我也建议在组件层面上进行这种关注点分离。例如,为 Redis 创建接口时,可以分别定义 reader 和 writer:
trait UserCacheReader[F[_]]:
def find(userId: UserId): F[Option[User]]
trait UserCacheWriter[F[_]]:
def save(user: User): F[Unit]
当读写操作被不同服务或应用使用时,这种做法尤其有意义。如果不是这种场景,差别也不大。
1.3.4 何时使用?何时不用?
虽然 CQRS 和事件溯源经常被一起提及,但这并不意味着我们在设计应用时必须同时采用它们。具体选用哪种模式,还是要看实际业务场景,也可以根据需要做混合设计。
CQRS 最大的优势在于读写分离,可以分别做专门的优化。事件溯源(ES)则带来了可审计性和可恢复性,因为我们可以随时通过重放事件流来还原应用状态。
从这个角度看,CQRS 和 ES 是互补的,但它们本质上是不同的概念。微软官方有大量关于该模式及其在 Azure 平台上实践的文档。
CQRS/ES 已经是一套成熟的设计模式,解决了很多实际问题。它在异步场景下效果极佳,比如无需等待命令执行结果的系统。但对于某些应用,CQRS/ES 反而带来不便(比如典型的 HTTP 请求-响应这种同步模型)。
回到用户登录的例子,可以说 CQRS/ES 并不是最适合的模式。正如前文所述,直接在用户服务中处理登录请求,并用同步模型返回响应更加高效。此时引入命令消息反而过于复杂。
不过,把 UserEvent.SignedIn 发布出去,让事件溯源部分为系统带来审计能力,依然是很有价值的。
总结一下,更适合这个场景的流程如下:
- 接收包含用户凭证的 HTTP 请求;
- 从数据库读取用户信息,校验凭证;
- 发送
UserEvent.SignedIn事件; - 返回 HTTP 响应。
还有一些场景并不适合 CQRS/ES,比如 ATM 取款。取款请求天生是同步的,需要立即判断账户余额是否充足。但交易完成后发送 MoneyWithdrawn 等事件,用于后续的统计、分析和历史追踪,依然是有意义的。
采用该模式时,一定要权衡利弊,最终还是要看具体需求。等到第 6 章讲具体系统实现时,我们还会继续讨论这个话题。
1.3.5 框架
你所需要的,无非是一种函数、模式匹配和一个左折叠。
——Greg Young
Greg Young 在他《DDD、CQRS、事件溯源的十年》演讲中谈到事件溯源框架,意思是说:其实我们完全不需要专门的框架就能构建事件溯源应用。
对此我完全同意。不过,市面上还是有不少框架提供了远不止事件溯源功能的“全家桶”解决方案,这对于希望开箱即用的开发者来说是有吸引力的。
在 Scala 生态圈里,最主流的框架大概是 Akka Persistence,它构建在 Akka Actor 体系之上,开箱即享有容错、分布式、集群和可扩展性等特性,因此人气很高。
还有 Aecor,它是在 Akka 之上构建的纯函数式抽象,同样支持分布式和容错。
在第 3 章,我们还会深入学习有状态与无状态应用、集群,以及无需框架、无副作用、完全函数式的事件溯源解决方案。
1.4 小结
微服务架构让大型、复杂系统的可维护性大大提升,因为每个服务都可以拥有独立的生命周期,并能按需扩展。同时,服务之间也具备容错能力。
事件驱动架构通过基于事件的系统通信,实现了高可观测性和关注点分离。
CQRS 将“命令”作为可能触发事件的指令引入,而事件溯源(ES)让我们能够重放这些事件,从而具备审计和恢复的能力。
这四个核心概念(微服务、事件驱动架构、CQRS 和事件溯源)组合在一起,为大规模分布式系统的设计提供了强大支撑。当然,选择这些架构也并非没有代价,本章也已经提及其中的一些不足。
比如,CQRS 并不适合同步应用场景。微服务虽然可以让业务逻辑拆分为可独立部署的单元,但也增加了运维难度和发布协作的复杂度。此外,在事件驱动架构下实现高可观测性其实并不容易(我们将在第 8 章深入讨论这个话题)。
尽管如此,我依然认为这种架构的优点远大于缺点。后续章节,我们将以“高可用分布式交易系统的架构设计”为切入点,进一步讨论这些权衡。