如何構建一個flink sql平臺 原 薦

我們都知道,離線計算有Hive,使用過的知道,需要先定義一個schema,比如針對HDFS這種存儲對標mysql定義一個schema,schema的本質是什麼?主要描述下面這些信息

1)當前存儲的物理位置的描述

2)數據格式的組成形式

然後Hive可以讓用戶定義一段sql,針對上面定義的schema進行,sql的本質是什麼,是業務邏輯的描述。然後Hive內部會將這段sql進行編譯轉化爲原生的底層MapReduce操作,通過這種方式,屏蔽底層技術原理,讓業務開發人員集中精力在schema和sql業務邏輯上,flink sql平臺也正是做同樣的事情。

 

一開始經過跟上海同事的討論,選擇Uber的Athenax作爲技術選型,通過翻閱源碼,發現還是有很多不完善的地方,比如配置文件採用yaml,如果做多集羣調度,平臺代碼優化,多存儲擴展機制,都沒有考慮得很清楚,所以代碼拿過來之後基本上可以說按照對yarn和flink的理解重新寫了一遍。

 

大致的工作流程如圖所示:

簡單解釋一下:

1)業務定義job

2)提交到web服務器,存到mysql中

3)flink平臺進程定時掃描mysql,探測到udf變化,按需實時編譯class,class常駐內存

4)同時打包推送到hdfs

5)flink平臺進程定時掃描mysql,探測到job定義,並從yarn集羣獲取當前運行狀態的job的report

比較時間戳,決定哪些任務要殺死,啓動

6)flink提交到yarn集羣的任務,yarn會從hdfs拉取job描述裏的jar包,啓動這個flink job

然後步驟3,4,5,6 重複執行

 

 

下面是平臺代碼的思路

 

1)通過springboot提供HTTP API,提供多集羣定義,存儲在mysql裏

一個集羣需要定義的信息點如下:

 

2)提供HTTP API讓業務進行Job定義

這裏的Job定義包含3個方面:job的輸出輸出的schema定義,job的業務邏輯定義(sql),job需要的yarn資源定義,具體來說如下所示:

Job定義


文中的sql定義

SELECT SUM(nested.number) as nestedNumber,

hundredFunction(SUM(CAST(`value` AS DOUBLE))) as `sum`,

COUNT(`value`) as `count`,

AVG(CAST(`value` AS DOUBLE)) as `avg`,

MAX(CAST(`value` AS DOUBLE)) as `max`,

MIN(CAST(`value` AS DOUBLE)) as `min`,

TUMBLE_END(`time`, INTERVAL '3' SECOND)  as `time`  

FROM input.`ymm-appmetric-dev-self1`  

WHERE metric IS NOT NULL AND `value` IS NOT NULL 

and `time` IS NOT NULL

GROUP BY metric,TUMBLE(`time`, INTERVAL '3' SECOND)

 

輸入/輸出schema定義,以kafka爲例,輸入和輸出格式差不多

{

"brokerAddress":"略",

"topic":"dev-metric",

"schemas":[

                   {"key":"sum","type":"double"},

                   {"key":"count","type":"int"},

                   {"key":"avg","type":"double"},

                   {"key":"max","type":"double"},

                   {"key":"min","type":"double"},

                   {"key":"time","type":"timestamp"},

                   {"key":"nestedNumber","type":"int"}

                 ]

}

 

對於業務來說,“打開IDE->瞭解flink語法寫java代碼->打包成jar->提交到yarn集羣”這一環節省去了,直接打開界面,點擊按鈕定義sql,寫一段業務邏輯sql,提交此業務到mysql,關閉瀏覽器即可.由平臺進行調度(秒級),永遠不用擔心這個任務某一天掛了怎麼辦,平臺會自動發現自動拉起.提交一次永遠不需要再人工干預,除非邏輯發生變化,在邏輯發生變化時也簡單,打開任務修改再提交,關閉瀏覽器,結束,平臺會發現job變化殺死老任務拉起新任務.

