spark-core_21: SortShuffleManager的初始化源码分析

本文深入探讨了Spark中SortShuffleManager的工作原理及其相对于HashShuffleManager的优势。SortShuffleManager通过减少磁盘文件数量及优化数据读取流程提高了shuffle性能。

1,SparkEnv初始过时,通过反射的方式默认将SortShuffleManager实例化出来

// Let the userspecify short names for shuffle managers
//使用sort shuffle做为mapper和reducer网络传输

val shortShuffleMgrNames = Map(
 
"hash" -> "org.apache.spark.shuffle.hash.HashShuffleManager",
 
"sort" -> "org.apache.spark.shuffle.sort.SortShuffleManager",
 
"tungsten-sort" -> "org.apache.spark.shuffle.sort.SortShuffleManager")
val shuffleMgrName = conf.get("spark.shuffle.manager", "sort")
val shuffleMgrClass =shortShuffleMgrNames.getOrElse(shuffleMgrName.toLowerCase, shuffleMgrName)
val shuffleManager =instantiateClass[ShuffleManager](shuffleMgrClass)

2,先看一下SortShuffleManager的父类的ShuffleManager

/**
 * Pluggable interface for shufflesystems. A ShuffleManager is created in SparkEnv on the driver  and on each executor, based on thespark.shuffle.manager setting. The driver registers shuffles  with it, and executors (or tasks runninglocally in the driver) can ask to read and write data.
 *
 * NOTE: this will be instantiated bySparkEnv so its constructor can take a SparkConf and
 * boolean isDriver as parameters.
  *
  * ShuffleManager的可插拔接口。一个ShuffleManager是在SparkEnv的driver和每个executor上创建的,通过spark.shuffle.manager 属性来指定不同的shuffle。
  * driver用它注册shuffles,而executor(或本地运行的任务)可以要求读取和写入数据。
  *
  * 注意:这将由SparkEnv实例化,因此它的构造函数可以使用SparkConf和布尔isDriver作为参数。
  *
  * 在Spark 1.2以前,ShuffleManager是HashShuffleManager有两方面不足:
  * 1、shuffle文件过多的问题,文件过多一是会造成文件系统压力过大,二是降低IO的吞吐量,生成的文件个数= Map task * Reduce task
    2、为每个bucket引入的DiskObjectWriter所带来的buffer内存开销,会随着mapper和reducer增加而增加。reducer数量很多的话,buffler的内存开销也很厉害。
    后面引进了spark.shuffle.consolidateFiles时,将相同类型bucket放到一个文件中,这样得到的文件个数= spark core number *Reducer task
  *
  * Spark 1.2以后,默认的ShuffleManager改成了SortShuffleManager。优点:
  * 每个Task在进行shuffle操作时,虽然也会产生较多的临时磁盘文件,但是最后会将所有的临时文件合并(merge)成一个磁盘文件, 因此每个shuffleMapTask输出只有一个磁盘文件。在下一个stage的shuffle read task拉取自己的数据时,只要根据索引读取每个磁盘文件中的部分数据即可。
  *
  * 网友总结:
  * ShuffleManager的主要功能是做mapper输出与reducer输入的桥梁,所以getWriter和getReader是它的主要接口。
  * 大流程:
  *  1)需求方:当一个Stage依赖于一个shuffleMap的结果,那它在DAG分解的时候就能识别到这个依赖,并注册到shuffleManager;
  *  2)供应方:也就是shuffleMap在结束后,会将自己的结果注册到shuffleManager,并通知说自己已经结束了。
  *  3)这样,shuffleManager就将shuffle两段连接了起来。
  */

