Flink Table API与SQL(二)

Table API

Table API是Apache Flink的统一关系型API,支持批处理和流处理,它是SQL语言的超集,Flink Table API可以使用Scala、Java和Python语言进行编写,在实际开发中Table API没有SQL编程方式简便,建议在开发中使用SQL编程方式,下面我们对一些重点的Table API使用进行介绍。

基础操作

Flink Table API基础操作较为简单,这里以Java代码为例,直接给出每种操作使用方式,不再进行案例演示。注意,以下所有操作在Flink批和流处理中都支持。

1) From

From与SQL中的From子句类似,可以对一个注册的表进行扫描得到Table对象,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");

2) FromValues

FromValues和SQL查询中的VALUES 子句类似,可以基于给定的行生成一张表,可以通过row(...)表达式创建复合行,适用于批和流处理。

复制代码
Table table = tEnv.fromValues(
   row(1, "ABC"),
   row(2L, "ABCDE")
);

以上方式会根据输入的数据类型自动获取推断类型,也可以手动明确指定出类型。

复制代码
Table table = tEnv.fromValues(
DataTypes.ROW(
        DataTypes.FIELD("id", DataTypes.DECIMAL(10, 2)),
        DataTypes.FIELD("name", DataTypes.STRING())
), 
row(1, "ABC"),
row(2L, "ABCDE")
);

3) Select&As

Select和SQL中的Select子句类似,执行一个Select查询,执行查询后可以通过as方法重命名字段,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.select($("a"), $("c").as("d"));

在Select中还可以选择星号(*)作为通配符,查询 表中的所有列。

复制代码
Table result = orders.select($("*"));

4) Where&filter

Where和SQL中的Where子句类似,用来过滤数据,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.where($("b").isEqual("red"));

对于数据过滤也可以使用Filter完成。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.filter($("b").isEqual("red"));

5) Distinct

Distinct和SQL中的Distinct子句类似,对数据进行去重,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.distinct();

6) InsertInto

InsertInto和SQL查询中的Insert into 子句类似,可以将Table数据写出到注册的输出表中。已注册表的Schema必须与查询中的schema相匹配,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");
orders.insertInto("OutOrders").execute();

7) AddColumns

AddColumns可以对Table添加字段,添加字段名称不能已经存在于Table中,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.addColumns(concat($("c"), "sunny"));

8) AddOrReplaceColumns

AddOrReplaceColumns执行字段添加操作。 如果添加的列名称和已存在的列名称相同,则已存在的字段将被替换。 此外,如果添加的字段里面有重复的字段名,则会使用最后一个字段,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.addOrReplaceColumns(concat($("c"), "sunny").as("desc"));

9) DropColumns

删除列操作,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.dropColumns($("b"), $("c"));

10) RenameColumns

对字段进行重命名操作, 字段表达式应该是别名表达式,并且仅当字段已存在时才能被重命名,适用于批和流处理。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.renameColumns($("b").as("b2"), $("c").as("c2"));

表连接操作

Flink Table表连接操作包括union、unionAll、Intersect、IntersectAll、minus、minusAll、Join操作,关于Join操作后续小节再做介绍,这里对除了Join外的其他表连接操作进行演示并给出对应的案例。

1) Union

Union和SQL中的union子句类似,可以对两张表进行合并,两张表必须有相同的字段类型,Union会自动删除两张表中重复的记录,只支持批操作,不支持流操作

复制代码
Table left = tableEnv.from("orders1");
Table right = tableEnv.from("orders2");
left.union(right);

2) UnionAll

UnionAll和SQL 中Union All类似,可以对两张表进行合并,两张表必须有相同的字段类型,与Union不同,该操作不会删除两张表中重复的记录。批流都支持。

复制代码
Table left = tableEnv.from("orders1");
Table right = tableEnv.from("orders2");
left.unionAll(right);

3) Intersect

Intersect和SQL中Intersect子句类似,可以对两张表做连接,返回两张表中都存在的记录,要求两张表具有相同的字段类型。如果一条记录在一张或者两张表中存在多次,则只返回一条记录。返回的结果中不存在重复记录。只支持批操作。

复制代码
Table left = tableEnv.from("orders1");
Table right = tableEnv.from("orders2");
left.intersect(right);

4) IntersectAll

IntersetcAll和SQL中Intersect All子句类似,可以对两张表做关联,返回两张表中都存在的记录,要求两张表具有相同的字段类型。如果一条记录在两张表中出现多次,那么也会返回到结果中,结果表中会有重复记录。只支持批操作。

复制代码
Table left = tableEnv.from("orders1");
Table right = tableEnv.from("orders2");
left.intersectAll(right);

5) Minus

Minus和SQL中EXCEPT子句类似,Minus可以对两张表关联,返回左标中存在且右表中不存在的记录,要求两张表具有相同的字段类型。左表中重复记录只返回一次,结果表中不会存在重复记录。只支持批操作。

复制代码
Table left = tableEnv.from("orders1");
Table right = tableEnv.from("orders2");
left.minus(right);

6) MinusAll

MinusAll与SQL中EXCEPT ALL子句类似,MinusAll可以对两张表关联,返回右表中不存在的记录,如果一条相同数据在左表中出现 n 次且在右表中出现 m 次的记录,在结果表中出现 (n - m) 次。要求两张表具有相同的字段类型。只支持批操作。

复制代码
Table left = tableEnv.from("orders1");
Table right = tableEnv.from("orders2");
left.minusAll(right);

7) In

In和SQL中In子句类似,如果表达式的值存在于给定表的子查询中,那么In子句返回true。子查询必须由一列组成,这个列必须与表达式具有相同的数据类型。批流操作都支持。

复制代码
Table left = tableEnv.from("Orders1")
Table right = tableEnv.from("Orders2");
Table result = left.select($("a"), $("b"), $("c")).where($("a").in(right));

结合以下案例来理解以上Table API表连接操作,以下案例中创建了两个table:t1,t2,每个Table中存在重复数据,两表中也有部分相同数据,由于Java代码和Scala代码类似,这里只给出Java代码案例。

java 复制代码
EnvironmentSettings settings = EnvironmentSettings.newInstance()
        .inBatchMode()
        .build();
​
TableEnvironment tableEnv = TableEnvironment.create(settings);
​
Table t1 = tableEnv.fromValues(
        row(1, "zs", 18),
        row(1, "zs", 18),
        row(2, "ls", 19),
        row(2, "ls", 19),
        row(3, "ww", 20),
        row(4, "ml", 21),
        row(5, "tq", 22)
);
​
Table t2 = tableEnv.fromValues(
        row(1, "zs", 18),
        row(2, "ls", 19),
        row(2, "ls", 19),
        row(3, "ww", 20),
        row(3, "ww", 20),
        row(4, "ml", 21),
        row(6, "gb", 23)
);
​
//打印Schema
t1.printSchema();
t2.printSchema();
​
// 1. union
Table union = t1.union(t2);
union.execute().print();
​
// 2. unionAll
Table unionAll = t1.unionAll(t2);
unionAll.execute().print();
​
// 3. intersect
Table intersect = t1.intersect(t2);
intersect.execute().print();
​
// 4. intersectAll
Table intersectAll = t1.intersectAll(t2);
intersectAll.execute().print();
​
// 5. minus
Table minus = t1.minus(t2);
minus.execute().print();
​
// 6. minusAll
Table minusAll = t1.minusAll(t2);
minusAll.execute().print();
​
// 7. In
Table in = t1.select($("f0"),$("f1"),$("f2")).where($("f0").in(2,3));
in.execute().print();

以上代码对应的结果如下:

复制代码
#t1、t2 Schema
(
  `f0` INT NOT NULL,
  `f1` CHAR(2) NOT NULL,
  `f2` INT NOT NULL
)
(
  `f0` INT NOT NULL,
  `f1` CHAR(2) NOT NULL,
  `f2` INT NOT NULL
)
​
#union结果
+-------------+--------------------------------+-------------+
|          f0 |                             f1 |          f2 |
+-------------+--------------------------------+-------------+
|           3 |                             ww |          20 |
|           4 |                             ml |          21 |
|           6 |                             gb |          23 |
|           1 |                             zs |          18 |
|           2 |                             ls |          19 |
|           5 |                             tq |          22 |
+-------------+--------------------------------+-------------+
​
#unionAll结果
+-------------+--------------------------------+-------------+
|          f0 |                             f1 |          f2 |
+-------------+--------------------------------+-------------+
|           1 |                             zs |          18 |
|           1 |                             zs |          18 |
|           2 |                             ls |          19 |
|           2 |                             ls |          19 |
|           3 |                             ww |          20 |
|           4 |                             ml |          21 |
|           5 |                             tq |          22 |
|           1 |                             zs |          18 |
|           2 |                             ls |          19 |
|           2 |                             ls |          19 |
|           3 |                             ww |          20 |
|           3 |                             ww |          20 |
|           4 |                             ml |          21 |
|           6 |                             gb |          23 |
+-------------+--------------------------------+-------------+
​
#intersect结果
+-------------+--------------------------------+-------------+
|          f0 |                             f1 |          f2 |
+-------------+--------------------------------+-------------+
|           2 |                             ls |          19 |
|           1 |                             zs |          18 |
|           3 |                             ww |          20 |
|           4 |                             ml |          21 |
+-------------+--------------------------------+-------------+
#intersectAll 结果
+-------------+--------------------------------+-------------+
|          f0 |                             f1 |          f2 |
+-------------+--------------------------------+-------------+
|           1 |                             zs |          18 |
|           3 |                             ww |          20 |
|           4 |                             ml |          21 |
|           2 |                             ls |          19 |
|           2 |                             ls |          19 |
+-------------+--------------------------------+-------------+
​
#minus结果
+-------------+--------------------------------+-------------+
|          f0 |                             f1 |          f2 |
+-------------+--------------------------------+-------------+
|           5 |                             tq |          22 |
+-------------+--------------------------------+-------------+
​
#minusAll结果
+-------------+--------------------------------+-------------+
|          f0 |                             f1 |          f2 |
+-------------+--------------------------------+-------------+
|           1 |                             zs |          18 |
|           5 |                             tq |          22 |
+-------------+--------------------------------+-------------+
​
#In查询结果
+-------------+--------------------------------+-------------+
|          f0 |                             f1 |          f2 |
+-------------+--------------------------------+-------------+
|           2 |                             ls |          19 |
|           2 |                             ls |          19 |
|           3 |                             ww |          20 |
+-------------+--------------------------------+-------------+

Order By 操作

1) order by

OrderBy和SQL中Order by 子句类似,返回跨所有并行分区的全局有序记录,对于无界表,该操作可以对时间属性进行排序或进行后续的fetch操作,适用于批和流处理。

复制代码
Table result = tab.orderBy($("a").asc());

2) offset&Fetch

Offset和Fetch类似于SQL中的OFFSET 和FETCH子句,Offset操作根据偏移位置来限定已排序的结果集,Fetch操作将排序的结果集限制为前n行。在无界表中offset操作需要和fetch一起使用,适用于批和流处理。

复制代码
// 从已排序的结果集中返回前5条记录
Table result1 = in.orderBy($("a").asc()).fetch(5);
// 从已排序的结果集中返回跳过3条记录之后的所有记录
Table result2 = in.orderBy($("a").asc()).offset(3);
// 从已排序的结果集中返回跳过10条记录之后的前5条记录
Table result3 = in.orderBy($("a").asc()).offset(10).fetch(5);

下面通过案例来演示orderby 、offset以及fetch的使用。案例是读取socket中基站日志数据,按照通话时长排序,跳过前2条数据继续获取3条数据进行展示,由于Java代码和Scala代码类似,这里给出java代码案例。

java 复制代码
//准备Stream Execution Environment
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
​
//准备TableEnvironment
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
​
SingleOutputStreamOperator<StationLog> ds = env.socketTextStream("node5", 9999)
        .map(new MapFunction<String, StationLog>() {
            @Override
            public StationLog map(String s) throws Exception {
                return new StationLog(
                        s.split(",")[0],
                        s.split(",")[1],
                        s.split(",")[2],
                        s.split(",")[3],
                        Long.valueOf(s.split(",")[4]),
                        Long.valueOf(s.split(",")[5]));
            }
        });
​
Table table = tableEnv.fromDataStream(
        ds,
        Schema.newBuilder()
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
                .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
                .build()
);
​
//按照duration升序排序,跳过前2条,获取之后的3条数据
Table result = table.orderBy($("duration").asc()).offset(2).fetch(3);
​
result.execute().print();

以上代码执行后,向socket中输入如下数据,可以看到随着实时数据的输入,数据结果也有更新输出,最终统计的是按照输入数据的通话时长排序,输出第3-5条数据。

复制代码
002,184,185,success,6000,60
001,181,182,success,1000,30
002,182,183,success,3000,20
001,183,184,success,2000,10
003,181,183,success,4000,40
001,183,184,success,7000,50
002,182,183,success,3000,15

Over Window操作

假设现在我们有一批用户访问网站停留时长信息表,共有3列:用户ID、访问时间、停留时长,数据如下:

复制代码
用户ID	      访问时间		   停留时长(秒)
uid1		1000			1
uid1		4000			2
uid1		8000			3
uid2		2000			4
uid2		7000			5
uid2		15000			6
uid3		3000			7
uid3		12000			8
uid3		25000			9
uid4		30000			10

现在我们如果想要统计每个用户停留总时长,可以直接按照用户ID分组,对停留时长进行聚合统计,这样计算的结果是每个用户ID都最终对应一条数据,如下:

复制代码
uid1 6
uid2 15
uid3 24
uid4 10

如果我们现在的需求是当每个用户有一条数据产生后,我们就统计该用户对应到此条数据的累计停留时长,如下:

复制代码
用户ID         访问时间               停留时长(秒)              累计停留总时长
uid1		1000			1			1
uid1		4000			2			3
uid1		8000			3			6
uid2		2000			4			4
uid2		7000			5			9
uid2		15000			6			15
uid3		3000			7			7
uid3		12000			8			15
uid3		25000			9			24
uid4		30000			10			10

对于以上这种需求我们就需要使用Over开窗函数来解决。在Flink中Over Window指的就是开窗函数,普通的分组聚合(sum/max/min/count)是将数据按照某个或多个列的值进行分组,然后对每个分组进行聚合计算,最终每个分组只返回一个聚合结果,而开窗函数可以为每个行返回一个或多个聚合值。

在Flink Table API中,开窗函数可以应用于批或者流处理,使用形式如下:

复制代码
Table table = input
// 定义开窗函数并将窗口赋值给一个别名 w
  .window(Over
          .partitionBy($("a"))
          .orderBy($("rowtime"))
          .preceding(UNBOUNDED_RANGE)
          .following(CURRENT_RANGE)
          .as("w")
)
  //基于窗口w上进行聚合统计
  .select(
$("a"), 
$("b").sum().over($("w")), 
$("c").min().over($("w"))
); 

以上使用方式中,Table API通过Over函数定义了窗口的属性,可以基于ProcessTime或者EventTime以及指定时间间隔或者行计数方式方式来定义Over Window开窗函数,具体如下:

Ø Partition By

可选项,在一个或者多个属性上定义输入的分组,每个分组单独排序,聚合函数分别应用于每个分组。在Flink流式处理中,如果不指定partition by 而设置Over开窗函数,则Flink所有数据分到一个分组中,并由一个并行度处理。

Ø Order By

必选项,定义每个分组内行的排序,从而定义聚合函数应用于行的顺序,默认升序不可修改。对于Flink流式处理,order by 必须指定事件时间或者处理时间属性列,目前,仅支持单个排序属性。

