Spark 常用算子。RDD 持久化。 Spark 共享变量 Broadcast,Accumulator

Spark32个常用算子总结

Spark32个常用算子总结_Fortuna_i的博客-CSDN博客

1、Transformations算子

含义:create a new dataset from an existing on 从已经存在的创建一个新的数据集

RDDA---------transformation----------->RDDB

Spark32个常用算子总结 Spark32个常用算子总结_Fortuna_i的博客-CSDN博客

  • map:map(func)

将func函数作用到数据集的每一个元素上,生成一个新的分布式的

数据集返回

例子:1

data = [1, 2, 3, 4, 5]
rdd1 = sc.parallelize(data)
rdd2 = rdd1.map(lambda x:x*2)
print(rdd2.collect())


例子2:

a = sc.parallelize(["dog","tiger","lion","cat","panther","eagle"]).map(lambda x:(x,1))
print(a.collect())

结果:

  • map  和 flatMap 区别? 

这两者都是遍历RDD中数据,并对数据进行数据操作,并且会的到一个全新RDD

Map多用于计算或处理一些特殊数据类型,不能使用扁平化处理的数据类型

flatMap不仅可以对数据遍历处理,而且可以将存在RDD中集合中数据进行处理并且存储到一个新的集合中

所以两种的使用本质上没有区别,但flatMap比Map多出了对集合数据压平的作用,flatMap会将其返回的数组全部拆散,然后合成到一个数组中

ps:一般情况下在Spark开发中较多使用flatMap,但是 flatMap不能使用所有的场景,所以也会使用map来进行处理数据

输入的item能够被map到0或者多个items输出,返回值是一个Sequence (拆分) 

        

data = ["hello spark","hello word","hello word"]
RDD = sc.parallelize(data)
print(RDD.flatMap(lambda line:line.split(" ")).collect())
结果
 

 val arrayRDD =sc.parallelize(Array("a_b","c_d","e_f"))
    arrayRDD.foreach(println) //打印结果1

    arrayRDD.map(string=>{
      string.split("_")
    }).foreach(x=>{
      println(x.mkString(",")) //打印结果2
    })

    arrayRDD.flatMap(string=>{
      string.split("_")
    }).foreach(x=>{
      println(x.mkString(","))//打印结果3
    })

map函数后,RDD的值为 Array(Array("a","b"),Array("c","d"),Array("e","f"))

flatMap函数处理后,RDD的值为 Array("a","b","c","d","e","f")

即最终可以认为,flatMap会将其返回的数组全部拆散,然后合成到一个数组中

  • filter(过滤)

选出所有func返回值为true的元素,生成一个新的分布式数据集返回

RDDA = sc.parallelize([1, 2, 3, 4, 5]).map(lambda x:x*2).filter(lambda x:x>5)
print(RDDA.collect())

  • RDD中reduceBykey与groupByKey哪个性能好,

val a = sc.parallelize(List("dog", "cat", "owl", "gnu", "ant"), 2)
val b = a.map(x => (x.length, x))
b.reduceByKey(_ + _).collect
res86: Array[(Int, String)] = Array((3,dogcatowlgnuant))

val a = sc.parallelize(List("dog", "tiger", "lion", "cat", "panther", "eagle"), 2)
val b = a.map(x => (x.length, x))
b.reduceByKey(_ + _).collect
res87: Array[(Int, String)] = Array((4,lion), (3,dogcat), (7,panther), (5,tigereagle))

reduceByKey主要作用是聚合,groupByKey主要作用是分组。(function对于key值来进行聚合)

reduceByKey:reduceByKey会在结果发送至reducer之前会对每个mapper在本地进行merge,有点类似于在MapReduce中的combiner。这样做的好处在于,在map端进行一次reduce之后,数据量会大幅度减小,从而减小传输,保证reduce端能够更快的进行结果计算。

reduceByKey在每个分区移动数据之前,会对每一个分区中的key所对应的values进行求和,然后再利用reduce对所有分区中的每个键对应的值进行再次聚合。整个过程如图:

groupByKey:groupByKey会对每一个RDD中的value值进行聚合形成一个序列(Iterator),此操作发生在reduce端,所以势必会将所有的数据通过网络进行传输,造成不必要的浪费。同时如果数据量十分大,可能还会造成OutOfMemoryError。

