一. 流處理中的特殊概念
Table API 和 SQL,本質上還是基於關係型表的操作方式;而關係型表、關係代數,以及 SQL 本身,一般是有界的,更適合批處理的場景。這就導致在進行流處理的過程中,理解會 稍微複雜一些,需要引入一些特殊概念。
1.1 流處理和關係代數(表,及 SQL)的區別
可以看到,其實關係代數(主要就是指關係型數據庫中的表)和 SQL,主要就是針對批 處理的,這和流處理有天生的隔閡。
1.2 動態表(Dynamic Tables)
因爲流處理面對的數據,是連續不斷的,這和我們熟悉的關係型數據庫中保存的“表” 完全不同。所以,如果我們把流數據轉換成 Table,然後執行類似於 table 的 select 操作,結 果就不是一成不變的,而是隨着新數據的到來,會不停更新。
我們可以隨着新數據的到來,不停地在之前的基礎上更新結果。這樣得到的表,在 Flink Table API 概念裏,就叫做“動態表”(Dynamic Tables)。
動態表是 Flink 對流數據的 Table API 和 SQL 支持的核心概念。與表示批處理數據的靜態 表不同,動態表是隨時間變化的。動態表可以像靜態的批處理表一樣進行查詢,查詢一個動 態表會產生持續查詢(Continuous Query)。連續查詢永遠不會終止,並會生成另一個動態表。
查詢(Query)會不斷更新其動態結果表,以反映其動態輸入表上的更改。
1.3 流式持續查詢的過程
下圖顯示了流、動態表和連續查詢的關係:
流式持續查詢的過程爲:
- 流被轉換爲動態表。
- 對動態表計算連續查詢,生成新的動態表。
- 生成的動態表被轉換回流。
1.3.1 將流轉換成表(Table)
爲了處理帶有關係查詢的流,必須先將其轉換爲表。 從概念上講,流的每個數據記錄,都被解釋爲對結果表的插入(Insert)修改。因爲流式持續不斷的,而且之前的輸出結果無法改變。本質上,我們其實是從一個、只有插入操作的 changelog(更新日誌)流,來構建一個表。
爲了更好地說明動態表和持續查詢的概念,我們來舉一個具體的例子。 比如,我們現在的輸入數據,就是用戶在網站上的訪問行爲,數據類型(Schema)如下:
user: VARCHAR, // 用戶名
cTime: TIMESTAMP, // 訪問某個 URL 的時間戳
url: VARCHAR // 用戶訪問的 URL
下圖顯示瞭如何將訪問 URL 事件流,或者叫點擊事件流(左側)轉換爲表(右側)。
隨着插入更多的訪問事件流記錄,生成的表將不斷增長。
1.3.2 持續查詢(Continuous Query)
持續查詢,會在動態表上做計算處理,並作爲結果生成新的動態表。與批處理查詢不同, 連續查詢從不終止,並根據輸入表上的更新更新其結果表。
在任何時間點,連續查詢的結果在語義上,等同於在輸入表的快照上,以批處理模式執 行的同一查詢的結果。
在下面的示例中,我們展示了對點擊事件流中的一個持續查詢。
這個 Query 很簡單,是一個分組聚合做 count 統計的查詢。它將用戶字段上的 clicks 表 分組,並統計訪問的 url 數。圖中顯示了隨着時間的推移,當 clicks 表被其他行更新時如何 計算查詢。
1.3.3 將動態錶轉換成流
與常規的數據庫表一樣,動態表可以通過插入(Insert)、更新(Update)和刪除(Delete) 更改,進行持續的修改。將動態錶轉換爲流或將其寫入外部系統時,需要對這些更改進行編 碼。Flink 的 Table API 和 SQL 支持三種方式對動態表的更改進行編碼:
僅追加(Append-only)流
僅通過插入(Insert)更改,來修改的動態表,可以直接轉換爲“僅追加”流。這個流 中發出的數據,就是動態表中新增的每一行。-
撤回(Retract)流
Retract 流是包含兩類消息的流,添加(Add)消息和撤回(Retract)消息。 動態表通過將 INSERT 編碼爲 add 消息、DELETE 編碼爲 retract 消息、UPDATE 編碼爲被
更改行(前一行)的 retract 消息和更新後行(新行)的 add 消息,轉換爲 retract 流。 下圖顯示了將動態錶轉換爲 Retract 流的過程。
Upsert(更新插入)流
Upsert 流包含兩種類型的消息:Upsert 消息和 delete 消息。轉換爲 upsert 流的動態表, 需要有唯一的鍵(key)。
通過將 INSERT 和 UPDATE 更改編碼爲 upsert 消息,將 DELETE 更改編碼爲 DELETE 消息, 就可以將具有唯一鍵(Unique Key)的動態錶轉換爲流。
下圖顯示了將動態錶轉換爲 upsert 流的過程。
這些概念我們之前都已提到過。需要注意的是,在代碼裏將動態錶轉換爲 DataStream 時,僅支持 Append 和 Retract 流。而向外部系統輸出動態表的 TableSink 接口,則可以有不 同的實現,比如之前我們講到的 ES,就可以有 Upsert 模式。
1.4 時間特性
基於時間的操作(比如 Table API 和 SQL 中窗口操作),需要定義相關的時間語義和時間 數據來源的信息。所以,Table 可以提供一個邏輯上的時間字段,用於在表處理程序中,指示時間和訪問相應的時間戳。
時間屬性,可以是每個表 schema 的一部分。一旦定義了時間屬性,它就可以作爲一個 字段引用,並且可以在基於時間的操作中使用。
時間屬性的行爲類似於常規時間戳,可以訪問,並且進行計算。
1.4.1 處理時間(Processing Time)
處理時間語義下,允許表處理程序根據機器的本地時間生成結果。它是時間的最簡單概 念。它既不需要提取時間戳,也不需要生成 watermark。
定義處理時間屬性有三種方法:在 DataStream 轉化時直接指定;在定義 Table Schema時指定;在創建表的 DDL 中指定。
1.4.1.1 DataStream 轉化成 Table 時指定
由 DataStream 轉換成表時,可以在後面指定字段名來定義 Schema。在定義 Schema 期間,可以使用.proctime,定義處理時間字段。
注意,這個 proctime 屬性只能通過附加邏輯字段,來擴展物理 schema。因此,只能在 schema 定義的末尾定義它。
代碼如下:
// 定義好 DataStream
DataStream<String> inputStream = env.readTextFile("\\sensor.txt") DataStream<SensorReading> dataStream = inputStream
.map( line -> {
String[] fields = line.split(",");
return new SensorReading(fields[0], new Long(fields[1]), new
Double(fields[2]));
} );
// 將 DataStream 轉換爲 Table,並指定時間字段
Table sensorTable = tableEnv.fromDataStream(dataStream, "id, temperature, timestamp, pt.proctime");
1.4.1.2 定義Table Schema時指定
這種方法其實也很簡單,只要在定義 Schema 的時候,加上一個新的字段,並指定成proctime 就可以了。
代碼如下:
tableEnv.connect(
new FileSystem().path("..\\sensor.txt"))
.withFormat(new Csv())
.withSchema(new Schema()
.field("id", DataTypes.STRING())
.field("timestamp", DataTypes.BIGINT())
.field("temperature", DataTypes.DOUBLE())
.field("pt", DataTypes.TIMESTAMP(3))
.proctime() // 指定 pt 字段爲處理時間
) // 定義表結構
.createTemporaryTable("inputTable"); // 創建臨時表
1.4.1.3 創建表的 DDL 中指定
在創建表的 DDL 中,增加一個字段並指定成 proctime,也可以指定當前的時間字段。
代碼如下:
String sinkDDL = "create table dataTable (" +
" id varchar(20) not null, " +
" ts bigint, " +
" temperature double, " +
" pt AS PROCTIME() " +
") with (" +
" 'connector.type' = 'filesystem', " + " 'connector.path' = '/sensor.txt', " + " 'format.type' = 'csv')";
tableEnv.sqlUpdate(sinkDDL);
注意:運行這段 DDL,必須使用 Blink Planner。
1.4.2 事件時間(Event Time)
事件時間語義,允許表處理程序根據每個記錄中包含的時間生成結果。這樣即使在有亂 序事件或者延遲事件時,也可以獲得正確的結果。
爲了處理無序事件,並區分流中的準時和遲到事件;Flink 需要從事件數據中,提取時 間戳,並用來推進事件時間的進展(watermark)。
1.4.2.1 DataStream 轉化成 Table 時指定
在 DataStream 轉換成 Table,schema 的定義期間,使用.rowtime 可以定義事件時間屬性。 注意,必須在轉換的數據流中分配時間戳和 watermark。
在將數據流轉換爲表時,有兩種定義時間屬性的方法。根據指定的.rowtime 字段名是否 存在於數據流的架構中,timestamp 字段可以:
⚫ 作爲新字段追加到 schema
⚫ 替換現有字段
在這兩種情況下,定義的事件時間戳字段,都將保存 DataStream 中事件時間戳的值。
代碼如下:
DataStream<String> inputStream = env.readTextFile("\\sensor.txt") DataStream<SensorReading> dataStream = inputStream
.map( line -> {
String[] fields = line.split(",");
return new SensorReading(fields[0], new Long(fields[1]), new
Double(fields[2]));
} )
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<SensorReading>(Time.seconds(1)) {
@Override
public long extractTimestamp(SensorReading element) {
return element.getTimestamp() * 1000L;
}
});
Table sensorTable = tableEnv.fromDataStream(dataStream, "id, timestamp.rowtime as ts, temperature");
1.4.2.2 定義 Table Schema 時指定
這種方法只要在定義 Schema 的時候,將事件時間字段,並指定成 rowtime 就可以了。
代碼如下:
tableEnv.connect(
new FileSystem().path("sensor.txt"))
.withFormat(new Csv())
.withSchema(new Schema()
.field("id", DataTypes.STRING())
.field("timestamp", DataTypes.BIGINT())
.rowtime(
new Rowtime()
.timestampsFromField("timestamp") // 從字段中提取時間戳
.watermarksPeriodicBounded(1000) // watermark 延遲 1 秒
)
.field("temperature", DataTypes.DOUBLE())
) // 定義表結構
.createTemporaryTable("inputTable"); // 創建臨時表
1.4.2.3 創建表的 DDL 中指定
事件時間屬性,是使用 CREATE TABLE DDL 中的 WARDMARK 語句定義的。watermark 語 句,定義現有事件時間字段上的 watermark 生成表達式,該表達式將事件時間字段標記爲事 件時間屬性。
代碼如下:
String sinkDDL = "create table dataTable (" +
" id varchar(20) not null, " +
" ts bigint, " +
" temperature double, " +
" rt AS TO_TIMESTAMP( FROM_UNIXTIME(ts) ), " +
" watermark for rt as rt - interval '1' second" +
") with (" +
" 'connector.type' = 'filesystem', " + " 'connector.path' = '/sensor.txt', " + " 'format.type' = 'csv')";
tableEnv.sqlUpdate(sinkDDL);
這裏 FROM_UNIXTIME 是系統內置的時間函數,用來將一個整數( 秒數) 轉換成 “YYYY-MM-DD hh:mm:ss”格式(默認,也可以作爲第二個 String 參數傳入)的日期時間 字符串(date time string);然後再用 TO_TIMESTAMP 將其轉換成 Timestamp。
二.案例
代碼:
package org.flink.tableapi;
import org.flink.beans.SensorReading;
import org.apache.flink.streaming.api.TimeCharacteristic;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.table.api.Over;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.Tumble;
import org.apache.flink.table.api.java.StreamTableEnvironment;
import org.apache.flink.types.Row;
/**
* @author 只是甲
* @date 2021-09-30
*/
public class TableTest5_TimeAndWindow {
public static void main(String[] args) throws Exception {
// 1. 創建環境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 2. 讀入文件數據,得到DataStream
DataStream<String> inputStream = env.readTextFile("C:\\Users\\Administrator\\IdeaProjects\\FlinkStudy\\src\\main\\resources\\sensor.txt");
// 3. 轉換成POJO
DataStream<SensorReading> dataStream = inputStream.map(line -> {
String[] fields = line.split(",");
return new SensorReading(fields[0], new Long(fields[1]), new Double(fields[2]));
})
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<SensorReading>(Time.seconds(2)) {
@Override
public long extractTimestamp(SensorReading element) {
return element.getTimestamp() * 1000L;
}
});
// 4. 將流轉換成表,定義時間特性
// Table dataTable = tableEnv.fromDataStream(dataStream, "id, timestamp as ts, temperature as temp, pt.proctime");
Table dataTable = tableEnv.fromDataStream(dataStream, "id, timestamp as ts, temperature as temp, rt.rowtime");
tableEnv.registerTable("sensor", dataTable);
// 5. 窗口操作
// 5.1 Group Window
// table API
Table resultTable = dataTable.window(Tumble.over("10.seconds").on("rt").as("tw"))
.groupBy("id, tw")
.select("id, id.count, temp.avg, tw.end");
// SQL
Table resultSqlTable = tableEnv.sqlQuery("select id, count(id) as cnt, avg(temp) as avgTemp, tumble_end(rt, interval '10' second) " +
"from sensor group by id, tumble(rt, interval '10' second)");
// 5.2 Over Window
// table API
Table overResult = dataTable.window(Over.partitionBy("id").orderBy("rt").preceding("2.rows").as("ow"))
.select("id, rt, id.count over ow, temp.avg over ow");
// SQL
Table overSqlResult = tableEnv.sqlQuery("select id, rt, count(id) over ow, avg(temp) over ow " +
" from sensor " +
" window ow as (partition by id order by rt rows between 2 preceding and current row)");
// dataTable.printSchema();
// tableEnv.toAppendStream(resultTable, Row.class).print("result");
// tableEnv.toRetractStream(resultSqlTable, Row.class).print("sql");
tableEnv.toAppendStream(overResult, Row.class).print("result");
tableEnv.toRetractStream(overSqlResult, Row.class).print("sql");
env.execute();
}
}
測試記錄: