[大厂实践] 少即是多:Zendesk 长时间作业执行优化

本文介绍了 Zendesk 构建数据迁移器进行长时间大规模账户数据迁移的实践,以及选择这种作业执行方式的权衡和取舍。原文:Less is More: Improving job execution by ditching the job executor

本文概述了我们所做的架构调整,这些调整极大简化了长时间运行任务的执行模式。

通过利用客户端行为,系统不仅提升了整体功能,还消除了分布式任务执行中的诸多复杂问题。

背景:Zendesk 的账户迁移

在 Zendesk 的后台系统,每个客户账户的相关数据都存放在全球各地的某个数据中心。我们不想让账户永远停留在其创建时所在的原始数据中心,因此通过完善的账户迁移工具,能够以几乎零停机时间的方式将账户迁移到新的数据中心。

该工具非常有用,既对客户有益,也对我们自身有益。最初是为将单一 Rackspace 部署扩展到多个数据中心而设计,多年后,它再次在将数据中心迁移至 AWS 时发挥了关键作用。目前仍被用于在各个数据中心之间平衡容量和其他指标,而且最近在将收购公司的服务迁移到我们的共享基础设施方面也发挥了重要作用。

账户迁移工具包含中央协调器以及若干数据迁移器。协调器负责管理整个账户迁移生命周期,随着迁移进程的推进,会协调各个系统。而实际进行数据传输工作的通常是数据迁移器,我们支持的每个数据存储类型都配备有一个数据迁移器。

欢迎加入!请将物品放置在 Zendesk 基础设施内

所以,当我们收购了一家使用不同数据库系统的公司时,就会面临难题。

最简单且直接的解决办法是"我们能不能不这么做?"如果有已被认可且同样适用的数据存储方案,我们将转而使用该方案。

如果这种方式不可行,且需要迁移数据的话,那么通常就需要新的数据迁移工具。这是一项繁重工作,所以我们很不情愿去进行这项工作,但让数据永远滞留在核心 Zendesk 基础设施之外会使得情况变得复杂,并且还会使所收购的产品失去许多组织层面的益处。

因此,在将收购项目整合到共享基础设施中时,构建数据传输系统的复杂性会产生重大影响。

数据迁移器是作业执行服务器

数据传输的具体细节既重要又有趣,但今天我们要关注的是任务管理,因为数据传输工具就是通过执行任务来工作的。那么,什么样的事情可以被称为"任务"呢?在我看来,任务的核心特征是:

  • 长期运行(如果运行时间较短,那可能只是一个请求而已),并且
  • 会进行完成情况监控(如果无需等待其完成,那么只需触发事件或通知,然后离开即可)

除了这些常见特性之外,数据迁移任务通常都是持续进行的,会将数据从源系统复制到目标系统,随着新变化的出现而保持同步。因此,我们会让它们一直运行,直到整个账户迁移完成。

典型作业系统 API

如果有作业,可能需要运行作业系统。对于作业,编排器是请求作业运行的客户端,而数据移动器是实现作业的服务器。

大多数用于长时间运行作业的系统(包括我们的初始实现)都有类似于这样的 API:

  • StartJob(config) -> jobId
  • GetStatus(jobId) -> status
  • StopJob(jobId)

API 构造简单,但要成为适合执行数据迁移任务的工具,必须满足一系列要求。

持久性

所有任务绝不遗失!如果客户创建了任务,那么服务器在发生崩溃或重启时也必须不会忘记这个任务。

容错性

作业可以运行很长时间,而 Kubernetes 容器并非永远存在。如果某个容器崩溃或被替换,作业就需要通过让另一个容器接替来继续进行。

恢复

中断不应导致工作重新开始,工作应从(接近)上次停止的地方继续进行。

唯一性

我们不希望两个实例在同一时间执行相同的任务。

悬置任务

如果客户出于任何原因忘记了某个任务,我们不希望一直执行这个任务,因为这既浪费资源,还可能在客户意料之外的情况下引发问题,因此需要检测到这些悬置任务并停止执行。

任务执行架构

基于上述 API 和需求,显而易见的架构包含数据库和一个锁 API。该锁 API 可能会复用相同底层数据存储,也可能是独立系统,如 Consuletcd

当作业被创建时,会将其保存在数据库中(以确保持久性 ),并且其当前状态会定期进行保存(以便于恢复 )。当进程正在执行某个作业时,首先会获取该作业的锁(以确保唯一性 )。如果数据存储中存在未完成的作业但没有活跃锁,那么这些作业就可以由工作进程来接管(以实现容错)。

我们用 3 个服务器实例、1 个作业数据库以及 1 个锁服务将这一切整合起来。以下是执行一个示例作业的步骤序列,其中包括在另一个服务器实例上的恢复操作:

工作完成了吗?

嗯,我们正在接近目标,不过仍有一些问题需要解决。

悬置任务 :如果不介意在客户端离开后让任务继续运行一段时间的话,这个问题其实并不难解决。我们决定只有在客户端要求查看任务状态时才执行非活跃任务。如果客户端不再调用 GetStatus 函数,当前容器仍会继续运行该任务,直到容器终止,但此后该任务将不再执行。

重复任务:如果客户端创建了一个任务,但由于出现错误导致无法处理响应,那么就会产生一个立即失效的任务。我们不会永远在它上面浪费资源,但可能会持续执行该任务数小时之久。在处理数据方面,如果有两个任务在处理相同的数据,还可能会导致写入冲突和传输失败。


要点:幂等性密钥

有一种常见且非常有效的防止重复任务的方法,称为幂等性密钥(idempotency key) 。这种技术在诸如 StripeSquare 这样的支付 API 中非常常见,因为人们倾向于每次购买只支付一次。

将此概念应用于工作流程中时,其含义是:客户端为要创建的每个工作生成一个唯一的密钥,并将其作为 StartJob 的一部分发送。如果服务器收到两个具有相同"幂等性密钥"的请求,就知道客户端所指的正是同一个工作。因此,客户端可以多次调用 StartJob 操作(最多 10 次),而服务器则知道只需启动一次即可。

这种责任分配方式十分巧妙,因为服务器和客户端各自只需实现自己的部分即可,而两者结合在一起就能形成有效防止重复任务的可靠解决方案。

但客户端能做的远不止这些 ------ 事实证明,还可以通过利用客户端能够轻松提供的特性来解决许多问题。

极其简洁的界面

之前,我说如果任务时间很短,可能就只是一个请求而已。那么,如果任务本身就是请求呢?这种情况存在两个明显问题:

  1. 客户端希望在任务运行期间能够了解其状态。
  2. 请求是脆弱的 ------ 你不能指望单个请求能够持续足够长的时间来完成一项任务。

第一个问题(了解任务状态)可以通过流式响应来解决。我们用 GRPC,但流式 HTTP 也能很好发挥作用。服务器可以随时发出新的状态信息,客户端会立即接收到。这比让客户端定期查询任务状态要简单且响应更快。

至于连接的脆弱性问题,我们的任务原本就需要具备可恢复性。因此,如果连接中断,客户端可以发出一个新的长期有效的 RunJob 请求(使用相同的重复性密钥和配置),而服务器则可以根据其最新状态继续执行该任务。

按照这种设置,以下是任务执行的流程示例(包括在不同服务器实例上重新启动任务的情况):

没错,我们移除了服务器的锁 API 和任务存储功能

细心的读者可能会怀疑我是在故意隐瞒一些情况,将这些责任转嫁给了客户端(而客户端基础设施并未在图中展示)。继续阅读,就会明白这其实是一种有意为之的好处,而非不正当的账目操作。

太完美了 🌅

我通常不会对序列图产生过多的感情反应,但令人惊讶的是,这个简单的 API 重构竟然如此完美满足了需求。我来列举一下其中的优点:

不存在"悬置任务"这种说法

在这种模式中,工作仅在客户主动等待时才会进行,通过保持连接处于开启状态来体现这一点,一旦连接断开,工作就会停止。

这与结构化并发有着很好的相似之处,我对这种结构化并发方式非常赞赏。结构化并发通过防止异步子任务的生命周期超过其父任务来避免出现失控线程。通过保持请求处于打开状态,迫使客户端主动等待,从而实现了与防止失控任务类似的安全保护机制。

任务分配

客户端每次仅执行一个请求。我们原本依靠分布式锁来确保只有一个进程执行某个特定任务。但如果工作仅在客户端有活跃请求时进行,并且客户端只有一个活跃请求,就不需要明确的分配,而只需在接收请求的实例上执行工作即可。

错误与重试

账户迁移任务是耗时的,并且可能既昂贵又重要。之前的系统默认情况下较为脆弱:任何错误都会导致任务失败,直到数据移动器实现可靠的错误重试机制(包括关于如何延长等待时间以及何时放弃的逻辑)。

通过这个接口,任何错误都会默认导致请求失败。但客户端已经能够处理失败的请求了,可以让客户端在何时重试以及何时放弃方面尽可能灵活自主,同时保持服务器的实现简单。

实际上,如果一项操作被判定为重要的话,客户端会转而向人工寻求帮助。与在出现太多错误后就放弃不同,客户端会停止重试,并等待人工操作员来终止或恢复该操作。同样,这并不需要服务器提供任何特定支持。

负载均衡

这算是一个比较遥远的目标,因为很难实现。理想情况下,如果有 10 个实例和 100 个任务,我们希望每个实例能同时处理 10 个任务。有一些简单技巧可以实现一定程度的平衡,比如在接取未被占用的任务之前先短暂休息一下。如果休息时间与已经运行的任务数量成正比,那么空闲实例会比忙碌实例更频繁的接取任务。

但当连接数量成为工作数量的可靠指标时,平衡问题就变得简单多了,因为这就是负载均衡器所做的事情 ------ Istio 的默认设置是将流量发送到请求活跃度最低的实例。这在工作完成时不会主动重新平衡工作,但除此之外,我们还能实现最优平衡,而且是免费的。

