spark之shuffle原理及性能优化

ShuffleManager里有四个接口,register,reader,writer和stop。

 

核心接口则是reader和writer,当前版本reader接口只有1个实现,writer接口有3个实现,每种实现分别对应不同的场景。

 

writer 3种:

1.BypassMergeSortShuffleWriter ,

使用场景

  • shuffle中没有map端的聚合操作
  • 输出的分区数小于spark.shuffle.sort.bypassMergeThreshold,默认是200

shuffle 流程

  1. 将数据按key分partition,根据partitioner,比如hash
  2. 每个partition维护一个文件句柄,最后生成一个FileSegment
  3. 将所有FileSegment合并到一个文件
  4. 最后记录一个索引文件,根据输出分区的顺序记录每个FileSegment的长度

就是按照 partitionId 从小到大排序的数据, 读取过程中使用再按照 分区分段, 并且记录每个分区的文件起始写入位置,把这些位置数据写入索引文件中。

2.UnsafeShuffleWriter,

使用场景

  • shuffle中没有map端的聚合操作
  • 序列化框架支持对已经序列化数据的重定位,这个意思是被序列化后的几个对象可以任意交换位置而不影响对象的数据

shuffle 流程

  1. 将数据按key分partition,根据partitioner,比如hash
  2. 将数据写入Unsafe数据结构,并记录索引
  3. 当数据条数超过spark.shuffle.spill.numElementsForceSpillThreshold,spill数据到磁盘,默认是Integer.MAX_VALUE,也就是尽可能不spill
  4. 判断是否需要将内存容器扩容,spark.shuffle.sort.initialBufferSize初始化的大小只有4096字节,扩容失败也会触发spill,扩容涉及到spark的内存管理机制,这里不展开了
  5. 遍历内存的数据,排序后写入磁盘,注意排序后的分区id是连续的,因此当一条数据的partition和上一条不一样时,意味着上一个分区的数据已经写完了,此时生成一个Segment
  6. 如果有spill文件,那么还需要做最后的合并,将所有的spill数据和最后flush的数据合并成一个文件,这个合并就比较简单了,根据每个文件记录的分区索引合并即可
  7. 最后记录一个索引文件
  • 在步骤5的内存排序中spark给出了两种选择RadixSort,TimSort,通过配置spark.shuffle.sort.useRadixSort选择,默认是用RadixSort
  •  

3.SortShuffleWriter,

使用场景

兜底万能writer,单效率不高

shuffle 流程

总体流程来看,优先执行合并操作,最后在生成文件的时候再做排序,过程中穿插着spill操作。

  1. 根据是否需要进行聚合操作选择不同的容器,聚合用map数据结构,非聚合用数组数据结构,这也比较好理解
  2. 将数据按key分partition,根据partitioner,比如hash
  3. 数据写入相应容器,需要聚合的key通过聚合处理后再更新到容器
  4. 判断是否需要spill,执行spill
  5. 将内存中和spill的数据合并写入最后产生的文件,并且这个过程支持排序
  6. 最后记录一个索引文件

 

=====sort shuffle Manager 方式的介绍 版本一 ========

shuffle是分布式计算引擎的一个核心流程,遇到join,reduce等操作就可能会触发shuffle,文本通过代码分析spark的shuffle机制

前言

shuffle承接着map和reduce的数据桥梁,对map而言,shuffle可以对数据合并,排序,缓存,这样效率会比在reduce端更高;对reduce而言,shuffle可以将数据重洗牌,数据倾斜就是由这个原因引起的。

spark的shuffle是经历过不断更新的,从1.6之前版本的hash shuffle 到现在 sort shuffle。

老式的hash shuffle 介绍:https://blog.csdn.net/zhanglh046/article/details/78360762

新版3.0的sort shuffle源码介绍https://blog.csdn.net/newhandnew413/article/details/107730608

设计

那怎么设计shuffle这个流程呢。从spark执行机制的角度上来看,分为executor和task

spark的shuffle框架

