Flink join汇总总结

Flink Sql join汇总总结

  • 1 有界join
    • 1.1 Window Join
      • 1.1.1 DataStream API
      • 1.1.2 SQL
      • 1.1.3 window join总结
    • 1.2 Interval Join
      • 1.2.1 DataStream API
      • 1.2.2 SQL
      • 1.2.3 Interval Join总结
    • 1.3 Temporary Join
      • 1.3.1 DataStream API
      • 1.3.2 SQL
    • 1.4 LoopUp Join
    • Flink SQL 维表 JOIN 的优化
      • 维表 JOIN 的常见问题
        • 优化点 1:Async I/O
        • 优化点 2:维表缓存
        • 优化点 3:批量关联
        • 优化点 4:延迟关联
  • 2 无界join
    • 2.1 Regular Join
      • 2.1.1 SQL
      • 2.1.2 Regular Join总结
  • 3 join优化方案
    • 3.1 key相同时共用state
    • 3.2 state过大优化
    • 3.3 使用外部存储保存state
  • 引用

对于mysql或者hive等计算引擎的join相信大家都有一定了解,两个离线全量数据集根据规则匹配输出结果。这里引出一个概念,像这种数据范围固定的数据,可以称为有界数据,因为数据最大就那么大,100条关联100条 最多的关联结果就是笛卡尔积1w,总数据量是固定的。那么抛出一个问题,如果是两条流式数据做join,怎么做呢,数据集不是全量数据,并且是无界流,没人知道数据多大,不知道a流的数据在b中有没有 什么时候到达,这个join要如何做呢?
flink中做了两类方案来解决:1 用窗口的方式将无界数据转变成有界数据,称为和hive mysql一样的 两个确定的数据集直接的关联操作。2 还是使用无界流来做join,下面详细分析:

1 有界join

1.1 Window Join

window join就是将两条流划分出时间窗口,数据已经被划分为窗口,无界数据变为有界数据,就和离线批处理的方式一样了,两个窗口的数据简单的进行关联即可,窗口结束就把数据下发下去,关联到的数据就下发 [A, B],没有关联到的数据取决于是否是 outer join 然后进行数据下发。
Flink join汇总总结_第1张图片

1.1.1 DataStream API

flinkEnv.env()
    // A 流
    .addSource(new SourceFunction<Object>() {
        @Override
        public void run(SourceContext<Object> ctx) throws Exception {
            
        }
    
        @Override
        public void cancel() {
    
        }
    })
    // B 流
    .join(flinkEnv.env().addSource(new SourceFunction<Object>() {
        @Override
        public void run(SourceContext<Object> ctx) throws Exception {
            
        }
    
        @Override
        public void cancel() {
    
        }
    }))
    // A 流的 keyby 条件
    .where(new KeySelector<Object, Object>() {
        @Override
        public Object getKey(Object value) throws Exception {
            return null;
        }
    })
    // B 流的 keyby 条件
    .equalTo(new KeySelector<Object, Object>() {
        @Override
        public Object getKey(Object value) throws Exception {
            return null;
        }
    })
    // 开窗口
    .window(TumblingEventTimeWindows.of(Time.seconds(60)))
    // 窗口中关联到的数据的处理逻辑
    .apply(new JoinFunction<Object, Object, Object>() {
        @Override
        public Object join(Object first, Object second) throws Exception {
            return null;
        }
    });







public class WindowJoinTest {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        //定义两条流
        DataStream<Tuple2<String, Long>> stream1 = env.fromElements(
                        Tuple2.of("a", 1000L),
                        Tuple2.of("b", 1000L),
                        Tuple2.of("a", 2000L),
                        Tuple2.of("b", 2000L)
                )
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Tuple2<String, Long>>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                                                   @Override
                                                   public long extractTimestamp(Tuple2<String, Long> stringLongTuple2, long l) {
                                                       return stringLongTuple2.f1;
                                                   }
                                               }
                        )
                );
        DataStream<Tuple2<String, Long>> stream2 = env.fromElements(
                        Tuple2.of("a", 3000L),
                        Tuple2.of("b", 3000L),
                        Tuple2.of("a", 4000L),
                        Tuple2.of("b", 4000L)
                )
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Tuple2<String, Long>>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                                                   @Override
                                                   public long extractTimestamp(Tuple2<String, Long> stringLongTuple2, long l) {
                                                       return stringLongTuple2.f1;
                                                   }
                                               }
                        )
                );
        stream1
                .join(stream2)
                .where(data -> data.f0)
                .equalTo(data -> data.f0)
                .window(TumblingEventTimeWindows.of(Time.seconds(5)))
                .apply(new JoinFunction<Tuple2<String, Long>, Tuple2<String, Long>, String>() {
                    @Override
                    public String join(Tuple2<String, Long> left, Tuple2<String, Long> right) throws Exception {
                        return left + "=>" + right;
                    }
                })
                .print();
        env.execute();
    }
}


