StructuredStreaming項目開發記錄

StructuredStreaming項目開發記錄

版本說明:Spark2.4

前言

最近基於Spark Structured Streaming開發一套實時數據判別清洗系統,在開發過程接觸了一些StructuredStreaming的新特性以及新用法。本文主要記錄一下在開發過程中使用到的技術點,以及遇到的問題總結。

1 放棄Spark Streaming 選用Structured Streaming

關於當時項目技術選型最終選擇StructuredStreaming的原因,主要是因爲團隊具有Spark開發經驗且Structured 比Spark Streaming具有基於事件時間處理機制。這裏簡單對Spark Streaming 和Structured Streaming 做一個優劣對比,詳細的內容可以參考《是時候放棄Spark Streaming,轉向Structured Streaming了》

1.1 Spark Streaming的不足

  • 使用Processing Time 而不是 Event Time

剛纔提到,我們技術選型的時候,就是因爲Spark Streaming沒有事件時間處理機制,所以被放棄。簡單解釋一下這兩個時間的概念:Processing Time 是數據到達Spark被處理的時間,Event Time 是數據自身的時間,一般表示數據產生於數據源的時間。

  • Complex,low-level api

DStream (Spark Streaming 的數據模型)提供的 API 類似 RDD 的 API 的,非常的 low level。當我們編寫 Spark Streaming 程序的時候,本質上就是要去構造 RDD 的 DAG 執行圖,然後通過 Spark Engine 運行。這樣導致一個問題是,DAG 可能會因爲開發者的水平參差不齊而導致執行效率上的天壤之別。這樣導致開發者的體驗非常不好,也是任何一個基礎框架不想看到的(基礎框架的口號一般都是:你們專注於自己的業務邏輯就好,其他的交給我)。這也是很多基礎系統強調 Declarative 的一個原因。

  • reason about end-to-end application

DStream 只能保證自己的一致性語義是 exactly-once的,而Spark Streaming的inpu 和 output的一致性語義需要用戶自己來保證。

  • 批流代碼不統一

儘管批流本是兩套系統,但是這兩套系統統一起來確實很有必要,我們有時候確實需要將我們的流處理邏輯運行到批數據上面。關於這一點,最早在 2014 年 Google 提出 Dataflow 計算服務的時候就批判了 streaming/batch 這種叫法,而是提出了 unbounded/bounded data 的說法。DStream 儘管是對 RDD 的封裝,但是我們要將 DStream 代碼完全轉換成 RDD 還是有一點工作量的,更何況現在 Spark 的批處理都用 DataSet/DataFrame API 了。

1.2 Structured Streaming 優勢

  • 簡潔的模型

Structured Streaming 的模型很簡單,易於理解,用戶可以直接把一個流想象成一個無限增長的表格

  • 一致的API

和Spark SQL 共用大部分API,對Spark SQL熟悉的用戶很容易上手。批處理和流處理程序可以共用代碼,提高開發效率。

  • 卓越的性能

Structured Streaming 在與 Spark SQL 共用 API 的同時,也直接使用了 Spark SQL 的 Catalyst 優化器和 Tungsten,數據處理性能十分出色。此外,Structured Streaming 還可以直接從未來 Spark SQL 的各種性能優化中受益。

  • 多編程語言支持

Structured Streaming 直接支持目前 Spark SQL 支持的語言,包括 Scala,Java,Python,R 和 SQL。用戶可以選擇自己喜歡的語言進行開發

2 Structured 數據源

關於Structured數據源的問題,我在《Structured Streaming 內置數據源及實現自定義數據源》這邊文章裏有詳細介紹。

3 Kafka JSON格式數據解析

Kafka是最爲常見的數據源,kafka裏我們通常會以JSON格式存儲數據。Spark Structured 在處理kafka數據的時候,通常需要將kafka數據轉成DataFrame,之前在《Spark Streaming解析Kafka JSON格式數據》這篇文章中介紹了幾種Streaming處理Kafka JSON格式的方法,並在文末思考中,提到了Structured Streaming解析的方法。這裏重新介紹幾種方法。

3.1 通過定義schema 和 from_json函數解析

Kafka數據裏的value如下所示爲json字符串:

{"deviceId":"4d6021db-7483-4911-8025-87494776ba87","deviceName":"風機溫度","deviceValue":76.3,"deviceTime":1553140083}

Structured 使用select($“value”.cast(“String”))解析如下所示:

爲了使用from_json解析,我們首先要根據json結構定義好schema,如下:

    val schema = StructType(Seq(
      StructField("deviceId", StringType),
      StructField("deviceName", StringType),
      StructField("deviceValue", DoubleType),
      StructField("deviceTime", LongType)
    ))

使用from_json函數進行解析,需要通過import org.apache.spark.sql.functions._引入函數。

 .select(from_json($"value".cast("String"), schema).as("json")).select("json.*")

結果如下所示:

完整代碼:

package com.hollysys.spark.structured.usecase

import com.google.gson.Gson
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.types._

/**
  * Created by shirukai on 2019-03-21 11:36
  * Structured 解析Kafka數據
  */
object HandleKafkaJSONExample {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder()
      .appName(this.getClass.getSimpleName)
      .master("local[2]")
      .getOrCreate()

    val source = spark
      .readStream
      .format("kafka")
      .option("kafka.bootstrap.servers", "localhost:9092")
      .option("subscribe", "structured-json-data")
      .option("maxOffsetsPerTrigger", 1000)
      .option("startingOffsets", "earliest")
      .option("failOnDataLoss", "true")
      .load()

    import spark.implicits._
    import org.apache.spark.sql.functions._

    val schema = StructType(Seq(
      StructField("deviceId", StringType),
      StructField("deviceName", StringType),
      StructField("deviceValue", DoubleType),
      StructField("deviceTime", LongType)
    ))

    val query = source
      .select(from_json($"value".cast("String"), schema).as("json")).select("json.*")
      .writeStream
      .outputMode("update")
      .format("console")
      //.option("checkpointLocation", checkpointLocation)
      .option("truncate", value = false)
      .start()

    query.awaitTermination()
  }
}

3.2 通過定義case class 解析

此方法是先定義好case class,然後通過map函數,將json字符串使用gson轉成case class 返回。

