数据处理与转换|基于 data_engineering_book 玩转 ETL/ELT 核心流程

数据处理与转换|基于 data_engineering_book 玩转 ETL/ELT 核心流程

本文基于《Data Engineering Book》核心内容,深度拆解 ETL/ELT 的核心差异与适用场景,结合 Spark 批处理、Flink 流处理给出可落地的代码示例,总结数据转换最佳实践,并入门级讲解处理性能调优的核心思路,覆盖数据工程中数据处理环节的核心考点与实操要点。

GitHub地址: github.com/datascale-a...

在线链接:datascale-ai.github.io/

一、ETL vs ELT 核心区别与适用场景(书中解读)

《Data Engineering Book》将 ETL/ELT 定义为数据处理的两大核心范式,其本质差异在于"数据转换环节的执行时机与载体",以下是书中对二者的核心解读:

1. 核心定义与流程对比

维度 ETL(Extract-Transform-Load) ELT(Extract-Load-Transform)
核心流程 抽取数据 → 转换(离线/专用引擎)→ 加载到目标存储 抽取数据 → 加载到目标存储(数据湖/仓)→ 转换(目标存储引擎内)
转换执行位置 中间转换层(如 Spark 集群、ETL 工具节点) 目标存储层(如湖仓、数据仓库计算引擎)
数据流向 源系统 → 转换引擎 → 数仓(结构化) 源系统 → 数据湖/仓(全类型)→ 按需转换
依赖的存储 依赖结构化数仓(目标端) 依赖低成本、高扩展性的湖仓/对象存储(目标端)

2. 核心区别与适用场景

特性 ETL ELT
数据类型支持 仅结构化数据(转换需提前定义Schema) 全类型数据(结构化/半结构化/非结构化)
转换灵活性 低(转换规则提前固化,改造成本高) 高(加载后按需转换,支持灵活探索)
性能瓶颈 转换引擎算力(需单独扩容) 目标存储计算引擎算力(可弹性扩展)
成本 高(转换引擎+专用存储,资源独占) 低(复用湖仓存储/计算,按需付费)
适用场景 1. 数据量小、结构固定的传统BI场景; 2. 对数据质量要求极高,需前置清洗; 3. 目标端为传统数仓(如Teradata、Oracle) 1. 大数据量、多类型数据场景(日志、视频、JSON); 2. 数据探索、机器学习等灵活分析场景; 3. 目标端为湖仓(Delta Lake/Hudi)、云原生数仓(Snowflake)

书中核心结论

ETL 是"为存储适配数据",适合稳定的传统业务;ELT 是"让存储适配数据",是大数据时代的主流范式,尤其在湖仓架构中,ELT 几乎成为标配------先将原始数据加载到数据湖,再基于业务需求分层转换,兼顾灵活性与成本。

二、批处理(Spark)+ 流处理(Flink)核心代码示例

1. Spark 批处理(ELT 范式:先加载后转换)

场景:从 CSV 文件抽取电商订单数据,加载到 Delta Lake 后完成清洗、聚合转换。

环境依赖
bash 复制代码
pip install pyspark delta-spark
核心代码
python 复制代码
from pyspark.sql import SparkSession
import pyspark.sql.functions as F

# 1. 初始化Spark Session
spark = SparkSession.builder \
    .appName("Spark_Batch_ELT") \
    .master("local[*]") \
    .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \
    .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \
    .getOrCreate()

# 2. Extract:抽取源数据(CSV文件)
source_path = "./data/order_source.csv"
raw_df = spark.read.csv(
    source_path,
    header=True,
    schema="order_id string, user_id string, amount double, create_time string, pay_status string"
)

# 3. Load:加载到数据湖(原始层)
raw_delta_path = "./delta_lake/raw/orders"
raw_df.write \
    .mode("overwrite") \
    .format("delta") \
    .partitionBy("pay_status") \
    .save(raw_delta_path)

