Flink 的側輸出流,怎麼玩?

大資料技術前線發表於2023-11-27

來源:安瑞哥是碼農

之所以想到要寫這篇文章呢,因為上篇文章寫過Doris 透過Flink CDC 只需要一個簡單的命令,就能同步整個mysql庫的所有表,其中這個多表的同步過程,就使用到了Flink的側流輸出功能。


所謂側流輸出(也叫旁路輸出),指的是透過對讀取的資料來源(DataStream),根據不同的業務規則進行條件篩選,然後根據篩選後生成的不同資料分支,做不同的業務目的輸出(比如寫到不同的資料目的地中)。


看到Flink官網的如此描述後,我就在想,印象中 Spark 是不是不具備這個功能?


為此,我還專門回過頭去折騰了下 Spark 的 structured stream,試圖根據兩個不同的資料處理邏輯,得到兩股不同的流,用兩股流分別做輸出,虛擬碼如下:


val rawDF = spark.readStream //獲取資料來源
            .format("kafka"//確定資料來源的來源格式
            .option("kafka.bootstrap.servers""xxxxx")
            .option(....)
            .load()
//第1個資料流            
val df01 = rawDF.map(//第1個處理邏輯)

//第2個資料流 
val df02 = rawDF.map(//第2個處理邏輯)

//第1個資料流輸出
df01.writeStream
    .outputMode(OutputMode.Append()) 
    .format(//第1個輸出)
    .option("checkpointLocation","xxxx")
    .start()
    .awaitTermination()
    
//第2個資料流輸出     
df02.writeStream
    .outputMode(OutputMode.Append()) 
    .format(//第2個輸出)
    .option("checkpointLocation","xxxx")
    .start()
    .awaitTermination()    

你們猜,這個程式碼能執行起來嗎?


其實可以,整個過程不會報錯,但是第2個資料流,永遠都不會輸出。


雖然Spark官網沒有明確說明,一個spark流式任務中,是否能做多個外部輸出,但是透過實際的動手驗證發現:不行。


所以目前來看,對比Spark,側輸出流是Flink的專屬,而且是DataStream API的專屬(SQL API 不具備)。


Flink 的側輸出流,怎麼玩?


那麼今天這篇內容,我們就來聊聊Flink的這個側流輸出能力,到底實際用起來怎麼樣?會遇到哪些坑?



0. 準備工作


側輸出流本身是 Flink 內建的功能,所以使用它,不需要跟Flink CDC 一樣引入額外的pom依賴,直接編碼實現就可以。


官方文件給的示例非常的簡單,只演示了一個側流跟一個主流的情況,但是從它的描述來看,使用的時候,其實是可以根據你的業務要求,切分出任意多個側輸出流的。


Flink 的側輸出流,怎麼玩?

那為了儘可能測試該功能,我準備從kafka資料來源中,以 client_ip 這個欄位值的最後一個數字作為區分,分成4股不同的流,對每股流的輸出計劃如下:


1. 主輸出流:寫入到kafka的一個topic中;


2. 側輸出流01:寫入到Doris表中;


3. 側輸出流02:直接列印到螢幕輸出;


4. 側輸出流03:寫入到hdfs檔案系統中。


因為官網提到不同的流輸出,可以支援不同的資料型別,所以我也決定不同的資料流,用不同的資料型別來表示。


廢話少嘮,直接上程式碼。



1. 完整編碼


根據官方文件的示例,帶有詳細註釋的編碼如下:


package com.anryg.side_output

import java.time.Duration
import java.util.Properties

import com.alibaba.fastjson.{JSONJSONObject}
import org.apache.doris.flink.cfg.{DorisExecutionOptionsDorisOptionsDorisReadOptions}
import org.apache.doris.flink.sink.DorisSink
import org.apache.doris.flink.sink.writer.SimpleStringSerializer
import org.apache.flink.api.common.eventtime.WatermarkStrategy
import org.apache.flink.api.common.serialization.{SimpleStringEncoderSimpleStringSchema}
import org.apache.flink.configuration.MemorySize
import org.apache.flink.connector.base.DeliveryGuarantee
import org.apache.flink.connector.kafka.sink.{KafkaRecordSerializationSchemaKafkaSink}
import org.apache.flink.connector.kafka.source.KafkaSource
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer
import org.apache.flink.contrib.streaming.state.EmbeddedRocksDBStateBackend
import org.apache.flink.core.fs.Path
import org.apache.flink.streaming.api.CheckpointingMode
import org.apache.flink.streaming.api.environment.CheckpointConfig.ExternalizedCheckpointCleanup
import org.apache.flink.streaming.api.functions.ProcessFunction
import org.apache.flink.streaming.api.functions.sink.filesystem.{OutputFileConfigStreamingFileSink}
import org.apache.flink.streaming.api.functions.sink.filesystem.bucketassigners.DateTimeBucketAssigner
import org.apache.flink.streaming.api.functions.sink.filesystem.rollingpolicies.DefaultRollingPolicy
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.util.Collector


/**
  * @DESC: Flik側輸出流案例測試
  * @Auther: Anryg
  * @Date: 2023/11/15 10:11
  */

object FlinkSideoutTest01 {

    private final val hdfsPrefix = "hdfs://192.168.211.106:8020" //HDFS路徑字首

    /**
      * @DESC: 定義hdfs輸出方式
      * */

    private def hdfsSink[Out](path:String): StreamingFileSink[Out] ={
        val hdfsSink = StreamingFileSink.forRowFormat(new Path(hdfsPrefix + path),
            new SimpleStringEncoder[Out]("UTF-8"))
            .withBucketAssigner(new DateTimeBucketAssigner("yyyy-MM-dd")) /**預設基於時間分配器,以小時為單位進行切分yyyy-MM-dd--HH*/
            .withOutputFileConfig(OutputFileConfig.builder().withPartPrefix("kafka").withPartSuffix(".csv").build()) //設定生成檔案的配置策略
            .withRollingPolicy( //設定檔案的滾動策略,也就是分檔案策略,也可以同時設定檔案的命名規則,這裡暫時用預設
            DefaultRollingPolicy.builder()
                .withRolloverInterval(Duration.ofSeconds(10)) //檔案滾動間隔,設為10分鐘,即每10分鐘生成一個新檔案
                .withInactivityInterval(Duration.ofSeconds(10)) //空閒間隔時間,也就是當前檔案有多久沒有寫入資料,則進行滾動
                .withMaxPartSize(MemorySize.ofMebiBytes(1024)) //單個檔案的最大檔案大小,設定為1G
                .build())
            .build()

        hdfsSink
    }

    /**
      * @DESC: 定義Doris的輸出方式
      * */

    private def dorisSink[Sting](label:String,table:String): DorisSink[String] ={
        /**配置Doris相關引數*/
        val dorisBuilder = DorisOptions.builder
        dorisBuilder.setFenodes("192.168.221.173:8030")
            .setTableIdentifier(table) //比如:example_db.dns_logs_from_flink01
            .setUsername("root")
            .setPassword("")
        /**確定資料寫入到Doris的方式,即stream load方式*/
        val executionBuilder = DorisExecutionOptions.builder
        executionBuilder.setLabelPrefix(label) //streamload label prefix

        /**新增額外的資料格式設定,否則寫不進去*/
        val properties = new Properties
        properties.setProperty("format""json")
        properties.setProperty("read_json_by_line""true")

        executionBuilder.setStreamLoadProp(properties)

        val builder = DorisSink.builder[String//注意這個資料型別String 需要加上
        builder.setDorisReadOptions(DorisReadOptions.builder.build) //確定資料讀取策略
            .setDorisExecutionOptions(executionBuilder.build) //確定資料執行策略
            .setSerializer(new SimpleStringSerializer())  //確定資料資料序列化(寫入)型別
            .setDorisOptions(dorisBuilder.build) //新增Doris配置
            .build()
        builder.build()
    }

    /**
      * @DESC: 定義kafka的輸出方式
      * */

    private def kafkaSink(topic:String): KafkaSink[String] ={
         KafkaSink.builder[String]()
             .setBootstrapServers("192.168.211.107:6667")
             .setRecordSerializer(
                 KafkaRecordSerializationSchema.builder()
                 .setTopic(topic)
                 .setValueSerializationSchema(new SimpleStringSchema())
                 .build()
             )
             .setDeliverGuarantee(DeliveryGuarantee.AT_LEAST_ONCE)
             .build()
     }


    def main(args: Array[String]): Unit = {
        val env = StreamExecutionEnvironment.getExecutionEnvironment

        env.enableCheckpointing(10000CheckpointingMode.AT_LEAST_ONCE)
        //env.setParallelism(args(0).toInt)

        env.setStateBackend(new EmbeddedRocksDBStateBackend(true)) //新的設定state backend的方式
        env.getCheckpointConfig.setCheckpointStorage("hdfs://192.168.211.106:8020/tmp/flink_checkpoint/FLinkSideoutTest01"//設定checkpoint的hdfs目錄
        env.getCheckpointConfig.setExternalizedCheckpointCleanup(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION//設定checkpoint記錄的保留策略

        val kafkaSource = KafkaSource.builder()  //獲取kafka資料來源
            .setBootstrapServers("192.168.211.107:6667")
            .setTopics("test")
            .setGroupId("SideoutTest01")
            .setStartingOffsets(OffsetsInitializer.latest())
            .setValueOnlyDeserializer(new SimpleStringSchema())
            .build()

        import org.apache.flink.streaming.api.scala._  //引入隱私轉換函式

        val kafkaDS = env.fromSource(kafkaSource,
            WatermarkStrategy.noWatermarks()
            ,"kafka-data"//讀取資料來源生成DataStream物件

        val rawDS = kafkaDS.map(line => {
                        val rawJson = JSON.parseObject(line)      //原始string是一個json,對其進行解析
                        val message = rawJson.getString("message")  //獲取業務資料部分
                        val msgArray = message.split(",")  //指定分隔符進行欄位切分
                        msgArray
                    }).filter(_.length == 9//只留欄位數為9的資料
                    .filter(array => ! array(0).trim.equals(""))
                    .map(array => (array(0),array(1).toLowerCase,array(2),array(3),array(4),array(5),array(6).toLowerCase(),array(7),array(8))) //將其轉化成為元組,為了方便下一步賦予schema

        /**定義第1個側輸出流的標籤*/
        val outputTag01 = OutputTag[(String,String,String,String,String,String,String,String,String)]("side-output01")

        /**定義第2個側輸出流的標籤*/
        val outputTag02 = OutputTag[String]("side-output02")

        /**定義第3個側輸出流的標籤*/
        val outputTag03 = OutputTag[(String,String,String)]("side-output03")

        val mainDS = rawDS.process(new ProcessFunction[(String,String,String,String,String,String,String,String,String),String] {
                        override def processElement(value: (StringStringStringStringStringStringStringStringString), ctx: ProcessFunction[(StringStringStringStringStringStringStringStringString), String]#Context, out: Collector[String]): Unit = {
                            val sufix = value._1.substring(value._1.length - 1//拿到client_ip的最後一個數字,然後根據其數字來進行資料流分組


                            if (sufix.equals("1") || sufix.equals("2")) {
                                out.collect(value._1)  /**主輸出流*/
                            }
                            else if (sufix.equals("3") || sufix.equals("4") || sufix.equals("5")){
                                ctx.output(outputTag01, value) /**側輸出流01*/
                            }
                            else if (sufix.equals("6") || sufix.equals("7") || sufix.equals("8")) {
                                ctx.output(outputTag02, value._2)  /**側輸出流02*/
                            }
                            else {
                                val out03 = (value._1, value._2, value._3)
                                ctx.output(outputTag03, out03)  /**側輸出流03*/
                            }
                        }
                    })

        /**獲取側輸出流01*/
        val sideDS01 = mainDS.getSideOutput(outputTag01)

        /**獲取側輸出流02*/
        val sideDS02 = mainDS.getSideOutput(outputTag02)

        /**獲取側輸出流03*/
        val sideDS03 = mainDS.getSideOutput(outputTag03)

        /**將主輸出流輸出到kafka*/
        mainDS.sinkTo(kafkaSink("example_topic"))

        /**將第1個側流輸出到Doris表*/
        sideDS01.map(array => {
            val json = new JSONObject(9)  //將資料封裝為json物件
            json.put("client_ip", array._1)
            json.put("domain",array._2)
            json.put("time",array._3)
            json.put("target_ip",array._4)
            json.put("rcode",array._5)
            json.put("query_type",array._6)
            json.put("authority_record",array._7)
            json.put("add_msg",array._8)
            json.put("dns_ip",array._9)
            json.toJSONString //轉換為json string
        }).sinkTo(dorisSink("side_ouput01""example_db.flink_side_stream01"))


        /**將第2個側流直接列印出來*/
        sideDS02.print("side stream02: ")

        /**將第3個側流輸出到HDFS的CSV檔案*/
        sideDS03.addSink(hdfsSink[(String,String,String)]("/tmp/flink_side-stream03"))
        
        env.execute("FlinkSideoutTest01")
    }
}

從程式碼中可以看出來,整個編碼邏輯還是非常清晰的。


只不過對於Flink來說,不同的輸出物件,不管是kafka、Doris、還是HDFS,每一個Sink物件的API風格都不一樣,沒有統一的格式與套路,相比之下,Spark則會更規整一些。


而且隨著 Flink 版本的不同,相同功能的 API 寫法還不一樣,就還挺難受的。



2. 執行結果


整個編碼過程雖然看起來內容比較多,而且涉及到多個不同的Sink物件但是編寫完卻出奇的順利(居然沒有發現坑)。


一跑就通,出奇的絲滑,簡直 Amazing...


最關鍵是4個輸出都符合預期。


先看主輸出流(寫kafka):


Flink 的側輸出流,怎麼玩?


再看第1個側輸出流(寫Doris表):


Flink 的側輸出流,怎麼玩?


然後看第2個側輸出流(直接控制檯輸出):


Flink 的側輸出流,怎麼玩?


最後看第3個側輸出流(寫HDFS):


Flink 的側輸出流,怎麼玩?



總結


雖然說根據官網文件的要求,整個編碼過程很順利,沒有遇到明顯的讓程式跑不起來的坑。


但是,這裡還是有個必須要注意的點,那就是在實踐中,會有這麼個問題,那就是,但凡有任何一個流在sink的時候發生了異常,程式預設是不會退出的


也就是說,程式在執行過程中有沒有問題,除非你去觀察日誌,否則即便你不對程式碼邏輯進行try...catch,程式本身是會忽略這些異常繼續跑的。


這就需要開發者在編碼時,確定多個流之間的關係,是相互不影響呢,還是要作為一個「事務」而捆綁在一起。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70027827/viewspace-2997332/,如需轉載,請註明出處,否則將追究法律責任。

相關文章