前言
今天花了一早上以及午休時間,終於把delta的Upsert功能做完了。加上上週週四做的Delta Compaction支持,我想要的功能基本就都有了。
Delta的核心是DeltaLog,其實就是元數據管理。通過該套元數據管理,我們可以很容易的將Compaction,Update,Upsert,Delete等功能加上,因爲本質上就是調用元數據管理API完成數據最後的提交。
代碼使用方式
Upsert支持流式和批的方式進行更新。因爲受限於Spark的SQL解析,大家可以使用Dataframe 或者 MLSQL的方式進行調用。
批使用方式:
val log = DeltaLog.forTable(spark, outputDir.getCanonicalPath)
val upsertTableInDelta = UpsertTableInDelta(data, Option(SaveMode.Append), None, log,
new DeltaOptions(Map[String, String](), df.sparkSession.sessionState.conf),
Seq(),
Map("idCols" -> "key,value"))
val items = upsertTableInDelta.run(df.sparkSession)
唯一需要大家指定的就是 idCols, 也就是你的表的唯一主鍵組合是啥。比如我這裏是key,value兩個字段組成唯一主鍵。
流使用技巧是一模一樣的,只需要做一點點修改:
UpsertTableInDelta(data, None, Option(OutputMode.Append())
UpsertTableInDelta 根據你設置的是SaveMode還是OutputMode來看是不是流寫入。
MLSQL 使用方式
寫入數據到Kafka:
set abc='''
{ "x": 100, "y": 201, "z": 204 ,"dataType":"A group"}
''';
load jsonStr.`abc` as table1;
select to_json(struct(*)) as value from table1 as table2;
save append table2 as kafka.`wow` where
kafka.bootstrap.servers="127.0.0.1:9092";
使用流程序消費Kafka:
-- the stream name, should be uniq.
set streamName="kafkaStreamExample";
!kafkaTool registerSchema 2 records from "127.0.0.1:9092" wow;
-- convert table as stream source
load kafka.`wow` options
kafka.bootstrap.servers="127.0.0.1:9092"
and failOnDataLoss="false"
as newkafkatable1;
-- aggregation
select * from newkafkatable1
as table21;
-- output the the result to console.
save append table21
as rate.`/tmp/delta/wow-0`
options mode="Append"
and idCols="x,y"
and duration="5"
and checkpointLocation="/tmp/s-cpl6";
同樣的,我們設置了idCols,指定x,y爲唯一主鍵。
然後查看對應的記錄變化:
load delta.`/tmp/delta/wow-0` as show_table1;
select * from show_table1 where x=100 and z=204 as output;
你會驚喜的發現數據可以更新了。
實現剖析
一共涉及到三個新文件:
org.apache.spark.sql.delta.commands.UpsertTableInDelta
org.apache.spark.sql.delta.sources.MLSQLDeltaDataSource
org.apache.spark.sql.delta.sources.MLSQLDeltaSink
對應源碼參看我fork的delta項目: mlsql-delta
第一個文件是實現核心的更新邏輯。第二個第三個支持Spark的datasource API來進行批和流的寫入。
這篇文章我們主要介紹UpsertTableInDelta。
case class UpsertTableInDelta(_data: Dataset[_],
saveMode: Option[SaveMode],
outputMode: Option[OutputMode],
deltaLog: DeltaLog,
options: DeltaOptions,
partitionColumns: Seq[String],
configuration: Map[String, String]
) extends RunnableCommand
with ImplicitMetadataOperation
with DeltaCommand with DeltaCommandsFun {
UpsertTableInDelta 集成了delta一些必要的基礎類,ImplicitMetadataOperation,DeltaCommand,主要是爲了方便得到一些操作日誌寫入的方法。
saveMode 和 outputMode 主要是爲了方便區分現在是流在寫,還是批在寫,以及寫的模式是什麼。
assert(configuration.contains(UpsertTableInDelta.ID_COLS), "idCols is required ")
if (outputMode.isDefined) {
assert(outputMode.get == OutputMode.Append(), "append is required ")
}
if (saveMode.isDefined) {
assert(saveMode.get == SaveMode.Append, "append is required ")
}
限制條件是必須都是用Append模式,並且idCols是必須存在的。
saveMode match {
case Some(mode) =>
deltaLog.withNewTransaction { txn =>
actions = upsert(txn, sparkSession)
val operation = DeltaOperations.Write(SaveMode.Overwrite,
Option(partitionColumns),
options.replaceWhere)
txn.commit(actions, operation)
}
case None => outputMode match {
如果是批寫入,那麼直接調用deltaLog開啓一個新的事物,然後進行upsert操作。同時進行commit,然後就搞定了。
如果是流寫入則麻煩一點,
case None => outputMode match {
case Some(mode) =>
val queryId = sparkSession.sparkContext.getLocalProperty(StreamExecution.QUERY_ID_KEY)
assert(queryId != null)
if (SchemaUtils.typeExistsRecursively(_data.schema)(_.isInstanceOf[NullType])) {
throw DeltaErrors.streamWriteNullTypeException
}
val txn = deltaLog.startTransaction()
// Streaming sinks can't blindly overwrite schema.
// See Schema Management design doc for details
updateMetadata(
txn,
_data,
partitionColumns,
configuration = Map.empty,
false)
val currentVersion = txn.txnVersion(queryId)
val batchId = configuration(UpsertTableInDelta.BATCH_ID).toLong
if (currentVersion >= batchId) {
logInfo(s"Skipping already complete epoch $batchId, in query $queryId")
} else {
actions = upsert(txn, sparkSession)
val setTxn = SetTransaction(queryId,
batchId, Some(deltaLog.clock.getTimeMillis())) :: Nil
val info = DeltaOperations.StreamingUpdate(outputMode.get, queryId, batchId)
txn.commit(setTxn ++ actions, info)
}
}
}
首選我們獲取queryId,因爲在delta裏需要使用queryId獲取事務ID(batchId),並且最後寫完成之後的會額外寫入一些數據到元數據裏,也需要queryId。
updateMetadata 主要是爲了檢測schema信息,譬如如果stream 是complte模式,那麼是直接覆蓋的,而如果是其他模式,則需要做schema合併。
如果我們發現當前事務ID>batchId,說明這個已經運行過了,跳過。如果沒有,則使用upsert進行實際的操作。最後設置一些額外的信息提交。
upsert 方法
upsert的基本邏輯是:
- 獲取idCols是不是有分區字段,如果有,先根據分區字段過濾出所有的文件。
- 如果沒有分區字段,則得到所有的文件
- 將這些文件轉化爲dataframe
- 和新寫入的dataframe進行join操作,得到受影響的行(需要更新的行),然後得到這些行所在的文件。
- 獲取這些文件裏沒有無需變更的記錄,寫成新文件。
- 刪除這些文件
- 將新數據寫成新文件
4,5兩個步驟需要對數據進行join,但是在Spark裏靜態表並不能直接join流表,所以我們需要將流錶轉化爲靜態表。
def upsert(txn: OptimisticTransaction, sparkSession: SparkSession): Seq[Action] = {
// if _data is stream dataframe, we should convert it to normal
// dataframe and so we can join it later
val data = if (_data.isStreaming) {
class ConvertStreamDataFrame[T](encoder: ExpressionEncoder[T]) {
def toBatch(data: Dataset[_]): Dataset[_] = {
val resolvedEncoder = encoder.resolveAndBind(
data.logicalPlan.output,
data.sparkSession.sessionState.analyzer)
val rdd = data.queryExecution.toRdd.map(resolvedEncoder.fromRow)(encoder.clsTag)
val ds = data.sparkSession.createDataset(rdd)(encoder)
ds
}
}
new ConvertStreamDataFrame[Row](_data.asInstanceOf[Dataset[Row]].exprEnc).toBatch(_data)
} else _data
上述代碼就是將流錶轉化爲普通靜態表。接着我們需要拿到主鍵字段裏滿足分區字段的字段,然後獲取他們的min/max值
val minMaxColumns = partitionColumnsInIdCols.flatMap { column =>
Seq(F.lit(column), F.min(column).as(s"${column}_min"), F.max(F.max(s"${column}_max")))
}.toArray
val minxMaxKeyValues = data.select(minMaxColumns: _*).collect()
最後得到過濾條件:
// build our where statement
val whereStatement = minxMaxKeyValues.map { row =>
val column = row.getString(0)
val minValue = row.get(1).toString
val maxValue = row.get(2).toString
if (isNumber(column)) {
s"${column} >= ${minValue} and ${maxValue} >= ${column}"
} else {
s"""${column} >= "${minValue}" and "${maxValue}" >= ${column}"""
}
}
logInfo(s"whereStatement: ${whereStatement.mkString(" and ")}")
val predicates = parsePartitionPredicates(sparkSession, whereStatement.mkString(" and "))
Some(predicates)
現在可以得到所有相關的文件了:
val filterFilesDataSet = partitionFilters match {
case None =>
snapshot.allFiles
case Some(predicates) =>
DeltaLog.filterFileList(
metadata.partitionColumns, snapshot.allFiles.toDF(), predicates).as[AddFile]
}
將這些文件轉化爲dataframe,並且將裏面的每條記錄都帶上所屬文件的路徑:
// Again, we collect all files to driver,
// this may impact performance and even make the driver OOM when
// the number of files are very huge.
// So please make sure you have configured the partition columns or make compaction frequently
val filterFiles = filterFilesDataSet.collect
val dataInTableWeShouldProcess = deltaLog.createDataFrame(snapshot, filterFiles, false)
val dataInTableWeShouldProcessWithFileName = dataInTableWeShouldProcess.
withColumn(UpsertTableInDelta.FILE_NAME, F.input_file_name())
通過Join獲取哪些文件裏面的記錄需要被更新:
// get all files that are affected by the new data(update)
val filesAreAffected = dataInTableWeShouldProcessWithFileName.join(data,
usingColumns = idColsList,
joinType = "inner").select(UpsertTableInDelta.FILE_NAME).
distinct().collect().map(f => f.getString(0))
val tmpFilePathSet = filesAreAffected.map(f => f.split("/").last).toSet
val filesAreAffectedWithDeltaFormat = filterFiles.filter { file =>
tmpFilePathSet.contains(file.path.split("/").last)
}
val deletedFiles = filesAreAffectedWithDeltaFormat.map(_.remove)
將需要刪除的文件裏沒有改變的記錄單獨拿出來寫成新文件:
// we should get not changed records in affected files and write them back again
val affectedRecords = deltaLog.createDataFrame(snapshot, filesAreAffectedWithDeltaFormat, false)
val notChangedRecords = affectedRecords.join(data,
usingColumns = idColsList, joinType = "leftanti").
drop(F.col(UpsertTableInDelta.FILE_NAME))
val notChangedRecordsNewFiles = txn.writeFiles(notChangedRecords, Some(options))
最後將我們新增數據寫入:
val newFiles = txn.writeFiles(data, Some(options))
因爲第一次寫入的時候,schema還沒有形成,所以不能走upsert邏輯,而是需要直接寫入,這裏我偷懶,沒有把邏輯寫在UpsertTableInDelta裏,而是寫在了MLSQLDeltaSink裏:
override def addBatch(batchId: Long, data: DataFrame): Unit = {
val metadata = deltaLog.snapshot.metadata
val readVersion = deltaLog.snapshot.version
val isInitial = readVersion < 0
if (!isInitial && parameters.contains(UpsertTableInDelta.ID_COLS)) {
UpsertTableInDelta(data, None, Option(outputMode), deltaLog,
new DeltaOptions(Map[String, String](), sqlContext.sparkSession.sessionState.conf),
Seq(),
Map(UpsertTableInDelta.ID_COLS -> parameters(UpsertTableInDelta.ID_COLS),
UpsertTableInDelta.BATCH_ID -> batchId.toString
)).run(sqlContext.sparkSession)
} else {
super.addBatch(batchId, data)
}
}
總結
Delta 具備了數據的增刪改查能力,同時流批共享,併發修改控制,加上小文件compaction功能,基本解決了我們之前在使用流計算遇到的大部分問題。後續持續優化delta的查詢功能,相信前景無限。