# 4. Transform:加载后转换(清洗+聚合)
## 4.1 清洗转换(去重、格式标准化、过滤异常值)
clean_df = spark.read.format("delta").load(raw_delta_path) \
    .dropDuplicates(["order_id"])  # 去重
    .withColumn("create_time", F.to_timestamp("create_time", "yyyy-MM-dd HH:mm:ss"))  # 时间格式标准化
    .filter(F.col("amount") > 0)  # 过滤金额异常值
    .filter(F.col("create_time").isNotNull())  # 过滤空时间

# 保存清洗层数据
clean_delta_path = "./delta_lake/clean/orders"
clean_df.write \
    .mode("overwrite") \
    .partitionBy("pay_status") \
    .format("delta") \
    .save(clean_delta_path)

## 4.2 聚合转换(按日统计订单金额)
agg_df = clean_df \
    .withColumn("dt", F.to_date("create_time")) \
    .groupBy("dt", "pay_status") \
    .agg(
        F.count("order_id").alias("order_count"),
        F.sum("amount").alias("total_amount"),
        F.avg("amount").alias("avg_amount")
    )

# 保存聚合层数据
agg_delta_path = "./delta_lake/agg/order_daily_stats"
agg_df.write \
    .mode("overwrite") \
    .partitionBy("dt") \
    .format("delta") \
    .save(agg_delta_path)

# 5. 查看转换结果
print("每日订单统计结果:")
spark.read.format("delta").load(agg_delta_path).orderBy("dt").show()

spark.stop()

场景:实时抽取 Kafka 中的用户行为日志,加载到 Delta Lake 后,实时计算UV、PV。

环境依赖

需提前部署 Kafka + Flink,添加依赖(pom.xml):

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-connector-kafka</artifactId>
        <version>1.17.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-connector-hive</artifactId>
        <version>1.17.0</version>
    </dependency>
    <dependency>
        <groupId>io.delta</groupId>
        <artifactId>delta-flink-connector</artifactId>
        <version>3.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-streaming-java</artifactId>
        <version>1.17.0</version>
    </dependency>
</dependencies>
核心代码(Java)
java 复制代码
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.TumblingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer;
import org.apache.flink.connector.delta.sink.DeltaSink;
import io.delta.flink.sink.DeltaSinkBuilder;
import java.util.Properties;

// 定义用户行为实体
class UserBehavior {
    public String user_id;
    public String action;
    public Long timestamp;
    public UserBehavior() {}
    public UserBehavior(String user_id, String action, Long timestamp) {
        this.user_id = user_id;
        this.action = action;
        this.timestamp = timestamp;
    }
}

public class FlinkStreamELT {
    public static void main(String[] args) throws Exception {
        // 1. 初始化Flink环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(2);

        // 2. Kafka配置
        Properties kafkaProps = new Properties();
        kafkaProps.setProperty("bootstrap.servers", "localhost:9092");
        kafkaProps.setProperty("group.id", "user_behavior_consumer");

        // 3. Extract:从Kafka抽取流数据
        FlinkKafkaConsumer<String> kafkaSource = new FlinkKafkaConsumer<>(
            "user_behavior_topic",
            new SimpleStringSchema(),
            kafkaProps
        );
        DataStream<String> kafkaStream = env.addSource(kafkaSource);

        // 4. 解析数据(JSON字符串转UserBehavior)
        DataStream<UserBehavior> behaviorStream = kafkaStream.map((MapFunction<String, UserBehavior>) value -> {
            // 模拟JSON解析(实际可使用FastJSON/Jackson)
            String[] fields = value.split(",");
            return new UserBehavior(fields[0], fields[1], Long.parseLong(fields[2]));
        });

        // 5. Load:加载到Delta Lake原始层
        DeltaSink<UserBehavior> rawDeltaSink = DeltaSink.forRow(
                behaviorStream.getJavaStream(),
                UserBehavior.class,
                env.getJavaEnv(),
                "./delta_lake/raw/user_behavior_stream"
            )
            .build();
        behaviorStream.sinkTo(rawDeltaSink);

        // 6. Transform:实时转换(5分钟窗口统计UV/PV)
        DataStream<Tuple2<String, Long>> uvStream = behaviorStream
            .keyBy(behavior -> behavior.action)
            .window(TumblingProcessingTimeWindows.of(Time.minutes(5)))
            .aggregate(
                new UvAggregateFunction(),  // 自定义聚合函数计算UV
                new WindowResultFunction()  // 窗口结果封装
            );

        // 7. 加载转换结果到Delta Lake聚合层
        DeltaSink<Tuple2<String, Long>> aggDeltaSink = DeltaSink.forRow(
                uvStream.getJavaStream(),
                Tuple2.class,
                env.getJavaEnv(),
                "./delta_lake/agg/user_behavior_5min_stats"
            )
            .build();
        uvStream.sinkTo(aggDeltaSink);

        // 8. 执行任务
        env.execute("Flink Stream ELT Demo");
    }

