ITPub博客

首页 > 数据库 > 数据库开发技术 > Flink跟Spark流计算状态对比,你感兴趣不?

Flink跟Spark流计算状态对比,你感兴趣不?

原创 数据库开发技术 作者:安瑞哥是码农 时间:2024-04-03 09:06:34 0 删除 编辑


这段时间不是在倒腾 Flink 跟 Spark 各种场景下的数据读写对比嘛,于是就想着,既然这两款引擎都支持流式计算,那咱就看看,它们在基于「状态计算」下,各自的表现怎么样。


我们都知道,最近这几年, 关于实时计算中,跟状态计算相关的话题,几乎都被 Flink 给包圆了,网络上,关于 Spark 状态计算相关的话题都很少,或者说即便有,也都只是停留在那些老掉牙的,对 updateStateByKey、mapWithState 这两个算子的解读上。


殊不知,人家老大哥 Spark structed streaming 能支持的状态计算,可不止这一点呢。


不信,咱今天就来瞧瞧,在面对流状态计算场景中,常见的聚合计算,Spark 的表现如何?以及对比 Flink,谁更好用?



0. 对比前准备


对比场景很简单,我们先将一份特定的,知道聚合结果的数据用一定的速率,灌入到 Kafka 中。


然后,同时启动 Spark structed streaming 任务,以及 Flink 任务,让它们分别同时消费上面这份 Kafka 数据;


通过 Spark 跟 Flink 的聚合计算,计算出每个ip(client_ip)访问的网站(domain)次数,用 SQL 表示就是:


SELECT
    client_ip,
    lower(domain) AS domain,
    count(*) AS count
FROM data
GROUP BY
    client_ip,
    domain


然后将最终的计算结果写入到 Doris 表里,由于是流方式实时写入,所以写入的结果随时需要被更新,于是需要设计一张去重表:


CREATE TABLE IF NOT EXISTS example_db.data_from_flink01/spark01
(
    `client_ip` VARCHAR(50),
    `domain` VARCHAR(200),
    `count` INT
)
UNIQUE KEY(`client_ip`, `domain`)
DISTRIBUTED BY HASH(`client_ip`) BUCKETS 3
PROPERTIES (
"replication_allocation" = "tag.location.default: 1"
);


过程中,观察 Spark 以及 Flink 的中间输出结果变化,以及存储状态变化。


最后,看最终写入到 Doris 数据库的结果,是否符合预期。



1. Spark 编码方式


经过一番折腾调试之后,能顺利跑通的 Spark API 如下:


package com.anryg.bigdata.streaming.stateful


import com.alibaba.fastjson.JSONObject
import org.apache.log4j.{Level, Logger}
import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.{OutputMode, Trigger}

/**
  * @DESC: 测试 Spark 在流式计算下的聚合计算,并将结果写入到Doris
  * @Auther: Anryg
  * @Date: 2024/4/2 11:23
  */

object SparkGroupBy01 {

