目录

第三章 以队列为中心的工作流程模式

这种松耦合的基本模式专注于异步传递从用户界面发送到后端服务的命令请求,以便进行处理。该模式是 CQRS 模式的子集。

该模式允许交互式用户通过 Web 层进行更新,而不会降低 Web 服务器的速度。它尤其适用于处理耗时、资源密集或依赖于可能并非始终可用的远程服务的更新。例如,社交媒体网站在处理状态更新、照片上传、视频上传或发送电子邮件时,可以受益于此模式。

该模式用于响应来自交互式用户的更新请求。首先,用户界面代码(位于 Web 层)会创建一个消息,描述满足更新请求需要完成的工作。该消息会被添加到队列中。在某个时间点,另一个节点(运行在服务层)上的服务会从队列中移除消息并执行所需的工作。消息流向是单向的,从 Web 层流向队列,最终到达服务层。此模式并未指定如何(或是否)告知用户进度。

这是一个异步模型,因为发送方不会等待响应。实际上,不会直接返回任何响应。(在编程术语中,返回值为 void。)它有助于用户界面保持始终如一的快速响应时间。

背景

以队列为中心的工作流程模式能够有效应对以下挑战:

  • 应用程序在各层之间解耦,但各层仍需协作
  • 应用程序需要保证消息在各层至少被处理一次
  • 即使依赖性处理发生在其他层,用户界面层也应始终保持响应迅速的用户体验
  • 即使处理过程中访问了第三方服务,用户界面层也应始终保持响应迅速的用户体验

此模式同样适用于通过 Web 服务访问相同功能的 Web 应用程序和移动应用程序。任何面向交互式用户的应用程序都适用。

云的重要性

通过使用云服务,此模式的基础架构实现通常非常简单。在云之外,这些方面可能会复杂得多。可靠的队列可以作为云服务使用。

使用云服务还可以简化中间数据的存储。云服务可用于存储、NoSQL 数据库和关系数据库。

影响

可用性、可靠性、可扩展性、用户体验

机制

以队列为中心的工作流程模式用于 Web 应用程序中,旨在解耦 Web 层(实现用户界面)和服务层(执行业务处理)之间的通信。

不使用此类模式的应用程序通常会通过用户界面代码直接调用服务层来响应网页请求。这种方法虽然简单,但在分布式系统中却面临诸多挑战。其中一个挑战是,所有服务调用必须在 Web 请求完成之前完成。此外,该模型还要求服务层的可扩展性和可用性达到或超过 Web 层,而使用第三方服务时,这一点可能难以保证。服务层如果运行不可靠或速度缓慢,会严重影响 Web 层的用户体验,并对可扩展性产生负面影响。

解决方案是采用异步通信。Web 层向服务层发送命令,其中命令是指执行某些操作的请求。命令示例包括:创建新用户帐户、添加照片、更新状态(例如在 Twitter 或 Facebook 上)、预订酒店房间和取消订单。

命令以消息的形式通过队列发送。队列是一种简单的数据结构,它包含两个基本操作:添加 和 删除。队列的特性在于,删除操作会返回队列中存在时间最长的消息。这有时被称为先进先出 (FIFO) 顺序。执行添加操作通常称为入队,执行删除操作称为出队 (dequeuing)。

在最简单(也是最常见)的场景中,模式非常简单:发送方将命令消息添加到队列中(入队 (enqueues messages)),接收方从队列中移除这些命令消息(出队 (dequeues messages))并进行处理。如图 3-1 所示。 (稍后我们将看到,从队列中移除消息的编程模型比简单的出队操作要复杂得多。)

发送方和接收方被称为松耦合。它们仅通过队列中的消息进行通信。这种模式允许发送方和接收方以不同的速度或时间表运行;当发送方向队列添加消息时,接收方甚至不需要运行。双方都不知道对方的具体实现,但双方需要就使用哪个队列实例以及从发送方传递到接收方的命令消息的结构达成一致。

图 3-1。 Web 层将消息添加到队列中。服务层从队列中移除并处理消息。队列中的命令消息数量会波动,从而形成一个缓冲,使 Web 层能够快速卸载工作,同时又不会使服务层不堪重负。服务层可以有充足的时间处理新消息,仅在资源可用时才进行处理。
图 3-1。 Web 层将消息添加到队列中。服务层从队列中移除并处理消息。队列中的命令消息数量会波动,从而形成一个缓冲,使 Web 层能够快速卸载工作,同时又不会使服务层不堪重负。服务层可以有充足的时间处理新消息,仅在资源可用时才进行处理。

发送方不必是 Web 用户界面;例如,它也可以是原生移动应用程序,通过 Web 服务(例如 REST API)进行通信。也可以有多个发送方和多个接收方。这种模式仍然有效。

本模式描述的其余部分主要关注如何防范故障场景以及处理用户体验问题。

队列的可靠性

工作流程中,发送方将消息添加到队列,接收方会在某个时间点将其移除。我们能确定消息一定会到达接收方吗?

需要强调的是,云队列服务提供的是一个可靠的队列。“可靠”主要源于两个方面:数据的持久性和高吞吐量(每秒至少数百次交互)。

队列本身是可靠的,不会丢失我们的数据,但这种模式并非旨在保护我们的应用程序免受所有故障的影响。相反,这种模式要求我们的应用程序实现特定的行为,以便成功应对故障场景。

接收器的编程模型

在实现接收器时,使用可靠队列服务的编程模型有时会让开发者感到意外,因为它比基本队列略微复杂一些:

  1. 从队列中获取下一条可用消息
  2. 处理消息
  3. 从队列中删除消息

实现过程首先将消息出队,然后再删除消息。为什么要分两步删除呢?这是为了确保至少处理一次。

不可见窗口和至少一次处理

处理命令请求包括从队列中获取消息、理解消息内容并相应地执行请求的命令。具体细节取决于应用程序。如果一切按计划进行,从队列中删除消息是最后一步。只有到那时,命令才算完全处理完毕。

但并非一切都总是按计划进行。例如,可能会出现应用程序代码无法控制的故障。这类故障可能由多种原因引起,但最容易理解的是硬件故障。如果您使用的硬件发生故障,无论进程处于生命周期的哪个阶段,都会停止运行。如果云平台因为自动扩缩容逻辑认为某个正在运行的节点不再需要而将其关闭,也可能发生故障。或者,您的节点可能被重启。

无论故障原因是什么,您的处理过程都已中断,需要恢复。它是如何恢复的呢?

当消息出队时,它并不会从队列中完全移除,而是被隐藏起来。消息会被隐藏一段指定的时间(该时间在出队操作期间指定,之后可以增加)。我们将这段时间称为“不可见窗口”。当消息处于不可见窗口内时,它无法被出队。

只有当处理时间超过允许的时间时,不可见窗口才会生效。消息在队列中的自动重现是克服故障的关键之一,也是至少一次处理模型中至少一次部分的基础。任何第一次出队时未完全处理的消息都会有第二次机会。代码会不断尝试,直到处理完成(或者我们放弃,这将在后面的“毒消息处理”部分中解释)。

任何第二次出队的消息可能在第一次出队时已被部分处理。如果不加以防范,这可能会导致问题。

重复消息的幂等处理

幂等操作是指可以重复执行的操作,其任意次数的成功操作与一次成功的操作没有区别。例如,根据 HTTP 规范,HTTP 动词 PUT、GET 和 DELETE 都是幂等操作:我们可以对特定资源执行一次 DELETE 操作,也可以执行 100 次,最终结果都相同;(假设操作成功)资源已被删除。

有些操作被认为是天然幂等的,例如 HTTP DELETE 操作,其幂等性几乎是与生俱来的。涉及从一个账户取款并存入另一个账户的多步骤金融交易可以实现幂等,但它绝对不是天然幂等的。有些情况比其他情况更复杂。

云队列服务会跟踪消息的出队次数。每次消息出队时,队列服务都会将此值与消息一起提供。我们称之为出队计数。消息首次出队时,此值为 1。通过检查此值,应用程序代码可以判断这是首次处理尝试还是重复尝试。

首次处理时,应用程序逻辑可以进行简化;但对于重复尝试,可能需要一些额外的逻辑来支持幂等性。

例如,假设有一个命令,用于根据用户提供的电子邮件地址创建新用户帐户,并且消息出队计数为 2。正确的处理方式需要考虑部分(或全部)处理工作可能已完成的情况,因此需要采取更智能的处理方式。“智能处理”的具体方法会因应用程序而异。

更简单的场景可能不需要任何特定的幂等性支持。例如,发送确认电子邮件。由于失败事件很少发生,而且偶尔重复发送电子邮件的影响也很小,因此每次都发送电子邮件可能就足够了。