    // 自定义聚合函数:计算UV(去重用户数)
    public static class UvAggregateFunction implements org.apache.flink.api.common.functions.AggregateFunction<UserBehavior, java.util.HashSet<String>, Long> {
        @Override
        public java.util.HashSet<String> createAccumulator() {
            return new java.util.HashSet<>();
        }

        @Override
        public java.util.HashSet<String> add(UserBehavior value, java.util.HashSet<String> accumulator) {
            accumulator.add(value.user_id);
            return accumulator;
        }

        @Override
        public Long getResult(java.util.HashSet<String> accumulator) {
            return (long) accumulator.size();
        }

        @Override
        public java.util.HashSet<String> merge(java.util.HashSet<String> a, java.util.HashSet<String> b) {
            a.addAll(b);
            return a;
        }
    }

    // 窗口结果封装函数
    public static class WindowResultFunction implements org.apache.flink.streaming.api.functions.windowing.WindowFunction<Long, Tuple2<String, Long>, String, org.apache.flink.streaming.api.windowing.windows.TimeWindow> {
        @Override
        public void apply(String key, org.apache.flink.streaming.api.windowing.windows.TimeWindow window, java.lang.Iterable<Long> input, org.apache.flink.util.Collector<Tuple2<String, Long>> out) {
            out.collect(new Tuple2<>(key, input.iterator().next()));
        }
    }
}

三、书中的「数据转换最佳实践」(清洗、脱敏、标准化)

《Data Engineering Book》将数据转换的核心目标定义为"提升数据可用性、保障数据安全、降低分析成本",并总结了三大维度的最佳实践:

1. 数据清洗最佳实践

清洗的核心是"保留有效数据,剔除噪声",书中强调"最小清洗原则"(仅清洗必要内容,保留原始数据可追溯):

  • 去重
    • 主键去重:基于业务主键(如订单ID、用户ID)去重,避免重复计算;
    • 时间窗口去重:对日志类数据,按"用户+行为+时间窗口"去重(如10秒内同一用户重复点击);
    • 工具:Spark dropDuplicates()、Flink distinct()
  • 异常值处理
    • 数值型:定义合理范围(如金额>0、年龄1-120),超出范围标记为异常值(而非直接删除);
    • 字符串型:过滤空值、特殊字符(如filter(col("name").rlike("^[a-zA-Z0-9]+$")));
    • 时间型:统一转换为UTC时间,过滤非法时间格式(如to_timestamp(col("time"), "yyyy-MM-dd HH:mm:ss"))。
  • 缺失值处理
    • 核心字段(如订单ID):缺失则删除整条记录;
    • 非核心字段(如用户备注):用默认值填充(如fillna("unknown")),或按业务规则插值(如均值/中位数填充数值字段)。

2. 数据脱敏最佳实践

脱敏的核心是"合规使用数据,保护隐私",书中强调"分级脱敏"(按数据敏感级别选择策略):

