千家信息网

Flink如何实现双流 join

发表于:2026-11-01 作者:千家信息网编辑
千家信息网最后更新 2026年11月01日,这篇文章给大家分享的是有关Flink如何实现双流 join的内容。小编觉得挺实用的,因此分享给大家做个参考,一起跟随小编过来看看吧。在数据库中的静态表上做 OLAP 分析时,两表 join 是非常常见
千家信息网最后更新 2026年11月01日Flink如何实现双流 join

这篇文章给大家分享的是有关Flink如何实现双流 join的内容。小编觉得挺实用的,因此分享给大家做个参考,一起跟随小编过来看看吧。

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

  • join()

  • coGroup()

  • intervalJoin()

准备数据

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

DataStream clickSourceStream = env  .addSource(new FlinkKafkaConsumer011<>(    "ods_analytics_access_log",    new SimpleStringSchema(),    kafkaProps  ).setStartFromLatest());DataStream orderSourceStream = env  .addSource(new FlinkKafkaConsumer011<>(    "ods_ms_order_done",    new SimpleStringSchema(),    kafkaProps  ).setStartFromLatest());DataStream clickRecordStream = clickSourceStream  .map(message -> JSON.parseObject(message, AnalyticsAccessLogRecord.class));DataStream 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() {    @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>() {    @Override    public void coGroup(Iterable accessRecords, Iterable orderRecords, Collector> 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() {    @Override    public void processElement(AnalyticsAccessLogRecord accessRecord, OrderDoneLogRecord orderRecord, Context context, Collector 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  SingleOutputStreamOperator process(        ProcessJoinFunction processJoinFunction,        TypeInformation outputType) {    Preconditions.checkNotNull(processJoinFunction);    Preconditions.checkNotNull(outputType);    final ProcessJoinFunction cleanedUdf = left.getExecutionEnvironment().clean(processJoinFunction);    final IntervalJoinOperator 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>> leftBuffer;private transient MapState>> 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> 表示该时刻到来的数据记录。当左流和右流有数据到达时,会分别调用 processElement1() 和 processElement2() 方法,它们都调用了 processElement() 方法,代码如下。

@Overridepublic void processElement1(StreamRecord record) throws Exception {    processElement(record, leftBuffer, rightBuffer, lowerBound, upperBound, true);}@Overridepublic void processElement2(StreamRecord record) throws Exception {    processElement(record, rightBuffer, leftBuffer, -upperBound, -lowerBound, false);}@SuppressWarnings("unchecked")private  void processElement(        final StreamRecord record,        final MapState>> ourBuffer,        final MapState>> 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>> bucket: otherBuffer.entries()) {        final long timestamp  = bucket.getKey();        if (timestamp < ourTimestamp + relativeLowerBound ||                timestamp > ourTimestamp + relativeUpperBound) {            continue;        }        for (BufferEntry 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 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);    }}

感谢各位的阅读!关于"Flink如何实现双流 join"这篇文章就分享到这里了,希望以上内容可以对大家有一定的帮助,让大家可以学到更多知识,如果觉得文章不错,可以把它分享出去让更多的人看到吧!

时间 数据 方法 区间 算子 两个 事件 代码 用户 订单 逻辑 双流 下界 字段 定时器 水印 偏移 关联 处理 不同 数据库的安全要保护哪些东西 数据库安全各自的含义是什么 生产安全数据库录入 数据库的安全性及管理 数据库安全策略包含哪些 海淀数据库安全审计系统 建立农村房屋安全信息数据库 易用的数据库客户端支持安全管理 连接数据库失败ssl安全错误 数据库的锁怎样保障安全 蓝思科技飞鸽互联网 贷财互联网科技 西安互联网科技公司 互联网科技的未来发展 互联网科技公司的市场部门 互联网科技教育视频 中化能源互联网科技招聘 青海科技产业互联网发展 国大互联网教育科技有限公司工作 内蒙古九一互联网科技 广州新华互联网科技学校电竞方面 互联网科技和法律行业 伍佰万互联网科技怎么赚钱 云南互联网科技学费 西安左小互联网科技有限公司 数字互联网科技有限公司 10位科技互联网大佬 平安好医生打造互联网科技医疗 银川新华互联网科技学校招聘 高科技和互联网的现代国家 互联网最新的科技 紫鲸互联网科技 互联网科技类展会 崇明区互联网教育科技售后服务 苏州铭阳互联网科技有限公司 健康食品互联网创新科技园项目 每天都有哪些好的互联网科技 富国互联网金融科技混合基金 科技信息时代互联网发展 科技互联网企业在行动
0