Ø Preceding

可选项,定义了包含在窗口中并位于当前行之前的行间隔。间隔可以是时间间隔或行计数间隔,默认为时间间隔。

对于有界数据设置Over开窗函数时,preceding可以设置时间间隔或者行计数间隔,设置方式如下:

复制代码
// 有界的事件时间 over window(假定有一个叫“rowtime”的事件时间属性)
.window(Over.partitionBy($("a")).orderBy($("rowtime")).preceding(lit(1).minutes()).as("w"));
// 有界的处理时间 over window(假定有一个叫“proctime”的处理时间属性)
.window(Over.partitionBy($("a")).orderBy($("proctime")).preceding(lit(1).minutes()).as("w"));
// 有界的事件时间行数 over window(假定有一个叫“rowtime”的事件时间属性)
.window(Over.partitionBy($("a")).orderBy($("rowtime")).preceding(rowInterval(10)).as("w")); 
// 有界的处理时间行数 over window(假定有一个叫“proctime”的处理时间属性)
.window(Over.partitionBy($("a")).orderBy($("proctime")).preceding(rowInterval(10)).as("w"));

对于无界数据设置Over开窗函数时,preceding通过常量来指定。例如,用UNBOUNDED_RANGE指定时间间隔或用UNBOUNDED_ROW指定行计数间隔,设置方式如下:

复制代码
// 无界的事件时间 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"));
// 无界的事件时间行数 over window(假定有一个叫“rowtime”的事件时间属性)
.window(Over.partitionBy($("a")).orderBy($("rowtime")).preceding(UNBOUNDED_ROW).as("w"));
// 无界的处理时间行数 over window(假定有一个叫“proctime”的处理时间属性)
.window(Over.partitionBy($("a")).orderBy($("proctime")).preceding(UNBOUNDED_ROW).as("w"));

如果在设置Over开窗函数时不指定preceding,Preceding默认值为UNBOUNDED_RANGE。

Ø Following

可选项,定义包含在窗口中并在当前行之后的行间隔。间隔必须与Preceding指定的单位相同,要么都是时间间隔,要么都是行计数间隔。

当指定了Following参数后,其与Preceding指定的参数组成整个开窗函数的窗口范围。特别需要注意的是目前Flink批和流处理中暂不支持FOLLOWING 往后指定时间间隔或者行计数间隔,只能设置两个值:CURRENT_ROW/CURRENT_RANGE,时间间隔窗口的上限为CURRENT_RANGE,行计数间隔窗口的上限为CURRENT_ROW。如果该值不设置,针对时间间隔窗口或者行计数间隔窗口以上两个值分别为默认值。

Ø as

必须项,为Over开窗函数指定别名,别名用于在之后的select子句中方便引用该窗口。值得注意的是目前在同一个select()中调用的所有聚合函数必须基于同一个Over窗口上计算。

下面通过2个案例分别来演示Table API Over开窗函数中以时间间隔和行间隔设置Over窗口并进行聚合计算。

案例一:读取基站日志数据,设置时间间隔Over开窗函数,统计最近2秒每个基站的通话时长。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
​
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
​
//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000");
​
//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });
​
Table table = tableEnv.fromDataStream(stationLogDS,
        Schema.newBuilder()
                //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
                //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
                .build());
​
//设置基于时间间隔的Over开窗函数
OverWindowedTable window = table.window(
        Over.partitionBy($("sid"))
                .orderBy($("rowtime"))
                .preceding(lit(2).second())
                .following(CURRENT_RANGE)
                .as("w")
);
​
Table result = window.select(
        $("sid"),
        $("duration"),
        $("callTime"),
        $("duration").sum().over($("w")).as("sum_duration"));
​
//输出结果
result.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
​
//导入隐式转换
import org.apache.flink.streaming.api.scala._
​
//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)
​
//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000")
​
val ds: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })
​
val table: Table = tableEnv.fromDataStream(ds,
  Schema.newBuilder()
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build())
​
//设置基于时间间隔的Over开窗函数
val window: OverWindowedTable = table.window(
  Over partitionBy $"sid" orderBy $"rowtime" preceding 2.seconds() following CURRENT_RANGE as "w"
)
​
val result: Table = window.select(
  $"sid",
  $"duration",
  $"callTime",
  $"duration".sum over $"w" as "sum_duration"
)
​
//打印结果
result.execute().print()

以上Java代码和Scala代码编程中需要注意以下节点:

  1. Java代码和Scala 代码Over开窗函数写法略有不同

  2. 代码中时间语义使用EventTime并设置了watermark,需要指定table.exec.source.idle-timeout参数自动推进watermark

  3. Over窗口函数数据结果输出时,当watermark大于或等于某条数据的事件时间时,该条数据及对应Over窗口此刻聚合结果才会输出。

  4. Over窗口函数中指定的Preceding 2秒是以watermark值为标准往前推2秒作为Over窗口的下边界。

代码编写完成运行后,向socket中输入如下数据:

复制代码
#socket-9999中输入数据
001,184,185,success,1000,10
002,181,182,success,1000,20
001,184,185,success,2000,30
002,181,182,success,2000,40
#此条输入后,wm为1s,输出每行有Over窗口聚合结果,Over窗口统计边界为[-1s~-1s]
001,182,183,success,3000,50
002,182,183,success,3000,60
#此条输入后,wm为2s,输出每行有Over窗口聚合结果,Over窗口统计边界为[0s~2s]
001,183,184,success,4000,70
002,181,183,success,4000,80
001,183,184,success,5000,90
#此条输入后,wm为4s,输出每行有Over窗口聚合结果,Over窗口统计边界为[2s~4s]
001,182,183,success,6000,100
#此条输入后,wm为5s,输出每行有Over窗口聚合结果,Over窗口统计边界为[3s~5s]
001,182,183,success,7000,110

随着socket中不断输入数据,可以看到Over开窗函数统计的窗口范围为:当前数据事件时间减去2秒\~当前条数据(以watermark为标准)。控制台对应输出结果如下:

复制代码
+----+--------+------------+---------+--------------+
| op |    sid |   duration |callTime | sum_duration |
+----+--------+------------+---------+--------------+
| +I |    001 |         10 |    1000 |           10 |
| +I |    002 |         20 |    1000 |           20 |
| +I |    002 |         40 |    2000 |           60 |
| +I |    001 |         30 |    2000 |           40 |
| +I |    002 |         60 |    3000 |          120 |
| +I |    001 |         50 |    3000 |           90 |
| +I |    002 |         80 |    4000 |          180 |
| +I |    001 |         70 |    4000 |          150 |
| +I |    001 |         90 |    5000 |          210 |

案例二:读取基站日志数据,设置行间隔Over开窗函数,统计最近2条每个基站的通话时长。

  • Java代码
java 复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
​
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
​
//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000");
​
//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });
​
Table table = tableEnv.fromDataStream(stationLogDS,
        Schema.newBuilder()
                //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
                //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
                .build());
​
//设置基于行计数间隔的Over开窗函数
OverWindowedTable window = table.window(
        Over.partitionBy($("sid"))
                .orderBy($("rowtime"))
                .preceding(rowInterval(2L))
                .following(CURRENT_ROW)
                .as("w")
);
​
Table result = window.select(
        $("sid"),
        $("duration"),
        $("callTime"),
        $("duration").sum().over($("w")).as("sum_duration"));
​
//输出结果
result.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
​
//导入隐式转换
import org.apache.flink.streaming.api.scala._
​
//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)
​
//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000")
​
val ds: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })
​
val table: Table = tableEnv.fromDataStream(ds,
  Schema.newBuilder()
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build())
​
//设置基于行计数间隔的Over开窗函数
val window: OverWindowedTable = table.window(
  Over partitionBy $"sid" orderBy $"rowtime" preceding 2.rows following CURRENT_ROW as "w"
)
​
val result: Table = window.select(
  $"sid",
  $"duration",
  $"callTime",
  $"duration".sum over $"w" as "sum_duration"
)
​
//打印结果
result.execute().print()