spark的shuffle的插件式的,我们可以通过配置spark.shuffle.manager自定义shuffle manager,默认是sort,也就是SortShuffleManager,目前spark也就这一个ShuffleManager实现,我们可以通过继承ShuffleManager来实现自己的shuffle manager。
ShuffleManager里有四个接口,register,reader,writer和stop。核心接口则是reader和writer,当前版本reader接口只有1个实现,writer接口有3个实现,每种实现分别对应不同的场景。

SortShuffleManager

介绍

可以把这个类当做是shuffle工厂类,需要解释一下的是这里的sort不是指key-value数据的key的sort,而是对数据输出分区id的sort。输出的数据会根据输出的分区进行排序,并且通过记录索引文件的方式记录每个分区在文件中的位置,毕竟下游还需要通过分区来定位数据位置,如果同一个分区不是连续的话,那拉取数据的效率将会大大下降,这也是输出数据必须按分区排序的原因。

BypassMergeSortShuffleWriter

介绍

这个类是shuffle writer的一个实现,基于每个分区(这里的分区指的是输出的分区,而不是写本身的分区)各自维护一个文件句柄的思路,直接将数据分文件写到磁盘,最后再将所有分区文件合并成一个文件并记录一个index文件。这种思路是最简单的,类似分桶操作,不算最后的合并,整个过程只用了O(n)的时间复杂度,秒杀所有的排序算法。当前这个策略的使用前提是输出分区不大于200个分区。

使用场景

  • shuffle中没有map端的聚合操作
  • 输出的分区数小于spark.shuffle.sort.bypassMergeThreshold,默认是200

shuffle 流程

  1. 将数据按key分partition,根据partitioner,比如hash
  2. 每个partition维护一个文件句柄,最后生成一个FileSegment
  3. 将所有FileSegment合并到一个文件
  4. 最后记录一个索引文件,根据输出分区的顺序记录每个FileSegment的长度

核心类

  • BypassMergeSortShuffleWriter负责整体流程控制
  • DiskBlockObjectWriter负责将数据写到文件,通过partitioner确定数据输出的分区,每个分区和都有一个独立的对象,也就是一个独立的文件句柄
  • LocalDiskShuffleMapOutputWriter将输出的所有分区文件合并成一个文件
  • IndexShuffleBlockResolver负责维护Block和文件的映射关系,记录index文件,校验index文件等

UnsafeShuffleWriter

介绍

Spark有很多Unsafe前缀的类,这些类大多数都和数据结构相关,而其底层则是用到了Java的Unsafe类,这个类可以在对象中开辟内存空间,但是索引信息需要上层维护,使用起来比较麻烦,好处是可以节省java对容器的一系列封装造成的内存浪费。为此,spark也单独封装了一个模块,unsafe模块。

使用场景

  • shuffle中没有map端的聚合操作
  • 序列化框架支持对已经序列化数据的重定位,这个意思是被序列化后的几个对象可以任意交换位置而不影响对象的数据

shuffle 流程

  1. 将数据按key分partition,根据partitioner,比如hash
  2. 将数据写入Unsafe数据结构,并记录索引
  3. 当数据条数超过spark.shuffle.spill.numElementsForceSpillThreshold,spill数据到磁盘,默认是Integer.MAX_VALUE,也就是尽可能不spill
  4. 判断是否需要将内存容器扩容,spark.shuffle.sort.initialBufferSize初始化的大小只有4096字节,扩容失败也会触发spill,扩容涉及到spark的内存管理机制,这里不展开了
  5. 遍历内存的数据,排序后写入磁盘,注意排序后的分区id是连续的,因此当一条数据的partition和上一条不一样时,意味着上一个分区的数据已经写完了,此时生成一个Segment
  6. 如果有spill文件,那么还需要做最后的合并,将所有的spill数据和最后flush的数据合并成一个文件,这个合并就比较简单了,根据每个文件记录的分区索引合并即可
  7. 最后记录一个索引文件
  • 在步骤5的内存排序中spark给出了两种选择RadixSort,TimSort,通过配置spark.shuffle.sort.useRadixSort选择,默认是用RadixSort