首先根據json結構定義case class:

case class Device(deviceId: String, deviceName: String, deviceValue: Double, deviceTime: Long)

定義json字符串轉case class 函數,這裏使用gson

  def handleJson(json: String): Device = {
    val gson = new Gson()
    gson.fromJson(json, classOf[Device])
  }

使用map轉換

.select($"value".cast("String")).as[String].map(handleJson)

結果如下所示:

完整代碼:

package com.hollysys.spark.structured.usecase

import com.google.gson.Gson
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.types._

/**
  * Created by shirukai on 2019-03-21 11:36
  * Structured 解析Kafka數據
  */
object HandleKafkaJSONExample {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder()
      .appName(this.getClass.getSimpleName)
      .master("local[2]")
      .getOrCreate()

    val source = spark
      .readStream
      .format("kafka")
      .option("kafka.bootstrap.servers", "localhost:9092")
      .option("subscribe", "structured-json-data")
      .option("maxOffsetsPerTrigger", 1000)
      .option("startingOffsets", "earliest")
      .option("failOnDataLoss", "true")
      .load()

    import spark.implicits._
    import org.apache.spark.sql.functions._
    val query = source
      .select($"value".cast("String")).as[String].map(handleJson)
      .writeStream
      .outputMode("update")
      .format("console")
      //.option("checkpointLocation", checkpointLocation)
      .option("truncate", value = false)
      .start()
    query.awaitTermination()
  }

  def handleJson(json: String): Device = {
    val gson = new Gson()
    gson.fromJson(json, classOf[Device])
  }
}

case class Device(deviceId: String, deviceName: String, deviceValue: Double, deviceTime: Long)

3.3 處理JSONArray格式數據

上面提到的兩種方法,是處理JSONObject的情況,即"{}"。如果數據爲JSONArray格式,如[{},{}]該如何處理呢?

假如我們Kafka名爲structured-json-array-data的topic裏的單條數據如下:

[
    {
        "deviceId": "4d6021db-7483-4911-8025-87494776ba87",
        "deviceName": "風機溫度",
        "deviceValue": 76.3,
        "deviceTime": 1553140083
    },
    {
        "deviceId": "89cf0815-9a1e-4dd5-a2d9-ff16c2308ddf",
        "deviceName": "風機轉速",
        "deviceValue": 600,
        "deviceTime": 1553140021
    }
]

3.3.1 from_json+explode函數處理JSONArray格式數據

首先同樣需要定義好schema,這裏就不重複了,與上面定義schema相同。只不過這裏我們使用from_json時需要在schema外嵌套一層結構ArrayType(schema),這時拿到的是array嵌套結構的json,然後我們在使用explode函數將其展開。代碼如下:

.select(from_json($"value".cast("String"), ArrayType(schema))
.as("jsonArray"))
.select(explode($"jsonArray"))
.select("col.*")

3.3.2 flatmap + case class 處理JSONArray格式數據

上面我們提到使用map + case class 能夠處理JSONObject 格式的數據,同樣的道理,這裏我們可以使用flatmap + case class 處理 JSONArray 格式的數據。具體思路是,重寫上面handleJson 的方法,將json字符串轉爲 Array[clase class]格式,然後傳入flatmap函數。

編寫handleJsonArray方法:

  def handleJsonArray(jsonArray: String): Array[Device] = {
    val gson = new Gson()
    gson.fromJson(jsonArray, classOf[Array[Device]])
  }

使用flatmap函數展開

.select($"value".cast("String")).as[String].flatMap(handleJsonArray)

4 輸出模式

Structured Streaming 提供了三種輸出模式:complete、update、append

  • complete:將整個更新的結果表將寫入外部存儲器
  • update:只有自上次觸發後在結果表中更新的行纔會寫入外部存儲(結果表中更新的行=新增行+更新歷史行)
  • append:自上次觸發後,只有結果表中附加的新行纔會寫入外部存儲器。這僅適用於預計結果表中的現有行不會更改的查詢,因此,這種方式能保證每行數據僅僅輸出一次。例如,帶有Select,where,map,flatmap,filter,join等的query操作支持append模式。

不同類型的Streaming query 支持不同的輸出模式:如下表所示:

4.1 Complete 模式

描述:

complete模式下,會將整個更新的結果表寫出到外部存儲,即會將整張結果表寫出,重點注意"更新"這個詞,這就意味着,使用Complete模式是需要有聚合操作的,因爲在結果表中保存非聚合的數據是沒有意義的,所以,當在沒有聚合的query中使用complete輸出模式,就會報如下錯誤:

可應用Query:

具有聚合操作的Query

Example:

以wordcount爲例

/**
  * 測試輸出模式爲complete
  * 整個更新的結果表將寫入外部存儲器
  * -------------------------------------------
  * 輸入:dog cat
  * 結果:
  * +-----+-----+
  * |value|count|
  * +-----+-----+
  * |dog  |1    |
  * |cat  |1    |
  * +-----+-----+
  * -------------------------------------------
  * 輸入:dog fish
  * 結果:
  * +-----+-----+
  * |value|count|
  * +-----+-----+
  * |dog  |2    |
  * |cat  |1    |
  * |fish |1    |
  * +-----+-----+
  * -------------------------------------------
  * 輸入:cat lion
  * 結果:
  * +-----+-----+
  * |value|count|
  * +-----+-----+
  * |lion |1    |
  * |dog  |2    |
  * |cat  |2    |
  * |fish |1    |
  * +-----+-----+
  * -------------------------------------------
  */

代碼:

/**
  * Created by shirukai on 2019-03-21 15:27
  * Structured Streaming 三種輸出模式的例子
  * 以wordcount爲例
  * socket 命令:nc -lk 9090
  */
object OutputModeExample {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder()
      .appName(this.getClass.getSimpleName)
      .master("local[2]")
      .getOrCreate()

    val line = spark.readStream
      .format("socket")
      .option("host", "localhost")
      .option("port", 9090)
      .load()
    import spark.implicits._
    import org.apache.spark.sql.functions._
    val words = line.as[String].flatMap(_.split(" "))