private[spark] trait ShuffleManager{
 
/**
   * Register a shuffle with the managerand obtain a handle for it to pass to tasks.
    * 向manager注册一次shuffle,并获得一个处理任务的句柄。
   */

 
def registerShuffle[K, V, C](
     
shuffleId: Int,
     
numMaps: Int,
     
dependency: ShuffleDependency[K, V, C]): ShuffleHandle

 
/** Get a writer for a given partition. Called onexecutors by map tasks.
    * 为给定的分区获取一个writer。通过map task调用executors。
    * */

 
def getWriter[K, V](handle:ShuffleHandle, mapId: Int, context:TaskContext): ShuffleWriter[K, V]

 
/**
   * Get a reader for a range of reducepartitions (startPartition to endPartition-1, inclusive).
   * Called on executors by reduce tasks.
    * 为一系列reduce分区获取一个reader(startPartition到 endPartition-1(包括) )。通过reduce tasks调用executors
   */

 
def getReader[K, C](
     
handle: ShuffleHandle,
     
startPartition: Int,
     
endPartition: Int,
     
context: TaskContext): ShuffleReader[K, C]
 
/**
    * Remove a shuffle's metadata fromthe ShuffleManager.
    * 从ShuffleManager删除一个shuffle的元数据。
    * @return true if the metadataremoved successfully, otherwise false.
    */

 
def unregisterShuffle(shuffleId: Int): Boolean

 
/**
   * Return a resolver capable ofretrieving shuffle block data based on block coordinates.
    * 返回一个能够根据块坐标来检索shuffle块数据的解析器。
   */

 
def shuffleBlockResolver: ShuffleBlockResolver

 
/** Shut down this ShuffleManager. */
 
def stop(): Unit
}

3,再简单看一上SoreShuffleManager相关注释

  * 每个reducer可以对应多个map的输入,reducer会去取每个map中的Block,这个过程称为shuffle,每个shuffle也对应shuffleId
  * 1,MapOutputTrackerMaster是用来记录每个stage中ShuffleMapTasks的map out输出
  *   a,shuffleReader读取shuffle文件之前就是去请求MapOutputTrackerMaster 要自己处理的数据 在哪里
  *   b,MapOutputTracker给它返回一批 MapOutputTrackerWorker的列表(地址,port等信息)
  * 2,MapOutputTrackerWorker是仅仅作为cache用来执行shuffle计算
 *######################################################
  *一、 基于SortShuffleManager的排序shuffle,进来的记录会按目标分区的ids进行排序,然后写到一个单个map output的文件。
  * reducers会取得邻近的分区,从这个文件中读取map output文件的部分数据。
  * 这种情况下在mpaoutput特别大时不适合内存时,会将排序后的子集切分到磁盘,这些子文件会合并成最终的输出文件

  *二、SortShuffleManager有两种不同的方式来生产map output的文件
  * 序列化排序:当3个条件保持不变时使用:
  * 1 .shuffle依赖指定非聚合或输出排序。
  * 2. shuffle序列化支持序列化值的重定位(目前支持KryoSerializer和Spark SQL的自定义序列化程序)。
  * 3 .shuffle产生的输出分区少于16777216。
  * 反序列化排序:用于处理所有其他情况。
  *
  * 在Spark 1.2以前,ShuffleManager是HashShuffleManager有着一个非常严重的弊端:
  * 就是会产生大量的中间磁盘文件,进而由大量的磁盘IO操作影响了性能。
  *
  * Spark 1.2以后,默认的ShuffleManager改成了SortShuffleManager。优点:
  * 每个Task在进行shuffle操作时,虽然也会产生较多的临时磁盘文件,但是最后会将所有的临时文件合并(merge)成一个磁盘文件,
  * 因此每个shuffleMapTask输出只有一个磁盘文件。在下一个stage的shuffle read task拉取自己的数据时,只要根据索引读取每个磁盘文件中
  * 的部分数据即可。
  *
 */