通过以上对比可以发现在进行大量数据的reduce操作时候建议使用reduceByKey。不仅可以提高速度,还是可以防止使用groupByKey造成的内存溢出问题。

groupByKey是把分区中的所有的键值对都进行移动,然后再进行整体求和,这样会导致集群节点之间的开销较大,传输效率较低,也是上文所说的内存溢出错误出现的根本原因 

  • sortByKey()默认按照key值升序排列

data = ["hello spark", "hello word", "hello word"]
RDD = sc.parallelize(data)
RDD2 = RDD.flatMap(lambda line: line.split(" ")).map(lambda x: (x, 1))
RDD3 = RDD2.reduceByKey(lambda a, b: a + b)
sortRDD = RDD3.sortByKey()
sortRDD.collect()

加False参数降序排列

实现按照数字排序

使用map交换一下顺序

  • union连接(把RDD连接起来)

a = sc.parallelize([1, 2, 3])
b = sc.parallelize([4, 5, 6])
a.union(b).collect()
结果
  • distinct(去除重复)

a = sc.parallelize([1, 2, 3])
b = sc.parallelize([4, 3, 3])
a.union(b).distinct().collect()

  • subtract(去掉含有重复的项)

val a = sc.parallelize(1 to 9, 3)
val b = sc.parallelize(1 to 3, 3)
val c = a.subtract(b)
c.collect
res3: Array[Int] = Array(6, 9, 4, 7, 5, 8)
  • sample

val a = sc.parallelize(1 to 10000, 3)
a.sample(false, 0.1, 0).count
res24: Long = 960
  • Join

//设置运行环境  
    val conf = new SparkConf().setAppName("SparkRDDJoinOps").setMaster("local[4]")  
    val sc = new SparkContext(conf)  
    //建立一个基本的键值对RDD,包含ID和名称,其中ID为1、2、3、4  
    val rdd1 = sc.makeRDD(Array(("1","Spark"),("2","Hadoop"),("3","Scala"),("4","Java")),2)  
    //建立一个行业薪水的键值对RDD,包含ID和薪水,其中ID为1、2、3、5  
    val rdd2 = sc.makeRDD(Array(("1","30K"),("2","15K"),("3","25K"),("5","10K")),2)  
  
    println("//下面做Join操作,预期要得到(1,×)、(2,×)、(3,×)")  
    val joinRDD=rdd1.join(rdd2).collect.foreach(println)  
  
    println("//下面做leftOutJoin操作,预期要得到(1,×)、(2,×)、(3,×)、(4,×)")  
    val leftJoinRDD=rdd1.leftOuterJoin(rdd2).collect.foreach(println)  
    println("//下面做rightOutJoin操作,预期要得到(1,×)、(2,×)、(3,×)、(5,×)")  
    val rightJoinRDD=rdd1.rightOuterJoin(rdd2).collect.foreach(println)  
  
    sc.stop()  


/*
//下面做Join操作,预期要得到(1,×)、(2,×)、(3,×)  
(2,(Hadoop,15K))  
(3,(Scala,25K))  
(1,(Spark,30K))  
//下面做leftOutJoin操作,预期要得到(1,×)、(2,×)、(3,×)、(4,×)  
(4,(Java,None))  
(2,(Hadoop,Some(15K)))  
(3,(Scala,Some(25K)))  
(1,(Spark,Some(30K)))  
//下面做rightOutJoin操作,预期要得到(1,×)、(2,×)、(3,×)、(5,×)  
(2,(Some(Hadoop),15K))  
(5,(None,10K))  
(3,(Some(Scala),25K))  
(1,(Some(Spark),30K))
*/
  • join(内连接,左外连接,右外连接)

a = sc.parallelize([("A","a1"),("C","c1"),("D","d1"),("F","f1"),("F","f2")])
b = sc.parallelize([("A","a2"),("C","c2"),("C","c3"),("E","e1")]) 
a.join(b).collect()               # 内连接
a.rightOuterJoin(b).collect()     # 右外连接
a.leftOuterJoin(b).collect()      # 左外连接
a.fullOuterJoin(b).collect()      # 全连接 