Flink join汇总总结_第2张图片

上述解决方案只支持 inner join,即窗口内能关联到的才会下发,关联不到的则直接丢掉。

如果你想实现 window 上的 outer join,可以使用 coGroup 算子,案例如下:

public class CogroupFunctionDemo02 {

    public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env=StreamExecutionEnvironment.getExecutionEnvironment();

        // A 流
        DataStream<Tuple2<String,String>> input1=env.socketTextStream("",9002)
                .map(new MapFunction<String, Tuple2<String,String>>() {

                    @Override
                    public Tuple2<String,String> map(String s) throws Exception {

                        return Tuple2.of(s.split(" ")[0],s.split(" ")[1]);
                    }
                });

        // B 流
        DataStream<Tuple2<String,String>> input2=env.socketTextStream("",9001)
                .map(new MapFunction<String, Tuple2<String,String>>() {

                    @Override
                    public Tuple2<String,String> map(String s) throws Exception {

                        return Tuple2.of(s.split(" ")[0],s.split(" ")[1]);
                    }
                });

        // A 流关联 B 流
        input1.coGroup(input2)
                // A 流的 keyby 条件
                .where(new KeySelector<Tuple2<String,String>, Object>() {

                    @Override
                    public Object getKey(Tuple2<String, String> value) throws Exception {
                        return value.f0;
                    }
                }).equalTo(new KeySelector<Tuple2<String,String>, Object>() {
                // B 流的 keyby 条件

            @Override
            public Object getKey(Tuple2<String, String> value) throws Exception {
                return value.f0;
            }
        })
                // 窗口
                .window(ProcessingTimeSessionWindows.withGap(Time.seconds(3)))
                .apply(new CoGroupFunction<Tuple2<String,String>, Tuple2<String,String>, Object>() {

                // 可以自定义实现 A 流和 B 流在关联不到时的输出数据格式

                    @Override
                    public void coGroup(Iterable<Tuple2<String, String>> iterable, Iterable<Tuple2<String, String>> iterable1, Collector<Object> collector) throws Exception {
                        StringBuffer buffer=new StringBuffer();
                        buffer.append("DataStream frist:\n");
                        for(Tuple2<String,String> value:iterable){
                            buffer.append(value.f0+"=>"+value.f1+"\n");
                        }
                        buffer.append("DataStream second:\n");
                        for(Tuple2<String,String> value:iterable1){
                            buffer.append(value.f0+"=>"+value.f1+"\n");
                        }
                        collector.collect(buffer.toString());
                    }
                }).print();

        env.execute();
    }
}

或者你还可以使用 connect 算子自定义各种关联操作(connect 算子相比 join、coGroup 算子灵活很多):

// (userEvent, userId)
KeyedStream<UserEvent, String> customerUserEventStream = env
        .addSource(kafkaUserEventSource)
        .assignTimestampsAndWatermarks(new CustomWatermarkExtractor(Time.hours(24)))
        .keyBy(new KeySelector<UserEvent, String>() {
            @Override
            public String getKey(UserEvent userEvent) throws Exception {
                return userEvent.getUserId();
            }
        });
//customerUserEventStream.print();

final BroadcastStream<Config> configBroadcastStream = env
        .addSource(kafkaConfigEventSource)
        .broadcast(configStateDescriptor);

final FlinkKafkaProducer010 kafkaProducer = new FlinkKafkaProducer010<EvaluatedResult>(
        params.get(OUTPUT_TOPIC),
        new EvaluatedResultSerializationSchema(),
        producerProps);

DataStream<EvaluatedResult> connectedStream = customerUserEventStream
        .connect(configBroadcastStream)
        .process(new ConnectedBroadcastProcessFuntion());

1.1.2 SQL

SELECT 
    L.num as L_Num
    , L.id as L_Id
    , R.num as R_Num
    , R.id as R_Id
    , L.window_start
    , L.window_end
FROM (
    SELECT * 
    FROM TABLE(TUMBLE(TABLE LeftTable, DESCRIPTOR(row_time), INTERVAL '5' MINUTES))
) L
FULL JOIN (
    SELECT * 
    FROM TABLE(TUMBLE(TABLE RightTable, DESCRIPTOR(row_time), INTERVAL '5' MINUTES))
) R
ON L.num = R.num 
AND L.window_start = R.window_start 
AND L.window_end = R.window_end;

1.1.3 window join总结

当我们的窗口大小划分的越细时,在窗口边缘关联不上的数据就会越多,数据质量就越差。窗口大小划分的越宽时,窗口内关联上的数据就会越多,数据质量越好,但是产出时效性就会越差。所以小伙伴萌在使用时要注意取舍。

举个例子:以曝光关联点击来说,如果我们划分的时间窗口为 1 分钟,那么一旦出现曝光在 0:59,点击在 1:01 的情况,就会关联不上,当我们的划分的时间窗口 1 小时时,只有在每个小时的边界处的数据才会出现关联不上的情况。