    def main(args: Array[String]): Unit = {
        val conf = new SparkConf().setAppName("SparkGroupBy01").setMaster("local[*]")
        val spark = SparkSession.builder()
            .config("spark.sql.streaming.stateStore.providerClass", "org.apache.spark.sql.execution.streaming.state.RocksDBStateStoreProvider")//指定流计算的状态存储为RocksDB,3.2之后支持
            .config(conf)
            .getOrCreate()

        val rawDF = spark.readStream
            .format("kafka")
            .option("kafka.bootstrap.servers", "192.168.211.107:6667")
            .option("subscribe", "test")
            .option("failOnDataLoss",false)
            .option("fetchOffset.numRetries",3)
            .option("startingOffsets","latest")
            .load()

        import spark.implicits._
        val df1 = rawDF.selectExpr("CAST(value AS string)")
            .map(row =>{
                val line = row.getAs[String]("value")
                val fieldArray:Array[String] = line.split(",")
                fieldArray
            }).filter(_.length == 9)
            .map(array =>(array(0),array(1),array(2),array(3),array(4),array(5),array(6),array(7),array(8)))
            .toDF("client_ip","domain","time","target_ip","rcode","query_type","authority_record","add_msg","dns_ip")

        df1.createOrReplaceTempView("data_from_kafka")

        /**聚合计算逻辑,取出聚合结果*/
        val df2 = spark.sql(
                    """
                      |SELECT
                      |    client_ip,
                      |    lower(domain) AS domain,
                      |    count(*) AS count
                      |FROM data_from_kafka
                      |WHERE client_ip != ''
                      |GROUP BY
                      |    client_ip,
                      |    domain
                    "
"".stripMargin)

        /**必须要对结果的格式重新定义为json string,否则直接无法写入Doris*/
        val df3 = df2.map(row => {
                    val json = new JSONObject(3)  /**将数据封装为json对象*/
                    val client_ip = row.getString(0)
                    val domain = row.getString(1)
                    val count = row.getLong(2)

                    json.put("client_ip",client_ip)
                    json.put("domain", domain)
                    json.put("count", count)
                    json.toJSONString
                }).toDF()

       df3.writeStream
            .outputMode(OutputMode.Complete()) //确定输出模式,因为是聚合结果,要用complete模式
            .format("doris")
            .option("doris.table.identifier", "example_db.data_from_spark01")
            .option("doris.fenodes","192.168.221.173:8030")
            .option("user","root")
            .option("password","xxx")
            .option("checkpointLocation","hdfs://192.168.211.106:8020/tmp/spark_checkpoint/test/SparkGroupBy01") /**这个必须加上,记录offset以及计算状态*/
            .start()
            .awaitTermination()
    }
}

这里面有几个需要你注意的地方在于:


1. Spark 也可以设置它状态的存储方式,比如,这里用的就是跟  Flink 一样的 RocksDB。


只不过,它是在 Spark 3.2 及之后版本才支持;


2. 因为是对聚合结果进行输出,所以它的输出方式必须为 complete 模式;



3. 也是比较坑爹的,那就是如果输出结果要写 Doris,必须要把 DataFrame 里面的 Row 对象,转行成为 json string 才行,否则就会抛出下面这个让你一脸懵逼的错:



当然,对于这个我认为特傻逼的问题(这个 Dataframe 本身就是带 schema 的数据结构,为毛还要多此一举),之前文章也吐槽过。



2. Flink 编码方式


方便起见,这里我用 Flink 的 SQL API。同样,经过调试之后,能顺利跑通的代码如下:


package com.anryg

import java.time.Duration

import org.apache.flink.contrib.streaming.state.EmbeddedRocksDBStateBackend
import org.apache.flink.streaming.api.CheckpointingMode
import org.apache.flink.streaming.api.environment.CheckpointConfig.ExternalizedCheckpointCleanup
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.table.api.bridge.scala.StreamTableEnvironment

/**
  * @DESC: 测试 Flink SQL 在流式计算下的聚合计算,并将结果写入到Doris
  * @Auther: Anryg
  * @Date: 2024/4/2 17:20
  */

object FlinkGroupBy01 {

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

        env.enableCheckpointing(60000L)

        env.setStateBackend(new EmbeddedRocksDBStateBackend(true)) //新的设置state backend的方式

        env.getCheckpointConfig.setCheckpointStorage("hdfs://192.168.211.106:8020/tmp/flink_checkpoint/FlinkGroupBy01")
        env.getCheckpointConfig.setExternalizedCheckpointCleanup(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION) //设置checkpoint记录的保留策略
        env.getCheckpointConfig.setAlignedCheckpointTimeout(Duration.ofMinutes(1L))
        env.getCheckpointConfig.setCheckpointingMode(CheckpointingMode.AT_LEAST_ONCE)

        val tableEnv = StreamTableEnvironment.create(env)

        /**第一步:读取kafka数据源*/
        tableEnv.executeSql(
            """
             Create table data_from_kafka(
             |client_ip STRING,
             |domain STRING,
             |`time` STRING,
             |target_ip STRING,
             |rcode STRING,
             |query_type STRING,
             |authority_record STRING,
             |add_msg STRING,
             |dns_ip STRING
             |)
             |with(
             |'connector' = 'kafka',
             |'topic' = 'test',
             |'properties.bootstrap.servers' = '192.168.211.107:6667',
             |'properties.group.id' = 'FlinkGroupBy01',
             |'scan.startup.mode' = 'latest-offset',
             |'value.format'='csv',                                 //确定数据源为文本格式
             |'value.csv.field-delimiter'=','                      //确定文本数据源的分隔符
             |)
            "
"".stripMargin)

        /**第二步:创建Doris映射表*/
        tableEnv.executeSql(
            """
               |CREATE TABLE data_from_flink01 (
               |client_ip STRING,
               |domain STRING,
               |`count` BIGINT
               |)
               |    WITH (
               |      'connector' = 'doris',
               |      'fenodes' = '192.168.221.173:8030',
               |      'table.identifier' = 'example_db.data_from_flink01',
               |      'username' = 'root',
               |      'password' = 'xxx',
               |     'sink.label-prefix' = 'label01'  //导入标签前缀,每次要不一样
               |)
            "
"".stripMargin)

        /**第三步:写聚合结果到Doris表*/
        tableEnv.executeSql(
            """
              |INSERT INTO data_from_flink01
              |SELECT
              |    client_ip,
              |    lower(domain) AS domain,
              |    count(*) AS `count`
              |FROM data_from_kafka
              |WHERE client_ip <> ''
              |GROUP BY
              |    client_ip,
              |    domain
            "
"".stripMargin)
    }
}