    /**
      * 測試輸出模式爲complete
      * 整個更新的結果表將寫入外部存儲器
      * -------------------------------------------
      * 輸入:dog cat
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |dog  |1    |
      * |cat  |1    |
      * +-----+-----+
      * -------------------------------------------
      * 輸入:dog fish
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |dog  |2    |
      * |cat  |1    |
      * |fish |1    |
      * +-----+-----+
      * -------------------------------------------
      * 輸入:cat lion
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |lion |1    |
      * |dog  |2    |
      * |cat  |2    |
      * |fish |1    |
      * +-----+-----+
      * -------------------------------------------
      */
       val completeQuery = words.groupBy("value").count().writeStream
          .outputMode(OutputMode.Complete())
          .format("console")
          .option("truncate", value = false)
          .start()
       completeQuery.awaitTermination()

  }
}

4.2 Update模式

描述:

update模式下,會將自上次觸發後在結果表中更新的行寫入外部存儲(結果表中更新的行=新增行+更新歷史行)

可應用Query:

所有的query

Example:

    /**
      * 測試輸出模式爲:update
      * 只有自上次觸發後在結果表中更新的行纔會寫入外部存儲(結果表中更新的行=新增行+更新歷史行)
      * -------------------------------------------
      * 輸入:dog cat
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |dog  |1    |
      * |cat  |1    |
      * +-----+-----+
      * -------------------------------------------
      * 輸入:dog fish
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |dog  |2    |
      * |fish |1    |
      * +-----+-----+
      * -------------------------------------------
      * 輸入:cat lion
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |lion |1    |
      * |cat  |2    |
      * +-----+-----+
      * -------------------------------------------
      */

代碼:

package com.hollysys.spark.structured.usecase

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode

/**
  * Created by shirukai on 2019-03-21 15:27
  * Structured Streaming 三種輸出模式的例子
  * 以wordcount爲例
  * socket 命令:nc -lk 9090
  */
object OutputModeExample {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder()
      .appName(this.getClass.getSimpleName)
      .master("local[2]")
      .getOrCreate()

    val line = spark.readStream
      .format("socket")
      .option("host", "localhost")
      .option("port", 9090)
      .load()
    import spark.implicits._
    import org.apache.spark.sql.functions._
    val words = line.as[String].flatMap(_.split(" "))
    /**
      * 測試輸出模式爲:update
      * 只有自上次觸發後在結果表中更新的行纔會寫入外部存儲(結果表中更新的行=新增行+更新歷史行)
      * -------------------------------------------
      * 輸入:dog cat
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |dog  |1    |
      * |cat  |1    |
      * +-----+-----+
      * -------------------------------------------
      * 輸入:dog fish
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |dog  |2    |
      * |fish |1    |
      * +-----+-----+
      * -------------------------------------------
      * 輸入:cat lion
      * 結果:
      * +-----+-----+
      * |value|count|
      * +-----+-----+
      * |lion |1    |
      * |cat  |2    |
      * +-----+-----+
      * -------------------------------------------
      */
    val updateQuery = words.groupBy("value").count().writeStream
      .outputMode(OutputMoade.Update())
      .format("console")
      .option("truncate", value = false)
      .start()
    updateQuery.awaitTermination()
}

4.3 Append 模式

描述:

此模式下,會將上次觸發後,結果表中附加的新行寫入外部存儲器。這僅適用於預計結果表中的現有行不會更改的查詢,因此,這種方式能保證每行數據僅僅輸出一次。例如,帶有Select,where,map,flatmap,filter,join等的query操作支持append模式。支持聚合操作下使用Append模式,但是要求聚合操作必須設置Watermark,否則會報如下錯誤:

可應用Query:

帶Watermark的聚合操作

flatMapGroupWithState函數之後使用聚合操作

非聚合操作

Example:

package com.hollysys.spark.structured.usecase

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode

/**
  * Created by shirukai on 2019-03-21 15:27
  * Structured Streaming 三種輸出模式的例子
  * 以wordcount爲例
  * socket 命令:nc -lk 9090
  */
object OutputModeExample {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder()
      .appName(this.getClass.getSimpleName)
      .master("local[2]")
      .getOrCreate()

    val line = spark.readStream
      .format("socket")
      .option("host", "localhost")
      .option("port", 9090)
      .load()
    import spark.implicits._
    import org.apache.spark.sql.functions._
    val words = line.as[String].flatMap(_.split(" "))
    /**
      * 測試輸出模式爲:append
      * 自上次觸發後,只有結果表中附加的新行纔會寫入外部存儲器。這僅適用於預計結果表中的現有行不會更改的查詢
      * -------------------------------------------
      * 輸入:dog cat
      * 結果:
      * +-----+
      * |value|
      * +-----+
      * |dog  |
      * |cat  |
      * +-----+
      * -------------------------------------------
      * 輸入:dog fish
      * 結果:
      * +-----+
      * |value|
      * +-----+
      * |dog  |
      * |fish |
      * +-----+
      * -------------------------------------------
      * 輸入:cat lion
      * 結果:
      * +-----+
      * |value|
      * +-----+
      * |cat  |
      * |lion |
      * +-----+
      * -------------------------------------------
      */
    val appendQuery = words.writeStream
      .outputMode(OutputMode.Complete())
      .format("console")
      .option("truncate", value = false)
      .start()
    appendQuery.awaitTermination()
}

5基於事件時間的窗口操作

使用Structured Streaming 基於時間的滑動窗口的聚合操作是很簡單的,使用window()函數即可,很像分組聚合。在一個分組聚合操作中,聚合值被唯一保存在用戶指定的列中。在基於窗口的聚合情況下,對於行的事件時間的每個窗口,維護聚合指。

5.1 窗口函數的簡單使用:對十分鐘的數據進行WordCount

在前面的輸出模式的例子中,我們使用了wordcount進行演示。現在有這樣的一個需求:要求我們以10分鐘爲窗口,且每5分鐘滑動一次來進行10分鐘內的詞頻統計。如下圖所示,此圖是官網的改進版,按照實際輸出畫圖。由於使用的輸出模式是Complete,會將完整的結果表輸出。很容易理解,12:05時觸發計算,計算12:02 和12:03來的兩條數據,因爲我們的窗口爲滑動窗口,10分鐘窗口大小,5分鐘滑動一次,所以Spark會爲每條數據劃分兩個窗口,結果如圖所示。關於窗口的劃分,後面會單獨解釋。