该种解决方案适用于可以评估出窗口内的关联率高的场景,如果窗口内关联率不高则不建议使用。

注意:这种方案由于上面说到的数据质量和时效性问题在实际生产环境中很少使用。

1.2 Interval Join

其也是将两条流的数据从无界数据变为有界数据,但是这里的有界和上节说到的 Flink Window Join 的有界的概念是不一样的,这里的有界是指两条流之间的有界。

以 A 流 join B 流举例,interval join 可以让 A 流可以关联 B 流一段时间区间内的数据,比如 A 流关联 B 流前后 5 分钟的数据。

Flink join汇总总结_第3张图片
数据已经被划分为窗口,无界数据变为有界数据,就和离线批处理的方式一样了,两个窗口的数据简单的进行关联即可。窗口结束(这里的窗口结束是指 interval 区间结束,区间的结束是利用 watermark 来判断的)就把数据下发下去,关联到的数据就下发 [A, B],没有关联到的数据取决于是否是 outer join 然后进行数据下发。

  • 时间区间JOIN:让一条流去JOIN另一条流的前后一段时间内的数据,INTERVAL JOIN可以避免回撤流的产生,在某些场景下,下游输出系统不具备处理回撤流的能力,此时可以借助INTERVAL JOIN
  • INNER INTERVAL JOIN:只有两条流 JOIN 到(满足ON中的条件:两条流的数据在时间区间 + 满足其他等值条件)才输出,输出 +[L, R]
  • LEFT INTERVAL JOIN:流任务中,左流数据到达之后,如果没有JOIN到右流的数据,就会等待(放在 State 中等),如果之后右流之后数据到达之后,发现能和刚刚那条左流数据 JOIN 到,则会输出+[L, R]。事件时间中随着 Watermark 的推进, 如果发现发现左流 State 中的数据过期了,就把左流中过期的数据从 State 中删除,然后输出+[L, R],如果右流 State 中的数据过期了,就直接从 State 中删除
  • RIGHT INTERVAL JOIN:处理逻辑和LEFT INTERVAL JOIN类似
  • FULL INTERVAL JOIN:流任务中,左流或者右流的数据到达之后,如果没有 Join 到另外一条流的数据,就会等待(左流放在左流对应的 State 中等,右流放在右流对应的 State 中等),如果之后另一条流数据到达之后,发现能和刚刚那条数据 Join 到,则会输出+[L, R]。事件时间中随着 Watermark 的推进(也支持处理时间),发现 State 中的数据能够过期了,就将这些数据从 State 中删除并且输出(左流过期输出 +[L, NULL],右流过期输出 -[NULL, R])

1.2.1 DataStream API

clickRecordStream
  .keyBy(record -> record.getMerchandiseId())
  .intervalJoin(orderRecordStream.keyBy(record -> record.getMerchandiseId()))
  // 定义 interval 的时间区间
  .between(Time.seconds(-30), Time.seconds(30))
  .process(new ProcessJoinFunction<AnalyticsAccessLogRecord, OrderDoneLogRecord, String>() {
    @Override
    public void processElement(AnalyticsAccessLogRecord accessRecord, OrderDoneLogRecord orderRecord, Context context, Collector<String> collector) throws Exception {
      collector.collect(StringUtils.join(Arrays.asList(
        accessRecord.getMerchandiseId(),
        orderRecord.getPrice(),
        orderRecord.getCouponMoney(),
        orderRecord.getRebateAmount()
      ), '\t'));
    }
  })
  .print();

1.2.2 SQL

CREATE TABLE show_log_table (
     log_id BIGINT,
     show_params STRING,
     row_time AS cast(CURRENT_TIMESTAMP as timestamp(3)),
     WATERMARK FOR row_time AS row_time
 ) WITH (
   'connector' = 'datagen',
   'rows-per-second' = '1',
   'fields.show_params.length' = '1',
   'fields.log_id.min' = '1',
   'fields.log_id.max' = '10'
 );
 
 CREATE TABLE click_log_table (
     log_id BIGINT,
     click_params STRING,
     row_time AS cast(CURRENT_TIMESTAMP as timestamp(3)),
     WATERMARK FOR row_time AS row_time
 )
 WITH (
   'connector' = 'datagen',
   'rows-per-second' = '1',
   'fields.click_params.length' = '1',
   'fields.log_id.min' = '1',
   'fields.log_id.max' = '10'
 );
 
 CREATE TABLE sink_table (
     s_id BIGINT,
     s_params STRING,
     c_id BIGINT,
     c_params STRING
 ) WITH (
   'connector' = 'print'
 );
 
 INSERT INTO sink_table
 SELECT
     show_log_table.log_id as s_id,
     show_log_table.show_params as s_params,
     click_log_table.log_id as c_id,
     click_log_table.click_params as c_params
 FROM show_log_table FULL JOIN click_log_table ON show_log_table.log_id = click_log_table.log_id
 AND show_log_table.row_time BETWEEN click_log_table.row_time - INTERVAL '5' SECOND AND click_log_table.row_time

