数据工程设计模式——数据湖与勋章架构

106 阅读19分钟

引言(Introduction)

本章将不再沿用前面电商网站的示例,转而引入另一类常见行业——出行聚合平台。出行聚合平台提供机票预订、酒店预订、火车票、汽车/巴士票、网约车等多种服务。日常运营中,这些服务彼此独立运行;但从数据分析角度看,需要把它们产生的数据汇总联分析,以发现模式并制定业务策略。

不同应用产生的数据格式与模式可能各不相同。为存放这类格式各异的数据,数据工程师通常采用一种流行的存储模式——数据湖(Data Lake) 。本章将通过示例理解数据湖的概念,并了解使用数据湖的收益。

用户应用产生的是原始数据,通常需要进一步处理后才能更好地服务于具体业务场景。数据首次进入数据湖时即为原始态。数据工程师可采用勋章(Medallion)架构对其进行过滤、处理与丰富(enrich) ;在数据湖模式中,加工后的数据仍存回数据湖。我们将用示例说明如何做过滤、处理与富化。

结构

本章涵盖以下主题:

  • 出行聚合平台示例
  • 数据湖架构
  • 在数据湖中组织数据
  • 提取-加载-转换(ELT)模式的使用
  • 勋章(Medallion)架构
  • 勋章架构的收益

目标

读完本章,你将能够通过示例理解数据湖存储模式;知道如何在数据仓库数据湖之间做选择及其取舍;并通过示例学会如何在数据湖中组织数据。你还将了解勋章架构如何迭代式提升数据质量,以及在实践中的收益。

出行聚合平台示例

出行聚合网站提供机票、酒店、巴士/长途汽车、火车、网约车等功能。为了给用户提供流畅的预订体验,平台需要:

  • 汇集来自各独立外部服务的航班、酒店、巴士、火车数据
  • 与这些外部服务对接以完成下单
  • 实施支付清结算流程
  • 管理预订确认、发票与收据
  • 与第三方供应商对接取消退款结算
  • 分析用户行为、预订模式与点击流(clickstream) ,以生成后续推荐

网站的事务性部分会使用后端数据库完成:

  • 存储航班、酒店、巴士、火车、网约车等信息
  • 支持用户搜索这些服务
  • 支持用户预订并在支付后完成下单
  • 提供取消与退款能力

这些事务流程产生的数据可被传输到数据仓库数据湖做进一步分析。上一章我们介绍了数据仓库;本章将介绍数据湖及其与数据仓库的区别。

数据湖:一种以对象存储(blob storage)为后端的数据对象仓库,可存放结构化、半结构化、非结构化数据,并能大规模扩展

数据湖之所以流行,主要因为其灵活性。例如,数据湖既可存放关系型数据库中的行列数据,也可存放应用产生的 JSON/CSV,以及文本、图片、音频、视频等非结构化数据。实操中,这些不同类型的数据对象常被统一存放在同一个对象存储后端(如 AWS S3Azure Blob Storage)的同一 Bucket/Container 中,借助无模式(schema-less)设计形成一个数据湖。存入数据湖的数据通常是原始形态

为了更深入理解数据湖架构,先从数据仓库 vs 数据湖的对比入手。

数据仓库与数据湖的差异

下表从用途、成本、性能等角度对比数据仓库与数据湖(文字化呈现):

  • 主要目的

    • 数据仓库:存放结构化数据(跨一张或多张表)。
    • 数据湖:存放结构化 / 半结构化 / 非结构化数据。
  • 模式应用时机

    • 数据仓库:写时施加模式(schema-on-write) ,在数据写入时即强制模式。
    • 数据湖:读时施加模式(schema-on-read) ,读取时再按需解析;在此之前以无模式的对象形式存放。
  • 查询优化

    • 数据仓库:查询执行重性能优化
    • 数据湖:查询执行相对未优化
  • 常见流程

    • 数据仓库:更可能使用 ETL(抽取-转换-加载)
    • 数据湖:更可能使用 ELT(抽取-加载-转换)
  • 成本

    • 数据仓库:因内置的性能优化,成本较高
    • 数据湖:优化较少,成本更低
  • 主要用途

    • 数据仓库:主要用于数据分析(Analytics/BI)
    • 数据湖:既用于分析,也常用于数据科学与机器学习
  • 主要用户

    • 数据仓库:数据分析师、业务决策者
    • 数据湖:数据科学家、机器学习工程师,以及分析师与决策者。