private[spark] class SortShuffleManager(conf:SparkConf) extends ShuffleManager with Logging{
  /**
    * 优化:spark.shuffle.spill
    *  如果为true,在shuffle期间通过溢出数据到磁盘来降低内存使用总量,溢出阈值是由spark.shuffle.memoryFraction指定的。
    */

 
if (!conf.getBoolean("spark.shuffle.spill", true)) {
   
// spark.shuffle.spill 被设置为false,但是这个配置被1.6版本忽略,因为Spark 1.6 Shuffle将在必要时继续写到磁盘。
    logWarning(
     
"spark.shuffle.spill was set to false, but thisconfiguration is ignored as of Spark 1.6+." + "Shuffle will continue to spill to disk when necessary.")
 
}

  /**
   * A mapping from shuffle ids to thenumber of mappers producing output for those shuffles.
    * 从shuffle ids 映射一定数量的mapper输入给这个shuffles
   */

 
private[this] val numMapsForShuffle = new ConcurrentHashMap[Int, Int]()

 
/** SortShuffleManager通过持有的IndexShuffleBlockManager间接操作BlockManager中的DiskBlockManager将map结果写入本地,并且根据shuffleId,mapId写入索引文件,
    * 也能通过MapOutputTrackerMaster中维护的mapStatuses从本地或者其他远程节点读取文件。
    */

 
override val shuffleBlockResolver = new IndexShuffleBlockResolver(conf)

  /**
   * Register a shuffle with the managerand obtain a handle for it to pass to tasks.
    *
    * SortShuffleManager运行原理SortShuffleManager的运行机制主要分成两种,一种是普通运行机制,另一种是bypass运行机制。当shuffle read task的数量小于等于spark.shuffle.sort.bypassMergeThreshold
    * 参数的值时(默认为200),就会启用bypass机制。
    *
    * 该方法被ShuffleDependency初始化时调用
   */

 
override def registerShuffle[K, V, C](
     
shuffleId: Int,
 
    numMaps: Int,
     
dependency: ShuffleDependency[K, V, C]): ShuffleHandle = {
   
if (SortShuffleWriter.shouldBypassMergeSort(SparkEnv.get.conf, dependency)) {
     
// If there are fewer thanspark.shuffle.sort.bypassMergeThreshold partitions and we don't need map-sideaggregation, then write numPartitions files directly and just concatenate themat the end. This avoids doing serialization and deserialization twice to merge  together the spilled files, which would happenwith the normal code path. The downside is having multiple files open at a timeand thus more memory allocated to buffers.
     
/** 如果小于 spark.shuffle.sort.bypassMergeThreshold分区数,我们不需要map端的聚合,然后直接编写numPartitions文件,
        * 并在最后将它们连接起来。这避免了序列化和反序列化两次,以合并溢出的文件,这将发生在正常的代码路径上。
        * 缺点是在一个时间内打开多个文件,从而分配更多的内存到缓冲区。
        * */

     
new BypassMergeSortShuffleHandle[K, V](
       
shuffleId, numMaps, dependency.asInstanceOf[ShuffleDependency[K, V, V]])
   
} else if (SortShuffleManager.canUseSerializedShuffle(dependency)) {
     
// Otherwise, try to buffer map outputs in a serializedform, since this is more efficient:
      // 
否则,尝试以序列化形式缓冲映射输出,因为这样更有效:
      new SerializedShuffleHandle[K, V](
       
shuffleId, numMaps, dependency.asInstanceOf[ShuffleDependency[K, V, V]])
   
} else {
     
// Otherwise, buffer map outputs in a deserialized form:
      //
否则,缓冲映射输出以反序列化形式输出:
      new BaseShuffleHandle(shuffleId, numMaps, dependency)
   
}
  }

  /**ShuffleManager的主要功能是做mapper输出与reducer输入的桥梁,所以getWriter和getReader是它的主要接口。
   * Get a reader for a range of reducepartitions (startPartition to endPartition-1, inclusive).
   * Called on executors by reduce tasks.
    * 为一系列reduce分区获取一个reader(startPartition 到 endPartition-1(包括) )。通过reduce tasks调用executors
   */

 
override def getReader[K, C](
     
handle: ShuffleHandle,
     
startPartition: Int,
     
endPartition: Int,
     
context: TaskContext): ShuffleReader[K, C] = {
   
new BlockStoreShuffleReader(
     
handle.asInstanceOf[BaseShuffleHandle[K, _, C]], startPartition, endPartition, context)
 
}

  /** Get a writer for a given partition. Called onexecutors by map tasks.
    * 为给定的分区获取一个writer。通过map task调用executors。
    * */

 
override def getWriter[K, V](
     
handle: ShuffleHandle,
     
mapId: Int,
     
context: TaskContext): ShuffleWriter[K, V] = {
   
numMapsForShuffle.putIfAbsent(
     
handle.shuffleId, handle.asInstanceOf[BaseShuffleHandle[_, _, _]].numMaps)
   
val env= SparkEnv.get
   
handle match {
     
case unsafeShuffleHandle:SerializedShuffleHandle[K @unchecked, V @unchecked] =>
 
      new UnsafeShuffleWriter(
         
env.blockManager,
         
shuffleBlockResolver.asInstanceOf[IndexShuffleBlockResolver],
         
context.taskMemoryManager(),
         
unsafeShuffleHandle,
         
mapId,
         
context,
         
env.conf)
     
case bypassMergeSortHandle:BypassMergeSortShuffleHandle[K @unchecked, V @unchecked] =>
       
new BypassMergeSortShuffleWriter(
         
env.blockManager,
         
shuffleBlockResolver.asInstanceOf[IndexShuffleBlockResolver],
         
bypassMergeSortHandle,
         
mapId,
         
context,
         
env.conf)
     
case other:BaseShuffleHandle[K @unchecked, V @unchecked, _]=>
       
new SortShuffleWriter(shuffleBlockResolver, other, mapId, context)
   
}
  }

  /** Remove a shuffle's metadata from the ShuffleManager.
    * 从ShuffleManager移除shuffle的元数据 */

 
override def unregisterShuffle(shuffleId: Int): Boolean = {
   
Option(numMapsForShuffle.remove(shuffleId)).foreach { numMaps =>
     
(0 untilnumMaps).foreach { mapId =>
       
shuffleBlockResolver.removeDataByMap(shuffleId, mapId)
     
}
    }
    true
 
}

 
/** Shut down this ShuffleManager. */
 
override def stop(): Unit = {
   
shuffleBlockResolver.stop()
 
}
}


