数据工程设计模式——ETL和ELT

105 阅读13分钟

引言(Introduction)

本章将探讨 ETL(Extract, Transform, Load:抽取-转换-加载)ELT(Extract, Load, Transform:抽取-加载-转换) 两种数据模式的运行方式,使读者熟悉它们各自能够解决的用例。我们还将基于开源与云技术,设计同时采用 ETL 与 ELT 的系统,并通过示例应用与代码片段进行演示。此外,本章也会介绍 ETL 与 ELT 模式在实际中的应用案例。

结构(Structure)

本章将涵盖以下主题:

  • ETL 与 ELT 模式的用例
  • 采用 ETL 与 ELT 的系统设计
  • ETL 与 ELT 系统的技术选型
  • 真实案例

目标(Objectives)

在本章结束时,读者将深入理解 ETL 与 ELT 数据工程模式;你还将能够基于这些模式设计并构建数据管道,并编写代码实现相应设计。你将了解用于构建 ETL/ELT 管道的技术栈。最后,你还将理解这些模式在银行业等真实场景中的应用。

ETL 与 ELT 模式的用例(Use cases for ETL and ELT patterns)

在批处理模式之后,ETL 可能是第二常见的数据工程模式。几乎所有传统的数据仓库系统都是基于该模式:从源事务系统抽取数据、进行转换、再加载入数据仓库。更现代的数据仓库也常采用 ELT:在初始摄取后,利用数据库自身的能力完成数据转换。尤其在实时系统中,因难以在实时路径上进行复杂转换,往往更依赖 ELT

数据仓库中的 ETL(ETL in data warehousing)

自上世纪 80 年代末能处理海量数据的数据库出现以来,数据仓库便应运而生。数据仓库并非直接从应用写入,而是从上游事务数据库获取数据。为此,需要一个抽取流程,从事务系统抓取所需数据(既包括一次性回填,也包括持续增量)。这构成 ETL 中的 E(Extract)

数据从源系统抽出后,很少会原样加载到数据仓库中;通常需要多种丰富化与整形,这就是 T(Transform) 。例如,在入仓前,数据需要反规范化并建模为 星型(Star)雪花(Snowflake)模式:星型以中心事实表连接多维表;雪花是在星型基础上将维表进一步规范化为子表。同时还需经过数据清洗,以便标准化用于报表(如地址规范化、隐私信息去除、数据质量提升等)。

完成转换后,将数据加载至数据仓库的过程即 L(Load) 。加载既包括用于历史回填(back population)的批量装载,也包括用于前向填充(forward population) 的新变化“细流式”加载。根据回填或前向的不同,所用加载技术也会不同。

注意:ETL 是更高层的模式,内部通常组合批处理微批等基础模式。
例如,ETL 几乎总是采用批/微批:转换计算开销大,难以“边到边转”;将记录成批后再统一转换更高效。类似地,ETL 的加载也几乎总是以批/微批方式完成。

下图展示了使用 Apache Spark 在从事务数据库到数据仓库过程中执行转换的 ETL(图 7.1):

image.png

图 7.1:基于 Apache Spark 的 ETL(ETL with Apache Spark)

点击流分析中的 ELT(ELT in clickstream analysis)

来自移动应用与网站的点击流分析对理解消费者行为至关重要:由此识别用户的搜索与购买模式,并将结果输入推荐引擎以制定合适的广告与促销方案。任何相对成功的应用或网站都会产生海量且实时的点击流数据,必须即时捕获

原始点击流需要处理与存储才能发挥价值。例如,要计算某网页的流失率,需跨时间聚合大量用户的点击流。但若在实时链路上完成这些聚合会很困难,且在入库前先聚合会拖慢摄取速率ELT 可以解决该问题:数据先入库,再在数据库内进行转换/聚合。现代 MPP 数据库引擎非常擅长在库内完成此类转换。

在工程上,可用 Apache KafkaKafka Connect 将应用产生的原始点击流抽取并加载至数据仓库。为保证摄取速率跟上数据生成速率,流数据以原样方式进入仓库,不在链路上做复杂转换;在 Kafka 中进行实时转换代价昂贵,尽管 SMT(Single Message Transforms) 支持轻量级的单条消息转换(如大小写转换、类型转换、四舍五入等),但并不适合聚合等复杂或高计算量的任务。

下图展示了 ELT 模式:数据先加载进仓库,随后在仓库内完成转换(图 7.2):

image.png

图 7.2:ELT 模式(ELT pattern)

设计 ETL 与 ELT 系统(Designing ETL and ELT system)