1.2.3 Interval Join总结

interval join 的方案比 window join 方案在数据质量上好很多,但是其也是存在 join 不到的情况的。并且如果为 outer join 的话,outer 一测的流数据需要要等到区间结束才能下发。

该种解决方案适用于两条流之间可以明确评估出相互延迟的时间是多久的,这里我们可以使用离线数据进行评估,使用离线数据的两条流的时间戳做差得到一个分布区间。

比如在 A 流和 B 流时间戳相差在 1min 之内的有 95%,在 1-4 min 之内的有 4.5%,则我们就可以认为两条流数据时间相差在 4 min 之内的有 99.5%,这时我们将上下界设置为 4min 就是一个能保障 0.5% 误差的合理区间。

注意:这种方案在生产环境中还是比较常用的。

1.3 Temporary Join

首先介绍一个时态表的概念,这是一个随时间不断变化的动态表,它可能包含表的多个快照。对于时态表中的记录,可以追踪、访问其历史版本的表称为版本表,如数据库的 changeLog;只能追踪、访问最新版本的表称为普通表,如数据库的表。举个例子,外汇订单金额计算,要计算当时的汇率来汇总,这时汇率表用时态表就很合适。

1.3.1 DataStream API

import org.apache.flink.table.functions.TemporalTableFunction;
(...)

// 获取 stream 和 table 环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment tEnv = StreamTableEnvironment.create(env);

// 提供一个汇率历史记录表静态数据集
List<Tuple2<String, Long>> ratesHistoryData = new ArrayList<>();
ratesHistoryData.add(Tuple2.of("US Dollar", 102L));
ratesHistoryData.add(Tuple2.of("Euro", 114L));
ratesHistoryData.add(Tuple2.of("Yen", 1L));
ratesHistoryData.add(Tuple2.of("Euro", 116L));
ratesHistoryData.add(Tuple2.of("Euro", 119L));

// 用上面的数据集创建并注册一个示例表
// 在实际设置中,应使用自己的表替换它
DataStream<Tuple2<String, Long>> ratesHistoryStream = env.fromCollection(ratesHistoryData);
Table ratesHistory = tEnv.fromDataStream(ratesHistoryStream, $("r_currency"), $("r_rate"), $("r_proctime").proctime());

tEnv.createTemporaryView("RatesHistory", ratesHistory);

// 创建和注册时态表函数
// 指定 "r_proctime" 为时间属性,指定 "r_currency" 为主键
TemporalTableFunction rates = ratesHistory.createTemporalTableFunction("r_proctime", "r_currency"); // <==== (1)
tEnv.registerFunction("Rates", rates);     

1.3.2 SQL

	SELECT column-names
	FROM table1 [AS <alias1>]
	[LEFT] JOIN table2 FOR SYSTEM_TIME AS OF table1.proctime [AS <alias2>]
	ON table1.column-name1 = table2.key-name1

定义时态表 要求 (1)主键(2)WATERMARK

1.4 LoopUp Join

在关联维度表时。JDBC 连接器可以用在时态表关联中作为一个可 lookup 的 source (又称为维表),当前只支持同步的查找模式。

默认情况下,lookup cache 是未启用的,你可以设置 lookup.cache.max-rows and lookup.cache.ttl 参数来启用。

lookup cache 的主要目的是用于提高时态表关联 JDBC 连接器的性能。默认情况下,lookup cache 不开启,所以所有请求都会发送到外部数据库。
当 lookup cache 被启用时,每个进程(即 TaskManager)将维护一个缓存。
Flink 将优先查找缓存,只有当缓存未查找到时才向外部数据库发送请求,并使用返回的数据更新缓存。
当缓存命中最大缓存行 lookup.cache.max-rows 或当行超过最大存活时间 lookup.cache.ttl 时,缓存中最老的行将被设置为已过期。
缓存中的记录可能不是最新的,用户可以将 lookup.cache.ttl 设置为一个更小的值以获得更好的刷新数据,但这可能会增加发送到数据库的请求数。
所以要做好吞吐量和正确性之间的平衡。

CREATE TEMPORARY TABLE mysql_behavior_conf (
   id int
  ,code STRING
  ,map_val STRING
  ,update_time TIMESTAMP(3)
--   ,primary key (id) not enforced
--   ,WATERMARK FOR update_time AS update_time - INTERVAL '5' SECOND
) WITH (
   'connector' = 'jdbc'
   ,'url' = 'jdbc:mysql://localhost:3306/venn'
   ,'table-name' = 'lookup_join_config'
   ,'username' = 'root'
   ,'passwordPA' = '******'
   ,'lookup.cache.max-rows' = '1000'
   ,'lookup.cache.ttl' = '1 minute' -- 缓存时间,即使一直在访问也会删除
);

