在数据分析系统中, 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 机制, 主要是为了解决以下两个问题:
- 处理聚合中的延迟数据
- 减少内存中维护的聚合状态.
在不同输出模式(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
有以下几条数据:
测试:
-
输入数据:
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
-
输入数据:
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
-
输入数据:
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
有以下几条数据:
测试:
-
输入数据:
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
-
输入数据:
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
-
输入数据:
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 模式中, 仅输出新增的数据, 且输出后的数据无法变更.
测试:
-
输入数据:
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
-
输入数据:
2019-08-14 11:00:00,dog
这条数据作为第二批数据, 计算得到 5 个窗口. 此时的watermark=10:53, 所有的窗口的结束时间均大于 watermark, 仍然不会输出.
+------+----+-----+ |window|word|count| +------+----+-----+ +------+----+-----+
然后计算 watermark = 11:00 - 2min = 10:58
-
输入数据:
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 机制总结
加水印以清除聚合状态的条件
-
watermark 在用于基于时间的状态聚合操作时, 该时间可以基于窗口, 也可以基于 event-time本身.
The aggregation must have either the event-time column, or a
window
on the event-time column. -
输出模式必须是
append
或update
. 在输出模式是complete
的时候(必须有聚合), 要求每次输出所有的聚合结果. 我们使用 watermark 的目的是丢弃一些过时聚合数据, 所以complete
模式使用wartermark
无效也无意义. -
在输出模式是
append
时, 必须设置 watermask 才能使用聚合操作. 其实, watermask 定义了 append 模式中何时输出聚合聚合结果(状态), 并清理过期状态. -
在输出模式是
update
时, watermask 主要用于过滤过期数据并及时清理过期状态. -
watermask 会在处理当前批次数据时更新, 并且会在处理下一个批次数据时生效使用. 但如果节点发送故障, 则可能延迟若干批次生效.
-
withWatermark
必须使用与聚合操作中的时间戳列是同一列.df.withWatermark("time", "1 min").groupBy("time2").count()
无效 -
withWatermark
必须在聚合之前调用.f.groupBy("time").count().withWatermark("time", "1 min")
无效
带水印聚合的语义保证
- 水印延迟(使用withWatermark设置)为“ 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()
}
}
注意:
dropDuplicates
不可用在聚合之后, 即通过聚合得到的 df/ds 不能调用dropDuplicates
- 使用
watermask
- 如果重复记录的到达时间有上限,则可以在事件时间列上定义水印,并使用guid和事件时间列进行重复数据删除。该查询将使用水印从过去的记录中删除旧的状态数据,这些记录不会再被重复。这限制了查询必须维护的状态量。 - 没有
watermask
- 由于重复记录可能到达时没有界限,查询将来自所有过去记录的数据存储为状态。
测试
-
第一批:
1,2019-09-14 11:50:00,dog
+---+-------------------+----+ |uid| ts|word| +---+-------------------+----+ | 1|2019-09-14 11:50:00| dog| +---+-------------------+----+
-
第 2 批:
2,2019-09-14 11:51:00,dog
+---+-------------------+----+ |uid| ts|word| +---+-------------------+----+ | 2|2019-09-14 11:51:00| dog| +---+-------------------+----+
-
第 3 批:
1,2019-09-14 11:50:00,dog
id 重复无输出 -
第 4 批:
3,2019-09-14 11:53:00,dog
+---+-------------------+----+ |uid| ts|word| +---+-------------------+----+ | 3|2019-09-14 11:53:00| dog| +---+-------------------+----+
此时 watermask=11:51
-
第 5 批:
1,2019-09-14 11:50:00,dog
数据重复, 并且数据过期, 所以无输出 -
第 6 批
4,2019-09-14 11:45:00,dog
数据过时, 所以无输出