本节我们将设计并构建一个结合 Lambda 架构ETL/ELT 模式的系统:使用 ETL 支持历史数据回填(back population) ,用 ELT 支持前向增量(forward population) 。该系统通过融合历史与实时数据来完成分析。演示中以 MySQL 为源系统、ClickHouse 为目标系统;在 ETL 流程中,Apache Spark 用于转换阶段。

下面是“回填”与“前向增量”的高层对比:

表 7.1:数据回填与前向增量对比(Comparison of back and forward population of data)

数据加载用例(Data loading use case)回填(Back population)前向增量(Forward population)
摄取类型(Ingestion type)批处理(Batch)批处理与实时(Batch and real-time)
数据特性(Data characteristic)历史数据(Past data)当前与未来数据(Current and future data)
常见用例(Common use case)迁移或数据修复(Migration or data correction)新数据摄取(New data ingestion)

使用 ELT 的前向增量(Forward population using ELT)

速度层的前向增量通过 Kafka 源连接器从 MySQL 实时抽取数据(读取 CDC 变更日志)构建。前向增量即把新生成的数据引入系统。生成的数据被推送到 Kafkatopic,再由 ClickHousesink 连接器消费。为保证 Kafka 端实时摄取,数据原样推送,不做转换。

ClickHouse sink 连接器将 Kafka 消息转换为 INSERT/UPDATE/DELETE 操作,写入 ClickHouse 的 speed 表。此阶段同样不做数据转换,以确保消费速度能跟上数据产生速度。

下图展示了从 MySQL 到 ClickHouse 的基于 Kafka、不做转换的实时装载(体现 ELT 模式)(图 7.3):

image.png

图 7.3:前向数据增量(Forward data population)

前向增量搭建步骤(MySQL → Kafka → ClickHouse):

下载并安装 Kafka 集群:

wget https://dlcdn.apache.org/kafka/3.9.0/kafka_2.12-3.9.0.tgz
tar -xvf kafka_2.12-3.9.0.tgz
mv kafka_2.12-3.9.0 /opt/kafka
cd /opt/kafka

启动 Zookeeper 与 Kafka broker:

/opt/kafka/bin/zookeeper-server-start.sh /opt/kafka/config/zookeeper.properties
/opt/kafka/bin/kafka-server-start.sh /opt/kafka/config/server.properties

创建 Kafka topic:

/opt/kafka/bin/kafka-topics.sh \
  --create --topic <topic> \
  --bootstrap-server <broker-node-list> \
  --replication-factor <num-replicas> \
  --partitions <partition-count>

下载并配置 MySQL Kafka 连接器:

wget https://repo1.maven.org/maven2/io/debezium/debezium-connector-mysql/3.0.6.Final/debezium-connector-mysql-3.0.6.Final.jar
mkdir -p /opt/kafka/plugins/mysql
tar -xvzf debezium-connector-mysql-3.0.6.Final.jar -C /opt/kafka/plugins/mysql

启动 Kafka Connect:

/opt/kafka/bin/connect-standalone.sh connector.properties

创建 MySQL 源连接器配置文件:

{ "name": "debezium-mysql-connector", "config": { "connector.class": "io.debezium.connector.mysql.MySqlConnector", "tasks.max": "1", "database.hostname": "host", "database.port": "port", "database.user": "debezium", "database.password": "password", "database.server.id": "1", "database.server.name": "mysql_server", "database.include.list": "database-name", "table.include.list": "table-name", "database.history.kafka.bootstrap.servers": "ip:port", "database.history.kafka.topic": "topic-name", "include.schema.changes": "true", "snapshot.mode": "initial" } }

部署 MySQL 源连接器:

curl -X POST -H "Content-Type: application/json" \
  --data @<mysql-config-file> \
  http://ip:port/connectors

创建 ClickHouse Kafka sink 连接器配置文件:

{ "name": "clickhouse-sink-connector", "config": { "connector.class": "com.altinity.clickhouse.sink.connector.ClickHouseSinkConnector", "tasks.max": "1", "topics": "<kafka_topic_name>", "clickhouse.url": "http://host:port", "clickhouse.database": "database-name", "clickhouse.username": "username", "clickhouse.password": "password", "clickhouse.table.name": "clickhouse-table", "key.converter": "org.apache.kafka.connect.storage.StringConverter", "value.converter": "org.apache.kafka.connect.json.JsonConverter", "value.converter.schemas.enable": "false", "batch.size": "1000", "insert.mode": "insert" } }

部署 ClickHouse sink 连接器:

curl -X POST -H "Content-Type: application/json" \
  --data @<clickhouse-config-file> \
  http://ip:port/connectors

使用 ETL 的历史回填(Backward population using ETL)

