本章的重点不再是如何与 Delta Lake 表交互和使用它们,而是介绍一些高级特性,这些特性将对你非常有用。从本质上讲,这些 Delta Lake 特性更多地与元数据相关。在本章中,我们将首先探讨如何使用生成列作为表定义的一部分,以减少数据加载操作所需的插入或转换工作。接着,我们将讨论 Delta Lake 元数据如何通过约束和注释帮助提高数据质量标准,并为用户提供更丰富的信息。最后,我们将分享删除向量如何加速许多操作的洞见,这些操作适用于相关表。每个特性都展示了如何通过精心设计的表元数据和事务日志来增强 Delta Lake 的强大功能。
生成列、键和ID
Delta Lake 的一个较少使用的特性是使用生成列(generated columns)在 Spark 中动态创建列值。简单来说,生成列允许你在表定义中添加简单的语句,这些语句会在应用时创建列的值,而不是依赖于插入新数据时为这些列插入值。生成列的使用可以有多种形式,从身份列(identity column)到执行简单转换的列。
注意
本章中的所有示例和一些其他辅助代码可以在本书的 GitHub 仓库中找到。
你可以在表定义中包括两种生成表达式,允许你控制列值是始终生成还是默认生成。始终生成的列不能被覆盖,而对于默认生成的列,你可以在插入操作中指定值。通常,选择始终生成列较为简单,但在某些情况下,你可能希望能够显式地覆盖生成的值。例如,假设你想在每个月初将键的起始值递增到下一个千位或百万位;此时,你可以使用“生成默认”选项,这样你就可以手动设置该初始的月度事务。无论如何,如果你想生成列,就需要在最初的表定义中添加生成表达式。以下示例演示了如何使用 Spark SQL 函数从传入的日期列中提取年份并将其作为列生成。这也可以用于类型转换列,甚至从输入列创建更复杂的数据结构,如结构体(struct):
-- SQL
CREATE TABLE IF NOT EXISTS summary_cases(
state STRING,
fips INT,
cases INT,
deaths INT,
county STRING,
year INT GENERATED ALWAYS AS (YEAR(date))
)
USING DELTA
生成列最常见的应用之一是创建身份列(identity column)或替代键列(surrogate key column)。过去,你可以通过其他方法来实现这一点,例如利用外部库创建 UUID 或使用哈希方法生成唯一键。Delta Lake 相比这些方法有一些优势。通过将生成列的能力直接嵌入格式的基础中,你可以避免许多以前方法中的非确定性问题,并获得更简洁、易读的 ID 列,而不是哈希方法的结果。
定义身份列实际上只是生成表达式的一个小扩展,除了不需要执行任何变换的 SQL 语句外,使用 IDENTITY 关键字会触发一些后台操作,使其生效。这样,你得到的本质上是一个自动跟踪的列,保持身份列的增量特性:
-- SQL
id BIGINT GENERATED BY DEFAULT AS IDENTITY
-- SQL
id BIGINT GENERATED ALWAYS AS IDENTITY
这些身份列作为替代键(surrogate keys)可以在下游应用中创建主键(primary key)和外键(foreign key)关系,或者甚至用于慢变维(SCD)类型的表。需要注意的是,Databricks 提供了一项功能,可以通过 Unity Catalog 强制执行这些主键和外键关系。
在实现层面,Delta Lake 协议定义中有一些值得注意的细节。最重要的一个是,每当禁止覆盖值时,简单的单调函数将生成列的值。这意味着你可以放心,值的生成是一个高效的操作,主要依赖于表元数据和简单的整数数学。
有几个细节你需要注意。首先,当使用 ALWAYS 生成列时,表会应用一个约束(稍后我们会详细讨论约束)。这意味着,如果你尝试在插入操作时为生成列提供值,事务将报错。其次,使用生成列会有一些使用限制;例如,你不能根据生成的身份列来分区表,也不允许并发事务。最后,针对身份列,必须使用 BIGINT 类型,而其他生成列则根据你的实际应用,类型定义更为灵活。
注释和约束
Delta Lake 的元数据帮助描述表的更多细节,或者提供更多细粒度的信息,这些信息是通常不可用的。本节重点介绍两种特定的元数据组件——注释和约束,它们分别有不同的用途。表的创建者和维护者通常使用第一种类型,即表注释。你可以使用这些注释为列或表的数据提供更丰富的上下文。精明的用户和数据消费者可以通过不必逆向工程功能,快速获取更多附加信息,节省大量时间。第二种元数据类型更具操作性。约束在许多应用中较少见,但它们在提高数据表质量和比其他方法更早地检测异常方面,发挥着巨大的作用。
注意
标签(Tags)是包含有关事务操作的附加元数据的映射对象。它们是添加或删除文件、删除向量文件(deletion vector files)和 CDC 文件中的可选字段。在使用 checkpoint V2 时,checkpoint 和相关的 sidecar 文件也可以有标签。请注意,checkpoint 中的移除操作是墓碑,仅供 VACUUM 使用,不包含 stats 字段或 tags 字段。这些主要用于实现层面,支持或为特定的 Delta Lake 实现添加新功能。标签的一个常见用途是标注不同处理引擎中的表属性。这里不深入探讨标签的使用,因为大多数用户不会使用它们,但我们提到它们是因为它们与目录中使用的标签有所不同。
注释
注释应当经常并且合理地使用。你可能希望为不同的目的添加多种类型的注释,它们可以传达关于所有权或列设计的重要信息。注释的建设性类型包括但不限于以下几种:
指导性注释
有时在创建不同的数据集时,我们会做出一些布局上的决策,这些决策可能对最终用户来说并不透明。例如,如果一个表没有唯一的键列,但需要通过组合多个列来实现唯一键,那么我们可能希望在这些列的注释中记录哪些列可以组合在一起形成唯一键。
解释性注释
在某些情况下,注释可能有助于标注列中数据的来源、数据的安全级别、预期用户或计算字段的推导信息。当 Delta Lake 用于不自动捕获血缘信息(lineage)的环境时,标注数据来源尤为重要。所有这些都可以为数据消费者提供丰富的信息,增加数据产品的附加值。对于包含非标准关键绩效指标(KPI)的表格,参照设计文档时,注释尤为有用。
你在注释中包含的内容最终由你和你的组织决定。我们建议你制定一个标准定义并始终遵守它,因为附加信息可能带来的许多好处可以极大提升用户体验。
注意
我们强烈推荐使用如 Unity Catalog 之类的目录,它支持表级注释(以及理想情况下的标签)和其他功能,如血缘跟踪。下游的表消费者,尤其是在多个系统中访问表时,可能会受益于有关表维护者的信息或联系方式,以防出现问题。很多情况下,表注释中的信息如果复制到此表级别,会更加方便和有用。
以下是一个快速示例,展示了如何在创建表时轻松为列添加注释。这允许你为表中所有列同时提供说明或解释性注释,只需将注释作为表模式定义的一部分:
-- SQL
CREATE TABLE example_table (
id INT COMMENT 'uid column',
content STRING COMMENT 'payload column for text'
)
USING delta
有时,初始的注释可能不如预期清晰。在这种情况下,你可以更新单个列的注释以进行精细调整。这也使你能够灵活地在表创建时无法获取的情况下,补充更多信息:
-- SQL
ALTER TABLE example_table
ALTER COLUMN id
COMMENT 'unique id column'
最后,在许多情况下,添加事务注释到表更改中可能会非常有用。你可以在单个操作期间将此选项作为表选项进行设置,在使用 Python API 时设置它,或在 SQL 会话中设置,并在会话期间多次重新使用,直到你完成这些更新为止。
当使用 Python API 作为表选项时,你只需设置 userMetadata 选项来加入自定义的元数据:
# Python
(spark
.read
.table(<source>)
.write
.format("delta")
.option("path", <destination>)
.option("userMetadata", "custom commit metadata for the creation operation")
.save()
)
在 SQL 中设置相同的选项,如前所述,它将在会话级别生效。这意味着,如果你不希望信息持续存在,你需要记得更新它:
-- SQL
SET spark.databricks.delta.commitInfo.userMetadata='comment here'
例如,当你运行多个删除、更新或其他操作时,可能希望标注它们属于同一组操作。你也可以将 userMetadata 重置为 NULL,以返回到默认行为。
Delta 表约束
Delta Lake 的表元数据不仅用于提供有关表的附加信息。在某些情况下,元数据还可以创建额外的操作,帮助为你的数据资产提供保障和保证。你已经在第三章中看到过表版本如何存储在元数据中,以及它们如何允许你通过时间旅行查看表的先前版本,而不必回滚操作。另一种可以触发操作的元数据类型是,当你在表上添加约束时。
注意
使用写入版本 7 及以上的表需要在 writerFeatures 中包含 checkConstraints 特性。版本 3 到 6 始终支持 CHECK 约束。
CHECK 约束与 userMetadata 和列注释一样,存储在表的元数据中,作为键值对对象。你可以通过属性 delta.constraints.<name> 来查看表中特定约束的值。该值作为 SQL 表达式存储,并返回一个布尔值。由于这种表达式的性质,表达式中指定的列必须在表中存在。表中的所有行必须满足约束表达式,且在评估时返回 true。
当你向表中添加约束时,它会检查现有的表数据以确保其合规。如果不合规,ALTER TABLE 执行将失败。同样,在向表中写入数据后,所有行必须满足约束表达式,否则写入操作将失败。这可以帮助你避免将格式错误或不合规的数据写入表中。
此功能最常见的用途之一是通过添加 NOT NULL 参数来确保某些列始终被填充。
在创建表时,你可以将约束作为列参数的一部分添加,类似于列注释:
-- SQL
CREATE TABLE IF NOT EXISTS example_table (
id INT COMMENT 'uid column' NOT NULL,
content STRING COMMENT 'payload column for text'
)
USING delta
这里需要注意的是,只有通过 ALTER TABLE 命令添加的 CHECK 约束才会在表元数据中表示,但你可以放心,创建时设置的 NOT NULL 约束也会生效。通过 ALTER TABLE 设置约束也相对简单。以下是一个例子,确保 id 列的值为非负数:
-- SQL
ALTER TABLE example_table
ADD CONSTRAINT id CHECK (id > 0)
无论你选择哪种方式设置约束,它们都是提高数据质量、增强对数据平台信心的有效方法。
删除向量
有时我们可以从不同角度思考一个问题,并找到不同的解决方案。Delta Lake 中的一个特性,称为删除向量(deletion vectors),就是这个思路的一个很好的例子。第 10 章介绍了优化表读取者或表写入者的几种方式,以及在这个过程中可能需要做出的权衡。虽然删除向量无疑在这个讨论中占有一席之地,但它们也应当作为 Delta Lake 中的高级特性进行专门探讨,因此在这里进行了详细介绍。之所以这样做,是因为它们的工作方式引入了一个新的概念,需要稍作解释。另一个原因是,“删除向量”这个术语更侧重于该过程的形式和功能,而不是它如何作为一个特性帮助你。删除向量的一个关键好处是,它使得你可以执行“读取时合并”(Merge-on-Read,MoR)操作。它显著减少了执行简单删除操作的性能影响,而是将这些操作推迟到未来一个更合适的时间,以批处理的方式进行。
读取时合并(Merge-on-Read)
那么,读取时合并是什么意思呢?它意味着,在删除特定文件中的记录或一组记录时,不需要立即重写文件,而是做一些标记,指明哪些记录已被删除。因此,你可以推迟实际执行删除操作的性能影响,直到稍后的某个时间。通常,当你能够执行优化操作(OPTIMIZE)或更复杂的更新操作(UPDATE)时,你会选择这样做。对于列式文件(例如 Parquet、Delta、Iceberg 等),行级删除会引发相对昂贵的重写操作,这些操作涉及整个包含这些行的文件。当然,如果在执行“读取时合并”操作后,有人读取该表,那么在读取操作期间,它会合并这些记录。这样做的目的是为了最小化执行简单删除操作的性能影响,而是在稍后的时间进行删除操作,那时你已经在读取时过滤了相同的文件集。对于其他情况,你可以避免在不需要立即执行删除的情况下进行删除操作。这使得你能够将多个(或许是许多)删除操作推迟到一个稍后的时间,合并成一个大的批处理操作。
删除向量就是实现这种“读取时合并”行为的一种方式。简单来说,删除向量就是一个或多个文件,这些文件与数据文件相邻,允许你知道哪些记录需要从数据文件中删除,并将删除(重写)操作推迟到一个更高效和方便的时间点。这里的“相邻”是相对的:删除向量文件是 Delta Lake 表组成的文件集合的一部分,但在分区表中,你会注意到删除向量文件位于顶级目录,而不是分区目录内。在接下来的例子中,你可以看到这一点。
注意
我们可能会将删除向量文件称为“侧车文件”(sidecar file),因为它是与表中其他文件并列的一个文件。然而,在 Delta Lake 中,我们希望将其与 V2 检查点规范中正式组成部分的侧车文件区分开,后者用于指定添加或移除文件操作。
对于大多数优化 Delta Lake 写入操作性能的情况,删除向量提供了一个独特的机会,可以减少写入操作的延迟,因为它们避免了重写文件的操作,而这些文件在没有数据变化的情况下本来不需要重写。虽然这样做会带来一些额外的过滤操作,尤其是在后续读取操作时,但总体上,性能影响并不是很大。
要读取带有删除向量的表,你必须使用至少版本为 3 的读取客户端。这可能会产生潜在的冲突。如果你使用的环境中有较旧的客户端,版本较低,可能会导致这些表无法从该环境中访问。写入只需要写入版本为 7。例如,在 Databricks 中,你需要使用 14 或更高版本的 Databricks Runtime(DBR)来写入删除向量,但只需要 12.1 或更高版本的 DBR 才能读取它们。删除向量仅在通过 enableDeletionVectors 表属性启用时才有效。
设置此属性非常简单,只需要执行以下 ALTER TABLE 命令:
-- SQL
ALTER TABLE tblName
SET TBLPROPERTIES ('delta.enableDeletionVectors' = true);
逐步分析删除向量
在本节中,我们将通过一个扩展的例子,突出使用删除向量时发生的文件级变化。你将看到整个书籍中常用的 covid_nyt 数据集,但为了特别突出删除向量的行为,数据集的大小已缩小,并按特定方式进行了分区。为了帮助你理解流程,我们将展示以下步骤:
- 创建一个表并识别需要删除的特定值。
- 启用删除向量。
- 对表执行删除操作,并在每次操作后检查文件结构。
这个例子应该能帮助你理解删除向量的工作方式。值得一提的是,原始表的创建不需要在支持删除向量的环境中进行,但一旦启用了该特性,读写操作将受到上述版本限制的约束。
首先,创建一个缩小大小的表;这样更容易同时查看所有文件:
from pyspark.sql.functions import col
(
spark
.read
.load("rs/data/COVID-19_NYT/")
.filter(col("state") == "Florida")
.filter(col("county").isin(['Hillsborough', 'Pasco', 'Pinellas', 'Sarasota']))
.repartition("county")
.write
.format("delta")
.partitionBy("county")
.option("path", "nyt_covid_19/")
.save()
)
(
spark
.read
.load("nyt_covid_19/")
.write
.mode("overwrite")
.format("delta")
.saveAsTable("nyt")
)
接下来,识别一个记录作为删除目标(对于分区级删除操作,你可以使用任何分区值):
spark.sql("""
select
date,
county,
state,
count(1) as rec_count
from
nyt
where
county = "Pinellas"
and date = "2020-03-11"
group by
date, county, state
order by
date
""").show()
输出:
date county state rec_count
2020-03-11 Pinellas Florida 1
现在,启用表上的删除向量:
spark.sql("""
ALTER TABLE nyt SET TBLPROPERTIES ('delta.enableDeletionVectors' = true);
""")
使用文件树浏览器,验证表结构,在进行任何更改之前检查表的文件结构。由于表数据按 county 分区,你将看到四个结果分区目录。此外,当启用了删除向量操作时,表版本会递增,并在 _delta_log 子目录中添加一个事务记录。这可以提供跨表事务的可追溯性,这在后续出现问题时很有帮助:
!tree spark-warehouse/nyt/
spark-warehouse/nyt/
├── county=Hillsborough
│ └── part-00000-6cf1fac7-1237-48b5-a7ca-ce824054a997.c000.snappy.parquet
├── county=Pasco
│ └── part-00003-dc22f540-c7f7-449c-8dc1-816f0f357075.c000.snappy.parquet
├── county=Pinellas
│ └── part-00001-42060e31-83e8-48d2-9174-02325ca5e686.c000.snappy.parquet
├── county=Sarasota
│ └── part-00002-dfb35d92-25bc-4caf-8aa0-1228143444a7.c000.snappy.parquet
└── _delta_log
├── 00000000000000000000.json
└── 00000000000000000001.json
对之前识别的记录执行单个删除操作:
spark.sql("""
delete from
nyt
where
county = 'Pinellas'
and date = '2020-03-11'
""").show()
检查结果,注意表中的所有原始文件仍然存在。唯一增加的是一个新的文件,它位于表的顶级目录外,而不是分区目录内。这个文件就是来自删除操作的删除向量文件:
!tree spark-warehouse/nyt/
spark-warehouse/nyt/
├── county=Hillsborough
│ └── part-00000-6cf1fac7-1237-48b5-a7ca-ce824054a997.c000.snappy.parquet
├── county=Pasco
│ └── part-00003-dc22f540-c7f7-449c-8dc1-816f0f357075.c000.snappy.parquet
├── county=Pinellas
│ └── part-00001-42060e31-83e8-48d2-9174-02325ca5e686.c000.snappy.parquet
├── county=Sarasota
│ └── part-00002-dfb35d92-25bc-4caf-8aa0-1228143444a7.c000.snappy.parquet
├── deletion_vector_7de8988e-d96d-447c-9f99-1428e354907a.bin
└── _delta_log
├── 00000000000000000000.json
├── 00000000000000000001.json
└── 00000000000000000002.json
现在,执行两个删除操作——一个对齐到分区,另一个跨多个分区:
spark.sql("""
delete
from
nyt
where
county = 'Pasco' # 这是整个分区
""").show()
spark.sql("""
delete
from
nyt
where
date = '2020-03-13' # 这涉及多个分区
""").show()
再次检查文件。注意,这次只出现了一个新的删除向量:
!tree spark-warehouse/nyt/
spark-warehouse/nyt/
├── county=Hillsborough
│ └── part-00000-6cf1fac7-1237-48b5-a7ca-ce824054a997.c000.snappy.parquet
├── county=Pasco
│ └── part-00003-dc22f540-c7f7-449c-8dc1-816f0f357075.c000.snappy.parquet
├── county=Pinellas
│ └── part-00001-42060e31-83e8-48d2-9174-02325ca5e686.c000.snappy.parquet
├── county=Sarasota
│ └── part-00002-dfb35d92-25bc-4caf-8aa0-1228143444a7.c000.snappy.parquet
├── deletion_vector_7de8988e-d96d-447c-9f99-1428e354907a.bin
├── deletion_vector_eda97b62-a3df-4b8f-885d-68295f324c2d.bin
└── _delta_log
├── 00000000000000000000.json
├── 00000000000000000001.json
├── 00000000000000000002.json
├── 00000000000000000003.json
└── 00000000000000000004.json
那么发生了什么呢?答案相当简单,如果你查看事务日志中的 operationMetrics,就能部分揭示其中的过程。在第一次删除操作中,你会看到 numDeletionVectorsAdded: "1",这对应于删除的记录数量(numDeletedRows: "1"),因为它位于一个单独的分区文件中。第三次删除操作则显示 numDeletionVectorsAdded: "3",这直接对应于删除的记录数(numDeletedRows: "3")。然而,还会出现两个额外的条目:numDeletionVectorsRemoved: "1" 和 numDeletionVectorsUpdated: "1"。Delta Lake 会在应用于相同分区时,将删除向量压缩成一个文件。你可能会问:为什么第二次删除操作没有生成新文件?因为这次操作与整个分区的边界对齐,Delta Lake 直接删除了事务日志中显示为 numRemovedFiles: "1" 的文件。原始删除向量现在成为过期文件,并且新删除向量中的字节级信息也包含了旧的信息。额外的数据文件和原始删除向量文件仍然会遵循常规的保留规则,因此,直到你运行 vacuum 操作,它们仍然会与活动表文件并排存在。
这些删除向量的架构相对简单。删除向量会指定应用的存储类型、路径或内联规范(根据指定的存储类型),如果适用,还会包含偏移量(根据存储类型),以及大小(如果适用,亦取决于存储类型),并记录删除操作的基数。这些规范也会作为后续受影响操作的一部分出现在事务日志中,例如,在删除向量存在的情况下执行的添加操作。由于这些操作通常在引擎层面实现,因此不需要进一步探讨。不过,如果你有兴趣并想了解更多关于架构定义的细节,可以查看协议文档中的“删除向量描述符架构”部分。
总结
Delta Lake 的高级功能,如生成列、约束、注释和删除向量,虽然实现起来相对简单,但却能带来巨大的影响。这些功能提升了数据质量、提供了更丰富的元数据,并优化了删除相关操作的性能。
- 生成列:允许基于表达式动态创建列值,从而减少了数据加载的工作量。
- 约束(例如 CHECK 约束):强制执行数据质量规则,并更早地发现问题。
- 注释:使用户能够为表和列添加有价值的上下文信息。
- 删除向量:启用了 Merge-on-Read 方法,推迟删除操作对性能的影响,直到更合适的时间(如读取或优化时)。
总体而言,这些 Delta Lake 的高级元数据能力展示了如何通过战略性地使用表元数据和事务日志,增强 Delta Lake 的强大功能,从而提供更高的数据质量标准和更丰富的信息,为数据使用者提供更优质的体验。