下面將使用具體的程序,進行演示。

package com.hollysys.spark.structured.usecase

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode
import org.apache.spark.sql.types.TimestampType

/**
  * Created by shirukai on 2019-03-22 14:17
  * Structured Streaming 窗口函數操作例子
  * 基於事件時間的wordcount
  * socket:nc -lk 9090
  */
object WindowOptionExample {
  def main(args: Array[String]): Unit = {

    val spark = SparkSession
      .builder()
      .appName(this.getClass.getSimpleName)
      .master("local[2]")
      .getOrCreate()

    // Read stream form socket,The data format is: 1553235690:dog cat|1553235691:cat fish
    val lines = spark.readStream
      .format("socket")
      .option("host", "localhost")
      .option("port", 9090)
      .load()

    import spark.implicits._
    import org.apache.spark.sql.functions._

    // Transform socket lines to DataFrame of schema { timestamp: Timestamp, word: String }
    val count = lines.as[String].flatMap(line => {
      val lineSplits = line.split("[|]")
      lineSplits.flatMap(item => {
        val itemSplits = item.split(":")
        val t = itemSplits(0).toLong
        itemSplits(1).split(" ").map(word => (t, word))
      })
    }).toDF("time", "word")
      .select($"time".cast(TimestampType), $"word")
      // Group the data by window and word and compute the count of each group
      .groupBy(
      window($"time", "10 minutes", "5 minutes"),
      $"word"
    ).count()

    val query = count.writeStream
      .outputMode(OutputMode.Complete())
      .format("console")
      .option("truncate", value = false)
      .start()

    query.awaitTermination()
  }
}

上面的程序,是模擬數據處理,從socket裏讀取1553235690:dog cat|1553235691:cat owl格式的數據,然後經過轉換,轉成schema爲{ timestamp: Timestamp, word: String }的DataFrame,然後使用window函數進行窗口劃分,再使用groupBy進行聚合count,最後將結果輸出到控制檯,輸出模式爲Complete。

通過nc -lk 9090向socket發送數據

發送:1553227320:cat dog|1553227380:dog dog

結果:

-------------------------------------------
Batch: 0
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-03-22 11:55:00, 2019-03-22 12:05:00]|cat |1    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|cat |1    |
|[2019-03-22 11:55:00, 2019-03-22 12:05:00]|dog |3    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|dog |3    |
+------------------------------------------+----+-----+

發送:1553227620:owl cat

結果:

-------------------------------------------
Batch: 1
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-03-22 11:55:00, 2019-03-22 12:05:00]|cat |1    |
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|cat |1    |
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|owl |1    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|owl |1    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|cat |2    |
|[2019-03-22 11:55:00, 2019-03-22 12:05:00]|dog |3    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|dog |3    |
+------------------------------------------+----+-----+

發送:1553227860:dog|1553227980:owl

結果:

-------------------------------------------
Batch: 2
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-03-22 11:55:00, 2019-03-22 12:05:00]|cat |1    |
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|dog |1    |
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|cat |1    |
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|owl |2    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|owl |1    |
|[2019-03-22 12:10:00, 2019-03-22 12:20:00]|owl |1    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|cat |2    |
|[2019-03-22 12:10:00, 2019-03-22 12:20:00]|dog |1    |
|[2019-03-22 11:55:00, 2019-03-22 12:05:00]|dog |3    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|dog |3    |
+------------------------------------------+----+-----+

5.2 窗口劃分:window()函數

上面基於窗口的詞頻統計例子中,我們接觸到了window()函數,這是spark的內置函數,具體底層實現,沒要找到的源碼位置,如有人知曉,煩請指點。我們可以看到在org.apache.spark.sql.functions中的window函數解析成表達式之前的函數定義:

  /**
    * window
    *
    * @param timeColumn     事件時間所在的列
    * @param windowDuration 窗口間隔 字符串表達式:"10 seconds" or "10 minutes"等
    * @param slideDuration  滑動間隔 字符串表達式:"10 seconds" or "10 minutes"等
    * @return Column
    */  
 def window(timeColumn: Column, windowDuration: String, slideDuration: String): Column = {
    window(timeColumn, windowDuration, slideDuration, "0 second")
  }

5.2.1 簡單例子

這裏通過一個簡單的例子,來演示一下window()函數的作用。

我們對上面的WordCount的例子做一下改變,不去groupBy(window()),直接select來看一下window()的效果。

    // Transform socket lines to DataFrame of schema { timestamp: Timestamp, word: String }
    val count = lines.as[String].flatMap(line => {
      val lineSplits = line.split("[|]")
      lineSplits.flatMap(item => {
        val itemSplits = item.split(":")
        val t = itemSplits(0).toLong
        itemSplits(1).split(" ").map(word => (t, word))
      })
    }).toDF("time", "word")
      .select($"time".cast(TimestampType), $"word")
      .select(
      window($"time", "10 minutes", "5 minutes"),
      $"word",
      $"time"
    )

如上,我們以10分鐘爲一個窗口,5分鐘滑動一次,這時輸入一個2019-03-23 13:58:24時間的數據,會產生幾個窗口呢?

輸入:1553320704:cat

結果:如下所示產生了兩個窗口,13:50-14:00 和13:55-14:05

-------------------------------------------
Batch: 1
-------------------------------------------
+------------------------------------------+----+-------------------+
|window                                    |word|time               |
+------------------------------------------+----+-------------------+
|[2019-03-23 13:50:00, 2019-03-23 14:00:00]|cat |2019-03-23 13:58:24|
|[2019-03-23 13:55:00, 2019-03-23 14:05:00]|cat |2019-03-23 13:58:24|
+------------------------------------------+----+-------------------+

如果我同樣設置10分鐘的窗口,3分鐘滑動一次,同樣輸入2019-03-23 13:58:24時間的數據,這時會產生幾個窗口呢?

輸入:1553320704:cat

結果:如下所示產生了三個窗口

-------------------------------------------
Batch: 0
-------------------------------------------
+------------------------------------------+----+-------------------+
|window                                    |word|time               |
+------------------------------------------+----+-------------------+
|[2019-03-23 13:51:00, 2019-03-23 14:01:00]|cat |2019-03-23 13:58:24|
|[2019-03-23 13:54:00, 2019-03-23 14:04:00]|cat |2019-03-23 13:58:24|
|[2019-03-23 13:57:00, 2019-03-23 14:07:00]|cat |2019-03-23 13:58:24|
+------------------------------------------+----+-------------------+

如果我同樣設置10分鐘的窗口,2分鐘滑動一次,同樣輸入2019-03-23 13:58:24時間的數據,這時會產生幾個窗口呢?

輸入:1553320704:cat

結果:如下所示產生了五個窗口

-------------------------------------------
Batch: 0
-------------------------------------------
+------------------------------------------+----+-------------------+
|window                                    |word|time               |
+------------------------------------------+----+-------------------+
|[2019-03-23 13:50:00, 2019-03-23 14:00:00]|cat |2019-03-23 13:58:24|
|[2019-03-23 13:52:00, 2019-03-23 14:02:00]|cat |2019-03-23 13:58:24|
|[2019-03-23 13:54:00, 2019-03-23 14:04:00]|cat |2019-03-23 13:58:24|
|[2019-03-23 13:56:00, 2019-03-23 14:06:00]|cat |2019-03-23 13:58:24|
|[2019-03-23 13:58:00, 2019-03-23 14:08:00]|cat |2019-03-23 13:58:24|
+------------------------------------------+----+-------------------+

5.2.3 窗口劃分邏輯

上面例子中,我們可以使用簡單的窗口函數,根據不同的窗口大小和滑動間隔劃分出不同的窗口,那麼具體Spark是如何劃分窗口的呢?如下圖所示,我們以窗口大小爲10分鐘,滑動間隔爲5分爲例,進行窗口劃分。

通過上面的圖我們可以看出,當00:12這個時間點來了一條數據之後,它會落在00:05-00:15和00:10-00:20這兩個窗口裏,同樣,當00:28來的數據會落到00:20-00:30和00:25-00:35這兩個窗口。對於這個例子來說,每一條數據都會落到兩個窗口裏,那麼,關於具體落到哪個窗口裏是如何計算的呢?我們可以查看Spark window函數的實現,源碼位置在org.apache.spark.sql.catalyst.analysis包下的SimpleAnalyzer,查看TimeWindowing的實現,註釋裏說的很清楚。

大體步驟如下

1 首先計算一個窗口跨度裏最多有多少個重疊窗口

maxNumOverlapping = ceil(windowDuration / slideDuration)

使用窗口跨度除以滑動間隔向上取整,即可以得到最大重疊窗口個數,如windowDuration=10,slideDuration=5,則maxNumOverlapping=2;windowDuration=10,slideDuration=3,則maxNumOverlapping=4。

val overlappingWindows =
math.ceil(window.windowDuration * 1.0 / window.slideDuration).toInt

2 計算當前時間落入的距離窗口開始時間最近的窗口ID

windowId = ceil((timestamp - startTime) / slideDuration)

startTime這裏指的是整個時間維度的開始時間,默認爲0,即時間戳的0

使用當前時間減去開始時間startTime然後除以窗口跨度向上取整就能求出窗口ID來,這個ID表示,從startTime開始,一共劃分了多少個窗口。

val division = (PreciseTimestampConversion(
window.timeColumn, TimestampType, LongType) - window.startTime) / window.slideDuration
val ceil = Ceil(division)

3 按最多的情況窗口劃分

上面第一步中,我們計算出了一個窗口跨度裏最多有多少個重疊窗口,這個有什麼意義呢?其實它可以表示,一條數據,最多可以落到多少個窗口裏,注意這裏是最多,有些情況下,一條數據可能落不到計算的最多窗口裏。比如說,當我窗口時間設置爲10分鐘,窗口跨度設置爲3分鐘的時候,這個時候計算出最多重疊個數爲4,大多數情況下爲3,如下圖所示:

所以這裏Spark先按照最多的情況劃分窗口

  val windows =
            Seq.tabulate(overlappingWindows)(i => getWindow(i, overlappingWindows))

4 過濾不符合要求的窗口

通過上面的圖,我們可以看出,如果我們按照最多重疊個數來劃分窗口,數據會落到不正確的窗口中,如果我們 劃分00:11:10這條數據,它會落到:00:00-00:10、00:03-00:13、00:06-00:16、00:09-00:19這四個窗口中,顯然,00:00-00:10這個窗口是個錯誤的窗口,不需要出現,所以這裏Spark又對窗口進行了一次過濾。

val filterExpr =
    window.timeColumn >= windowAttr.getField(WINDOW_START) &&
    window.timeColumn < windowAttr.getField(WINDOW_END)

通過上面四個步驟,spark很容易將我們的數據,劃分到不同的窗口中,從而實現了窗口計算。

下面我按照源碼思路,簡單的實現了一個窗口劃分的Demo,代碼如下:

package com.hollysys.spark.structured.usecase

import java.util.Date

import org.apache.commons.lang3.time.FastDateFormat
import org.apache.spark.unsafe.types.CalendarInterval

/**
  * Created by shirukai on 2019-03-23 11:29
  * 官方源碼window()函數實現
  *
  *
  * The windows are calculated as below:
  * maxNumOverlapping <- ceil(windowDuration / slideDuration)
  * for (i <- 0 until maxNumOverlapping)
  * windowId <- ceil((timestamp - startTime) / slideDuration)
  * windowStart <- windowId * slideDuration + (i - maxNumOverlapping) * slideDuration + startTime
  * windowEnd <- windowStart + windowDuration
  * return windowStart, windowEnd
  */
object WindowSourceFunctionExample {
  val TARGET_FORMAT: FastDateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss")


  /**
    * 計算窗口ID
    *
    * @param t 事件時間
    * @param s 滑動間隔
    * @return id
    */
  def calculateWindowId(t: Long, s: Long): Int = Math.ceil(t.toDouble / s.toDouble).toInt

  /**
    * 計算窗口開始時間
    *
    * @param windowId          窗口ID
    * @param s                 滑動間隔
    * @param maxNumOverlapping 最大重疊窗口個數
    * @param numOverlapping    當前重疊數
    * @return start
    */
  def calculateWindowStart(windowId: Int, s: Long, maxNumOverlapping: Int, numOverlapping: Int): Long =
    windowId * s + (numOverlapping - maxNumOverlapping) * s