表 11.1:数据仓库与数据湖的差异

数据湖架构

看一个场景:平台希望在用户订了某地的机票或火车票后,向其追加销售(upsell)该地的酒店或网约车

假设平台在演进过程中的技术选择如下:

  1. 创业初期只有机票预订流程;
  2. 机票信息存储在 MySQL 等关系型数据库;
  3. 业务增长后扩展到火车、网约车、酒店等;
  4. 火车与酒店选择自研能力
  5. 网约车选择接入第三方聚合服务;
  6. 鉴于传统关系库的局限,自研的火车/酒店预订数据采用分布式 NoSQL(如 Couchbase);
  7. 第三方网约车服务返回的成功预订响应是纯文本(非 JSON/XML),平台将其作为二进制文档存入网约车预订表,并附带订单 ID、用户 ID 等元数据。

基于上述设定,我们会发现:不同业务的数据存放在不同数据库/存储中,且数据格式各异。为了进一步分析,需要从这些事务源采集并汇聚到统一的存储后端——这便形成了数据湖。图 11.1 示意了数据湖架构的高层模块图。

数据湖架构通常包含:

  • 数据存储层:以对象存储为后端;
  • 数据转换层:把来自多源(多类型)的数据转换为可统一存放/处理的对象格式(blob) ,并为各类业务用例提供可用数据。

image.png

图 11.1:数据湖架构概览

接下来,我们以 Azure Blob Storage 为例,看看如何在一个容器(Container)中组织数据

在数据湖中组织数据

如前所述,数据湖以数据对象/Blob 存储为后端。典型的对象存储会基于键值语义暴露非常简单的 API 来存取数据。用户程序可以利用 Blob 元数据或对键名(key)做“路径式”设计来在逻辑上划分各种数据类型。若应用需要遍历大量数据,依赖元数据通常开销较大,因此更常见的做法是通过键名来组织数据

要理解键名组织的方式,我们先看一下 Blob 存储暴露的基础 API。以 Azure Blob Storage 为例,常见的键值 API 如下:

  • Put(key, value)
  • value = Get(key)
  • List[keys] = List Blobs(key-prefix)

前两个 API(Get 与 Put)不言自明。第三个 按前缀列出 Blob 的 API 允许我们把对象存储在逻辑意义上当作文件系统来用。拥有“目录列出”的能力,能帮助我们更直观地规划数据的逻辑组织结构。继续之前多源数据的例子,若把预订信息按类文件系统布局,我们可能会设计如图 11.2 的目录结构:

image.png

图 11.2:使用目录与文件组织的 Bookings 目录结构

在图 11.2 中,用户将所有预订信息置于顶层目录 Bookings 之下,再按预订类型(Flight/Train/Hotel/Cab)建立子目录;每条预订数据各自存放为独立文件。为简单起见,文件名记为 Booking1/Booking2/Booking3,数字代表预订 ID。由于文件独立存放,不强制文件格式。文件系统还提供按目录列出的能力,便于在不打开数据文件的情况下按文件名搜索某个预订。

在 Azure Blob Storage(或其它 Blob 服务)中,可用 Get/Put/List Blobs API 来模拟这种文件系统行为。用户在写入时将 Blob 的 key 构造成类似“路径”的形式。例如:

  • Bookings/Flight/Booking101 表示 ID=101 的航班预订详情;
  • Bookings/Hotel/Booking302 指向 ID=302 的酒店预订详情文件。

下面的 PySpark 示例演示如何把 MySQL 表中的航班预订数据转换为每条预订一个 JSON 文档,并存入 Azure Blob Store(作为数据湖)。示例中通过拼接键前缀来模拟上述目录结构:

from pyspark.sql import SparkSession 
from pyspark.sql.functions import to_json, struct, col
from azure.storage.blob import BlobServiceClient

