Lookup join,何为Lookup?

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

### Flink Lookup Join 的使用方法及常见问题解决方案 Flink 中的 Lookup Join 是一种流与外部存储介质(如 Redis、MySQL、HBase 等)进行实时关联的操作。它允许流式数据通过实时查找的方式访问外部维表,从而丰富流中的数据内容。以下是 Lookup Join 的使用方法及常见问题的解决方案。 #### 1. 使用方法 Lookup Join 的实现通常基于 Flink SQL 或 DataStream API。以下为具体实现方式: - **Flink SQL 实现**: 在 Flink SQL 中,可以通过定义一个外部表并将其作为维表参与 Join 操作来实现 Lookup Join[^3]。例如,假设需要将 Kafka 流中的数据与 MySQL 中的维表进行关联,可以按照以下步骤操作: ```sql -- 定义 Kafka 表 CREATE TABLE kafka_table ( id BIGINT, name STRING, ts TIMESTAMP(3), WATERMARK FOR ts AS ts - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'user_topic', 'properties.bootstrap.servers' = 'localhost:9092', 'format' = 'json' ); -- 定义 MySQL 维表 CREATE TABLE mysql_table ( id BIGINT, address STRING, PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'jdbc', 'url' = 'jdbc:mysql://localhost:3306/db', 'table-name' = 'dim_address', 'username' = 'root', 'password' = '123456' ); -- 执行 Lookup Join SELECT k.id, k.name, m.address FROM kafka_table AS k JOIN mysql_table FOR SYSTEM_TIME AS OF k.ts AS m ON k.id = m.id; ``` - **DataStream API 实现**: 如果使用 DataStream API,则可以通过异步 I/O 或缓存机制实现 Lookup Join。以下是一个简单的示例代码: ```java import org.apache.flink.api.common.functions.RichMapFunction; import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; public class LookupJoinExample { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); // 模拟 Kafka 数据流 DataStream<String> kafkaStream = env.fromElements("1", "2", "3"); // 使用 RichMapFunction 实现 Lookup Join DataStream<String> result = kafkaStream.map(new RichMapFunction<String, String>() { @Override public String map(String id) throws Exception { // 模拟从 MySQL 查询地址 String address = queryAddressFromMySQL(id); return id + ": " + address; } private String queryAddressFromMySQL(String id) { // 实际查询逻辑 return "Address-" + id; } }); result.print(); env.execute("Lookup Join Example"); } } ``` #### 2. 常见问题及解决方案 - **数据库连接数不足**: 当大量 Task 并行访问数据库时,可能导致连接数耗尽。为解决此问题,可以采取以下措施: - 使用连接池控制最大并发连接数[^1]。 - 为查询量大的场景准备只读分库或分片。 - 使用专门的缓存层(如 Redis 或 HBase)代替直连数据库[^1]。 - **性能优化**: 在高吞吐场景下,Lookup Join 的性能可能会成为瓶颈。以下是一些优化策略: - **按 key 分桶 + Local Cache**:通过按 key 分桶的方式,让大多数数据的维表关联访问本地缓存,减少外部存储的访问次数[^4]。 - **异步访问外存**:利用 DataStream API 的异步算子,通过线程池同时多次请求维表外部存储,提升吞吐量。 - **批量访问外存**:使用外部存储的批量处理能力(如 Redis 的 Pipeline),攒一批数据后一次性发送请求,降低网络开销[^4]。 - **数据一致性问题**: Lookup Join 中的维表数据可能发生变化,导致流中数据无法关联到最新的维度信息。为确保数据一致性,可以选择支持增量更新的维表,并结合 Flink 的 Checkpoint 机制定期刷新缓存[^3]。 #### 3. 注意事项 - 只有支持 lookup source connector 类型的表才可以用于 Lookup Join,例如 JDBC connector[^3]。 - Lookup Join 的性能高度依赖于外部存储的响应速度和网络延迟。因此,在设计系统时应充分考虑这些因素[^4]。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值