  /**
    * 計算窗口結束時間
    *
    * @param start 開始時間
    * @param w     窗口大小
    * @return end
    */
  def calculateWindowEnd(start: Long, w: Long): Long = start + w

  /**
    * 計算最多的窗口重疊個數
    * 思路:當前事件時間到所在窗口的結束時間 / 滑動間隔 向上取整,即是窗口個數
    *
    * @param w 窗口間隔
    * @param s 滑動間隔
    * @return 窗口個數
    */
  def calculateMaxNumOverlapping(w: Long, s: Long): Int = Math.ceil(w.toDouble / s.toDouble).toInt

  /**
    * 模擬計算某個時間的窗口
    *
    * @param eventTime      事件時間 毫秒級時間戳
    * @param windowDuration 窗口間隔 字符串表達式:"10 seconds" or "10 minutes"
    * @param slideDuration  滑動間隔 字符串表達式:"10 seconds" or "10 minutes"
    * @return List
    */
  def window(eventTime: Long, windowDuration: String, slideDuration: String): List[String] = {

    // Format window`s interval by CalendarInterval, e.g. "10 seconds" => "10000"
    val windowInterval = CalendarInterval.fromString(s"interval $windowDuration").milliseconds()

    // Format slide`s interval by CalendarInterval, e.g. "10 seconds" => "10000"
    val slideInterval = CalendarInterval.fromString(s"interval $slideDuration").milliseconds()

    if (slideInterval > windowInterval) throw
      new RuntimeException(s"The slide duration ($slideInterval) must be less than or equal to the windowDuration ($windowInterval).")

    val maxNumOverlapping = calculateMaxNumOverlapping(windowInterval, slideInterval)

    val windowId = calculateWindowId(eventTime, slideInterval)

    List.tabulate(maxNumOverlapping)(x => {
      val start = calculateWindowStart(windowId, slideInterval, maxNumOverlapping, x)
      val end = calculateWindowEnd(start, windowInterval)
      (start, end)
    }).filter(x => x._1 < eventTime && x._2 > eventTime)
      .map(x =>
        s"[${TARGET_FORMAT.format(new Date(x._1))}, ${TARGET_FORMAT.format(new Date(x._2))}]"
      )
  }

  def main(args: Array[String]): Unit = {
    window(1553320704000L, "10 minutes", "2 minutes").foreach(println)
  }
}

之前沒看找到源碼前,一開始沒想明白Spark是如何實現的窗口劃分。後來按照自己的邏輯實現了一版窗口劃分,與源碼實現有異曲同工之處,但是還是有不少出入,下面貼一下我的思路,我的思路大體有三步:

1 計算事件時間最近的窗口的開始時間

windowStartTime = timestamp - (timestamp % slideDuration)

默認以0爲第一個窗口的開始時間,滑動時間爲s

第二個窗口的開始時間:0+s

第三個窗口的開始時間:0+2s

第四個窗口的開始時間:0+3s,第n個窗口的開始時間:(n-1)s

設時間t落在第n個窗口,根據上面的公式,t所在的窗口開始時間爲:startTime_n = (n-1)s

再設時間t距離所在窗口開始時間爲x,那麼窗口開始時間也可以表示爲:startTime_n = t-x

由上面兩個式子可以得出:

t-x = (n-1)s

n-1 = (t-x)/s

n-1爲整數,由上面式子可以得出,x = t % s

所以:startTime_n = t - (t % s)

def calculateWindowStartTime(t: Long, s: Long): Long = t - (t % s)

2 精確計算產生的窗口個數

windowNumber =ceil ((windowDuration - (timestamp % slideDuration)) / slideDuration)

當前事件時間到所在窗口的結束時間 / 滑動間隔 向上取整,即是窗口個數

 def calculateWindowNumber(t: Long, w: Long, s: Long): Int = ((w - (t % s) + (s - 1)) / s).toInt

3 劃分窗口

計算出最後一個窗口的開始時間,根據窗口跨度可以計算出最後一個窗口,根據窗口個數和滑動間隔,向前可以計算出所有的窗口

List.tabulate(windowNumber)(x => {
  val start = windowStartTime - x * slideInterval
  val end = start + windowInterval
  s"[${TARGET_FORMAT.format(new Date(start))}, ${TARGET_FORMAT.format(new Date(end))}]"
}).reverse

完整代碼:

package com.hollysys.spark.structured.usecase

import java.util.Date

import org.apache.commons.lang3.time.FastDateFormat
import org.apache.spark.unsafe.types.CalendarInterval

/**
  * Created by shirukai on 2019-03-23 11:29
  * 模擬window()函數實現
  */
object WindowMockFunctionExample {
  val TARGET_FORMAT: FastDateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss")

  /**
    * 計算最近窗口開始時間
    * 思路:
    * 默認以0爲第一個窗口的開始時間,滑動時間爲s
    * 第二個窗口的開始時間:0+s
    * 第三個窗口的開始時間:0+2s
    * 第四個窗口的開始時間:0+3s
    * ……
    * 第n個窗口的開始時間:(n-1)s
    *
    * 設時間t落在第n個窗口,根據上面的公式,t所在的窗口開始時間爲:startTime_n = (n-1)s
    * 再設時間t距離所在窗口開始時間爲x,那麼窗口開始時間也可以表示爲:startTime_n = t-x
    * t-x = (n-1)s
    * n-1 = (t-x)/s
    * n-1爲整數,由上面式子可以得出,x = t % s
    * 所以:startTime_n = t - (t % s)
    *
    * @param t 事件時間
    * @param s 滑動間隔
    * @return 窗口開始時間
    */
  def calculateWindowStartTime(t: Long, s: Long): Long = t - (t % s)

  /**
    * 計算窗口個數
    * 思路:當前事件時間到所在窗口的結束時間 / 滑動間隔 向上取整,即是窗口個數
    * @param w 窗口間隔
    * @param s 滑動間隔
    * @return 窗口個數
    */
  def calculateWindowNumber(t: Long, w: Long, s: Long): Int = ((w - (t % s) + (s - 1)) / s).toInt