案例二中的编写代码注意事项与案例一注意事项类似:

  1. Java代码和Scala 代码Over开窗函数写法略有不同

  2. 代码中时间语义使用EventTime并设置了watermark,需要指定table.exec.source.idle-timeout参数自动推进watermark

  3. Over窗口函数数据结果输出时,当watermark大于或等于某条数据的事件时间时,该条数据及对应Over窗口此刻聚合结果才会输出。

  4. Over窗口函数中指定的Preceding 2 rows是以watermark值为标准往前推2条数据作为Over窗口的下边界。

代码编写完成运行后,向socket中输入如下数据:

复制代码
#socket-9999中输入数据
001,184,185,success,1000,10
002,181,182,success,1000,20
001,184,185,success,2000,30
002,181,182,success,2000,40
#此条输入后,wm为1s,输出每行有Over窗口聚合结果
001,182,183,success,3000,50
002,182,183,success,3000,60
#此条输入后,wm为2s,输出每行有Over窗口聚合结果
001,183,184,success,4000,70
002,181,183,success,4000,80
#此条输入后,wm为3s,输出每行有Over窗口聚合结果
001,183,184,success,5000,90
#此条输入后,wm为4s,输出每行有Over窗口聚合结果
001,182,183,success,6000,100
#此条输入后,wm为5s,输出每行有Over窗口聚合结果
001,182,183,success,7000,110

随着socket中不断输入数据,可以看到Over开窗函数统计的窗口范围为:当前数据往前推2条数据\~当前条数据(以watermark为标准)。输入结果虽然与案例一一样,但是两者Over开窗函数计算的原理不同,控制台对应输出结果如下:

复制代码
+----+--------+------------+---------+--------------+
| op |    sid |   duration |callTime | sum_duration |
+----+--------+------------+---------+--------------+
| +I |    001 |         10 |    1000 |           10 |
| +I |    002 |         20 |    1000 |           20 |
| +I |    002 |         40 |    2000 |           60 |
| +I |    001 |         30 |    2000 |           40 |
| +I |    002 |         60 |    3000 |          120 |
| +I |    001 |         50 |    3000 |           90 |
| +I |    002 |         80 |    4000 |          180 |
| +I |    001 |         70 |    4000 |          150 |
| +I |    001 |         90 |    5000 |          210 |

聚合操作

1) GroupBy分组聚合

GroupBy和SQL中Group By子句一样,按照某列对数据进行分组,往往group by 后会跟上聚合算子按照分组对数据进行聚合。Group By适用于批和流。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders.groupBy($("a")).select($("a"), $("b").sum().as("d"));

注意:对于流处理,聚合得到的结果会存储在状态中,随着数据的实时流入,状态会越来越大,最好设置状态保存的时效,避免状态无限扩大。

2) GroupBy Window 聚合

GroupBy Window指的是设置window窗口后,必须跟上GroupBy算子按照窗口进行分组聚合,关于窗口设置,后续小节再做介绍。GroupBy Window适用于批和流。

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders
 // 定义窗口
.window(Tumble.over(lit(5).minutes()).on($("rowtime")).as("w"))
// 按窗口和键分组  
.groupBy($("a"), $("w")) 
// 访问窗口属性并聚合  
.select(
        $("a"), 
       $("w").start(),
        $("w").end(),
        $("w").rowtime(),
        $("b").sum().as("d")
    );

3) Over Window聚合

上一小节已经介绍过Table API中Over Window开窗函数,类似SQL中的OVER 子句。使用示例如下:

复制代码
Table orders = tableEnv.from("Orders");
Table result = orders
// 定义窗口
.window(
Over
          .partitionBy($("a"))
          .orderBy($("rowtime"))
          .preceding(UNBOUNDED_RANGE)
          .following(CURRENT_RANGE)
          .as("w"))
// 滑动聚合
.select(
        $("a"),
        $("b").avg().over($("w")),
        $("b").max().over($("w")),
        $("b").min().over($("w"))
);

在Table API中使用Over开窗函数时,order by 排序列必须指定为单一的时间属性,所有的聚合必须定义在同一个窗口上。目前Flink批和流处理中只支持PRECEDING到当前行范围的窗口,暂不支持FOLLOWING 范围的窗口。

4) Distinct 聚合

这里说的Distinct 聚合与SQL中Count(distinct a)聚合子句类似,在Flink Table中Distinct可以应用于GroupBy 聚合、GroupBy Window聚合、Over Window 聚合。可以对批和流处理使用。

复制代码
Table orders = tableEnv.from("Orders");
// 按属性分组后的的互异(互不相同、去重)聚合
Table groupByDistinctResult = orders
.groupBy($("a"))
.select($("a"), $("b").sum().distinct().as("d"));
​
// 按属性、时间窗口分组后的互异(互不相同、去重)聚合
Table groupByWindowDistinctResult = orders
.window(Tumble
         .over(lit(5).minutes())
         .on($("rowtime"))
         .as("w")
 )
.groupBy($("a"), $("w"))
.select($("a"), $("b").sum().distinct().as("d"));
​
// over window 上的互异(互不相同、去重)聚合
Table result = orders
.window(Over
    .partitionBy($("a"))
    .orderBy($("rowtime"))
    .preceding(UNBOUNDED_RANGE)
    .as("w"))
.select(
        $("a"), $("b").avg().distinct().over($("w")),
        $("b").max().over($("w")),
        $("b").min().over($("w"))
   );

Flink Table API 聚合计算随着实时流的增多可能会导致状态不断增大,必要时请设置状态保存时间以防止状态无限增大。关于窗口聚合操作后续小节会进一步介绍,这里不再演示聚合操作案例。

Join操作

1) Inner Join

Inner Join类似SQL中的Inner Join,用于关联两张表,两张表字段名不能存在相同,并且必须通过join算子或者使用where/filter算子定义至少一个join等式连接条件。可以对批和流处理使用。

复制代码
Table left = tableEnv.from("MyTable").select($("a"), $("b"), $("c"));
Table right = tableEnv.from("MyTable").select($("d"), $("e"), $("f"));
Table result = left.join(right)
.where($("a").isEqual($("d")))
    .select($("a"), $("b"), $("e"));

2) Outer Join

Outer Join类似SQL LEFT/RIGHT/FULL outer join 子句,用于关联两张表,两张表字段名不能存在相同,并且必须定义至少一个等式连接条件。可以对批和流处理使用。

复制代码
Table left = tableEnv.from("MyTable").select($("a"), $("b"), $("c"));
Table right = tableEnv.from("MyTable").select($("d"), $("e"), $("f"));
Table leftOuterResult = left.leftOuterJoin(right, $("a").isEqual($("d")))
                            .select($("a"), $("b"), $("e"));
Table rightOuterResult = left.rightOuterJoin(right, $("a").isEqual($("d")))
                            .select($("a"), $("b"), $("e"));
Table fullOuterResult = left.fullOuterJoin(right, $("a").isEqual($("d")))
                            .select($("a"), $("b"), $("e"));

3) Interval Join

Interval Join 类似 DataStream API中Interval Join可以在指定时间区间内关联两个流数据。Flink Table API中的Interval Join也是基于时间区间进行关联,使用时至少需要一个equal join关联条件和一个限制两个流关联时间范围的条件,限制时间范围的条件可以基于两流ProcessTime/EventTime来定义。Interval Join可以对批和流处理使用。

例如,对于两个输入表 left 和 right,假设它们具有时间属性 ltime 和 rtime,我们可以通过如下方式进行 interval join:

复制代码
Table left = tableEnv.from("MyTable").select($("a"), $("b"), $("c"), $("ltime"));
Table right = tableEnv.from("MyTable").select($("d"), $("e"), $("f"), $("rtime"));
Table result = left.join(right)
  .where(
and(
        $("a").isEqual($("d")),
        $("ltime").isGreaterOrEqual($("rtime").minus(lit(5).minutes())),
        $("ltime").isLess($("rtime").plus(lit(10).minutes()))
    ))
  .select($("a"), $("b"), $("e"), $("ltime"));

与聚合计算类似,Flink Table API 以上Join计算随着实时流的增多可能会导致状态不断增大,必要时请设置状态保存时间以防止状态无限增大。下面通过两个案例来演示Table API中fullOuterJoin和Interval Join使用。

案例一:读取socket中数据形成两流,进行FullOuterJoin操作

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000");

//读取socket-8888中基站日志数据并转换为Tuple类型DataStream
//1,zs,18,1000
SingleOutputStreamOperator<Tuple4<Integer, String, Integer, Long>> personInfo = env.socketTextStream("node5", 8888)
        .map(new MapFunction<String, Tuple4<Integer, String, Integer, Long>>() {
            @Override
            public Tuple4<Integer, String, Integer, Long> map(String s) throws Exception {
                return Tuple4.of(
                        Integer.valueOf(s.split(",")[0]),
                        s.split(",")[1],
                        Integer.valueOf(s.split(",")[2]),
                        Long.valueOf(s.split(",")[3]));
            }
        });

//读取socket-9999中基站日志数据并转换为Tuple类型DataStream
//1,zs,beijing,1000
SingleOutputStreamOperator<Tuple4<Integer, String, String, Long>> addressInfo = env.socketTextStream("node5", 9999)
        .map(new MapFunction<String, Tuple4<Integer, String, String, Long>>() {
            @Override
            public Tuple4<Integer, String, String, Long> map(String s) throws Exception {
                return Tuple4.of(
                        Integer.valueOf(s.split(",")[0]),
                        s.split(",")[1],
                        s.split(",")[2],
                        Long.valueOf(s.split(",")[3]));
            }
        });

Table personTbl = tableEnv.fromDataStream(personInfo,
        Schema.newBuilder()
                .column("f0", DataTypes.INT())
                .column("f1", DataTypes.STRING())
                .column("f2", DataTypes.INT())
                .column("f3", DataTypes.BIGINT())
                //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(f3,3)")
                //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
                .build())
        .as("left_id","left_name","age","left_rowtime","left_ltz_time");

Table addressTbl = tableEnv.fromDataStream(addressInfo,
        Schema.newBuilder()
                .column("f0", DataTypes.INT())
                .column("f1", DataTypes.STRING())
                .column("f2", DataTypes.STRING())
                .column("f3", DataTypes.BIGINT())
                //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(f3,3)")
                //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
                .build())
        .as("right_id","right_name","address","right_rowtime","right_ltz_time");

//打印schema
personTbl.printSchema();
addressTbl.printSchema();

//fullOuterJoin
Table resultTbl = personTbl.fullOuterJoin(
        addressTbl,$("left_id").isEqual($("right_id")))
        .select(
                $("left_id"),
                $("left_name"),
                $("age"),
                $("address"),
                $("left_rowtime"),
                $("right_rowtime"));

resultTbl.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.streaming.api.scala._

//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)

//读取socket-8888中基站日志数据并转换为Tuple类型DataStream
//1,zs,18,1000
val personInfo: DataStream[(Int, String, Int, Long)] = env.socketTextStream("node5", 8888)
  .map(line => {
    val arr = line.split(",")
   (arr(0).trim.toInt, arr(1).trim, arr(2).trim.toInt, arr(3).trim.toLong)
  })

//读取socket-9999中基站日志数据并转换为Tuple类型DataStream
//1,zs,beijing,1000
val addressInfo: DataStream[(Int, String, String, Long)] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    (arr(0).trim.toInt, arr(1).trim, arr(2).trim, arr(3).trim.toLong)
  })

val personTbl: Table = tableEnv.fromDataStream(personInfo,
  Schema.newBuilder()
    .column("_1",DataTypes.INT())
    .column("_2",DataTypes.STRING())
    .column("_3",DataTypes.INT())
    .column("_4",DataTypes.BIGINT())
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(_4,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build())
  .as("left_id","left_name","age","left_rowtime","left_ltz_time")

val addressTbl: Table = tableEnv.fromDataStream(addressInfo,
  Schema.newBuilder()
    .column("_1",DataTypes.INT())
    .column("_2",DataTypes.STRING())
    .column("_3",DataTypes.STRING())
    .column("_4",DataTypes.BIGINT())
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(_4,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build())
  .as("right_id","right_name","address","right_rowtime","right_ltz_time")

//打印表结构
personTbl.printSchema()
addressTbl.printSchema()

//fullOuterJoin
val resultTbl: Table = personTbl.fullOuterJoin(addressTbl,$"left_id" === $"right_id" && $"left_name" === $"right_name")
  .select(
    $("left_id"),
    $("left_name"),
    $("age"),
    $("address"),
    $("left_rowtime"),
    $("right_rowtime"))

resultTbl.execute().print()

以上案例Java和Scala代码中 通过将Tuple类型的DataStream转换成Table对象,默认生成的Table列在Java中为"f0、f1、f2、f3",在Scala中为"_1、_2、_3、_4",针对默认生成的Table对象通过as可以进行列重新命名,关联的两张表中不能有重复名称的列。

代码执行后,向socket中输入如下数据,每条数据输入后都会有对应结果输出。

复制代码
#socket-8888输入数据
1,zs,18,1000
#socket-9999输入数据
1,zs,beijing,1000
#socket-8888输入数据
2,ls,19,2000
#socket-9999输入数据
2,ls,shanghai,2000
#socket-8888输入数据
3,ww,20,3000
#socket-9999输入数据
4,ml,shenzhen,4000

对应输出的结果如下:

复制代码
(
  `left_id` INT,
  `left_name` STRING,
  `age` INT,
  `left_rowtime` BIGINT,
  `left_ltz_time` TIMESTAMP_LTZ(3) *ROWTIME*
)
(
  `right_id` INT,
  `right_name` STRING,
  `address` STRING,
  `right_rowtime` BIGINT,
  `right_ltz_time` TIMESTAMP_LTZ(3) *ROWTIME*
)

+----+---------+----------+-------+---------+--------------+--------------+
| op | left_id |left_name |   age | address | left_rowtime |right_rowtime |
+----+---------+----------+-------+---------+--------------+--------------+
| +I |       1 |       zs |    18 |  <NULL> |         1000 |       <NULL> |
| -D |       1 |       zs |    18 |  <NULL> |         1000 |       <NULL> |
| +I |       1 |       zs |    18 | beijing |         1000 |         1000 |
| +I |       2 |       ls |    19 |  <NULL> |         2000 |       <NULL> |
| -D |       2 |       ls |    19 |  <NULL> |         2000 |       <NULL> |
| +I |       2 |       ls |    19 |shanghai |         2000 |         2000 |
| +I |       3 |       ww |    20 |  <NULL> |         3000 |       <NULL> |
| +I |  <NULL> |   <NULL> |<NULL> |shenzhen |       <NULL> |         4000 |

案例二:读取用户登录流和广告点击流,通过IntervalJoin分析用户点击广告的行为。

  • Java代码
java 复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket-8888 中用户login 数据:user_1,6000
SingleOutputStreamOperator<Tuple2<String, Long>> loginDS = env.socketTextStream("node5", 8888)
        .map(new MapFunction<String, Tuple2<String, Long>>() {
            @Override
            public Tuple2<String, Long> map(String s) throws Exception {
                String[] split = s.split(",");
                return Tuple2.of(split[0], Long.valueOf(split[1]));
            }
        });

//读取socket-9999中的点击广告数据:user_1,product_1,3000
SingleOutputStreamOperator<Tuple3<String, String, Long>> clickDS =
        env.socketTextStream("node5", 9999)
                .map(new MapFunction<String, Tuple3<String, String, Long>>() {
                    @Override
                    public Tuple3<String, String, Long> map(String s) throws Exception {
                        String[] split = s.split(",");
                        return Tuple3.of(split[0], split[1], Long.valueOf(split[2]));
                    }
                });

//将DS转换成Table
Table loginTable = tableEnv.fromDataStream(loginDS,
        Schema.newBuilder()
                .column("f0", DataTypes.STRING())
                .column("f1", DataTypes.BIGINT())
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(f1,3)")
                .watermark("rowtime", "rowtime - INTERVAL '2' SECONDS")
                .build()
).as("left_uid", "left_dt", "left_rowtime");

Table clickTable = tableEnv.fromDataStream(clickDS,
        Schema.newBuilder()
                .column("f0", DataTypes.STRING())
                .column("f1", DataTypes.STRING())
                .column("f2", DataTypes.BIGINT())
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(f2,3)")
                .watermark("rowtime", "rowtime - INTERVAL '2' SECONDS")
                .build()
).as("right_uid", "right_adv", "right_dt","right_rowtime");

loginTable.printSchema();
clickTable.printSchema();

//interval Join
Table result = loginTable.join(clickTable)
        .where(
                and(
                        $("left_uid").isEqual($("right_uid")),
                        $("right_rowtime").isGreaterOrEqual($("left_rowtime").minus(lit(2).second())),
                        $("right_rowtime").isLess($("left_rowtime").plus(lit(2).second()))
                )
        ).select(
                $("left_uid"),
                $("left_rowtime"),
                $("right_adv"),
                $("right_rowtime")
        );

result.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
​
//导入隐式转换
import org.apache.flink.streaming.api.scala._
​
//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)
​
//读取socket-8888中基站用户登录数据,并转换为Tuple类型DataStream
//user_1,1000
val loginInfo: DataStream[(String, Long)] = env.socketTextStream("node5", 8888)
  .map(line => {
    val arr = line.split(",")
    (arr(0).trim, arr(1).trim.toLong)
  })
​
//读取socket-9999中用户点击广告数据并转换为Tuple类型DataStream
//user_1,product_1,1000
val clickInfo: DataStream[(String, String, Long)] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    (arr(0).trim, arr(1).trim, arr(2).trim.toLong)
  })