数据类型 脱敏策略 示例(Spark代码)
手机号 中间4位掩码 regexp_replace(col("phone"), "(\\d{3})\\d{4}(\\d{4})", "$1****$2")
身份证号 中间6位掩码 regexp_replace(col("id_card"), "(\\d{6})\\d{6}(\\d{8})", "$1******$2")
邮箱 用户名部分掩码 regexp_replace(col("email"), "(\\w{2})\\w+@(\\w+)", "$1****@$2")
地址 省/市保留,详细地址掩码 concat(col("province"), col("city"), lit("****"))
核心业务数据 加密存储(不可逆) 基于AES加密:encrypt(col("amount"), lit("secret_key"))

核心原则

  • 脱敏后的数据需可关联(如手机号掩码后仍能区分不同用户);
  • 生产环境脱敏,测试环境使用仿真数据(避免真实数据泄露);
  • 脱敏规则可配置,避免硬编码(如通过配置文件定义掩码规则)。

3. 数据标准化最佳实践

标准化的核心是"统一数据格式,降低分析成本",书中总结了五大标准化规则:

  • 字段命名标准化
    • 统一命名规范:小写+下划线(如user_id而非UserID/userid);
    • 字段含义唯一:避免"订单金额"同时出现order_amount/order_money
  • 数据格式标准化
    • 时间格式:统一为yyyy-MM-dd HH:mm:ss(UTC时间);
    • 数值格式:金额保留2位小数,百分比统一为小数(如20%→0.2);
    • 编码格式:统一为UTF-8,避免乱码。
  • 单位标准化
    • 数值单位统一(如金额统一为元,长度统一为米);
    • 新增单位字段标注(如amount_unit="元")。
  • 枚举值标准化
    • 枚举值统一为字符串(如支付状态:success/failed/pending,而非1/0/-1);
    • 维护枚举值字典表,所有转换逻辑引用字典表(避免硬编码)。
  • 分区格式标准化
    • 时间分区:dt=2024-05-20hour=10
    • 地域分区:region=cn-north-1city=beijing
    • 所有分区键小写,值无特殊字符。

四、处理性能调优入门(并行度、资源配置)

《Data Engineering Book》强调:性能调优的核心是"让资源匹配数据处理需求,避免资源浪费或瓶颈",入门级调优聚焦并行度资源配置两大核心维度,以下是批处理(Spark)和流处理(Flink)的通用调优思路:

1. 并行度调优

并行度决定了任务的执行线程数/进程数,是调优的第一步,核心原则:并行度 = 总数据量 / 单任务处理量(单任务处理量建议:批处理128MB~256MB,流处理1000条/秒)。

Spark 批处理并行度调优
  • 全局并行度 :设置spark.default.parallelism(默认=集群CPU核数),建议值:CPU核数 * 2~3

    python 复制代码
    spark.conf.set("spark.default.parallelism", 16)  # 8核CPU→16并行度
  • Shuffle并行度 :设置spark.sql.shuffle.partitions(默认200),建议值:总数据量 / 256MB(如100GB数据→400分区);

    python 复制代码
    spark.conf.set("spark.sql.shuffle.partitions", 400)
  • 读写并行度 :通过repartition()/coalesce()调整数据分区数,避免小文件/大文件;

    python 复制代码
    # 读取后调整分区数为16
    df = spark.read.format("delta").load(path).repartition(16)
    # 写入前合并分区(减少小文件)
    df.coalesce(8).write.format("delta").save(path)
  • 全局并行度env.setParallelism(8)(建议=CPU核数);

  • 算子并行度 :为不同算子设置差异化并行度(计算密集型算子更高);

    java 复制代码
    // 源算子并行度2,聚合算子并行度8
    kafkaStream.setParallelism(2)
               .keyBy(...)
               .window(...)
               .aggregate(...)
               .setParallelism(8);
  • 分区策略 :避免数据倾斜(如按user_id % 并行度重新分区);

    java 复制代码
    behaviorStream.rebalance()  // 随机重分区,解决数据倾斜
                  .keyBy(...);

