联童大数据调度平台之路
各位联童 IT MAN 大家好!列车长近日收到一篇来自大数据团队 - 张永清同学的原创投稿,这位多本畅销书的作者今天为大家分享了联童基于 incubator-dolphinscheduler 从 0 到 1 构建大数据调度平台的历程。
联童是一家智能化母婴童产业平台,从事母婴童行业以及互联网技术多年,拥有丰富的母婴门店运营和系统开发经验,在会员经营和商品经营方面,能够围绕会员需求,深入场景,更贴近合作伙伴和消费者,提供最优服务产品。公司致力于以技术来驱动母婴童产业的发展,也希望借助于大数据为客户提供更多智能数据分析和决策分析,大数据是公司重点发展的一部分,公司从成立初期起就搭建了大数据团队,有了大数据团队后,大数据调度平台的构建自然是最基础也是最重要的环节。
01
为什么选择 incubator-dolphinscheduler
1、incubator-dolphinscheduler 是一个由国内公司发起的开源项目,中国本土社区成员非常活跃,更加容易去进行社区沟通,同时联童也希望能加入到这个社区中,一起把这个由本土成员为主成立的社区做的更好。
2、incubator-dolphinscheduler 能够支撑非常多的应用场景
· 以DAG图的方式将Task按照任务的依赖关系关联起来,可实时可视化监控任务的运行状态
· 支持丰富的任务类型:Shell、MR、Spark、SQL(mysql、postgresql、hive、sparksql), Python, Sub_Process、Procedure,flink,datax,sqoop,http等
· 支持工作流定时调度、依赖调度、手动调度、手动暂停/停止/恢复,同时支持失败重试/告警、从指定节点恢复失败、Kill任务等操作
· 支持工作流优先级、任务优先级及任务的故障转移及任务超时告警/失败
· 支持工作流全局参数及节点自定义参数设置
· 支持资源文件的在线上传/下载,管理等,支持在线文件创建、编辑
· 支持任务日志在线查看及滚动、在线下载日志等
· 实现集群 HA,通过 Zookeeper实现Master集群和Worker集群去中心化
· 支持对 Master/Worker,cpu load,memory,cpu 在线查看
· 支持工作流运行历史树形/甘特图展示、支持任务状态统计、流程状态统计
· 支持补数
· 支持多租户
· 支持国际化
其中 DAG 图在 dolphinscheduler 一个工作流可以对应多个工作任务,每一个工作任务对应一个 DAG 中的节点。
3、incubator-dolphinscheduler在保证了高并发和高可用的设计时,架构思路也相对简单,技术架构中没有引入非常多的复杂技术组件,降低了学习和维护的成本。
备注:此架构图摘自社区官方网站
incubator-dolphinscheduler 在设计时,除了 zookeeper 外,没有引入太多复杂的技术组件。整个架构以 zookeeper 作为集群管理,采用去中心化思想进行设计。
02
incubator-dolphinscheduler 功能的不足
1、无法支持串行调度策略
incubator-dolphinscheduler 在一开始设计时,只支持并行调度,不支持串行调度,而在联童中,大部分场景都是需要串行运行的,也就是每一个工作流任务都只能有一个实例在运行,同一个工作流任务中必须要等前一个实例执行结束,下一个实例才能开始执行,这种场景大多出现在准实时任务中。
2、任务依赖不够强大,只能支持被动等待依赖执行成功,无法主动触发下游工作流实例运行
如下图所示,只能支持在创建任务时,被动去等待依赖执行成功,无法在当前任务执行成功后,主动去触发别的工作流任务执行。
3、部分模块中用户体验不足,并且在数据量大时,部分模块数据查询性能较慢
4、缺少比较完备的监控体系
在 incubator-dolphinscheduler 只提供了一些简单的监控,当有多大几千个任务在运行时,很难做到完备监控,更是缺少对每一个任务运行的性能分析。
03
我们对于 incubator-dolphinscheduler 的功能升级开发
1、增加串行调度的支持
如下图所示,我们在原有并行执行的基础上,增加了串行执行方式。
在串行执行时,我们还增加了串行执行的队列功能,每一任务都可以指定队列的长度大小。
2、增加 下游工作流实例运行
如下图所示,我们在原有并行执行的基础上,增加主动触发下游一个或者多个工作流实例运行。
运行后效果如下:
3、一些较大的 Bug 修复
联童在使用 incubator-dolphinscheduler 时,也踩过不少坑,这里我们举其中一个例子,比如在内部使用时,同事反馈最多的问题就是调度任务的日志刷新不及时,有时候很久才能刷新出日志。后来经过源码分析,发现是源码中存在了一些不太健壮的处理导致了这个问题。
incubator-dolphinscheduler 中 AbstractCommandExecutor.java 部分源码
/**
* abstract command executor
*/
public abstract class AbstractCommandExecutor {
..........
/**
* build process
*
* @param commandFile command file
* @throws IOException IO Exception
*/
private void buildProcess(String commandFile) throws IOException {
// setting up user to run commands
List<String> command = new LinkedList<>();
//init process builder
ProcessBuilder processBuilder = new ProcessBuilder();
// setting up a working directory
processBuilder.directory(new File(taskExecutionContext.getExecutePath()));
// merge error information to standard output stream
processBuilder.redirectErrorStream(true);
// setting up user to run commands
command.add("sudo");
command.add("-u");
command.add(taskExecutionContext.getTenantCode());
command.add(commandInterpreter());
command.addAll(commandOptions());
command.add(commandFile);
// setting commands
processBuilder.command(command);
process = processBuilder.start();
// print command
printCommand(command);
}
..........
/**
* get the standard output of the process
*
* @param process process
*/
private void parseProcessOutput(Process process) {
String threadLoggerInfoName = String.format(LoggerUtils.TASK_LOGGER_THREAD_NAME + "-%s", taskExecutionContext.getTaskAppId());
ExecutorService parseProcessOutputExecutorService = ThreadUtils.newDaemonSingleThreadExecutor(threadLoggerInfoName);
parseProcessOutputExecutorService.submit(new Runnable() {
@Override
public void run() {
BufferedReader inReader = null;
try {
inReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
long lastFlushTime = System.currentTimeMillis();
while ((line = inReader.readLine()) != null) {
if (line.startsWith("${setValue(")) {
varPool.append(line.substring("${setValue(".length(), line.length() - 2));
varPool.append("$VarPool$");
} else {
logBuffer.add(line);
lastFlushTime = flush(lastFlushTime);
}
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
clear();
close(inReader);
}
}
});
parseProcessOutputExecutorService.shutdown();
}
................
/**
* when log buffer siz or flush time reach condition , then flush
*
* @param lastFlushTime last flush time
* @return last flush time
*/
private long flush(long lastFlushTime) {
long now = System.currentTimeMillis();
/**
* when log buffer siz or flush time reach condition , then flush
*/
if (logBuffer.size() >= Constants.DEFAULT_LOG_ROWS_NUM || now - lastFlushTime > Constants.DEFAULT_LOG_FLUSH_INTERVAL) {
lastFlushTime = now;
/** log handle */
logHandler.accept(logBuffer);
logBuffer.clear();
}
return lastFlushTime;
}
/**
* close buffer reader
*
* @param inReader in reader
*/
private void close(BufferedReader inReader) {
if (inReader != null) {
try {
inReader.close();
} catch (IOException e) {
logger.error(e.getMessage(), e);
}
}
}
protected List<String> commandOptions() {
return Collections.emptyList();
}
protected abstract String buildCommandFilePath();
protected abstract String commandInterpreter();
protected abstract void createCommandFileIfNotExists(String execCommand, String commandFile) throws IOException;
}
在这段源码中,parseProcessOutput(Process process) 方法是负责任务日志的获取以及 Flush。但是由于采用了 BufferedReader 中的 readLine() 方法来读取任务进程的process.getInputStream() 日志,由于 readLine() 是一个阻塞方法,
flush(long lastFlushTime) 方法在处理时有一个判断条件 if (logBuffer.size() >= Constants.DEFAULT_LOG_ROWS_NUM || now - lastFlushTime > Constants.DEFAULT_LOG_FLUSH_INTERVAL),只有当日志条数达到 64 条或者间隔 1s 时才会 flush。按理说,代码其实是要实现至少每隔 1s 会 flash 一次日志,但是由于 readLine() 是一个阻塞方法,所以并不会一直在执行,而是 readLine() 必须是读取到新数据后,才会执行flush 方法。那么在出现 1s 内产生的任务日志不满足 64 条,而任务又很久没有新日志出现时,就会触发这个 bug。例如执行如下一个 shell 脚本任务,由于每个执行步骤产生的日志少,而且每个步骤执行的时间又很久,时间间隔很大,就会出现很久都不会刷新上一次产生的日志。
1 2 3 4 5 6 7 8 9 10 | #!/bin/bash echo "hello world" exec 10m sleep 100000s echo "hello world2" exec 10m sleep 100000s echo "hello world3" exec 10m sleep 100000s |
之后我们对这段源码进行了重写,采用了两个线程进行处理,一个线程负责readline(),一个线程负责 flush 做到在 readline() 方法的线程阻塞时,不影响 flush 线程的处理。我们也把修改后的代码贡献给了社区,已被 merge 到 dev 分支。
public abstract class AbstractCommandExecutor {
/**
* rules for extracting application ID
*/
protected static final Pattern APPLICATION_REGEX = Pattern.compile(Constants.APPLICATION_REGEX);
/**
* process
*/
private Process process;
/**
* log handler
*/
protected Consumer<List<String>> logHandler;
/**
* logger
*/
protected Logger logger;
/**
* log list
*/
protected final List<String> logBuffer;
protected boolean logOutputIsScuccess = false;
/**
* taskExecutionContext
*/
protected TaskExecutionContext taskExecutionContext;
/**
* taskExecutionContextCacheManager
*/
private TaskExecutionContextCacheManager taskExecutionContextCacheManager;
.........
/**
* get the standard output of the process
*
* @param process process
*/
private void parseProcessOutput(Process process) {
String threadLoggerInfoName = String.format(LoggerUtils.TASK_LOGGER_THREAD_NAME + "-%s", taskExecutionContext.getTaskAppId());
ExecutorService getOutputLogService = ThreadUtils.newDaemonSingleThreadExecutor(threadLoggerInfoName + "-" + "getOutputLogService");
getOutputLogService.submit(() -> {
BufferedReader inReader = null;
try {
inReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;while ((line = inReader.readLine()) != null) {
logBuffer.add(line);
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
logOutputIsScuccess = true;
close(inReader);
}
});
getOutputLogService.shutdown();
ExecutorService parseProcessOutputExecutorService = ThreadUtils.newDaemonSingleThreadExecutor(threadLoggerInfoName);
parseProcessOutputExecutorService.submit(() -> {
try {
long lastFlushTime = System.currentTimeMillis();
while (logBuffer.size() > 0 || !logOutputIsScuccess) {
if (logBuffer.size() > 0) {
lastFlushTime = flush(lastFlushTime);
} else {
Thread.sleep(Constants.DEFAULT_LOG_FLUSH_INTERVAL);
}
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
clear();
}
});
parseProcessOutputExecutorService.shutdown();
}
.......
/**
* when log buffer siz or flush time reach condition , then flush
*
* @param lastFlushTime last flush time
* @return last flush time
*/
private long flush(long lastFlushTime) throws InterruptedException {
long now = System.currentTimeMillis();
/**
* when log buffer siz or flush time reach condition , then flush
*/
if (logBuffer.size() >= Constants.DEFAULT_LOG_ROWS_NUM || now - lastFlushTime > Constants.DEFAULT_LOG_FLUSH_INTERVAL) {
lastFlushTime = now;
/** log handle */
logHandler.accept(logBuffer);
logBuffer.clear();
}
return lastFlushTime;
}
.......
}
4、将调度系统的监控接入到 prometheus 和 grafana 中
incubator-dolphinscheduler 只提供了一些如下的简单实时监控,尤其缺少对任务的监控。
联童在此基础上,引入了 prometheus 和 grafana。
使用 prometheus 和 grafana 不但可以监控到调度系统任务的总体运行,也可以监控到单个任务的运行耗时曲线等。
5、对 incubator-dolphinscheduler 的性能优化
未完待续
首先,列车长非常感谢大数据团队的分享,也要为共创/共享的精神鼓掌,同时,我们希望各个团队能够在工作中沉淀经验、总结复盘、最终形成价值输出。
欢迎各位同学多多投稿,分享你的见解
Hello World→Change Our World
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· .NET Core 托管堆内存泄露/CPU异常的常见思路
· PostgreSQL 和 SQL Server 在统计信息维护中的关键差异
· C++代码改造为UTF-8编码问题的总结
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 【.NET】调用本地 Deepseek 模型
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)
· 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)