Spark内核之Shuffle解析

目录

一、Shuffle的核心要点

1.1 ShuffleMapStage与ResultStage

1.2  HashShuffle 解析

1.2.1 未优化的HashShuffle

1.2.2 优化后的HashShuffle

1.2.3  总结

1.3 SortShuffle解析

1.3.1  shuffle中的读操作源码

1.3.2  shuffle中的写操作源码(SortShuffle)

1.3.3 shuffle的三种handle

1.3.4 shuffle的三种handle的Writer方法

1.4 bypass SortShuffle解析

1.4.1 总结 

1.4.2 bypass SortShuffle 源码解析



一、Shuffle的核心要点

1.1 ShuffleMapStage与ResultStage

在划分stage时,最后一个stage称为finalStage,它本质上是一个ResultStage对象,前面的所有stage被称为ShuffleMapStage。
ShuffleMapStage的结束伴随着shuffle文件的写磁盘。
ResultStage基本上对应代码中的action算子,即将一个函数应用在RDD的各个partition的数据集上,意味着一个job的运行结束。

1.2  HashShuffle 解析

1.2.1 未优化的HashShuffle

       Spark-1.6 之前默认的shuffle方式是hash. 在 spark-1.6版本之后使用Sort-Base Shuffle,因为HashShuffle存在的不足所以就替换了HashShuffle. Spark2.0之后, 从源码中完全移除了HashShuffle.

       这里我们先明确一个假设前提:每个Executor只有1个CPU core,也就是说,无论这个Executor上分配多少个task线程,同一时间都只能执行一个task线程。

        如下图中有3个 Reducer,从Task 开始那边各自把自己进行 Hash 计算(分区器:hash/numreduce取模),分类出3个不同的类别,每个 Task 都分成3种类别的数据,想把不同的数据汇聚然后计算出最终的结果,所以Reducer 会在每个 Task 中把属于自己类别的数据收集过来,汇聚成一个同类别的大集合,每1个 Task 输出3份本地文件,这里有4个 Mapper Tasks,所以总共输出了4个 Tasks x 3个分类文件 = 12个本地小文件。

1.2.2 优化后的HashShuffle

        优化的HashShuffle过程就是启用合并机制,合并机制就是复用buffer,开启合并机制的配置是spark.shuffle.consolidateFiles。该参数默认值为false,将其设置为true即可开启优化机制。通常来说,如果我们使用HashShuffleManager,那么都建议开启这个选项。

        这里还是有4个Tasks,数据类别还是分成3种类型,因为Hash算法会根据你的 Key 进行分类,在同一个进程中,无论是有多少过Task,都会把同样的Key放在同一个Buffer里,然后把Buffer中的数据写入以Core数量为单位的本地文件中,(一个Core只有一种类型的Key的数据),每1个Task所在的进程中,分别写入共同进程中的3份本地文件,这里有4个Mapper Tasks,所以总共输出是 2个Cores x 3个分类文件 = 6个本地小文件。

1.2.3  总结

-- spark的shuffle两种实现
    在spark1.2之前,默认的shuffle是HashShuffle。该shuffle有一个严重的弊端,会产生大量的中间磁盘文件,进而由大量的磁盘IO影响性能
因此在spark1.2之后,默认的shuffle就改为sortShuffle了。改进在于:每个task在进行shuffle操作的时候,虽然也会产生较多的临时磁盘文件,但是最后将所有的临时文件合并(merge)成一个磁盘文件,并且有一个与之对应的索引文件。在下一个stage的shuffle read task拉取自己的数据时,只要根据索引读取每个磁盘文件中的数据即可。

-- hashShuffle过程
    shuffle write:每一个map task将数据分区(对key执行hash算法取余reduce任务个数),从而将相同的key的数据写入同一个buffer中,最终一个buffer文件对应一个磁盘文件