其实本质上跟使用异步IO加缓存实现的效果相同,lookup join底层也是使用guava 的 LocalCache做缓存

现在让我们详细看下 LookupJoin 对应的 Operator 是如何进行维表关联的。

前往 CommonExecLookupJoin.translateToPlanInternal() 方法[1],可以看到这个 Operator 的 operatorFactory 由 createAsyncLookupJoin 或者 createSyncLookupJoin 生成,最终生成的 LookupJoinRunner 算子使用用户定义的 LookupFunction 来作为最终访问外部维表的函数。

Lookup JOIN 算子的调用链如下图所示:

Flink join汇总总结_第4张图片
LookupTableSource 和 LookupFunction
通过上面的分析,我们知道维表 JOIN 实际上基于 Flink SQL 的 LookupTableSource 实现。LookupTableSource 的 scan 逻辑基于 UDF LookupFunction,当事实表的数据到来时,调用 LookupFunction 的 eval 方法,前往外部数据源进行关联查询。代码详情请关注 LookupTableSource.java。

LookupFunction 的实现通常分为以下几个部分:

  1. 在 open() 方法中建立并维护与外部系统的连接;
  2. eval() 方法实现与外部系统的关联逻辑。

Flink SQL 维表 JOIN 的优化

维表 JOIN 的常见问题

维表 Join 的默认策略是实时、同步查询维表,每条流数据到来时,在 Flink 算子中直接访问维表数据源来进行关联。这种方式可以保证维表数据是最新的,但是当数据流量过大时,频繁的维表实时查询会对外部系统带来巨大的压力,可能导致连接失败、处理线程打满等情况,出现线程阻塞、数据返回缓慢等后果,影响任务整体的吞吐量。而且这种方案对外部系统能承受的 QPS 要求较高,在大数据实时计算场景下,QPS 远高于普通的后台系统,峰值高达百万甚至千万,导致整体作业处理瓶颈转移到外部系统。

此外,维表并不是永远不变的,而维表的变化可能导致无法关联。例如维表有新增维度,而 JOIN 操作发生在维度新增之前,由于维表 JOIN 只能关联处理时间的快照,就会导致事实数据关联不上。这也是很多用户的使用痛点。

优化点 1:Async I/O

维表 JOIN 默认为同步访问方式,上游每输入一条数据就会前往外部表中查询一次,等待返回后输出关联结果,期间的网络耗时与外部表的查询延迟极大地阻碍了流作业的吞吐,加大了数据处理延迟。为了解决同步访问外部数据源的问题,可以引入异步模式处理查询请求,使得连续的关联请求之间不需要阻塞等待。

同步请求和异步请求外部维表,对比图如下:
Flink join汇总总结_第5张图片基于 Flink Async I/O 和异步客户端,我们可以实现维表 JOIN 的异步化,极大地提高维表 JOIN 的吞吐率。

在 Flink SQL 中,通过继承 AsyncTableFunction,实现异步的 eval() 方法,即可完成异步维表 JOIN。以 HBaseAsyncLookupFunction 为例,简单分析异步化维表 JOIN 的实现:

实际的实现是集成AsyncTableFunction 实现了他的方法,自己在open中缓存一份数据,以hbase维度表为例

public class HBaseRowDataAsyncLookupFunction extends AsyncTableFunction<RowData> {
 
 
  @Override
  public void open(FunctionContext context) {
 
 
      // 建立线程池
      final ExecutorService threadPool =
              Executors.newFixedThreadPool(
                      THREAD_POOL_SIZE,
                      new ExecutorThreadFactory(
                              "hbase-async-lookup-worker", Threads.LOGGING_EXCEPTION_HANDLER));
      Configuration config = prepareRuntimeConfiguration();
       
      // 异步建立 HBase 连接
      CompletableFuture<AsyncConnection> asyncConnectionFuture =
              ConnectionFactory.createAsyncConnection(config);
      asyncConnection = asyncConnectionFuture.get();
      table = asyncConnection.getTable(TableName.valueOf(hTableName), threadPool);
      this.serde = new HBaseSerde(hbaseTableSchema, nullStringLiteral);
  }
   
  public void eval(CompletableFuture<Collection<RowData>> future, Object rowKey) {
      Get get = serde.createGet(rowKey);
      // 去 HBase 表中查询
      CompletableFuture<Result> responseFuture = table.get(get);        
      responseFuture.whenCompleteAsync(
          (result, throwable) -> {
              if (throwable != null) {
              // 发生异常时,调用 future.completeExceptionally
              resultFuture.completeExceptionally(
                      new RuntimeException("HBase table '" + hTableName + "' not found.",throwable));
              } else {
                  RowData rowData = serde.convertToNewRow(result);
                  // 正常返回时,调用 future.complete,向下游发送消息
                  resultFuture.complete(Collections.singletonList(rowData));
              }
          }
      )
  }
}