2. 资源配置调优

资源配置包括内存、CPU、磁盘,核心原则:避免资源过度分配(浪费),避免资源不足(OOM/卡顿)

Spark 资源配置(本地/集群)
资源类型 配置参数 入门建议值
驱动内存 spark.driver.memory 本地:4G8G;集群:8G16G
执行器内存 spark.executor.memory 本地:2G4G;集群:8G16G
执行器CPU spark.executor.cores 本地:24核;集群:48核
堆外内存 spark.executor.memoryOverhead 执行器内存的10%~20%(避免OOM)

示例(提交Spark任务):

bash 复制代码
spark-submit \
  --master yarn \
  --driver-memory 8G \
  --executor-memory 16G \
  --executor-cores 8 \
  --num-executors 4 \
  batch_elt_demo.py
资源类型 配置参数 入门建议值
TaskManager内存 taskmanager.memory.process.size 8G~16G(包含堆内存+堆外内存)
TaskManager CPU taskmanager.numberOfTaskSlots 4~8(每个Slot对应1个并行度)
JobManager内存 jobmanager.memory.process.size 4G~8G

示例(flink-conf.yaml):

yaml 复制代码
taskmanager.memory.process.size: 16g
taskmanager.numberOfTaskSlots: 8
jobmanager.memory.process.size: 8g
parallelism.default: 8

3. 入门级调优 Checklist

  1. 检查数据倾斜:Spark通过explain()查看Shuffle分区,Flink通过WebUI查看算子数据分布;
  2. 调整并行度:确保每个任务处理数据量在合理范围(批处理128MB~256MB);
  3. 优化资源:避免Executor/TaskManager内存不足(OOM)或CPU闲置;
  4. 减少Shuffle:尽量使用map/filter等非Shuffle算子,避免不必要的groupBy
  5. 优化文件格式:使用Parquet/Delta Lake列式存储,开启压缩(Snappy/Gzip)。

总结

ETL/ELT 的核心差异在于转换时机,ELT 是大数据时代的主流选择;Spark 批处理和 Flink 流处理是数据转换的核心工具,需结合业务场景选择;数据转换需遵循清洗、脱敏、标准化最佳实践,保障数据质量与安全;性能调优入门需聚焦并行度和资源配置,让资源与数据处理需求匹配。《Data Engineering Book》的核心思路是:数据处理需"先保证正确性,再优化性能,最后降低成本",避免为了性能牺牲数据可用性。

如果你想进一步详细了解数据处理与转换,不妨到项目仓库 github.com/datascale-a... 获取完整代码和实战文档,也欢迎在仓库中交流经验, 觉得有帮助的朋友,欢迎点个 Star ⭐️ 支持一下!

相关推荐
XX1231223 小时前
重写图文描述(Recaptioning)| 基于 data_engineering_book让文本更适配模型、更贴合图片
llm
EasyLLM4 小时前
MiniMax M2.5实测
人工智能·llm
Baihai_IDP7 小时前
Prompt caching 技术是如何实现 1 折的推理成本优化的?
人工智能·面试·llm
马腾化云东9 小时前
Agent开发应知应会(Langfuse):Langfuse Session概念详解和实战应用
人工智能·python·llm
Tadas-Gao12 小时前
大模型实战装备全解析:从本地微调到移动算力的笔记本电脑选择指南
架构·系统架构·大模型·llm
dawdo2221 天前
自己动手从头开始编写LLM推理引擎(12)-xLLM的整体调优
llm·transformer·性能调优·推理引擎·xllm·模型执行器
缘友一世1 天前
GRPO奖励模型微调:从数据构建到技术路径选择
llm·数据集
Gain_chance2 天前
01-从零构建LangChain知识体系通俗易懂!!!
langchain·llm·rag