  /**
    * 模擬計算某個時間的窗口
    *
    * @param eventTime      事件時間 毫秒級時間戳
    * @param windowDuration 窗口間隔 字符串表達式:"10 seconds" or "10 minutes"
    * @param slideDuration  滑動間隔 字符串表達式:"10 seconds" or "10 minutes"
    * @return List
    */
  def window(eventTime: Long, windowDuration: String, slideDuration: String): List[String] = {

    // Format window`s interval by CalendarInterval, e.g. "10 seconds" => "10000"
    val windowInterval = CalendarInterval.fromString(s"interval $windowDuration").milliseconds()

    // Format slide`s interval by CalendarInterval, e.g. "10 seconds" => "10000"
    val slideInterval = CalendarInterval.fromString(s"interval $slideDuration").milliseconds()

    if (slideInterval > windowInterval) throw
      new RuntimeException(s"The slide duration ($slideInterval) must be less than or equal to the windowDuration ($windowInterval).")

    val windowStartTime = calculateWindowStartTime(eventTime, slideInterval)
    val windowNumber = calculateWindowNumber(eventTime,windowInterval, slideInterval)

    List.tabulate(windowNumber)(x => {
      val start = windowStartTime - x * slideInterval
      val end = start + windowInterval
      s"[${TARGET_FORMAT.format(new Date(start))}, ${TARGET_FORMAT.format(new Date(end))}]"
    }).reverse
  }

  def main(args: Array[String]): Unit = {
    window(1553320704000L, "10 minutes", "3 minutes").foreach(println)
  }

}

6 處理延遲數據和水位設置

對於業務中的延遲數據,我們該如何處理呢?同樣是wordCount的例子,假如在12:11分時,接收到了12:04分的數據,那麼Spark是如何處理的呢?這裏爲了方便理解,將上面的wordcount例子輸出模式改爲Update.

    val query = count.writeStream
      .outputMode(OutputMode.Update())
      .format("console")
      .option("truncate", value = false)
      .start()

通過上面的圖可以看出,Spark會我們保存歷史狀態,當遇到延遲數據後仍然會根據狀態進行計算。但是,如果我們要持續執行這個Query,系統必須限制其積累的內存中間狀態的數量。這意味着系統要知道何時可以從內存狀態中刪除舊聚合,因爲應用程序不會再爲該聚合接收到較晚的數據。爲了實現這一點,在Spark2.1之後,引入了watermark,使得引擎可以自動跟蹤數據中的當前事件時間,並嘗試相應地清除舊狀態。假如我們設置watermark的延遲閾值爲10分鐘,上一次Trigger中事件最大時間爲12:15,那麼本次Trigger中,12:05之前的數據將不會被計算。這裏我們繼續進行代碼演示,上面我們修改了之前的wordcount例子的輸出模式爲update,因爲使用complete模式,watermark是不生效的,也就是說complete會一直保存聚合狀態。現在我們對程序設置watermark,並進行測試。

.withWatermark("time", "10 minutes")
// Group the data by window and word and compute the count of each group
.groupBy(
  window($"time", "10 minutes", "5 minutes"),
  $"word"
).count()

如上圖所示爲輸出模式爲Update,窗口時間爲10分鐘,滑動間隔爲5分鐘,watermark閾值設置爲10分鐘的計算過程。X軸表示trigger時間即觸發計算的時間,Y軸表示事件時間,即數據產生的時間。藍色虛線表示最大事件時間,紅色時間爲watermark時間。在紅色線和藍色虛線之間的數據會被計算,之外的數據所在的部分窗口或者全部窗口將會被丟棄。如12:25時,由於上一次trigger計算的watermark爲12:06,表示11:55-12:05之前的窗口將會被丟棄,此時來了一條12:04的延遲數據,所以這條數據在11:55-12:05的窗口會被丟棄,但是在12:00-12:10的窗口仍然會參與計算。同理,12:30時,上一次trigger計算的watermark爲12:11,表示12:00-12:10分之前的窗口將會被丟棄,此時來了也一條12:04的延遲數據,因爲12:04的數據會落到11:55-12:05和12:00-12:10這兩個窗口,都在watermark之前,所以這兩個窗口都會被丟棄,該數據不參與計算。下面以Trigger順序進行分析。

12:05

由於沒有數據,此次計算爲空。

12:10

此時接收到12:06 dog、12:08 owl 兩條數據,最大事件時間爲12:08,因爲我們設置watermark的delayThreshold=10min分鐘,所以計算下一個trigger的watermark=12:08-10min = 11:58,因爲程序是從12:00開啓的,所以這裏Spark認爲不會有延遲數據,就沒有畫出watermark爲11:58分的線。

12:15

此時接收到12:14 dog和一條延遲的數據12:09 cat,最大事件時間爲12:14,上一次計算的wm爲11:58所以沒有數據需要被丟棄。計算下一個trigger的watermark=12:14-10min = 12:04。

12:20

此時接收到12:16 cat、延遲數據12:08 dog 、延遲數據12:13 owl,上一次計算的wm爲12:04,沒有窗口被丟棄。此次最大事件時間爲12:16,計算wm=12:16-10min=12:06。

12:25

此時接收到12:21 owl、延遲數據12:17 owl、延遲數據12:04 fish,上一次計算wm=12:06,所以11:55-12:05的窗口數據將會被丟棄。12:04的數據有一個窗口是落在了11:55-12:05,所以這個窗口中間狀態將會被丟棄,但是12:04在12:00-12:10的窗口將繼續參與計算。計算下一個trigger的wm =12:21-10min=12:11。

12:30

此時接收到12:27 owl、延遲數據12:04 lion,上一次計算的wm爲12:11,所以在12:00-12:10之前的窗口中間狀態將會被丟棄。12:04數據的窗口都在12:00-12:10之前,所以該條數據所有窗口都不會參與計算,計算下一個trigger的wm=12:27-10min=12:17。

……

代碼示例:

package com.hollysys.spark.structured.usecase

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode
import org.apache.spark.sql.types.TimestampType

/**
  * Created by shirukai on 2019-03-22 14:17
  * Structured Streaming 窗口函數操作例子
  * 基於事件時間的wordcount,設置watermark
  * socket:nc -lk 9090
  */