从代码中可以看出,维表 JOIN 异步化的关键点在于:

  1. 需要支持异步查询的外部数据源客户端;

  2. eval 方法中使用 CompletableFuture 处理异步请求的结果。

优化点 2:维表缓存

除了将同步查询改为异步,我们还可以缓存维表中的数据,保存到 Flink 作业 TaskManager 的内存中,流数据到来时,只需要查询本地缓存中的数据,无需与远程数据源进行交互,可以极大提升数据处理的吞吐量。

维表缓存的实现有多种方式,可以用一张表格进行总结:

缓存类型 实现细节 优点 缺点
全量缓存 LookupFunction 的 open() 方法中预加载维表全量数据,并保存到本地缓存中。eval() 方法先查询缓存,无法找到再查询维表外部数据源。 1.实现简单;2.有效提高维表 JOIN 的吞吐。 1.数据全量保存,无法应对超大维表;2.维表数据更新比较困难。
LRU 缓存 LookupFunction 的 open() 方法中初始化 LRU 缓存。eval() 方法先查询缓存,无法找到再查询维表外部数据源,返回的结果存入缓存以备下次查询。需要设置缓存 TTL 和缓存 Size 来控制缓存数据的失效时间和缓存大小。 1.降低数据库的查询压力;2.降低内存消耗。 1.QPS 很高的情况下缓存命中率较低;2.需要合理设置 TTL 和缓存大小。
Partitioned 缓存 LookupFunction 的 open() 方法中初始化 LRU/全量 缓存。事实数据关联维表前,先按照 JOIN Key 进行 Hash 操作。 每个 Subtask 加载所需的维表数据到缓存,降低内存消耗,提高吞吐。 Hash 操作消耗额外的网络和CPU资源。
全量缓存和 LRU 缓存的实现都比较简单,只需调整 LookupFunction 即可,而 Partitioned 缓存的实现涉及的改动点很多,下面进行详细分析。

通过观察作业拓扑和执行计划,我们发现 Cacl 算子和 LookupJoin 算子是 Chain 在一起的。维表 JOIN 是一种等值 JOIN,天然具有 Hash 属性,如果能在 Cacl 算子和 LookupJoin 算子之间生成 Hash 算子,即可实现 Partitioned cache。

方案 1

方案1:在 ExecNodeGraph 生成 Transformation 时进行调整。考虑在 CaclTransformation 和 LookupJoin Transformation 之间添加 PartitionTransformation。

修改 LookupJoin 对应的 ExecNode CommonExecLookupJoin,调整 translateToPlanInternal()方法,在生成的 outputTransformation 和上游的 inputTransformation 之间添加 PartitionTransformation,根据 JOIN Key 进行 Hash。

public Transformation<RowData> translateToPlanInternal(PlannerBase planner) {
  // 之前的代码省略
  Transformation<RowData> inputTransformation =
            (Transformation<RowData>) inputEdge.translateToPlan(planner);
 
 
    // TODO: 新增 partitionTransformation
    int[] hashKeys = lookupKeys.keySet().stream().mapToInt(key -> key).toArray();
    final RowDataKeySelector keySelector =
        KeySelectorUtil.getRowDataSelector(hashKeys, InternalTypeInfo.of(inputRowType));
    final StreamPartitioner<RowData> partitioner =
        new KeyGroupStreamPartitioner<>(
            keySelector, DEFAULT_LOWER_BOUND_MAX_PARALLELISM);
    final Transformation<RowData> partitionTransformation =
        new PartitionTransformation<>(inputTransformation, partitioner);
    partitionTransformation.setParallelism(inputTransformation.getParallelism());
 
 
    OneInputTransformation<RowData, RowData> inputTransform = new OneInputTransformation<>(
        partitionTransformation,
        getDescription(),
        operatorFactory,
        InternalTypeInfo.of(resultRowType),
        partitionTransformation.getParallelism());
    inputTransform.setParallelism(partitionTransformation.getParallelism());
    inputTransform.setOutputType(InternalTypeInfo.of(resultRowType));
    return inputTransform;
}

方案 2

方案 2:在 Logical 优化阶段为节点添加 Hash FlinkRelDistribution Trait,在 Physical 优化阶段该 Trait 会生成 StreamPhysicalExchange Node。

在 StreamPhysicalLookupJoinRule.doTransform() 中将 FlinkLogicalRel 中的默认 FlinkRelDistribution Trait 替换成 Hash。