​
val loginTbl: Table = tableEnv.fromDataStream(loginInfo,
  Schema.newBuilder()
    .column("_1", DataTypes.STRING())
    .column("_2", DataTypes.BIGINT())
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(_2,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build())
  .as("left_uid", "left_dt", "left_rowtime")
​
val clickTbl: Table = tableEnv.fromDataStream(clickInfo,
  Schema.newBuilder()
    .column("_1", DataTypes.STRING())
    .column("_2", DataTypes.STRING())
    .column("_3", DataTypes.BIGINT())
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(_3,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build())
  .as("right_uid", "right_adv", "right_dt", "right_rowtime")
​
​
//打印表结构
loginTbl.printSchema()
clickTbl.printSchema()
​
//interval join
val result: Table = loginTbl.join(clickTbl)
  .where($"left_uid" === $"right_uid" &&
    $"right_rowtime" >= $"left_rowtime" - 2.second() &&
    $"right_rowtime" < $"left_rowtime" + 2.second()
  ).select($"left_uid", $"left_rowtime", $"right_adv", $"right_rowtime")
​
result.execute().print()

以上Java和Scala编码中设置登录流和点击流进行Interval Join,登录流为左流,点击流为右流,两流按照uid进行关联,并且设置Interval Join关联范围为右流事件时间大于等于左流事件时间减去2秒,小于左流事件时间加上2秒。代码编写完成后,向socket-8888和socket-9999中输入如下数据:

复制代码
#socket-8888中数据流
user_1,6000
​
#socket-9999中数据流
user_1,product_1,3000
user_1,product_2,4000
user_1,product_3,5000
user_1,product_4,6000
user_1,product_5,7000
user_1,product_6,8000
user_1,product_7,9000

控制台中输入如下结果,可以看到Table API 中Interval Join按照设置方式进行数据匹配关联。

复制代码
(
  `left_uid` STRING,
  `left_dt` BIGINT,
  `left_rowtime` TIMESTAMP_LTZ(3) *ROWTIME*
)
(
  `right_uid` STRING,
  `right_adv` STRING,
  `right_dt` BIGINT,
  `right_rowtime` TIMESTAMP_LTZ(3) *ROWTIME*
)
​
+----+---------+-------------------------+-----------+-------------------------+
| op |left_uid |            left_rowtime | right_adv |           right_rowtime |
+----+---------+-------------------------+-----------+-------------------------+
| +I |  user_1 | 1970-01-01 08:00:06.000 | product_5 | 1970-01-01 08:00:07.000 |
| +I |  user_1 | 1970-01-01 08:00:06.000 | product_2 | 1970-01-01 08:00:04.000 |
| +I |  user_1 | 1970-01-01 08:00:06.000 | product_4 | 1970-01-01 08:00:06.000 |
| +I |  user_1 | 1970-01-01 08:00:06.000 | product_3 | 1970-01-01 08:00:05.000 |

时态Join(Temporal Join)

在前面小节中我们介绍了Flink中的时态表(Temporal Table),通过时态表可以追踪表中的数据变化、访问数据历史版本数据。时态表不能单独使用,只能与其他表进行Join关联时使用。

Flink Table API与SQL编程中创建时态表时需要指定主键约束和事件时间,只要定义一张表时包含事件时间和主键约束,那么这张表就是时态表 ,定义时态表时可以基于ProcessTime也可以是EventTime。但访问时态表时需要定义"时态表函数"(Temporal Table Function),通过时态表函数传递一个时间属性可以访问时态表中数据版本,目前时态表函数仅支持通过Table API方式定义,不支持SQL DDL方式定义

Table API 中可以基于普通表并指定时间属性和主键来定义时态表函数,后续在Table API中可以通过".joinLateral(...)"方式使用时态表函数并查询版本数据。Table API中定义时态表函数及调用时态表函数查询数据方式如下:

复制代码
#基于currency_rates普通表定义时态表函数
TemporalTableFunction rates = tEnv
.from("currency_rates")
#指定“update_time”为时间属性,“currency”为主键
.createTemporalTableFunction("update_time", "currency");
​
#创建和注册时态表函数,这样可以在Table API中使用rates函数
tEnv.createTemporarySystemFunction("rates", rates);   
   
#通过joinLateral调用表函数关联表,查询版本数据
Table result = orders
.joinLateral(
call("rates", $("o_proctime")),
$("o_currency").isEqual($("r_currency")))
    .select($("o_amount").sum().as("amount"));

在Table API中使用时态表时需要注意以下几个点:

  1. 创建时态表函数时需要指定时间属性和主键。时间属性格式必须为TIMESTAMP/TIMESTAMP_LTZ,指定主键主要是保证该时态表中数据能按照主键进行更新或删除。

  2. 时态表不能单独查询,需要通过与其他表进行Join关联查询,以上示例中通过joinLateral调用时态表函数,将时态表函数放在右侧,类似右表进行关联,左右表都需要设置watermark,两表关联条件中必须有主键。

  3. 目前仅支持类似Inner Join,即左右表关联上的数据才会输出,左表和右表关联不上的数据不会输出。

  4. 右表时态表必须为CDC(Change Data Capture,数据变更捕获)数据,即数据是有增删改查的UNBOUND实时流,不能是BOUND有边界的流。

  5. 在两表Join时,通过call方法调用时态表函数,时态表函数需要传入一个时间列,该列类型必须是TIMESTAMP/TIMESTAMP_LTZ类型,暂时不支持传入常量数据,一般这里传入的都是左表中的转换成TIMESTAMP/TIMESTAMP_LTZ类型的时间字段。

  6. 通过时态表函数查询数据时,需要注意时态表中只会存储上一个watermark到当前时刻的所有版本数据,watermark之前的数据不会存储。

下面通过一个代码案例来学习在Flink Table API中如何进行Temproal Join。该案例是从Socket中读取数据形成两个流进行Temproal Join,左流是浏览产品数据,右流是产品实时价格数据,基于右流设置时态表函数进行实时价格查询。

  • Java代码
java 复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
​
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
​
//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000");
​
//读取socket中数据 ,p_001,1000
SingleOutputStreamOperator<Tuple2<String, Long>> leftInfo = env.socketTextStream("node5", 8888)
        .map(new MapFunction<String, Tuple2<String, Long>>() {
            @Override
            public Tuple2<String, Long> map(String s) throws Exception {
                return Tuple2.of(
                        s.split(",")[0],
                        Long.valueOf(s.split(",")[1])
                );
            }
        });
​
Table leftTbl = tableEnv.fromDataStream(leftInfo,
                Schema.newBuilder()
                        .column("f0", DataTypes.STRING())
                        .column("f1", DataTypes.BIGINT())
                        //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                        .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(f1,3)")
                        //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                        .watermark("rowtime", "rowtime - INTERVAL '5' SECOND")
                        .build())
        .as("left_product_id","left_dt","left_rowtime");
​
//读取socket中数据 ,p_001,1000
SingleOutputStreamOperator<Tuple4<Long,String,String,Double>> rightInfo = env.socketTextStream("node5", 9999)
        .map(new MapFunction<String, Tuple4<Long,String,String,Double>>() {
            @Override
            public Tuple4<Long,String,String,Double> map(String s) throws Exception {
                return Tuple4.of(
                        Long.valueOf(s.split(",")[0]),
                        s.split(",")[1],
                        s.split(",")[2],
                        Double.valueOf(s.split(",")[3])
                );
            }
        });
​
Table rightTbl = tableEnv.fromDataStream(rightInfo,
                Schema.newBuilder()
                        .column("f0", DataTypes.BIGINT())
                        .column("f1", DataTypes.STRING())
                        .column("f2", DataTypes.STRING())
                        .column("f3", DataTypes.DOUBLE())
                        //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                        .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(f0,3)")
                        //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                        .watermark("rowtime", "rowtime - INTERVAL '5' SECOND")
                        .build())
        .as("right_update_time", "right_product_id", "right_product_name", "right_price", "right_rowtime");
​
//创建时态表函数,"right_rowtime"为时间属性,"right_product_id"为主键
TemporalTableFunction temporalTableFunction = rightTbl.createTemporalTableFunction($("right_rowtime"), $("right_product_id"));
tableEnv.createTemporarySystemFunction("temporalTableFunction", temporalTableFunction);
​
//使用时态表
Table result = leftTbl.joinLateral(
                call("temporalTableFunction", $("left_rowtime")),
                $("left_product_id").isEqual($("right_product_id"))
        )
        .select($("left_product_id"), $("left_dt"),$("right_product_name"),$("right_price") );
​
result.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
​
//导入隐式转换
import org.apache.flink.streaming.api.scala._
​
//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)
​
//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000")
​
//读取socket中数据 ,p_001,1000
val leftInfo: DataStream[(String, Long)] = env.socketTextStream("node5", 8888)
  .map(line => {
    val arr = line.split(",")
    (arr(0), arr(1).toLong)
  })
​
val leftTbl: Table = tableEnv.fromDataStream(leftInfo,
  Schema.newBuilder()
    .column("_1", DataTypes.STRING())
    .column("_2", DataTypes.BIGINT())
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(_2,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '5' SECOND")
    .build())
  .as("left_product_id", "left_dt", "left_rowtime")
​
//读取socket中数据 ,p_001,1000
val rightInfo: DataStream[(Long,String, String,Double)] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    (arr(0).toLong, arr(1),arr(2),arr(3).toDouble)
  })
​
val rightTbl: Table = tableEnv.fromDataStream(rightInfo,
  Schema.newBuilder()
    .column("_1", DataTypes.BIGINT())
    .column("_2", DataTypes.STRING())
    .column("_3", DataTypes.STRING())
    .column("_4", DataTypes.DOUBLE())
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(_1,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '5' SECOND")
    .build())
  .as("right_update_time", "right_product_id", "right_product_name", "right_price", "right_rowtime")
​
//创建时态表函数,"right_rowtime"为时间属性,"right_product_id"为主键
val temporalTableFunction: TemporalTableFunction = rightTbl.createTemporalTableFunction($("right_rowtime"), $("right_product_id"))
tableEnv.createTemporarySystemFunction("temporalTableFunction", temporalTableFunction)
​
//使用时态表
val result: Table = leftTbl.joinLateral(
  call("temporalTableFunction", $("left_rowtime")),
  $("left_product_id").isEqual($("right_product_id"))
).select(
  $("left_product_id"),
  $("left_dt"),
  $("right_product_name"),
  $("right_price")
)
​
result.execute.print()

在编写Java和Scala代码时需要注意将Tuple类型的DataStream转化成Table对象时,列默认名称为"_1"、"_2"、"_3"...时态表中存储的数据为上一个watermark到当前时刻的所有版本数据,这里为了方便看出关联时态数据的结果,代码中设置了watermark延迟时间为5秒并且设置了table.exec.source.idle-timeout参数自动推进watermark。代码运行后,首先向socket-9999中输入如下数据,形成右表时态数据:

复制代码
#socket - 9999 输入产品价格数据,形成右表时态数据
1000,p_001,电脑,3.0
2000,p_002,手机,4.0
3000,p_001,电脑,9.0
4000,p_002,手机,6.0
5000,p_001,电脑,2.0

请注意,这里在代码中通过读取socket数据生成时态表,向socket-9999中输入以上数据实际上时态表中只有Append数据,没有更新操作,我们仍然可以通过该案例来测试从时态中获取对应时刻时态数据,关于不仅有Append操作也有update操作的时态表我们将在SQL部分详细讲解。

向socket-9999中输入以上数据后,再向socket-8888中输入如下浏览产品的数据形成左表数据:

复制代码
#socket - 8888 输入浏览产品数据,形成左表数据
p_002,1000
p_001,2000
p_002,3000
p_001,4000
p_001,5000
p_002,5000
​
#当输入此条数据时,wm达到5000,会输出结果
p_003,10000

在左表中输入"p_003,10000"数据之前,由于watermark为0,左表中没有任何事件输出。当输入"p_003,10000"数据时,此时watermark达到5000,左表中事件时间小于等于5000的事件并且事件时间能与右表时态数据关联上的数据被输出,结果如下:

复制代码
+----+----------------+--------+-------------------+------------+
| op |left_product_id |left_dt |right_product_name |right_price |
+----+----------------+--------+-------------------+------------+
| +I |          p_002 |   3000 |              手机 |        4.0 |
| +I |          p_002 |   5000 |              手机 |        6.0 |
| +I |          p_001 |   5000 |              电脑 |        2.0 |
| +I |          p_001 |   4000 |              电脑 |        9.0 |
| +I |          p_001 |   2000 |              电脑 |        3.0 |

通过以上输出结果可以看到,左表中每条数据会根据事件时间和主键从时态表中查询对应数据并输出,如果左表中某时刻对应的数据在时态表中没有查询到对应版本数据不会被输出,例如"p_002,1000"数据就不能根据主键和事件时间在时态表中获取数据,所以不会输出查询结果。

windows操作

Flink Table API支持window窗口操作,窗口使用window子句定义并且需要使用as子句来指定别名,为了按窗口对表进行分组,窗口别名必须像常规分组属性一样在grouop by(...)子句中被引用,使用形式如下。

复制代码
Table table = input
  // 定义窗口并指定别名为 w
  .window([GroupWindow w].as("w"))
  // 以窗口 w 对表进行分组
  .groupBy($("w"))
  // 聚合
  .select($("b").sum());

Flink API对流式数据设置窗口时,groupBy中除了可以指定窗口别名外还可以指定一个或者多个属性进行分组,类似DataStream API中的keyBy分组,如下:

复制代码
Table table = input
  // 定义窗口并指定别名为 w
  .window([GroupWindow w].as("w"))
  // 以属性 a 和窗口 w 对表进行分组
  .groupBy($("w"), $("a"))
  // 聚合
  .select($("a"), $("b").sum());

如果在groupBy中仅指定了按照窗口别名分组,那么整个Flink程序处理数据时,将全部数据会放在一个窗口中由一个并行度进行处理。groupBy中指定除了窗口别名外还有其他分组属性可以针对其他属性进行窗口分组并行计算。

在Table API中,window窗口支持滚动窗口(Tumbling Windows)、滑动窗口(Sliding Windows)、会话窗口(Session Windows),这些窗口类型都需要指定别名,如果指定窗口别名为"w",我们还可以同通过w.start/w.end获取窗口起始时间。下面对这些种类窗口使用方式进行介绍。

1) 滚动窗口 - Tumbling Windows

Table API中滚动窗口可以根据固定时间大小或者固定事件数量进行窗口划分,即滚动窗口可以定义在事件时间、处理时间或者行数上。使用方式如下:

复制代码
// 基于事件时间的滚动窗口,rowtime为事件时间
.window(Tumble.over(lit(10).minutes()).on($("rowtime")).as("w"));
// 基于处理时间的滚动窗口,proctime为处理时间
.window(Tumble.over(lit(10).minutes()).on($("proctime")).as("w"));
// 基于行计数的滚动窗口
.window(Tumble.over(rowInterval(10)).on($("proctime")).as("w"));

2) 滑动窗口 - Sliding Windows