存储状态

这或许是我们过度依赖客户端的地方 ------ 我们把状态交给客户端,让其自行进行存储。

作为流式传输响应的一部分,我们有一个不可读的字节字段 persist_state。收到该字段后,客户端会将其存储在某个位置。在每次发起请求时,客户端会在 RunJob 请求中将最近存储的状态作为 persist_state 字段的值。

这意味着服务器可以完全实现无状态化,这对于需要处理持久化数据的服务来说是一种奇特的特性。但这些数据属于正在迁移的服务,不适合用作我们自己的作业存储库。

对我们而言,这样做是值得的,因为我们拥有的服务器数量远远多于客户端数量(在未来可预见的时期内,只有一个客户端),而且一个完全无状态的服务器所带来的好处足以弥补让客户端保存状态所带来的额外工作量。

你完全可以采纳本文其余观点,而无需让客户端掌控状态。而且,除非完全信任客户端,否则切勿这样做。我们选择充分信任客户端,以至于会故意破坏其自身数据(例如,向我们发送虚假状态,可能会导致跳过传输过程中的某些部分)。但我们不会在状态中暴露任何与授权相关的敏感数据,以免让客户端能够通过其不拥有的数据存储系统施加影响。

令人惊讶的是,对无状态数据传输器的需求正是整个设计的最初动机,因为无状态系统是降低复杂性时自然而然的想法。回过头来看,删除状态存储可能是最不重要的好处 ------ 如果不必担心所有的分布式协调难题,写入数据库其实也不是那么困难。


为什么(以及何时)这种方法能奏效呢?

当然,这一切只有在客户端具备诸如"不会忘记处理任务"以及"每次处理一个任务时只提出一个请求"这样的良好习惯时才会有效。这......听起来像是作业执行器的工作吗?

嗯,账户管理协调器实际上就是一种被赋予了更高职责的作业执行器,其大部分工作内容包括运行各种内部作业并记录其状态。这里所描述的方法并没有消除对作业执行器的需要,但意味着可以将单一作业执行器应用于系统最外层。我们并非直接与作业系统进行集成,而是通过构建接口来利用它所提供的有用特性。

这显然有利于简化现有数据传输系统,使它们无需再负责管理任务(以及由此带来的任何复杂性或故障)。但更重要的是那些尚未编写的数据传输程序。现在,当我们需要为一家被收购公司的数据存储系统编写数据传输程序时,大部分工作仅仅是进行数据传输,而无需构建可靠的任务执行系统。

欢呼"耦合"吧? 🔗

人们往往会倾向于构建模块化、解耦、独立且具备所有那些让人感觉良好的特质(但其实没人应该讨厌这些特质)的系统。

事实上,康威定律表明,如果将"数据传输器"作为独立系统和团队来设立,人们自然会倾向于将其构建为一个独立系统,就像我们所做的那样。但通过采用轻量级耦合方式,可以实现巨大的效率提升。而这种耦合方式确实是非常轻量级的,只是在客户端和服务器之间确定了一套特定的协议,从而构建出了整体上最稳固、复杂度最低的系统。


结语:"为什么不直接使用[我最喜欢的作业系统]呢?"

由于不了解具体细节,或许本可以这么做!鉴于我们的需求涉及多种不同编程语言,没有一种系统能完全满足需求,也没有现成系统具备我们所需要的所有功能。我确信可以通过各种方法来实现,只需添加额外代码来整合或增强缺失的功能即可。但有什么比编写一堆代码更好的呢?那就是不做这些!

感谢阅读!

我希望你会觉得这种针对作业系统 API 的替代方法颇具吸引力。它未必适用于每一个类似工作的系统,关键在于,如果从给定系统的整体环境以及其使用方式的角度去思考,有时能够找到一种复杂程度低得多的解决方案,这确实很美妙。


你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

相关推荐
500佰2 小时前
京东后端架构技术,Pipline 设计 解决复杂查询逻辑
后端·架构
失散133 小时前
分布式专题——49 SpringBoot整合ElasticSearch8.x实战
java·spring boot·分布式·elasticsearch·架构
leafff1233 小时前
AI数据库研究:RAG 架构运行算力需求?
数据库·人工智能·语言模型·自然语言处理·架构
绝无仅有4 小时前
某团互联网大厂的网络协议与数据传输
后端·面试·架构
绝无仅有4 小时前
某多多面试相关操作系统、分布式事务、消息队列及 Linux 内存回收策略
后端·面试·架构
GISer_Jing4 小时前
Flutter架构解析:从引擎层到应用层
前端·flutter·架构
杨筱毅13 小时前
【底层机制】ART虚拟机深度解析:Android运行时的架构革命
android·架构·底层机制
言之。13 小时前
【数据库】TiDB 技术选型与架构分析报告
数据库·架构·tidb
GIOTTO情13 小时前
舆情处置技术深度解析:Infoseek 字节探索的 AI 闭环架构与实现逻辑
人工智能·架构