数据处理与转换|基于 data_engineering_book 玩转 ETL/ELT 核心流程
本文基于《Data Engineering Book》核心内容,深度拆解 ETL/ELT 的核心差异与适用场景,结合 Spark 批处理、Flink 流处理给出可落地的代码示例,总结数据转换最佳实践,并入门级讲解处理性能调优的核心思路,覆盖数据工程中数据处理环节的核心考点与实操要点。
GitHub地址: github.com/datascale-a...
一、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()
2. Flink 流处理(实时 ELT:先加载后实时转换)
场景:实时抽取 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()、Flinkdistinct()。
- 异常值处理 :
- 数值型:定义合理范围(如金额>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-20、hour=10; - 地域分区:
region=cn-north-1、city=beijing; - 所有分区键小写,值无特殊字符。
- 时间分区:
四、处理性能调优入门(并行度、资源配置)
《Data Engineering Book》强调:性能调优的核心是"让资源匹配数据处理需求,避免资源浪费或瓶颈",入门级调优聚焦并行度 和资源配置两大核心维度,以下是批处理(Spark)和流处理(Flink)的通用调优思路:
1. 并行度调优
并行度决定了任务的执行线程数/进程数,是调优的第一步,核心原则:并行度 = 总数据量 / 单任务处理量(单任务处理量建议:批处理128MB~256MB,流处理1000条/秒)。
Spark 批处理并行度调优
-
全局并行度 :设置
spark.default.parallelism(默认=集群CPU核数),建议值:CPU核数 * 2~3;pythonspark.conf.set("spark.default.parallelism", 16) # 8核CPU→16并行度 -
Shuffle并行度 :设置
spark.sql.shuffle.partitions(默认200),建议值:总数据量 / 256MB(如100GB数据→400分区);pythonspark.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)
Flink 流处理并行度调优
-
全局并行度 :
env.setParallelism(8)(建议=CPU核数); -
算子并行度 :为不同算子设置差异化并行度(计算密集型算子更高);
java// 源算子并行度2,聚合算子并行度8 kafkaStream.setParallelism(2) .keyBy(...) .window(...) .aggregate(...) .setParallelism(8); -
分区策略 :避免数据倾斜(如按
user_id % 并行度重新分区);javabehaviorStream.rebalance() // 随机重分区,解决数据倾斜 .keyBy(...);
2. 资源配置调优
资源配置包括内存、CPU、磁盘,核心原则:避免资源过度分配(浪费),避免资源不足(OOM/卡顿)。
Spark 资源配置(本地/集群)
| 资源类型 | 配置参数 | 入门建议值 |
|---|---|---|
| 驱动内存 | spark.driver.memory | 本地:4G |
| 执行器内存 | spark.executor.memory | 本地:2G |
| 执行器CPU | spark.executor.cores | 本地:2 |
| 堆外内存 | 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
Flink 资源配置(Standalone/Yarn)
| 资源类型 | 配置参数 | 入门建议值 |
|---|---|---|
| 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
- 检查数据倾斜:Spark通过
explain()查看Shuffle分区,Flink通过WebUI查看算子数据分布; - 调整并行度:确保每个任务处理数据量在合理范围(批处理128MB~256MB);
- 优化资源:避免Executor/TaskManager内存不足(OOM)或CPU闲置;
- 减少Shuffle:尽量使用
map/filter等非Shuffle算子,避免不必要的groupBy; - 优化文件格式:使用Parquet/Delta Lake列式存储,开启压缩(Snappy/Gzip)。
总结
ETL/ELT 的核心差异在于转换时机,ELT 是大数据时代的主流选择;Spark 批处理和 Flink 流处理是数据转换的核心工具,需结合业务场景选择;数据转换需遵循清洗、脱敏、标准化最佳实践,保障数据质量与安全;性能调优入门需聚焦并行度和资源配置,让资源与数据处理需求匹配。《Data Engineering Book》的核心思路是:数据处理需"先保证正确性,再优化性能,最后降低成本",避免为了性能牺牲数据可用性。
如果你想进一步详细了解数据处理与转换,不妨到项目仓库 github.com/datascale-a... 获取完整代码和实战文档,也欢迎在仓库中交流经验, 觉得有帮助的朋友,欢迎点个 Star ⭐️ 支持一下!