Table API和SQL,本质上还是基于关系型表的操作方式;而关系型表、SQL本身,一般是有界的,更适合批处理的场景。所以在流处理的过程中,有一些特殊概念。
SQL | 流处理 | |
---|---|---|
处理对象 | 字段元组的有界集合 | 字段元组的无限序列 |
查询对数据的访问 | 可以访问完整的数据输入 | 无法访问所有数据,必须持续等待流式输入 |
查询终止条件 | 生成固定大小的结果集后终止 | 永不停止,根据持续收到的数据不断更新查询结果 |
尽管存在这些差异,使用关系查询和SQL处理流并不是不可能的。高级关系数据库系统提供了一种称为物化视图的特性。物化视图被定义为SQL查询,就像常规的虚拟视图一样。与虚拟视图相反,物化视图缓存查询的结果,以便在访问视图时不需要计算查询。缓存的一个常见挑战是防止缓存提供过时的结果。当物化视图的定义查询的基表被修改时,物化视图就会过时。即时视图维护是一种技术,用于在基本表更新后立即更新物化视图。
在Flink Table API概念里有个动态表(Dynamic Tables),动态表随着新数据的到来不停的在之前的基础上更新结果。这与传统的关系型数据库中的表示是截然不同的,因为流处理的数据是源源不断的,将流数据转换成Table,然后进行操作结果也就不是一成不变,而是随着新数据不断更新。
动态表是Flink对流数据的Table API和SQL支持的核心概念。动态表可以像传统关系型数据库中的表一样查询,查询一个动态表会产生持续查询,持续查询永远不会终止,还会生成另一个动态表,查询操作会不断更新动态结果表,反应动态数据表的更改。
本质上,动态表上的连续查询与定义物化视图的查询非常相似,注意:连续查询的结果在语义上总是等价于在输入表的快照上以批处理模式执行相同查询的结果。
下图可视化了流、动态表和连续查询之间的关系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bcLFveIX-1596812198844)(C:\资料\flink\笔记\8 Table API与SQL\assets\1596357675358.png)]
流式持续查询的过程为:①流被转换为动态表②对动态表计算连续查询,生成新的动态表③ 生成的动态表被转换回流。
注意:动态表首先是一个逻辑概念。动态表不一定在查询执行期间物化
为了处理带有关系查询的流,必须先将其转换为表。从概念上讲,流的每个数据记录,都被解释为对结果表的插入(Insert)修改。因为流式持续不断的,而且之前的输出结果无法改变。本质上,我们其实是从一个、只有插入操作的changelog(更新日志)流,来构建一个表。
使用具有以下模式的单击事件流解释动态表和连续查询的概念:
[
user: VARCHAR, // the name of the user
cTime: TIMESTAMP, // the time when the URL was accessed
url: VARCHAR // the URL that was accessed by the user
]
下图可视化了单击事件流(左侧)如何转换为表(右侧)。随着插入更多单击流的记录,结果表不断增长。
注意:定义在流上的表在内部不是物化的
对动态表进行持续查询,并生成新的动态表作为结果。与批处理查询不同,持续查询从不根据输入表上的更新终止而更新其结果表。在任何时间点,持续查询的结果在语义上等同于输入表快照上以批处理模式执行的同一查询的结果。
在下面的示例中,我们将在clicks表,该表是在单击事件流上定义的。第一个查询是一个简单的GROUP BY COUNT聚合查询,它将clicks表上user字段,并计算访问的URL数。下面的图显示了查询是如何在一段时间内作为clicks表使用其他行更新。
当查询开始时,clicks表(左侧)示空的,这个查询开始计算结果表,当第一行[Mary, ./home]插入到clicks表,结果表(右侧)由一行组成[Mary, 1];当第二行[Bob, ./cart]插入到clicks表,结果表(右侧)插入一个新的行[Bob, 1];当第三行[Mary, ./prod?id=1]`插入到clicks表,生成已计算结果行的更新[Mary, 1]更新成[Mary, 2];最后[Liz, 1]插入到结果表,当第四行追加到clicks表中。
第二个查询类似于第一个查询,但将clicks表之外的user属性的每小时滚动在计算URL数量之前(基于时间的计算(如windows)是基于特殊的)。同样,图显示了不同时间点的输入和输出,以可视化动态表的变化性质。
和前面一样,输入表clicks显示在左边。查询每小时连续计算结果并更新结果表。在12:00:00和12:59:59clicks表包含带有时间戳的四行(cTime)。该查询从此输入计算两个结果行(每个输入一行),并将它们附加到结果表中。之间的下一个窗口13:00:00和
13:59:59,clicks表包含三行,这将导致将另两行附加到结果表。随着时间的推移,结果表将被更新,因为会追加更多的行。
虽然上面两个示例查询看起来非常相似(两者都计算分组计数聚合),但它们在一个重要方面有所不同:
①第一个查询更新先前发出的结果,即定义结果表的Changelog流包含INSERT和UPDATE改变;
②第二个查询仅附加到结果表,即结果表的Changelog流仅由INSERT
改变
查询是生成仅附加的表还是更新的表有一些含义:
①产生更新更改的查询通常需要维护更多的状态
②将仅追加的表转换为流与对更新的表的转换不同
与常规的数据库表一样,动态表可以通过插入(Insert)、更新(Update)和删除(Delete)更改,进行持续的修改。将动态表转换为流或将其写入外部系统时,需要对这些更改进行编码。Flink的Table API和SQL支持三种方式对动态表的更改进行编码:
仅通过插入(Insert)更改,来修改的动态表,可以直接转换为“仅追加”流。这个流中发出的数据,就是动态表中新增的每一行。
Retract流是包含两类消息的流,添加(Add)消息和撤回(Retract)消息。
动态表通过将INSERT 编码为add消息、DELETE 编码为retract消息、UPDATE编码为被更改行(前一行)的retract消息和更新后行(新行)的add消息,转换为retract流。
下图显示了将动态表转换为Retract流的过程
Upsert流包含两种类型的消息:Upsert消息和delete消息。转换为upsert流的动态表,**需要有唯一的键(key)。**通过将INSERT和UPDATE更改编码为upsert消息,将DELETE更改编码为DELETE消息,就可以将具有唯一键(Unique Key)的动态表转换为流。
下图显示了将动态表转换为upsert流的过程:
注意:在代码里将动态表转换为DataStream时,仅支持Append和Retract流。而向外部系统输出动态表的TableSink接口,则可以有不同的实现。
基于时间的操作(如Table API和SQL中窗口操作),需要定义相关的时间语义和时间数据来源的信息。所以,Table可以提供一个逻辑上的时间字段,用于在表处理程序中,指示时间和访问相应的时间戳。
时间属性,可以是每个表schema的一部分。一旦定义了时间属性,它就可以作为一个字段引用,并且可以在基于时间的操作中使用。**只要时间属性没有被修改,只是从查询的一个部分转发到另一个部分,它仍然是一个有效的时间属性。**时间属性的行为类似于常规时间戳,可以访问,并且进行计算。如果在计算中使用时间属性,则它将被物化并成为常规时间戳。常规时间戳不配合Flink的时间和水印系统,因此不能再用于基于时间的操作。
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime) // default
// alternatively:
// env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime)
// env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
处理时间语义下,允许表处理程序根据机器的本地时间生成结果。它是时间的最简单概念。它既不需要提取时间戳,也不需要生成watermark。
定义处理时间属性有三种方法:在DataStream转化时直接指定;在定义Table Schema时指定;在创建表的DDL中指定。
在创建表的DDL中,增加一个字段并指定成proctime,也可以指定当前的时间字段。代码如下:
CREATE TABLE user_actions (
user_name STRING,
data STRING,
user_action_time AS PROCTIME() -- declare an additional field as a processing time attribute
) WITH (
'connector.type' = 'filesystem',
'connector.path' = 'file:///D:\\..\\sensor.txt',
'format.type' = 'csv'
);
SELECT TUMBLE_START(user_action_time, INTERVAL '10' MINUTE), COUNT(DISTINCT user_name)
FROM user_actions
GROUP BY TUMBLE(user_action_time, INTERVAL '10' MINUTE);
注意:运行这段DDL,必须使用Blink Planner
由DataStream转换成表时,可以在后面指定字段名来定义Schema。在定义Schema期间,可以使用.proctime,定义处理时间字段。
注意,这个proctime属性只能通过附加逻辑字段,来扩展物理schema。因此,只能在schema定义的末尾定义它。
val stream: DataStream[(String, String)] = ...
// declare an additional logical field as a processing time attribute
val table = tEnv.fromDataStream(stream, 'UserActionTimestamp, 'user_name, 'data, 'user_action_time.proctime)
val windowedTable = table.window(Tumble over 10.minutes on 'user_action_time as 'userActionWindow)
只要在定义Schema的时候,加上一个新的字段,并指定成proctime就可以了。
tableEnv.connect(
new FileSystem().path("file_path"))
.withFormat(new Csv())
.withSchema(new Schema()
.field("user_name", DataTypes.STRING())
.field("UserActionTimestamp", DataTypes.BIGINT())
.field("data", DataTypes.STRING())
.field("pt", DataTypes.TIMESTAMP(3))
.proctime() // 指定 pt字段为处理时间
) // 定义表结构
.createTemporaryTable("inputTable") // 创建临时表
事件时间语义,允许表处理程序根据每个记录中包含的时间生成结果。这样即使在有乱序事件或者延迟事件时,也可以获得正确的结果。
为了处理无序事件,并区分流中的准时和迟到事件;Flink需要从事件数据中,提取时间戳,并用来推进事件时间的进展(watermark)。事件时间属性可以在CREATE TABLE DDL中定义,也可以在Datastream到表转换期间或者通过使用TableSource来定义。
事件时间属性,是使用CREATE TABLE DDL中的WARDMARK语句定义的。watermark语句,定义现有事件时间字段上的watermark生成表达式,该表达式将事件时间字段标记为事件时间属性。
CREATE TABLE user_actions (
user_name STRING,
data STRING,
user_action_time TIMESTAMP(3),
-- declare user_action_time as event time attribute and use 5 seconds delayed watermark strategy
WATERMARK FOR user_action_time AS user_action_time - INTERVAL '5' SECOND
) WITH (
...
);
SELECT TUMBLE_START(user_action_time, INTERVAL '10' MINUTE), COUNT(DISTINCT user_name)
FROM user_actions
GROUP BY TUMBLE(user_action_time, INTERVAL '10' MINUTE);
在DataStream转换成Table,schema的定义期间,使用.rowtime可以定义事件时间属性。必须在转换的数据流中分配时间戳和watermark。
在将数据流转换为表时,有两种定义时间属性的方法。根据指定的.rowtime字段名是否存在于数据流的架构中,timestamp字段可以作为新字段追加到schema替换现有字段
在这两种情况下,定义的事件时间戳字段,都将保存DataStream中事件时间戳的值。
// Option 1:
// extract timestamp and assign watermarks based on knowledge of the stream
val stream: DataStream[(String, String)] = inputStream.assignTimestampsAndWatermarks(...)
// declare an additional logical field as an event time attribute
val table = tEnv.fromDataStream(stream, 'user_name, 'data, 'user_action_time.rowtime)
// Option 2:
// extract timestamp from first field, and assign watermarks based on knowledge of the stream
val stream: DataStream[(Long, String, String)] = inputStream.assignTimestampsAndWatermarks(...)
// the first field has been used for timestamp extraction, and is no longer necessary
// replace first field with a logical event time attribute
val table = tEnv.fromDataStream(stream, 'user_action_time.rowtime, 'user_name, 'data)
// Usage:
val windowedTable = table.window(Tumble over 10.minutes on 'user_action_time as 'userActionWindow)
这种方法只要在定义Schema的时候,将事件时间字段,并指定成rowtime就可以了。
tableEnv.connect(
new FileSystem().path("flie_path"))
.withFormat(new Csv())
.withSchema(new Schema()
.field("user_name", DataTypes.STRING())
.field("timestamp", DataTypes.BIGINT())
.rowtime(
new Rowtime()
.timestampsFromField("timestamp") // 从字段中提取时间戳
.watermarksPeriodicBounded(1000) // watermark延迟1秒
)
.field("data", DataTypes.STRING())
) // 定义表结构
.createTemporaryTable("inputTable") // 创建临时表
时间语义要配合窗口操作,根据时间段做计算了才能发挥作用。在Table API和SQL中,主要有两种窗口:Group Windows(分组窗口)和Over Windows。
Group Windows会根据时间或行计数间隔,将行聚合到有限的组(Group)中,并对每个组的数据执行一次聚合函数。
Table API中的Group Windows都是使用.window(w:GroupWindow)子句定义的,必须由as子句指定一个别名。为了按窗口对表进行分组,窗口的别名必须在group by子句中,像常规的分组字段一样引用。
val table = input
.window([w: GroupWindow] as 'w) // 定义窗口,别名 w
.groupBy('w, 'a) // 以属性a和窗口w作为分组的key
.select('a, 'b.sum) // 聚合字段b的值,求和
也可以把窗口的相关信息,作为字段添加到结果表中:
val table = input
.window([w: GroupWindow] as 'w)
.groupBy('w, 'a)
.select('a, 'w.start, 'w.end, 'w.rowtime, 'b.count)
Table API提供了一组具有特定语义的预定义Window类,这些类会被转换为底层DataStream或DataSet的窗口操作。Table API支持的窗口定义,主要也是三种:滚动(Tumbling)、滑动(Sliding)和会话(Session)。
滚动窗口(Tumbling windows)要用Tumble类来定义,另外还有三个方法:①over:定义窗口长度;②on:用来分组(按时间间隔)或者排序(按行数)的时间字段;③as:别名,必须出现在后面的groupBy中
// Tumbling Event-time Window(事件时间字段rowtime)
.window(Tumble over 10.minutes on 'rowtime as 'w)
// Tumbling Processing-time Window(处理时间字段proctime)
.window(Tumble over 10.minutes on 'proctime as 'w)
// Tumbling Row-count Window (类似于计数窗口,按处理时间排序,10行一组)
.window(Tumble over 10.rows on 'proctime as 'w)
滑动窗口(Sliding windows)要用Slide类来定义,另外还有四个方法:①over:定义窗口长度②every:定义滑动步长③on:用来分组(按时间间隔)或者排序(按行数)的时间字段④as:别名,必须出现在后面的groupBy中
// Sliding Event-time Window
.window(Slide over 10.minutes every 5.minutes on 'rowtime as 'w)
// Sliding Processing-time window
.window(Slide over 10.minutes every 5.minutes on 'proctime as 'w)
// Sliding Row-count window
.window(Slide over 10.rows every 5.rows on 'proctime as 'w)
会话窗口(Session windows)要用Session类来定义,另外还有三个方法:①withGap:会话时间间隔②on:用来分组(按时间间隔)或者排序(按行数)的时间字段③as:别名,必须出现在后面的groupBy中
// Session Event-time Window
.window(Session withGap 10.minutes on 'rowtime as 'w)
// Session Processing-time Window
.window(Session withGap 10.minutes on 'proctime as 'w)
Over window聚合是标准SQL中已有的(Over子句),可以在查询的SELECT子句中定义。Over window 聚合,会针对每个输入行,计算相邻行范围内的聚合。Over windows使用.window(w:overwindows*)子句定义,并在select()方法中通过别名来引用。
val table = input
.window([w: OverWindow] as 'w)
.select('a, 'b.sum over 'w, 'c.min over 'w)
Table API提供了Over类,来配置Over窗口的属性。可以在事件时间或处理时间,以及指定为时间间隔、或行计数的范围内,定义Over windows。无界的over window是使用常量指定的。也就是说,时间间隔要指定UNBOUNDED_RANGE,或者行计数间隔要指定UNBOUNDED_ROW。而有界的over window是用间隔的大小指定的。
(1) 无界的 over window
// 无界的事件时间over window (时间字段 "rowtime")
.window(Over partitionBy 'a orderBy 'rowtime preceding UNBOUNDED_RANGE as 'w)
//无界的处理时间over window (时间字段"proctime")
.window(Over partitionBy 'a orderBy 'proctime preceding UNBOUNDED_RANGE as 'w)
// 无界的事件时间Row-count over window (时间字段 "rowtime")
.window(Over partitionBy 'a orderBy 'rowtime preceding UNBOUNDED_ROW as 'w)
//无界的处理时间Row-count over window (时间字段 "rowtime")
.window(Over partitionBy 'a orderBy 'proctime preceding UNBOUNDED_ROW as 'w)
(2)有界的 over window
// 有界的事件时间over window (时间字段 "rowtime",之前1分钟)
.window(Over partitionBy 'a orderBy 'rowtime preceding 1.minutes as 'w)
// 有界的处理时间over window (时间字段 "rowtime",之前1分钟)
.window(Over partitionBy 'a orderBy 'proctime preceding 1.minutes as 'w)
// 有界的事件时间Row-count over window (时间字段 "rowtime",之前10行)
.window(Over partitionBy 'a orderBy 'rowtime preceding 10.rows as 'w)
// 有界的处理时间Row-count over window (时间字段 "rowtime",之前10行)
.window(Over partitionBy 'a orderBy 'proctime preceding 10.rows as 'w)
Group Windows在SQL查询的Group BY子句中定义。与使用常规GROUP BY子句的查询一样,使用GROUP BY子句的查询会计算每个组的单个结果行。
SQL支持以下Group窗口函数:
TUMBLE(time_attr, interval)
定义一个滚动窗口,第一个参数是时间字段,第二个参数是窗口长度。
HOP(time_attr, interval, interval)
定义一个滑动窗口,第一个参数是时间字段,第二个参数是窗口滑动步长,第三个是窗口长度。
SESSION(time_attr, interval)
另外还有一些辅助函数,可以用来选择Group Window的开始和结束时间戳,以及时间属性。
TUMBLE_START(time_attr, interval)
TUMBLE_END(time_attr, interval)
TUMBLE_ROWTIME(time_attr, interval)
TUMBLE_PROCTIME(time_attr, interval)
由于Over本来就是SQL内置支持的语法,所以这在SQL中属于基本的聚合操作。所有聚合必须在同一窗口上定义,也就是说,必须是相同的分区、排序和范围。目前仅支持在当前行范围之前的窗口(无边界和有边界)。
注意,ORDER BY必须在单一的时间属性上指定。
SELECT COUNT(amount) OVER (
PARTITION BY user
ORDER BY proctime
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
FROM Orders
// 也可以做多个聚合
SELECT COUNT(amount) OVER w, SUM(amount) OVER w
FROM Orders
WINDOW w AS (
PARTITION BY user
ORDER BY proctime
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)