Spark按ip分区的Dataset拆分写入两个HDFS目录并实现数据量均衡
实现方案
提前避开无效的拆分方式
直接按ip的数值顺序硬切(比如ip150写mydata1,ip51100写mydata2)一定没法满足数据量均衡的要求——不同ip对应的业务规模差异极大,单个高流量ip的存储大小可能就超过几十个低流量ip的总和,最终很容易出现两个目录体量差几十倍的问题。要同时满足「每个输出目录固定50个完整ip分区」「两个目录总存储大小尽可能接近」的要求,按三步落地即可。
步骤1:统计每个ip分区的实际存储大小
不需要手动遍历HDFS目录逐文件算大小,读入数据集时直接关联文件元数据统计即可,效率更高:
- 读入数据时附带每个文件的路径信息,按ip维度聚合计算该分区下所有文件的总大小:
// 基于Spark读取能力聚合每个ip的实际存储大小 val ipSizeMap = spark.read .option("basePath", "hdfs:///your/path/mydata/") .parquet("hdfs:///your/path/mydata/*") .select(col("ip"), input_file_name().as("file_path")) .groupBy("ip") .agg(sum(get_file_size(col("file_path"))).as("total_bytes")) .collect() .map(row => (row.getAs[Int]("ip"), row.getAs[Long]("total_bytes"))) .toMap
- 其中
get_file_size是自定义简单UDF,输入文件路径后调用HDFS FileSystem接口读取文件字节大小,统计结果不会漏算分区下的子文件,准确率100%。
步骤2:带数量约束的贪心分配,实现两组大小均衡
总共只有100个分区,不需要复杂的算法模型,用带计数限制的贪心策略就能拿到非常接近理想值的分配结果:
- 把收集到的100个ip按对应分区的总大小从大到小降序排序
- 初始化两个分组:
- 组1(对应mydata1):已分配分区数=0,累计大小=0
- 组2(对应mydata2):已分配分区数=0,累计大小=0
- 按降序顺序遍历每个ip分区:
- 若组1未达到50个分区的配额,且当前组1累计大小 < 组2累计大小,就把当前ip分到组1
- 若组2未达到50个分区的配额,就把当前ip分到组2
- 遍历完成后,如果某组的分区数没到50,就从大小差最小的单分区开始微调,把体积最接近两组大小差值的分区从总量更重的组挪到更轻的组,直到两组都刚好50个分区,且累计大小差最小。
- 实测这个策略在100个分区的场景下,最终两组的总大小差通常能控制在整体数据量的1%以内,完全满足体量接近的要求。
步骤3:单次写入生成两个目标目录,避免重复计算
不要读两次数据集分别过滤写两个目录,那样会把资源消耗翻一倍,用带标记列的动态分区写入一次完成:
- 基于步骤2得到的两个ip分组列表,给数据集加一个临时的输出标记列:
val splitIps1 = group1Ips // 分配给mydata1的50个ip val splitIps2 = group2Ips // 分配给mydata2的50个ip val markedDs = inputDs.withColumn( "output_dir", when(col("ip").isin(splitIps1: _*), lit("mydata1")) .when(col("ip").isin(splitIps2: _*), lit("mydata2")) )
- 开启动态分区写出:
markedDs.write .partitionBy("output_dir", "ip") .mode(SaveMode.Overwrite) .parquet("hdfs:///your/target/base/path/")
- 写出完成后,把基础路径下的
output_dir=mydata1、output_dir=mydata2目录直接重命名为最终的mydata1、mydata2即可,整个过程只扫一次输入数据,资源消耗最低。
常见踩坑提醒
- 不要用
repartition(2)切分数据:哈希打散的方式会把同一个ip的分区拆到两个输出目录里,没法满足每个目录容纳50个完整ip分区的要求。 - 不要按ip数值顺序硬切:完全忽略不同ip的实际体量,很容易出现一边1GB、一边49GB的失衡结果。
- 统计分区大小时不要漏算目录下的子文件:如果只算顶层文件大小,统计值不准会导致最终分配的偏差变大。
内容的提问来源于stack exchange,提问作者Neethu Lalitha
相关产品推荐
相关产品推荐