批处理层的回填通过按日从 MySQL 表抽取数据,在 Apache Spark 作业中进行转换/整备(curate) ,并写入 ClickHouse 的 batch 表来实现。回填即将历史数据装入系统。Spark 分别通过 JDBC 连接器读取 MySQL、写入 ClickHouse;可用 cron 调度每日的批作业。

下图展示了将数据从 MySQL 搬运到 ClickHouse 的批处理层作业(图 7.4):

image.png

图 7.4:批处理层作业(MySQL → ClickHouse)

系统包含一张名为 sales_table 的 MySQL 表(存放当月销售数据),其中 transaction_date 为交易日期。Spark 程序以天为批读取、处理后,加载到 ClickHouse 目标表用于分析。

该程序需要 MySQLClickHouseJDBC 驱动(从各自下载页获取);还需安装 Spark。在本 ETL 流程中,我们用 JDBC 从 MySQL 抽取数据,在 Spark 中做一个简单舍入/取整类转换示例(文中示例使用绝对值),最后用 JDBC 写回 ClickHouse。实际的数据仓库常支持批量装载以应对大体量 ETL,但为简化示例,这里使用 JDBC。

brew install apache-spark

在创建 MySQL 表之前,需要安装并启动 MySQL 服务(详见官方文档)。同样,创建 ClickHouse 数据库与表之前,需要在本地或云端完成 ClickHouse 的安装/开通。

步骤如下:

创建 MySQL 数据库:

CREATE DATABASE sales_db;

连接至 sales_db 并创建月度交易数据表:

USE sales_db;
CREATE TABLE sales_table (
    transaction_id INT AUTO_INCREMENT PRIMARY KEY,
    transaction_amount DECIMAL(10, 2) NOT NULL,
    product_id INT NOT NULL,
    location_id INT NOT NULL,
    transaction_date DATE NOT NULL
);

向 MySQL 载入示例数据:

LOAD DATA INFILE '/tmp/sales_table_january_2024.csv' INTO TABLE sales_table
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(transaction_id, transaction_amount, product_id, location_id, transaction_date);

在 ClickHouse 上创建数据库:

CREATE DATABASE sales_db;

连接到 sales_db 并创建用于分析的 ClickHouse 表:

USE sales_db;
CREATE TABLE sales_table (
    transaction_id UInt32,
    transaction_amount Decimal(10, 2),
    product_id UInt32,
    location_id UInt32,
    transaction_ts Timestamp
)
ENGINE = MergeTree()
ORDER BY transaction_id;

按日运行、在加载前做简单转换(将金额取绝对值)的 Spark 作业示例:

from pyspark.sql import SparkSession

# Create a SparkSession
spark = SparkSession.builder \
    .appName("MySQL to Clickhouse") \
    .getOrCreate()

# Setup the MySQL JDBC connection properties
mysql_url = "jdbc:mysql://localhost:3306/sales_db"
mysql_properties = {
    "user": "root",
    "password": "MyPassword",
    "driver": "com.mysql.cj.jdbc.Driver"
}

# Query to batch query the day's data in MySQL
query = "(SELECT transaction_id, abs(transaction_amount), product_id, location_id, transaction_date FROM sales_table where DAYOFMONTH(transaction_date)=DAYOFMONTH(CURDATE()) limit 5) AS tmp"

# Read from MySQL daily data table into DataFrame
mysql_df = spark.read.jdbc(url=mysql_url, table=query, properties=mysql_properties)

# Setup ClickHouse JDBC connection properties
clickhouse_url = "jdbc:clickhouse://url:port/default"
clickhouse_properties = {
    "user": "default",
    "password": "MyPassword",
    "ssl": "true",
    "driver": "com.clickhouse.jdbc.ClickHouseDriver"
}

# Write daily data from MySQL to Clickhouse in a batch
mysql_df.write \
    .mode("append") \
    .jdbc(url=clickhouse_url, table="sales_table", properties=clickhouse_properties)

# Stop the SparkSession
spark.stop()

使用 cron 调度 Spark 作业:

crontab -e
0 22 * * * spark-submit --jars "/tmp/jdbc_dir/mysql-connector-j-9.0.0.jar,/tmp/jdbc_dir/clickhouse-jdbc-0.7.0.jar" spark_program.py

ETL 与 ELT 系统的技术(Technologies for ETL and ELT systems)

ETL 与 ELT 系统所用技术与批处理、微批以及实时系统相近,因为它们在底层正是利用这些数据模式。