滑动窗口与滚动窗口类似,滑动窗口长度大小固定,使用滑动窗口时需要指定一个时间参数表示窗口长度(window size),既然是滑动窗口,那么同时还需要指定一个窗口滑动步长(window slide)来控制生成窗口的频率,例如:滑动窗口中指定window size 为10分钟并且widow slide 为5分钟,就代表每隔5分钟生成一个包含最近10分钟时间范围内的窗口进行计算,在Flink Table API中滑动窗口可以定义在事件时间、处理时间或行数上。使用方式如下:

复制代码
// 基于事件时间的滑动窗口
.window(Slide.over(lit(10).minutes())
            .every(lit(5).minutes())
            .on($("rowtime"))
            .as("w"));

// 基于处理时间的滑动窗口
.window(Slide.over(lit(10).minutes())
            .every(lit(5).minutes())
            .on($("proctime"))
            .as("w"));

// 基于行计数的滑动窗口
.window(Slide.over(rowInterval(10)).every(rowInterval(5)).on($("proctime")).as("w"));

3) 会话窗口 - Session Windows

会话窗口(Session Windows)主要是将某段时间内活跃度较高的数据聚合成一个窗口进行计算,窗口的触发的条件是Session Gap(会话间隔),是指在规定的时间内如果没有活跃数据接入,则认为窗口结束,然后触发窗口计算结果。与滑动窗口、滚动窗口不同的是Session Windows不需要有固定windows size和slide time,只需要定义session gap,来规定不活跃数据的时间上限即可,此外,会话窗口不会相互重叠。Session Windows 窗口类型比较适合非连续性数据处理或周期性产生数据场景。