同样,这里也有几个需要值得注意的地方:


1. 同样的 SQL,Flink 跟 Spark 这里就出现了语法上的2处不一致。


第1处不一致:


这个 count 必须要用「尖括号」(tab键上面那个)给括起来,否则语法不通过,而 Spark sql 不用;


第2处不一致:


这个不等号,Flink 得用 “<>” 符号,而Spark 用的是 “!=”。


2. 还记得之前我的文章验证过,Flink 消费 Kafka 数据时,如果用 latest 模式,会丢数据,你猜这次它会不会呢?



3. Spark 跟 Flink 运行过程对比


因为都用的 latest 消费模式,所以先启动 Spark 跟 Flink 进程,然后往 Kafka 推数据,这样它们就能消费到一样的数据了。


先往 Kafka 的 topic 推了3条数据,观察到如下的输出情况。


对于 Spark 的中间输出数据来说:


因为它不像 Flink 有「回撤流」的概念,所以聚合结果每次都是全量最新的,而且它的聚合 SQL 可以用「任意字段」进行排序,比如对 count 排序。


而对于 Flink 的中间结果来说:



因为它有「回撤流」的概念,所以每次如果聚合结果有更新,都会有 -U 的回撤数据出现,因为这个,相比 Spark,它不能用「非时间」字段进行排序。 


再来看一眼 Spark 跟 Flink 的状态目录情况。


Spark 的状态目录:



可以看到它的目录结构比较丰富,总共有5个,而且才写入3条数据,居然它的状态就有 1.8MB+ 大小,但最终的状态大小,咱后面需再进一步比较看。


Flink 的状态目录:


相比 Spark,Flink 的状态目录结构更简单,而且一开始占用空间也更小,但是它这个即便在没有数据处理(流入)的情况下,还在不断地「删除老目录,创建新目录」这个行为,多少有点让人想不通。



4. 运行结果对比


先确定这两个程序在本地环境测试运行OK,然后打包上传到 YARN 集群运行,确保这两个进程已经完全启动。


将事先准备好的一张四百多万记录 Clickhouse 表数据,给推入到 kafka 中,



这样做的最大好处在于,我是提前知道聚合结果的,这样就能验证经过 Spark 以及 Flink 的流式聚合计算之后,写入到 Doris 的结果,它到底准不准。


一番操作猛如虎之后发现,数据已经全部写入到对应的两张 Doris 表里。


可以看到输出的聚合结果数据量是一样的,都是 5591条。


为了验证这个数量的正确性,我们来看一下原表的聚合结果数据量:


可以看到,CK 的原表聚合记录也是 5591条,证明这个总数量没错。


但是,这样你是不是就以为写入结果就真的正确了呢?


从运行效率来看,Spark 处理完这些数据耗时如下:


花了 204秒。


而 Flink 因为玩法跟 Spark 不一样,只能盯着它数据处理开始的时间,然后用数据处理完的大概时间跟它取时间差,我大概算了一下,用时140秒左右。


再来看一眼这两者的状态存储情况。


Spark 的状态:


占用 22MB 多一点。


Flink 的状态:


占用 17MB 多一点。



最后


从这次 Spark 跟 Flink 简单的聚合状态计算对比来看,这两款时下主流的计算引擎,都支持在实时模式下的「状态计算」。


但是从这次测试对比来看,它们之间的区别跟联系在于:


1. Spark 跟 Flink,都能够根据业务需求,达到聚合状态计算的目的;


2. 聚合状态计算的效率不一样,Flink 的效率,要高于 Spark;


3. 从存储的状态来看,Flink 占用的存储空间要略小于 Spark,但是,在没有数据流入的情况下,Flink 依然会定期「删除目录,重建目录」,Spark 则不会。


最后,留个思考题:


在这次设计的测试对比中,最终写入到 Doris 的聚合结果,对?还是不对呢?为什么?


答案咱们在下一篇揭晓。




你可以添加我的私人微信,拉你入技术讨论群,跟一群热爱技术的小伙伴一起成长...


原文作者:安瑞哥是码农;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2022-02-16

  • 博文量
    228
  • 访问量
    115488