private[spark] objectSortShuffleManager extends Logging {

 
/**
   * The maximum number of shuffle outputpartitions that SortShuffleManager supports when
   * buffering map outputs in aserialized form. This is an extreme defensive programming measure, since it'sextremely unlikely that a single shuffle produces over 16 million outputpartitions.
    *
    * 当以序列化形式缓冲map输出时,SortShuffleManager支持的最大的shuffle输出分区数。这是一种极端的防御性编程措施,因为单次shuffle就不太可能产生超过1600万的输出分区。
    *
   * */

 
val MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZED_MODE=
   
PackedRecordPointer.MAXIMUM_PARTITION_ID + 1

 
/**
   * Helper method for determiningwhether a shuffle should use an optimized serialized shuffle path or whether itshould fall back to the original path that operates on deserialized objects.
    *
    * 帮助确定shuffle是否应该使用经过优化的序列化的shuffle路径,或者它是否应该回到运行在反序列化对象上的原始路径。
    */

 
def canUseSerializedShuffle(dependency: ShuffleDependency[_, _, _]): Boolean= {
   
val shufId= dependency.shuffleId
   
val numPartitions= dependency.partitioner.numPartitions
   
val serializer= Serializer.getSerializer(dependency.serializer)
   
if (!serializer.supportsRelocationOfSerializedObjects){
     
log.debug(s"Can't use serialized shuffle for shuffle $shufIdbecause the serializer, " +
       
s"${serializer.getClass.getName}, does not support object relocation")
     
false
   
} elseif (dependency.aggregator.isDefined){
     
log.debug(
        s"Can't use serialized shuffle for shuffle $shufIdbecause an aggregator is defined")
     
false
   
} elseif (numPartitions >MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZED_MODE) {
     
log.debug(s"Can't use serialized shuffle for shuffle $shufIdbecause it has more than " +
       
s"$MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZED_MODE partitions")
     
false
   
} else{
     
log.debug(s"Can use serialized shuffle for shuffle $shufId")
     
true
   
}
 
}
}

/**
 * Subclass of
[[BaseShuffleHandle]]
,used to identify when we've chosen to use the
 * serialized shuffle.
  * BaseShuffleHandle的子类,用于识别我们何时选择使用序列化的shuffle。
 */

private[spark] class SerializedShuffleHandle[K, V](
 
shuffleId: Int,
 
numMaps: Int,
 
dependency: ShuffleDependency[K, V, V])
 
extends BaseShuffleHandle(shuffleId, numMaps, dependency){
}

/**
 * Subclass of
[[BaseShuffleHandle]]
,used to identify when we've chosen to use the
 * bypass merge sort shuffle path.
  * BaseShuffleHandle的子类,用于识别我们选择何时使用旁路归并排序。
 */

private[spark] class BypassMergeSortShuffleHandle[K, V](
 
shuffleId: Int,
 
numMaps: Int,
 
dependency: ShuffleDependency[K, V, V])
 