幂等性处理是处理重复消息的正确第一步。如果消息重复次数过多,超过应用程序定义的阈值,则应将其视为毒消息。

处理重复次数过多的“毒消息”

有些消息由于其内容而无法成功处理。这些消息被称为“毒消息 (poison messages)”。

例如,假设有一条消息包含一个命令,要求根据用户提供的电子邮件地址创建新的用户帐户。如果该电子邮件地址已被使用,应用程序仍然应该成功处理该消息,但不会创建新的用户帐户。这并非“毒消息”。

但是,如果电子邮件地址字段包含一个长达 10,000 个字符的字符串,而这种情况在应用程序代码中是未预料到的,则可能会导致程序崩溃。这就是“毒消息”。

如果应用程序在处理消息时崩溃,消息的“不可见窗口”最终会过期,该消息将再次出现在队列中,等待下一次尝试。上一节解释了在这种情况下需要幂等处理的原因。处理“毒消息”时,幂等处理将永远不会终止。

当消息出队时,云队列服务会提供一个出队计数,可用于判断这是否是首次处理尝试。该计数与用于检测重复消息以实现幂等处理的值相同。您的毒消息检测逻辑必须包含一条规则,将任何反复出现的消息在第 N 次出现时视为毒消息。选择 N 的值是一个业务决策,需要在浪费资源处理毒消息的成本和未处理有效消息的风险之间进行权衡。实际上,执行中断通常不频繁,因此在设置毒消息策略时应考虑这一点。如果处理过程资源密集,例如需要 60 分钟,您可能不希望重试任何失败的进程;因此,对于 N > 1 的情况,该消息将被视为毒消息。但是,根据具体情况,通常会重试一次到几次。

一旦识别出毒消息,如何处理它也是另一个业务决策。如果需要人工审核毒消息以改进处理方式,一种方法是使用所谓的 死信队列,即用于存储无法正常处理的消息。一些队列系统内置了对死信队列的支持,但自己实现一个也并不难。对于低优先级消息,甚至可以考虑直接删除它们。关键在于,一旦应用程序检测到毒消息,就应立即将其从主处理队列中移除。

出队计数大于 1 并不一定意味着存在毒消息。该值是出队计数,而不是毒消息计数。

用户体验影响

这种模式涉及异步处理、重复处理和请求失败。所有这些都会对用户体验产生影响。

在用户界面中处理异步处理可能比较棘手,并且与应用程序密切相关。我们希望面向用户的界面能够快速响应,因此,与其在用户等待期间执行耗时的操作,不如将执行该操作的命令请求放入队列。这样,用户界面就能尽快返回给用户(改善用户体验),同时使 Web 服务器层能够专注于网页服务(增强可扩展性)。

另一方面,您现在需要让用户理解,即使系统已确认他们的操作(并已发出命令),该操作的处理也并非立即完成。对此,有多种方法。

在某些情况下,用户无法轻易判断操作是否已完成,因此无需采取特殊措施。

如果用户希望在操作完成后收到通知,则在操作完成后发送电子邮件可能是一个不错的选择。在电子商务场景中,“您的订单已发货”等进度通知十分常见。

有时用户希望等待任务完成。这就需要用户界面层轮询服务层直至任务完成,或者服务层主动通知用户界面层。主动通知可以通过长轮询来实现。在长轮询中,Web 客户端会与服务器建立 HTTP 连接,但服务器会故意不响应,直到收到响应为止。

使用长轮询技术与在 Web 层“内联”执行原始(耗时)操作有所不同。在 Web 层阻塞直到操作完成会影响可扩展性。而这种方法仍然允许在服务层执行耗时的工作。

独立扩展各层

队列长度和消息在队列中停留的时间是自动扩展的重要环境信号。云队列服务可以轻松获取这些关键指标。例如,不断增长的队列可能表明需要增加服务层的容量。请注意,这些信号可能仅表明需要扩展一个层或一个特定的处理服务。这种独立于关注点的扩展有助于优化成本和效率。

在高规模下,队列本身可能会成为瓶颈,需要多个队列实例。但这不会改变核心模式。

示例:在 Windows Azure 上构建 PoP

照片页​​面 (PoP) 应用程序(在前言中已介绍,并将贯穿本书作为示例)使用第 3 章来处理新照片导入系统。

PoP 中的两个应用程序层协同工作。Web 层的用户界面负责为已登录用户提供照片上传功能,并将命令消息加入队列。服务层负责从队列中取出命令消息并进行处理。

用户界面层

运行在 Web 层的 PoP 用户界面由运行在可变数量 Web 角色实例上的 ASP.NET MVC 代码组成。用户身份验证(登录)在此层处理,已验证用户可以上传照片。

待处理照片的位置、照片的纯文本描述以及 PoP 用户的帐户标识符存储在一个消息对象中,然后该消息对象会被加入队列。在 PoP 中,待处理的照片被假定已上传到 Blob 存储(尽可能使用 代客密钥模式(第 13 章)),并以 Blob 的形式存储在 Windows Azure 存储中;只有对该 Blob 的引用(URL)才会存储在加入队列的消息对象中。虽然此特定过程操作的是外部资源(存储为 Blob 的照片),但这并不意味着此模式必须依赖外部资源才能发挥作用。通常情况下,所有必需的数据都完全包含在消息对象中。

无论运行多少个 Web 角色实例,它们都会将消息提交到同一个 Windows Azure 队列。

发送方和接收方使用同一个消息队列:

进入队列的消息中包含的字段类似于以下内容:

请注意,图像并非进入队列的消息的一部分,而是对图像的引用。这样做的实际原因是队列不允许消息大于特定大小(截至撰写本文时为 64 KB)。从更深层次的哲学角度来看,Blob 存储是存储 Windows Azure 上上传图像的“正确”位置。

服务层

PoP 服务运行在服务层中数量不定的工作角色实例上。这些服务中的 C# 代码会持续检查队列中是否有新消息。一旦队列中有消息可用,就会将其移除并进行处理。

有时队列中可能没有消息可用。在这种情况下,任何出队尝试都会立即返回。务必避免代码在紧密的循环中频繁调用队列服务,因为每次队列操作都会消耗少量资源。请务必添加适当的延迟。

每条消息代表一张等待集成到 PoP 站点其他部分的新照片上传。集成过程包含几个步骤:创建缩略图、提取地理标记数据,然后更新用户帐户数据,将新照片添加到其公共页面。每个步骤完成后,消息都会更新回 Blob 存储,并更新 LastCompletedStep 的值。

PoP 缩略图的创建是幂等的。这一点至关重要,因为如果我们疏忽大意,最终可能会导致大量孤立图像文件散落在我们的 Blob 存储账户中。为了处理幂等缩略图的创建,PoP 选择通过从完整尺寸照片的文件名派生来确保缩略图文件名的确定性。所有上传的照片都会被分配一个唯一的系统生成名称,例如 william.jpg。缩略图的名称是通过在根文件名后附加“_thumb”而生成的,结果为 william_thumb.jpg。这种简单的方法可以确保 Blob 存储中始终只有一个缩略图。请注意,虽然结果与首次成功生成缩略图并不完全相同——例如,文件的时间戳会有所不同——但我们可以得出结论,结果是等效的。

PoP 有一条业务规则,规定任何来自 popuploads 队列且出队计数为 3 或以上的消息都被视为毒消息。当检测到毒消息时,PoP 会发送电子邮件通知提交照片的用户,该照片已被拒绝并删除。

照片页面系统变更概要

为了处理新上传的照片:

  • 照片存储在为此目的创建的公共 Blob 容器中。
  • 包含新创建照片相关数据的消息被加入队列。此操作由 Web 层(Web 角色)完成。
  • 服务层中的工作角色监控队列中待处理的消息,并在消息可用时进行处理。

消息处理完成后,PoP 的静态状态包括:

  • 原始照片以 Blob 形式存储。
  • 生成的缩略图以 Blob 形式存储。
  • 照片的元数据存储在 Windows Azure SQL 数据库中(详见 数据库分片模式(第 7 章))。

总结

此模式用于解耦应用程序的各个层,特别是 Web(用户界面)层和执行业务处理的服务层。它不适用于例行的只读页面请求。通信是单向的,从 Web 层到服务层,并通过将消息添加到队列来处理。可靠的云队列服务简化了部署。解耦的 Web 层可以实现更高的响应速度和可靠性,从而提供更佳的用户体验。独立于关注点的扩展也允许为每个层配置最适合该层的资源。

在 GitHub 上查看本页或提交勘误 ↗