Flink 物理分区

分区概述

分区是大数据处理中一个非常重要的一环。分区是将数据进行重新分布,传输到不同的通道进行下一步数据处理。之前在进行 wordcount 的时候已经使用过有关分区的算子 keyby,keiby 是按照键的哈希值进行的重分区操作。

   KeyedStream<Tuple2<String, Long>, String> keyedDS =
                wordToOneDS.keyBy(data -> data.f0);//按照第一个远程 进行分组

这种分区只能把数据按 key 分开,至于分的均匀与否,每个key的数据具体会分到哪一个区,这些是无法控制的(原因是不能保证待分区数据key 是否均匀分布),因此将 keyby 称之为逻辑分区(软分区)。真正的硬核分区称之为物理分区。也就是我们真正要控制的分区策略,精准的调配数据,通知每个数据要去哪个分区进行处理,其实这种分区在之前已经使用过了,即设置任务的并行度,如果上下设置不同的并行度,那么当数据执行上下游任务并行度变化时,数据就不应该还在当前的分区直通(forward)方式传输,如果并行度减小,当前分区可能没有下游任务,如果并行度增大,所有数据还在原有的分区处理就会导致资源浪费。所以这种情况,系统会自动的将数据均匀的发往下游所有的并行任务,从而保证各分区负载均衡。

有些时候,我们还需要手动控制数据分区分配策略。比如当发生数据倾斜的时候,系统无法自动调整,这时就需要我们重新进行负载均衡,将数据流较为平均地发送到下游任务操作分区中去。Flink对于经过转换操作之后的DataStream,提供了一系列的底层操作接口,能够帮我们实现数据流的手动重分区。为了同keyBy相区别,我们把这些操作统称为“物理分区”操作。物理分区与keyBy另一大区别在于,keyBy之后得到的是一个KeyedStream,而物理分区之后结果仍是DataStream,且流中元素数据类型保持不变。从这一点也可以看出,分区算子并不对数据进行转换处理,只是定义了数据的传输方式。常见的物理分区策略有随机分配(Random)、轮询分配(Round-Robin)、重缩放(Rescale)和广播(Broadcast)

物理分区

数据准备,我们现在拿几个对象为例,读取一些POJO,并且做一个简单的转换返回用户的名称,来验证几个分区策略

  StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        //从元素中读取数据
        DataStreamSource<Event> elementStream =
                //直接构建对象进行读取数据
                env.fromElements(new Event("令狐冲", "./cart", 1000L),
                        new Event("依琳", "./pro?id191", 3200L),
                        new Event("任盈盈", "./pro?id101", 3000L),
                        new Event("依琳", "./home", 6300L),
                        new Event("任盈盈", "./cart", 3500L),
                        new Event("依琳", "./home", 6900L),
                        new Event("令狐冲", "./home", 2000L),
                        new Event("依琳", "./cart", 6500L));

方式一 随机分区(shuffle)

最简单的重分区方式就是直接“洗牌”。通过调用DataStream的.shuffle()方法,将数据随机地分配到下游算子的并行任务中去。100随机分区服从均匀分布(uniform distribution),所以可以把流中的数据随机打乱,均匀地传递到下游任务分区。因为是完全随机的,所以对于同样的输入数据, 每次执行得到的结果也不会相同

经过随机分区之后,得到的依然是一个DataStream。我们可以做个简单测试:将数据读入之后直接打印到控制台,将输出的并行度设置为4,中间经历一次shuffle。执行多次,观察结果是否相同。

 elementStream.shuffle().print("shuffle--").setParallelism(4);

第一次执行结果

shuffle--:2> Event{user='令狐冲', url='./cart', timestamp=1970-01-01 08:00:01.0}
shuffle--:3> Event{user='任盈盈', url='./cart', timestamp=1970-01-01 08:00:03.5}
shuffle--:2> Event{user='依琳', url='./pro?id191', timestamp=1970-01-01 08:00:03.2}
shuffle--:2> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.3}
shuffle--:1> Event{user='任盈盈', url='./pro?id101', timestamp=1970-01-01 08:00:03.0}
shuffle--:3> Event{user='令狐冲', url='./home', timestamp=1970-01-01 08:00:02.0}
shuffle--:1> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.9}
shuffle--:3> Event{user='依琳', url='./cart', timestamp=1970-01-01 08:00:06.5}

第二次执行结果

shuffle--:4> Event{user='令狐冲', url='./home', timestamp=1970-01-01 08:00:02.0}
shuffle--:1> Event{user='依琳', url='./pro?id191', timestamp=1970-01-01 08:00:03.2}
shuffle--:1> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.3}
shuffle--:1> Event{user='任盈盈', url='./cart', timestamp=1970-01-01 08:00:03.5}
shuffle--:1> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.9}
shuffle--:2> Event{user='令狐冲', url='./cart', timestamp=1970-01-01 08:00:01.0}
shuffle--:2> Event{user='任盈盈', url='./pro?id101', timestamp=1970-01-01 08:00:03.0}
shuffle--:2> Event{user='依琳', url='./cart', timestamp=1970-01-01 08:00:06.5}

可见无论执行几次,随机分区的结果都不会保持一致,可见 .shuffle() 分区的特点就是随机分区。

方式二 轮询分区(Round-Robin)

轮询也是一种常见的重分区方式。简单来说就是“发牌”,按照先后顺序将数据做依次分发。通过调用DataStream的.rebalance()方法,就可以实现轮询重分区。rebalance使用的是Round-Robin负载均衡算法,可以将输入流数据平均分配到下游的并行任务中去。注:Round-Robin算法用在了很多地方,例如Kafka和Nginx 
  elementStream.rebalance().print().setParallelism(4);

执行结果

1> Event{user='任盈盈', url='./pro?id101', timestamp=1970-01-01 08:00:03.0}
1> Event{user='令狐冲', url='./home', timestamp=1970-01-01 08:00:02.0}
3> Event{user='令狐冲', url='./cart', timestamp=1970-01-01 08:00:01.0}
3> Event{user='任盈盈', url='./cart', timestamp=1970-01-01 08:00:03.5}
4> Event{user='依琳', url='./pro?id191', timestamp=1970-01-01 08:00:03.2}
4> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.9}
2> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.3}
2> Event{user='依琳', url='./cart', timestamp=1970-01-01 08:00:06.5}

方式三 重缩放分区(rescale)

重缩放分区和轮询分区非常相似。当调用rescale()方法时,其实底层也是使用Round-Robin算法进行轮询,但是只会将数据轮询发送到下游并行任务的一部分中,如图5-11所示。也就是说,“发牌人”如果有多个,那么rebalance的方式是每个发牌人都面向所有人发牌;而rescale的做法是分成小团体,发牌人只给自己团体内的所有人轮流发牌。当下游任务(数据接收方)的数量是上游任务(数据发送方)数量的整数倍时,rescale的效率明显会更高。比如当上游任务数量是2,下游任务数量是6时,上游任务其中一个分区的数据就将会平均分配到下游任务的3个分区中。由于rebalance是所有分区数据的“重新平衡”,当TaskManager数据量较多时,这种跨节点的网络传输必然影响效率;而如果我们配置的task slot数量合适,用rescale的方式进行“局部重缩放”,就可以让数据只在当前TaskManager的多个slot之间重新分配,从而避免了网络传输带来的损耗。从底层实现上看,rebalance和rescale的根本区别在于任务之间的连接机制不同。rebalance将会针对所有上游任务(发送数据方)和所有下游任务(接收数据方)之间建立通信通道,这是一个笛卡尔积的关系;而rescale仅仅针对每一个任务和下游对应的部分任务之间建立通信通道,节省了很多资源。可以在代码中测试如下: 
        env.addSource(new RichParallelSourceFunction<Integer>() {

            @Override
            public void run(SourceContext<Integer> ctx) throws Exception {
                for (int i = 1; i <= 8; i++) {
                    //将 奇偶数分配到不同的分区
                    if (i % 2 == getRuntimeContext().getIndexOfThisSubtask()) {
                        ctx.collect(i);
                    }
                }
            }

            @Override
            public void cancel() {

            }
        }).setParallelism(2).rescale().print().setParallelism(4);

执行结果

2> 4
2> 8
1> 2
1> 6
3> 1
3> 5
4> 3
4> 7

分区方式四 广播(broadcast)

这种方式其实不应该叫做“重分区”,因为经过广播之后,数据会在不同的分区都保留一份,可能进行重复处理。可以通过调用DataStream的broadcast()方法,将输入数据复制并发送到下游算子的所有并行任务中去。 

 elementStream.broadcast().print().setParallelism(4);

执行结果

1> Event{user='令狐冲', url='./cart', timestamp=1970-01-01 08:00:01.0}
3> Event{user='令狐冲', url='./cart', timestamp=1970-01-01 08:00:01.0}
3> Event{user='依琳', url='./pro?id191', timestamp=1970-01-01 08:00:03.2}
3> Event{user='任盈盈', url='./pro?id101', timestamp=1970-01-01 08:00:03.0}
2> Event{user='令狐冲', url='./cart', timestamp=1970-01-01 08:00:01.0}
4> Event{user='令狐冲', url='./cart', timestamp=1970-01-01 08:00:01.0}
2> Event{user='依琳', url='./pro?id191', timestamp=1970-01-01 08:00:03.2}
4> Event{user='依琳', url='./pro?id191', timestamp=1970-01-01 08:00:03.2}
4> Event{user='任盈盈', url='./pro?id101', timestamp=1970-01-01 08:00:03.0}
4> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.3}
4> Event{user='任盈盈', url='./cart', timestamp=1970-01-01 08:00:03.5}
4> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.9}
4> Event{user='令狐冲', url='./home', timestamp=1970-01-01 08:00:02.0}
4> Event{user='依琳', url='./cart', timestamp=1970-01-01 08:00:06.5}
1> Event{user='依琳', url='./pro?id191', timestamp=1970-01-01 08:00:03.2}
1> Event{user='任盈盈', url='./pro?id101', timestamp=1970-01-01 08:00:03.0}
1> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.3}
3> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.3}
3> Event{user='任盈盈', url='./cart', timestamp=1970-01-01 08:00:03.5}
3> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.9}
3> Event{user='令狐冲', url='./home', timestamp=1970-01-01 08:00:02.0}
3> Event{user='依琳', url='./cart', timestamp=1970-01-01 08:00:06.5}
1> Event{user='任盈盈', url='./cart', timestamp=1970-01-01 08:00:03.5}
1> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.9}
1> Event{user='令狐冲', url='./home', timestamp=1970-01-01 08:00:02.0}
2> Event{user='任盈盈', url='./pro?id101', timestamp=1970-01-01 08:00:03.0}
2> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.3}
2> Event{user='任盈盈', url='./cart', timestamp=1970-01-01 08:00:03.5}
2> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.9}
2> Event{user='令狐冲', url='./home', timestamp=1970-01-01 08:00:02.0}
2> Event{user='依琳', url='./cart', timestamp=1970-01-01 08:00:06.5}
1> Event{user='依琳', url='./cart', timestamp=1970-01-01 08:00:06.5}
View Code

分区方式五 全局分区(global)

全局分区也是一种特殊的分区方式。这种做法非常极端,通过调用.global()方法,会将所有的输入流数据都发送到下游算子的第一个并行子任务中去。这就相当于强行让下游任务并行度变成了1,所以使用这个操作需要非常谨慎,可能对程序造成很大的压力。 
 elementStream.global().print().setParallelism(4);

执行结果

1> Event{user='令狐冲', url='./cart', timestamp=1970-01-01 08:00:01.0}
1> Event{user='依琳', url='./pro?id191', timestamp=1970-01-01 08:00:03.2}
1> Event{user='任盈盈', url='./pro?id101', timestamp=1970-01-01 08:00:03.0}
1> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.3}
1> Event{user='任盈盈', url='./cart', timestamp=1970-01-01 08:00:03.5}
1> Event{user='依琳', url='./home', timestamp=1970-01-01 08:00:06.9}
1> Event{user='令狐冲', url='./home', timestamp=1970-01-01 08:00:02.0}
1> Event{user='依琳', url='./cart', timestamp=1970-01-01 08:00:06.5}

分区方式六 自定义分区

当Flink提供的所有分区策略都不能满足用户的需求时,我们可以通过使用partitionCustom()方法来自定义分区策略。在调用时,方法需要传入两个参数,第一个是自定义分区器(Partitioner)对象,第二个是应用分区器的字段,它的指定方式与keyBy指定key基本一样:可以通过字段名称指定,也可以通过字段位置索引来指定,还可以实现一个KeySelector。例如,我们可以对一组自然数按照奇偶性进行重分区。代码如下
        env.fromElements(1, 2, 3, 4, 5, 6, 7, 8).partitionCustom(
                new Partitioner<Integer>() {
                    @Override
                    public int partition(Integer key, int numPartitions) {
                        return key % 2;
                    }
                }, new KeySelector<Integer, Integer>() {
                    @Override
                    public Integer getKey(Integer value) throws Exception {
                        return value;
                    }
                }
        ).print().setParallelism(4);

执行结果

2> 1
2> 3
2> 5
2> 7
1> 2
1> 4
1> 6
1> 8 
posted @ 2022-03-22 14:52  晓枫的春天  阅读(533)  评论(0编辑  收藏  举报