核心类

  • ShuffleInMemorySorter负责在内存中排序
  • ShuffleExternalSorter负责排序
  • IndexShuffleBlockResolver负责维护Block和文件的映射关系,记录index文件,校验index文件等

SortShuffleWriter

介绍

这个shuffle write实现类是用于兜底的,支持所有的shuffle场景,包括map端的聚合,排序等操作。但换个角度说,通用意味着效率不高。由于涉及到的操作比较多,相对前两种shuffle write比较复杂

shuffle 流程

总体流程来看,优先执行合并操作,最后在生成文件的时候再做排序,过程中穿插着spill操作。

  1. 根据是否需要进行聚合操作选择不同的容器,聚合用map数据结构,非聚合用数组数据结构,这也比较好理解
  2. 将数据按key分partition,根据partitioner,比如hash
  3. 数据写入相应容器,需要聚合的key通过聚合处理后再更新到容器
  4. 判断是否需要spill,执行spill
  5. 将内存中和spill的数据合并写入最后产生的文件,并且这个过程支持排序
  6. 最后记录一个索引文件

核心类

  • SortShuffleWriter入口类
  • ExternalSorter负责操作数据,串联整个流程
  • PartitionedAppendOnlyMap聚合时需要的容器
  • PartitionedPairBuffer非聚合需要的容器
  • IndexShuffleBlockResolver负责维护Block和文件的映射关系,记录index文件,校验index文件等

shuffle read

相比写,读更简单一点,目前也只有一种实现,BlockStoreShuffleReader,所有的寻址以及拉数据都基于其分布式存储BlockManager

MapOutputTracker

说到shuffle就不得不提到MapOutputTracker,用于跟踪shuffle的block信息。MapOutputTrackerBlockManager的架构类似,也采用了主从,在driver端是master,在executor是worker。当有一个ShuffleMapTask执行成功,driver就会把这个task的shuffleId,partitionId,BlockManagerId注册起来。跟踪shuffle信息是为了串联起下游需要读取shuffle数据的task,其中MapOutputTracker.getPreferredLocationsForShuffle()这个方法返回一组更适合的executor地址,也就是尽可能让shuffle读的task和shuffle数据在同一个executor,减少网络io。这个功能默认是开启的spark.shuffle.reduceLocality.enabled,但是必须满足下面3个条件。

  1. map shuffle分区数必须小于1000
  2. 读取shuffle数据的reduce分区数也小于1000
  3. 该BlockManager占有的数据量超过该reduce task所需要拉取的总shuffle数据量的0.2,如果这个系数太小会导致返回过多的BlockManager,太大会返回过少或者没有适合的BlockManager
    注意这三个参数不能通过配置修改,可以认为是spark的经验参数。

总结

虽然spark提供了3种shuffle write的实现方式,但是这3种实现方式最后输出的数据结构是一致的。首先他们都是以输出分区作为粒度,也就是说,每个分区各自拥有一个write实例对象,互不影响,保证了高并发。每个分区最终shuffle写完的文件数也是一致的,一个数据文件和一个索引文件,索引文件里记录了输出分区在数据文件中的顺序和长度。这里举个例子,假设在shuffle前我们有分区20个,shuffle后我们有分区200个,那么在shuffle写的时候,20个分区中,每个分区都要输出200个分区中的某些数据,极端情况下某些分区数据量为0。输出包含200个分区的数据文件数据结构是根据分区排序的,从0开始,一直到199,并且索引文件依次记录每个分区的数据长度,这样就能通过索引文件快速定位到某个分区在数据文件中的位置,方便下游task来读取shuffle数据。

题外

RadixSort

基数排序,又称为桶排序,是一种特殊的排序方法,其思路在于将待排序的数字对每一位排序,先排个位数,再十位,再百位以此类推。排序基于10个有序的桶,对应0-9,当前位的数字放入对应桶里,用桶的顺序表示数字的顺序。从其原理可以看出,影响其排序性能在于数字大小和数量,假设有m个n位数进行排序,那么时间复杂度为:O(m*n),而n不会很大,因此这种排序算法有很好的排序性能。spark shuffle排序的场景就是基于分区id排序,而分区id都是小数字,非常适合用桶排序。要是缺点,基数排序唯一的缺点就是需要另外维护数字和桶之间的映射关系,增加对内存使用。