下面講一下平臺內部是如何實現的

 

3)集羣自動發現

    如果平臺維護方想增加一個集羣,通過界面直接定義一個存在mysql即可,後臺線程會自動發現,爲每個集羣創建一個線程,多節點情況下,整個環境中某個特定集羣的多個線程通過ZK進行搶佔決定哪個線程當前爲這個集羣服務.

    增加JVM關閉鉤子,在JVM退出時,主動關閉ZK客戶端,釋放ZK上的臨時節點.

 

4)UDF的支持&自動發現

平臺支持平臺級UDF的定義,由平臺人員進行維護,平臺人員編寫腳本,通過base64編碼存在mysql裏,歸屬到某個集羣,這個集羣的掃描線程發現有必要進行編譯時,實時編譯成class常駐內存,同時,打包成jar包上傳到遠程HDFS,後面會將此路徑放入到具體job的classpath路徑下. job就可以正確發現UDF.

 

當UDF沒有發生變化時,線程不會編譯,而是複用上一次的編譯結果.

 

5)程序可以任意部署,不依賴大數據環境

       程序本身不依賴大數據環境的配置,具體是指不需要依賴當前宿主機.../etc/hadoop/*.xml文件

通過讀取cluster的配置,動態生成XML配置,再生成HDFS/YARN的客戶端client,這樣,平臺代碼可以任意部署到物理機/容器中,只要環境可以通過TCP連接到對應域名/ip即可.

 

6)如何做任務調度-任務的自動發現

這裏的任務調度是指:哪些任務需要下線,哪些任務需要第一次上線,哪些任務需要重新上線,

這裏的業務邏輯就是比較mysql裏job的時間戳和yarn集羣裏任務的時間戳

yarn集羣裏任務的時間戳是通過提交時打上Tag標記,就是爲了下一次比較用。

這裏有一個細節,就是Athenax的做法是先算出所有要殺死的任務,殺死,再拉起所有要拉起的任務,個人認爲這裏不妥,優化之後的做法是:按照任務級別,算出(killaction,startaction),對於單個job來說,二者至少存在1個action,然後以任務爲級別進行調度,不再是之前的大一統提交方式,這樣就算單個任務調度異常,也不影響其它任務,做到了任務之間做隔離.

 

通過時間戳的方式,就不難理解業務一旦修改任務,平臺發現時間戳有變化,就可以自動殺死老任務,拉起新任務,不需要人工操作.

 

7)拉起任務中的編譯工作

一個job需要拉起時,會實時結合(輸入schema,SQL業務邏輯,輸出schema)進行編譯,

正如hive會翻譯成原生的mapreduce操作,flink sql編譯工作會翻譯成原生的flink jobgraph

這部分是抽取了athenax裏的編譯工作做2開

代碼如下:

private JobCompilerResult compile(Map inputs, String originSql,
                                      ExternalCatalog output, ResourceDTO resourceDTO,
                                      ClusterDTO athenaxCluster,
                                      Configuration flinkConf) throws Exception {
        // 解析sql
        LoggerUtil.info("to be compiled sql : [{}]", originSql);
        SqlNodeList stmts = (SqlNodeList) new CalciteSqlParser().parse(originSql);
        Validator validator = new Validator();
        validator.validateQuery(stmts);
        HashMap udfMap = validator.getUserDefinedFunctions();
        String selectSql = validator.getStatement().toString();
        List additionalResources = validator.getAdditionalResources();
        LoggerUtil.info("succeed to parse sql,result is : [{}]", stmts);
        LoggerUtil.info("udf {}", udfMap);
        LoggerUtil.info("statement {}", selectSql);
        LoggerUtil.info("additionalResources {}", additionalResources);
        // 準備編譯,輸出Flink的JobGraph
        LoggerUtil.info("begin to create execution environment");
        StreamExecutionEnvironment localExecEnv = StreamExecutionEnvironment
            .createLocalEnvironment();
        //非常重要
        setFeature(localExecEnv,
            resourceDTO.getTaskManagerCount() * resourceDTO.getSlotPerTaskManager(), flinkConf);
        StreamTableEnvironment tableEnv = StreamTableEnvironment.getTableEnvironment(localExecEnv);
        LoggerUtil.info("tableEnv : {} ", tableEnv);
        // 註冊UDF,收歸到平臺了,也就是說,只支持平臺開發人員預定義,暫時不支持業務自定義
        for (Map.Entry e : udfMap.entrySet()) {
            final String name = e.getKey();
            String clazzName = e.getValue();
            LoggerUtil.info("used udf specified by business : {}", name);
        }
        registerSDF(athenaxCluster, tableEnv);
        LoggerUtil.info("all udf registerd , bingo");
        // 開始註冊所有的input相關的schema
        for (Map.Entry e : inputs.entrySet()) {
            LoggerUtil.info("Registering input catalog {}", e.getKey());
            tableEnv.registerExternalCatalog(e.getKey(), e.getValue());
        }
        LoggerUtil.info("all input catalog registerd , bingo");
        Table table = tableEnv.sqlQuery(selectSql);
        LoggerUtil.info("succeed to execute tableEnv.sqlQuery(...)");
        LoggerUtil.info("table {}", table);
        LoggerUtil.info("bingo! input work done completely,let us handle output work now!!!");
        // 開始註冊output
        List outputTables = output.listTables();
        for (String t : outputTables) {
            table.writeToSink(getOutputTable(output.getTable(t)));
        }
        LoggerUtil.info("handle output ok");
        // 生成JobGraph
        StreamGraph streamGraph = localExecEnv.getStreamGraph();
        JobGraph jobGraph = streamGraph.getJobGraph();
        // this is required because the slots are allocated lazily
        //如果爲true就會報錯,然後flink內部就是一直重啓,所以設置爲false
        jobGraph.setAllowQueuedScheduling(false);
        LoggerUtil.info("create flink job ok {}", jobGraph);
        JobGraphTool.analyze(jobGraph);
        // 生成返回結果
        JobCompilerResult jobCompilerResult = new JobCompilerResult();
        jobCompilerResult.setJobGraph(jobGraph);
        ArrayList paths = new ArrayList();
        Collection values = udfMap.values();
        for (String value : values) {
            paths.add(value);
        }
        jobCompilerResult.setAdditionalJars(paths);
        return jobCompilerResult;
    }

 

這部分工作要理解,需要對Calcite有基礎

 

8)多存儲的支持

平臺在一開始編寫的時候,就考慮到了多存儲支持,雖然很多任務是從kafka->計算->Kafka

但是平臺並不只滿足於這一點,因爲寫到kafka之後,可能還需要業務再去維護一段代碼取讀取kafka的消息進行消費,如果有的業務希望直接能把結果寫到mysql,這個時候就是需要對多存儲進行擴展

 

通過設計和擴展機制,平臺開發人員只需要定義儲存相關的類,針對schema定義的解析工作已經再父類中完成,所有存儲類共用,這樣可以靈活支持多存儲,平臺開發人員只需要把重點放在特定存儲性質的支撐即可.

 

PS:編寫此類存儲類需要對fink job內部的運行機制,否則會造成資源泄露和浪費.

 

平臺內部已經針對每種類型進行了定義

    // 存儲類型
    //排名不分先後    
    public static int STORAGE_REDIS    = 1 << 0; //1
    public static int STORAGE_MYSQL    = 1 << 1; //2
    public static int STORAGE_ROCKETMQ = 1 << 2; //4
    public static int STORAGE_KAFKA    = 1 << 3; //8
    public static int STORAGE_PULSAR   = 1 << 4; //16
    public static int STORAGE_OTHER0   = 1 << 5; //32
    public static int STORAGE_OTHER1   = 1 << 6; //64
    public static int STORAGE_OTHER2   = 1 << 7; //128
    public static int STORAGE_RABBITMQ = 1 << 8; //256
    public static int STORAGE_HBASE    = 1 << 9; //512
    public static int STORAGE_ES       = 1 << 10;//1024
    public static int STORAGE_HDFS     = 1 << 11;//2048

目前支持的情況如下:

輸入:Kafka

輸出:Kafka/Mysql

PS:輸出mysql是基於flink官方的提供類實現的第一版,經過分析源碼,mysql sink官方這部分代碼寫得太隨意,差評.

後續當業務有需求時,需要結合zebra做2次開發.畢竟運維不會提供生產環境的ip和端口等信息,只會提供一個數據源字符串標識.這樣更貼合公司內部的運行環境

 

9)任務提交

一旦生成flink原生的job,就可以準備提交工作

這部分需要對yarn的運行機制比較清楚,比如任務提交到RM上經過哪些狀態變化,ApplicationMaster如何申請資源啓動TaskManager, 具體的job是如何提交給JobManager的,平臺開發人員需要對此有基本的原理掌握,當初也是0基礎開始學習,通過快速翻閱源代碼掌握一些運行機制,方可安心進行平臺開發.

 

10)其它優化

針對yarn client的參數優化,保證可在一定時間內返回,否則可能一直卡死

針對flink job的平臺級優化,比如禁止緩存,讓信息立刻傳輸到下一個環節(默認100毫秒延遲)

定義flink job的重啓次數,當發生異常時可自行恢復等

 

11)壓測結果

輸入:本地啓動7個線程,發送速度

 

每秒發送到kafka 十幾萬條

 

接收topic描述

ymm-appmetric-dev-self1 開發環境   partitions 6  replication 1

 

 flink任務描述

2個TaskManager進程   每個進程800M內存   每個進程3個線程,

並行度 2*3=6

 

flink計算任務所用sql

SELECT SUM(nested.number) as nestedNumber,

hundredFunction(SUM(CAST(`value` AS DOUBLE))) as `sum`,

COUNT(`value`) as `count`,

AVG(CAST(`value` AS DOUBLE)) as `avg`,

MAX(CAST(`value` AS DOUBLE)) as `max`,

MIN(CAST(`value` AS DOUBLE)) as `min`,

TUMBLE_END(`time`, INTERVAL '3' SECOND)  as `time`  

FROM input.`ymm-appmetric-dev-self1`  

WHERE metric IS NOT NULL AND `value` IS NOT NULL and `time` IS NOT NULL 

GROUP BY metric,     TUMBLE(`time`, INTERVAL '3' SECOND)

 

輸出topic

ymm-appmetric-dev-result  partitions 3

 

觀察flink consumer端的消費速度

每個線程的消費速度在24000上下浮動,併發度6,每秒可消費kafka消息14萬+,應該說目前不會碰到性能瓶頸.

 

其它

本次測試發送數據條數:4.3 億條

耗時:56分鐘

 

 

 

對於業務開發人員來說,我覺得好處就是

1)不需要懂flink語法(你真的想知道flink的玩法?好吧我承認你很好學)

2)不需要打開IDE寫java代碼(你真的想寫Java代碼?好吧我承認你對Java是真愛)

3)提交一次,不再需要人工介入(你真的想在假期/晚上/過節/過年 擔心任務掛掉?好吧我承認你很敬業)

只需要

1)界面點擊操作,定義你的schema

2)寫一段你所擅長的sql
3)點擊提交按鈕

4)關閉瀏覽器

5)關閉電腦

 

其它的就交給平臺吧!

 

後續:針對平臺來說,後續的主要工作是根據業務需求擴展多存儲

如果再長遠,那就是要深度閱讀flink源碼對平臺進行二次優化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章