这两天正在折腾ClickHouse,折腾完再写文章记录,今天就先弄一篇关于Flink的小知识吧。

DataStream分区

Spark的RDD有分区的概念,Flink的DataStream同样也有,只不过没有RDD那么显式而已。Flink通过流分区器StreamPartitioner来控制DataStream中的元素往下游的流向,以StreamPartitioner抽象类为中心的类图如下所示。


flink创建hudi分区表_ide


在Flink的Web UI界面中,各算子之间的分区器类型会在箭头上标注出来,如下所示。



flink创建hudi分区表_flink创建hudi分区表_02


StreamPartitioner继承自ChannelSelector接口。这里的Channel概念与Netty不同,只是Flink对于数据写入目的地的简单抽象,我们可以直接认为它就是下游算子的并发实例(即物理分区)。所有StreamPartitioner的子类都要实现selectChannel()方法,用来选择分区号。下面分别来看看Flink提供的8种StreamPartitioner的源码,以加深理解。

GlobalPartitioner
// dataStream.global()
    @Override
    public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
        return 0;
    }

GlobalPartitioner只会将数据输出到下游算子的第一个实例,简单暴力。

ShufflePartitioner
private Random random = new Random();
    // dataStream.shuffle()
    @Override
    public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
        return random.nextInt(numberOfChannels);
    }

ShufflePartitioner会将数据随机输出到下游算子的并发实例。由于java.util.Random生成的随机数符合均匀分布,故能够近似保证平均。

RebalancePartitioner
private int nextChannelToSendTo;

    @Override
    public void setup(int numberOfChannels) {
        super.setup(numberOfChannels);
        nextChannelToSendTo = ThreadLocalRandom.current().nextInt(numberOfChannels);
    }
    // dataStream.rebalance()
    @Override
    public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
        nextChannelToSendTo = (nextChannelToSendTo + 1) % numberOfChannels;
        return nextChannelToSendTo;
    }

RebalancePartitioner会先随机选择一个下游算子的实例,然后用轮询(round-robin)的方式从该实例开始循环输出。该方式能保证完全的下游负载均衡,所以常用来处理带有自然倾斜的原始数据流,比如各Partition之间数据量差距比较大的Kafka Topic。

KeyGroupStreamPartitioner
private final KeySelector<T, K> keySelector;
    private int maxParallelism;
    // dataStream.keyBy()
    @Override
    public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
        K key;
        try {
            key = keySelector.getKey(record.getInstance().getValue());
        } catch (Exception e) {
            throw new RuntimeException("Could not extract key from " + record.getInstance().getValue(), e);
        }
        return KeyGroupRangeAssignment.assignKeyToParallelOperator(key, maxParallelism, numberOfChannels);
    }

    public static int assignKeyToParallelOperator(Object key, int maxParallelism, int parallelism) {
        return computeOperatorIndexForKeyGroup(maxParallelism, parallelism, assignToKeyGroup(key, maxParallelism));
    }

    public static int assignToKeyGroup(Object key, int maxParallelism) {
        return computeKeyGroupForKeyHash(key.hashCode(), maxParallelism);
    }

    public static int computeKeyGroupForKeyHash(int keyHash, int maxParallelism) {
        return MathUtils.murmurHash(keyHash) % maxParallelism;
    }

    public static int computeOperatorIndexForKeyGroup(int maxParallelism, int parallelism, int keyGroupId) {
        return keyGroupId * parallelism / maxParallelism;
    }

这就是keyBy()算子底层所采用的StreamPartitioner,可见是先在key值的基础上经过了两重哈希得到key对应的哈希值,第一重是Java自带的hashCode(),第二重则是MurmurHash。然后将哈希值乘以算子并行度,并除以最大并行度,得到最终的分区ID。

看官可能会觉得上面的代码有点眼熟,其实它们在之前讲解Key Group机制时出现过,详情参见这篇文章

BroadcastPartitioner
// dataStream.broadcast()
    @Override
    public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
        throw new UnsupportedOperationException("Broadcast partitioner does not support select channels.");
    }

    @Override
    public boolean isBroadcast() {
        return true;
    }

BroadcastPartitioner是广播流专用的分区器。由于广播流发挥作用必须靠DataStream.connect()方法与正常的数据流连接起来,所以实际上不需要BroadcastPartitioner来选择分区(广播数据总会投递给下游算子的所有并发),selectChannel()方法也就不必实现了。细节请参见Flink中BroadcastStream相关的源码,这里就不再列举了。

RescalePartitioner
private int nextChannelToSendTo = -1;
    // dataStream.rescale()
    @Override
    public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
        if (++nextChannelToSendTo >= numberOfChannels) {
            nextChannelToSendTo = 0;
        }
        return nextChannelToSendTo;
    }

这个看起来也太简单了,并且与RebalancePartitioner的逻辑是相同的?实际上并不是。我们看看StreamingJobGraphGenerator类,它负责把Flink执行计划中的StreamGraph(逻辑执行计划)转换为JobGraph(优化的逻辑执行计划)。其connect()方法中有如下代码。

if (partitioner instanceof ForwardPartitioner || partitioner instanceof RescalePartitioner) {
            jobEdge = downStreamVertex.connectNewDataSetAsInput(
                headVertex,
                DistributionPattern.POINTWISE,
                resultPartitionType);
        } else {
            jobEdge = downStreamVertex.connectNewDataSetAsInput(
                    headVertex,
                    DistributionPattern.ALL_TO_ALL,
                    resultPartitionType);

粗略地讲,如果分区逻辑是RescalePartitioner或ForwardPartitioner(下面会说),那么采用POINTWISE模式来连接上下游的顶点,对于其他分区逻辑,都用ALL_TO_ALL模式来连接。看下面两张图会比较容易理解。



flink创建hudi分区表_Parallelism_03



flink创建hudi分区表_数据_04


也就是说,POINTWISE模式的RescalePartitioner在中间结果传送给下游节点时,会根据并行度的比值来轮询分配给下游算子实例的子集,对TaskManager来说本地性会比较好。而ALL_TO_ALL模式的RebalancePartitioner是真正的全局轮询分配,更加均衡,但是就会不可避免地在节点之间交换数据,如果数据量大的话,造成的网络流量会很可观。

ForwardPartitioner
// dataStream.forward()
   @Override
   public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
       return 0;
   }

与GlobalPartitioner的实现相同。但通过上面对POINTWISE和ALL_TO_ALL连接模式的讲解,我们能够知道,它会将数据输出到本地运行的下游算子的第一个实例,而非全局。在上下游算子的并行度相同的情况下,默认就会采用ForwardPartitioner。反之,若上下游算子的并行度不同,默认会采用前述的RebalancePartitioner。

CustomPartitionerWrapper
Partitioner<K> partitioner;
    KeySelector<T, K> keySelector;
    // dataStream.partitionCustom()
    @Override
    public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
        K key;
        try {
            key = keySelector.getKey(record.getInstance().getValue());
        } catch (Exception e) {
            throw new RuntimeException("Could not extract key from " + record.getInstance(), e);
        }

        return partitioner.partition(key, numberOfChannels);
    }

这就是自定义的分区逻辑了,我们可以通过继承Partitioner接口自己实现,并传入partitionCustom()方法。举个简单的栗子,以key的长度做分区:

sourceStream.partitionCustom(new Partitioner<String>() {
      @Override
      public int partition(String key, int numPartitions) {
        return key.length() % numPartitions;
      }
    }, 0);
The End