TimSort

TimSort是由Tim创建的排序方法,其思想是找出已经有序的子序列,然后直接利用这部分序列,避免冗余排序。如果某一点序列是有序的,那么可以用二分插入来对下一个数据排序。现在TimSort是一种优化排序算法,在JDK7的时候引入到了Arrays.sort(),并且作为默认的排序算法。

 

 

===========shuffle 运行原理介绍版本二 ==========================
 

ShuffleManager里有四个接口,register,reader,writer和stop。

 

核心接口则是reader和writer,当前版本reader接口只有1个实现,writer接口有3个实现,每种实现分别对应不同的场景。

 

writer 3种:BypassMergeSortShuffleWriter , UnsafeShuffleWriter, SortShuffleWriter

 

1.spark的shuffleManager是负责shuffle过程的执行、计算和处理的组件。

shuffleManager是trait,主要实现类有两个:HashShuffleManager和SortShuffleManager。

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.HashShuffleManager:

shuffle write阶段,默认Mapper阶段会为Reducer阶段的每一个Task单独创建一个文件来保存该Task中要使用的数据。

优点:就是操作数据简单。

缺点:但是在一些情况下(例如数据量非常大的情况)会造成大量文件(M*R,其中M代表Mapper中的所有的并行任务数量,R代表Reducer中所有的并行任务数据)大数据的随机磁盘I/O操作且会形成大量的Memory(极易造成OOM)。

HashShuffleManager产生的问题:

第一:不能够处理大规模的数据

第二:Spark不能够运行在大规模的分布式集群上!

改进方案:Consolidate机制:

spark.shuffle.consolidateFiles  该参数默认值为false,将其设置为true即可开启优化机制

后来的改善是加入了Consolidate机制来将Shuffle时候产生的文件数量减少到C*R个(C代表在Mapper端,同时能够使用的cores数量,R代表Reducer中所有的并行任务数量)。但是此时如果Reducer端的并行数据分片过多的话则C*R可能已经过大,此时依旧没有逃脱文件打开过多的厄运!!!Consolidate并没有降低并行度,只是降低了临时文件的数量,此时Mapper端的内存消耗就会变少,所以OOM也就会降低,另外一方面磁盘的性能也会变得更好。

开启consolidate机制之后,在shuffle write过程中,task就不是为下游stage的每个task创建一个磁盘文件了。此时会出现shuffleFileGroup的概念,每个shuffleFileGroup会对应一批磁盘文件,磁盘文件的数量与下游stage的task数量是相同的。一个Executor上有多少个CPU core,就可以并行执行多少个task。而第一批并行执行的每个task都会创建一个shuffleFileGroup,并将数据写入对应的磁盘文件内。

前提:每个Excutor分配1个cores,假设第二个stage有100个task,第一个stage有50个task,总共还是有10个Executor,每个Executor执行5个task。那么原本使用未经优化的HashShuffleManager时,每个Executor会产生500个磁盘文件,所有Executor会产生5000个磁盘文件的。但是此时经过优化之后,每个Executor创建的磁盘文件的数量的计算公式为:CPU core的数量 * 下一个stage的task数量。也就是说,每个Executor此时只会创建100个磁盘文件,所有Executor只会创建1000个磁盘文件。

当Executor的CPU core执行完一批task,接着执行下一批task时,下一批task就会复用之前已有的shuffleFileGroup,包括其中的磁盘文件。也就是说,此时task会将数据写入已有的磁盘文件中,而不会写入新的磁盘文件中。因此,consolidate机制允许不同的task复用同一批磁盘文件,这样就可以有效将多个task的磁盘文件进行一定程度上的合并,从而大幅度减少磁盘文件的数量,进而提升shuffle write的性能。

3.SortShuffleManager

