You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

AWS Glue优化IoT遥测数据转换:解决写入慢问题

问题描述

我在S3桶中存储了2年的JSON格式IoT遥测数据,希望通过AWS Glue将其转换为按年、月、日、小时、产品类型、MAC地址分区的结构后,写入数据湖中的另一个S3桶。但当前使用Glue作业执行时写入速度极慢,预估耗时至少70小时,现寻求快速且低成本的解决方案,当前使用的PySpark代码如下:

import sys
from awsglue.utils import getResolvedOptions
import json
from datetime import datetime
import pyspark.sql.functions as F
from pyspark.sql import SparkSession
from pyspark.sql.functions import lit, col, explode, arrays_zip
from pyspark.sql.types import (StructType, StructField, StringType, LongType,
                               ArrayType)
import logging
logging.basicConfig(level=logging.INFO)
import boto3
from pyspark.sql.functions import col, to_timestamp, year, month, dayofmonth, when, lit,trim

################ 从S3读取JSON数据和配置文件 开始 #######################
def get_configuration_file_from_s3(bucket, file_key, region_name):

    # 初始化boto3以使用S3客户端
    s3_client = boto3.client('s3', region_name=region_name)

    # 获取S3中的文件
    s3_response = s3_client.get_object(
        Bucket=bucket,
        Key=file_key
    )

    # 从响应中获取Body对象并以字节格式读取数据
    s3_obj_body = s3_response.get('Body')
    content = s3_obj_body.read()

    try:
        # 解析JSON
        json_dict = json.loads(content)

    except json.decoder.JSONDecodeError as e:
            logging.info(f'JSON file is not properly formatted. ERROR: {e}')

    return json_dict
################ 从S3读取JSON数据和配置文件 结束 #######################


########################## 将输出数据写入S3 开始 ############################
def write_json(processed_df, output_s3_path, partition_columns, num_partitions=20):
    '''
    将分区后的JSON文件写入指定位置
    :param processed_df: 待写入的输入Spark Dataframe
    :param output_s3_path: 输出文件路径的URI
    :param num_partitions: 分区列内的分区数量
    '''
    logging.info('write the data into s3 Started {}'.format(datetime.now()))
    processed_df.repartition(num_partitions, partition_columns).write.mode('append').option(
        'maxRecordsPerFile', 9000000).partitionBy(
        partition_columns).format('json').save(output_s3_path)
    logging.info('writing completed {0} with -- {1}'.format(output_s3_path, datetime.now()))

########################## 将输出数据写入S3 结束 ############################

########################## 根据process_timestamp生成原始路径 开始 ##################


def raw_path(process_timestamp, source_bucket, input_path, data_format, config_bucket, data_format_config):

    process_date = datetime.strptime(process_timestamp[0:19], '%Y-%m-%d %H:%M:%S')
    process_date_month = f'{process_date.month}' if process_date.month >9 else f'0{process_date.month}'
    process_date_day = f'{process_date.day}' if process_date.day >9 else f'0{process_date.day}'
    
    read_path = f"{source_bucket}/{input_path}/history_{process_date.year}-{process_date_month}-{process_date_day}*.json"
    return read_path

def process_column(df, column_name):
    field_type = df.schema[column_name].dataType

    if isinstance(field_type, ArrayType):
        #return col(column_name)[0].alias(column_name)
        return when(col(column_name).isNotNull(), col(column_name)[0]).otherwise(lit(None)).alias(column_name)

    elif isinstance(field_type, StructType):
        # 处理嵌套结构体,如{"$numberDouble": "3120.7"}或{"$numberInt": "0"}
        field_names = [f.name for f in field_type.fields]

        if "$numberDouble" in field_names:
            #return col(f"{column_name}.$numberDouble").cast("float").alias(column_name)
            return when(col(column_name).isNotNull(), col(f"{column_name}.$numberDouble").cast("float")).otherwise(lit(None)).alias(column_name)
        elif "$numberInt" in field_names:
            #return col(f"{column_name}.$numberInt").cast("int").alias(column_name)
            return when(col(column_name).isNotNull(), col(f"{column_name}.$numberInt").cast("int")).otherwise(lit(None)).alias(column_name)
        elif "$date" in field_names:
            #return col(f"{column_name}.$date.$numberLong").cast("long").alias(column_name)
            return when(col(column_name).isNotNull(), col(f"{column_name}.$numberLong").cast("long")).otherwise(lit(None)).alias(column_name)
        else:
            #return col(column_name)
            return when(col(column_name).isNotNull(),col(column_name)).otherwise(lit(None)).alias(column_name)

    elif isinstance(field_type, StringType):
        #return trim(col(column_name)).alias(column_name)
        return when((col(column_name).isNotNull()) & (trim(col(column_name)) != ''),col(column_name)).otherwise(lit(None)).alias(column_name)

    else:
        #return col(column_name).alias(column_name)
        return when((col(column_name).isNotNull()) & (trim(col(column_name)) != ''),col(column_name)).otherwise(lit(None)).alias(column_name)