ETL 系统通常基于批处理或微批模式构建。原因在于:实时路径上除了非常简单的转换外,几乎无法完成更复杂的转换。既然 ETL 采用批/微批模式,就必须能处理成批汇聚的大量数据。历史上,Hadoop MapReduce 是常见的批处理方案:作业可可靠消费并处理海量数据,且以 HDFS 作为临时与持久存储。随着现代系统的发展,工程师意识到 MapReduce 的资源利用效率不高,逐渐转向 Apache Spark 执行批处理作业。Spark 具备从失败点恢复的能力,是更高性能、更可靠的批处理执行框架。存储方面,对象存储正逐渐取代 HDFS,成为批处理的默认存储系统。

此外,ETL 作业需要根据批频进行调度与编排Apache Airflow 由于能适配多种系统、并以 DAG(有向无环图) 建模工作流而成为强力选择;ETL 作业之间常存在依赖关系,DAG 是很好的表达方式。对于不需要复杂编排的简单场景,Linux 的 Cron 依旧是便捷的批作业调度工具。

市面上有多款商用现成(COTS) ETL 产品(如 IBM DataStage、Informatica),也有新入局者(如 Fivetran、Hevo Data、Airbyte)。当需要不仅从数据库,还要从 ERP/CRM 等业务软件中抽取数据时,这些工具凭借丰富的连接器生态更受欢迎。它们通常集成 ETL 所需的大部分能力,无需再分开采购“抽取、编排、加载”等组件。

表 7.2:Fivetran、Airbyte 与 Informatica 的高层对比

维度(Dimension)InformaticaFivetranAirbyte
成本(Cost)高(High)中(Medium)中到低(Medium to Low)
开源(Open-source)否(No)否(No)是(Yes)
部署模型(Deployment model)SaaS、云托管、本地部署SaaSSaaS、云托管、本地部署
用例(Use case)ETL、ELT、治理、主数据管理ETLETL 与数据集成

ELT 模式多采用实时 + 批处理的混合:抽取与加载(EL)通常走实时模式,以数据生成速率摄取到库;而转换(T)入库后批处理完成。由于实时 EL 需要低延迟消息投递,Apache Kafka、Apache Flink、Spark Streaming 等技术十分流行。其中 Kafka 因为对各类源与汇系统提供广泛的连接器而最为常用,并提供至少一次投递语义,大幅简化实时系统开发。受 SMT 可做转换能力所限(仅适合轻量单消息转换),复杂转换需延后到入库后进行。

真实世界示例(Real-world examples)

下面给出一个来自银行业的实例。

银行业(Banking)

银行常用 ETL 构建报表系统。银行拥有多条业务线(信用、外汇、公司金融、零售银行、保险等),各自的数据存放在对应的数据集市中;企业数据仓库(EDW)汇聚这些集市的数据,生成如 Customer 360(客户全景视图)等能力。为实现这一点,需要将各业务线的数据转换并与其他数据源整合。

难点在于:不同业务线数据集市的模型与格式差异很大——有的为关系模型,有的为文档模型,或以 CSV/Parquet 等平面文件存在。将它们统一进一个数据仓库具有挑战,这正是 ETL 发挥作用之处:从多源抽取数据,利用转换框架标准化为统一的数据模型与格式,然后加载入仓。

下图展示一种架构:从多数据源抽取、合并、转换,并加载到数据仓库。各业务线数据集市、对象存储与平面文件等由 Apache Spark 完成转换,随后写入数据仓库(图 7.5)。

image.png

图 7.5:银行业中的 ETL(ETL in banking)

当然,银行也会在适用场景中采用 ELT。例如反欺诈:为实现实时欺诈检测,交易发生时数据必须立刻可用于打分;在加载前做转换会引入不可接受的延迟,可能拖慢交易,甚至放任欺诈发生。因此数据需要实时入库以供打分。

随后,为了在实时交易数据上进行特征工程,数据仍需再做转换。特征工程往往较复杂,难以在实时系统的 SMT 中完成;同时还需要来自其他数据源的查找表/特征表,这类与外部数据的结合也不适合在单条消息转换中完成。

下图展示了这种路径:使用实时模式完成 ELT 的 EL(抽取+加载),随后对数据进行转换以开展特征工程(图 7.6)。

image.png

图 7.6:用于 ELT 的实时模式(Real-time pattern for ELT)

结语(Conclusion)

本章介绍了基于 ETLELT 数据模式的常见用例,并设计了在 Lambda 系统中以 ETL 支撑批处理层、以 ELT 支撑速度层的方案。我们看到这些模式如何借助批处理与实时技术栈来实现;同时给出了银行业的行业案例:通过 ETLELT 既支持 Customer 360 的全景视图,也支撑实时反欺诈

下一章将在进入数据存储与服务模式之前,回顾数据的基础知识。