内连接

得到两者key值相同的值的集合

右外连接:以右表key为基准进行连接

左外连接:以左表以右表key为基准进行连接

全连接:左右连接的并集所有的都出来

2、Actions算子

data = [1, 2, 3, 4, 5, 6, 7, 8, 9,10]
rdd = sc.parallelize(data)

rdd .collect
rdd.count()    # 数量
rdd.take(3)    # 前几个
rdd.max()      # 最大值
rdd.min()      # 最小值
rdd.sum()      # 求和
rdd.reduce(lambda x,y:x+y)    # 求和
rdd.foreach(lambda x:print(x))    #foreach遍历
rdd.saveAsTextFile        #写入文件系统
        

6.take(n)

返回一个包含数据集前n个元素的数组(从0下标到n-1下标的元素),不排序。

11.countByKey()

用于统计RDD[K,V]中每个K的数量,返回具有每个key的计数的(k,int)pairs的hashMap。

8.saveAsTextFile(path)

将dataSet中元素以文本文件的形式写入本地文件系统或者HDFS等。Spark将对每个元素调用toString方法,将数据元素转换为文本文件中的一行记录。

若将文件保存到本地文件系统,那么只会保存在executor所在机器的本地目录。
 

 Spark RDD常用算子整理

Spark RDD常用算子整理_Yore - Home-CSDN博客_rdd 算子

Spark Core核心----RDD常用算子编程_莫安逸无风雪-CSDN博客

spark的RDD中的action(执行)和transformation(转换)两种操作中常见函数介绍

spark的RDD中的action(执行)和transformation(转换)两种操作中常见函数介绍_helloxiaozhe的博客-CSDN博客

二. Spark RDD 持久化

Spark 非常重要的一个功能特性就是可以将 RDD 持久化在内存中,当对 RDD 执行持久化操作时,每个节点都会将自己操作的 RDD 的 partition 持久化到内存中, 并且在之后对该 RDD 的反复使用中,直接使用内存的 partition。这样的话,对于针 对一个 RDD 反复执行多个操作的场景,就只要对 RDD 计算一次即可,后面直接使 用该 RDD,而不需要反复计算多次该 RDD。

巧妙使用 RDD 持久化,甚至在某些场景下,可以将 Spark 应用程序的性能提高 10 倍。对于迭代式算法和快速交互式应用来说,RDD 持久化是非常重要的。

例如,读取一个有着数十万行数据的 HDFS 文件,形成 linesRDD,这一读取过 程会消耗大量时间,在 count 操作结束后,linesRDD 会被丢弃,会被后续的数据覆 盖,当第二次再次使用 count 时,又需要重新读取 HDFS 文件数据,再次形成新的 linesRDD,这回导致反复消耗大量时间,会严重降低系统性能。

如果在读取完成后将 linesRDD 缓存起来,那么下一次执行 count 操作时将会直 接使用缓存起来的 linesRDD,这会节省大量的时间。

要持久化一个 RDD,只要调用其 cache()或者 persist()方法即可。在该 RDD 第 一次被计算出来时,就会直接缓存在每个节点中,而且 Spark 的持久化机制还是自 动容错的,如果持久化的 RDD 的任何 partition 丢失了,那么 Spark 会自动通过其源 RDD,使用 transformation 操作重新计算该 partition。

cache()和 persist()的区别在于,

cache()是 persist()的一种简化方式,cache()的底 层就是调用的 persist()的无参版本,同时就是调用 persist(MEMORY_ONLY),将输 入持久化到内存中。如果需要从内存中清除缓存,那么可以使用 unpersist()方法。

Spark 自己也会在 shuffle 操作时,进行数据的持久化,比如写入磁盘,主要是 为了在节点失败时,避免需要重新计算整个过程

三, Spark 共享变量

Spark 一个非常重要的特性就是共享变量。

默认情况下,如果在一个算子的函数中使用到了某个外部的变量,那么这个变 量的值会被拷贝到每个 task 中,此时每个 task 只能操作自己的那份变量副本。如果 多个 task 想要共享某个变量,那么这种方式是做不到的。