在Mapper中的每一个ShuffleMapTask中产生两个文件:Data文件和Index文件,其中Data文件是存储当前Task的Shuffle输出的。而index文件中则存储了Data文件中的数据通过Partitioner的分类信息,此时下一个阶段的Stage中的Task就是根据这个Index文件获取自己所要抓取的上一个Stage中的ShuffleMapTask产生的数据的,Reducer就是根据index文件来获取属于自己的数据。

涉及问题:Sorted-based Shuffle:会产生 2*M(M代表了Mapper阶段中并行的Partition的总数量,其实就是ShuffleMapTask的总数量)个Shuffle临时文件。

SortShuffleManager由于有一个磁盘文件merge的过程,因此大大减少了文件数量。比如第一个stage有50个task,总共有10个Executor,每个Executor执行5个task,而第二个stage有100个task。由于每个task最终只有一个磁盘文件,因此此时每个Executor上只有5个磁盘文件,所有Executor只有50个磁盘文件。

默认Sort-based Shuffle的几个缺陷:

1)如果Mapper中Task的数量过大,依旧会产生很多小文件,此时在Shuffle传递数据的过程中到Reducer端,reduce会需要同时打开大量的记录来进行反序列化,导致大量的内存消耗和GC的巨大负担,造成系统缓慢甚至崩溃!

2)如果需要在分片内也进行排序的话,此时需要进行Mapper端和Reducer端的两次排序!!!

优化:

可以改造Mapper和Reducer端,改框架来实现一次排序。

4.bypass运行机制

下图说明了bypass SortShuffleManager的原理。bypass运行机制的触发条件如下:

1) shuffle map task数量小于spark.shuffle.sort.bypassMergeThreshold参数的值。

这个参数仅适用于SortShuffleManager,如前所述,SortShuffleManager在处理不需要排序的Shuffle操作时,由于排序带来性能的下降。这个参数决定了在这种情况下,当Reduce分区的数量小于多少的时候,在SortShuffleManager内部不使用Merge Sort的方式处理数据,而是与Hash Shuffle类似,直接将分区文件写入单独的文件,不同的是,在最后一步还是会将这些文件合并成一个单独的文件。这样通过去除Sort步骤来加快处理速度,代价是需要并发打开多个文件,所以内存消耗量增加,本质上是相对HashShuffleMananger一个折衷方案。这个参数的默认值是200个分区,如果内存GC问题严重,可以降低这个值。

2) 不是聚合类的shuffle算子(比如reduceByKey)。

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

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

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

 

5.shuffle相关参数调优

以下是Shffule过程中的一些主要参数,这里详细讲解了各个参数的功能、默认值以及基于实践经验给出的调优建议。

spark.shuffle.file.buffer

默认值:32k

参数说明:该参数用于设置shuffle write task的BufferedOutputStream的buffer缓冲大小。将数据写到磁盘文件之前,会先写入buffer缓冲中,待缓冲写满之后,才会溢写到磁盘。

调优建议:如果作业可用的内存资源较为充足的话,可以适当增加这个参数的大小(比如64k),从而减少shuffle write过程中溢写磁盘文件的次数,也就可以减少磁盘IO次数,进而提升性能。在实践中发现,合理调节该参数,性能会有1%~5%的提升。

spark.reducer.maxSizeInFlight

默认值:48m

参数说明:该参数用于设置shuffle read task的buffer缓冲大小,而这个buffer缓冲决定了每次能够拉取多少数据。

调优建议:如果作业可用的内存资源较为充足的话,可以适当增加这个参数的大小(比如96m),从而减少拉取数据的次数,也就可以减少网络传输的次数,进而提升性能。在实践中发现,合理调节该参数,性能会有1%~5%的提升。

spark.shuffle.io.maxRetries

默认值:3

参数说明:shuffle read task从shuffle write task所在节点拉取属于自己的数据时,如果因为网络异常导致拉取失败,是会自动进行重试的。该参数就代表了可以重试的最大次数。如果在指定次数之内拉取还是没有成功,就可能会导致作业执行失败。

