Spark基本sort shuffle write流程解析
shuffle write入口
先回忆一下基础知识:
- Spark作业执行的单元从高到低为job→stage→task
- stage分为ShuffleMapStage与ResultStage,task也分为ShuffleMapTask与ResultTask
- 调用shuffle类算子会导致stage的划分
上一篇shuffle机制概述文章已经提到,ShuffleWriter都实现了write()方法。它由o.a.s.scheduler.ShuffleMapTask.runTask()方法来调用:
val manager = SparkEnv.get.shuffleManagerwriter = manager.getWriter[Any, Any](dep.shuffleHandle, partitionId, context)writer.write(rdd.iterator(partition, context).asInstanceOf[Iterator[_ <: Product2[Any, Any]]])writer.stop(success = true).get
write()方法就是我们的入手点。
#1 - o.a.s.shuffle.sort.SortShuffleWriter.write()方法
/** Write a bunch of records to this task's output */override def write(records: Iterator[Product2[K, V]]): Unit = {//【创建外部排序器ExternalSorter】sorter = if (dep.mapSideCombine) {//【如果shuffle依赖中有map端预聚合,如reduceByKey()算子,就传入aggregator和keyOrdering】//【aggregator表示预聚合规则,keyOrdering表示key的排序】require(dep.aggregator.isDefined, "Map-side combine without Aggregator specified!")new ExternalSorter[K, V, C](context, dep.aggregator, Some(dep.partitioner), dep.keyOrdering, dep.serializer)} else {// In this case we pass neither an aggregator nor an ordering to the sorter, because we don't// care whether the keys get sorted in each partition; that will be done on the reduce side// if the operation being run is sortByKey.//【如果没有map端预聚合,也就不需要传aggregator和keyOrdering,如sortByKey()算子这样的排序就交给reduce做】new ExternalSorter[K, V, V](context, aggregator = None, Some(dep.partitioner), ordering = None, dep.serializer)}//【#2 - 将shuffle数据放入ExternalSorter进行处理】sorter.insertAll(records)// Don't bother including the time to open the merged output file in the shuffle write time,// because it just opens a single file, so is typically too fast to measure accurately// (see SPARK-3570).//【创建输出数据文件与临时数据文件】val output = shuffleBlockResolver.getDataFile(dep.shuffleId, mapId)val tmp = Utils.tempFileWith(output)try {//【根据shuffle ID和map ID确定shuffle块ID(reduce ID是0)】val blockId = ShuffleBlockId(dep.shuffleId, mapId, IndexShuffleBlockResolver.NOOP_REDUCE_ID)//【#6 - 将ExternalSorter中的shuffle数据按分区写入临时文件中,返回各个分区的大小】val partitionLengths = sorter.writePartitionedFile(blockId, tmp)//【#9 - 创建输出索引文件和临时索引文件,写入索引信息,然后将临时文件改成输出文件】shuffleBlockResolver.writeIndexFileAndCommit(dep.shuffleId, mapId, partitionLengths, tmp)//【MapStatus是ShuffleMapTask返回给TaskScheduler的数据结构】mapStatus = MapStatus(blockManager.shuffleServerId, partitionLengths)} finally {if (tmp.exists() && !tmp.delete()) {logError(s"Error while deleting temp file ${tmp.getAbsolutePath}")}}}
从上面的代码可以有个整体印象:SortShuffleWriter不仅会输出中间数据,还会输出索引,并且它主要借助一个叫ExternalSorter的类来处理数据。下面来看一下ExternalSorter的细节。
内存缓存
#2 - o.a.s.util.collection.ExternalSorter.insertAll()方法
// Data structures to store in-memory objects before we spill. Depending on whether we have an// Aggregator set, we either put objects into an AppendOnlyMap where we combine them, or we// store them in an array buffer.//【两个内存数据结构,注意volatile】@volatile private var map = new PartitionedAppendOnlyMap[K, C]@volatile private var buffer = new PartitionedPairBuffer[K, C]def insertAll(records: Iterator[Product2[K, V]]): Unit = {// TODO: stop combining if we find that the reduction factor isn't highval shouldCombine = aggregator.isDefined//【如果需要做map端聚合的话】if (shouldCombine) {// Combine values in-memory first using our AppendOnlyMap//【获取aggregator的mergeValue()函数,它将一个新值合并到当前聚合结果中】val mergeValue = aggregator.get.mergeValue//【获取aggregator的createCombiner()函数,它负责创建聚合过程的初始值】val createCombiner = aggregator.get.createCombinervar kv: Product2[K, V] = null//【如果一个key当前已有聚合值,执行mergeValue()合并value;如果无,执行createCombiner()创建初始值】val update = (hadValue: Boolean, oldValue: C) => {if (hadValue) mergeValue(oldValue, kv._2) else createCombiner(kv._2)}while (records.hasNext) {//【Spillable类中的操作,将已读记录的计数+1】addElementsRead()kv = records.next()//【map是一个PartitionedAppendOnlyMap结构。将分区和key作为键,然后回调上面的update来聚合值】map.changeValue((getPartition(kv._1), kv._1), update)//【#3 - 检查并执行溢写磁盘】maybeSpillCollection(usingMap = true)}} else {// Stick values into our buffer//【如果不用做map端聚合】while (records.hasNext) {addElementsRead()val kv = records.next()//【buffer是一个PartitionedPairBuffer结构。直接将分区和键值数据加进去】buffer.insert(getPartition(kv._1), kv._1, kv._2.asInstanceOf[C])//【#3 - 检查并执行溢写磁盘】maybeSpillCollection(usingMap = false)}}}
ExternalSorter在插入数据时,会分需要与不需要map端预聚合两种情况来处理。对需要map端预聚合的情况,采用PartitionedAppendOnlyMap来保存聚合数据。它是一个能够保留分区信息的,且没有remove()方法的映射型结构。之所以能够保留分区,是因为它的键类型是(partition_id, key)
二元组。对于不需要预聚合的情况,数据会放入PartitionedPairBuffer中。它是分区的键值对缓存,作用与PartitionedAppendOnlyMap大同小异,不过内部实现由映射换成了变长数组。
前面的处理都是在内存中进行,当内存不够用时会向磁盘溢写。下面来看看判断及实行溢写的逻辑。
磁盘溢写
#3 - o.a.s.util.collection.Spillable.maybeSpill()方法
Spillable抽象类是ExternalSorter的父类,上面代码#2中调用的maybeSpillCollection()方法就是maybeSpill()方法的简单封装。它会调用maybeSpill()方法检查是否需要溢写,一旦发生了溢写,就重新new出#2中的map或buffer结构,从零开始再缓存。
/*** Spills the current in-memory collection to disk if needed. Attempts to acquire more* memory before spilling.** @param collection collection to spill to disk* @param currentMemory estimated size of the collection in bytes* @return true if `collection` was spilled to disk; false otherwise*/protected def maybeSpill(collection: C, currentMemory: Long): Boolean = {var shouldSpill = false//【溢写初始判断条件:读取的记录总数是32的倍数,并且map或者buffer的预估内存占用量大于当前阈值】//【溢写阈值是可变的,初始值由spark.shuffle.spill.initialMemoryThreshold参数指定,默认5MB】if (elementsRead % 32 == 0 && currentMemory >= myMemoryThreshold) {// Claim up to double our current memory from the shuffle memory pool//【先试图向ShuffleMemoryManager申请(2 * 预估占用内存 - 当前阈值)这么多的执行内存】//【详情可以深挖acquireMemory()方法,这里暂时先不展开】val amountToRequest = 2 * currentMemory - myMemoryThresholdval granted = acquireMemory(amountToRequest)//【用申请到的量更新阈值】myMemoryThreshold += granted// If we were granted too little memory to grow further (either tryToAcquire returned 0,// or we already had more memory than myMemoryThreshold), spill the current collection//【如果申请到的不够,map或buffer预估占用内存量还是大于阈值,确定溢写】shouldSpill = currentMemory >= myMemoryThreshold}//【如果上面判定不需要溢写,但读取的记录总数比spark.shuffle.spill.numElementsForceSpillThreshold大,也还是得溢写】//【这个参数默认值是Long.MAX_VALUE】shouldSpill = shouldSpill || _elementsRead > numElementsForceSpillThreshold// Actually spillif (shouldSpill) {_spillCount += 1logSpillage(currentMemory)//【#4 - 将缓存的集合溢写】spill(collection)_elementsRead = 0_memoryBytesSpilled += currentMemory//【释放内存】releaseMemory()}shouldSpill}
在真正溢写数据之前,writer会先申请内存扩容,如果申请不到或者申请的过少,才会开始溢写。这符合Spark尽量充分地利用内存的中心思想。
另外需要注意的是,传入的currentMemory参数含义为“缓存的预估内存占用量”,而不是“缓存的当前占用量”。这是因为PartitionedAppendOnlyMap与PartitionedPairBuffer都能动态扩容,并且具有SizeTracker特征,能够通过采样估计其大小。这是个很有点意思的实现,之后也会专门写文章来分析一下这些数据结构。
负责溢写数据的spill()方法是抽象方法,其实现仍然在ExternalSorter中。
#4 - o.a.s.util.collection.ExternalSorter.spill()方法
/*** Spill our in-memory collection to a sorted file that we can merge later.* We add this file into `spilledFiles` to find it later.** @param collection whichever collection we're using (map or buffer)*/override protected[this] def spill(collection: WritablePartitionedPairCollection[K, C]): Unit = {//【根据指定的比较器comparator进行排序,返回排序结果的迭代器】//【如果细看的话,destructiveSortedWritablePartitionedIterator()方法最终采用TimSort算法来排序】val inMemoryIterator = collection.destructiveSortedWritablePartitionedIterator(comparator)//【#5 - 将内存数据溢写到磁盘文件】val spillFile = spillMemoryIteratorToDisk(inMemoryIterator)//【用ArrayBuffer记录所有溢写的磁盘文件】spills += spillFile}
#5 - o.a.s.util.collection.ExternalSorter.spillMemoryIteratorToDisk()方法
/*** Spill contents of in-memory iterator to a temporary file on disk.*/private[this] def spillMemoryIteratorToDisk(inMemoryIterator: WritablePartitionedIterator): SpilledFile = {// Because these files may be read during shuffle, their compression must be controlled by// spark.shuffle.compress instead of spark.shuffle.spill.compress, so we need to use// createTempShuffleBlock here; see SPARK-3426 for more context.//【上面英文注释的大意:因为溢写文件在shuffle过程中会被读取,因此它们的压缩不由spill相关参数控制】//【所以要创建一个临时块】val (blockId, file) = diskBlockManager.createTempShuffleBlock()// These variables are reset after each flushvar objectsWritten: Long = 0val spillMetrics: ShuffleWriteMetrics = new ShuffleWriteMetrics//【创建溢写文件的DiskBlockObjectWriter】//【fileBufferSize对应参数为spark.shuffle.file.buffer(默认值32K)。如果资源充足,可以适当增大,从而减少flush次数】val writer: DiskBlockObjectWriter =blockManager.getDiskWriter(blockId, file, serInstance, fileBufferSize, spillMetrics)// List of batch sizes (bytes) in the order they are written to disk//【记录写入批次的大小】val batchSizes = new ArrayBuffer[Long]// How many elements we have in each partition//【记录每个分区的条数】val elementsPerPartition = new Array[Long](numPartitions)// Flush the disk writer's contents to disk, and update relevant variables.// The writer is committed at the end of this process.//【将内存数据按批次刷到磁盘的方法】def flush(): Unit = {//【提交写操作后,会产生一个FileSegment对象,表示一个java.io.File的一部分。其中包含offset和length】val segment = writer.commitAndGet()batchSizes += segment.length_diskBytesSpilled += segment.lengthobjectsWritten = 0}var success = falsetry {//【遍历map或buffer缓存中的记录】while (inMemoryIterator.hasNext) {val partitionId = inMemoryIterator.nextPartition()require(partitionId >= 0 && partitionId < numPartitions,s"partition Id: ${partitionId} should be in the range [0, ${numPartitions})")//【逐条写入,更新计数值】inMemoryIterator.writeNext(writer)elementsPerPartition(partitionId) += 1objectsWritten += 1//【当写入的记录条数达到批次阈值spark.shuffle.spill.batchSize(默认值10000),将这批刷到磁盘】if (objectsWritten == serializerBatchSize) {flush()}}//【遍历完毕,将剩余的刷到磁盘】if (objectsWritten > 0) {flush()} else {writer.revertPartialWritesAndClose()}success = true} finally {if (success) {writer.close()} else {// This code path only happens if an exception was thrown above before we set success;// close our stuff and let the exception be thrown furtherwriter.revertPartialWritesAndClose()if (file.exists()) {if (!file.delete()) {logWarning(s"Error deleting ${file}")}}}}//【返回溢写文件对象】SpilledFile(file, blockId, batchSizes.toArray, elementsPerPartition)}
至此,shuffle write缓存和溢写就完成了。缓存采用了高效的数据结构,并且溢写时会保证顺序。
与溢写相关的四个配置参数是:
spark.shuffle.file.buffer
spark.shuffle.spill.initialMemoryThreshold
spark.shuffle.spill.numElementsForceSpillThreshold
spark.shuffle.spill.batchSize
既然这个机制叫做sort shuffle,那么输出时也自然少不了排序。下面来看排序逻辑。
排序与合并
#6 - o.a.s.util.collection.ExternalSorter.writePartitionedFile()方法
/*** Write all the data added into this ExternalSorter into a file in the disk store. This is* called by the SortShuffleWriter.** @param blockId block ID to write to. The index file will be blockId.name + ".index".* @return array of lengths, in bytes, of each partition of the file (used by map output tracker)*/def writePartitionedFile(blockId: BlockId,outputFile: File): Array[Long] = {// Track location of each range in the output fileval lengths = new Array[Long](numPartitions)//【创建输出文件的DiskBlockObjectWriter】val writer = blockManager.getDiskWriter(blockId, outputFile, serInstance, fileBufferSize,context.taskMetrics().shuffleWriteMetrics)if (spills.isEmpty) {// Case where we only have in-memory data//【如果不存在溢写文件,那么表示内存够用,根据有无聚合,只取map或buffer中的数据就行了】val collection = if (aggregator.isDefined) map else buffer//【根据指定的比较器comparator进行排序,返回排序结果的迭代器,与代码#4中逻辑相同】val it = collection.destructiveSortedWritablePartitionedIterator(comparator)while (it.hasNext) {//【取分区ID,依次将分区内的数据按序输出,并记录分区大小】val partitionId = it.nextPartition()while (it.hasNext && it.nextPartition() == partitionId) {it.writeNext(writer)}//【提交写操作,获得FileSegment】val segment = writer.commitAndGet()lengths(partitionId) = segment.length}} else {// We must perform merge-sort; get an iterator by partition and write everything directly.//【如果存在溢写文件,那么要把溢写文件和缓存数据归并排序。排序完后再根据分区依次写入输出文件中】//【#7 - 归并和排序的逻辑在partitionedIterator中调用的merge()方法里】for ((id, elements) <- this.partitionedIterator) {if (elements.hasNext) {for (elem <- elements) {writer.write(elem._1, elem._2)}val segment = writer.commitAndGet()lengths(id) = segment.length}}}writer.close()//【记录内存和磁盘的指标,最终返回各个分区的大小,后面有用】context.taskMetrics().incMemoryBytesSpilled(memoryBytesSpilled)context.taskMetrics().incDiskBytesSpilled(diskBytesSpilled)context.taskMetrics().incPeakExecutionMemory(peakMemoryUsedBytes)lengths}
可见,在输出之前排序时,需要区分有无溢写文件。如果没有就比较简单,直接对缓存数据排序即可。如果有的话,还需要将溢写文件与缓存数据归并排序。但是,不管有无溢写文件以及有多少个溢写文件,最终所有中间数据都会被合并到一个文件中,而不会分散在多个文件。
#7 - o.a.s.util.collection.ExternalSorter.partitionedIterator成员
/*** Return an iterator over all the data written to this object, grouped by partition and* aggregated by the requested aggregator. For each partition we then have an iterator over its* contents, and these are expected to be accessed in order (you can't "skip ahead" to one* partition without reading the previous one). Guaranteed to return a key-value pair for each* partition, in order of partition ID.** For now, we just merge all the spilled files in once pass, but this can be modified to* support hierarchical merging.* Exposed for testing.*/def partitionedIterator: Iterator[(Int, Iterator[Product2[K, C]])] = {val usingMap = aggregator.isDefinedval collection: WritablePartitionedPairCollection[K, C] = if (usingMap) map else bufferif (spills.isEmpty) {// Special case: if we have only in-memory data, we don't need to merge streams, and perhaps// we don't even need to sort by anything other than partition IDif (!ordering.isDefined) {// The user hasn't requested sorted keys, so only sort by partition ID, not key//【如果没有溢写,并且没有排序规则,那么就只按照分区ID排序】groupByPartition(destructiveIterator(collection.partitionedDestructiveSortedIterator(None)))} else {// We do need to sort by both partition ID and key//【如果没有溢写,但有排序规则,那么需要先按分区ID排序,再按key排序】groupByPartition(destructiveIterator(collection.partitionedDestructiveSortedIterator(Some(keyComparator))))}} else {// Merge spilled and in-memory data//【#8 - 如果有溢写,就将溢写文件和缓存数据归并排序】merge(spills, destructiveIterator(collection.partitionedDestructiveSortedIterator(comparator)))}}
从上面可以看出,排序时一定会保证按分区ID有序。至于是否按key值有序,需要看有无排序规则。所以从严格意义上讲,这个“排序”并不能完全保证数据有序。
#8 - o.a.s.util.collection.ExternalSorter.merge()方法
/*** Merge a sequence of sorted files, giving an iterator over partitions and then over elements* inside each partition. This can be used to either write out a new file or return data to* the user.** Returns an iterator over all the data written to this object, grouped by partition. For each* partition we then have an iterator over its contents, and these are expected to be accessed* in order (you can't "skip ahead" to one partition without reading the previous one).* Guaranteed to return a key-value pair for each partition, in order of partition ID.*/private def merge(spills: Seq[SpilledFile], inMemory: Iterator[((Int, K), C)]): Iterator[(Int, Iterator[Product2[K, C]])] = {//【创建多个SpillReader来读取溢写文件】val readers = spills.map(new SpillReader(_))val inMemBuffered = inMemory.buffered//【按序遍历分区】(0 until numPartitions).iterator.map { p =>//【将溢写文件与缓存对应分区的数据拼合起来】val inMemIterator = new IteratorForPartition(p, inMemBuffered)val iterators = readers.map(_.readNextPartition()) ++ Seq(inMemIterator)if (aggregator.isDefined) {// Perform partial aggregation across partitions//【如果有聚合逻辑,归并时做分区级别的聚合,按keyComparator做key排序】(p, mergeWithAggregation(iterators, aggregator.get.mergeCombiners, keyComparator, ordering.isDefined))} else if (ordering.isDefined) {// No aggregator given, but we have an ordering (e.g. used by reduce tasks in sortByKey);// sort the elements without trying to merge them//【如果没有聚合逻辑但有排序逻辑,按照ordering做归并排序】(p, mergeSort(iterators, ordering.get))} else {//【什么都没有的话,直接返回拼合后的结果就是了】(p, iterators.iterator.flatten)}}}
这里会将所有溢写文件与缓存数据合并起来,并且排序规则与代码#7中仍然一致,这样整个排序与合并过程就完成了。最后,来看数据和索引文件是如何输出的。
输出数据和索引文件
#9 - o.a.s.shuffle.IndexShuffleBlockResolver.writeIndexFileAndCommit()方法
/*** Write an index file with the offsets of each block, plus a final offset at the end for the* end of the output file. This will be used by getBlockData to figure out where each block* begins and ends.** It will commit the data and index file as an atomic operation, use the existing ones, or* replace them with new ones.** Note: the `lengths` will be updated to match the existing index file if use the existing ones.*/def writeIndexFileAndCommit(shuffleId: Int,mapId: Int,lengths: Array[Long],dataTmp: File): Unit = {//【创建索引文件和临时索引文件】val indexFile = getIndexFile(shuffleId, mapId)val indexTmp = Utils.tempFileWith(indexFile)try {//【BufferedOutputStream用于批量写,性能好】val out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(indexTmp)))Utils.tryWithSafeFinally {// We take in lengths of each block, need to convert it to offsets.//【取得之前代码#6中算出的每个分区的大小,累加成索引文件中的偏移量,先写入临时索引文件】var offset = 0Lout.writeLong(offset)for (length <- lengths) {offset += lengthout.writeLong(offset)}} {out.close()}val dataFile = getDataFile(shuffleId, mapId)// There is only one IndexShuffleBlockResolver per executor, this synchronization make sure// the following check and rename are atomic.//【一个executor中能跑多个task,但只有一个IndexShuffleBlockResolver实例,下面用synchronized加锁】//【这样,对数据文件和索引文件的检查、提交操作都具有了原子性】synchronized {//【检查索引和数据文件是否已经存在了有效的对应关系】val existingLengths = checkIndexAndDataFile(indexFile, dataFile, lengths.length)if (existingLengths != null) {// Another attempt for the same task has already written our map outputs successfully,// so just use the existing partition lengths and delete our temporary map outputs.//【如果已经存在,就是shuffle write早先已经成功完成,这次操作产生的临时文件就无用了,可以删掉】System.arraycopy(existingLengths, 0, lengths, 0, lengths.length)if (dataTmp != null && dataTmp.exists()) {dataTmp.delete()}indexTmp.delete()} else {// This is the first successful attempt in writing the map outputs for this task,// so override any existing index and data files with the ones we wrote.//【如果还没存在对应关系,就是第一次shuffle write刚刚成功,删除可能存在的其他索引和数据文件,防止混淆】if (indexFile.exists()) {indexFile.delete()}if (dataFile.exists()) {dataFile.delete()}//【然后将临时文件重命名成正式的文件】if (!indexTmp.renameTo(indexFile)) {throw new IOException("fail to rename file " + indexTmp + " to " + indexFile)}if (dataTmp != null && dataTmp.exists() && !dataTmp.renameTo(dataFile)) {throw new IOException("fail to rename file " + dataTmp + " to " + dataFile)}}}} finally {if (indexTmp.exists() && !indexTmp.delete()) {logError(s"Failed to delete temporary index file at ${indexTmp.getAbsolutePath}")}}}
IndexShuffleBlockResolver类专门负责维护shuffle数据与索引的对应关系,后面的shuffle read阶段也还要用到它。
从整个shuffle write流程可知,每一个ShuffleMapTask通过SortShuffleWriter只会产生两个文件,一个分区的数据文件,一个索引文件。这与之前的hash shuffle机制相比,文件数量已经大大减少了。
总结
![](/assets/blank.gif)
暂未涉及细节的知识点
- PartitionedAppendOnlyMap与PartitionedPairBuffer的底层实现
- destructiveSortedWritablePartitionedIterator()方法和排序算法逻辑
- ShuffleMemoryManager如何分配内存
- DiskBlockObjectWriter
Spark基本sort shuffle write流程解析相关推荐
- Spark Tungsten-sort shuffle write流程解析
Tungsten简介 tungsten-sort这个名字作为一种排序方法,听起来有点怪异.下面简单介绍一下Tungsten. Project Tungsten("钨丝计划")是Da ...
- Spark SQL之queryExecution运行流程解析Logical Plan(三)
1.整体运行流程 使用下列代码对SparkSQL流程进行分析,让大家明白LogicalPlan的几种状态,理解SparkSQL整体执行流程 // sc is an existing SparkCont ...
- spark源码-shuffle原理分析-1-ShuffleWriter
ShuffleWriter 1.概述 2.ShuffleHandle注册 2.1.注册时间点 2.2.向shuffleManager注册shuffle 2.2.1.BypassMergeSortShu ...
- Spark源码分析之Sort-Based Shuffle读写流程
一 概述 我们知道Spark Shuffle机制总共有三种: # 未优化的Hash Shuffle:每一个ShuffleMapTask都会为每一个ReducerTask创建一个单独的文件,总的文件数是 ...
- Spark SQL架构工作原理及流程解析
Spark SQL架构工作原理及流程解析,spark sql从shark发展而来,Shark为了实现Hive兼容,在HQL方面重用了Hive中HQL的解析.逻辑执行计划翻译.执行计划优化等逻辑. Sp ...
- 第36课:kaishi 彻底解密Spark 2.1.X中Sort Shuffle中Reducer端源码内幕
第36课:kaishi 彻底解密Spark 2.1.X中Sort Shuffle中Reducer端源码内幕 本文根据家林大神系列课程编写 http://weibo.com/ilovepains 本课讲 ...
- Spark架构与作业执行流程简介
2019独角兽企业重金招聘Python工程师标准>>> Spark架构与作业执行流程简介 博客分类: spark Local模式 运行Spark最简单的方法是通过Local模式(即伪 ...
- SpringMVC 执行流程解析
SpringMVC 执行流程解析 注:SpringMVC 版本 5.2.15 上面这张图许多人都看过,本文试图从源码的角度带大家分析一下该过程. 1. ContextLoaderListener 首先 ...
- Spring Cloud Gateway (七)处理流程解析
Spring Cloud Gateway (七)处理流程解析 简介 初步梳理 Spring Cloud Gateway 的处理流程 过程记录 主要请求流程 在前面的分析中,我们知道在 ...
最新文章
- 【ubuntu工具】Atom的简介及安装
- ubuntu下关于 undefined reference to 'pcap_flex'错误 以及 无法导入/找到libpcap.so.1错误...
- 大脚导入配置选择哪个文件_「科普向」为何我用大脚时,单体插件会安装失败?...
- unix更喜欢进程,可是...
- Magical Sticks 棍子拼接
- 字符串 CSV解析 表格 逗号分隔值 通讯录 电话簿 MD
- 用.NET模拟天体运动
- 计算机病毒及其防治评课,区初中信息技术教研活动公开课评课感想
- CSDN专訪:大数据时代下的商业存储
- HTML如何实现斜体字
- Tensorflow2.0数据和部署(二)——基于设备的模型与TensorFlow Lite
- 设计模式原则之四:迪米特法则
- 比亚迪汉鸿蒙系统测评_深度:预判比亚迪汉EV电驱动系统技术状态
- js中substr,substring,indexOf,lastIndexOf,split 的用法
- emacs for Mac命令
- 前端实战项目:前端框架Vue3.0项目教程(一)Vue3.0环境的搭建
- bugku--never_give_up
- 区块链交易性能、隐私保护、监管问题
- IntelliJ IDEA下载安装教程
- 傻瓜式解决pycrypto安装错误
热门文章
- 基于 Python 的音乐流派分类
- HTML5期末大作业:旅游网站设计——简单大气的响应式旅游网页(5页) HTML+CSS+JavaScript...
- Android am和pm命令
- 乡村小微企业创新发展计划启动 广东清远成为首个先行区
- 《密码学系列》—信息安全威胁
- Android 天气APP(二十三)增加灾害预警、优化主页面UI
- 小米手机解bl锁正规方法!
- boost------asio库的使用2(Boost程序库完全开发指南)读书笔记
- jexus-net-dockerfile
- [附源码]计算机毕业设计JAVA超市收银系统论文