shuffle read:通常是一个stage刚开始要做的事情。此时stage的每一个task需要将上一个stage的计算结果中拉取属于自己的磁盘文件。每个read task会有自己的buffer缓冲,每次拉取与buffer缓冲大小相同的数据,通过内存中的map进行聚合操作。聚合完一批数据,在拉取下一批数据,知道最后所有数据拉去完,得到最终结果。
hashshuffle普通机制的问题:shuffle write阶段每个task都会产生对应reduce task数量的小文件
,此时会产生大量耗时抵消的IO操作

-- 合并机制的hashshuffle(优化后的shuffle)
优化的地方是:现在每个Executor(Executor内部可能对应多个task)输出小文件的数量是reduce task的数量。比普通的hashshuffle小文件数量少了很多。

hashshuffle生成文件个数总结:
普通的hashshuffle:
    第一个stage中有M个task 第二个stage有N个task 结果会生成MN个文件
优化的hashShuffle:
    有M个Executor,第二个stage有N个task,结果会生成NM个文件

1.3 SortShuffle解析

1.3.1  shuffle中的读操作源码

【在"shuffleMapTask类"中的runTask()方法中的读操作源码分析】
 <***************************************runTask*************************************>

 override def runTask(context: TaskContext): MapStatus = {
   ........
    //此处的写操作也包括读操作,spark任务一行一行的读,读完写到内存
    dep.shuffleWriterProcessor.write(rdd, dep, mapId, context, partition)
  }
<***************************************write*************************************>
  def write(
    ........ 
    var writer: ShuffleWriter[Any, Any] = null
      ........
      writer = manager.getWriter[Any, Any](
      .... .
      //写操作
      writer.write(
        //迭代器进行读
       //一层一层的调,在shuffleRDD中的computer中有:"读的操作"
        rdd.iterator(partition, context).asInstanceOf[Iterator[_ <: Product2[Any, Any]]])
	 .................
}
<***************************************iterator*************************************>          
  final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
    //如果缓存级别为none,说明磁盘与内存都没有数据,即无cache,进行get计算任务
    if (storageLevel != StorageLevel.NONE) {
      getOrCompute(split, context)
    } else {
      computeOrReadCheckpoint(split, context)
    }
  }
<***************************************getOrCompute*************************************>
 private[spark] def getOrCompute(partition: Partition, context: TaskContext): Iterator[T] = {
   .......
   computeOrReadCheckpoint(partition, context)
    ........
 }
<******************************computeOrReadCheckpoint********************************>
 private[spark] def computeOrReadCheckpoint(split: Partition, context: TaskContext): Iterator[T] =
  {
    ......
    //所有的算子都有自己的计算方法
    compute(split, context)
  }
          
<***************************************compute*************************************>
  //是一个抽象类,找到其实现方方法ShuffledRDD的compute方法
  def compute(split: Partition, context: TaskContext): Iterator[T]
          
          
<***************************************compute*************************************> //ShuffledRDD的compute方法
//"读操作"
override def compute(split: Partition, context: TaskContext): Iterator[(K, C)] = {
    .......
    SparkEnv.get.shuffleManager.getReader(dep.shuffleHandle, split.index, split.index + 1, context, metrics)
      .read()
    .......
  }
此时看到读操作:getReader  

1.3.2  shuffle中的写操作源码(SortShuffle)

【在"shuffleMapTask类"中的runTask()方法中的写操作源码分析】          
 <***************************************runTask*************************************>

 override def runTask(context: TaskContext): MapStatus = {
   ........
    //此处的写操作也包括读操作,spark任务一行一行的读,读完写到内存
    dep.shuffleWriterProcessor.write(rdd, dep, mapId, context, partition)
  }
