Flink 双流 Join 的3种操作示例

在数据库中的静态表上做 OLAP 分析时,两表 join 是非常常见的操作。同理,在流式处理作业中,有时也需要在两条流上做 join 以获得更丰富的信息。Flink DataStream API 为用户提供了3个算子来实现双流 join,分别是:

  • join()

  • coGroup()

  • intervalJoin()

本文举例说明它们的使用方法,顺便聊聊比较特殊的 interval join 的原理。

准备数据

从 Kafka 分别接入点击流和订单流,并转化为 POJO。

DataStream<String> clickSourceStream = env  .addSource(new FlinkKafkaConsumer011<>(    "ods_analytics_access_log",    new SimpleStringSchema(),    kafkaProps  ).setStartFromLatest());DataStream<String> orderSourceStream = env  .addSource(new FlinkKafkaConsumer011<>(    "ods_ms_order_done",    new SimpleStringSchema(),    kafkaProps  ).setStartFromLatest());
DataStream<AnalyticsAccessLogRecord> clickRecordStream = clickSourceStream  .map(message -> JSON.parseObject(message, AnalyticsAccessLogRecord.class));DataStream<OrderDoneLogRecord> orderRecordStream = orderSourceStream  .map(message -> JSON.parseObject(message, OrderDoneLogRecord.class));


join()

join() 算子提供的语义为"Window join",即按照指定字段和(滚动/滑动/会话)窗口进行 inner join,支持处理时间和事件时间两种时间特征。

以下示例以10秒滚动窗口,将两个流通过商品 ID 关联,取得订单流中的售价相关字段。

clickRecordStream  .join(orderRecordStream)  .where(record -> record.getMerchandiseId())  .equalTo(record -> record.getMerchandiseId())  .window(TumblingProcessingTimeWindows.of(Time.seconds(10)))  .apply(new JoinFunction<AnalyticsAccessLogRecord, OrderDoneLogRecord, String>() {    @Override    public String join(AnalyticsAccessLogRecord accessRecord, OrderDoneLogRecord orderRecord) throws Exception {      return StringUtils.join(Arrays.asList(        accessRecord.getMerchandiseId(),        orderRecord.getPrice(),        orderRecord.getCouponMoney(),        orderRecord.getRebateAmount()      ), '\t');    }  })  .print().setParallelism(1);


简单易用。

coGroup()

只有 inner join 肯定还不够,如何实现 left/right outer join 呢?答案就是利用 coGroup() 算子。它的调用方式类似于 join() 算子,也需要开窗,但是 CoGroupFunction 比 JoinFunction 更加灵活,可以按照用户指定的逻辑匹配左流和/或右流的数据并输出。

以下的例子就实现了点击流 left join 订单流的功能,是很朴素的 nested loop join 思想(二重循环)。