spark = SparkSession.builder \
    .appName("Store flight bookings to data lake") \
    .config("spark.jars", "/path/to/mysql-connector-java.jar") \ 
    .getOrCreate()

# MySQL 连接参数
mysql_db_url = "jdbc:mysql://<db-host>:<db-port>/<database-name>" 
mysql_db_properties = {
    "user": "<db-username>",
    "password": "<db-password>",
    "driver": "com.mysql.cj.jdbc.Driver"
} 
mysql_table_name = "<mysql-table-name>"

# Azure Blob 存储参数
azure_blob_container_name = "<your-container-name>
azure_account_url = "https://<storage-account-name>.blob.core.windows.net"

# 读取 MySQL 表
def read_mysql_table():
    return spark.read.format("jdbc") \
    .option("url", mysql_db_url) \
    .option("dbtable", mysql_table_name) \
    .option("user", mysql_db_properties["user"]) \
    .option("password", mysql_db_properties["password"]) \
    .option("driver", mysql_db_properties["driver"]) \
    .load()

def convert_to_individual_json_docs(df): 
    json_df = df.withColumn("document", 
        to_json( struct([col(c) for c in df.columns]))
    )
    return json_df

def write_individual_json_docs_to_blob(json_df):
    json_df.rdd.foreach(lambda row: write_to_blob(row['document']))

def write_to_blob(json_data):
    blob_service_client = BlobServiceClient(azure_account_url,
        credential=<azure_credential>)
    container_client = blob_service_client \
        .get_container_client(container=azure_blob_container_name)
    # 以文件名模拟图 11.2 的目录层级
    filename = "Bookings/Flight/" + json_data["bookingId"] + ".json"
    blob_client = container_client.upload_blob(name=filename, data=json_data)

# 读 MySQL、转 JSON、写入 Azure Blob
mysql_df = read_mysql_table()
json_df = convert_to_individual_json_docs(mysql_df)
write_individual_json_docs_to_blob(json_df)

使用 ELT(Extract-Load-Transform)模式

回看图 11.1,可见摄入阶段数据会写入原始数据容器。在写入原始容器前,我们仅做键名(前缀)设计。但若要开展 BI 等工作,通常需要对数据进行转换、过滤与脱敏。转换后的数据再写回数据湖——可以放在不同容器,或在**同容器的不同前缀(逻辑目录)**下。

延续我们的场景:需要基于火车/航班预订对用户做酒店或网约车的追加销售。为此,数据分析程序每天需执行:

  1. 找出前一日完成的火车/航班预订列表;

  2. 对列表中每条预订:

    • 若已取消,跳过;
    • 若未取消,抽取用户 ID、用户邮箱、出行日期
    • 检查该用户在出行日是否已有酒店或网约车预订;
    • 若缺少其一,发送对应促销邮件
    • 标记“已发送促销”,避免重复发送。

若直接读取原始区的数据,程序需要:

  • 遍历全部火车/航班预订(因为键按 bookingId 组织,并非按日期;需从 value 中解析出预订日期);
  • 遍历全部酒店/网约车预订以核对同一用户在出行日是否已预订。

假设每天约有 1000 条火车/航班预订,那么上述两类全量遍历将被重复执行 1000 次。对整表遍历与从 value 解析信息的做法不仅性能差,也会带来高额读流量与请求数成本。为优化此过程,可在加载后对数据进行转换与重排按最优键前缀重写。

对该用例,较优的键前缀设计是:

  • 火车/航班预订:以预订日期作为键前缀
  • 酒店/网约车预订:以 用户 ID / 出行日期 / 城市 作为键前缀

这样:

  • 查“某天的火车/航班预订”时,只需列出该日期前缀下的键即可,极大减少读取的数据量与请求数;
  • 查“某用户在某日某城的酒店/网约车预订”时,直接命中对应复合前缀,查询又快又省钱

注:在多数云厂商中,读取成本取决于读取字节数请求次数

下面的 PySpark 片段演示如何从 Couchbase 读取火车/酒店预订,并按优化后的键前缀写入 Azure Blob 的数据湖:其中火车预订按预订日期作键前缀;酒店预订按用户 ID / 出行日期 / 城市作键前缀。示例假设两类预订存于同一 Couchbase bucket(实际可分桶):

from pyspark.sql import SparkSession
from azure.storage.blob import BlobServiceClient

# Couchbase 配置
cb_cluster = "couchbases://YourCouchbaseClusterHostname"
cb_username = "username"
cb_password = "password.123"

# 通过 SparkSession 连接 Couchbase
spark = SparkSession.builder \
    .appName("Couchbase Spark Connector Example") \
    .config("spark.couchbase.connectionString", cb_cluster) \
    .config("spark.couchbase.username", cb_username) \
    .config("spark.couchbase.password", cb_password) \
    .getOrCreate()

# Azure Blob 存储参数
azure_blob_container_name = "<your-container-name>"
azure_account_url = "https://<storage-account-name>.blob.core.windows.net"

# 从 Couchbase 建立 DataFrame
df = spark.read.format("couchbase.query") \
    .option("bucket", "bookings") \
    .load()

# 写入 Azure Blob 的函数(按优化后的键前缀)
def write_to_blob(row):
    row_json = row.asDict(recursive=True)
    json_data = json.dumps(row_json, indent=4)
        blob_service_client = BlobServiceClient(azure_account_url,
        credential=<azure_credential>)
        container_client = blob_service_client \
        .get_container_client(container= azure_blob_container_name)
        filename = "Bookings/Hotels/" + \
        row_json["bookingDate"] + "/" + row_json["bookingId"] + ".json"
    blob_client = container_client.upload_blob(name=filename, data=json_data)

# 写入 Azure Blob
df.foreach(write_to_blob)

到目前为止,我们学习了如何用数据湖这种无模式的设计模式来容纳各类数据格式,并在加载后对数据做转换以构建高性能、低成本的应用。对原始数据做恰当的转换,能显著减少计算量与费用。最受欢迎的一类转换与存储模式是——勋章(Medallion)架构

勋章(Medallion)架构

Medallion 架构中,数据会按数据质量被划分为三种形态、三层存放。数据依次流经各层,质量逐步提升。结合本章中“向已订机/火用户加售网约车与酒店”的用例,我们来看如何实施该架构。

此前我们看到,源数据可能来自不同类型的数据库、以不同格式出现。原始数据按到达顺序直接落入数据湖中 bookings 前缀下,这就是 Medallion 的第一层——青铜层(bronze)

从青铜层进行转换

数据进入青铜层后,可按各类数据分析场景进行转换。示例如下:

  • 数据过滤(filtering) :并非所有字段都用于分析。若不打算基于支付信息做分析,可在进入下一层前剔除支付字段。
  • 数据重组(reorganizing) :如前所述,可调整键(key)设计来避免代价高、耗时长的操作。
  • 数据清洗(cleansing) :若当前并无分析“取消订单”模式的需求,可选择让被取消的预订不进入下一层。
  • 数据丰富(enrichment) :同一目的城市在航班和火车预订中可能以不同代码出现(城市内多机场、多车站且使用代码而非城市名)。需要将机场/车站代码统一映射为城市名

完成这些转换后,数据形成企业统一视图,即 白银层(silver)

下面的 PySpark 代码从 bronze 读取原始数据,通过将机场/车站映射为城市名来做数据丰富,并写入 silver

from pyspark.sql import SparkSession
from azure.storage.blob import BlobServiceClient

# Azure Blob 连接参数
azure_blob_src_container_name = "<your-source-container-name>"
azure_blob_dst_container_name = "<your-destination-container-name>"
azure_account_url = "https://<storage-account-name>.blob.core.windows.net"

# 连接(示例)
spark = SparkSession.builder \
    .appName("Azure Spark Connector") \
    .config(azure_account_url, <azure_account_credential>) \
    .getOrCreate()

# 读取 bronze 层 JSON
read_src = azure_blob_src_container_name
read_url = "wasbs://read_src@blob_storage_account.blob.core.windows.net"
df = spark.read.json("read_url/bronze/*.json")