在Flink Table API中会话窗口支持事件时间和处理时间。使用方式如下:

复制代码
// 基于事件时间的会话窗口
.window(Session.withGap(lit(10).minutes()).on($("rowtime")).as("w"));
// 基于处理时间的会话窗口
.window(Session.withGap(lit(10).minutes()).on($("proctime")).as("w"));

通过以上三种窗口类型的学习,可以看到Flink Table API中的窗口整体与DataStream API中的窗口类似。下面我们针对滚动窗口(Tumbling Windows)进行案例演示。

案例:读取socket基站日志数据,每隔5s统计窗口通话时长。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
​
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
​
//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000");
​
//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });
​
Table table = tableEnv.fromDataStream(stationLogDS,
        Schema.newBuilder()
                //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
                //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
                .build());
​
//基于时间的滑动窗口
Table result = table.window(
         Tumble.over(lit(5).seconds())
                 .on($("rowtime"))
                 .as("w")
        )
        .groupBy($("sid"), $("w"))
        .select(
                $("sid"),
                $("w").start().as("window_start"),
                $("w").end().as("window_end"),
                $("duration").sum().as("total_duration")
        );
​
//打印结果
result.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
​
//导入隐式转换
import org.apache.flink.streaming.api.scala._
​
//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)
​
//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout", "5000")
​
val ds: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })
​
val table: Table = tableEnv.fromDataStream(ds,
  Schema.newBuilder()
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build())
​
val result: Table = table.window(Tumble over 5.second on $"rowtime" as "w")
  .groupBy($"sid", $"w")
  .select($"sid", $"w".start as "window_start", $"w".end as "window_end", $"duration".sum as "sum_duration")
​
result.execute().print()

以上代码代码编写完成执行后,在socket中输入如下数据,可以看到每隔5秒会将最近10秒数据生成窗口进行统计:

复制代码
#socket中输入数据如下
001,181,182,busy,1000,10
002,182,183,fail,3000,20
001,183,184,busy,2000,30
002,184,185,busy,6000,40
003,181,183,busy,5000,50
#输入此条数据后会生成第一个窗口结果
001,181,182,busy,7000,10
002,182,183,fail,9000,20
001,183,184,busy,11000,30
002,184,185,busy,6000,40
#输入此条数据后会生成第二个窗口结果
003,181,183,busy,12000,50

控制台输出结果如下:

复制代码
+----+-----+-------------------------+-------------------------+-----+
| op | sid |            window_start |              window_end |total|
+----+-----+-------------------------+-------------------------+-----+
| +I | 002 | 1970-01-01 08:00:00.000 | 1970-01-01 08:00:05.000 | 20  |
| +I | 001 | 1970-01-01 08:00:00.000 | 1970-01-01 08:00:05.000 | 40  |
| +I | 003 | 1970-01-01 08:00:05.000 | 1970-01-01 08:00:10.000 | 50  |
| +I | 002 | 1970-01-01 08:00:05.000 | 1970-01-01 08:00:10.000 |100  |
| +I | 001 | 1970-01-01 08:00:05.000 | 1970-01-01 08:00:10.000 | 10  |
相关推荐
杨云龙UP1 小时前
Spotlight 接入 Oracle 数据库监控操作指南 2026-06-16
数据库·oracle·性能监控·预警·阈值·spotlight·瓶颈分析
叫我:松哥1 小时前
基于Python flask的中学可控智能命题系统设计与实现,整合遗传算法、DeepSeek 大模型及数据库技术构建一体化应用
数据库·人工智能·python·算法·机器学习·flask·遗传算法
阿维的博客日记1 小时前
Hippo4j 线程池监控接入方法
数据库·hippo4j
审判长烧鸡2 小时前
数据库字段命名规范速查表
数据库·sql
承渊政道2 小时前
【MySQL数据库学习】(MySQL表的内外连接)
数据库·学习·mysql·leetcode·bash·数据库开发·数据库系统
瀚高PG实验室2 小时前
db_ha集群中某个节点启动失败,报错缺少sm4加密模块
数据库·瀚高数据库·highgo
IvorySQL2 小时前
PostgreSQL 技术日报 (6月16日)|Neon 自动化再进一步,逻辑复制冲突日志迎来 v50 更新
数据库·postgresql·自动化
小小工匠2 小时前
Redis - 主从集群脑裂:数据丢失的隐藏杀手
数据库·redis
JAMSAN09302 小时前
机器人轴承:被低估的“物理关节”,正在打开300倍增长空间
数据库·人工智能·机器人·智能硬件