设计案例:多优先级规则的分布式任务调度
项目背景
这个项目我并未全程参与,只是在方案设计遇到问题时参与了讨论,所以文中对于业务场景和需求的描述并不全面,只记录了讨论中获取到的信息
一个分布式的数据采集应用,其基本功能为采集各个电商平台上的商品信息,具体要求如下:
- 应用有多个用户,用户可以上传采集任务
- 应用能够并行执行多个任务,可以水平伸缩
- 采集任务的执行模块和管理模块分别独立地部署在独立的两个网络环境中
初始方案
在此之上,开发者设计出一个初步的方案:
由一个任务调度中心(manager)负责接收和管理所有由用户上传的任务,并根据相应的设定,分配各个任务给各个采集器(worker)执行,如图所示:
这样的模式中,有两个特点
- 应用被分离成manager和worker,manager负责接收和管理任务,worker负责任务的执行
- master中维护了一个任务队列,如果所有worker都在忙碌状态中时,任务可以在队列中进行排队,并以先进先出的方式等待执行
出现的问题和新的需求
这个方案有一些问题:
- manager作为任务的控制和调度中心,需要知道每个worker的位置,manager需要监控所有worker的地址、状态等,这样才能有效地将任务分配到一个已启动且空闲的节点
- 由于worker和manager处于分离的网络空间,manager获得各个worker的地址会非常麻烦,可能需要配置和维护一个worker列表
- 由于worker所处的网络环境的特殊性,甚至需要运维部门协助配置端口转发
而在开发过程中,也出现了新的需求:
- worker有不同的分组,专属任务只能被特定分组的worker执行
- 特定分组的worker优先执行专属任务,如果没有专属任务,则执行未被分类的普通任务
- 用户上传任务时,可以指定某任务优先执行
这样的需求下,任务的调度决策开始变得复杂,事情开始变得混乱起来
对于熟悉一些基本数据结构的开发者来说,如果没有全面详细地了解其需求的话,在这里可能容易想到优先队列,在任务入队时指定任务的优先级,优先队列则会按照相应的优先级将任务出队。 但是了解其具体需求后,则会发现优先队列无法解决上面的专属任务问题,甚至对于“上传优先执行任务”这个需求,其真实的数据结构甚至不是一个先进先出的队列,而是后进先出的栈。
来自线程调度的启发
这个问题和线程池调度有些类似,在许多编程语言的线程池调度中,也有着类似的"worker",即线程。这里以我最熟悉的.NET线程池调度为例(Java和Rust中也有类似的机制):
在.NET线程池中,每个工作线程都有着自己专属的任务队列(local queue),也有一个全局队列(global queue),任务在创建时根据需要被放进相应的队列中。工作线程则按照以下规则来获取和执行任务:
- 首先尝试从本地队列头部获取任务
- 如果本地队列为空,则尝试从全局队列头部获取任务
- 如果全局队列也为空,则尝试从其它工作线程的本地队列的尾部获取任务
- 如果其它工作线程的本地队列也为空,则进入休眠
这个机制被叫做work-stealing,能够帮助线程池更有效率、更均衡的进行多线程任务调度。它与前面提到的问题有些不同,比如在采集任务调度中,不需要也不可以“偷取”其它worker的任务
但是总体上,它给了我一些启发:
1. 这里存在着两种优先级,一种是队列中的优先级(如先进先出),另一个是不同队列之间的优先级(本地队列 > 全局队列 > 其它线程的本地队列)
结合对需求的梳理,可以得出在这个采集任务的调度中这里面大体上存在3个任务通道,分别是:
- 先进先出的普通任务通道
- 先进先出的专属任务通道
- 后进先出的优先任务通道
2. 对不同队列优先级决策属于各个工作线程,是工作线程在决定自己应该优先从哪个队列中获取任务
这里可以看出,将选择任务通道的决策点转移给worker,并结合“竞争消费”的机制后,任务的调度被简化了许多,每个worker只需要按照既定的规则,从各个任务通道中“抢”任务。
进一步还发现,由于选择任务通道的决策点被转移到了worker中,这使得manager和worker之间的交互方式,由之前的 manager选择任务然后分配给worker,得以变化为 worker选择通道然后从manager中获取任务 这种交互方式下,manager不再需要知道所有节点的物理位置及状况,只需要worker知道manager的位置就可以了
基于这些启发,给出了新的解决方案:
- 用户上传的任务,会根据其分类,分别进入普通通道、专属通道或优先通道
- worker以竞争的方式,从manager中获取任务,任务一旦被某worker获取后,不能再被其它worker获取
- worker首先访问优先通道,再访问自己对应分组的专属通道,最后访问全局通道
在和开发者对此方案进行讨论后,确认了其可以较好地满足需求
总结
在这个设计方案中,我其实并没有创新出任何一丁点的东西,所有的思路和答案都来自于已存在的案例和设计模式:
除了上面介绍的.NET线程池调度,对于任务调度这个领域,还有很多值得参考的案例,比如一些CI/CD任务的调度,如Azure DevOps、GitLab和GitHub的CI/CD agent,都能提供非常有价值的参考和启发,又比如在Hangfire项目中,其也有着类似的“多通道”任务调度的设计,任务队列的具体实现就可以参考它。
另外这个设计里还使用了一些其它设计模式,比如一开始的manager/worker模式,比如worker获取任务时使用的竞争消费者模式等。
而这些模式基本也都是从一些项目中学习来的,比如manager/worker是从Azure DevOps Agent中学到的,竞争消费者是从kafka的消费行为中学到的。
所以平常对于一些产品和工具,除了学习如何使用它们,学习它们的设计和实现原理也十分有价值,在遇到类似的问题时也许就可以用得上。
参考: