Spark RDD算子学习笔记


文章学习笔记内容来源:拉勾教育大数据开发高薪训练营。

记录一下Spark RDD算子学习笔记,这里记录一下RDD算子的使用,重点还是学习Spark SQL。Spark SQL比RDD开发更简单,Spark的优化器对SQL语句做了大量的优化,一般情况下实现同样的功能,Spark SQL更容易也更高效。使用的spark2.4.5,scala。
Spark SQL学习笔记[https://blog.csdn.net/qq_38914431/article/details/109741135]

什么是RDD

RDD是spark的基石,是实现spark数据处理核心抽象。RDD是一个抽象类,它代表一个不可变、可分区、里面的元素可并行计算的集合,具有如下特征。

  • 一个分区的列表
  • 一个计算函数compute,对每个分区进行计算
  • 对其他RDDs的依赖(宽依赖、窄依赖)列表
  • 对key-value RDDs来说,存在一个分区器(Parititoner)[可选的]
  • 对每个分区有一个优先位置的列表[可选的]

RDD逻辑上是分区的,每个分区的数据是抽象存在的,在计算的时候会通过一个compute函数得到每个分区的数据。RDD是只读,一旦RDD被创建,数据则不能修改,只能通过一个RDD转换为另外一个RDD。在RDD的转换操作算子有两类

  • transformation。用来对RDD进行转化,延迟执行(Lazy)
  • action。用来触发RDD的计算;得到相关计算结果或者将RDD保存的文件系统中

RDDs通过操作算子进行转换,转换得到新的RDD包含了从其他RDDs,衍生所必需的信息,RDDs之间维护着这种血缘关系(lineage),也称为依赖,有两种依赖。

  • 窄依赖。RDDs之间分区是一一对应的(1:1 或 n:1)
    -宽依赖。子RDD每个分区与父RDD的每个分区都有关,是多对多的关系(即n:m)。有shuffle发生

对于多次使用的同一个RDD可以进行将RDD缓存起来,该RDD只有在第一次计算的时候会根据血缘关系得到分区的数据,在后续其他地方用到该RDD的时候,会直接从缓存处取而不用再根据血缘关系计算,这样就加速后期的重用。
RDD的血缘关系可以容错,对于计算迭代比较长的,RDDs之间的血缘关系会越来越长,出错后,需要经过非常长的血缘关系重新计算,对性能有影响。RDD支持 checkpoint 将数据保存到持久化的存储中,这样就可以切断之前的血缘关
系,因为checkpoint后的RDD不需要知道它的父RDDs了,它可以从 checkpoint 处拿到数据。

RDD创建方式

parallelize、makeRDD、range:主要用于测试

val rdd1 = sc.parallelize(Array(1,2,3,4,5))
// 1到100
val rdd2 = sc.parallelize(1 to 100)
//  1到6,2个分区
val rdd2 = sc.parallelize(1 to 6, 2)

val rdd3 = sc.makeRDD(List(1,2,3,4,5))
// 1到100
val rdd4 = sc.makeRDD(1 to 100)
// 1到99,间隔为3
val rdd5 = sc.range(1, 100, 3)
// 1到99,间隔为2, 10个分区
val rdd6 = sc.range(1, 100, 2 ,10)

从文件系统加载数据:主要用于生产

val lines = sc.textFile("file:///root/data/wc.txt")
val lines =sc.textFile("hdfs://linux121:9000/user/root/data/uaction.dat")

getNumPartitions获取RDD分区数,spark rdd完整demo

import org.apache.spark.SparkContext
import org.apache.spark.SparkConf

object TestSpark {
  def main(args: Array[String]): Unit = {
    // 1.创建sparkcontext
    val conf = new SparkConf().setAppName("WordCount")
    // 打包时去掉
    conf.setMaster("local[*]")
    val sc = new SparkContext(conf)
    sc.setLogLevel("WARN")
   
    val rdd5 = sc.range(1, 100, 3)
    println(rdd5.getNumPartitions)
    println(rdd5.collect().toBuffer)
    val rdd6 = sc.range(1, 100, 2 ,10)
    println(rdd6.collect().toBuffer)
    println(rdd6.getNumPartitions)

    // 关闭
    sc.stop();
  }
}

RDD算子

Transformation:用来对RDD进行转换,这个操作时延执行的(是Lazy 的)
Action:用来触发RDD的计算;得到相关计算结果 或者 将结果保存的外部系统中
Transformation:返回一个新的RDD
Action:返回结果int、double、集合(不会返回新的RDD)

宽依赖算子

常见宽依赖的算子(shuffle):**ByKey类型的、groupBy、distinct、repartition、sortBy、intersection、subtract 、cartesian、reduceByKey、countByKey

value类型

map(func)

对数据集中的每个元素都使用func,然后返回一个新的RDD

sc.makeRDD(1 to 10).map(_*2).collect
//结果 Array(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)

filter(func)

对数据集中的每个元素都使用func,然后返回一个包含使func为true的元素构成的RDD

sc.makeRDD(1 to 10).filter(_%2==0).collect
// 结果Array(2, 4, 6, 8, 10)

flatMap(func)

与 map 类似,每个输入元素被映射为0或多个输出元素

sc.parallelize(1 to 6).flatMap(1 to _).collect
// 结果Array(1, 1, 2, 1, 2, 3, 1, 2, 3, 4, 1, 2, 3, 4, 5, 1, 2, 3,4, 5, 6)

mapPartitions(func)

和map很像,但是map是将func作用在每个元素上,而mapPartitions是func作用在整个分区上。假设一个RDD有N个元素,M个分区(N>> M),那么map的函数将被调用N次,而mapPartitions中的函数仅被调用M次,一次处理一个分区中的所有元素

map和mapPartition的区别

map:每次处理一条数据
mapRartition:每次处理一个分区的数据,这个分区的数据处理完之后,原 RDD 中分区的数据才能释放,可能导致OOM。
当内存空间较大的时候建议使用 mapPartition() ,以提高处理效率

// 创建2个分区
val rdd = sc.parallelize(1 to 6, 2)
rdd.mapPartitions(items => items.filter(_>=3)).collect
// 结果 Array[Int] = Array(3, 4, 5, 6)

mapPartitionsWithIndex(func)

与 mapPartitions 类似,多了分区的索引值的信息

// 创建2个分区
val rdd = sc.parallelize(1 to 6, 2)
rdd.mapPartitionsWithIndex((index,items) => Iterator(index + ":" +items.toList)).collect
//结果 Array(0:List(1, 2, 3), 1:List(4, 5, 6))

groupBy(func)

按照传入函数的返回值进行分组。将key相同的值放入一个迭代器

val rdd = sc.parallelize(1 to 4)
val group = rdd.groupBy(_%2).collect
// 结果Array((0,CompactBuffer(2, 4)),(1,CompactBuffer(1, 3)))

glom()

将每一个分区形成一个数组,形成新的RDD类型 RDD[Array[T]]

val rdd = sc.parallelize(1 to 16,4)
rdd.glom().collect()
// 结果Array[Array[Int]] = Array(Array(1, 2, 3, 4), Array(5, 6, 7, 8), Array(9,10, 11, 12), Array(13, 14, 15, 16))

sample(withReplacement, fraction, seed)

采样算子。以指定的随机种子(seed)随机抽样出数量为fraction的数据,withReplacement表示是抽出的数据是否放回,true为有放回的抽样,false为无放回的抽样
有放回时,fraction可以大于1,代表元素被抽到的次数
无放回时,fraction代表元素被抽到的概率(0-1)

val rdd = sc.parallelize(1 to 10)
//放回抽样 Array(1, 2, 2, 7, 7, 8, 9)
rdd.sample(true,0.4,2).collect()
//不放回抽样结果 Array(1, 9)
rdd.sample(false,0.2,3).collect()

distinct([numTasks]))

对RDD元素去重后,返回一个新的RDD。可传入numTasks参数改变RDD分区数

val rdd = sc.parallelize(List(1,2,3,4,4,2,1))
rdd.distinct.collect
// 结果Array(4, 2, 1, 3)

coalesce(numPartitions)

缩减分区数,无shuffle

// 创建分区数为4
val rdd = sc.parallelize(1 to 16,4)
// 进行减少分区
val coalesceRDD = rdd.coalesce(3)
// 分区数 3
coalesceRDD.partitions.size

repartition(numPartitions)

增加或减少分区数,有shuffle

// 创建分区数为4
val rdd = sc.parallelize(1 to 16,4)
// 进行减少分区
val coalesceRDD = rdd.coalesce(3)
// 分区数 3
coalesceRDD.partitions.size

coalesce和repartition的区别

repartition:增大或减少分区数;有shuffle
coalesce:一般用于减少分区数(此时无shuffle)

sortBy(func, [ascending], [numTasks])

使用 func 对数据进行处理,对处理后的结果进行排序,默认为正序

val rdd = sc.parallelize(List(4,2,1,3))
rdd.sortBy(x => x).collect()
// 结果 Array(1, 2, 3, 4)

双Value类型交互

intersection(otherRDD)、union(otherRDD)、subtract (otherRDD)

RDD之间的交、并、差算子
union得到的RDD分区数为:两个RDD分区数之和

val rdd1 = sc.parallelize(Array(1,1,2,3,4,5,4,5))
val rdd2 = sc.parallelize(Array(1,2,1,4))
// 交集 Array(1, 2, 4)
rdd1.intersection(rdd2).collect
// 并集 Array(1, 1, 2, 3, 4, 5, 4, 5, 1, 2, 1, 4)
rdd1.union(rdd2).collect
// 差集Array(3, 5, 5)
rdd1.subtract(rdd2).collect()

cartesian(otherRDD)

笛卡尔积
cartesian得到的RDD分区数为:两个RDD分区数之积。慎用

val rdd1 = sc.parallelize(1 to 3)
val rdd2 = sc.parallelize(2 to 5)
rdd1.cartesian(rdd2).collect()
// 结果Array[(Int, Int)] = Array((1,2), (1,3), (1,4), (1,5), (2,2), (2,3),(2,4), (2,5), (3,2), (3,3), (3,4), (3,5))

zip(otherRDD)

将两个RDD组合成 key-value 形式的RDD,默认两个RDD的partition数量以及元素数量都相同,否则会抛出异常。

val rdd1 = sc.parallelize(Array(1,2,3),3)
val rdd2 = sc.parallelize(Array("a","b","c"),3)
rdd1.zip(rdd2).collect
// 结果Array((1,a), (2,b), (3,c))

Key-Value 类型

类似 map 操作

mapValues

针对于(K,V)形式的类型只对V进行操作

val rdd = sc.parallelize(Array((1,"a"),(1,"d"),(2,"b"),(3,"c")))
//对value添加字符1
rdd3.mapValues(_+"1").collect()
// 结果Array((1,a1), (1,d1), (2,b1), (3,c1))
flatMapValues

flatMapValues 将 value 的值压平

val rdd = sc.parallelize(List((1,2),(3,4),(5,6)))
rdd.flatMapValues(x=>1 to x).collect
// 结果 Array((1,1), (1,2), (3,1), (3,2), (3,3), (3,4), (5,1), (5,2), (5,3), (5,4), (5,5), (5,6))
keys、 values

获取所有的Key 、values

val rdd = sc.parallelize(List((1,2),(3,4),(5,6)))
// Array(1,3,5)
rdd.keys.collect()
// Array(2,4,6)
rdd.values.collect()

聚合操作

groupByKey

groupByKey是对每个key进行操作,根据Key分组,但只生成一个sequence。

val words = Array("one", "two", "two", "three", "three", "three")
val wordPairsRDD = sc.parallelize(words).map(word => (word, 1))
// 将相同key对应值聚合到一个sequence中
wordPairsRDD.groupByKey().collect()
//结果 Array[(String, Iterable[Int])] = Array((two,CompactBuffer(1, 1)),(one,CompactBuffer(1)), (three,CompactBuffer(1, 1, 1)))

reduceByKey

在一个(K,V)的RDD上调用,返回一个(K,V)的RDD,使用指定的reduce函数,将相同key的值聚合到一起

val rdd = sc.parallelize(List(("a",1),("b",5),("a",5),("b",2)))
// 计算相同key对应值的相加结果
rdd.reduceByKey((x,y) => x+y).collect()
// 结果Array((a,6), (b,7))
aggregateByKey

aggregateByKey => 定义初值 + 分区内的聚合函数 + 分区间的聚合函数
参数: (zeroValue:U,[partitioner: Partitioner]) (seqOp: (U, V) => U,combOp: (U, U)=> U)参数描述:
zeroValue: 给每一个分区的每一个key一个初始值。
seqOp: 分区内合并,函数用于在每一个分区中用初始值逐步迭代value
combOp:分区间合并,函数用于合并每个分区中的结果

val rdd = sc.makeRDD(Array(("spark", 12), ("hadoop", 26),("hadoop", 23), ("spark", 15), ("scala", 26), ("spark", 25),("spark", 23), ("hadoop", 16), ("scala", 24), ("spark", 16)))
// 根据Key求平均值
rdd.mapValues((_, 1)).aggregateByKey((0,0))(
(x, y) => (x._1 + y._1, x._2 + y._2),
(a, b) => (a._1 + b._1, a._2 + b._2)
).mapValues(x=>x._1.toDouble / x._2).collect()
// 结果 Array((spark,18.2), (hadoop,21.666666666666668), (scala,25.0))
foldByKey

(zeroValue: V)(func: (V, V) => V): RDD[(K, V)] aggregateByKey的简化操作、

val rdd = sc.makeRDD(Array(("spark", 12), ("hadoop", 26),("hadoop", 23), ("spark", 15), ("scala", 26), ("spark", 25),("spark", 23), ("hadoop", 16), ("scala", 24), ("spark", 16)))
// 根据Key求平均值
rdd.mapValues((_, 1)).foldByKey((0, 0))((x, y) => {
(x._1+y._1, x._2+y._2)
}).mapValues(x=>x._1.toDouble/x._2).collect
// 结果 Array((spark,18.2), (hadoop,21.666666666666668), (scala,25.0))

排序操作

sortByKey

sortByKey函数作用于PairRDD,对Key进行排序

val rdd = sc.parallelize(Array((3,"aa"),(6,"cc"),(2,"bb"),(1,"dd")))
//按照key的正序 Array((1,dd), (2,bb), (3,aa), (6,cc))
rdd.sortByKey(true).collect()
// 按照key的倒序 Array((6,cc), (3,aa), (2,bb), (1,dd))
rdd.sortByKey(false).collect()

join操作

cogroup、join、leftOuterJoin、rightOuterJoin 、fullOuterJoin

cogroup:类似全外JOIN
join、leftOuterJoin、rightOuterJoin 、fullOuterJoin调用的还是 cogroup

val rdd1 = sc.makeRDD(Array(("1","Spark"),("2","Hadoop"),("3","Scala"),("4","Java")))
val rdd2 = sc.makeRDD(Array(("3","20K"),("4","18K"),("5","25K"),("6","10K")))
rdd1.cogroup(rdd2).collect.foreach(println)
//(1,(CompactBuffer(Spark),CompactBuffer()))
//(2,(CompactBuffer(Hadoop),CompactBuffer()))
//(3,(CompactBuffer(Scala),CompactBuffer(20K)))
//(4,(CompactBuffer(Java),CompactBuffer(18K)))
//(5,(CompactBuffer(),CompactBuffer(25K)))
//(6,(CompactBuffer(),CompactBuffer(10K)))

rdd1.join(rdd2).collect
rdd1.leftOuterJoin(rdd2).collect
rdd1.rightOuterJoin(rdd2).collect
rdd1.fullOuterJoin(rdd2).collect
//join
//Array((3,(Kylin,李四)), (4,(Flink,王五)))
//leftOuterJoin:
//Array((1,(Spark,None)), (2,(Hadoop,None)), (3,(Kylin,Some(李四))), (4,(Flink,Some(王五))))
//rightOuterJoin:
//Array((6,(None,冯七)), (3,(Some(Kylin),李四)), (4,(Some(Flink),王五)), (5,(None,赵六)))
//fullOuterJoin:
//Array((6,(None,Some(冯七))), (1,(Some(Spark),None)), (2,(Some(Hadoop),None)), (3,(Some(Kylin),Some(李四))), (4,(Some(Flink),Some(王五))), (5,(None,Some(赵六))))

Action操作

Action 用来触发RDD的计算,得到相关计算结果
Action触发Job。一个Spark程序(Driver程序)包含了多少 Action算子,那么就有多少Job;
典型的Action算子: collect / count
collect() => sc.runJob() => … => dagScheduler.runJob() => 触发了Job

collect

collect 将分布式的 RDD 返回为一个单机的 scala Array 数组,在这个数组上运用 scala 的函数式操作

sc.makeRDD(1 to 10).collect
//结果 Array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

collectAsMap

collectAsMap对(K,V)型的RDD数据返回一个单机HashMap。 对于重复K的RDD元素,后面的元素覆盖前面的元素

val rdd = sc.makeRDD(Array(("1","Spark1"),("1","Spark2"),("2","Hadoop"), ("3","Scala")))
rdd.collectAsMap()
// 结果Map(2 -> Hadoop, 1 -> Spark2, 3 -> Scala)

countByKey

针对(K,V)类型的RDD,返回一个(K,Int)的map,表示每一个key对应的元素个数

val rdd = sc.parallelize(List((1,3),(1,2),(1,4),(2,3),(3,6),(3,8)))
//统计每种key的个数
rdd.countByKey
//结果 Map(3 -> 2, 1 -> 3, 2 -> 1)

lookup(key)

lookup(key):高效的查找方法,只查找对应分区的数据(如果RDD有分区器的话)

val rdd = sc.makeRDD(Array(("1","Spark"),("2","Hadoop"),("3","Scala"),("1","Java")))
rdd.lookup("1")
//结果WrappedArray(Spark, Java)

stats 、count 、mean 、stdev 、max 、 min

val rdd = sc.range(1, 10)
rdd.stats
rdd.count
// 平均值
rdd.mean
// 方差
rdd.stdev
rdd.max
rdd.min
//(count: 9, mean: 5.000000, stdev: 2.581989, max: 9.000000, min: 1.000000)
//9
//5.0
//2.5819888974716116
//9
//1

first()、 take(n)、 top(n)

first():返回RDD中的第一个元素,类似于take(1)
take(n):返回一个由RDD的前n个元素组成的数组
top(n):按照默认(降序)或者指定的排序规则,返回前num个元素

val rdd = sc.range(1, 10)
rdd.first()
rdd.take(3).toBuffer
rdd.top(3).toBuffer
// 1
// ArrayBuffer(1, 2, 3)
// ArrayBuffer(9, 8, 7)

takeSample(withReplacement, num, [seed])

参数:是否放回/采集多少个数据/种子。种子固定,采集到的数据不会变

val rdd = sc.range(1, 10)
rdd.takeSample(false,5).toBuffer
//结果ArrayBuffer(4, 6, 5, 8, 3)

foreach(func) 、foreachPartition(func)

与map、mapPartitions类似,区别是map、mapPartitions是Transformation操作,而foreach(func) / foreachPartition(func)是Action操作

saveAsTextFile(path) 、 saveAsSequenceFile(path) 、saveAsObjectFile(path)

保存文件到指定路径(rdd有多少分区,就保存为多少文件,保存文件时注意小文件问题)
可以使用coalesce减少分区数: 减少成一个分区rdd.coalesce(1).saveAsTextFile(“data/t1”)

rdd.coalesce(1).saveAsTextFile("hdfs://linux11:9000/wcinput/part.txt")

案列

import org.apache.spark.{SparkConf, SparkContext}

/**
 * <p>假设点击日志文件(click.log)中每行记录格式如下:</p>
 *
 * INFO 2019-09-01 00:29:53 requestURI:/click?app=1&p=1&adid=18005472&industry=469&adid=31
 * INFO 2019-09-01 00:30:31 requestURI:/click?app=2&p=1&adid=18005472&industry=469&adid=31
 * INFO 2019-09-01 00:31:03 requestURI:/click?app=1&p=1&adid=18005472&industry=469&adid=32
 * INFO 2019-09-01 00:31:51 requestURI:/click?app=1&p=1&adid=18005472&industry=469&adid=33
 *
 * 另有曝光日志(imp.log)格式如下:
 *
 * INFO 2019-09-01 00:29:53 requestURI:/imp?app=1&p=1&adid=18005472&industry=469&adid=31
 * INFO 2019-09-01 00:29:53 requestURI:/imp?app=1&p=1&adid=18005472&industry=469&adid=31
 * INFO 2019-09-01 00:29:53 requestURI:/imp?app=1&p=1&adid=18005472&industry=469&adid=34
 *
 * <p>3.1、用Spark-Core实现统计每个adid的曝光数与点击数,将结果输出到hdfs文件;</p>
 *
 * <p>输出文件结构为adid、曝光数、点击数。注意:数据不能有丢失(存在某些adid有imp,没有clk;或有clk没有imp)</p>
 *
 * <p>3.2、你的代码有多少个shuffle,是否能减少?</p>
 *
 * <p>(提示:仅有1次shuffle是最优的)</p>
 */
object Part {
  def main(args: Array[String]): Unit = {
    val conf = new SparkConf().setAppName("Part").setMaster("local[*]")
    val sc = new SparkContext(conf)
    sc.setLogLevel("WARN")

    // 进行数据装载
    val clict: Array[String] = Array(
      "INFO 2019-09-01 00:29:53 requestURI:/click?app=1&p=1&adid=18005472&industry=469&adid=31",
      "INFO 2019-09-01 00:30:31 requestURI:/click?app=2&p=1&adid=18005472&industry=469&adid=31",
      "INFO 2019-09-01 00:31:03 requestURI:/click?app=1&p=1&adid=18005472&industry=469&adid=32",
      "INFO 2019-09-01 00:31:51 requestURI:/click?app=1&p=1&adid=18005472&industry=469&adid=33"
    )
    val imp: Array[String] = Array(
      "INFO 2019-09-01 00:29:53 requestURI:/imp?app=1&p=1&adid=18005472&industry=469&adid=31",
      "INFO 2019-09-01 00:29:53 requestURI:/imp?app=1&p=1&adid=18005472&industry=469&adid=31",
      "INFO 2019-09-01 00:29:53 requestURI:/imp?app=1&p=1&adid=18005472&industry=469&adid=34"
    )

    // =================== 方法一 ===============================
    // 标记 点击日志文件(click.log)
    val clictRdd1 = sc.makeRDD(clict).map(line =>{
      val index = line.lastIndexOf("=")
      val key = line.substring(index + 1, line.length)
      (key, "clict")
    })
    // 标记 曝光日志(imp.log)
    val impRdd1 = sc.makeRDD(imp).map(line =>{
      val index = line.lastIndexOf("=")
      val key = line.substring(index + 1, line.length)
      (key, "imp")
    })

    // 合并后进行处理
    clictRdd1.union(impRdd1).aggregateByKey((0,0))(
      // 分区内 合并
      (x1, y1) => {
        if("clict".equals(y1)) (x1._1 + 1, x1._2)
        else (x1._1, x1._2 + 1)
      },
      // 分区间合并
      (x2, y2) => (x2._1 + y2._1, x2._2 + y2._2)
    ).map(x => (x._1,x._2._1,x._2._2)).collect().foreach(println)

    // ================打印结果===============
    // (33,1,0)
    // (34,0,1)
    // (31,2,2)
    // (32,1,0)



    // =================== 方法二 ===============================
    println("==================================================")
    // 标记 点击日志文件(click.log)
    val clictRdd2 = sc.makeRDD(clict).map(line =>{
      val index = line.lastIndexOf("=")
      val key = line.substring(index + 1, line.length)
      (key, (1,0))
    })

    // 标记 曝光日志(imp.log)
    val impRdd2 = sc.makeRDD(imp).map(line =>{
      val index = line.lastIndexOf("=")
      val key = line.substring(index + 1, line.length)
      (key, (0, 1))
    })


    // 合并后进行处理
    val resultRdd = clictRdd2.union(impRdd2).reduceByKey((x,y) => (x._1 + y._1, x._2 + y._2))
      .map(x => (x._1,x._2._1,x._2._2))
    resultRdd.collect().foreach(println)

    // ================打印结果===============
    // (33,1,0)
    // (34,0,1)
    // (31,2,2)
    // (32,1,0)


    // 对结果保存到HDFS
    resultRdd.coalesce(1).saveAsTextFile("hdfs://linux111:9000/wcinput/part.txt")

    sc.stop()
  }
}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值