Spark 为此提供了两种共享变量,一种是 Broadcast Variable(广播变量),另一 种是 Accumulator(累加变量)。

Broadcast Variable 会将用到的变量,仅仅为每个节点拷贝一份,更大的用途是优化性能,减少网络传输以及内存损耗。Accumulator 则可以让多个 task 共同操作一份变量,主要可以进行累加操作。

Broadcast Variable 是共享读变量,task 不能去修改它,而 Accumulator 可以让多个 task 操作一个变量。

3.1、Broadcast

1. 广播变量

        Driver端的变量的值事先广播到每一个Worker端,以后再计算过程中只需要从本地拿取该值即可,避免网络IO,提高计算效率。

        广播变量:实际上就是Driver端的变量通过Broadcast方法传输到Executor端,Executor端不能修改广播变量的值,使用广播变量是为了减少Executor端的数据备份,减少Executor端的内存。

        广播变量允许程序员在每个机器上保留缓存的只读变量,而不是给每个任务发送一个副本。 例如,可以使用它们以有效的方式为每个节点提供一个大型输入数据集的副本。Spark 还尝试使用高效的广播算法分发广播变量,以降低通信成本。

        Spark action 被划分为多个 Stages,被多个“shuffle”操作(宽依赖)所分割。Spark 自动广播每个阶段任务所需的公共数据(一个 Stage 中多个 task 使用的数据),以 这种方式广播的数据以序列化形式缓存,并在运行每个任务之前反序列化。 这意味着,显式创建广播变量仅在跨多个阶段的任务需要相同数据或者以反序列化格式缓存数据很重要时才有用。

        Spark 提供的 Broadcast Variable 是只读的,并且在每个节点上只会有一个副本, 而不会为每个 task 都拷贝一份副本,因此,它的最大作用,就是减少变量到各个节 点的网络传输消耗,以及在各个节点上的内存消耗。此外,Spark 内部也使用了高效 的广播算法来减少网络消耗。

可以通过调用 SparkContext 的 broadcast()方法来针对每个变量创建广播变量。 然后在算子的函数内,使用到广播变量时,每个节点只会拷贝一份副本了,每个节 点可以使用广播变量的 value()方法获取值 

1. 为什么使用Broadcast广播

举个例子: 
val factor = 3 
rdd.map( num => num*factor)

以上两行代码显示了rdd的一个map操作,其中factor是一个外部变量。默认情况下,算子的函数内,如果使用到了外部变量,那么会将这个变量拷贝到执行这个函数的每一个task中。如果该变量非常大的话,那么网络传输耗费的资源会特别大,而且在每个节点上占用的内存空间也特别大。 
Spark提供的Broadcast Variable,是只读的。并且在每个节点上只会有一份副本,而不会为每个task都拷贝一份副本。因此其最大作用,就是减少变量到各个节点的网络传输消耗,以及在各个节点上的内存消耗。此外,spark自己内部也使用了高效的广播算法来减少网络消耗。 
可以通过调用SparkContext的broadcast()方法,来针对某个变量创建广播变量。然后在算子的函数内,使用到广播变量时,每个节点只会拷贝一份副本了。每个节点可以使用广播变量的value()方法获取值。广播变量是只读的。 

2. 具体示例:IP归属地查询

ip.txt文件的内容:
1.0.1.0|1.0.3.255|16777472|16778239|亚洲|中国|福建|福州||电信|350100|China|CN|119.306239|26.075302
1.0.8.0|1.0.15.255|16779264|16781311|亚洲|中国|广东|广州||电信|440100|China|CN|113.280637|23.125178
1.0.32.0|1.0.63.255|16785408|16793599|亚洲|中国|广东|广州||电信|440100|China|CN|113.280637|23.125178
1.1.0.0|1.1.0.255|16842752|16843007|亚洲|中国|福建|福州||电信|350100|China|CN|119.306239|26.075302
1.1.2.0|1.1.7.255|16843264|16844799|亚洲|中国|福建|福州||电信|350100|China|CN|119.306239|26.075302 
object IPFind {

