Delta Lake - 数据更新的旅程

在《Delta Lake - 数据写入的旅程》文章中,我们已经从源码层面掌握了 Delta Lake 数据写入的实现过程,再结合 Delta Lake 的实战,相信读者应该有比较深入的理解。

数据写入成功后,可能会涉及后续的数据更新,那么 Delta Lake 数据更新是如何实现的呢?笔者将在本章从源码层面和大家一起揭开 Delta Lake 数据更新的神秘面纱。

数据更新的示例

Delta Lake - 数据更新的旅程_第1张图片

以 Scala 编程语言实现,看一下数据更新的例子: 

数据集为 Spark 安装包里面自带的 people.json,数据如下:


     
     
     
     
  1. {"name":"Michael"}

  2. {"name":"Andy", "age":30}

  3. {"name":"Justin", "age":19}

将数据转为 Delta 存储格式:


     
     
     
     
  1. scala> val people = spark.read.json("/spark/datasets/people/")

  2. scala> people.write.format("delta").save("/spark/datasets/delta/")

  3. scala> spark.sql("CREATE TABLE people_tbl USING DELTA LOCATION '/spark/datasets/delta/'")

  4. scala> spark.sql("select * from people_tbl").show()

  5. +----+-------+

  6. | age| name|

  7. +----+-------+

  8. |null|Michael|

  9. | 30| Andy|

  10. | 19| Justin|

  11. +----+-------+

下面我们将 name 为 Michael 的 age 设置为 20:


     
     
     
     
  1. scala> import io.delta.tables._

  2. scala> val deltaTable = DeltaTable.forPath(spark,"/spark/datasets/delta/")

  3. scala> deltaTable.updateExpr(

  4. "name = 'Michael'",

  5. Map("age" -> "'20'")

  6. )

  7. scala> spark.sql("select * from people_tbl").show()

  8. +---+-------+

  9. |age| name|

  10. +---+-------+

  11. | 20|Michael|

  12. | 30| Andy|

  13. | 19| Justin|

  14. +---+-------+

换一个函数,再将 Michael 的 age 更新为 40:


     
     
     
     
  1. scala> deltaTable.update(

  2. col("name") === "Michael",

  3. Map("age" -> lit("40")))

  4. scala> spark.sql("select * from people_tbl").show()

  5. +---+-------+

  6. |age| name|

  7. +---+-------+

  8. | 40|Michael|

  9. | 30| Andy|

  10. | 19| Justin|

  11. +---+-------+

  12. scala> deltaTable.toDF.show()

  13. +---+-------+

  14. |age| name|

  15. +---+-------+

  16. | 40|Michael|

  17. | 30| Andy|

  18. | 19| Justin|

  19. +---+-------+

可以看到 Delta 数据再次进行了更新。

Delta 操作历史记录

