Lookup join,如果按照“join”思想进行理解,很容易将其想象成是实现TwoInputStreamOperator
的双流操作,实则不然,Lookup join是实现OneInputStreamOperator
的单流操作。是否略微惊讶?当初使自己产生这种思维误区的主要原因是未正真理解flink sql中Lookup的含义。
既然是单流操作,Lookup join可以简单解释为左表记录作为事件输入到operator中,在operator内部提供了一个方法,对每条记录根据join条件去外部存储中(如mysql)查询并输出结果的操作。
何为Lookup?在 Flink’s Table & SQL API 中,使用动态表DynamicTableSource
抽象,以统一的方式处理有界和无界数据流,在读取动态表时,可以将内容视为两种不同形式:
- 一个有限或无限的changelog,其所有更改都会被持续使用,直到changelog耗尽。即
ScanTableSource
。 - 一个不断变化或非常大的外部表,其内容通常不会被完全读取,而是必要时查询单个值。即
LookupTableSource
。
ScanTableSource
可以包含insert、update、delete的row。而LookupTableSource
仅支持only-insert的row,不支持其他功能。当然一个source可以同时实现二者(如JDBC方式的实现类JdbcDynamicTableSource
),然后由planner根据指定的查询决定使用哪种方式。
planner最终会通过调用ScanTableSource
和LookupTableSource
中的getXxxProvider(Context context)
方法来获取运行时具体实现,Provider中封装了实际执行操作。
ScanRuntimeProvider getScanRuntimeProvider(ScanContext runtimeProviderContext)
LookupRuntimeProvider getLookupRuntimeProvider(LookupContext context);
因此Lookup表示实现了LookupTableSource
接口的查询外部表操作(重点:外部表数据不是以流/批的形式进入到应用程序中)。反映到Lookup join,则意味着根据左表记录(事件)去外部表中查询数据并输出结果。
本文将通过JDBC方式,探究Lookup和Lookup join的实现原理。
在JdbcDynamicTableSource
中关于scan和lookup两种方式的getXxxProvider(Context context)
方式实现的主要逻辑如下,其中lookup方式返回的Provider中封装了JdbcRowDataLookupFunction
实例。
@Override
public ScanRuntimeProvider getScanRuntimeProvider(ScanContext runtimeProviderContext) {
final JdbcRowDataInputFormat.Builder builder = ...;
return InputFormatProvider.of(builder.build());
}
@Override
public LookupRuntimeProvider getLookupRuntimeProvider(LookupContext context) {
// JDBC only support non-nested look up keys
String[] keyNames = ...;
JdbcRowDataLookupFunction lookupFunction = ...;
if (cache != null) {
// 带缓存的Provider
return PartialCachingLookupProvider.of(lookupFunction, cache);
} else {
return LookupFunctionProvider.of(lookupFunction);
}
}
JdbcRowDataLookupFunction
是TableFunction
实现类。TableFunction
是用户自定义表函数的基类(base class),表函数用于将零个、一个或多个标量值映射到零个、一个或多个rows或structured types。
TableFunction
通过一个属性和一个方法,完成数据处理和输出。
- 属性:
Collector<T> collector
,函数处理结果由该属性负责输出,并且Collector的具体实现在运行时由代码生成。 - 方法:子类具体行为通过实现一个或多个public、非static的且固定名称为
eval
的方法来实现(对入参无要求),该方法可以进行重载。eval方法没有定义在TableFunction
中,而是一种固定约定。
LookupFunction
抽象类表示从外部系统同步查询与lookup keys匹配的行。输出类型为RowData
。内部定义了eval方法,子类通过实现lookup
方法来实现具体查询操作。
public abstract class LookupFunction extends TableFunction<RowData> {
// 抽象的查询数据方法
public abstract Collection<RowData> lookup(RowData keyRow) throws IOException;
// 定义了TableFunction中的eval方法
public final void eval(Object... keys) {
GenericRowData keyRow = GenericRowData.of(keys);
try {
Collection<RowData> lookup = lookup(keyRow);
if (lookup == null) {
return;
}
// 输出结果
// this::collect即TableFunction中collector.collect()
lookup.forEach(this::collect);
} catch (IOException e) {
}
}
}
JdbcRowDataLookupFunction.lookup
方法实现如下,通过PreparedStatement API执行sql查询结果,然后将结果转成RowData
后输出,(重试开启时)会按照1s的时间间隔进行重试。揭开层层封装面纱之后的lookup查询的核心逻辑是不是有熟悉的味道。
@Override
public Collection<RowData> lookup(RowData keyRow) {
// 根据重试配置进行重试,重试间隔为1s
for (int retry = 0; retry <= maxRetryTimes; retry++) {
try {
statement = ...;
// 执行sql查询结果
try (ResultSet resultSet = statement.executeQuery()) {
ArrayList<RowData> rows = new ArrayList<>();
while (resultSet.next()) {
RowData row = jdbcRowConverter.toInternal(resultSet);
rows.add(row);
}
rows.trimToSize();
return rows;
}
} catch (SQLException e) {
//...
try {
// 重试时间间隔
Thread.sleep(1000L * retry);
} catch (InterruptedException e1) {
throw new RuntimeException(e1);
}
}
}
return Collections.emptyList();
}
了解lookup原理之后,接下来便看下lookup在join中是如何封装的。
CommonExecLookupJoin
类负责完成Lookup Join具体实现的实例化,该抽象类统一封装了批和流模式下Lookp join的公共逻辑。
CommonExecLookupJoin
封装内核可以粗暴的概括为UserDefinedFunction(TableFunction/AsyncTableFunction)->StreamOperator->Transformation
。最终返回OneInputTransformation
实例。
上述封装过程的入口方法为createJoinTransformation()
,主要步骤如下
- 1.获取到具体的
LookupTableSource.LookupRuntimeProvider
实现,从Provider实例中获取UserDefinedFunction
实例(lookupFunction)。 - 2.实例化具体的xxxRunner实现,具体实现类有4种:
AsyncLookupJoinRunner
、AsyncLookupJoinWithCalcRunner
、LookupJoinRunner
、LookupJoinWithCalcRunner
。
UserDefinedFunction lookupFunction = ...;
if (upsertMaterialize) {
// upsertMaterialize 仅支持同步查找模式,不支持异步查找模式
return createSyncLookupJoinWithState(...,(TableFunction<Object>) lookupFunction,...);
} else {
StreamOperatorFactory<RowData> operatorFactory;
if (isAsyncEnabled) {
operatorFactory =
createAsyncLookupJoin(...,(AsyncTableFunction<Object>) lookupFunction,...);
} else {
operatorFactory =
createSyncLookupJoin(...,(TableFunction<Object>) lookupFunction,...);
}
}
- 3.实例化
StreamOperatorFactory
。 - 4.实例化
Transformation
。
最终封装结构如下。
AsyncLookupJoinRunner
、AsyncLookupJoinWithCalcRunner
、LookupJoinRunner
、LookupJoinWithCalcRunner
四种作为AbstractRichFunction
子类执行数据处理。在其内部通过AsyncTableFunction
/TableFunction
执行Lookup Join数据查询。
直接查看LookupJoinRunner
类会发现在其内部并没有直接的TableFunction
引用。processElement()
方法中数据处理方式如下
FlatMapFunction<RowData, RowData> fetcher;
ListenableCollector<RowData> collector;
public void processElement(RowData in, Context ctx, Collector<RowData> out) throws Exception {
// ...
fetcher.flatMap(in, collector);
// ...
}
LookupJoinRunner、FlatMapFunction、TableFunction三者关系是什么?
fetcher和collector属性表示由代码生成器生成的子类实现,实际内容在运行时在内存中生成具体的、可执行的实现代码。代码生成过程和逻辑在LookupJoinRunner.open
方法中。
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
this.fetcher = generatedFetcher.newInstance(getRuntimeContext().getUserCodeClassLoader());
this.collector = generatedCollector.newInstance(getRuntimeContext().getUserCodeClassLoader());
}
通过demo进行debug后发现,fetcher实现类大致如下,在其内部调用了TableFunction.eval
方法。所以在LookupJoinRunner
中数据处理的完整的链路是LookupJoinRunner#processElement->FlatMapFunction#flatMap->TableFunciton#eval->LookupFunction#lookup
。
public class xxxFunction$12 extends RichFlatMapFunction {
// LookupFuction实例
private transient JdbcRowDataLookupFunction lookupFunction;
// 结果输出
private TableFunctionResultConverterCollector$10 resultConverterCollector$11 = null;
@Override
public void open(Configuration parameters) throws Exception {
// TableFunction中collector赋值
lookupFunction.setCollector(resultConverterCollector$11);
}
@Override
public void flatMap(in, out) throws Exception {
// 设置LookupFunction(TableFunction)的collector
resultConverterCollector$11.setCollector(out);
// 调用LookupFunction(TableFunction)的eval方法处理数据
lookupFunction.eval(in);
}
}
关于数据输出链路,在LookupJoinRunner.processElement()
方法中将collector属性传入到FlatMapFunction#flatMap
中,在上述生成代码中的flatMap中将out传入到resultConverterCollector$11
中,而resultConverterCollector$11
在open方法中赋值到了TableFunction
中的collector中,这样最终在TableFunction
中就持有了LookupJoinRunner
中的collector。
最后,LookupJoinWithCalcRunner
是在LookupJoinRunner
基础上多增加了一个额外数据处理的FlatMapFunction
实例。AsyncLookupJoinRunner
、AsyncLookupJoinWithCalcRunner
表示异步场景下的实现方式,代码风格同同步类一致。