在《Delta Lake - 数据写入的旅程》文章中,我们已经从源码层面掌握了 Delta Lake 数据写入的实现过程,再结合 Delta Lake 的实战,相信读者应该有比较深入的理解。
数据写入成功后,可能会涉及后续的数据更新,那么 Delta Lake 数据更新是如何实现的呢?笔者将在本章从源码层面和大家一起揭开 Delta Lake 数据更新的神秘面纱。
以 Scala 编程语言实现,看一下数据更新的例子:
数据集为 Spark 安装包里面自带的 people.json,数据如下:
{"name":"Michael"}
{"name":"Andy", "age":30}
{"name":"Justin", "age":19}
将数据转为 Delta 存储格式:
scala> val people = spark.read.json("/spark/datasets/people/")
scala> people.write.format("delta").save("/spark/datasets/delta/")
scala> spark.sql("CREATE TABLE people_tbl USING DELTA LOCATION '/spark/datasets/delta/'")
scala> spark.sql("select * from people_tbl").show()
+----+-------+
| age| name|
+----+-------+
|null|Michael|
| 30| Andy|
| 19| Justin|
+----+-------+
下面我们将 name 为 Michael 的 age 设置为 20:
scala> import io.delta.tables._
scala> val deltaTable = DeltaTable.forPath(spark,"/spark/datasets/delta/")
scala> deltaTable.updateExpr(
"name = 'Michael'",
Map("age" -> "'20'")
)
scala> spark.sql("select * from people_tbl").show()
+---+-------+
|age| name|
+---+-------+
| 20|Michael|
| 30| Andy|
| 19| Justin|
+---+-------+
换一个函数,再将 Michael 的 age 更新为 40:
scala> deltaTable.update(
col("name") === "Michael",
Map("age" -> lit("40")))
scala> spark.sql("select * from people_tbl").show()
+---+-------+
|age| name|
+---+-------+
| 40|Michael|
| 30| Andy|
| 19| Justin|
+---+-------+
scala> deltaTable.toDF.show()
+---+-------+
|age| name|
+---+-------+
| 40|Michael|
| 30| Andy|
| 19| Justin|
+---+-------+
可以看到 Delta 数据再次进行了更新。
可以通过 Delta Lake 的 HISTORY 命令查看操作历史记录(按时间倒序返回):
scala> deltaTable.history().show()
+-------+--------------------+------+--------+---------+--------------------+----+--------+---------+-----------+--------------+-------------+
|version| timestamp|userId|userName|operation| operationParameters| job|notebook|clusterId|readVersion|isolationLevel|isBlindAppend|
+-------+--------------------+------+--------+---------+--------------------+----+--------+---------+-----------+--------------+-------------+
| 2|2019-11-21 16:38:...| null| null| UPDATE|[predicate -> (na...|null| null| null| 1| null| false|
| 1|2019-11-21 16:29:...| null| null| UPDATE|[predicate -> (na...|null| null| null| 0| null| false|
| 0|2019-11-21 16:18:...| null| null| WRITE|[mode -> ErrorIfE...|null| null| null| null| null| true|
+-------+--------------------+------+--------+---------+--------------------+----+--------+---------+-----------+--------------+-------------+
可以清楚地看到笔者操作的三次记录,不再细说。
如果只想查看最后一次操作时(显示完整,show 传入 false 参数),执行如下代码:
scala> deltaTable.history(1).show(false)
+-------+-----------------------+------+--------+---------+-----------------------------------+----+--------+---------+-----------+--------------+-------------+
|version|timestamp |userId|userName|operation|operationParameters |job |notebook|clusterId|readVersion|isolationLevel|isBlindAppend|
+-------+-----------------------+------+--------+---------+-----------------------------------+----+--------+---------+-----------+--------------+-------------+
|2 |2019-11-21 16:38:22.419|null |null |UPDATE |[predicate -> (name#721 = Michael)]|null|null |null |1 |null |false |
+-------+-----------------------+------+--------+---------+-----------------------------------+----+--------+---------+-----------+--------------+-------------+
我们再来看一下上面 Delta 数据更新后,事务日志变化如何:
# 为了方便显示,只保留了时间和存储路径
$ hdfs dfs -ls /spark/datasets/delta/_delta_log/
2019-11-21 16:18 /spark/datasets/delta/_delta_log/00000000000000000000.json
2019-11-21 16:29 /spark/datasets/delta/_delta_log/00000000000000000001.json
2019-11-21 16:38 /spark/datasets/delta/_delta_log/00000000000000000002.json
从上面的操作来看,笔者总共进行了三次变更,生成了三个事务日志文件:
1. Json 格式存储为 Delta Lake
2. 更新 name = 'Michael' 的 age = 20
3. 更新 name = 'Michael' 的 age = 40
上面示例中,Delta Lake 的更新指定了更新的条件,类似 where 条件。当然也可以不指定条件,那么则会更新 Delta 整张表。如果被操作的表有满足条件的行数据,Delta Lake 就会更新相关的数据,并在表的 _delta_log
目录下生成一个事务日志。为了更好地理解,我们读取最后一次更新的事务日志 /spark/datasets/delta/_delta_log/00000000000000000002.json
,日志记录为:
{"commitInfo":{"timestamp":1574325502396,"operation":"UPDATE","operationParameters":{"predicate":"(name#721 = Michael)"},"readVersion":1,"isBlindAppend":false}}
{"remove":{"path":"part-00000-d82a0fac-6296-4d88-a779-594c98c1bdbd-c000.snappy.parquet","deletionTimestamp":1574325501781,"dataChange":true}}
{"add":{"path":"part-00000-9c1da674-7c4d-4061-ba3c-0ae3926bd593-c000.snappy.parquet","partitionValues":{},"size":658,"modificationTime":1574325502391,"dataChange":true}}
如果是更新操作,则上面的事务日志记录了 update 时间、update 条件、remove 文件 和 add 文件等信息。
有几点需要补充一下:
1. Delta Lake Update 操作在最新版本中支持 Scala、Java、Python API,不支持 SQL,而在 Databricks Runtime 商业版本中支持 SQL。
2. Update 操作成功后,被 remove 的文件不会被删除,执行 vacuum 命令后真正删除
接下来,进入我们今天的正题,从源码层面去深入理解 Delta Lake Update 操作的来龙去脉。
笔者在上面的实战环节中,使用了两个更新的方法,即 update 和 updateExpr,那我们就从这两个方法入手。
从源码中搜索上面的方法,我们会发现 io.delta.tables 包下面有一个重要的类,DeltaTable,该类是与 Delta 表交互的主类,可以使用该类中的静态方法创建 DeltaTable 实例,比如我们之前用过 DeltaTable.forPath(sparkSession,pathToTheDeltaTable)
,然后进行 update、delete、merge 等操作。笔者认为该类整体上定义很清晰,而且注释非常详细,特别容易理解。
下面笔者把里面的 update 相关的方法都提取出来,省略注释,自行补全:
def update(set: Map[String, Column]): Unit = {
executeUpdate(set, None)
}
def update(set: java.util.Map[String, Column]): Unit = {
executeUpdate(set.asScala, None)
}
def update(condition: Column, set: Map[String, Column]): Unit = {
executeUpdate(set, Some(condition))
}
def update(condition: Column, set: java.util.Map[String, Column]): Unit = {
executeUpdate(set.asScala, Some(condition))
}
def updateExpr(set: Map[String, String]): Unit = {
executeUpdate(toStrColumnMap(set), None)
}
def updateExpr(set: java.util.Map[String, String]): Unit = {
executeUpdate(toStrColumnMap(set.asScala), None)
}
def updateExpr(condition: String, set: Map[String, String]): Unit = {
executeUpdate(toStrColumnMap(set), Some(functions.expr(condition)))
}
def updateExpr(condition: String, set: java.util.Map[String, String]): Unit = {
executeUpdate(toStrColumnMap(set.asScala), Some(functions.expr(condition)))
}
可以看到 Delta Lake 支持 Java 和 Scala 版本,set: java.util.Map 为 Java 版本的 API。另外我们可以看到所有的方法都调用 executeUpdate 方法。
executeUpdate 方法定义在 io.delta.tables.execution 包下面的 DeltaTableOperations trait 中,功能实现如下:
protected def executeUpdate(set: Map[String, Column], condition: Option[Column]): Unit = {
val setColumns = set.map{ case (col, expr) => (col, expr) }.toSeq
// 1. 截止本篇文章发布时,还不支持有子查询的更新
executePlan().
subqueryNotSupportedCheck(condition.map {_.expr}, "UPDATE")
// 2. 调用 makeUpdateTable 方法,对更新的 columns 进行调整
val update = makeUpdateTable(self, condition, setColumns)
// 3. 将 Update 更新条件和更新表达式和表进行绑定
val resolvedUpdate =
UpdateTable.resolveReferences(update, tryResolveReferences(sparkSession)(_, update))
// 4. 构造 UpdateCommand
val updateCommand = PreprocessTableUpdate(sparkSession.sessionState.conf)(resolvedUpdate)
// 5. 更新的具体逻辑处理
updateCommand.run(sparkSession)
}
详细内容,请看下文细说:
1. 目前不支持有子查询的更新,这个很好理解,功能还未实现
2. makeUpdateTable 方法实现如下:
protected def makeUpdateTable(
target: DeltaTable,
onCondition: Option[Column],
setColumns: Seq[(String, Column)]): UpdateTable = {
// 定义一些规则,比如 ` 符号必须成对出现,所引用的字符串必须是完整的名称部分,比如 `ab..c`e.f 是不允许的。目前还不支持转义字符。另外也会把`x.y` 调整为 x.y。
val updateColumns = setColumns.map { x => UnresolvedAttribute.quotedString(x._1) }
val updateExpressions = setColumns.map{ x => x._2.expr }
val condition = onCondition.map {_.expr}
// 构造 UpdateTable 对象,在表上执行 Update 操作。UpdateTable 是一个 case class,extends UnaryNode,具有单个 child(表示目标表的逻辑计划) 的逻辑计划节点,UpdateTable 对象包括逻辑计划、更新字段、更新表达式以及更新条件。
UpdateTable(
target.toDF.queryExecution.analyzed, updateColumns, updateExpressions, condition)
}
3. resolveReferences 位于 org.apache.spark.sql.catalyst.plans.logical,可知是执行计划部分的逻辑,代码如下,如果深入讲解的话,内容比较多,这一块以后可以单独文章再补充:
/** Resolve all the references of target columns and condition using the given `resolver` */
def resolveReferences(update: UpdateTable, resolver: Expression => Expression): UpdateTable = {
// 如果此 expression 及其所有 children 都已解析为特定的 schema,而且输入数据类型检查通过,则返回 true;如果它仍包含任何未解析的占位符或数据类型不匹配,则返回 false
if (update.resolved) return update
assert(update.child.resolved)
val UpdateTable(child, updateColumns, updateExpressions, condition) = update
val cleanedUpAttributes = updateColumns.map { unresolvedExpr =>
// Keep them unresolved but use the cleaned-up name parts from the resolved
val errMsg = s"Failed to resolve ${unresolvedExpr.sql} given columns " +
s"[${child.output.map(_.qualifiedName).mkString(", ")}]."
val resolveNameParts =
UpdateTable.getNameParts(resolver(unresolvedExpr), errMsg, update)
UnresolvedAttribute(resolveNameParts)
}
update.copy(
updateColumns = cleanedUpAttributes,
updateExpressions = updateExpressions.map(resolver),
condition = condition.map(resolver))
}
其实,大概就是将 Update 更新条件和更新表达式和 Delta 表进行绑定,可能有的字段传入不对。另外,会把前面操作的语句:
scala> deltaTable.update(
col("name") === "Michael",
Map("age" -> lit("40")))
转变为 (name#721 = Michael) 类似的表达式。
4. updateCommand 包括了更新表的所有列的信息,生成更新目标列的表达式。
val updateCommand = PreprocessTableUpdate(sparkSession.sessionState.conf)(resolvedUpdate)
5. 真正执行更新逻辑的代码
updateCommand.run(sparkSession)
run 方法的代码如下:
final override def run(sparkSession: SparkSession): Seq[Row] = {
// 记录操作的持续时间和成功与否。
recordDeltaOperation(tahoeFileIndex.deltaLog, "delta.dml.update") {
// 获取 delta log,便于更新操作时写入事务日志
val deltaLog = tahoeFileIndex.deltaLog
// 检查表是否只支持 append 追加数据
deltaLog.assertRemovable()
// 这里和我们之前介绍的数据写入类似了
// 开启一个新事务,保证原子性,用于执行 Update 操作
deltaLog.withNewTransaction { txn =>
performUpdate(sparkSession, deltaLog, txn)
}
// 重新缓存所有引用这个数据源关系的缓存计划
sparkSession.sharedState.cacheManager.recacheByPlan(sparkSession, target)
}
Seq.empty[Row]
}
上面代码调用一个核心的方法,用于真正执行更新的操作,performUpdate 方法,代码比较长,具体解说直接看注解描述:
private def performUpdate(
sparkSession: SparkSession, deltaLog: DeltaLog, txn: OptimisticTransaction): Unit = {
import sparkSession.implicits._
// 一些统计信息,更新中状态记录
var numTouchedFiles: Long = 0
var numRewrittenFiles: Long = 0
var scanTimeMs: Long = 0
var rewriteTimeMs: Long = 0
// 更新开始时间
val startTime = System.nanoTime()
// 当前版本中文件个数
val numFilesTotal = deltaLog.snapshot.numOfFiles
// 更新条件以及拆分
val updateCondition = condition.getOrElse(Literal(true, BooleanType))
val (metadataPredicates, dataPredicates) =
DeltaTableUtils.splitMetadataAndDataPredicates(
updateCondition, txn.metadata.partitionColumns, sparkSession)
// 挑选出需要更新的候选文件
val candidateFiles = txn.filterFiles(metadataPredicates ++ dataPredicates)
// 生成 Map,添加更新的文件条目
val nameToAddFile = generateCandidateFileMap(deltaLog.dataPath, candidateFiles)
// 记录耗用时间
scanTimeMs = (System.nanoTime() - startTime) / 1000 / 1000
val actions: Seq[Action] = if (candidateFiles.isEmpty) {
// Case 1: 如果更新的表是分区表,更新条件里面有分区字段,但是没有命中表的任何分区,则不需要更新,也不用读取 Delta 表,直接返回
Nil
} else if (dataPredicates.isEmpty) {
// Case 2: 如果更新的表是分区表,更新条件只是分区字段,直接从事务日志 snapshot 里面获取要更新的文件,然后将要更新的文件标记为 RemoveFile
numTouchedFiles = candidateFiles.length
val filesToRewrite = candidateFiles.map(_.path)
val operationTimestamp = System.currentTimeMillis()
val deleteActions = candidateFiles.map(_.removeWithTimestamp(operationTimestamp))
val rewrittenFiles = rewriteFiles(sparkSession, txn, tahoeFileIndex.path,
filesToRewrite, nameToAddFile, updateCondition)
numRewrittenFiles = rewrittenFiles.size
rewriteTimeMs = (System.nanoTime() - startTime) / 1000 / 1000 - scanTimeMs
deleteActions ++ rewrittenFiles
} else {
// Case 3: 如果不是上面两种情况,则首先扫描表,获取满足更新条件的数据所在的文件
val fileIndex = new TahoeBatchFileIndex(
sparkSession, "update", candidateFiles, deltaLog, tahoeFileIndex.path, txn.snapshot)
// Keep everything from the resolved target except a new TahoeFileIndex
// that only involves the affected files instead of all files.
val newTarget = DeltaTableUtils.replaceFileIndex(target, fileIndex)
val data = Dataset.ofRows(sparkSession, newTarget)
val filesToRewrite =
withStatusCode("DELTA", s"Finding files to rewrite for UPDATE operation") {
data.filter(new Column(updateCondition)).select(input_file_name())
.distinct().as[String].collect()
}
scanTimeMs = (System.nanoTime() - startTime) / 1000 / 1000
numTouchedFiles = filesToRewrite.length
if (filesToRewrite.isEmpty) {
// Case 3.1: 如果根据更新条件,没有查询到符合的 Delta 数据文件,则无需更新
Nil
} else {
// Case 3.2: 如果根据查询条件,获取到符合的数据文件,读出这该数据文件里面的数据,然后进行更新,将更新的数据写到新文件,同时将原文件标记为 RemoveFile,方便以后 Vacuum 清除
val operationTimestamp = System.currentTimeMillis()
val deleteActions =
removeFilesFromPaths(deltaLog, nameToAddFile, filesToRewrite, operationTimestamp)
val rewrittenFiles =
withStatusCode("DELTA", s"Rewriting ${filesToRewrite.size} files for UPDATE operation") {
rewriteFiles(sparkSession, txn, tahoeFileIndex.path,
filesToRewrite, nameToAddFile, updateCondition)
}
numRewrittenFiles = rewrittenFiles.size
rewriteTimeMs = (System.nanoTime() - startTime) / 1000 / 1000 - scanTimeMs
deleteActions ++ rewrittenFiles
}
}
if (actions.nonEmpty) {
txn.commit(actions, DeltaOperations.Update(condition.map(_.toString)))
}
// 记录事件发生的相关统计信息。
recordDeltaEvent(
deltaLog,
"delta.dml.update.stats",
data = UpdateMetric(
condition = condition.map(_.sql).getOrElse("true"),
numFilesTotal,
numTouchedFiles,
numRewrittenFiles,
scanTimeMs,
rewriteTimeMs)
)
}
至此,笔者从源码层面分析了 Delta Lake 数据更新的整个流程,大家可以根据源码进行数据更新的调试过程,加深印象。