设计案例:多优先级规则的分布式任务调度

项目背景

这个项目我并未全程参与,只是在方案设计遇到问题时参与了讨论,所以文中对于业务场景和需求的描述并不全面,只记录了讨论中获取到的信息

一个分布式的数据采集应用,其基本功能为采集各个电商平台上的商品信息,具体要求如下:

初始方案

在此之上,开发者设计出一个初步的方案:

由一个任务调度中心(manager)负责接收和管理所有由用户上传的任务,并根据相应的设定,分配各个任务给各个采集器(worker)执行,如图所示:

这样的模式中,有两个特点

出现的问题和新的需求

这个方案有一些问题:

而在开发过程中,也出现了新的需求:

这样的需求下,任务的调度决策开始变得复杂,事情开始变得混乱起来

对于熟悉一些基本数据结构的开发者来说,如果没有全面详细地了解其需求的话,在这里可能容易想到优先队列,在任务入队时指定任务的优先级,优先队列则会按照相应的优先级将任务出队。 但是了解其具体需求后,则会发现优先队列无法解决上面的专属任务问题,甚至对于“上传优先执行任务”这个需求,其真实的数据结构甚至不是一个先进先出的队列,而是后进先出的栈。

来自线程调度的启发

这个问题和线程池调度有些类似,在许多编程语言的线程池调度中,也有着类似的"worker",即线程。这里以我最熟悉的.NET线程池调度为例(Java和Rust中也有类似的机制):

在.NET线程池中,每个工作线程都有着自己专属的任务队列(local queue),也有一个全局队列(global queue),任务在创建时根据需要被放进相应的队列中。工作线程则按照以下规则来获取和执行任务:

  1. 首先尝试从本地队列头部获取任务
  2. 如果本地队列为空,则尝试从全局队列头部获取任务
  3. 如果全局队列也为空,则尝试从其它工作线程的本地队列的尾部获取任务
  4. 如果其它工作线程的本地队列也为空,则进入休眠

这个机制被叫做work-stealing,能够帮助线程池更有效率、更均衡的进行多线程任务调度。它与前面提到的问题有些不同,比如在采集任务调度中,不需要也不可以“偷取”其它worker的任务

但是总体上,它给了我一些启发:

1. 这里存在着两种优先级,一种是队列中的优先级(如先进先出),另一个是不同队列之间的优先级(本地队列 > 全局队列 > 其它线程的本地队列)

结合对需求的梳理,可以得出在这个采集任务的调度中这里面大体上存在3个任务通道,分别是:

2. 对不同队列优先级决策属于各个工作线程,是工作线程在决定自己应该优先从哪个队列中获取任务

这里可以看出,将选择任务通道的决策点转移给worker,并结合“竞争消费”的机制后,任务的调度被简化了许多,每个worker只需要按照既定的规则,从各个任务通道中“抢”任务。

进一步还发现,由于选择任务通道的决策点被转移到了worker中,这使得manager和worker之间的交互方式,由之前的 manager选择任务然后分配给worker,得以变化为 worker选择通道然后从manager中获取任务 这种交互方式下,manager不再需要知道所有节点的物理位置及状况,只需要worker知道manager的位置就可以了

基于这些启发,给出了新的解决方案:

在和开发者对此方案进行讨论后,确认了其可以较好地满足需求

总结

在这个设计方案中,我其实并没有创新出任何一丁点的东西,所有的思路和答案都来自于已存在的案例和设计模式:
除了上面介绍的.NET线程池调度,对于任务调度这个领域,还有很多值得参考的案例,比如一些CI/CD任务的调度,如Azure DevOps、GitLab和GitHub的CI/CD agent,都能提供非常有价值的参考和启发,又比如在Hangfire项目中,其也有着类似的“多通道”任务调度的设计,任务队列的具体实现就可以参考它。 另外这个设计里还使用了一些其它设计模式,比如一开始的manager/worker模式,比如worker获取任务时使用的竞争消费者模式等。

而这些模式基本也都是从一些项目中学习来的,比如manager/worker是从Azure DevOps Agent中学到的,竞争消费者是从kafka的消费行为中学到的。

所以平常对于一些产品和工具,除了学习如何使用它们,学习它们的设计和实现原理也十分有价值,在遇到类似的问题时也许就可以用得上。

参考:

  1. Work stealing (Wikipedia)
  2. .NET TaskScheduler
  3. Manager-Worker Communication Patterns
  4. Hangfire