可以通过 Delta Lake 的 HISTORY 命令查看操作历史记录(按时间倒序返回):


     
     
     
     
  1. scala> deltaTable.history().show()

  2. +-------+--------------------+------+--------+---------+--------------------+----+--------+---------+-----------+--------------+-------------+

  3. |version| timestamp|userId|userName|operation| operationParameters| job|notebook|clusterId|readVersion|isolationLevel|isBlindAppend|

  4. +-------+--------------------+------+--------+---------+--------------------+----+--------+---------+-----------+--------------+-------------+

  5. | 2|2019-11-21 16:38:...| null| null| UPDATE|[predicate -> (na...|null| null| null| 1| null| false|

  6. | 1|2019-11-21 16:29:...| null| null| UPDATE|[predicate -> (na...|null| null| null| 0| null| false|

  7. | 0|2019-11-21 16:18:...| null| null| WRITE|[mode -> ErrorIfE...|null| null| null| null| null| true|

  8. +-------+--------------------+------+--------+---------+--------------------+----+--------+---------+-----------+--------------+-------------+

可以清楚地看到笔者操作的三次记录,不再细说。

如果只想查看最后一次操作时(显示完整,show 传入 false 参数),执行如下代码:


     
     
     
     
  1. scala> deltaTable.history(1).show(false)

  2. +-------+-----------------------+------+--------+---------+-----------------------------------+----+--------+---------+-----------+--------------+-------------+

  3. |version|timestamp |userId|userName|operation|operationParameters |job |notebook|clusterId|readVersion|isolationLevel|isBlindAppend|

  4. +-------+-----------------------+------+--------+---------+-----------------------------------+----+--------+---------+-----------+--------------+-------------+

  5. |2 |2019-11-21 16:38:22.419|null |null |UPDATE |[predicate -> (name#721 = Michael)]|null|null |null |1 |null |false |

  6. +-------+-----------------------+------+--------+---------+-----------------------------------+----+--------+---------+-----------+--------------+-------------+

Delta 事务日志分析

我们再来看一下上面 Delta 数据更新后,事务日志变化如何:


     
     
     
     
  1. # 为了方便显示,只保留了时间和存储路径

  2. $ hdfs dfs -ls /spark/datasets/delta/_delta_log/

  3. 2019-11-21 16:18 /spark/datasets/delta/_delta_log/00000000000000000000.json

  4. 2019-11-21 16:29 /spark/datasets/delta/_delta_log/00000000000000000001.json

  5. 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,日志记录为:


     
     
     
     
  1. {"commitInfo":{"timestamp":1574325502396,"operation":"UPDATE","operationParameters":{"predicate":"(name#721 = Michael)"},"readVersion":1,"isBlindAppend":false}}

  2. {"remove":{"path":"part-00000-d82a0fac-6296-4d88-a779-594c98c1bdbd-c000.snappy.parquet","deletionTimestamp":1574325501781,"dataChange":true}}

  3. {"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 相关的方法都提取出来,省略注释,自行补全:


     
     
     
     
  1. def update(set: Map[String, Column]): Unit = {

  2. executeUpdate(set, None)

  3. }

  4. def update(set: java.util.Map[String, Column]): Unit = {

  5. executeUpdate(set.asScala, None)

  6. }

  7. def update(condition: Column, set: Map[String, Column]): Unit = {

  8. executeUpdate(set, Some(condition))

  9. }

  10. def update(condition: Column, set: java.util.Map[String, Column]): Unit = {

  11. executeUpdate(set.asScala, Some(condition))

  12. }

  13. def updateExpr(set: Map[String, String]): Unit = {

  14. executeUpdate(toStrColumnMap(set), None)

  15. }

  16. def updateExpr(set: java.util.Map[String, String]): Unit = {

  17. executeUpdate(toStrColumnMap(set.asScala), None)

  18. }

  19. def updateExpr(condition: String, set: Map[String, String]): Unit = {

  20. executeUpdate(toStrColumnMap(set), Some(functions.expr(condition)))

  21. }

  22. def updateExpr(condition: String, set: java.util.Map[String, String]): Unit = {

  23. executeUpdate(toStrColumnMap(set.asScala), Some(functions.expr(condition)))

  24. }

可以看到 Delta Lake 支持 Java 和 Scala 版本,set: java.util.Map 为 Java 版本的 API。另外我们可以看到所有的方法都调用 executeUpdate 方法。

executeUpdate 方法定义在 io.delta.tables.execution 包下面的 DeltaTableOperations trait 中,功能实现如下:


     
     
     
     
  1. protected def executeUpdate(set: Map[String, Column], condition: Option[Column]): Unit = {

  2. val setColumns = set.map{ case (col, expr) => (col, expr) }.toSeq

  3. // 1. 截止本篇文章发布时,还不支持有子查询的更新

  4. executePlan().

  5. subqueryNotSupportedCheck(condition.map {_.expr}, "UPDATE")

  6. // 2. 调用 makeUpdateTable 方法,对更新的 columns 进行调整

  7. val update = makeUpdateTable(self, condition, setColumns)

  8. // 3. 将 Update 更新条件和更新表达式和表进行绑定

  9. val resolvedUpdate =

  10. UpdateTable.resolveReferences(update, tryResolveReferences(sparkSession)(_, update))

  11. // 4. 构造 UpdateCommand

  12. val updateCommand = PreprocessTableUpdate(sparkSession.sessionState.conf)(resolvedUpdate)

  13. // 5. 更新的具体逻辑处理

  14. updateCommand.run(sparkSession)

  15. }

详细内容,请看下文细说:

1. 目前不支持有子查询的更新,这个很好理解,功能还未实现

2. makeUpdateTable 方法实现如下:


     
     
     
     
  1. protected def makeUpdateTable(

  2. target: DeltaTable,

  3. onCondition: Option[Column],

  4. setColumns: Seq[(String, Column)]): UpdateTable = {

  5. // 定义一些规则,比如 ` 符号必须成对出现,所引用的字符串必须是完整的名称部分,比如 `ab..c`e.f 是不允许的。目前还不支持转义字符。另外也会把`x.y` 调整为 x.y。

  6. val updateColumns = setColumns.map { x => UnresolvedAttribute.quotedString(x._1) }

  7. val updateExpressions = setColumns.map{ x => x._2.expr }

  8. val condition = onCondition.map {_.expr}

  9. // 构造 UpdateTable 对象,在表上执行 Update 操作。UpdateTable 是一个 case class,extends UnaryNode,具有单个 child(表示目标表的逻辑计划) 的逻辑计划节点,UpdateTable 对象包括逻辑计划、更新字段、更新表达式以及更新条件。

  10. UpdateTable(

  11. target.toDF.queryExecution.analyzed, updateColumns, updateExpressions, condition)

  12. }

3. resolveReferences 位于 org.apache.spark.sql.catalyst.plans.logical,可知是执行计划部分的逻辑,代码如下,如果深入讲解的话,内容比较多,这一块以后可以单独文章再补充:


     
     
     
     
  1. /** Resolve all the references of target columns and condition using the given `resolver` */

  2. def resolveReferences(update: UpdateTable, resolver: Expression => Expression): UpdateTable = {

  3. // 如果此 expression 及其所有 children 都已解析为特定的 schema,而且输入数据类型检查通过,则返回 true;如果它仍包含任何未解析的占位符或数据类型不匹配,则返回 false

  4. if (update.resolved) return update

  5. assert(update.child.resolved)

  6. val UpdateTable(child, updateColumns, updateExpressions, condition) = update

  7. val cleanedUpAttributes = updateColumns.map { unresolvedExpr =>

  8. // Keep them unresolved but use the cleaned-up name parts from the resolved

  9. val errMsg = s"Failed to resolve ${unresolvedExpr.sql} given columns " +

  10. s"[${child.output.map(_.qualifiedName).mkString(", ")}]."

  11. val resolveNameParts =

  12. UpdateTable.getNameParts(resolver(unresolvedExpr), errMsg, update)

  13. UnresolvedAttribute(resolveNameParts)

  14. }

  15. update.copy(

  16. updateColumns = cleanedUpAttributes,

  17. updateExpressions = updateExpressions.map(resolver),

  18. condition = condition.map(resolver))

  19. }

其实,大概就是将 Update 更新条件和更新表达式和 Delta 表进行绑定,可能有的字段传入不对。另外,会把前面操作的语句:


     
     
     
     
  1. scala> deltaTable.update(

  2. col("name") === "Michael",

  3. Map("age" -> lit("40")))

转变为 (name#721 = Michael) 类似的表达式。

4. updateCommand 包括了更新表的所有列的信息,生成更新目标列的表达式。


     
     
     
     
  1. val updateCommand = PreprocessTableUpdate(sparkSession.sessionState.conf)(resolvedUpdate)

5. 真正执行更新逻辑的代码


     
     
     
     
  1. updateCommand.run(sparkSession)

run 方法的代码如下:


     
     
     
     
  1. final override def run(sparkSession: SparkSession): Seq[Row] = {

  2. // 记录操作的持续时间和成功与否。

  3. recordDeltaOperation(tahoeFileIndex.deltaLog, "delta.dml.update") {

  4. // 获取 delta log,便于更新操作时写入事务日志

  5. val deltaLog = tahoeFileIndex.deltaLog

  6. // 检查表是否只支持 append 追加数据

  7. deltaLog.assertRemovable()

  8. // 这里和我们之前介绍的数据写入类似了

  9. // 开启一个新事务,保证原子性,用于执行 Update 操作

  10. deltaLog.withNewTransaction { txn =>

  11. performUpdate(sparkSession, deltaLog, txn)

  12. }

  13. // 重新缓存所有引用这个数据源关系的缓存计划

  14. sparkSession.sharedState.cacheManager.recacheByPlan(sparkSession, target)

  15. }

  16. Seq.empty[Row]

  17. }

上面代码调用一个核心的方法,用于真正执行更新的操作,performUpdate 方法,代码比较长,具体解说直接看注解描述:


     
     
     
     
  1. private def performUpdate(

  2. sparkSession: SparkSession, deltaLog: DeltaLog, txn: OptimisticTransaction): Unit = {

  3. import sparkSession.implicits._

  4. // 一些统计信息,更新中状态记录

  5. var numTouchedFiles: Long = 0

  6. var numRewrittenFiles: Long = 0

  7. var scanTimeMs: Long = 0

  8. var rewriteTimeMs: Long = 0

  9. // 更新开始时间

  10. val startTime = System.nanoTime()

  11. // 当前版本中文件个数

  12. val numFilesTotal = deltaLog.snapshot.numOfFiles

  13. // 更新条件以及拆分

  14. val updateCondition = condition.getOrElse(Literal(true, BooleanType))

  15. val (metadataPredicates, dataPredicates) =

  16. DeltaTableUtils.splitMetadataAndDataPredicates(

  17. updateCondition, txn.metadata.partitionColumns, sparkSession)

  18. // 挑选出需要更新的候选文件

  19. val candidateFiles = txn.filterFiles(metadataPredicates ++ dataPredicates)

  20. // 生成 Map,添加更新的文件条目

  21. val nameToAddFile = generateCandidateFileMap(deltaLog.dataPath, candidateFiles)

  22. // 记录耗用时间

  23. scanTimeMs = (System.nanoTime() - startTime) / 1000 / 1000

  24. val actions: Seq[Action] = if (candidateFiles.isEmpty) {

  25. // Case 1: 如果更新的表是分区表,更新条件里面有分区字段,但是没有命中表的任何分区,则不需要更新,也不用读取 Delta 表,直接返回

  26. Nil

  27. } else if (dataPredicates.isEmpty) {

  28. // Case 2: 如果更新的表是分区表,更新条件只是分区字段,直接从事务日志 snapshot 里面获取要更新的文件,然后将要更新的文件标记为 RemoveFile

  29. numTouchedFiles = candidateFiles.length

  30. val filesToRewrite = candidateFiles.map(_.path)

  31. val operationTimestamp = System.currentTimeMillis()

  32. val deleteActions = candidateFiles.map(_.removeWithTimestamp(operationTimestamp))

  33. val rewrittenFiles = rewriteFiles(sparkSession, txn, tahoeFileIndex.path,

  34. filesToRewrite, nameToAddFile, updateCondition)

  35. numRewrittenFiles = rewrittenFiles.size

  36. rewriteTimeMs = (System.nanoTime() - startTime) / 1000 / 1000 - scanTimeMs

  37. deleteActions ++ rewrittenFiles

  38. } else {

  39. // Case 3: 如果不是上面两种情况,则首先扫描表,获取满足更新条件的数据所在的文件

  40. val fileIndex = new TahoeBatchFileIndex(

  41. sparkSession, "update", candidateFiles, deltaLog, tahoeFileIndex.path, txn.snapshot)

  42. // Keep everything from the resolved target except a new TahoeFileIndex

  43. // that only involves the affected files instead of all files.

  44. val newTarget = DeltaTableUtils.replaceFileIndex(target, fileIndex)

  45. val data = Dataset.ofRows(sparkSession, newTarget)

  46. val filesToRewrite =

  47. withStatusCode("DELTA", s"Finding files to rewrite for UPDATE operation") {

  48. data.filter(new Column(updateCondition)).select(input_file_name())

  49. .distinct().as[String].collect()

  50. }

  51. scanTimeMs = (System.nanoTime() - startTime) / 1000 / 1000

  52. numTouchedFiles = filesToRewrite.length

  53. if (filesToRewrite.isEmpty) {

  54. // Case 3.1: 如果根据更新条件,没有查询到符合的 Delta 数据文件,则无需更新

  55. Nil

  56. } else {

  57. // Case 3.2: 如果根据查询条件,获取到符合的数据文件,读出这该数据文件里面的数据,然后进行更新,将更新的数据写到新文件,同时将原文件标记为 RemoveFile,方便以后 Vacuum 清除

  58. val operationTimestamp = System.currentTimeMillis()

  59. val deleteActions =

  60. removeFilesFromPaths(deltaLog, nameToAddFile, filesToRewrite, operationTimestamp)

  61. val rewrittenFiles =

  62. withStatusCode("DELTA", s"Rewriting ${filesToRewrite.size} files for UPDATE operation") {

  63. rewriteFiles(sparkSession, txn, tahoeFileIndex.path,

  64. filesToRewrite, nameToAddFile, updateCondition)

  65. }

  66. numRewrittenFiles = rewrittenFiles.size

  67. rewriteTimeMs = (System.nanoTime() - startTime) / 1000 / 1000 - scanTimeMs

  68. deleteActions ++ rewrittenFiles

  69. }

  70. }

  71. if (actions.nonEmpty) {

  72. txn.commit(actions, DeltaOperations.Update(condition.map(_.toString)))

  73. }

  74. // 记录事件发生的相关统计信息。

  75. recordDeltaEvent(

  76. deltaLog,

  77. "delta.dml.update.stats",

  78. data = UpdateMetric(

  79. condition = condition.map(_.sql).getOrElse("true"),

  80. numFilesTotal,

  81. numTouchedFiles,

  82. numRewrittenFiles,

  83. scanTimeMs,

  84. rewriteTimeMs)

  85. )

  86. }

总结

至此,笔者从源码层面分析了 Delta Lake 数据更新的整个流程,大家可以根据源码进行数据更新的调试过程,加深印象。

你可能感兴趣的:(Delta Lake - 数据更新的旅程)