
Flink SQL 知其所以然:2w 字详述 Join 操作
作者 | antigeneral了呀
来源 | 大数据羊说(ID:young_say)
转载请联系授权(微信ID:___antigeneral)
Flink Joins
大家好,我是老羊,今天我们来学习 Flink SQL 中的· Join 操作。
Flink 支持了非常多的数据 Join 方式,主要包括以下三种:
- ⭐ 动态表(流)与动态表(流)的 Join
- ⭐ 动态表(流)与外部维表(比如 Redis)的 Join
- ⭐ 动态表字段的列转行(一种特殊的 Join)
细分 Flink SQL 支持的 Join:
- ⭐ Regular Join:流与流的 Join,包括 Inner Equal Join、Outer Equal Join
- ⭐ Interval Join:流与流的 Join,两条流一段时间区间内的 Join
- ⭐ Temporal Join:流与流的 Join,包括事件时间,处理时间的 Temporal Join,类似于离线中的快照 Join
- ⭐ Lookup Join:流与外部维表的 Join
- ⭐ Array Expansion:表字段的列转行,类似于 Hive 的 explode 数据炸开的列转行
- ⭐ Table Function:自定义函数的表字段的列转行,支持 Inner Join 和 Left Outer Join
1.Regular Join
- ⭐ Regular Join 定义(支持 Batch\Streaming):Regular Join 其实就是和离线 Hive SQL 一样的 Regular Join,通过条件关联两条流数据输出。
- ⭐ 应用场景:Join 其实在我们的数仓建设过程中应用是非常广泛的。离线数仓可以说基本上是离不开 Join 的。那么实时数仓的建设也必然离不开 Join,比如日志关联扩充维度数据,构建宽表;日志通过 ID 关联计算 CTR。
- ⭐ Regular Join 包含以下几种(以
L
作为左流中的数据标识,R
作为右流中的数据标识):
- ⭐ Inner Join(Inner Equal Join):流任务中,只有两条流 Join 到才输出,输出
+[L, R]
- ⭐ Left Join(Outer Equal Join):流任务中,左流数据到达之后,无论有没有 Join 到右流的数据,都会输出(Join 到输出
+[L, R]
,没 Join 到输出+[L, null]
),如果右流之后数据到达之后,发现左流之前输出过没有 Join 到的数据,则会发起回撤流,先输出-[L, null]
,然后输出+[L, R]
- ⭐ Right Join(Outer Equal Join):有 Left Join 一样,左表和右表的执行逻辑完全相反
- ⭐ Full Join(Outer Equal Join):流任务中,左流或者右流的数据到达之后,无论有没有 Join 到另外一条流的数据,都会输出(对右流来说:Join 到输出
+[L, R]
,没 Join 到输出+[null, R]
;对左流来说:Join 到输出+[L, R]
,没 Join 到输出+[L, null]
)。如果一条流的数据到达之后,发现之前另一条流之前输出过没有 Join 到的数据,则会发起回撤流(左流数据到达为例:回撤-[null, R]
,输出+[L, R]
,右流数据到达为例:回撤-[L, null]
,输出+[L, R]
)。
- ⭐ 实际案例:案例为曝光日志关联点击日志筛选既有曝光又有点击的数据,并且补充点击的扩展参数(show inner click):
下面这个案例为 Inner Join 案例
:
输出结果如下:
如果为 Left Join
案例:
输出结果如下:
如果为 Full Join
案例:
输出结果如下:
关于 Regular Join 的注意事项:
- ⭐ 实时 Regular Join 可以不是
等值 join
。等值 join
和非等值 join
区别在于,等值 join
数据 shuffle 策略是 Hash,会按照 Join on 中的等值条件作为 id 发往对应的下游;非等值 join
数据 shuffle 策略是 Global,所有数据发往一个并发,按照非等值条件进行关联- ⭐ Join 的流程是左流新来一条数据之后,会和右流中符合条件的所有数据做 Join,然后输出。
- ⭐ 流的上游是无限的数据,所以要做到关联的话,Flink 会将两条流的所有数据都存储在 State 中,所以 Flink 任务的 State 会无限增大,因此你需要为 State 配置合适的 TTL,以防止 State 过大。
- ⭐
SQL 语义
:
2.Interval Join(时间区间 Join)
- ⭐ Interval Join 定义(支持 Batch\Streaming):Interval Join 在离线的概念中是没有的。Interval Join 可以让一条流去 Join 另一条流中前后一段时间内的数据。
- ⭐ 应用场景:为什么有 Regular Join 还要 Interval Join 呢?刚刚的案例也讲了,Regular Join 会产生回撤流,但是在实时数仓中一般写入的 sink 都是类似于 Kafka 这样的消息队列,然后后面接 clickhouse 等引擎,这些引擎又不具备处理回撤流的能力。所以博主理解 Interval Join 就是用于消灭回撤流的。
- ⭐ Interval Join 包含以下几种(以
L
作为左流中的数据标识,R
作为右流中的数据标识):
- ⭐ Inner Interval Join:流任务中,只有两条流 Join 到(满足 Join on 中的条件:两条流的数据在时间区间 + 满足其他等值条件)才输出,输出
+[L, R]
- ⭐ Left Interval Join:流任务中,左流数据到达之后,如果没有 Join 到右流的数据,就会等待(放在 State 中等),如果之后右流之后数据到达之后,发现能和刚刚那条左流数据 Join 到,则会输出
+[L, R]
。事件时间中随着 Watermark 的推进(也支持处理时间)。如果发现发现左流 State 中的数据过期了,就把左流中过期的数据从 State 中删除,然后输出+[L, null]
,如果右流 State 中的数据过期了,就直接从 State 中删除。 - ⭐ Right Interval Join:和 Left Interval Join 执行逻辑一样,只不过左表和右表的执行逻辑完全相反
- ⭐ Full Interval Join:流任务中,左流或者右流的数据到达之后,如果没有 Join 到另外一条流的数据,就会等待(左流放在左流对应的 State 中等,右流放在右流对应的 State 中等),如果之后另一条流数据到达之后,发现能和刚刚那条数据 Join 到,则会输出
+[L, R]
。事件时间中随着 Watermark 的推进(也支持处理时间),发现 State 中的数据能够过期了,就将这些数据从 State 中删除并且输出(左流过期输出+[L, null]
,右流过期输出-[null, R]
)
可以发现 Inner Interval Join 和其他三种 Outer Interval Join 的区别在于,Outer 在随着时间推移的过程中,如果有数据过期了之后,会根据是否是 Outer 将没有 Join 到的数据也给输出。
- ⭐ 实际案例:还是刚刚的案例,曝光日志关联点击日志筛选既有曝光又有点击的数据,条件是曝光关联之后发生 4 小时之内的点击,并且补充点击的扩展参数(show inner interval click):
下面为 Inner Interval Join
:
输出结果如下:
如果是 Left Interval Join
:
输出结果如下:
如果是 Full Interval Join
:
输出结果如下:
关于 Interval Join 的注意事项:
⭐ 实时 Interval Join 可以不是
等值 join
。等值 join
和 非等值 join
区别在于,等值 join
数据 shuffle 策略是 Hash,会按照 Join on 中的等值条件作为 id 发往对应的下游;非等值 join
数据 shuffle 策略是 Global,所有数据发往一个并发,然后将满足条件的数据进行关联输出
- ⭐ SQL 语义:
3.Temporal Join(快照 Join)
- ⭐ Temporal Join 定义(支持 Batch\Streaming):Temporal Join 在离线的概念中其实是没有类似的 Join 概念的,但是离线中常常会维护一种表叫做
拉链快照表
,使用一个明细表去 join 这个拉链快照表
的 join 方式就叫做 Temporal Join。而 Flink SQL 中也有对应的概念,表叫做Versioned Table
,使用一个明细表去 join 这个Versioned Table
的 join 操作就叫做 Temporal Join。Temporal Join 中,Versioned Table
其实就是对同一条 key(在 DDL 中以 primary key 标记同一个 key)的历史版本(根据时间划分版本)做一个维护,当有明细表 Join 这个表时,可以根据明细表中的时间版本选择Versioned Table
对应时间区间内的快照数据进行 join。 - ⭐ 应用场景:比如常见的汇率数据(实时的根据汇率计算总金额),在 12:00 之前(事件时间),人民币和美元汇率是 7:1,在 12:00 之后变为 6:1,那么在 12:00 之前数据就要按照 7:1 进行计算,12:00 之后就要按照 6:1 计算。在事件时间语义的任务中,事件时间 12:00 之前的数据,要按照 7:1 进行计算,12:00 之后的数据,要按照 6:1 进行计算。这其实就是离线中快照的概念,维护具体汇率的表在 Flink SQL 体系中就叫做
Versioned Table
。 - ⭐ Verisoned Table:Verisoned Table 中存储的数据通常是来源于 CDC 或者会发生更新的数据。Flink SQL 会为 Versioned Table 维护 Primary Key 下的所有历史时间版本的数据。举一个汇率的场景的案例来看一下一个 Versioned Table 的两种定义方式。
- ⭐ PRIMARY KEY 定义方式:
- ⭐ Deduplicate 定义方式:
- ⭐ Temporal Join 支持的时间语义:事件时间、处理时间
- ⭐ 实际案例:就是上文提到的汇率计算。
以 事件时间
任务举例:
结果如下,可以看到相同的货币汇率会根据具体数据的事件时间不同 Join 到对应时间的汇率:
注意:
- ⭐ 事件时间的 Temporal Join 一定要给左右两张表都设置 Watermark。
- ⭐ 事件时间的 Temporal Join 一定要把 Versioned Table 的主键包含在 Join on 的条件中。
还是相同的案例,如果是 处理时间
语义:
可以发现处理时间就比较好理解了,因为处理时间语义中是根据左流数据到达的时间决定拿到的汇率值。Flink 就只为 LatestRates 维护了最新的状态数据,不需要关心历史版本的数据。
4.Lookup Join(维表 Join)
- ⭐ Lookup Join 定义(支持 Batch\Streaming):Lookup Join 其实就是维表 Join,比如拿离线数仓来说,常常会有用户画像,设备画像等数据,而对应到实时数仓场景中,这种实时获取外部缓存的 Join 就叫做维表 Join。
- ⭐ 应用场景:小伙伴萌会问,我们既然已经有了上面介绍的 Regular Join,Interval Join 等,为啥还需要一种 Lookup Join?因为上面说的这几种 Join 都是流与流之间的 Join,而 Lookup Join 是流与 Redis,Mysql,HBase 这种存储介质的 Join。Lookup 的意思就是实时查找,而实时的画像数据一般都是存储在 Redis,Mysql,HBase 中,这就是 Lookup Join 的由来
- ⭐ 实际案例:使用曝光用户日志流(show_log)关联用户画像维表(user_profile)关联到用户的维度之后,提供给下游计算分性别,年龄段的曝光用户数使用。
来一波输入数据:
曝光用户日志流(show_log)数据(数据存储在 kafka 中):
用户画像维表(user_profile)数据(数据存储在 redis 中):
注意:
redis 中的数据结构存储是按照 key,value 去存储的。其中 key 为 user_id,value 为 age,sex 的 json。
具体 SQL:
输出数据如下:
注意:
实时的 lookup 维表关联能使用
处理时间
去做关联。
- ⭐ SQL 语义:
其实,Flink 官方并没有提供 redis 的维表 connector 实现。
没错,博主自己实现了一套。关于 redis 维表的 connector 实现,直接参考下面的文章。都是可以从 github 上找到源码拿来用的!
注意:
- ⭐ 同一条数据关联到的维度数据可能不同:实时数仓中常用的实时维表都是在不断的变化中的,当前流表数据关联完维表数据后,如果同一个 key 的维表的数据发生了变化,已关联到的维表的结果数据不会再同步更新。举个例子,维表中 user_id 为 1 的数据在 08:00 时 age 由 12-18 变为了 18-24,那么当我们的任务在 08:01 failover 之后从 07:59 开始回溯数据时,原本应该关联到 12-18 的数据会关联到 18-24 的 age 数据。这是有可能会影响数据质量的。所以小伙伴萌在评估你们的实时任务时要考虑到这一点。
- ⭐ 会发生实时的新建及更新的维表博主建议小伙伴萌应该建立起数据延迟的监控机制,防止出现流表数据先于维表数据到达,导致关联不到维表数据
再说说维表常见的性能问题及优化思路。
所有的维表性能问题都可以总结为:高 qps 下访问维表存储引擎产生的任务背压,数据产出延迟问题。
举个例子:
- ⭐ 在没有使用维表的情况下:一条数据从输入 Flink 任务到输出 Flink 任务的时延假如为
0.1 ms
,那么并行度为 1 的任务的吞吐可以达到1 query / 0.1 ms = 1w qps
。 - ⭐ 在使用维表之后:每条数据访问维表的外部存储的时长为
2 ms
,那么一条数据从输入 Flink 任务到输出 Flink 任务的时延就会变成2.1 ms
,那么同样并行度为 1 的任务的吞吐只能达到1 query / 2.1 ms = 476 qps
。两者的吞吐量相差21 倍
。
这就是为什么维表 join 的算子会产生背压,任务产出会延迟。
那么当然,解决方案也是有很多的。抛开 Flink SQL 想一下,如果我们使用 DataStream API,甚至是在做一个后端应用,需要访问外部存储时,常用的优化方案有哪些?这里列举一下:
- ⭐ 按照 redis 维表的 key 分桶 + local cache:通过按照 key 分桶的方式,让大多数据的维表关联的数据访问走之前访问过得 local cache 即可。这样就可以把访问外部存储 2.1 ms 处理一个 query 变为访问内存的 0.1 ms 处理一个 query 的时长。
- ⭐ 异步访问外存:DataStream api 有异步算子,可以利用线程池去同时多次请求维表外部存储。这样就可以把 2.1 ms 处理 1 个 query 变为 2.1 ms 处理 10 个 query。吞吐可变优化到 10 / 2.1 ms = 4761 qps。
- ⭐ 批量访问外存:除了异步访问之外,我们还可以批量访问外部存储。举一个例子:在访问 redis 维表的 1 query 占用 2.1 ms 时长中,其中可能有 2 ms 都是在网络请求上面的耗时 ,其中只有 0.1 ms 是 redis server 处理请求的时长。那么我们就可以使用 redis 提供的 pipeline 能力,在客户端(也就是 flink 任务 lookup join 算子中),攒一批数据,使用 pipeline 去同时访问 redis sever。这样就可以把 2.1 ms 处理 1 个 query 变为 7ms(2ms + 50 * 0.1ms) 处理 50 个 query。吞吐可变为 50 query / 7 ms = 7143 qps。博主这里测试了下使用 redis pipeline 和未使用的时长消耗对比
博主认为上述优化效果中,最好用的是 1 + 3,2 相比 3 还是一条一条发请求,性能会差一些。
既然 DataStream 可以这样做,Flink SQL 必须必的也可以借鉴上面的这些优化方案。具体怎么操作呢?看下文骚操作
- ⭐ 按照 redis 维表的 key 分桶 + local cache:sql 中如果要做分桶,得先做 group by,但是如果做了 group by 的聚合,就只能在 udaf 中做访问 redis 处理,并且 udaf 产出的结果只能是一条,所以这种实现起来非常复杂。我们选择不做 keyby 分桶。但是我们可以直接使用 local cache 去做本地缓存,虽然【直接缓存】的效果比【先按照 key 分桶再做缓存】的效果差,但是也能一定程度上减少访问 redis 压力。在博主实现的 redis connector 中,内置了 local cache 的实现,小伙伴萌可以参考下面这部篇文章进行配置。
- ⭐ 异步访问外存:目前博主实现的 redis connector 不支持异步访问,但是官方实现的 hbase connector 支持这个功能,参考下面链接文章的,点开之后搜索 lookup.async。https://nightlies.apache.org/flink/flink-docs-release-1.13/docs/connectors/table/hbase/
- ⭐ 批量访问外存:这玩意官方必然没有实现啊,但是,但是,但是,经过博主周末两天的疯狂 debug,改了改源码,搞定了基于 redis 的批量访问外存优化的功能。
5.Array Expansion(数组列转行)
- ⭐ 应用场景(支持 Batch\Streaming):将表中 ARRAY 类型字段(列)拍平,转为多行
- ⭐ 实际案例:比如某些场景下,日志是合并、攒批上报的,就可以使用这种方式将一个 Array 转为多行。
show_log_table 原始数据:
输出结果如下所示:
6.Table Function(自定义列转行)
- ⭐ 应用场景(支持 Batch\Streaming):这个其实和 Array Expansion 功能类似,但是 Table Function 本质上是个 UDTF 函数,和离线 Hive SQL 一样,我们可以自定义 UDTF 去决定列转行的逻辑
- ⭐ Table Function 使用分类:
- ⭐ Inner Join Table Function:如果 UDTF 返回结果为空,则相当于 1 行转为 0 行,这行数据直接被丢弃
- ⭐ Left Join Table Function:如果 UDTF 返回结果为空,折行数据不会被丢弃,只会在结果中填充 null 值
- ⭐ 实际案例:直接上 SQL 。
执行结果如下:
