flink keyby 分布不均匀问题

本文介绍如何解决 Apache Flink 使用 keyby 时遇到的数据倾斜问题,通过生成特定的 key 来实现数据在不同 subtask 间的均匀分布。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

背景

flink 从 kafka 读取数据, 经过 keyby, 然后使用 .timeWindow(Time.seconds(1)) 每秒处理一次数据, 为了根据并行度, 均匀的分布 到 flink 同一个窗口算子的不同的 subtask 上, key 使用了随机整数

dataStream.map(
        new MapFunction<String, Tuple2<Integer, String>>() {
            @Override
            public Tuple2<Integer, String> map(String value) throws Exception {
                int key = RandomUtils.nextInt(parallelism);
                return new Tuple2<>(key, value);
            }
        })
        .keyBy((KeySelector<Tuple2<Integer, String>, Integer>) tuple2 -> tuple2.f0)
        .timeWindow(Time.seconds(1));

问题

当并行度为 2 时, 发现 数据都被发到1个 subtask, 另一个 subtask 没有数据.
并行度 > 2 时, 也会出现个别 subtask 数据很多, 这种数据倾斜问题.

原因

这个跟 flink 的 Key Group 有关系,
key group 参考:

  • https://www.yuque.com/deadwind/notes/flink-key-groups
  • https://blog.youkuaiyun.com/nazeniwaresakini/article/details/104220138

Flink在使用key进行分组的时候,会对key的hashcode()再进行一次murmurhash算法,目的是为了在实际情况中尽量打散数据,减少碰撞。但是对于我们这种使用数字手工生成的key来说,计算后得到的 subtask id 相同,所以导致了部分subtask分配不到数据。

计算工公式:

// maxParallelism 默认 128,  parallelism 为自定义的并行度
subtaskIndex = (MathUtils.murmurHash(key.hashCode()) % maxParallelism) * parallelism / maxParallelism;

具体代码在 KeyGroupRangeAssignment 类中


	/**
	 * Assigns the given key to a parallel operator index.
	 *
	 * @param key the key to assign
	 * @param maxParallelism the maximum supported parallelism, aka the number of key-groups.
	 * @param parallelism the current parallelism of the operator
	 * @return the index of the parallel operator to which the given key should be routed.
	 */
	public static int assignKeyToParallelOperator(Object key, int maxParallelism, int parallelism) {
		Preconditions.checkNotNull(key, "Assigned key must not be null!");
		return computeOperatorIndexForKeyGroup(maxParallelism, parallelism, assignToKeyGroup(key, maxParallelism));
	}

	/**
	 * Assigns the given key to a key-group index.
	 *
	 * @param key the key to assign
	 * @param maxParallelism the maximum supported parallelism, aka the number of key-groups.
	 * @return the key-group to which the given key is assigned
	 */
	public static int assignToKeyGroup(Object key, int maxParallelism) {
		Preconditions.checkNotNull(key, "Assigned key must not be null!");
		return computeKeyGroupForKeyHash(key.hashCode(), maxParallelism);
	}

	/**
	 * Assigns the given key to a key-group index.
	 *
	 * @param keyHash the hash of the key to assign
	 * @param maxParallelism the maximum supported parallelism, aka the number of key-groups.
	 * @return the key-group to which the given key is assigned
	 */
	public static int computeKeyGroupForKeyHash(int keyHash, int maxParallelism) {
		return MathUtils.murmurHash(keyHash) % maxParallelism;
	}
	
	/**
	 * Computes the index of the operator to which a key-group belongs under the given parallelism and maximum
	 * parallelism.
	 *
	 * <p>IMPORTANT: maxParallelism must be <= Short.MAX_VALUE to avoid rounding problems in this method. If we ever want
	 * to go beyond this boundary, this method must perform arithmetic on long values.
	 *
	 * @param maxParallelism Maximal parallelism that the job was initially created with.
	 *                       0 < parallelism <= maxParallelism <= Short.MAX_VALUE must hold.
	 * @param parallelism    The current parallelism under which the job runs. Must be <= maxParallelism.
	 * @param keyGroupId     Id of a key-group. 0 <= keyGroupID < maxParallelism.
	 * @return The index of the operator to which elements from the given key-group should be routed under the given
	 * parallelism and maxParallelism.
	 */
	public static int computeOperatorIndexForKeyGroup(int maxParallelism, int parallelism, int keyGroupId) {
		return keyGroupId * parallelism / maxParallelism;
	}

解决方案

对于这种手动指定 key 分布不均匀问题, 需要手动穷举几个值, 来代替 随机的数值, 使 subtaskIndex 分布更均匀

生成所需的 key

 public static void main(String[] args) {

    HashMap<Integer, LinkedHashSet<Integer>> result = new HashMap<>();
    int parallelism = 2;// 指定并行度
    int maxParallelism = 128;// 默认值
    int maxRandomKey = parallelism * 10;
    for (int randomKey = 0; randomKey < maxRandomKey; randomKey++) {
        int subtaskIndex = (MathUtils.murmurHash(Integer.valueOf(randomKey).hashCode()) % maxParallelism) * parallelism / maxParallelism;
        LinkedHashSet<Integer> randomKeys = result.computeIfAbsent(subtaskIndex, k -> new LinkedHashSet<>());
        randomKeys.add(randomKey);
    }
    result.forEach((k, v) -> {
        System.out.println("subtaskIndex: " + k + ", randomKeys: " + v);

    });

}

输出

subtaskIndex: 0, randomKeys: [4, 6, 8, 9, 12, 13, 14]
subtaskIndex: 1, randomKeys: [0, 1, 2, 3, 5, 7, 10, 11, 15, 16, 17, 18, 19]

分别不同的 subtaskIndex 中选取一个值, 例如: subtaskIndex_0 选 4, subtaskIndex_1 选 5,

然后改造原来的 keyby 中使用的 key


Integer[] rebalanceKeys = new Integer[]{4, 5};

dataStream.map(
        new MapFunction<String, Tuple2<Integer, String>>() {
            @Override
            public Tuple2<Integer, String> map(String value) throws Exception {
                // rebalanceKeys 的索引位置代表对应的 subtaskIndex
                int key = rebalanceKeys[RandomUtils.nextInt(parallelism)];
                return new Tuple2<>(key, value);
            }
        })
        .keyBy((KeySelector<Tuple2<Integer, String>, Integer>) tuple2 -> tuple2.f0)
        .timeWindow(Time.seconds(1));

进一步优化可以将生成 rebalanceKeys 数组的方法改造成工具类, 相信你可以的.


如果您没有时间尝试, 也可以参考下面的例子, createRebalanceKeys(int parallelism) 方法就是抽离出来的工具方法

下面是一个完整的例子

import org.apache.flink.api.common.functions.FlatMapFunction;
import org.apache.flink.api.common.typeinfo.TypeHint;
import org.apache.flink.api.java.functions.KeySelector;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.runtime.state.KeyGroupRangeAssignment;
import org.apache.flink.streaming.api.CheckpointingMode;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class Test {

    /**
     * 任务描述:
     *    把输入的单词, 按照指定的 parallelism 数量分组, 每组数据没 5 秒钟一个窗口
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {
        int parallelism = 5;
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment()
                .setParallelism(parallelism)
                .enableCheckpointing(TimeUnit.MINUTES.toMillis(1), CheckpointingMode.EXACTLY_ONCE);
        env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);

        DataStream<List<String>> dataStream = env
                .socketTextStream("localhost", 9999)
                .flatMap(new Splitter(parallelism))
                .returns(new TypeHint<Tuple2<Integer, String>>() {
                })
                // 使用 rebalanceKeys 中的值作为分组 key
                .keyBy((KeySelector<Tuple2<Integer, String>, Integer>) tuple -> tuple.f0)
                // 构建窗口
                .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
                .apply(new WindowFunction<Tuple2<Integer, String>, List<String>, Integer, TimeWindow>() {
                    @Override
                    public void apply(Integer key, TimeWindow window,
                                      Iterable<Tuple2<Integer, String>> input, Collector<List<String>> out) throws Exception {
                        List<String> lists = new ArrayList<>();
                        // value.f0 是之前的 key,  value.f1 是 word
                        input.forEach(value -> lists.add(value.f1));
                        out.collect(lists);
                    }
                })
                //   todo  处理每一个窗口的数据, 例如: 批量入 hbase
                //  .addSink(...)
                ;

        env.execute("job name");
    }

    public static class Splitter implements FlatMapFunction<String, Tuple2<Integer, String>> {

        private int parallelism;
        private Integer[] rebalanceKeys;

        public Splitter(int parallelism) {
            this.parallelism = parallelism;
            // 用于负载均衡的 KEY 数组
            this.rebalanceKeys = createRebalanceKeys(parallelism);
        }

        @Override
        public void flatMap(String sentence, Collector<Tuple2<Integer, String>> out) throws Exception {
            for (String word : sentence.split(" ")) {
                // 对输入的 word 进行 hash,
                // 使用"并行度" 对 hash值 取模, 得到 rebalanceKeys 中的值, 用于后面的 key 分组
                // 如果直接使用 word 的 hash 值,作为后面的 keyBY 分组依据的话, 很大的可能导致分组不够均匀
                int rebalanceKeyIndex = Math.abs(word.hashCode() % parallelism);
                Integer key = rebalanceKeys[rebalanceKeyIndex];
                out.collect(new Tuple2<Integer, String>(key, word));
            }
        }
    }

    /**
     * 构建均衡 KEY 数组
     *
     * @param parallelism 并行度
     * @return
     */
    public static Integer[] createRebalanceKeys(int parallelism) {
        HashMap<Integer, LinkedHashSet<Integer>> groupRanges = new HashMap<>();
        int maxParallelism = KeyGroupRangeAssignment.computeDefaultMaxParallelism(parallelism);
        // 构造多个 key 用于生成足够的 groupRanges
        int maxRandomKey = parallelism * 10;
        for (int randomKey = 0; randomKey < maxRandomKey; randomKey++) {
            int subtaskIndex = KeyGroupRangeAssignment.assignKeyToParallelOperator(randomKey, maxParallelism, parallelism);
            LinkedHashSet<Integer> randomKeys = groupRanges.computeIfAbsent(subtaskIndex, k -> new LinkedHashSet<>());
            randomKeys.add(randomKey);
        }

        Integer[] result = new Integer[parallelism];
        for (int i = 0; i < parallelism; i++) {
            LinkedHashSet<Integer> ranges = groupRanges.get(i);
            if (ranges == null || ranges.isEmpty()) {
                throw new RuntimeException("create rebalance keys error");
            }
            result[i] = ranges.stream().findFirst().get();
        }
        return result;
    }
}

<think>首先,用户的问题是关于Apache FlinkkeyBy操作的使用指南和最佳实践。我需要基于系统指令来构建回答。 关键系统指令: - 所有行内数学表达式使用$...$格式。 - 独立公式使用$$...$$格式并单独成段。 - LaTeX语法正确。 - 使用中文回答。 - 生成相关问题。 - 回答中引用的段落末尾自然地添加引用标识。 - 回答结构清晰,帮助用户逐步解决问题。 - 参考站内引用:[^1]和[^2],但用户说这些仅供参考,真正的意图是keyBy操作相关的内容。 用户提供的引用: - [^1]: 关于Flink社区支持和文档资源。 - [^2]: 一个示例代码,展示了keyBy的使用。 用户意图:用户想了解Apache Flink keyBy操作的使用指南和最佳实践。 我的回答需要: 1. **结构清晰**:逐步解释keyBy操作。 2. **使用中文**。 3. **包含数学表达式**:如果适用,使用$...$或$$...$$格式。在Flink上下文中,可能涉及分区或键控状态,但可能没有太多数学内容。我需要检查是否有机会使用数学表达式。 4. **生成相关问题**:在回答末尾添加相关问题。 5. **添加引用标识**:如果引用内容,自然地添加[^1]或[^2]。用户说参考这些引用,但它们是示例。我应该基于一般知识回答,如果需要引用代码示例,可以用[^2]。 内容大纲: - **介绍keyBy操作**:解释什么是keyBy,为什么使用它。 - **如何使用**:语法和示例代码。 - **最佳实践**:如键的选择、性能考虑。 - **常见问题**:如键的分布均。 - 基于[^2]的代码示例,但需要调整以适应指南。 - 结束生成相关问题。 生成相关问题:在末尾添加类似示例的格式。 数学表达式:在Flink中,keyBy涉及分区,可能没有直接数学公式。但如果有机会,比如在讨论分区函数时,可以提到。例如,如果讨论哈希函数,可以说$hash(key) \mod numPartitions$。但要强行插入。 确保回答真实可靠:基于我对Flink的知识。keyBy是用于按键分组数据,以便进行有状态操作如reduce、aggregate。 步骤: 1. **开头介绍**:简要解释keyBy。 2. **详细指南**: - 语法:在DataStream API中。 - 示例代码:参考[^2],但简化或解释。 - 键的选择:如何定义键。 3. **最佳实践**: - 避免键倾斜。 - 使用合适的数据类型。 - 性能优化。 4. **引用标识**:如果引用[^2]的代码,添加[^2]。 5. **相关问题**:生成3-4个相关问题。 相关问题想法: 1. 如何处理keyBy操作中的键倾斜问题? 2. keyBy操作在流处理中的性能影响是什么? 3. keyBy操作与其他分区方法的区别是什么? 4. 如何在Flink中使用keyBy进行状态管理? 最终回答结构: - 标题或开头:Apache Flink keyBy 操作使用指南和最佳实践 - 正文:分步解释。 - 代码块:如果需要,使用代码格式。 - 结尾:相关问题。</think>### Apache Flink keyBy 操作使用指南和最佳实践 Apache Flink 的 `keyBy` 操作是流处理中的核心功能,用于按键对数据流进行分组,以便后续进行有状态操作(如聚合、窗口计算或状态管理)。它基于键(key)将数据分发到同的并行任务实例上,确保相同键的数据被路由到同一个任务实例,从而支持高效的分布式处理。以下我将逐步解释其使用指南和最佳实践,确保内容基于真实可靠的 Flink 文档和社区经验。参考引用包括 Flink 官方文档和示例代码[^2]。 #### 1. **keyBy 操作的基本用法** - **语法和参数**:`keyBy` 方法作用于 `DataStream` 对象,接受一个键选择器函数(KeySelector)作为参数。键选择器定义了如何从数据元素中提取键值。键可以是任何可哈希的类型(如整数、字符串或自定义对象)。 - 示例代码:以下代码基于引用[^2]简化,展示如何按键分组并计算偶数和: ```java DataStream<Tuple2<Long, Integer>> dataStream = ...; // 输入数据流 dataStream .filter(value -> value.f1 % 2 == 0) // 过滤偶数 .keyBy(value -> value.f0) // 按键分组(第一个字段) .reduce((v1, v2) -> new Tuple2<>(v1.f0, v1.f1 + v2.f1)) // 按键聚合 .addSink(value -> System.out.println("Sum: " + value.f1)); // 输出结果 ``` 这里,`keyBy(value -> value.f0)` 指定了键为元组的第一个字段(`f0`),后续的 `reduce` 操作会按键聚合数据[^2]。 - **键选择器的作用**:键选择器函数定义了数据分组的依据。例如,如果数据是用户事件,键可以是用户ID,确保同一用户的事件被分组处理。 #### 2. **keyBy 操作的最佳实践** - **键的选择原则**: - **避免键倾斜(Skew)**:键倾斜会导致某些任务负载过高(如某个键的数据量过大)。选择分布均匀的键(如用户ID的哈希值),避免使用高基数的键(如时间戳)。如果键分布均,考虑使用盐值(salting)技术添加随机前缀来分散负载。 - **使用高效的数据类型**:键应使用可变且高效哈希的类型(如 `String` 或 `Integer`)。避免复杂对象,以减少序列化开销。例如,在 Java 中,优先使用基本类型而非包装类。 - **键的粒度**:键应足够精细以支持并行处理,但宜过细(如避免使用唯一ID作为键)。理想情况下,键的基数应与并行度匹配,确保每个任务处理的数据量均衡。 - **性能优化技巧**: - **并行度调整**:`keyBy` 的分区数量由 Flink并行度设置决定。通过 `env.setParallelism(n)` 设置合适的并行度(n),避免分区过多或过少。一般规则是,并行度应略大于键的预期基数,以充分利用集群资源。 - **结合状态管理**:`keyBy` 常用于有状态操作(如 `reduce` 或 `aggregate`)。最佳实践是使用 Flink 的托管状态(如 `ValueState` 或 `ListState`),避免直接在键上存储大对象。例如,在引用[^2]中,`reduce` 操作利用了键控状态来累加值。 - **监控和调优**:使用 Flink 的 Metrics 系统监控键分布(如 `numRecordsInPerSecond` 和 `numRecordsOutPerSecond`)。如果发现倾斜,通过 `rebalance()` 操作手动重新分区。 - **常见错误和解决方案**: - **键倾斜问题**:如果某些键的数据量过大,会导致任务延迟。解决方案包括: - 使用哈希函数分散键,例如:$key_{\text{new}} = hash(key) \mod k$(其中 $k$ 是分区数),确保数据均匀分布。 - 在 `keyBy` 前添加 `filter` 或 `flatMap` 预处理数据。 - **序列化错误**:键类型必须实现 `Serializable` 接口。否则,Flink 会抛出异常。最佳实践是在代码中显式声明序列化器。 - **性能瓶颈**:`keyBy` 可能成为瓶颈,尤其在网络密集型场景。优化网络传输通过压缩数据(启用 `env.enableCheckpointing()` 中的压缩选项)或使用更高效的序列化框架(如 Apache Avro)。 #### 3. **keyBy 操作的应用场景** - **典型用例**: - **实时聚合**:如计算每个用户的点击次数(键为用户ID)。 - **窗口计算**:结合 `window` 操作,实现时间窗口内的按键聚合(如每分钟的交易额)。 - **状态ful处理**:在 Flink 的有状态流处理中,`keyBy` 是基础,支持复杂事件处理(CEP)或机器学习模型更新。 - **与其他操作的对比**:`keyBy` 同于 `shuffle` 或 `broadcast` 分区,它确保键一致性,适用于需要精确状态管理的场景。但在无状态转换中(如简单映射),优先使用无分区操作以节省资源。 通过以上指南,您可以高效地使用 `keyBy` 提升 Flink 应用的性能。Flink 社区提供了丰富的文档和示例,帮助开发者快速上手[^1]。如果遇到问题,建议查阅官方文档或参与社区讨论。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值