在 2021 年网络黑色星期五 (BFCM) 期间,Shopify 商家的销售额超过 50 亿美元,峰值销售额超过每小时 1 亿美元。在如此大规模的情况下,高可用性和快速响应时间至关重要。但即使对于较小的应用程序,可用性和响应时间对于出色的用户体验也很重要。
高可用性通常与高服务器正常运行时间混为一谈。但是,服务器没有崩溃或关闭是不够的。在 Shopify 的情况下,我们的商家需要能够进行销售。因此,买方需要能够与应用程序进行交互。一条消息说“稍后回复结果”是不够的,一次只服务一个买家也不够好。要考虑可用的应用程序,用户社区需要与应用程序进行有意义的交互。如果在用户需要时可以进行这些交互,则可以认为可用性很高。
迁移工作量异步处理
为了可用,应用程序需要能够接受传入的请求。如果应用程序的面向外部的部分(应用程序服务器)也在执行处理请求所需的繁重工作,它会很快变得不堪重负,无法接收新的传入请求。为了避免这种情况,我们可以将一些繁重的工作卸载到系统的不同部分,将其移到主请求响应周期之外,以免影响应用服务器接受和服务传入请求的可用性。这也缩短了响应时间,提供了更好的用户体验。
常见的卸载任务包括:
- 发送电子邮件
- 处理图像和视频
- 触发 webhook 或发出第三方请求
- 重建搜索索引
- 导入大数据集
- 清理陈旧数据
例如,当新用户注册 Web 应用程序时,该应用程序通常会创建一个新帐户并向他们发送欢迎电子邮件。帐户可用不需要发送电子邮件,而且用户也不会立即收到电子邮件。所以在请求响应周期内发送电子邮件是没有意义的。用户不必等待发送电子邮件,他们应该能够立即开始使用应用程序,并且应用程序服务器不应该承担发送电子邮件的任务。
在向调用者提供响应之前不需要完成的任何任务都是卸载的候选对象。将图像上传到 Web 应用程序时,需要处理图像文件,应用程序可能希望创建不同大小的缩略图。用户通常不需要成功的图像处理,因此通常可以卸载此任务。但是,服务器无法再响应说“图像已成功处理”或“发生图像处理错误”。现在,它只要回应“图像已成功上传“即可,如果一切按计划进行,图片将在稍后出现在网站上。 考虑到图像处理非常耗时的性质,考虑到响应时间的大幅改进和它提供的可用性优势,这种权衡通常是值得的。
后台工作
后台作业是一种卸载工作的方法。后台作业是稍后要在请求响应周期之外完成的任务。应用程序服务器将任务(例如图像处理)委托给工作进程,该进程甚至可能运行在完全不同的机器上。应用程序服务器需要将任务传达给工作者worker。工作人员可能很忙,无法立即接受任务,但应用程序服务器不应该等待工作者的响应。在应用程序服务器和工作者之间放置一个消息队列解决了这个难题,使它们的通信异步。消息的发送者和接收者可以在不同的时间点独立地与队列交互。应用服务器将消息放入队列并继续前进,立即可以接受更多传入请求。消息是工作者要完成的任务,这就是为什么这样的消息队列常被称为任务队列。工作者可以以自己的速度处理来自队列的消息。
后台作业后端本质上是一些任务队列以及一些用于管理工作者worker的代理代码。
Shopify 每秒对数以万计的作业进行排队以利用各种功能。下面是优点:
- 响应时间
- 尖峰能力
- 重试和冗余
- 并行化
- 优先排序
- 基于事件和基于时间的调度
- 代码简单
挑战
异步通信带来了一些挑战,这些挑战不会因为封装了它的一些复杂性而消失。后台工作没有什么不同。
- 作业参数的重大更改
- 无法实现恰好一次交付
例如,不向客户收费并不理想,但对某些企业而言,向他们收取两次费用可能更糟。在这种情况下,最多一次交付听起来是对的。但是,如果仔细跟踪每次收费并且作业在尝试收费之前检查这些状态,则第二次运行该作业不会导致第二次充费。这项工作是幂等的,允许我们安全地选择至少一次交付。
- 非事务性排队
当接受来自用户交互的外部输入时,通常会以最少的处理写入一些操作数据,并将执行额外步骤处理该数据的作业排入队列。除非我们在提交写入操作数据的事务后将其排队,否则此作业可能找不到它需要的数据。但是,系统可能会在提交事务之后和排队作业之前崩溃。作业永远不会运行,不会执行处理数据的附加步骤,使系统处于不一致的状态。
#发件箱模式 可用于创建事务性暂存作业队列。不是立即将作业排队,而是将作业参数写入操作数据存储中的临时表中。这可以是写入操作数据的数据库事务的一部分。调度程序可以定期检查暂存表,将作业排队,并在作业成功排队时更新暂存表。由于即使作业已排队,对临时表的此更新也可能失败,因此作业至少排队一次并且应该是幂等的。
根据作业量,事务性暂存作业队列可能会给数据库带来相当大的负载。虽然这种方法可以保证作业的排队,但不能保证它们会成功运行。
- 本地事务
- 顺序排队到无序交付
如果不考虑一致性保证,则可以使用更轻量级的替代方案。作业完成其任务后,它可以将另一个作业排队作为后续作业。这可确保作业按预定义的顺序运行。该方法快速且易于实现,因为它不需要临时表或调度程序,并且不会对数据库产生任何额外负载。但由此产生的系统可能会变得难以调试和维护,因为它会将其所有复杂性推向下一个潜在的长作业链,将其他作业排队,并且几乎无法观察到底哪里出了问题。
- 长时间运行的作业
另一个流行的 ruby 后台作业后端,Sidekiq, 在开始关闭工作程序时中止并重新排队作业。但是,下次作业运行时,它会从头开始,因此它可能会在完成之前再次中止。如果部署发生得比作业完成的速度快,作业就没有机会成功。Shopify的核心每天部署大约40次,这不是学术讨论而是我们需要解决的实际问题。
幸运的是,许多长期运行的作业在本质上是相似的:它们迭代庞大的数据集。Shopify 开发并开源了 Ruby on Rails 的 Active Job 框架的扩展,使这种工作可中断和可恢复。它在每次迭代后设置一个检查点并重新排队作业。下次处理作业时,检查点将继续工作,从而可以安全轻松地中断作业。通过可中断和可恢复的工作,工作人员可以随时关闭,这使他们对云更加友好,并允许频繁部署。可以限制或停止作业以进行灾难预防,例如,如果数据库上有大量负载。中断作业还允许在数据库分片之间安全地移动数据。
分布式后台作业
Ruby 中的Resque和Sidekiq等后台作业后端通常通过将序列化对象放入队列(具体作业类的实例)来将作业排队。这意味着排队作业的客户端和处理它的工作人员都需要能够使用此对象并具有此类的实现。这在客户端和工作人员运行相同代码库的单体架构中非常有效。但是,如果我们想将图像处理提取到专用的图像处理微服务中,甚至可能是用不同的语言编写的,我们需要一种不同的通信方法。
- 选择Redis:可以将 Sidekiq 与单独的服务一起使用,但工作人员仍然需要用 Ruby 编写,并且客户端必须为作业选择正确的 redis 队列。所以这种方式不太容易应用到大规模微服务架构中,而是避免了增加像RabbitMQ这样的消息代理的开销。
- 像RabbitMQ这样的面向消息的中间件在消息的生产者和消费者之间放置了一个纯粹基于数据的接口,例如 JSON 负载。消息代理可以充当分布式后台作业后端,客户端可以在其中将工作卸载到运行完全不同代码库的工作器上。利用任务队列的主题添加了强大的路由,而不是简单的点对点队列。与 HTTP 相比,这种路由不限于 1:1。除了委派特定任务之外,只要需要微服务之间的通信,就可以将消息传递用于不同的事件消息。在处理后删除消息后,就无法重放消息流,也没有系统范围状态的真实来源。
- 像Kafka这样的事件流有一种完全不同的方法:将事件写入仅附加事件日志中。所有消费者共享同一个日志,可以随时阅读。经纪人本身是无状态的;它不跟踪事件消耗。事件被分组到主题中,这提供了一些发布订阅功能,可用于将工作卸载到不同的服务。这些主题不基于队列,并且不会删除事件。由于事件日志可以重播,因此它可以用作,例如,事件溯源的真实来源。借助无状态代理和仅追加写入,吞吐量非常高,非常适合实时应用程序和数据流。
高可用性和快速响应时间对于提供出色的用户体验是必不可少的,无论应用程序的规模如何,后台作业都成为不可或缺的工具。
本文作者Kerstin 是shopify一名高级开发人员,她将 Shopify 的大量 Rails 代码库转变为更加模块化的整体,这种重构是基于她之前在分布式微服务架构方面的经验为基础。
##Shopify