  //Ip地址转换成十进制数字
  def ip2Long(ip: String): Long = {
    val fragments = ip.split("[.]")
    var ipNum = 0L
    for (i <- 0 until fragments.length) {
      ipNum = fragments(i).toLong | ipNum << 8L
    }
    ipNum
  }

  //二分查找
  def binarySearch(lines: Array[(String, String, String)], ip: Long): Int = {
    var low = 0
    var high = lines.length - 1
    while (low <= high) {
      val middle = (low + high) / 2
      if ((ip >= lines(middle)._1.toLong) && (ip <= lines(middle)._2.toLong))
        return middle
      if (ip < lines(middle)._1.toLong)
        high = middle - 1
      else {
        low = middle + 1
      }
    }
    -1
  }

  def main(args: Array[String]): Unit = {
    System.setProperty("hadoop.home.dir", "D:\\hadoop-2.6.1");
    val conf = new SparkConf().setAppName("IpJdbc2").setMaster("local[2]")
    val sc = new SparkContext(conf)

    val rdd1 = sc.textFile("D:\\textdata\\ip.txt").map(line =>{
      val fields = line.split("\\|")
      val start_num = fields(2)
      val end_num = fields(3)
      val province = fields(6)
      (start_num,end_num,province)
    })

	//生成不可变的集合,广播到task中去
    val rpRulesBroakcast =  rdd1.collect()
    val ipRulesBroadcast = sc.broadcast(rpRulesBroakcast)

	//读取要查找的Ip地址
    val rdd3 = sc.textFile("D:\\textdata\\ip.txt").map(line =>{
      val fields = line.split("\\|")
      fields(1)
    })

    //在每个task中获取广播值,进行查询
    val result = rdd3.map(ip =>{
      val ipNum = ip2Long(ip.toString)
      val index = binarySearch(ipRulesBroadcast.value,ipNum)
      val info = ipRulesBroadcast.value(index)
      //(ip的起始Num, ip的结束Num,省份名)
      info
    }).map(t => (t._3, 1)).reduceByKey(_+_)

}

3.2、Accumulator累加器

        累加器(accumulator):Accumulator 是仅仅被相关操作累加的变量,因此可以 在并行中被有效地支持。它们可用于实现计数器(如 MapReduce)或总和计数。

Accumulator 是存在于 Driver 端的,从节点不断把值发到 Driver 端,在 Driver 端计数(Spark UI 在 SparkContext 创建时被创建,即在 Driver 端被创建,因此它可 以读取 Accumulator 的数值),存在于 Driver 端的一个值,从节点是读取不到的。

Spark 提供的 Accumulator 主要用于多个节点对一个变量进行共享性的操作。 Accumulator 只提供了累加的功能,但是却给我们提供了多个 task 对于同一个变量 并行操作的功能,但是 task 只能对 Accumulator 进行累加操作,不能读取它的值, 只有 Driver 程序可以读取 Accumulator 的值。

自定义累加器类型的功能在 1.X 版本中就已经提供了,但是使用起来比较麻烦, 在 2.0 版本后,累加器的易用性有了较大的改进,而且官方还提供了一个新的抽象 类:AccumulatorV2 来提供更加友好的自定义类型累加器的实现方式。

官方同时给出了一个实现的示例:CollectionAccumulator 类,这个类允许以集 合的形式收集 spark 应用执行过程中的一些信息。例如,我们可以用这个类收集 Spark 处理数据时的一些细节,当然,由于累加器的值最终要汇聚到 driver 端,为了避免 driver 端的 outofmemory 问题,需要对收集的信息的规模要加以控制,不宜过大。

class SessionAggrStatAccumulator extends AccumulatorV2[String, mutable.HashMap[String, Int]] {
	// 保存所有聚合数据
	private val aggrStatMap = mutable.HashMap[String, Int]()
	// 判断是否为初始值
	override def isZero: Boolean = {
		aggrStatMap.isEmpty
	}
	// 复制累加器
	override def copy() : AccumulatorV2[String, mutable.HashMap[String, Int]] = {
		val newAcc = new SessionAggrStatAccumulator aggrStatMap.synchronized {
			newAcc.aggrStatMap++=this.aggrStatMap
		}
		newAcc
	}
	// 重置累加器中的值
	override def reset() : Unit = {
		aggrStatMap.clear()
	}
	// 向累加器中添加另一个值
	override def add(v: String) : Unit = {
		if (!aggrStatMap.contains(v)) aggrStatMap += (v - >0) aggrStatMap.update(v, aggrStatMap(v) + 1)
	}
	// 各个 task 的累加器进行合并的方法
	// 合并另一个类型相同的累加器
	override def merge(other: AccumulatorV2[String, mutable.HashMap[String, Int]]) : Unit = {
		other match {
		case acc:
			SessionAggrStatAccumulator = >{ (this.aggrStatMap / :acc.value) {
				case(map, (k, v)) = >map += (k - >(v + map.getOrElse(k, 0)))
				}
			}
		}
	}
	// 获取累加器中的值
	// AccumulatorV2对外访问的数据结果
	override def value: mutable.HashMap[String, Int] = {
		this.aggrStatMap
	}
}