def run_spark_job(device_history_source, curated_output_path, config_bucket, data_format, mac_address_config, region_name):

    logging.info('Script Started {}'.format(datetime.now()))
    application_name = 'unit level data processing-test'

    
    spark = SparkSession.builder.appName(application_name) \
    .config('spark.sql.broadcastTimeout', '36000') \
    .config("spark.sql.caseSensitive",True)\
    .config("spark.default.parallelism", "200") \
    .config("spark.rpc.message.maxSize", "2047") \
    .config("spark.shuffle.service.enabled  ", "true") \
    .config("spark.driver.memory", "40G") \
    .config("spark.driver.memoryOverhead", "40G") \
    .config("spark.executor.memory", "40G") \
    .config("spark.executor.memoryOverhead", "40G") \
    .config("spark.driver.maxResultSize", "40G") \
    .config("spark.shuffle.spill.compress", "true") \
    .config("spark.shuffle.compress", "true") \
    .config("spark.sql.optimizer.dynamicPruning.enforceBroadcastReuse", "true") \
    .config("spark.sql.optimizer.distinctBeforeIntersect.enabled", "true") \
    .config("spark.sql.optimizer.dynamicPartitionPruning.enabled", "true") \
    .config("spark.sql.parquet.fs.optimized.committer.optimization-enabled", "false") \
    .config("yarn.nodemanager.pmem-check-enabled", "true").getOrCreate()
    

    sc = spark.sparkContext

    try:
        mac_address_data = get_configuration_file_from_s3(config_bucket, mac_address_config, region_name)
        logging.info('Read the data from s3 Started {}'.format(datetime.now()))
        input_data = spark.read.json(device_history_source).dropDuplicates()
        logging.info('Read the data from s3 completed {}'.format(datetime.now()))

        mac_addresses = mac_address_data.get('mac_addresses', '*')
           
        # 允许的产品类型列表
        allowed_product_types = [
            'heatpumpWaterHeaterGen1',
            'heatpumpWaterHeaterGen4',
            'heatpumpWaterHeaterGen5',
        ]

        # 定义过滤规则
        logging.info('Process Fitring Started {}'.format(datetime.now()))
        ignore_fields = ['_id','device_base_address','created_at','transactionId', 'device_name', 'connected', 'cloud2_account_id', \
                        'cloud2_instance_id', 'account_id', 'user_connected', 'location_id', 'user_id', \
                        'name', 'software_version', 'deviceId', \
                        '@DATA_SHARING_PARTNER_ID', '@partner_id','@PERFORM_SCHEDULE']
        #print("......Process Fitring completed count ",df_filtered.count())

    except Exception as e:
        logging.error(f"Error occurred during processing: {str(e)}")
        return None

    logging.info('Process history_df Started {}'.format(datetime.now()))
    #df = df_filtered.withColumn("timestamp1", to_timestamp("timestamp"))#.drop(col("SCHEDULS"))
    df_filter = input_data.drop(*ignore_fields).filter((col("mac_address").isin(mac_addresses)) & (col("econetProductType").isin(allowed_product_types)))
    if 'product_type' in df_filter.columns:
        df_filter = df_filter.drop('product_type')
    df_filtered = df_filter.withColumnRenamed("econetProductType", "product_type")
    # 提取年、月、日
    df = df_filtered.withColumn("year", F.substring("timestamp", 1, 4).cast("int")) \
       .withColumn("month", F.substring("timestamp", 6, 2).cast("int")) \
       .withColumn("day", F.substring("timestamp", 9, 2).cast("int"))
    logging.info('Process history_df completed {}'.format(datetime.now()))
    logging.info('Process Fitring completed {}'.format(datetime.now()))
    
    # 应用转换
    logging.info('Process df_transformed Started {}'.format(datetime.now()))
    df_transformed = df.select(
        *[process_column(df, col_name) for col_name in df.columns]
    )
    logging.info('Process df_transformed completed {}'.format(datetime.now()))
    
    logging.info('Process converted into string  Started {}'.format(datetime.now()))
    history_final = df_transformed.select([col(c).cast("string").alias(c) for c in df.columns])
    logging.info('Process converted into string  completed {}'.format(datetime.now()))

    if history_final is not None:
        #history_final.persist()
        write_json(history_final, curated_output_path, ['year', 'month', 'day','mac_address', 'product_type'])

        logging.info('Data Storage Ends {}'.format(datetime.now()))
    logging.info('Script End {}'.format(datetime.now()))