private def doTransform(
  join: FlinkLogicalJoin,
  input: FlinkLogicalRel,
  temporalTable: RelOptTable,
  calcProgram: Option[RexProgram]): StreamPhysicalLookupJoin = {
 
 
  val joinInfo = join.analyzeCondition
 
 
  val cluster = join.getCluster
 
 
  val providedTrait = join.getTraitSet.replace(FlinkConventions.STREAM_PHYSICAL)
 
 
  var requiredTrait = input.getTraitSet.replace(FlinkConventions.STREAM_PHYSICAL)
  val options = temporalTable.asInstanceOf[TableSourceTable].catalogTable.getOptions
  // 获取维表配置
  val enablePartitionedCache = options.getOrDefault("lookup.enable-partitioned-cache", "false").toBoolean
  if (enablePartitionedCache) {
    val requiredDistribution = FlinkRelDistribution.hash(joinInfo.leftKeys, true)
    requiredTrait = input.getTraitSet
      // 替换 FlinkRelDistributionTraitDef
      .replace(requiredDistribution)
      .replace(FlinkConventions.STREAM_PHYSICAL)
  }
 
 
  val convInput = RelOptRule.convert(input, requiredTrait)
  new StreamPhysicalLookupJoin(
    cluster,
    providedTrait,
    convInput,
    temporalTable,
    calcProgram,
    joinInfo,
    join.getJoinType)
}

优化点 3:批量关联

维表 JOIN 时,攒一批数据以后调用维表的批量查询接口,进行批量关联,可以减少 RPC 的调用次数,提高吞吐量。

批量关联的实现可以分为以下步骤:

添加是否开启 Batch JOIN 对应的配置,设置 Batch Size 和 Batch 触发 TTL;

  1. CommonExecLookupJoin 构造 ProcessFunction 时,根据是否开启 Batch JOIN 配置分别构造 LookupJoinRunner 或 BatchLookupJoinRunner;

  2. BatchLookupJoinRunner 的 processElement() 方法中实现攒批逻辑,使用 ListState 攒批,通过 timer 触发 批量关联操作;

  3. 调整 CodeGen 相关类,为 BatchLookupJoinRunner 对应的 generatedFetcher、generatedCollector 和 generatedCalc 赋予正确的输入和输出:List;

  4. LookupFunction 的 eval 方法调用批量查询接口。

优化点 4:延迟关联

由于维表 JOIN 只能关联处理时间的快照,可能导致事实数据无法关联更新后的维度,造成关联失败。

对于这种场景,我们可以实现延迟关联功能。如果 Join 没有命中,数据无法关联,可以暂时将事实数据缓存在 Flink State 中,等待一段时间后进行重试,并且可以控制等待时间与重试次数。

延迟关联的实现可以分为以下步骤:

  1. 添加是否开启 Delay JOIN 对应的配置,设置 Delay Join Intervals 和 RetryTimes;

  2. CommonExecLookupJoin 构造 ProcessFunction 时,根据是否开启 Delay JOIN 配置分别构造 LookupJoinRunner 或 DelayedLookupJoinRunner;

  3. DelayedLookupJoinRunner 的 processElement() 方法中实现延迟 JOIN 逻辑,如果无法关联则将事实数据保存在 ListState 中,通过设置 timer 和重试次数,延时触发关联操作。

2 无界join

2.1 Regular Join

regular join 还是基于无界数据进行关联,以 A 流 left join B 流举例,A 流数据到来之后,直接去尝试关联 B 流数据。

  • 如果关联到了则直接下发关联到的数据
  • 如果没有关联到则也直接下发没有关联到的数据,后续 B 流中的数据到来之后,会把之前下发下去的没有关联到数据撤回,然后把关联到的数据数据进行下发。由此可以看出这是基于 Flink SQL 的 retract 机制,则也就说明了其目前只支持 Flink SQL。
    Flink join汇总总结_第6张图片两条流的数据会尝试关联,能关联到直接下发,关联不到先下发一个目前的结果数据。

2.1.1 SQL

CREATE TABLE show_log_table (
    log_id BIGINT,
    show_params STRING
) WITH (
  'connector' = 'datagen',
  'rows-per-second' = '1',
  'fields.show_params.length' = '3',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '10'
);

CREATE TABLE click_log_table (
  log_id BIGINT,
  click_params     STRING
)
WITH (
  'connector' = 'datagen',
  'rows-per-second' = '1',
  'fields.click_params.length' = '3',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '10'
);

CREATE TABLE sink_table (
    s_id BIGINT,
    s_params STRING,
    c_id BIGINT,
    c_params STRING
) WITH (
  'connector' = 'print'
);

INSERT INTO sink_table
SELECT
    show_log_table.log_id as s_id,
    show_log_table.show_params as s_params,
    click_log_table.log_id as c_id,
    click_log_table.click_params as c_params
FROM show_log_table
LEFT JOIN click_log_table ON show_log_table.log_id = click_log_table.log_id;
  • 实时REGULAR JOIN支持等值JOIN和不等值JOIN,等值JOIN SHUFFLE策略是HASH,非等值JOIN策略是GLOBAL,所有数据发往一个并发,按照非等值条件进行关联

  • REGULAR JOIN会将两条流的所有数据都存储在 State 中,所以 Flink 任务的 State 会无限增大,因此需要为 State 配置合适的 TTL,以防止 State 过大

