Lookup join,何为Lookup?

348 阅读5分钟

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最终会通过调用ScanTableSourceLookupTableSource中的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);
    }
}

在这里插入图片描述

JdbcRowDataLookupFunctionTableFunction实现类。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种:AsyncLookupJoinRunnerAsyncLookupJoinWithCalcRunnerLookupJoinRunnerLookupJoinWithCalcRunner
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

最终封装结构如下。

在这里插入图片描述

AsyncLookupJoinRunnerAsyncLookupJoinWithCalcRunnerLookupJoinRunnerLookupJoinWithCalcRunner四种作为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实例。AsyncLookupJoinRunnerAsyncLookupJoinWithCalcRunner表示异步场景下的实现方式,代码风格同同步类一致。