前些天和同事探讨一个系统该如何设计,有些心得记录一下。同事想要设计一个任务执行系统,其中有一个核心驱动部件,以有向无环图的形式来描述任务之间的依赖关系,使得没有前后依赖的任务可以并发执行。任务的实际执行发生在多个外部系统,任务执行系统主要负责任务调度以及状态更新、进度展示等。

同事找我探讨的原因是他发现「如何简单地发现一个任务所依赖的任务已经执行成功」越发成为一个困难的事情,比如任务 C 依赖任务 A、B 先执行成功,那么需要为 C 分配一个线程,定期去询问 A 和 B 的执行进展,一旦系统中同时存在的任务数量增多,系统就显得越发复杂和脆弱。

完整了解同事设计系统所要解决的问题之后,我从头开始分析系统的组件设计以及方案选项。

首先是任务拓扑(有向无环图)的定义。我认为这个拓扑描述应该放在代码中,以 DSL 或者类似方案表示,这样同一个 DAG 表示既可以直接生成内存中的数据结构辅助执行,也可以写入到数据库中供 Web 页面做可视化。同事表示这给直接从页面上修改拓扑带来了困难,不愿意接受。

那也没关系,拓扑就直接存储在关系数据库中好了。如何发现一个任务所依赖的任务执行全部成功了呢?我们可以设想任务这个对象上,有 N 个槽位(slot),每当一个依赖执行成功,就会填上这个槽位。如果一个任务已经没有空闲的槽位,这个任务将被执行。

同事对于槽位这种抽象的设计感到有些困惑不解,我只好直接从表结构设计去描述槽的实现。

对于每个新生成的任务拓扑,我们需要实例化对应的拓扑,并把实例化的拓扑记录到两张表中,一张是任务表 task(id, name, status),表示任务的元信息、执行状态等,另一张表是 task_dep(id, task_id, dep_id),表示任务间的依赖关系,也就是「槽位」,比如依赖 A、B 先执行成功的任务 C,在 task_dep 中有两行记录,也就是两个槽位。

如果一个任务已经没有空闲的槽位,这个任务将被执行

select task.id, name from task left join task_dep dep 
where task.id = dep.task_id 
and task.status = 'runnable' 
and dep.status is null;
-- get task.id [1, 2, 3]
update task set status = 'running' where id in (1, 2, 3);

发现执行中的任务,向外部系统询问其执行状态

select * from task where status = 'running';

每当一个依赖执行成功,就会填上这个槽位

-- 1, 2 执行成功,3 执行失败
update task set status = 'success' where id in (1, 2);
delete from task_dep where dep_id in (1, 2);

update task set status = 'fail' where id in (3);

整个系统只需要用一个定时器,周期性地执行这几个 SQL,用线程池并发处理对外部系统的状态查询,就可以实现同事想要的设计目标了,不需要专门为每个任务安排线程,不需要去轮询依赖任务的执行结果,因为任务执行成功后,自然会通知下游。

多么简洁的方案!同事表示满意之余,透露了我们分别设计方案时,一个重要的不同之处:如何表达任务之间的依赖关系。

同事的设计中,表达 C 依赖 A、B,用的是类似于在一个大字段中放 JSON 的做法,把 A,B 这样的信息直接放在一个 dep 字段中。而我的设计则是将 dep 独立成表,用 id pair 来表达依赖,这是使得同事设计出来的方案必须由 C 去轮询 A、B,而我的方案则是 A、B 通知 C 的关键区别。

我开玩笑地说同事设计的数据库是 NoSQL,我设计的是 SQL。

其实挖掘更本质的系统设计范式,有两点值得注意。一个是如何在关系数据库中存储图(图论的图,不是图像的图),另一个是如何恰当地使用询问和通知这两种机制。

前者通常使用两张表,一张存储点(task),一张存储边(task_dep)。

后者,在我个人来看,如果存在靠谱的通知渠道,尽可能使用通知。当然这里的通知更多的是一种形态上的东西,不一定非得用消息队列之类的东西才算通知。

License: CC BY-SA 4.0

Next Post Previous Post