聯童科技是一家智慧化母嬰童產業平臺,從事母嬰童行業以及網際網路技術多年,擁有豐富的母嬰門店運營和系統開發經驗,在會員經營和商品經營方面,能夠圍繞會員需求,深入場景,更貼近合作伙伴和消費者,提供最優服務產品,公司致力於以技術來驅動母嬰童產業的發展,公司也希望藉助於大資料為客戶提供更多智慧資料分析和決策分析,大資料是公司重點發展的一部分,公司從成立初期起就搭建了大資料團隊,有了大資料團隊後,大資料排程平臺的構建自然是最基礎也是最重要的環節。
一、為什麼選擇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圖 借鑑自spark ,在dolphinscheduler 一個工作流可以對應多個工作任務,每一個工作任務對應一個DAG中的節點。
3、incubator-dolphinscheduler在保證了高併發和高可用的設計時,架構思路也相對簡單,技術架構中沒有引入非常多的複雜技術元件,降低了學習和維護的成本。
備註:此架構圖摘自社群官方網站
incubator-dolphinscheduler在設計時,除了zookeeper外,沒有引入太多複雜的技術元件。整個架構以zookeeper 作為叢集管理,採用去中心化思想進行設計。
二、incubator-dolphinscheduler功能的不足
1、無法支援序列排程策略
incubator-dolphinscheduler 在一開始設計時,只支援並行排程,不支援序列排程,而在聯童中,大部分場景都是需要序列執行的,也就是每一個工作流任務都只能有一個例項在執行,同一個工作流任務中必須要等前一個例項執行結束,下一個例項才能開始執行,這種場景大多出現在準實時任務中。
2、任務依賴不夠強大,只能支援被動等待依賴執行成功,無法主動觸發下游工作流例項執行
如下圖所示,只能支援在建立任務時,被動去等待依賴執行成功,無法在當前任務執行成功後,主動去觸發別的工作流任務執行。
3、部分模組中使用者體驗不足,並且在資料量大時,部分模組資料查詢效能較慢
4、缺少比較完備的監控體系
在 incubator-dolphinscheduler 只提供了一些簡單的監控,當有多大幾千個任務在執行時,很難做到完備監控,更是缺少對每一個任務執行的效能分析。
三、我們對於incubator-dolphinscheduler的功能升級開發
1、增加序列排程的支援
如下圖所示,我們在原有並行執行的基礎上,增加了序列執行方式。
在序列執行時,我們還增加了序列執行的佇列功能,每一任務都可以指定佇列的長度大小。
2、增加主動觸發下游工作流例項執行
如下圖所示,我們在原有並行執行的基礎上,增加主動觸發下游一個或者多個工作流例項執行。
執行後效果如下:
3、一些較大的Bug修復
聯童在使用 incubator-dolphinscheduler時,同樣也踩過不少的坑,這裡我們舉其中一個例子,比如在內部使用時,同事反饋最多的問題就是排程任務的日誌重新整理不及時,有時候很久才能重新整理出日誌。後來經過原始碼分析,發現是原始碼中存在了一些不太健壯的處理導致了這個問題。
incubator-dolphinscheduler 中AbstractCommandExecutor.java 部分原始碼
/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.dolphinscheduler.server.worker.task; import static org.apache.dolphinscheduler.common.Constants.EXIT_CODE_FAILURE; import static org.apache.dolphinscheduler.common.Constants.EXIT_CODE_KILL; import static org.apache.dolphinscheduler.common.Constants.EXIT_CODE_SUCCESS; import org.apache.dolphinscheduler.common.Constants; import org.apache.dolphinscheduler.common.enums.ExecutionStatus; import org.apache.dolphinscheduler.common.thread.Stopper; import org.apache.dolphinscheduler.common.thread.ThreadUtils; import org.apache.dolphinscheduler.common.utils.HadoopUtils; import org.apache.dolphinscheduler.common.utils.LoggerUtils; import org.apache.dolphinscheduler.common.utils.OSUtils; import org.apache.dolphinscheduler.common.utils.StringUtils; import org.apache.dolphinscheduler.server.entity.TaskExecutionContext; import org.apache.dolphinscheduler.server.utils.ProcessUtils; import org.apache.dolphinscheduler.server.worker.cache.TaskExecutionContextCacheManager; import org.apache.dolphinscheduler.server.worker.cache.impl.TaskExecutionContextCacheManagerImpl; import org.apache.dolphinscheduler.service.bean.SpringApplicationContext; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.slf4j.Logger; /** * abstract command executor */ public abstract class AbstractCommandExecutor { /** * rules for extracting application ID */ protected static final Pattern APPLICATION_REGEX = Pattern.compile(Constants.APPLICATION_REGEX); protected StringBuilder varPool = new StringBuilder(); /** * process */ private Process process; /** * log handler */ protected Consumer<List<String>> logHandler; /** * logger */ protected Logger logger; /** * log list */ protected final List<String> logBuffer; /** * taskExecutionContext */ protected TaskExecutionContext taskExecutionContext; /** * taskExecutionContextCacheManager */ private TaskExecutionContextCacheManager taskExecutionContextCacheManager; public AbstractCommandExecutor(Consumer<List<String>> logHandler, TaskExecutionContext taskExecutionContext, Logger logger) { this.logHandler = logHandler; this.taskExecutionContext = taskExecutionContext; this.logger = logger; this.logBuffer = Collections.synchronizedList(new ArrayList<>()); this.taskExecutionContextCacheManager = SpringApplicationContext.getBean(TaskExecutionContextCacheManagerImpl.class); } /** * 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 指令碼任務,由於每個執行步驟產生的日誌少,而且每個步驟執行的時間又很久,時間間隔很大,就會出現很久都不會重新整理上一次產生的日誌。
#!/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執行緒的處理。
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 的效能優化
待稍後晚點補充
四、聯童對於開源社群的擁抱和回饋
聯童雖然是一家新興起的母嬰童公司,但是在成立的初始,就秉承著以技術來驅動母嬰童產業的發展,公司擁有一個非常好的技術團隊,也一直在擁抱開源社群,目前已經引入了incubator-dolphinscheduler、prometheus、grafana 、hadoop、spark、flink、hive、presto......等很多開源專案來支撐公司的技術驅動。在未來,聯童也一定回不斷的去回饋開源社群,去提供更多的Pull requests,貢獻自己的一份力量。