在 2021 年网络黑色星期五 (BFCM) 期间,Shopify 商家的销售额超过 50 亿美元,峰值销售额超过每小时 1 亿美元。在如此大规模的情况下,高可用性和快速响应时间至关重要。但即使对于较小的应用程序,可用性和响应时间对于出色的用户体验也很重要。
高可用性通常与高服务器正常运行时间混为一谈。但是,服务器没有崩溃或关闭是不够的。在 Shopify 的情况下,我们的商家需要能够进行销售。因此,买方需要能够与应用程序进行交互。一条消息说“稍后回复结果”是不够的,一次只服务一个买家也不够好。要考虑可用的应用程序,用户社区需要与应用程序进行有意义的交互。如果在用户需要时可以进行这些交互,则可以认为可用性很高。
迁移工作量异步处理
为了可用,应用程序需要能够接受传入的请求。如果应用程序的面向外部的部分(应用程序服务器)也在执行处理请求所需的繁重工作,它会很快变得不堪重负,无法接收新的传入请求。为了避免这种情况,我们可以将一些繁重的工作卸载到系统的不同部分,将其移到主请求响应周期之外,以免影响应用服务器接受和服务传入请求的可用性。这也缩短了响应时间,提供了更好的用户体验。
常见的卸载任务包括:
如果任务很慢、消耗大量资源或容易出错,那么卸载任务的好处就特别大。
例如,当新用户注册 Web 应用程序时,该应用程序通常会创建一个新帐户并向他们发送欢迎电子邮件。帐户可用不需要发送电子邮件,而且用户也不会立即收到电子邮件。所以在请求响应周期内发送电子邮件是没有意义的。用户不必等待发送电子邮件,他们应该能够立即开始使用应用程序,并且应用程序服务器不应该承担发送电子邮件的任务。
在向调用者提供响应之前不需要完成的任何任务都是卸载的候选对象。将图像上传到 Web 应用程序时,需要处理图像文件,应用程序可能希望创建不同大小的缩略图。用户通常不需要成功的图像处理,因此通常可以卸载此任务。但是,服务器无法再响应说“图像已成功处理”或“发生图像处理错误”。现在,它只要回应“图像已成功上传“即可,如果一切按计划进行,图片将在稍后出现在网站上。 考虑到图像处理非常耗时的性质,考虑到响应时间的大幅改进和它提供的可用性优势,这种权衡通常是值得的。
后台工作
后台作业是一种卸载工作的方法。后台作业是稍后要在请求响应周期之外完成的任务。应用程序服务器将任务(例如图像处理)委托给工作进程,该进程甚至可能运行在完全不同的机器上。应用程序服务器需要将任务传达给工作者worker。工作人员可能很忙,无法立即接受任务,但应用程序服务器不应该等待工作者的响应。在应用程序服务器和工作者之间放置一个消息队列解决了这个难题,使它们的通信异步。消息的发送者和接收者可以在不同的时间点独立地与队列交互。应用服务器将消息放入队列并继续前进,立即可以接受更多传入请求。消息是工作者要完成的任务,这就是为什么这样的消息队列常被称为任务队列。工作者可以以自己的速度处理来自队列的消息。
后台作业后端本质上是一些任务队列以及一些用于管理工作者worker的代理代码。
Shopify 每秒对数以万计的作业进行排队以利用各种功能。下面是优点:
使用后台作业允许我们将面向外部的请求(由应用程序服务器提供)与任何耗时的后端任务(由工作人员执行)分离。从而提高响应时间。提高单个请求的响应时间也提高了系统的整体可用性。
如果耗时的图像处理是由后台作业完成的,那么突然激增的图像上传不会造成伤害。应用程序服务器的可用性和响应时间受到它排队图像处理作业的速度的限制。但是排队更多作业的速度不受处理它们的速度的限制。如果工作人员无法跟上增加的图像处理任务量,队列就会增长。但是队列充当了工作者和应用程序服务器之间的缓冲区,以便用户可以像往常一样继续上传图像。随着 Shopify 面临每秒高达 17 万个请求的流量高峰,尽管流量不可预测,但后台作业对于保持高可用性至关重要。
当工作者在运行作业时遇到错误时,作业将重新排队并稍后重试。由于所有这些都发生在后面,因此不会影响面向外部的应用程序服务器的可用性或响应时间。它使后台作业非常适合容易出错的任务,例如向不可靠的第三方发出请求。
多个工作者可能会处理来自同一队列的消息,从而允许同时处理多个任务。这是分配工作量。我们还可以将一个大任务拆分为几个较小的任务,并将它们作为单独的后台作业进行排队,以便同时处理其中的几个子任务。
大多数后台作业后端允许对作业进行优先级排序。他们可能会使用不遵循先进先出方法的优先级队列,这样高优先级的作业最终就会被切断。或者他们为不同优先级的作业设置单独的队列,并配置工作者从较高优先级队列中优先处理作业。没有工作者需要完全专注于高优先级工作,因此只要队列中没有高优先级工作,工作者就会处理较低优先级的工作。这是足智多谋的,显着减少了工作者的空闲时间。
后台作业并不总是由应用程序服务器排队。处理作业的工作人员也可以将另一个作业排队。虽然他们根据用户交互或一些变异数据等事件对作业进行排队,但调度程序可能会根据时间对作业进行排队(例如,每日备份)。
后台作业后端封装了请求作业的客户端和执行作业的工作者之间的异步通信。将这种复杂性置于单独的抽象层中,可以使具体的作业类保持简单。具体的作业类只实现要完成的任务(例如,发送欢迎电子邮件或处理图像)。它不知道将来会运行,在几个工作程序之一上运行,或者在错误后重试。
挑战
异步通信带来了一些挑战,这些挑战不会因为封装了它的一些复杂性而消失。后台工作没有什么不同。
排队作业的客户端和处理它的工作者并不总是运行相同的软件版本。其中之一可能已经部署了较新的版本。这种情况可能会持续很长时间,尤其是在练习金丝雀部署时。如果作业已使用一组特定参数排入队列,则作业参数的更改可能会破坏应用程序,但处理作业的工作者需要不同的参数集。对作业参数的重大更改需要通过保持向后兼容性的一系列更改进行,直到队列中的所有旧作业都已处理完毕。
当工作人员完成一项工作时,它会返回报告说现在可以安全地从队列中删除该工作。但是如果处理工作的工人保持沉默怎么办?我们可以让其他工作者接手这样的工作并运行它。这确保即使第一个工作人员崩溃,作业也能运行。但是如果第一个工人只是比预期慢一点,我们的工作就会运行两次。另一方面,如果我们不允许其他工作人员接手工作,那么如果第一个工作人员崩溃,工作将根本无法运行。所以我们必须决定什么更糟:根本不运行作业,或者运行它两次。换句话说,我们必须在至少和最多一次交付之间进行选择。
例如,不向客户收费并不理想,但对某些企业而言,向他们收取两次费用可能更糟。在这种情况下,最多一次交付听起来是对的。但是,如果仔细跟踪每次收费并且作业在尝试收费之前检查这些状态,则第二次运行该作业不会导致第二次充费。这项工作是幂等的,允许我们安全地选择至少一次交付。
作业队列通常位于单独的数据存储中。Redis 是队列的常见选择,而许多 Web 应用程序将其操作数据存储在 MySQL 或 PostgreSQL 中。当用于写入操作数据的事务打开时,排队作业将不是此封闭事务的一部分 - 将作业写入 Redis 不是 MySQL 或 PostgreSQL 事务的一部分。作业会立即排队,并且可能会在封闭事务提交(或者即使它回滚)之前被处理。
当接受来自用户交互的外部输入时,通常会以最少的处理写入一些操作数据,并将执行额外步骤处理该数据的作业排入队列。除非我们在提交写入操作数据的事务后将其排队,否则此作业可能找不到它需要的数据。但是,系统可能会在提交事务之后和排队作业之前崩溃。作业永远不会运行,不会执行处理数据的附加步骤,使系统处于不一致的状态。
发件箱模式 可用于创建事务性暂存作业队列。不是立即将作业排队,而是将作业参数写入操作数据存储中的临时表中。这可以是写入操作数据的数据库事务的一部分。调度程序可以定期检查暂存表,将作业排队,并在作业成功排队时更新暂存表。由于即使作业已排队,对临时表的此更新也可能失败,因此作业至少排队一次并且应该是幂等的。
根据作业量,事务性暂存作业队列可能会给数据库带来相当大的负载。虽然这种方法可以保证作业的排队,但不能保证它们会成功运行。
业务流程可能涉及来自为请求提供服务的应用程序服务器和运行多个作业的工作者的数据库写入。这就产生了本地数据库事务的问题。当最后一个本地事务提交时达到最终一致性。但是,如果其中一项作业未能提交其数据,则系统将再次处于不一致状态。SAGA 模式可用于保证最终一致性。除了以事务方式对作业进行排队之外,作业在成功时还会向临时表报告。调度程序可以检查此表并发现不一致之处。这会导致数据库负载比单独的事务暂存作业队列更高。
作业以预定义的顺序离开队列,但它们最终可能会出现在不同的工作人员上,并且无法预测哪个工作完成得更快。如果作业失败并重新排队,它甚至会在稍后处理。因此,如果我们立即将多个作业排队,它们可能会出现故障。如果临时表也用于维护作业顺序,则 SAGA 模式可以确保作业以正确的顺序运行。
如果不考虑一致性保证,则可以使用更轻量级的替代方案。作业完成其任务后,它可以将另一个作业排队作为后续作业。这可确保作业按预定义的顺序运行。该方法快速且易于实现,因为它不需要临时表或调度程序,并且不会对数据库产生任何额外负载。但由此产生的系统可能会变得难以调试和维护,因为它会将其所有复杂性推向下一个潜在的长作业链,将其他作业排队,并且几乎无法观察到底哪里出了问题。
作业不必像面向用户的请求一样快,但长时间运行的作业可能会导致问题。例如,流行的 ruby 后台作业后端Resque可防止工作进程在作业运行时被关闭。但是无法部署它,它也不是非常适合云计算,因为资源需要在很长一段时间内连续可用。
另一个流行的 ruby 后台作业后端,Sidekiq, 在开始关闭工作程序时中止并重新排队作业。但是,下次作业运行时,它会从头开始,因此它可能会在完成之前再次中止。如果部署发生得比作业完成的速度快,作业就没有机会成功。Shopify的核心每天部署大约40次,这不是学术讨论而是我们需要解决的实际问题。
幸运的是,许多长期运行的作业在本质上是相似的:它们迭代庞大的数据集。Shopify 开发并开源了 Ruby on Rails 的 Active Job 框架的扩展,使这种工作可中断和可恢复。它在每次迭代后设置一个检查点并重新排队作业。下次处理作业时,检查点将继续工作,从而可以安全轻松地中断作业。通过可中断和可恢复的工作,工作人员可以随时关闭,这使他们对云更加友好,并允许频繁部署。可以限制或停止作业以进行灾难预防,例如,如果数据库上有大量负载。中断作业还允许在数据库分片之间安全地移动数据。
分布式后台作业
Ruby 中的Resque和Sidekiq等后台作业后端通常通过将序列化对象放入队列(具体作业类的实例)来将作业排队。这意味着排队作业的客户端和处理它的工作人员都需要能够使用此对象并具有此类的实现。这在客户端和工作人员运行相同代码库的单体架构中非常有效。但是,如果我们想将图像处理提取到专用的图像处理微服务中,甚至可能是用不同的语言编写的,我们需要一种不同的通信方法。
后台作业允许面向用户的应用程序服务器将任务委派给工作人员。由于工作量减少,应用服务器可以更快地处理面向用户的请求并保持更高的可用性,即使在面临不可预测的流量高峰或处理耗时且容易出错的后端任务时也是如此。后台工作将客户端和工作者之间异步通信的复杂性封装到一个单独的抽象层中,使得具体的代码保持简单和可维护。
高可用性和快速响应时间对于提供出色的用户体验是必不可少的,无论应用程序的规模如何,后台作业都成为不可或缺的工具。
本文作者Kerstin 是shopify一名高级开发人员,她将 Shopify 的大量 Rails 代码库转变为更加模块化的整体,这种重构是基于她之前在分布式微服务架构方面的经验为基础。
#Shopify
Shopify如何使用Ruby实现每小时销售1亿美元?