clickRecordStream  .coGroup(orderRecordStream)  .where(record -> record.getMerchandiseId())  .equalTo(record -> record.getMerchandiseId())  .window(TumblingProcessingTimeWindows.of(Time.seconds(10)))  .apply(new CoGroupFunction<AnalyticsAccessLogRecord, OrderDoneLogRecord, Tuple2<String, Long>>() {    @Override    public void coGroup(Iterable<AnalyticsAccessLogRecord> accessRecords, Iterable<OrderDoneLogRecord> orderRecords, Collector<Tuple2<String, Long>> collector) throws Exception {      for (AnalyticsAccessLogRecord accessRecord : accessRecords) {        boolean isMatched = false;        for (OrderDoneLogRecord orderRecord : orderRecords) {          // 右流中有对应的记录          collector.collect(new Tuple2<>(accessRecord.getMerchandiseName(), orderRecord.getPrice()));          isMatched = true;        }        if (!isMatched) {          // 右流中没有对应的记录          collector.collect(new Tuple2<>(accessRecord.getMerchandiseName(), null));        }      }    }  })  .print().setParallelism(1);


intervalJoin()

join() 和 coGroup() 都是基于窗口做关联的。但是在某些情况下,两条流的数据步调未必一致。例如,订单流的数据有可能在点击流的购买动作发生之后很久才被写入,如果用窗口来圈定,很容易 join 不上。所以 Flink 又提供了"Interval join"的语义,按照指定字段以及右流相对左流偏移的时间区间进行关联,即:

right.timestamp ∈ [left.timestamp + lowerBound; left.timestamp + upperBound]

interval join 也是 inner join,虽然不需要开窗,但是需要用户指定偏移区间的上下界,并且只支持事件时间。

示例代码如下。注意在运行之前,需要分别在两个流上应用 assignTimestampsAndWatermarks() 方法获取事件时间戳和水印。

clickRecordStream  .keyBy(record -> record.getMerchandiseId())  .intervalJoin(orderRecordStream.keyBy(record -> record.getMerchandiseId()))  .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().setParallelism(1);


由上可见,interval join 与 window join 不同,是两个 KeyedStream 之上的操作,并且需要调用 between() 方法指定偏移区间的上下界。如果想令上下界是开区间,可以调用 upperBoundExclusive()/lowerBoundExclusive() 方法。

interval join 的实现原理

以下是 KeyedStream.process(ProcessJoinFunction) 方法调用的重载方法的逻辑。

public <OUT> SingleOutputStreamOperator<OUT> process(        ProcessJoinFunction<IN1, IN2, OUT> processJoinFunction,        TypeInformation<OUT> outputType) {    Preconditions.checkNotNull(processJoinFunction);    Preconditions.checkNotNull(outputType);    final ProcessJoinFunction<IN1, IN2, OUT> cleanedUdf = left.getExecutionEnvironment().clean(processJoinFunction);    final IntervalJoinOperator<KEY, IN1, IN2, OUT> operator =        new IntervalJoinOperator<>(            lowerBound,            upperBound,            lowerBoundInclusive,            upperBoundInclusive,            left.getType().createSerializer(left.getExecutionConfig()),            right.getType().createSerializer(right.getExecutionConfig()),            cleanedUdf        );    return left        .connect(right)        .keyBy(keySelector1, keySelector2)        .transform("Interval Join", outputType, operator);}


可见是先对两条流执行 connect() 和 keyBy() 操作,然后利用 IntervalJoinOperator 算子进行转换。在 IntervalJoinOperator 中,会利用两个 MapState 分别缓存左流和右流的数据。

private transient MapState<Long, List<BufferEntry<T1>>> leftBuffer;private transient MapState<Long, List<BufferEntry<T2>>> rightBuffer;
@Overridepublic void initializeState(StateInitializationContext context) throws Exception {    super.initializeState(context);    this.leftBuffer = context.getKeyedStateStore().getMapState(new MapStateDescriptor<>(        LEFT_BUFFER,        LongSerializer.INSTANCE,        new ListSerializer<>(new BufferEntrySerializer<>(leftTypeSerializer))    ));    this.rightBuffer = context.getKeyedStateStore().getMapState(new MapStateDescriptor<>(        RIGHT_BUFFER,        LongSerializer.INSTANCE,        new ListSerializer<>(new BufferEntrySerializer<>(rightTypeSerializer))    ));}


其中 Long 表示事件时间戳,List<BufferEntry<T>> 表示该时刻到来的数据记录。

当左流和右流有数据到达时,会分别调用 processElement1() 和 processElement2() 方法,它们都调用了 processElement() 方法,代码如下。

@Overridepublic void processElement1(StreamRecord<T1> record) throws Exception {    processElement(record, leftBuffer, rightBuffer, lowerBound, upperBound, true);}
@Overridepublic void processElement2(StreamRecord<T2> record) throws Exception {    processElement(record, rightBuffer, leftBuffer, -upperBound, -lowerBound, false);}
@SuppressWarnings("unchecked")private <THIS, OTHER> void processElement(        final StreamRecord<THIS> record,        final MapState<Long, List<IntervalJoinOperator.BufferEntry<THIS>>> ourBuffer,        final MapState<Long, List<IntervalJoinOperator.BufferEntry<OTHER>>> otherBuffer,        final long relativeLowerBound,        final long relativeUpperBound,        final boolean isLeft) throws Exception {    final THIS ourValue = record.getValue();    final long ourTimestamp = record.getTimestamp();    if (ourTimestamp == Long.MIN_VALUE) {        throw new FlinkException("Long.MIN_VALUE timestamp: Elements used in " +                "interval stream joins need to have timestamps meaningful timestamps.");    }    if (isLate(ourTimestamp)) {        return;    }    addToBuffer(ourBuffer, ourValue, ourTimestamp);    for (Map.Entry<Long, List<BufferEntry<OTHER>>> bucket: otherBuffer.entries()) {        final long timestamp  = bucket.getKey();        if (timestamp < ourTimestamp + relativeLowerBound ||                timestamp > ourTimestamp + relativeUpperBound) {            continue;        }        for (BufferEntry<OTHER> entry: bucket.getValue()) {            if (isLeft) {                collect((T1) ourValue, (T2) entry.element, ourTimestamp, timestamp);            } else {                collect((T1) entry.element, (T2) ourValue, timestamp, ourTimestamp);            }        }    }    long cleanupTime = (relativeUpperBound > 0L) ? ourTimestamp + relativeUpperBound : ourTimestamp;    if (isLeft) {        internalTimerService.registerEventTimeTimer(CLEANUP_NAMESPACE_LEFT, cleanupTime);    } else {        internalTimerService.registerEventTimeTimer(CLEANUP_NAMESPACE_RIGHT, cleanupTime);    }}


这段代码的思路是:

  1. 取得当前流 StreamRecord 的时间戳,调用 isLate() 方法判断它是否是迟到数据(即时间戳小于当前水印值),如是则丢弃。

  2. 调用 addToBuffer() 方法,将时间戳和数据一起插入当前流对应的 MapState。

  3. 遍历另外一个流的 MapState,如果数据满足前述的时间区间条件,则调用 collect() 方法将该条数据投递给用户定义的 ProcessJoinFunction 进行处理。collect() 方法的代码如下,注意结果对应的时间戳是左右流时间戳里较大的那个。

private void collect(T1 left, T2 right, long leftTimestamp, long rightTimestamp) throws Exception {    final long resultTimestamp = Math.max(leftTimestamp, rightTimestamp);    collector.setAbsoluteTimestamp(resultTimestamp);    context.updateTimestamps(leftTimestamp, rightTimestamp, resultTimestamp);    userFunction.processElement(left, right, context, collector);}


  1. 调用 TimerService.registerEventTimeTimer() 注册时间戳为 timestamp + relativeUpperBound 的定时器,该定时器负责在水印超过区间的上界时执行状态的清理逻辑,防止数据堆积。注意左右流的定时器所属的 namespace 是不同的,具体逻辑则位于 onEventTime() 方法中。

@Overridepublic void onEventTime(InternalTimer<K, String> timer) throws Exception {    long timerTimestamp = timer.getTimestamp();    String namespace = timer.getNamespace();    logger.trace("onEventTime @ {}", timerTimestamp);    switch (namespace) {        case CLEANUP_NAMESPACE_LEFT: {            long timestamp = (upperBound <= 0L) ? timerTimestamp : timerTimestamp - upperBound;            logger.trace("Removing from left buffer @ {}", timestamp);            leftBuffer.remove(timestamp);            break;        }        case CLEANUP_NAMESPACE_RIGHT: {            long timestamp = (lowerBound <= 0L) ? timerTimestamp + lowerBound : timerTimestamp;            logger.trace("Removing from right buffer @ {}", timestamp);            rightBuffer.remove(timestamp);            break;        }        default:            throw new RuntimeException("Invalid namespace " + namespace);    }}


本文转载自简书,作者:LittleMagic
原文链接:https://www.jianshu.com/p/45ec888332df


  Flink Forward Asia 2020  

大会议程发布

Flink Forward Asia 2020 在线峰会重磅开启!12月13-15日,全球 38+ 一线厂商,70+ 优质议题,与您探讨新型数字化技术下的未来趋势!大会议程已正式上线,点击文末「阅读原文」即可免费预约~

(点击可了解更多大会详情)

戳我预约!

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Flink双流 Join 可以使用 `JoinFunction` 来实现。代码示例如下: ``` DataStream<Tuple2<String, Integer>> left = ...; DataStream<Tuple2<String, Integer>> right = ...; DataStream<Tuple2<String, Integer>> joined = left.join(right) .where(t -> t.f0) .equalTo(t -> t.f0) .window(TumblingEventTimeWindows.of(Time.seconds(30))) .apply(new JoinFunction<Tuple2<String, Integer>, Tuple2<String, Integer>, Tuple2<String, Integer>>() { @Override public Tuple2<String, Integer> join(Tuple2<String, Integer> first, Tuple2<String, Integer> second) throws Exception { return new Tuple2<>(first.f0, first.f1 + second.f1); } }); ``` 在代码中,左右两个数据流通过调用 `join` 方法进行 Join 操作。使用 `where` 和 `equalTo` 方法来指定 Join 条件,在本示例中,Join 条件是两个元组的第一个字段相等。使用 `window` 方法来指定 Join 操作的窗口,在本示例中,使用的是滚动窗口,窗口大小为 30 秒。最后,使用 `apply` 方法来指定 Join 的具体操作,在本示例中,Join 后的结果是两个元组的第二个字段相加。 ### 回答2: Flink双流join是指在Flink流处理框架中,将两个不同的流数据根据某些条件进行连接操作。下面是一个简单的Flink双流join代码示例: ```java // 导入必要的Flink库 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.streaming.api.functions.co.CoFlatMapFunction; import org.apache.flink.util.Collector; // 创建流处理环境 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); // 定义第一个流,如DataStream<String> DataStream<String> stream1 = env.fromElements("Apple", "Banana", "Orange", "Mango"); // 定义第二个流,如DataStream<Integer> DataStream<Integer> stream2 = env.fromElements(1, 2, 3, 4); // 使用connect方法将两个流连接起来 // 通过CoFlatMapFunction对连接的流进行处理 DataStream<String> result = stream1.connect(stream2) .flatMap(new CoFlatMapFunction<String, Integer, String>() { @Override public void flatMap1(String value, Collector<String> out) { // 对第一个流进行处理,这里示例是将流1中的元素转为大写 out.collect(value.toUpperCase()); } @Override public void flatMap2(Integer value, Collector<String> out) { // 对第二个流进行处理,这里示例是将流2中的元素乘以2后输出 out.collect(String.valueOf(value * 2)); } }); // 输出结果 result.print(); // 执行流处理任务 env.execute(); ``` 上述代码首先创建了一个流处理环境,并定义了两个不同的流`stream1`和`stream2`。然后通过`connect`方法将两个流进行连接,并使用`CoFlatMapFunction`对连接的流进行处理。`CoFlatMapFunction`中的`flatMap1`方法对第一个流进行处理,`flatMap2`方法对第二个流进行处理。最后通过`print()`方法打印处理结果,并调用`execute()`方法执行流处理任务。 ### 回答3: Flink双流join是一种实时数据处理技术,适用于将两个输入流根据指定条件进行连接操作的场景。下面是一个示例代码: ``` import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction; import org.apache.flink.util.Collector; public class StreamJoinExample { public static void main(String[] args) throws Exception { // 创建执行环境 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); // 创建第一个流 DataStream<Tuple2<String, Integer>> stream1 = env.fromElements( new Tuple2<>("A", 1), new Tuple2<>("B", 2), new Tuple2<>("C", 3) ); // 创建第二个流 DataStream<Tuple2<String, Double>> stream2 = env.fromElements( new Tuple2<>("A", 1.0), new Tuple2<>("B", 2.0), new Tuple2<>("D", 4.0) ); // 将两个流连接起来,并定义连接条件 DataStream<String> result = stream1 .keyBy(0) .intervalJoin(stream2.keyBy(0)) .between(Time.seconds(-5), Time.seconds(5)) .process(new ProcessJoinFunction<Tuple2<String, Integer>, Tuple2<String, Double>, String>() { @Override public void processElement(Tuple2<String, Integer> left, Tuple2<String, Double> right, Context ctx, Collector<String> out) throws Exception { out.collect(left.f0 + " -> " + right.f1); } }); // 打印结果 result.print(); // 执行程序 env.execute("Stream Join Example"); } } ``` 上述代码中,我们首先创建了两个输入流stream1和stream2,并添加了一些元素作为测试数据。然后,我们使用`keyBy`方法根据指定的key对两个流进行分区。在连接操作中,我们使用了`between`方法定义了时间窗口,在5秒的时间窗口内进行连接操作。最后,我们使用`process`方法将连接后的结果输出到一个新的流中,并使用`print`方法打印输出结果。最后,我们使用`env.execute`方法执行程序。 通过上述代码,我们可以在Flink中实现双流join操作,并根据自定义的条件将两个输入流连接起来,得到我们所需的结果。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值