object WindowOptionWithWatermarkExample {
  def main(args: Array[String]): Unit = {

    val spark = SparkSession
      .builder()
      .appName(this.getClass.getSimpleName)
      .master("local[2]")
      .getOrCreate()

    // Read stream form socket,The data format is: 1553235690:dog cat|1553235691:cat fish
    val lines = spark.readStream
      .format("socket")
      .option("host", "localhost")
      .option("port", 9090)
      .load()

    import org.apache.spark.sql.functions._
    import spark.implicits._

    // Transform socket lines to DataFrame of schema { timestamp: Timestamp, word: String }
    val count = lines.as[String].flatMap(line => {
      val lineSplits = line.split("[|]")
      lineSplits.flatMap(item => {
        val itemSplits = item.split(":")
        val t = itemSplits(0).toLong
        itemSplits(1).split(" ").map(word => (t, word))
      })
    }).toDF("time", "word")
      .select($"time".cast(TimestampType), $"word")
      .withWatermark("time", "10 minutes")
      // Group the data by window and word and compute the count of each group
      .groupBy(
      window($"time", "10 minutes", "5 minutes"),
      $"word"
    ).count()
    val query = count.writeStream
      .outputMode(OutputMode.Update())
      .format("console")
      .option("truncate", value = false)
      .start()
    query.awaitTermination()
  }
}
/*
輸入:1553227560:dog|1553227680:owl
-------------------------------------------
Batch: 0
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|dog |1    |
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|owl |1    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|owl |1    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|dog |1    |
+------------------------------------------+----+-----+


輸入:1553227740:cat|1553228040:dog
-------------------------------------------
Batch: 1
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|dog |2    |
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|cat |1    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|cat |1    |
|[2019-03-22 12:10:00, 2019-03-22 12:20:00]|dog |1    |
+------------------------------------------+----+-----+


輸入:1553228160:cat|1553227680:dog|1553227980:owl
-------------------------------------------
Batch: 2
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|dog |3    |
|[2019-03-22 12:05:00, 2019-03-22 12:15:00]|owl |2    |
|[2019-03-22 12:15:00, 2019-03-22 12:25:00]|cat |1    |
|[2019-03-22 12:10:00, 2019-03-22 12:20:00]|owl |1    |
|[2019-03-22 12:10:00, 2019-03-22 12:20:00]|cat |1    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|dog |2    |
+------------------------------------------+----+-----+

輸入:1553228460:owl|1553227440:fish|1553228220:owl
-------------------------------------------
Batch: 3
-------------------------------------------
+------------------------------------------+------+-----+
|window                                    |word  |count|
+------------------------------------------+------+-----+
|[2019-03-22 12:20:00, 2019-03-22 12:30:00]|owl   |1    |
|[2019-03-22 12:10:00, 2019-03-22 12:20:00]|owl   |2    |
|[2019-03-22 12:00:00, 2019-03-22 12:10:00]|fish  |1    |
|[2019-03-22 12:15:00, 2019-03-22 12:25:00]|owl   |2    |
+------------------------------------------+------+-----+

輸入:1553227440:lion|1553228820:owl
-------------------------------------------
Batch: 4
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-03-22 12:25:00, 2019-03-22 12:35:00]|owl |1    |
|[2019-03-22 12:20:00, 2019-03-22 12:30:00]|owl |2    |
+------------------------------------------+----+-----+
 */

上面講解了在Update模式下,設置watermark之後,我們的聚合操作對延遲數據的處理。下面,我們來看一下在Append模式下,設置watermark之後,聚合操作是如何作用的?

首先,將上面的代碼稍作修改,將輸出模式改爲Append

    val query = count.writeStream
      .outputMode(OutputMode.Append())
      .format("console")
      .option("truncate", value = false)
      .start()

如上圖所示爲Append模式下,設置watermark的聚合操作。與update模式不同,append模式不會立即輸出結果集,而是等到設置的watermark下,再沒有數據更新的情況下再輸出到結果集。12:00到12:25 這段時間,12:00-12:10這個窗口的數據一直可能被更新,所以沒有結果集輸出。12:30時的trigger,由於上一個trigger計算的wm=12:11,所以12:00-12:10窗口的數據將會拒之門外,12:04 lion不會參與計算,因此12:00-12:10的窗口,不會被更新,最後12:00-12:10的中間狀態將會被輸出到結果集,同時中間狀態將會被丟棄。

Watermark清理聚合狀態的條件需要重點注意,爲了清理聚合狀態(從Spark2.1.1開始,將來會更改),必須滿足以下條件:

A) 輸出模式必須是Append或者Update,Complete模式要求保留所有聚合數據,因此不能使用watermark來中斷狀態。

B) 聚合必須具有時間時間列活事件時間列上的窗口

C) 必須在聚合中使用的時間戳列相同的列上調用withWatermark。例如:df.withWatermark(“time”, “1 min”).groupBy(“time2”).count() 是在Append模式下是無效的,因爲watermark定義的列和聚合的列不一致。

D) 必須在聚合之前調用withWatermark 才能使用watermark 細節。例如,在附加輸出模式下,df.groupBy(“time”).count().withWatermark(“time”,”1 min”)無效。

7 動態更新參數

在使用StructuredStreaming的時候,我們可能會遇到在不重啓Spark應用的情況下動態的更新參數,如:動態更新某個過濾條件、動態更新分區數量、動態更新join的靜態數據等。在工作中,遇到了一個應用場景,是實時數據與靜態DataFrame去Join,然後做一些處理,但是這個靜態DataFrame偶爾會發生變化,要求在不重啓Spark應用的前提下去動態更新。目前總結了兩種解決方案,詳細可以閱讀我的《StructuredStreaming動態更新參數》這篇文章。

8 有狀態計算函數

爲保證多個Batch之間能夠進行有狀態的計算,SparkStreaming在1.6版本之前就引入了updateStateByKey的狀態管理機制,在1.6之後又引入了mapWithState的狀態管理機制。StructuredStreaming原本就是有狀態的計算,這裏我主要記錄一下在StructuredStreaming裏可以自定義狀態操作的算子。詳細內容可以閱讀我的《StructuredStreaming有狀態聚合》這篇文章。

9 提交應用到Yarn集羣與參數調優

10 遇到的錯誤與異常處理

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