前言
flink里面的joins有很多种,需要理解各种为什么要分这么多种join以及分别的使用场景、区别。
省流版:
regular join:不支持时间窗口
regular join 是最通用的 join 类型,不支持时间窗口以及时间属性,任何一侧数据流有更改都是可见的,直接影响整个 join 结果。如果有一侧数据流增加一个新纪录,那么它将会把另一侧的所有的过去和将来的数据合并在一起,因为 regular join 没有剔除策略,这就影响最新输出的结果; 正因为历史数据不会被清理,所以 regular join 支持数据流的任何更新操作。对于 regular join 来说,更适合用于离线场景和小数据量场景,不适合大数据量数据实时处理,因为状态会很大。
一个典型例子:
insert into test_window_tab
select t1.region,t1.qa_id
from t1
left join t2
on t1.qa_id=t2.newrow.qa_id
inner join 会两条流互相等,直到有数据才下发。
left join,right join,full join 不会互相等,只要来了数据,会尝试关联,能关联到则下发的字段是全的,关联不到则另一边的字段为 null。后续数据来了之后,发现之前下发过为没有关联到的数据时,就会做回撤,然后把关联到的结果进行下发
基于窗口的Join:窗口结束时触发计算,支持事件和处理时间
为什么需要基于窗口的join:regular join会有回撤,下游需要处理,而有时候不希望有回撤消息
基于窗口的Join需要用到Flink中的窗口机制。其原理是将两条输入流中的元素分配到公共窗口中并在窗口完成时进行Join(或Cogroup)。
底层原理: 两条实时流数据缓存在Window State中,当窗口触发计算时,执行join操作。
join算子:inner join
两条流数据按照关联主键在(滚动、滑动、会话)窗口内进行inner join, 底层基于State存储,并支持处理时间和事件时间两种时间特征
两条输入流都会根据各自的键值属性进行分区,公共窗口分配器会将二者的事件映射到公共窗口内(其中同时存储了两条流中的数据)。当窗口的计时器触发时,算子会遍历两个输入中元素的每个组合(叉乘积)去调用JoinFunction。由于两条流中的事件会被映射到同一个窗口中,因此该过程中的触发器和移除器与常规窗口算子中的完全相同。
一个典型例子:
val env = ...
// kafka 订单流
val orderStream = ...
// kafka 订单明细流
val orderDetailStream = ...
orderStream.join(orderDetailStream)
.where(r => r._1) //为input1指定键值属性 订单id
.equalTo(r => r._2) //为input2指定键值属性 订单id
.window(TumblingProcessTimeWindows.of(
Time.seconds(60)))// 指定WindowAssigner
[.trigger(...)] // 选择性的指定Trigger
[.evictor(...)] // 选择性的指定Evictor
.apply {(r1, r2) => r1 + " : " + r2}// 指定JoinFunction
.print()
coGroup算子:full outer join
coGroup算子也是基于window窗口机制,不过coGroup算子比Join算子更加灵活,可以按照用户指定的逻辑匹配左流或右流数据并输出。join算子是cogroup的一个特例,他们都是对同一个key的两组数据进行操作处理。
换句话说,我们通过自己指定双流的输出来达到left join和right join的目的。
一个典型例子:
orderDetailStream
.coGroup(orderStream)
.where(r -> r.getOrderId())
.equalTo(r -> r.getOrderId())
.window(TumblingProcessingTimeWindows.of(Time.seconds(60)))
.apply(new CoGroupFunction<OrderDetail, Order, Tuple2<String, Long>>() {//Tuple2中String是goods_name,Long是price
@Override
public void coGroup(Iterable<OrderDetail> orderDetailRecords, Iterable<Order> orderRecords, Collector<Tuple2<String, Long>> collector) {
for (OrderDetail orderDetaill : orderDetailRecords) {
boolean flag = false;
for (Order orderRecord : orderRecords) {
// 右流中有对应的记录
collector.collect(new Tuple2<>(orderDetailRecords.getGoods_name(), orderDetailRecords.getGoods_price()));
flag = true;
}
if (!flag) {
// 右流中没有对应的记录
collector.collect(new Tuple2<>(orderDetailRecords.getGoods_name(), null));
}
}
}
})
.print();
基于间隔的Join:左右流都会触发结果更新,支持各种join,支持事件和处理时间
为什么需要基于间隔的join:基于窗口的join中,对划分窗口后的数据流进行Join可能会产生意想不到的语义。例如,假设你为执行Join操作的算子配置了1小时的滚动窗口,那么一旦来自两个输入的元素没有被划分到同一窗口,它们就无法Join在一起,即使二者彼此仅相差1秒钟。而现实场景中,两条流往往会有时间先后,基于窗口的join无法处理这种场景。
Interval Join根据右流相对左流偏移的时间区间(interval)作为关联窗口,在偏移区间窗口中完成join操作。换句话说,基于间隔的Join会对两条流中拥有相同键值以及彼此之间时间戳不超过某一指定间隔的事件进行Join。
下图展示了两条流(A和B)上基于间隔的Join,如果B中事件的时间戳相较于A中事件的时间戳不早于1小时且不晚于15分钟,则会将两个事件Join起来。Join间隔具有对称性,因此上面的条件也可以表示为A中事件的时间戳相较B中事件的时间戳不早于15分钟且不晚于1小时。
实现原理:interval join也是利用Flink的state存储数据,不过此时存在state失效机制ttl,触发数据清理操作。
特点
- 左右流都会触发结果更新,而不是通过窗口结束来触发(从下面的datastream api/flink sql可以看到,并没有用诸如tumblewindow等,所以不是窗口触发计算的,而是来一条就触发计算一次)
- State 自动清理,根据时间区间保留数据
- 只支持双流,不支持三流及以上。
- Flink同时支持基于EventTime和ProcessingTime的Interval join,但是只支持append-only流。
- Interval join 已经支持inner ,left outer, right outer , full outer 等类型的join
例如下面的left join:
insert into realtime_db.test_window_tab select t1.region ,t1.qa_id from realtime_db.test__dwm_qa_score t1 left join realtime_db.score_result_tab t2 on t1.qa_id = t2.newrow.qa_id where TO_TIMESTAMP(FROM_UNIXTIME(t1.first_submit_timestamp)) between TO_TIMESTAMP(FROM_UNIXTIME(t2.newrow.first_submit_time)) - interval '1' HOUR and TO_TIMESTAMP(FROM_UNIXTIME(t2.newrow.first_submit_time))
Temporal Join
为什么需要temporal join:在 regular join和interval join中,join 两侧的表是平等的,任意的一个表的更新,都会去和另外的历史纪录进行匹配。而有时候,我们需要一个表 的更新对另一表在该时间节点以前的记录是不可见的,即不影响历史数据
时态表(Temporal table)是一个随时间变化的表:在 Flink 中被称为动态表。时态表中的行与一个或多个时间段相关联,所有 Flink 中的表都是时态的(Temporal)。 时态表包含一个或多个版本的表快照,它可以是一个变化的历史表,跟踪变化(例如,数据库变化日志,包含所有快照)或一个变化的维度表,也可以是一个将变更物化的维表(例如,存放最终快照的数据表)。
这里可以分为 JOIN 当前表和 JOIN 历史表。
JOIN 当前维表:处理时间temporal join
SELECT * FROM Orders AS o [LEFT] JOIN Products FOR SYSTEM_TIME AS OF PROCTIME() AS p ON o.productId = p.productId
Flink SQL 支持 LEFT JOIN 和 INNER JOIN 的维表关联。如上语法所示的,维表 JOIN 语法与传统的 JOIN 语法并无二异,加了left就是left join,否则就是inner join。
JOIN 历史维表:事件时间temporal join
SELECT *
FROM Orders AS o
[LEFT] JOIN Products FOR SYSTEM_TIME AS OF o.orderTime AS p
ON o.productId = p.productId
有时候想关联上的维度数据,并不是当前时刻的值,而是某个历史时刻的值(可以理解为事件时间temporal join)。比如,产品的价格一直在发生变化,订单流希望补全的是下单时的价格,而不是当前的价格,那就是 JOIN 历史维表。语法上只需要将上文的 PROCTIME() 改成 o.orderTime 即可。含义是关联上的是下单时刻的 Products 维表。
Lookup Join
为什么需要lookup join:因为temporal join需要双流,而有时候维表只是一个非流(例如mysql,hbase)
lookup join 通常用于使用从外部系统查询的数据来丰富表。join 要求一个表具有处理时间属性,另一个表由查找源连接器(lookup source connnector)支持。
lookup join 和上面的 处理时间 Temporal Join 语法相同,右表使用查找源连接器支持。
下面的例子展示了 lookup join 的语法。
-- Customers is backed by the JDBC connector and can be used for lookup joins CREATE TEMPORARY TABLE Customers ( id INT, name STRING, country STRING, zip STRING ) WITH ( 'connector' = 'jdbc', 'url' = 'jdbc:mysql://mysqlhost:3306/customerdb', 'table-name' = 'customers' ); -- enrich each order with customer information SELECT o.order_id, o.total, c.country, c.zip FROM Orders AS o JOIN Customers FOR SYSTEM_TIME AS OF o.proc_time AS c ON o.customer_id = c.id;
在上面的示例中,Orders 表由保存在 MySQL 数据库中的 Customers 表数据来丰富。带有后续 process time 属性的 FOR SYSTEM_TIME AS OF 子句确保在联接运算符处理 Orders 行时,Orders 的每一行都与 join 条件匹配的 Customer 行连接。它还防止连接的 Customer 表在未来发生更新时变更连接结果。lookup join 还需要一个强制的相等连接条件(不能空),在上面的示例中是 o.customer_id = c.id。
维表join优化的方法
非同步io+批量拉取
Async 极大地提高了吞吐,但是每一次 IO 请求只取了单 key 的数据,效率比较低。未来计划使用 Batch Get 来提高每次 IO 请求的吞吐。
缓存
数据库的维表查询请求,有大量相同 key 的重复请求。如何减少重复请求?本地缓存是常用的方案。Flink SQL 目前提供两种缓存方案:LRU 和 ALL。
引入 Partitioned-ALL-cache
也就是上游数据到 JoinTable 节点根据 JOIN key 分区,那么每个节点只需要加载属于该分区key的缓存数据,从而做到了缓存的水平扩展。从而遇到超大维表时可以通过扩并发也能够全量缓存下维表数据。
Side Input
ALL cache 现在每个节点是都会起一个线程去加载全量维表数据,如果有1000个节点,则会全量读数据库1000次。未来打算通过 Side Input功能做到只需要全量读取一次,维表数据会自动分发到各个节点。