数据质量和时效性高的原因都是因为 regular join 会保障目前 Flink 任务已经接收到的数据中能关联的一定是关联上的,即使关联不上,数据也会下发,完完全全保障了当前数据的客观性和时效性。

2.1.2 Regular Join总结

该种解决方案虽然是目前在产出质量、时效性上最好的一种解决方案,但是在实际场景中使用时,也存在一些问题:

  • 基于 retract 机制,所有的数据都会存储在 state 中以判断能否关联到,所以我们要设置合理的 state ttl 来避免大 state 问题导致的任务不稳定
  • 基于 retract 机制,所以在数据发生更新时,会下发回撤数据、最新数据 2 条消息,当我们的关联层级越多,则下发消息量的也会放大,并且会出现数据回撤导致的udf失效 ,及去重问题。
  • sink 组件要支持 retract,我们不要忘了最终数据是要提供数据服务给需求方进行使用的,所以我们最终写入的数据组件也需要支持 retract,比如 MySQL。如果写入的是 Kafka,则下游消费这个 Kafka 的引擎也需要支持回撤\更新机制。

3 join优化方案

但是我们可以发现,无论是哪一种 Join 方案,Join 的前提都是将 A 流和 B 流的数据先存储在状态中,然后再进行关联。

即在实际生产中使用时常常会碰到的问题就是:大状态的问题。

关于大状态问题业界常见两种解决思路:

  • 减少状态大小:在 Flink Join 中的可以想到的优化措施就是减少 state key 的数量。在未优化之前 A 流和 B 流的数据往往是存储在单独的两个 State 实例中的,那么我们的优化思路就是将同 Key 的数据放在一起进行存储,一个 key 的数据只需要存储一份,减少了 key 的数量
  • 转移状态至外存:大 State 会导致 Flink 任务不稳定,那么我们就将 State 存储在外存中,让 Flink 任务轻量化,比如将数据存储在 Redis 中,A 流和 B 流中相同 key 的数据共同维护在一个 Redis 的 hashmap 中,以供相互进行关联

3.1 key相同时共用state

将两条流的数据使用 union、connect 算子合并在一起,然后使用一个共享的 state 进行处理。
Flink join汇总总结_第7张图片

FlinkEnv flinkEnv = FlinkEnvUtils.getStreamTableEnv(args);

flinkEnv.env().setParallelism(1);

flinkEnv.env()
    .addSource(new SourceFunction<Object>() {
        @Override
        public void run(SourceContext<Object> ctx) throws Exception {

        }

        @Override
        public void cancel() {

        }
    })
    .keyBy(new KeySelector<Object, Object>() {
        @Override
        public Object getKey(Object value) throws Exception {
            return null;
        }
    })
    .connect(flinkEnv.env().addSource(new SourceFunction<Object>() {
        @Override
        public void run(SourceContext<Object> ctx) throws Exception {

        }

        @Override
        public void cancel() {

        }
    }).keyBy(new KeySelector<Object, Object>() {
        @Override
        public Object getKey(Object value) throws Exception {
            return null;
        }
    }))
    // 左右两条流的数据
    .process(new KeyedCoProcessFunction<Object, Object, Object, Object>() {
        // 两条流的数据共享一个 mapstate 进行处理
        private transient MapState<String, String> mapState;

        @Override
        public void open(Configuration parameters) throws Exception {
            super.open(parameters);
            
            this.mapState = getRuntimeContext().getMapState(new MapStateDescriptor<String, String>("a", String.class, String.class));
        }

        @Override
        public void processElement1(Object value, Context ctx, Collector<Object> out) throws Exception {
            
        }

        @Override
        public void processElement2(Object value, Context ctx, Collector<Object> out) throws Exception {

        }
    })
    .print();

3.2 state过大优化

定期清理state,比如在曝光关联点击的情况下,如果我们能明确一次曝光只有一次点击的话,只要这条曝光或者点击被关联到过,那么我们就可以在 KeyedCoProcessFunction 中自定义逻辑将已经被关联过得曝光、点击的 state 数据进行删除,以减小 state,减轻任务压力。

3.3 使用外部存储保存state

外存 State 到 redis。

此种方案就是完全不使用 Flink 的 state,直接将来的数据存储到 Redis 中进行维护,A 流的数据过来之后,去 Redis 中找 B 流的数据,B 流的数据过来之后,去 Redis 中找 A 流的数据。

某些金融公司内的关联,state 是不能被清理的,比如存储了借款信息之后,这些信息后续还是可能被修改的。所以这种场景下需要存储全量的 state。

引用

https://www.cnblogs.com/baran/p/15950363.html
https://mp.weixin.qq.com/s/66FyBdXaPtAZHqRXrgPrjQ

你可能感兴趣的:(flink,sql)