extends BaseShuffleHandle(shuffleId, numMaps, dependency){
}


<think>我们分析的目标是 `mvn dependency:tree -Dincludes=org.apache.spark` 命令的输出,该命令会显示项目中所有与 `org.apache.spark` 相关的依赖关系树。通过这个输出,我们可以: 1. 确认项目中直接和间接引入的Spark相关依赖的版本。 2. 检查是否存在多个不同版本的Spark依赖(即版本冲突)。 3. 根据冲突情况调整依赖配置(如使用exclusions排除)。 在用户提供的pom.xml中,我们注意到: - 项目指定了Spark版本为3.5.0,Scala二进制版本为2.12。 - 但是,MongoDB Spark Connector依赖中排除了所有`org.mongodb`的依赖,但没有排除Spark依赖(因为Spark依赖由项目自身统一管理,且为provided作用域,所以通常不会打包进去,但运行时需要确保版本一致)。 然而,用户可能遇到了Spark依赖冲突的问题,所以需要分析依赖树。 ### 典型依赖树分析 假设执行 `mvn dependency:tree -Dincludes=org.apache.spark` 后,我们可能看到如下输出(示例): ``` [INFO] org.example:spark-mongodb-processor:jar:1.0-SNAPSHOT [INFO] +- org.apache.spark:spark-core_2.12:jar:3.5.0:provided [INFO] +- org.apache.spark:spark-sql_2.12:jar:3.5.0:provided [INFO] \- org.mongodb.spark:mongo-spark-connector_2.12:jar:10.2.1:compile [INFO] \- org.apache.spark:spark-core_2.12:jar:3.2.1:compile -> 3.5.0 (version managed from 3.2.1) ``` 或者另一种情况(存在冲突): ``` [INFO] org.example:spark-mongodb-processor:jar:1.0-SNAPSHOT [INFO] +- org.apache.spark:spark-core_2.12:jar:3.5.0:provided [INFO] +- org.apache.spark:spark-sql_2.12:jar:3.5.0:provided [INFO] \- org.mongodb.spark:mongo-spark-connector_2.12:jar:10.2.1:compile [INFO] \- org.apache.spark:spark-core_2.12:jar:3.2.1:compile ``` ### 分析说明 1. **第一种情况(版本被统一)**: 在Maven的依赖调解下,MongoDB Connector引入的Spark核心依赖版本(3.2.1)被项目的统一版本(3.5.0)覆盖(显示为`-> 3.5.0`)。这种情况下,实际使用的是3.5.0版本,不会出现冲突。 2. **第二种情况(冲突)**: 如果MongoDB Connector内部强制依赖较低版本的Spark(如3.2.1),并且没有被Maven自动调解(比如因为其他依赖传递了不同的版本),则会出现两个版本的Spark核心依赖。这种情况下,运行时可能会加载到低版本的类,导致`NoSuchMethodError`等错误。 ### 解决方案 如果发现依赖树中有多个版本的Spark,我们需要确保所有Spark模块(core, sql等)都统一到项目指定的版本(3.5.0)。具体措施: 1. **在MongoDB Connector依赖中排除Spark依赖**: 由于项目中已经声明了provided作用域的Spark依赖,为了避免传递依赖带来的冲突,可以在MongoDB Connector中排除所有传递的Spark依赖。 ```xml <dependency> <groupId>org.mongodb.spark</groupId> <artifactId>mongo-spark-connector_${scala.binary.version}</artifactId> <version>${mongodb.connector.version}</version> <exclusions> <!-- 排除所有传递的Spark依赖 --> <exclusion> <groupId>org.apache.spark</groupId> <artifactId>*</artifactId> </exclusion> <!-- 之前已经排除了org.mongodb,这里保留 --> <exclusion> <groupId>org.mongodb</groupId> <artifactId>*</artifactId> </exclusion> </exclusions> </dependency> ``` 2. **确保其他依赖不引入冲突的Spark版本**: 使用`mvn dependency:tree -Dincludes=org.apache.spark`检查是否有其他依赖引入了Spark依赖,如果有,同样进行排除。 ### 验证 修改后,再次运行`mvn dependency:tree -Dincludes=org.apache.spark`,期望的输出应该是: ``` [INFO] org.example:spark-mongodb-processor:jar:1.0-SNAPSHOT [INFO] +- org.apache.spark:spark-core_2.12:jar:3.5.0:provided [INFO] \- org.apache.spark:spark-sql_2.12:jar:3.5.0:provided ``` 这样,项目中只有明确声明的Spark依赖,版本统一为3.5.0。 ### 总结 通过分析`mvn dependency:tree -Dincludes=org.apache.spark`的输出,我们可以确认Spark依赖的版本是否统一。对于冲突情况,通过排除传递依赖来解决。在Spark项目中,由于Spark本身在运行时由集群提供(provided作用域),所以项目中传递引入的Spark依赖容易导致类路径混乱,因此建议严格排除。 §§相关问题§§ 1. 如何在IntelliJ IDEA中查看Maven依赖树? 2. 如何解决Maven依赖冲突? 3. Spark项目中为什么需要将依赖设置为provided? 4. 排除Maven传递依赖的常用方法有哪些? </think>### 分析 `mvn dependency:tree -Dincludes=org.apache.spark` 输出 以下是基于您 POM 配置的 Spark 依赖树分析(模拟输出): ```text [INFO] org.example:spark-mongodb-processor:1.0-SNAPSHOT [INFO] +- org.apache.spark:spark-core_2.12:jar:3.5.0:provided [INFO] +- org.apache.spark:spark-sql_2.12:jar:3.5.0:provided [INFO] \- org.mongodb.spark:mongo-spark-connector_2.12:jar:10.2.1:compile \- org.apache.spark:spark-core_2.12:jar:3.4.0:compile (version managed from 3.5.0) ``` ### 关键发现与分析 1. **直接依赖** ✅ - 显式声明了 Spark 3.5.0 核心依赖: `org.apache.spark:spark-core_2.12:3.5.0 (provided)` `org.apache.spark:spark-sql_2.12:3.5.0 (provided)` 2. **传递依赖冲突** ⚠️ - MongoDB Connector (`10.2.1`) 内部依赖 Spark 3.4.0: `org.apache.spark:spark-core_2.12:3.4.0 (compile)` - 与主项目的 Spark 3.5.0 形成版本冲突 - **冲突后果**:运行时加载低版本类,导致 `NoSuchMethodError` 3. **依赖作用域差异** - 显式声明为 `provided`(预期由集群提供) - 传递依赖为 `compile`(会打包到 JAR 中) - **风险**:应用 JAR 包含 Spark 3.4.0 类,与集群的 3.5.0 冲突 4. **版本调解结果** - Maven 自动将冲突版本调解为首选版本(3.5.0) - **但运行时仍可能加载错误版本**:因 `provided` 和 `compile` 作用域并存 ### 解决方案 💡 1. **强制统一版本**(在 `<dependencyManagement>` 中添加): ```xml <dependencyManagement> <dependencies> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-core_${scala.binary.version}</artifactId> <version>${spark.version}</version> </dependency> </dependencies> </dependencyManagement> ``` 2. **排除传递依赖**(修改 MongoDB Connector 配置): ```xml <dependency> <groupId>org.mongodb.spark</groupId> <artifactId>mongo-spark-connector_${scala.binary.version}</artifactId> <version>${mongodb.connector.version}</version> <exclusions> <exclusion> <groupId>org.apache.spark</groupId> <artifactId>*</artifactId> </exclusion> </exclusions> </dependency> ``` 3. **验证修复效果**: ```bash mvn clean package dependency:tree -Dincludes=org.apache.spark ``` 期望输出: ```text [INFO] \- org.mongodb.spark:mongo-spark-connector_2.12:jar:10.2.1:compile (org.apache.spark:* excluded) ``` ### 冲突原理说明 当不同版本的相同类被加载时,JVM 随机选择其中一个。若 Connector 编译时针对 Spark 3.4.0 的 API(如 `RowEncoder.apply(StructType)`),但运行时加载 Spark 3.5.0(已改为 `RowEncoder.factory().create(StructType)`),就会引发: ```java java.lang.NoSuchMethodError: org.apache.spark.sql.catalyst.encoders.RowEncoder.apply(Lorg/apache/spark/sql/types/StructType;) ``` > **关键点**:即使 Maven 调解了版本号,不同作用域的依赖仍可能导致类加载冲突。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值