if __name__ == '__main__':

    args = getResolvedOptions(
        sys.argv, [
            'JOB_NAME', 'source_bucket', 'output_bucket', 'config_bucket',
            'input_path', 'data_format', 'process_timestamp', 'mac_address_config',
            'data_format_config', 'region_name'
        ]
    )

    source_bucket = args['source_bucket'].strip()
    output_bucket = args['output_bucket'].strip()
    config_bucket = args['config_bucket'].strip()
    input_path = args['input_path'].strip()
    data_format = args['data_format'].strip()
    process_timestamp = args['process_timestamp'].strip()
    mac_address_config = args['mac_address_config'].strip()
    #mac_address_config = 'staging/config/mac_address_config_24622_18_March_24_March.json'
    data_format_config = args['data_format_config'].strip()
    region_name = args['region_name'].strip()

    device_history_source = raw_path(process_timestamp, source_bucket, input_path, data_format, config_bucket, data_format_config)
    logging.info(f"Processing {device_history_source}")

    device_history_output = output_bucket + f'/telemetry_data/'
    logging.info(f"Processing2 {device_history_source}")

    run_spark_job(device_history_source, device_history_output, config_bucket, data_format, mac_address_config, region_name)

解决方案

一、存储格式替换:JSON → Parquet

JSON为行式存储,读写效率远低于列式存储的Parquet,切换后可提升IO性能3-5倍,同时支持压缩减少存储成本:

  • 将写入格式从.format('json')改为.format('parquet')
  • 添加.option('compression', 'snappy')启用Snappy压缩,兼顾压缩比与速度

二、Spark配置调优

清理冗余配置,优化并行与IO参数:

  1. 启用Parquet优化提交器:将spark.sql.parquet.fs.optimized.committer.optimization-enabled设为true,减少S3写入的HTTP请求数
  2. 调整并行度:spark.default.parallelism设为executor数量×executor核心数×2(如20个4核executor则设为160),避免任务并行度过低
  3. 移除无效配置:删除yarn.nodemanager.pmem-check-enabled,Glue无需YARN节点内存校验;删除手动设置的spark.driver.memory等参数,改用Glue作业DPU配置分配资源

三、代码逻辑优化

1. 提前过滤数据

将dropDuplicates()移至过滤逻辑之后,减少去重数据量;读取时利用S3前缀分区批量读取,避免全量扫描:

# 先过滤再去重
df_filter = input_data.drop(*ignore_fields).filter((col("mac_address").isin(mac_addresses)) & (col("econetProductType").isin(allowed_product_types))).dropDuplicates()

2. 完善分区策略

  • 补充hour分区列:从timestamp提取小时字段,满足需求中的小时分区要求
  • 调整分区列顺序为基数从高到低:year, month, day, hour, product_type, mac_address,减少小文件生成
# 提取完整时间分区列
df = df_filtered.withColumn("year", F.substring("timestamp", 1, 4).cast("int")) \
   .withColumn("month", F.substring("timestamp", 6, 2).cast("int")) \
   .withColumn("day", F.substring("timestamp", 9, 2).cast("int")) \
   .withColumn("hour", F.substring("timestamp", 12, 2).cast("int"))

3. 避免无意义类型转换

删除将所有列转为String类型的逻辑,仅保留嵌套结构到基础类型的转换,减少计算开销与存储空间浪费

4. 优化配置文件读取

用Spark分布式读取替换boto3单线程读取,提升配置加载效率:

# 用Spark读取配置文件
mac_address_df = spark.read.json(f"s3://{config_bucket}/{mac_address_config}")
mac_addresses = mac_address_df.first().get('mac_addresses', '*')

5. 调整repartition策略

根据数据量设置合理分区数(按每个分区1GB计算),避免小文件过多:

# 按分区列+数据量设置分区数
processed_df.repartition(100, *partition_columns)

四、Glue作业配置优化

  1. 选择高性能DPU类型:使用G.2X(4vCPU/16GB)或G.4X(8vCPU/32GB)类型,相比标准型提升计算能力
  2. 启用自动缩放:开启Glue作业自动缩放,根据数据量动态调整executor数量
  3. 批量拆分任务:将2年数据按月份拆分,每月运行一次作业,避免单次处理数据量过大
  4. 启用作业书签:避免重复处理已完成的数据,提升增量处理效率

修改后的核心代码片段

# 优化的Parquet写入函数
def write_parquet(processed_df, output_s3_path, partition_columns, num_partitions=100):
    logging.info('write the data into s3 Started {}'.format(datetime.now()))
    processed_df.repartition(num_partitions, *partition_columns).write.mode('append') \
        .option('maxRecordsPerFile', 1000000) \
        .option('compression', 'snappy') \
        .partitionBy(partition_columns) \
        .format('parquet') \
        .save(output_s3_path)
    logging.info('writing completed {0} with -- {1}'.format(output_s3_path, datetime.now()))

# 调用写入函数时传入完整分区列
write_parquet(history_final, curated_output_path, ['year', 'month', 'day', 'hour', 'product_type', 'mac_address'])

内容的提问来源于stack exchange,提问作者Ramanarao

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 06:24:50