 通常情况下,当向Spark操作(如map,reduce)传递一个函数时,它会在一个远程集群节点上执行,它会使用函数中所有变量的副本。这些变量被复制到所有的机器上,远程机器上并没有被更新的变量会向驱动程序回传,也就是说有结果Driver程序是拿不到的!共享变量就是为了解决这个问题。本博文介绍其中的一种累加器Accumulator。
  累加器只能够增加。 只有driver能获取到Accumulator的值(使用value方法),Task(excutor)只能对其做增加操作(使用 +=)。下面是累加器的实现代码

Spark提供的Accumulator,主要用于多个节点对一个变量进行共享性的操作。Accumulator只提供了累加的功能。但是确给我们提供了多个task对一个变量并行操作的功能。但是task只能对Accumulator进行累加操作,不能读取它的值。只有Driver程序可以读取Accumulator的值。 

 val accum = sc.longAccumulator("longAccum") //统计奇数的个数
    val sum = sc.parallelize(Array(1, 2, 3, 4, 5, 6, 7, 8, 9), 1).filter(n => {
      println("n:" + n)
      if (n % 2 != 0) accum.add(1L)
      println("after n:" + n)
      n % 2 == 0 //返回偶数 
    }).reduce(_ + _) //对返回的偶数进行求和

    println("sum: " + sum) //sum: 20 
    println("accum: " + accum.value) // accum: 5

累加器的使用陷阱:

我们都知道,spark中的一系列transform操作会构成一串长的任务链,此时需要通过一个action操作来触发,accumulator也是一样。因此在一个action操作之前,你调用value方法查看其数值,肯定是没有任何变化的。

如果程序中有两次 action操作,就会触发两次transform操作,相应地,累加器就会加两次。

    val accum = sc.accumulator(0, "Error Accumulator")
    val data = sc.parallelize(1 to 10)
    //用accumulator统计偶数出现的次数,同时偶数返回0,奇数返回1
    val newData = data.map { x => {
      if (x % 2 == 0) {
        accum += 1
        0
      } else 1
    }
    }
    //使用action操作触发执行
    newData.count
    println("accum.value: " + accum.value) //此时accum的值为5,是我们要的结果

    //继续操作,查看刚才变动的数据,foreach也是action操作
    newData.foreach(println)
    //上个步骤没有进行累计器操作,可是累加器此时的结果已经是10了
    println("accum.value: " + accum.value) //这并不是我们想要的结果

解决办法

看了上面的分析,大家都有这种印象了,那就是使用累加器的过程中只能使用一次action的操作才能保证结果的准确性。

事实上,还是有解决方案的,只要将任务之间的依赖关系切断就可以了。什么方法有这种功能呢?你们肯定都想到了,cache,persist。调用这个方法的时候会将之前的依赖切除,后续的累加器就不会再被之前的transfrom操作影响到了。

    val newData = data.map { x => {
    //同上
    }
    //使用cache缓存数据,切断依赖。
    newData.cache.count
    println("accum.value: " + accum.value)//此时accum的值为5

    newData.foreach(println)
    println("accum.value: " + accum.value)//此时的accum依旧是5

总结

使用Accumulator时,为了保证准确性,只使用一次action操作。如果需要使用多次则使用cache或persist操作切断依赖。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

四月天03

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值