调优建议:对于那些包含了特别耗时的shuffle操作的作业,建议增加重试最大次数(比如60次),以避免由于JVM的full gc或者网络不稳定等因素导致的数据拉取失败。在实践中发现,对于针对超大数据量(数十亿~上百亿)的shuffle过程,调节该参数可以大幅度提升稳定性。

spark.shuffle.io.retryWait

默认值:5s

参数说明:具体解释同上,该参数代表了每次重试拉取数据的等待间隔,默认是5s。

调优建议:建议加大间隔时长(比如60s),以增加shuffle操作的稳定性。

spark.shuffle.memoryFraction

默认值:0.2

参数说明:该参数代表了Executor内存中,分配给shuffle read task进行聚合操作的内存比例,默认是20%。

调优建议:在资源参数调优中讲解过这个参数。如果内存充足,而且很少使用持久化操作,建议调高这个比例,给shuffle read的聚合操作更多内存,以避免由于内存不足导致聚合过程中频繁读写磁盘。在实践中发现,合理调节该参数可以将性能提升10%左右。

spark.shuffle.manager

默认值:sort

参数说明:该参数用于设置ShuffleManager的类型。Spark 1.5以后,有三个可选项:hash、sort和tungsten-sort。HashShuffleManager是Spark 1.2以前的默认选项,但是Spark 1.2以及之后的版本默认都是SortShuffleManager了。tungsten-sort与sort类似,但是使用了tungsten计划中的堆外内存管理机制,内存使用效率更高。

调优建议:由于SortShuffleManager默认会对数据进行排序,因此如果你的业务逻辑中需要该排序机制的话,则使用默认的SortShuffleManager就可以;而如果你的业务逻辑不需要对数据进行排序,那么建议参考后面的几个参数调优,通过bypass机制或优化的HashShuffleManager来避免排序操作,同时提供较好的磁盘读写性能。这里要注意的是,tungsten-sort要慎用,因为之前发现了一些相应的bug。

spark.shuffle.sort.bypassMergeThreshold

默认值:200

参数说明:当ShuffleManager为SortShuffleManager时,如果shuffle read task的数量小于这个阈值(默认是200),则shuffle write过程中不会进行排序操作,而是直接按照未经优化的HashShuffleManager的方式去写数据,但是最后会将每个task产生的所有临时磁盘文件都合并成一个文件,并会创建单独的索引文件。

调优建议:当你使用SortShuffleManager时,如果的确不需要排序操作,那么建议将这个参数调大一些,大于shuffle read task的数量。那么此时就会自动启用bypass机制,map-side就不会进行排序了,减少了排序的性能开销。但是这种方式下,依然会产生大量的磁盘文件,因此shuffle write性能有待提高。

spark.shuffle.consolidateFiles

默认值:false

参数说明:如果使用HashShuffleManager,该参数有效。如果设置为true,那么就会开启consolidate机制,会大幅度合并shuffle write的输出文件,对于shuffle read task数量特别多的情况下,这种方法可以极大地减少磁盘IO开销,提升性能。

调优建议:如果的确不需要SortShuffleManager的排序机制,那么除了使用bypass机制,还可以尝试将spark.shffle.manager参数手动指定为hash,使用HashShuffleManager,同时开启consolidate机制。在实践中尝试过,发现其性能比开启了bypass机制的SortShuffleManager要高出10%~30%

6.shuffle方式的选择

两者的性能比较,取决于内存,排序,文件操作等因素的综合影响。

对于不需要进行排序的Shuffle操作来说,如repartition等,如果文件数量不是特别巨大,HashShuffleManager面临的内存问题不大,而SortShuffleManager需要额外的根据Partition进行排序,显然HashShuffleManager的效率会更高。

而对于本来就需要在Map端进行排序的Shuffle操作来说,如ReduceByKey等,使用HashShuffleManager虽然在写数据时不排序,但在其它的步骤中仍然需要排序,而SortShuffleManager则可以将写数据和排序两个工作合并在一起执行,因此即使不考虑HashShuffleManager的内存使用问题,SortShuffleManager依旧可能更快。


 

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值