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机制相比,文件数量已经大大减少了。

总结

sort shuffle write流程简图

暂未涉及细节的知识点

  • PartitionedAppendOnlyMap与PartitionedPairBuffer的底层实现
  • destructiveSortedWritablePartitionedIterator()方法和排序算法逻辑
  • ShuffleMemoryManager如何分配内存
  • DiskBlockObjectWriter

Spark基本sort shuffle write流程解析相关推荐

  1. Spark Tungsten-sort shuffle write流程解析

    Tungsten简介 tungsten-sort这个名字作为一种排序方法,听起来有点怪异.下面简单介绍一下Tungsten. Project Tungsten("钨丝计划")是Da ...

  2. Spark SQL之queryExecution运行流程解析Logical Plan(三)

    1.整体运行流程 使用下列代码对SparkSQL流程进行分析,让大家明白LogicalPlan的几种状态,理解SparkSQL整体执行流程 // sc is an existing SparkCont ...

  3. spark源码-shuffle原理分析-1-ShuffleWriter

    ShuffleWriter 1.概述 2.ShuffleHandle注册 2.1.注册时间点 2.2.向shuffleManager注册shuffle 2.2.1.BypassMergeSortShu ...

  4. Spark源码分析之Sort-Based Shuffle读写流程

    一 概述 我们知道Spark Shuffle机制总共有三种: # 未优化的Hash Shuffle:每一个ShuffleMapTask都会为每一个ReducerTask创建一个单独的文件,总的文件数是 ...

  5. Spark SQL架构工作原理及流程解析

    Spark SQL架构工作原理及流程解析,spark sql从shark发展而来,Shark为了实现Hive兼容,在HQL方面重用了Hive中HQL的解析.逻辑执行计划翻译.执行计划优化等逻辑. Sp ...

  6. 第36课:kaishi 彻底解密Spark 2.1.X中Sort Shuffle中Reducer端源码内幕

    第36课:kaishi 彻底解密Spark 2.1.X中Sort Shuffle中Reducer端源码内幕 本文根据家林大神系列课程编写 http://weibo.com/ilovepains 本课讲 ...

  7. Spark架构与作业执行流程简介

    2019独角兽企业重金招聘Python工程师标准>>> Spark架构与作业执行流程简介 博客分类: spark Local模式 运行Spark最简单的方法是通过Local模式(即伪 ...

  8. SpringMVC 执行流程解析

    SpringMVC 执行流程解析 注:SpringMVC 版本 5.2.15 上面这张图许多人都看过,本文试图从源码的角度带大家分析一下该过程. 1. ContextLoaderListener 首先 ...

  9. Spring Cloud Gateway (七)处理流程解析

    Spring Cloud Gateway (七)处理流程解析 简介     初步梳理 Spring Cloud Gateway 的处理流程 过程记录 主要请求流程     在前面的分析中,我们知道在 ...

最新文章

  1. 【ubuntu工具】Atom的简介及安装
  2. ubuntu下关于 undefined reference to 'pcap_flex'错误 以及 无法导入/找到libpcap.so.1错误...
  3. 大脚导入配置选择哪个文件_「科普向」为何我用大脚时,单体插件会安装失败?...
  4. unix更喜欢进程,可是...
  5. Magical Sticks 棍子拼接
  6. 字符串 CSV解析 表格 逗号分隔值 通讯录 电话簿 MD
  7. 用.NET模拟天体运动
  8. 计算机病毒及其防治评课,区初中信息技术教研活动公开课评课感想
  9. CSDN专訪:大数据时代下的商业存储
  10. HTML如何实现斜体字
  11. Tensorflow2.0数据和部署(二)——基于设备的模型与TensorFlow Lite
  12. 设计模式原则之四:迪米特法则
  13. 比亚迪汉鸿蒙系统测评_深度:预判比亚迪汉EV电驱动系统技术状态
  14. js中substr,substring,indexOf,lastIndexOf,split 的用法
  15. emacs for Mac命令
  16. 前端实战项目:前端框架Vue3.0项目教程(一)Vue3.0环境的搭建
  17. bugku--never_give_up
  18. 区块链交易性能、隐私保护、监管问题
  19. IntelliJ IDEA下载安装教程
  20. 傻瓜式解决pycrypto安装错误

热门文章

  1. 基于 Python 的音乐流派分类
  2. HTML5期末大作业:旅游网站设计——简单大气的响应式旅游网页(5页) HTML+CSS+JavaScript...
  3. Android am和pm命令
  4. 乡村小微企业创新发展计划启动 广东清远成为首个先行区
  5. 《密码学系列》—信息安全威胁
  6. Android 天气APP(二十三)增加灾害预警、优化主页面UI
  7. 小米手机解bl锁正规方法!
  8. boost------asio库的使用2(Boost程序库完全开发指南)读书笔记
  9. jexus-net-dockerfile
  10. [附源码]计算机毕业设计JAVA超市收银系统论文