<***************************************write*************************************>
  def write(
    ........ 
    var writer: ShuffleWriter[Any, Any] = null
      ........
      writer = manager.getWriter[Any, Any](
      .... .
      //写操作
      writer.write(
        //迭代器进行读
       //一层一层的调,在shuffleRDD中的computer中有:"读的操作"
        rdd.iterator(partition, context).asInstanceOf[Iterator[_ <: Product2[Any, Any]]])
	 .................
}                    
 <**************************************write*************************************>  
//是一个抽象类,找其实现类SortShuffleWriter的write
def write(records: Iterator[Product2[K, V]]): Unit   
                              
 <***************************************write*************************************> 
 /** Write a bunch of records to this task's output */
//
override def write(records: Iterator[Product2[K, V]]): Unit = {
    //判断的当前的Shuffle操作是不是Map端的预聚合操作
    sorter = if (dep.mapSideCombine) {
      //dep.keyOrdering排序
      new ExternalSorter[K, V, C](
        context, dep.aggregator, Some(dep.partitioner), dep.keyOrdering, dep.serializer)
    } else {
      // ordering = None不排序
      new ExternalSorter[K, V, V](
        context, aggregator = None, Some(dep.partitioner), ordering = None, dep.serializer)
    }
    sorter.insertAll(records)
  }                         
 <**************************************insertAll************************************>  
  def insertAll(records: Iterator[Product2[K, V]]): Unit = {
    ..........
    //判断是否进行聚合
    //在内存中聚合的时候无需排序,因为是Map结构
    if (shouldCombine) {
     ........   
      while (records.hasNext) {
      ......... 
        //聚合使用Map结构并排序
        map.changeValue((getPartition(kv._1), kv._1), update)
        maybeSpillCollection(usingMap = true)
      }
    } else {
      // Stick values into our buffer
      while (records.hasNext) {
        .................
        //不聚合,直接写入Buffer进行累加
        buffer.insert(getPartition(kv._1), kv._1, kv._2.asInstanceOf[C])
        maybeSpillCollection(usingMap = false)
  }  
                                     
<********************************maybeSpillCollectio*********************************>
//是否需要溢写        
private def maybeSpillCollection(usingMap: Boolean): Unit = {
    var estimatedSize = 0L
    if (usingMap) {
      estimatedSize = map.estimateSize()
      if (maybeSpill(map, estimatedSize)) {
        map = new PartitionedAppendOnlyMap[K, C]
      }
    } else {
      estimatedSize = buffer.estimateSize()
      if (maybeSpill(buffer, estimatedSize)) {
        buffer = new PartitionedPairBuffer[K, C]
      }
    }

    if (estimatedSize > _peakMemoryUsedBytes) {
      _peakMemoryUsedBytes = estimatedSize
    }
  }       
                  
 <**************************************maybeSpill************************************>
protected def maybeSpill(collection: C, currentMemory: Long): Boolean = {
     //默认不溢写
    var shouldSpill = false
     //                           当前内存  >= 5M(默认)       
    if (elementsRead % 32 == 0 && currentMemory >= myMemoryThreshold) {
      // Claim up to double our current memory from the shuffle memory pool
      //进行申请资源 一般2倍资源,当申请不到资源是进行溢写操作
      val amountToRequest = 2 * currentMemory - myMemoryThreshold
      val 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
      shouldSpill = currentMemory >= myMemoryThreshold
    }
    shouldSpill = shouldSpill || _elementsRead > numElementsForceSpillThreshold
    // Actually spill
    if (shouldSpill) {
      _spillCount += 1
      logSpillage(currentMemory)
      spill(collection)
      _elementsRead = 0
      _memoryBytesSpilled += currentMemory
      releaseMemory()
    }
    shouldSpill
  }
        
<**************************************spill************************************>     
//是抽象类,找其实现类ExternalSorter的spill方法       
protected def spill(collection: C): Unit
                
<**************************************spill************************************>
override protected[this] def spill(collection: WritablePartitionedPairCollection[K, C]): Unit = {
    //先将内存中数据拿出来
    val inMemoryIterator = collection.destructiveSortedWritablePartitionedIterator(comparator)
    //将拿出来的内存数据溢写到磁盘中
    val spillFile = spillMemoryIteratorToDisk(inMemoryIterator)
    //累加到spills中ArrayBuffer
    spills += spillFile
  }  
     
 <**************************************spills************************************>
  private val spills = new ArrayBuffer[SpilledFile]       
                       
<**************************************merge************************************> 
//最后进行meger合并排序
  /**
   * 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]])] = {
          ........
         // No aggregator given, but we have an ordering (e.g. used by reduce tasks in sortByKey);
        // sort the elements without trying to merge them
        (p, mergeSort(iterators, ordering.get))
      } else {
        (p, iterators.iterator.flatten) 
      ............    
  }   

流程图:

1.3.3 shuffle的三种handle

ShuffleMapTask类(Write)
      1. 点击getWrite
      -- writer = manager.getWriter[Any, Any](dep.shuffleHandle, partitionId, context)
            1. getWriter是一个抽象方法,所在的类为:ShuffleManager,'shuffle管理器',获取其实现类:"SortShuffleManager"是一个可排序的shuffleManager管理器。查询这个管理类的getWriter方法,在这个方法中,对handle的类型进行模式匹配,所以现在handle就很很重要了,从模式匹配项,可以知道有3种不同类型的handle,而且handle来自"getWriter方法"
               -- handle match {
                  case unsafeShuffleHandle: SerializedShuffleHandle
                  case bypassMergeSortHandle: BypassMergeSortShuffleHandle
                  case other: BaseShuffleHandle
      2.在 "manager.getWriter"方法中的handle到底是什么?看源码
             1. 是shuffle管理器注册shuffle获取的,点击registerShuffle
             --val shuffleHandle: ShuffleHandle = _rdd.context.env.shuffleManager.registerShuffle(
        shuffleId, this)
             2. 是一个抽象方法,获取抽象类"ShuffleManager"的实现类"SortShuffleManager",查询"registerShuffle"方法
                    从这里发现,确实有三种handle:
                    a、如果忽略索引文件的排序 --> 创建BypassMergeSortShuffleHandle
                    b、如果可以实现序列化    --> 创建SerializedShuffleHandle
                    c、如果不是以上两种      --> 创建BaseShuffleHandle
    if (SortShuffleWriter.shouldBypassMergeSort(conf, dependency)) {
      // If there are fewer than spark.shuffle.sort.bypassMergeThreshold partitions and we don't need map-side aggregation, 
//then write numPartitions files directly and just concatenate them at the end. This avoids doing serialization and deserialization 
//twice to merge together the spilled files, which would happen with the normal code path. The downside is having multiple files open at a time and thus more memory allocated to buffers.
      new BypassMergeSortShuffleHandle[K, V](
        shuffleId, dependency.asInstanceOf[ShuffleDependency[K, V, V]])
    } else if (SortShuffleManager.canUseSerializedShuffle(dependency)) {
      // Otherwise, try to buffer map outputs in a serialized form, since this is more efficient:
      new SerializedShuffleHandle[K, V](
        shuffleId, dependency.asInstanceOf[ShuffleDependency[K, V, V]])
    } else {
      // Otherwise, buffer map outputs in a deserialized form:
      new BaseShuffleHandle(shuffleId, dependency)
    }
  }
                      1. 点击"shouldBypassMergeSort",查看什么情况下忽略排序,如果当前rdd的map端有预聚合功能,就不能忽略排序,如reduceByKey算子
                        -- if (dep.mapSideCombine) {false}
                        如果map端没有预聚合功能,首先获取忽略合并的阈值,如果没有显示设置,就会默认给200,如果当前RDD的分区器的分区数量小于这个阈值,那么就返回true,则此时创建"BypassMergeSortShuffleHandle"
                        --else {
                        val bypassMergeThreshold: Int = conf.getInt("spark.shuffle.sortbypassMergeThreshold", 200)
                        dep.partitioner.numPartitions <= bypassMergeThreshold
                        -- 所以总结就是当rdd的map端没有预聚合功能,且分区器的分区数量小于阈值,那么就会创建
                            "BypassMergeSortShuffleHandle"
                     2. 点击"canUseSerializedShuffle",Spark的内存优化后的解决方案,对象序列化后不需要反序列化。
                          // 通过以下代码可知,创建"SerializedShuffleHandle"的条件为,满足以下三个条件即可:
                             a、序列化对象需要"支持"重定义
                             b、依赖的map端"没有"预聚合功能
                             c、分区数量"小于等于"(1 << 24) - 1 = 16777215
                          if (!dependency.serializer.supportsRelocationOfSerializedObjects) { false} 
                          else if (dependency.mapSideCombine) {false } 
                          else if (numPartitions > MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZED_MODE) { false} 
                          else {true }
****************************************************************************  
val MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZ ED_MODE =
    PackedRecordPointer.MAXIMUM_PARTITION_ID + 1   
                            
static final int MAXIMUM_PARTITION_ID = (1 << 24) - 1;  // 16777215 
****************************************************************************       
                     3. 如果以上两个handle都不满足,则选择最后一个handle:"BaseShuffleHandle" -->默认的handle
-- 总结: shuffle的handle有三种:
     1. BypassMergeSortShuffleHandle  --> BypassMergeSortShuffleWriter
        "条件":
        a、当前rdd的map端没有预聚合功能,如groupBy
        b、分区器的分区数量小于阈值,默认为200        
     2. SerializedShuffleHandle      --> UnsafeShuffleWriter
        "条件":
        a、序列化对象需要"支持"重定义
        b、依赖的map端"没有"预聚合功能
        c、分区数量"小于"(1 << 24) - 1 = 16777215
     3. BaseShuffleHandle           --> SortShuffleWriter
        "默认的handle"
如果前两种都不满足,那么就使用默认的write
拿着这三种handle,再来看这个"getWrite"方法

1.3.4 shuffle的三种handle的Writer方法

拿着这三种handle,再来看这个"getWrite"方法 
-- handle match {
     -- case unsafeShuffleHandle: SerializedShuffleHandle =>
        new UnsafeShuffleWriter....  
     -- case bypassMergeSortHandle: BypassMergeSortShuffleHandle =>
        new BypassMergeSortShuffleWriter....
     -- case other: BaseShuffleHandle =>
        new SortShuffleWriter....
 
 "不同的handle对应不同的writer"
     1. BypassMergeSortShuffleHandle  --> BypassMergeSortShuffleWriter
        // 点击"BypassMergeSortShuffleWriter"中的write方法,如下代码,根据分区的数量进行循环,'每一个分区就向磁盘写一个文件'。 
        //即map端的每一个task会为reduce端的每一个task都创建一个临时磁盘文件,根据key的hashcode%分区数量,决定数据去到 哪个分区文件中。
        -- for (int i = 0; i < numPartitions; i++) {
      partitionWriters[i] = blockManager.getDiskWriter(blockId, file, serInstance, fileBufferSize, writeMetrics);}
       
     2. SerializedShuffleHandle       --> UnsafeShuffleWriter 
   
     3. BaseShuffleHandle,"重要"       --> SortShuffleWriter
         // 点击"SortShuffleWriter"中的write方法,如下代码:
        // 1. "写文件过程":写磁盘文件时,首先将数据写到内存中,并在内存中的进行排序,如果内存(5M)不够,会溢写磁盘,
        生成临时文件(一个数据文件,一个索引文件),最终将所有的临时文件合并(原来的数据文件和索引文件会被删除)成数据
        文件和索引文件。
           2. "预聚和的原理":在排序时,构造了一种类似于hashtable的结构,所以相同的key就聚合在一起。
           3. "排序规则":首先会按照分区进行排序,然后按照key.
           4. "数据进入不同分区的原则":按照分区器的原则,默认是hashpartition,根据key的hash%分区数量。

 /** Write a bunch of records to this task's output */
override def write(records: Iterator[Product2[K, V]]): Unit = {
    sorter = if (dep.mapSideCombine) {
      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.
      new ExternalSorter[K, V, V](
        context, aggregator = None, Some(dep.partitioner), ordering = None, dep.serializer)
    }
    sorter.insertAll(records)
   	.........
    接shuffle中的写操作流程

  }    

 

-- 面试中常见shuffle的两个问题:
1. 我们现在spark使用了哪种shuffle,哪一种类型的?
   a、sortshuffle。
2. 忽略排序过程的shuffle什么时候会触发?
   a、map 端没有预聚合功能
   b、reduce端的分区数量小于一个阈值,默认是200

1.4 bypass SortShuffle解析

bypass运行机制的触发条件如下:

shuffle reduce task数量小于spark.shuffle.sort.bypassMergeThreshold参数的值,默认为200。(不进行预聚合,会产生大量的小文件)
不是聚合类的shuffle算子(比如reduceByKey)。不进行排序

        此时task会为每个reduce端的task都创建一个临时磁盘文件,并将数据按key进行hash然后根据key的hash值,将key写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件。

        该过程的磁盘写机制其实跟未经优化的HashShuffleManager是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的HashShuffleManager来说,shuffle read的性能会更好。

        而该机制与普通SortShuffleManager运行机制的不同在于:不会进行排序。也就是说,启用该机制的最大好处在于,shuffle write过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。

1.4.1 总结 

总结:
-- 写入内存数据结构
数据会先写入一个内存数据结构中(默认是5M),此时根据不同的shuffle算子,可能选用不同的数据结构。如果是聚合类操作,选用map数据结构,一边聚合一边写入内存,如果是join,那么就选用Array的数据结构,直接写入内存。接着,每写一条数据进入内存数据结构之后,就会判断一下,是否达到了某个临界阈值。如果达到临界阈值的话,那么就会尝试将内存数据结构中的数据溢写到磁盘(会先写到内存缓冲区),然后清空内存数据结构。

-- 排序
在溢写到磁盘文件之前,会先根据key对内存数据结构中已有的数据进行排序。

-- 溢写
对于排序之后的数据 ,会分批写入磁盘文件,数据会以每批一万条写入磁盘文件。首先会将数据缓存在内存缓冲区中,当内存缓冲区满了之后再一次写入磁盘文件,这样可以减少磁盘IO次数,提升性能。

-- merge归并排序
一个task将所有数据写入内存数据结构的过程中,会发生多次磁盘溢写操作,也就是产生多个临时文件。最后将所有的临时磁盘文件都进行合并,写入最终的磁盘文件中。再写一份索引文件,标识了下游各个task需要的数据在这个磁盘文件中的start offset和end offset
注意:一个map task会产生一个索引文件和磁盘大文件

-- sortshuffle的bypass机制:
和sortshuffle的普通机制不一样的地方是,(1)写机制(shuffle write)直接写入内存缓冲区,没有内存数据结构了,因为bypass的触发条件之一就是不能是聚合类算子。(2)不会进行排序,节省了这部分的性能开销

-- sortShuffle的普通机制和bypass机制生成文件个数都是:
第一个stage有N个task 最后生成N个数据文件和N个索引文件

 

1.4.2 bypass SortShuffle 源码解析

private[spark] object SortShuffleWriter {
    def shouldBypassMergeSort(conf: SparkConf, dep: ShuffleDependency[_, _, _]): Boolean = {
        // We cannot bypass sorting if we need to do map-side aggregation.
        // 如果 map 端有聚合, 则不能绕过排序
        if (dep.mapSideCombine) {
            require(dep.aggregator.isDefined, "Map-side combine without Aggregator specified!")
            false
        } else {
            val bypassMergeThreshold: Int = conf.getInt("spark.shuffle.sort.bypassMergeThreshold", 200)
            // 分区数不能超过200 默认值
            dep.partitioner.numPartitions <= bypassMergeThreshold
        }
    }
}

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值