基于 Watermark 处理延迟数据

在数据分析系统中, Structured Streaming 可以持续的按照 event-time 聚合数据, 然而在此过程中并不能保证数据按照时间的先后依次到达. 例如: 当前接收的某一条数据的 event-time 可能远远早于之前已经处理过的 event-time. 在发生这种情况时, 往往需要结合业务需求对延迟数据进行过滤.

现在考虑如果事件延迟到达会有哪些影响. 假如, 一个单词在 12:04(event-time) 产生, 在 12:11 到达应用. 应用应该使用 12:04 来在窗口(12:00 - 12:10)中更新计数, 而不是使用 12:11. 这些情况在我们基于窗口的聚合中是自然发生的, 因为结构化流可以长时间维持部分聚合的中间状态

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gYsvLZTy-1666402864047)(Structured Streaming.assets/1565742945.png)]

但是, 如果这个查询运行数天, 系统很有必要限制内存中累积的中间状态的数量. 这意味着系统需要知道何时从内存状态中删除旧聚合, 因为应用不再接受该聚合的后期数据.

为了实现这个需求, 从 spark2.1, 引入了 watermark(水印), 使用引擎可以自动的跟踪当前的事件时间, 并据此尝试删除旧状态.

通过指定 event-time 列和预估事件的延迟时间上限来定义一个查询的 watermark. 针对一个以时间 T 结束的窗口, 引擎会保留状态和允许延迟时间直到(max event time seen by the engine - late threshold > T). 换句话说, 延迟时间在上限内的被聚合, 延迟时间超出上限的开始被丢弃.

可以通过withWatermark() 来定义watermark

watermark 计算: watermark = MaxEventTime - Threshhod

而且, watermark只能逐渐增加, 不能减少

总结:

Structured Streaming 引入 Watermark 机制, 主要是为了解决以下两个问题:

  1. 处理聚合中的延迟数据
  2. 减少内存中维护的聚合状态.

在不同输出模式(complete, append, update)中, Watermark 会产生不同的影响.

complete模式下使用 watermark

package com.strive.ss

import java.sql.Timestamp

import org.apache.spark.sql._
import org.apache.spark.sql.streaming.{StreamingQuery, Trigger}

object WordCountWatermark1 {
    def main(args: Array[String]): Unit = {

        val spark: SparkSession = SparkSession
            .builder()
            .master("local[*]")
            .appName("WordCountWatermark1")
            .getOrCreate()

        import spark.implicits._
        val lines: DataFrame = spark.readStream
            .format("socket")
            .option("host", "localhost")
            .option("port", 10000)
            .load

        // 输入的数据中包含时间戳, 而不是自动添加的时间戳
        val words: DataFrame = lines.as[String].flatMap(line => {
            val split = line.split(",")
            split(1).split(" ").map((_, Timestamp.valueOf(split(0))))
        }).toDF("word", "timestamp")

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

        val wordCounts: Dataset[Row] = words
            // 添加watermark, 参数 1: event-time 所在列的列名 参数 2: 延迟时间的上限.
            .withWatermark("timestamp", "2 minutes")
            .groupBy(window($"timestamp", "10 minutes", "2 minutes"), $"word")
            .count()
            .sort("window")  // 只在complete模式支持

        val query: StreamingQuery = wordCounts.writeStream
            .outputMode("complete")
            .trigger(Trigger.ProcessingTime(1000))
            .format("console")
            .option("truncate", "false")
            .start
        query.awaitTermination()
    }
}

注意: 初始化wartmark 是 0

有以下几条数据:

测试:

  1. 输入数据:2019-08-14 10:55:00,dog

    这个条数据作为第一批数据. 按照window($"timestamp", "10 minutes", "2 minutes")得到 5 个窗口. 由于是第一批, 所有的窗口的结束时间都大于 wartermark(0), 所以 5 个窗口都显示.

    +------------------------------------------+----+-----+
    |window                                    |word|count|
    +------------------------------------------+----+-----+
    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
    |[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |1    |
    |[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |1    |
    |[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |1    |
    +------------------------------------------+----+-----+
    

    然后根据当前批次中最大的 event-time, 计算出来下次使用的 watermark. 本批次只有一个数据(10:55), 所有: watermark = 10:55 - 2min = 10:53

  2. 输入数据:2019-08-14 11:00:00,dog

    这条数据作为第二批数据, 计算得到 5 个窗口. 此时的watermark=10:53, 所有的窗口的结束时间均大于 watermark. 在 complete模式下, 数据全部输出.

    +------------------------------------------+----+-----+
    |window                                    |word|count|
    +------------------------------------------+----+-----+
    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
    |[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |1    |
    ---------------------新增数据---------------------------------
    |[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |2    |
    |[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |2    |
    |[2019-08-14 10:56:00, 2019-08-14 11:06:00]|dog |1    |
    |[2019-08-14 10:58:00, 2019-08-14 11:08:00]|dog |1    |
    |[2019-08-14 11:00:00, 2019-08-14 11:10:00]|dog |1    |
    +------------------------------------------+----+-----+
    

    此时的改变 watermark = 11:00 - 2min = 10:58

  3. 输入数据:2019-08-14 10:55:00,dog

    相当于一条延迟数据.

    这条数据作为第 3 批次, 计算得到 5 个窗口. 此时的 watermark = 10:58 当前内存中有两个窗口的结束时间已经低于 10: 58.

    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
    

    则立即删除这两个窗口在内存中的维护状态. 同时, 当前批次中新加入的数据所划分出来的窗口, 如果窗口结束时间低于 11:58, 则窗口会被过滤掉.

    所以这次输出结果:

    理论输出
    +------------------------------------------+----+-----+
    |window                                    |word|count|
    +------------------------------------------+----+-----+
    |[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |2    |
    |[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |3    |
    |[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |3    |
    |[2019-08-14 10:56:00, 2019-08-14 11:06:00]|dog |1    |
    |[2019-08-14 10:58:00, 2019-08-14 11:08:00]|dog |1    |
    |[2019-08-14 11:00:00, 2019-08-14 11:10:00]|dog |1    |
    +------------------------------------------+----+-----+
    
    实际输出
    +------------------------------------------+----+-----+
    |window                                    |word|count|
    +------------------------------------------+----+-----+
    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |2    |
    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |2    |
    |[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |2    |
    |[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |3    |
    |[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |3    |
    |[2019-08-14 10:56:00, 2019-08-14 11:06:00]|dog |1    |
    |[2019-08-14 10:58:00, 2019-08-14 11:08:00]|dog |1    |
    |[2019-08-14 11:00:00, 2019-08-14 11:10:00]|dog |1    |
    +------------------------------------------+----+-----+
    

    complete模式要求保留所有聚合数据,因此不能使用水印删除中间状态。

update 模式下使用 watermark

在 update 模式下, 仅输出与之前批次的结果相比, 涉及更新或新增的数据.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hROXttvj-1666402864050)(Structured Streaming.assets/1565747738.png)]

import java.sql.Timestamp

import org.apache.spark.sql._
import org.apache.spark.sql.streaming.{StreamingQuery, Trigger}

object WordCountWatermark1 {
    def main(args: Array[String]): Unit = {

        val spark: SparkSession = SparkSession
            .builder()
            .master("local[*]")
            .appName("WordCountWatermark1")
            .getOrCreate()

        import spark.implicits._
        val lines: DataFrame = spark.readStream
            .format("socket")
            .option("host", "localhost")
            .option("port", 10000)
            .load

        // 输入的数据中包含时间戳, 而不是自动添加的时间戳
        val words: DataFrame = lines.as[String].flatMap(line => {
            val split = line.split(",")
            split(1).split(" ").map((_, Timestamp.valueOf(split(0))))
        }).toDF("word", "timestamp")

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


        val wordCounts: Dataset[Row] = words
            // 添加watermark, 参数 1: event-time 所在列的列名 参数 2: 延迟时间的上限.
            .withWatermark("timestamp", "2 minutes")
            .groupBy(window($"timestamp", "10 minutes", "2 minutes"), $"word")
            .count()
            // .sort("window")报错  update模式只输出更新和新增数据,对窗口排序没有意义

        val query: StreamingQuery = wordCounts.writeStream
            .outputMode("update")
            .trigger(Trigger.ProcessingTime(1000))
            .format("console")
            .option("truncate", "false")
            .start
        query.awaitTermination()
    }
}

注意: 初始化wartmark 是 0

有以下几条数据:

测试:

  1. 输入数据:2019-08-14 10:55:00,dog

    这个条数据作为第一批数据. 按照window($"timestamp", "10 minutes", "2 minutes")得到 5 个窗口. 由于是第一批, 所有的窗口的结束时间都大于 wartermark(0), 所以 5 个窗口都显示.

    +------------------------------------------+----+-----+
    |window                                    |word|count|
    +------------------------------------------+----+-----+
    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
    |[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |1    |
    |[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |1    |
    |[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |1    |
    +------------------------------------------+----+-----+
    

    然后根据当前批次中最大的 event-time, 计算出来下次使用的 watermark. 本批次只有一个数据(10:55), 所有: watermark = 10:55 - 2min = 10:53

  2. 输入数据:2019-08-14 11:00:00,dog

    这条数据作为第二批数据, 计算得到 5 个窗口. 此时的watermark=10:53, 所有的窗口的结束时间均大于 watermark. 在 update 模式下, 只输出结果表中涉及更新或新增的数据.

    +------------------------------------------+----+-----+
    |window                                    |word|count|
    +------------------------------------------+----+-----+
    |[2019-08-14 11:00:00, 2019-08-14 11:10:00]|dog |1    |
    |[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |2    |
    |[2019-08-14 10:58:00, 2019-08-14 11:08:00]|dog |1    |
    |[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |2    |
    |[2019-08-14 10:56:00, 2019-08-14 11:06:00]|dog |1    |
    +------------------------------------------+----+-----+
    

    其中: count 是 2 的表示更新, count 是 1 的表示新增. 没有变化的就没有显示.(但是内存中仍然保存着)

    // 第一批次中的数据仍然在内存保存着
    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
    |[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |1    |
    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
    

    此时的 watermark = 11:00 - 2min = 10:58

  3. 输入数据:2019-08-14 10:55:00,dog

    相当于一条延迟数据.

    这条数据作为第 3 批次, 计算得到 5 个窗口. 此时的 watermark = 10:58 当前内存中有两个窗口的结束时间已经低于 10: 58.

    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
    

    则立即删除这两个窗口在内存中的维护状态. 同时, 当前批次中新加入的数据所划分出来的窗口, 如果窗口结束时间低于 11:58, 则窗口会被过滤掉.

    所以这次输出结果:

    +------------------------------------------+----+-----+
    |window                                    |word|count|
    +------------------------------------------+----+-----+
    |[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |3    |
    |[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |2    |
    |[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |3    |
    +------------------------------------------+----+-----+
    

    第三个批次的数据处理完成后, 立即计算: watermark= 10:55 - 2min = 10:53, 这个值小于当前的 watermask(10:58), 所以保持不变.(因为 watermask 只能增加不能减少)

append 模式下使用 wartermark

把前一个案例中的update改成append即可.

val query: StreamingQuery = wordCounts.writeStream
    .outputMode("append")
    .trigger(Trigger.ProcessingTime(0))
    .format("console")
    .option("truncate", "false")
    .start

在 append 模式中, 仅输出新增的数据, 且输出后的数据无法变更.

测试:

  1. 输入数据:2019-08-14 10:55:00,dog

    这个条数据作为第一批数据. 按照window($"timestamp", "10 minutes", "2 minutes")得到 5 个窗口. 由于此时初始 watermask=0, 当前批次中所有窗口的结束时间均大于 watermask.

    但是 Structured Streaming 无法确定后续批次的数据中是否会更新当前批次的内容. 因此, 基于 Append 模式的特点, 这时并不会输出任何数据(因为输出后数据就无法更改了), 直到某个窗口的结束时间小于 watermask, 即可以确定后续数据不会再变更该窗口的聚合结果时才会将其输出, 并移除内存中对应窗口的聚合状态.

    +------+----+-----+
    |window|word|count|
    +------+----+-----+
    +------+----+-----+
    

    然后根据当前批次中最大的 event-time, 计算出来下次使用的 watermark. 本批次只有一个数据(10:55), 所有: watermark = 10:55 - 2min = 10:53

  2. 输入数据:2019-08-14 11:00:00,dog

    这条数据作为第二批数据, 计算得到 5 个窗口. 此时的watermark=10:53, 所有的窗口的结束时间均大于 watermark, 仍然不会输出.

    +------+----+-----+
    |window|word|count|
    +------+----+-----+
    +------+----+-----+
    

    然后计算 watermark = 11:00 - 2min = 10:58

  3. 输入数据:2019-08-14 10:55:00,dog

    相当于一条延迟数据.

    这条数据作为第 3 批次, 计算得到 5 个窗口. 此时的 watermark = 10:58 当前内存中有两个窗口的结束时间已经低于 10: 58.

    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
    

    则意味着这两个窗口的数据不会再发生变化, 此时输出这个两个窗口的聚合结果, 并在内存中清除这两个窗口的状态.

    所以这次输出结果:

    +------------------------------------------+----+-----+
    |window                                    |word|count|
    +------------------------------------------+----+-----+
    |[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
    |[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
    +------------------------------------------+----+-----+
    

    第三个批次的数据处理完成后, 立即计算: watermark= 10:55 - 2min = 10:53, 这个值小于当前的 watermask(10:58), 所以保持不变.(因为 watermask 只能增加不能减少)

watermark 机制总结

加水印以清除聚合状态的条件
  1. watermark 在用于基于时间的状态聚合操作时, 该时间可以基于窗口, 也可以基于 event-time本身.

    The aggregation must have either the event-time column, or a window on the event-time column.

  2. 输出模式必须是appendupdate. 在输出模式是complete的时候(必须有聚合), 要求每次输出所有的聚合结果. 我们使用 watermark 的目的是丢弃一些过时聚合数据, 所以complete模式使用wartermark无效也无意义.

  3. 在输出模式是append时, 必须设置 watermask 才能使用聚合操作. 其实, watermask 定义了 append 模式中何时输出聚合聚合结果(状态), 并清理过期状态.

  4. 在输出模式是update时, watermask 主要用于过滤过期数据并及时清理过期状态.

  5. watermask 会在处理当前批次数据时更新, 并且会在处理下一个批次数据时生效使用. 但如果节点发送故障, 则可能延迟若干批次生效.

  6. withWatermark 必须使用与聚合操作中的时间戳列是同一列.df.withWatermark("time", "1 min").groupBy("time2").count() 无效

  7. withWatermark 必须在聚合之前调用. f.groupBy("time").count().withWatermark("time", "1 min") 无效

带水印聚合的语义保证
  1. 水印延迟(使用withWatermark设置)为“ 2小时”可确保引擎永远不会丢弃任何少于2小时的数据。换句话说,任何在此之前处理的最新数据比事件时间少2小时(以事件时间计)的数据都可以保证得到汇总。
  2. 但是,保证仅在一个方向上严格。延迟超过2小时的数据不能保证被删除;它可能会或可能不会聚合。数据延迟更多,引擎处理数据的可能性越小。

流数据去重

根据唯一的 id 实现数据去重.dropDuplicates

数据:

1,2019-09-14 11:50:00,dog
2,2019-09-14 11:51:00,dog
1,2019-09-14 11:50:00,dog
3,2019-09-14 11:53:00,dog
1,2019-09-14 11:50:00,dog
4,2019-09-14 11:45:00,dog

import java.sql.Timestamp

import org.apache.spark.sql.{DataFrame, Dataset, Row, SparkSession}

object StreamDropDuplicate {
    def main(args: Array[String]): Unit = {
        val spark: SparkSession = SparkSession
            .builder()
            .master("local[*]")
            .appName("Test")
            .getOrCreate()
        import spark.implicits._

        val lines: DataFrame = spark.readStream
            .format("socket")
            .option("host", "localhost")
            .option("port", 10000)
            .load()

        val words: DataFrame = lines.as[String].map(line => {
            val arr: Array[String] = line.split(",")
            (arr(0), Timestamp.valueOf(arr(1)), arr(2))
        }).toDF("uid", "ts", "word")

        val wordCounts: Dataset[Row] = words
            .withWatermark("ts", "2 minutes")  // 
            .dropDuplicates("uid")  // 去重重复数据 uid 相同就是重复.  可以传递多个列

        wordCounts.writeStream
            .outputMode("append")
            .format("console")
            .start
            .awaitTermination()
    }
}

注意:

  1. dropDuplicates 不可用在聚合之后, 即通过聚合得到的 df/ds 不能调用dropDuplicates
  2. 使用watermask - 如果重复记录的到达时间有上限,则可以在事件时间列上定义水印,并使用guid和事件时间列进行重复数据删除。该查询将使用水印从过去的记录中删除旧的状态数据,这些记录不会再被重复。这限制了查询必须维护的状态量。
  3. 没有watermask - 由于重复记录可能到达时没有界限,查询将来自所有过去记录的数据存储为状态。

测试

  1. 第一批:

    1,2019-09-14 11:50:00,dog
    
    +---+-------------------+----+
    |uid|                 ts|word|
    +---+-------------------+----+
    |  1|2019-09-14 11:50:00| dog|
    +---+-------------------+----+
    
  2. 第 2 批:

    2,2019-09-14 11:51:00,dog
    
    +---+-------------------+----+
    |uid|                 ts|word|
    +---+-------------------+----+
    |  2|2019-09-14 11:51:00| dog|
    +---+-------------------+----+
    
  3. 第 3 批: 1,2019-09-14 11:50:00,dog
    id 重复无输出

  4. 第 4 批: 3,2019-09-14 11:53:00,dog

    +---+-------------------+----+
    |uid|                 ts|word|
    +---+-------------------+----+
    |  3|2019-09-14 11:53:00| dog|
    +---+-------------------+----+
    

    此时 watermask=11:51

  5. 第 5 批: 1,2019-09-14 11:50:00,dog 数据重复, 并且数据过期, 所以无输出

  6. 第 6 批 4,2019-09-14 11:45:00,dog 数据过时, 所以无输出

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值