Structured Streaming state store分享
1.引入
Structured Streaming 在创建Sink之后,会调用sessionState.streamingQueryManager.startQuery()创建并启动流,在最后一步描述的query.streamingQuery.start()即真正创建StreamExecution的流处理线程。
private[sql] def startQuery(
...): StreamingQuery = {
val query = createQuery(...)
activeQueriesLock.synchronized {...}
try {
query.streamingQuery.start()
} catch {
case e: Throwable =>
activeQueriesLock.synchronized {
activeQueries -= query.id
}
throw e
}
query
}
/**
* Starts the execution. This returns only after the thread has started and [[QueryStartedEvent]]
* has been posted to all the listeners.
*/
def start(): Unit = {
logInfo(s"Starting $prettyIdString. Use $resolvedCheckpointRoot to store the query checkpoint.")
queryExecutionThread.setDaemon(true)
queryExecutionThread.start()
startLatch.await() // Wait until thread started and QueryStart event has been posted
}
实际是执行StreamExecution的runStream()方法:
val queryExecutionThread: QueryExecutionThread =
new QueryExecutionThread(s"stream execution thread for $prettyIdString") {
override def run(): Unit = {
// To fix call site like "run at <unknown>:0", we bridge the call site from the caller
// thread to this micro batch thread
sparkSession.sparkContext.setCallSite(callSite)
runStream()
}
}
持续查询的驱动引擎StreamExecution 会持续不断地驱动每个批次的执行。对于不需要跨批次的持续查询,如 map()
, filter()
等,每个批次之间的执行相互独立,不需要状态支持。而比如类似 count()
的聚合式持续查询,则需要跨批次的状态支持,这样本批次的执行只需依赖上一个批次的结果,而不需要依赖之前所有批次的结果。这也即增量式持续查询,能够将每个批次的执行时间稳定下来,避免越后面的批次执行时间越长的情形。
增量式持续查询的思路和实现:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S6uwjQbE-1690265931671)(/Users/apple/Documents/yangxin/记录/statestore_1.jpeg)]
1.State Store定义
Structured Streaming中的查询与面向批处理的Spark SQL中的查询不同。在批处理中,查询是针对有限数量的数据进行的,因此计算结果可以是最终的。但这种情况不适用于流处理,因为其结果是不断增长的。因此,不断增长的结果存储在容错的State Store中。
State Store的目的是提供一个可靠的地方,引擎可以从那里读取Structured Streaming聚合的中间结果。因此,即使driver出现故障,Spark也能将状态恢复到故障前的状态。State Store是由类似于HDFS的分布式文件系统支持的。为了保证可恢复性,必须至少存储两个最近版本。例如,如果批次#10在处理过程中失败,那么State Store可能具有批次#9和批次#10一半的状态。Spark将从批次#9开始重新计算,因为批次#9是最后一个成功完成的批次。同时,State Store中还存在对于旧状态的垃圾回收机制。
技术上讲,State Store是存在于每个executor中的对象,将数据存储为key-value对。这个对象是org.apache.spark.sql.stream.state的实现。如上一段所述,单个受支持的存储实际上是HDFSBackedStateStore。
2.State Store实现细节
下面的图片描述了底层的实现细节:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-55SakhOh-1690265931672)(/Users/apple/Documents/yangxin/记录/spark_statestore.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8mWeyzfG-1690265931672)(/Users/apple/Documents/yangxin/记录/summit_state_store_extension_interactions_1.png)]
上面的图表展示了生成org.apache.spark.sql.execution.streaming.state.StateStoreRDD的方法。它获取状态并随后使用可用的状态存储更新方法之一对其进行更新。该方法在FlatMapGroupsWithStateExec的doExecute中定义,更确切地说,作为child.execute()。mapPartitionsWithStateStore [InternalRow]调用的最后一个参数定义。顾名思义,这个RDD负责执行针对可用State Store的计算。StateStoreRDD的实现非常简单,因为它为给定的方法和partition检索State Store,并执行storeUpdateFunction:(StateStore, Iterator[T]) => Iterator[U],该函数由调用的地方传递。
override def compute(partition: Partition, ctxt: TaskContext): Iterator[U] = {
var store: StateStore = null
val storeProviderId = StateStoreProviderId(
StateStoreId(checkpointLocation, operatorId, partition.index),
queryRunId)
// If we're in continuous processing mode, we should get the store version for the current
// epoch rather than the one at planning time.
val isContinuous = Option(ctxt.getLocalProperty(StreamExecution.IS_CONTINUOUS_PROCESSING))
.map(_.toBoolean).getOrElse(false)
val currentVersion = if (isContinuous) {
val epoch = EpochTracker.getCurrentEpoch
assert(epoch.isDefined, "Current epoch must be defined for continuous processing streams.")
epoch.get
} else {
storeVersion
}
store = StateStore.get(
storeProviderId, keySchema, valueSchema, indexOrdinal, currentVersion,
storeConf, hadoopConfBroadcast.value.value)
val inputIter = dataRDD.iterator(partition, ctxt)
storeUpdateFunction(store, inputIter)
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-64uVEPe2-1690265931673)(/Users/apple/Documents/yangxin/记录/summit_state_store_extension_interactions_2.png)]
所有这些操作都是在StateManager类的帮助下进行的,该类在执行一些准备工作之后,将这些调用委托给state store实现。
StateStoreRDD是访问状态存储对象的唯一位置,可以先看一下关于StateStore的trait结构。
trait StateStore {
/** Unique identifier of the store */
// 返回给定状态存储的ID。该ID由StateStoreId实例表示,并由以下内容描述:checkpoint位置,operator ID和partition ID。第一个属性来自checkpointLocation选项或spark.sql.streaming.checkpointLocation属性。第二个代表查询计划中当前有状态operator的ID。它在IncrementalExecution中定义,并在每次SparkPlan执行时更新。最后一个属性与基础RDD的分区ID相关。
def id: StateStoreId
/** Version of the data in this store before committing updates. */
// 定义数据的版本。每次更新都会增加。首先,它在本地更新并保持待处理状态。仅在提交到状态存储后,才将本地更改的版本视为聚合的新当前版本。目前重要的一点是,Spark不会保留所有版本。org.apache.spark.sql.execution.streaming.state.HDFSBackedStateStoreProvider#cleanup方法仅保留最后X个版本,其中X对应于spark.sql.streaming.minBatchesToRetain属性中定义的数字。它定义了必须保留的最小批处理数量才能使处理可恢复。
def version: Long
/**
* Get the current value of a non-null key.
* @return a non-null row if the key exists in the store, otherwise null.
*/
// 获取存储区的存在的key,key不为空
def get(key: UnsafeRow): UnsafeRow
/**
* Put a new value for a non-null key. Implementations must be aware that the UnsafeRows in
* the params can be reused, and must make copies
of the data as needed for persistence.
*/
// 为非空的key输入新值
def put(key: UnsafeRow, value: UnsafeRow): Unit
/**
* Remove a single non-null key.
*/
// 移除key
def remove(key: UnsafeRow): Unit
/**
* Get key value pairs with optional approximate `start` and `end` extents.
* If the State Store implementation maintains indices for the data based on the optional
* `keyIndexOrdinal` over fields `keySchema` (see `StateStoreProvider.init()`), then it can use
* `start` and `end` to make a best-effort scan over the data. Default implementation returns
* the full data scan iterator, which is correct but inefficient. Custom implementations must
* ensure that updates (puts, removes) can be made while iterating over this iterator.
*
* @param start UnsafeRow having the `keyIndexOrdinal` column set with appropriate starting value.
* @param end UnsafeRow having the `keyIndexOrdinal` column set with appropriate ending value.
* @return An iterator of key-value pairs that is guaranteed not miss any key between start and
* end, both inclusive.
*/
// 获取 start end 范围内的键值对
def getRange(start: Option[UnsafeRow], end: Option[UnsafeRow]): Iterator[UnsafeRowPair] = {
iterator()
}
/**
* Commit all the updates that have been made to the store, and return the new version.
* Implementations should ensure that no more updates (puts, removes) can be after a commit in
* order to avoid incorrect usage.
*/
// 提交存储区的更新,并返回新版本
// 它提交本地更改并返回状态存储的新版本。对于HDFSBackedStateStore,更改将由每个分区逐步保存到临时文件中。该文件位于与HDFS文件系统目录兼容:checkpointLocation / operatorId / partitionId。从中创建代表所有更新的文件($ version.delta)。
def commit(): Long
/**
* Abort all the updates that have been made to the store. Implementations should ensure that
* no more updates (puts, removes) can be after an abort in order to avoid incorrect usage.
*/
// 清除crud状态
def abort(): Unit
/**
* Return an iterator containing all the key-value pairs in the StateStore. Implementations must
* ensure that updates (puts, removes) can be made while iterating over this iterator.
*/
// 返回一个iterator,其中包含StateStore中的所有键值对
def iterator(): Iterator[UnsafeRowPair]
/** Current metrics of the state store */
def metrics: StateStoreMetrics
/**
* Whether all updates have been committed
*/
def hasCommitted: Boolean
}
另一个重要的类是StateStoreProvider的实现——HDFSBackedStateStoreProvider。在伴生对象里中使用它来获得给定的存储并执行维护任务(清理旧状态)。维护任务还负责生成snapshot文件。这些文件将多个State Store文件(delta文件)合并到一个snapshot文件中。快照文件通过获取最新版本的增量文件并将所有更改保存在单个文件中来减少增量文件的沿袭。
整个逻辑如下所示:executor将本地更改(添加/更新/删除行)写入临时delta文件的文件流。最后他们调用commit方法:关闭流,为给定版本创建delta文件,使用提交的版本记录在log中。然后将store的状态从UPDATING改为COMMITTED。commit方法是从上面给出的storeUpdateFunction调用的。除此之外,还有一个后台任务做着一些维护工作,将最终确定的delta文件合并为一个snapshot文件,并删除旧的snapshot文件和delta文件。
State Store Files
State Store使用checkpoint location来保留状态,该状态被本地缓存在内存中,以便在处理过程中更快地访问。检查点位置在恢复阶段使用。状态存储处理两种文件:增量和快照。增量文件包含每个查询执行结果的状态表示。它是由在给定分区中注册的状态更改所提供的临时增量文件构造的(状态存储与一个分区相关,并且每个分区在hash中存储版本化的状态数据)。该临时文件的名称通过“ temp-${Random.nextLong}”模式解析。最后,当调用commit方法时,将为新版本创建最终的增量文件(s“ $ version.delta”)。
Spark还运行一个维护线程,该线程将获取状态存储的内存中映射并将其保存到.snapshot文件中。仅当创建的增量文件数达到spark.sql.streaming.stateStore.minDeltasForSnapshot配置属性中指定的值时,才会执行此操作。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MH15O8t4-1690265931673)(/Users/apple/Documents/yangxin/记录/spark_statestore_files.png)]
每次添加新状态,或者修改或过期一个已经存在的状态时,更改都会通过HDFSBackedStateStoreProvider的put或remove方法进行。这些方法实际上做两件事。第一个是底层内存映射的操作以及状态的更新或删除。第二个是通过调用writeUpdateToDeltaFile(output:DataOutputStream,key:UnsafeRow,value:UnsafeRow)进行更新或通过writeRemoveToDeltaFile(output:DataOutputStream,key:UnsafeRow)进行状态存储的更新。两种方法都与DataOutputStream交互,当完成当前查询执行的所有更新时,该数据输出将被刷新。缓冲区已关闭,因此以finalizeDeltaFile(output:DataOutputStream)方法刷新。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qiub9UN6-1690265931674)(/Users/apple/Documents/yangxin/记录/summit_state_store_delta.png)]
在每个查询结束时,将连续写入增量文件。快照文件有所不同,因为它们是由后台维护线程维护的。该线程将唤醒每个spark.sql.streaming.stateStore.maintenanceInterval(默认为60秒),并执行一些清理工作。其中之一是将多个增量文件“合并”(但实际上不是文件的实际物理合并)到一个快照文件中。
快照是通过以下方式执行的。首先,维护线程从检查点位置列出所有文件。由于使用“ $ {query_version}.$ {extension}”模式调用文件,因此返回的名称将按版本数进行排序。稍后,线程将计算上一个快照中的增量文件数。如果此数字大于spark.sql.streaming.stateStore.minDeltasForSnapshot(默认= 10),则它将执行快照。
继续之前的StoreUpdateFunction,它定义了如何处理给定的 micro-batch 中生成的数据。它的实现取决于定义它的许多对象。例如,对于StateStoreSaveExec,此方法根据writer使用的输出模式处理数据,并且它要么输出:每次所有行(complete模式),仅从数据存储中逐出的行(append模式),或仅更新的行(update模式)。对于StreamingDeduplicateExec运算符,Spark将首先遇到的行保存在状态存储中。此检测是通过以下代码段完成的:
val result = baseIterator.filter { r =>
val row = r.asInstanceOf[UnsafeRow]
val key = getKey(row)
val value = store.get(key)
if (value == null) {
store.put(key, StreamingDeduplicateExec.EMPTY_ROW)
numUpdatedStateRows += 1
numOutputRows += 1
true
} else {
// Drop duplicated rows
false
}
}