# 写入 Azure Blob 的帮助函数
def write_to_blob(filename, json_data):    
    blob_service_client = BlobServiceClient(azure_account_url,
        credential=<azure_credential>)
    container_client = blob_service_client \
        .get_container_client(container= azure_blob_container_name)
    blob_client = container_client.upload_blob(name=filename, data=json_data)

# 处理每条 JSON:从 bronze 转换并写入 silver
def process_and_write_data(row):
    row_json = row.asDict(recursive=True)
    # 本例中仅将路径中的层级名从 bronze 替换为 silver
    in_filename = row_json["id"]
    out_filename = in_filename.replace("bronze", "silver", 1)

    if row_json["bookingType"] == "Flight":
        city = get_city_from_airport(row_json["destination"])
        row_json["destination"] = city        
    elif row_json["bookingType"] == "Train":
        city = get_city_from_station(row_json["destination"])
        row_json["destination"] = city

    json_data = json.dumps(row_json, indent=4)
    write_to_blob(out_filename, json_data)

# 执行处理
df.foreach(process_and_write_data)

说明:get_city_from_airportget_city_from_station 可用简单的 Python 字典实现代码到城市名的映射,故此处略去实现。

从白银层进一步转换

silver 层形成企业视图后,还需将其加工为便于直接消费的分析数据,即 黄金层(gold) 。继续我们的加售策略,业务分析师会关注:

  • 有多少用户只订了航班/火车,却在平台订酒店/网约车?
  • 这些用户在年龄/性别/居住城市/国家等方面是否有模式?
  • 目的地城市/国家上是否呈现“不订酒店/网约车”的模式?
  • 近期是否出现模式变化,如差评增多等?

为支持这些问题,仪表盘既可直接基于 silver 计算,也可先把 silver 再加工,生成可直接驱动 BI 的 gold 数据。例如 gold 中可存放:

  • 过去一月中,只订了航班/火车而订酒店/网约车的用户列表;
  • “未订网约车/酒店”的Top 10 目的城市;
  • 近期网约车差评显著上升的 Top 10 城市。

示例 PySpark:找出“无酒店预订”的航班预订并写入 gold

设定 silver 层键格式:

  1. 航班/火车预订:Silver/Flights/<bookingDate>/<BookingId>.json
  2. 酒店预订:Silver/Hotels/<userId>/<travelDate>/<city>/<BookingId>.json

代码要点:

  • 遍历航班预订;
  • 查找同一用户在出行日 + 城市下是否存在酒店预订;
  • 若不存在,将该记录写入 gold
from pyspark.sql import SparkSession
from azure.storage.blob import BlobServiceClient
from datetime import date

# Azure Blob 连接
azure_blob_src_container_name = "<your-source-container-name>"
azure_blob_dst_container_name = "<your-destination-container-name>"
azure_account_url = "https://<storage-account-name>.blob.core.windows.net"

spark = SparkSession.builder \
    .appName("Azure Spark Connector") \
    .config(azure_account_url, <azure_account_credential>) \
    .getOrCreate()

# 读取当天的航班预订
booking_date = str(date.today())
read_src = azure_blob_src_container_name
read_url = "wasbs://read_src@blob_storage_account.blob.core.windows.net"
flight_df = spark.read.json("read_url/Silver/Flights/"+ booking_date + "/*.json")

# 查询指定 userId / travelDate / city 的酒店预订
def get_hotels_dataframe(userId, travelDate, city):
    prefix = "read_url/Silver/Hotels/" + userId + str(travelDate) + city
    hotels_df = spark.read.json(prefix + "/*.json")
    return hotels_df

# 写入 gold 的帮助函数
def write_to_blob(filename, json_data):    
    blob_service_client = BlobServiceClient(azure_account_url,
        credential=<azure_credential>)
    container_client = blob_service_client \
        .get_container_client(container= azure_blob_container_name)
    blob_client = container_client.upload_blob(name=filename, data=json_data)

# 处理每条航班记录:若无酒店预订则写入 gold
def process_and_write_data(row):
    row_json = row.asDict(recursive=True)
    # 注意:row_json["city"] 已在 bronze→silver 转换时完成了城市名丰富
    hotels_df = get_hotels_dataframe(row_json["userId"],
        row_json["travelDate"], row_json["city"])

    # 若未订酒店,则将该用户写入 gold(用于营销加售)
    if hotels_df.count() == 0:
        gold_json_data = {
            "userId": row_json["userId"],
            "travelDate": row_json["travelDate"],
            "city": row_json["city"]
        }
        out_filename = "read_url/Gold/<PromotionId>/<userId>.json"
        json_data = json.dumps(gold_json_data, indent=4)
        write_to_blob(out_filename, json_data)

# 执行
df.foreach(process_and_write_data)

总结:三层合一

我们已经理解了 bronze / silver / gold 三层:

  • Bronze(青铜) :接收来自事务系统的原始数据
  • Silver(白银) :对原始数据执行过滤、清洗、去重、丰富等转换,形成统一企业视图;
  • Gold(黄金) :将 silver 再加工为直接消费的分析数据,支撑 BI/报表/运营决策。

云端 Blob 存储(如 AWS S3、Azure Blob)提供了简单一致的对象 API,使数据湖的灵活性得以发挥,最终形如图 11.3:Medallion 架构所示的端到端流程。

image.png

勋章架构的优势

采用勋章(Medallion)架构有两大核心收益:

  • 职责分离
  • 数据管道的可复用性

职责分离

勋章架构中的每一层都有明确的目标,各层的数据可以分别存储、管理与清理。

例如:

  • 青铜层(Bronze) :聚焦数据摄取,不关心数据丰富等处理。
  • 白银层(Silver) :负责基础转换,如过滤、清洗、去重等。
  • 黄金层(Gold) :将白银层的数据加工为可直接消费的视图,并高效存储。

任一层的实现变更不影响其他层。

数据管道的可复用性

正如我们看到的,数据管道可能复杂、实现耗时且运行与运维成本较高。勋章架构在各层提供了有用的抽象,使得管道的部分环节可复用。例如,从青铜到白银的过滤、清洗、去重等转换通常具有通用性,对所有数据分析师和业务策略人员都有用,如下所示:

image.png

图 11.4:勋章架构中的数据管道复用

图 11.4 展示了多个营销推广如何借助勋章架构复用管道的通用部分。这里同时执行三项不同的营销活动,每项都需要在黄金层获得不同的可消费数据:某一活动可能需要基于位置的数据,另一项可能需要按年龄段的预订数据。无论哪种情况,青铜→白银阶段的清洗、去重与通用转换都可以共享,避免为多个活动分别转换与存储同类数据所产生的成本。

白银→黄金的转换可独立实现、执行和维护,因为黄金层数据几乎总是面向单个营销活动的。

青铜层在勋章架构中的重要性

青铜层为用户带来两点特定价值:

  1. 聚焦数据摄取:让其他层能够专注于面向业务的数据表达与管道优化。
  2. 保留原始数据:当出现新的业务诉求时,可能需要面向该诉求的特定数据子集。白银层中某些数据或许已被过滤掉。若没有青铜层的原始数据,数据工程师就需要新建或修改数据摄取管道来补齐数据。而青铜层完整保留原始数据后,工程师即可直接专注于业务侧的数据处理,节省构建或修改摄取管道的时间与成本。

结论

本章我们了解了数据湖架构在应对不断演进的用户需求与应用设计模式方面的灵活性,并将数据湖与数据仓库进行对比,以理解两者在设计取舍上的差异。

借助示例,我们看到如何用对象存储作为数据湖后端,并通过 Get / Put / List 这类简洁 API 完成数据的存取;也学习了通过键前缀建模来提升性能、降低云服务成本,以及对象存储如何用上述 API 模拟文件系统行为。

随后,我们学习了勋章架构:数据在多层中逐步提升质量,每一层都呈现了符合该层目标的数据版本。结合“对已订火/机票用户加售酒店与网约车”的示例,我们理解了各层的用途与协作方式。

下一章将讨论数据复制数据分区的设计模式。