读懂Spark分布式数据集RDD

21 篇文章 3 订阅

目 录

0 引 言

1 RDD 计算模型认识

1.1 RDD核心概念的理解

1.2 RDD核心属性

1.3 RDD的特点

1.3.1 弹性

1.3.2  分区

1.3.3 只读

 1.3.4 依赖(血缘)

1.3.5 缓存

 1.3.6 容错(checkpoint)

2 RDD的编程模型理解

2.1 IO流的执行模型

2.2 RDD执行模型

3 RDD 的创建

3.1 从集合中创建 RDD

3.2 从外部存储创建 RDD

3.3 从其他 RDD 转换得到新的 RDD

4 小 结



0 引 言

SparkRDD是学习熟悉Spark的基本概念,Spark编程的核心也是围绕RDD展开,因此了解RDD的概念至关重要,他是spark基本数据的抽象,是spark的基本数据结构,是spark的基本对象。本文主要也是讲解了spark RDD的基本概念及其创建方法。

1 RDD 计算模型认识

1.1 RDD核心概念的理解

RDD(Resilient Distributed Dataset)叫做弹性分布式数据集,是Spark中最基本的数据抽象。在代码中是一个抽象类,它代表一个弹性的、不可变、可分区、里面的元素可并行计算的集合。本质上是一组分布式的 JVM 不可变对象集合,不可变决定了它是只读的,所以 RDD 在经过变换产生新的 RDD 时,原有 RDD 不会改变。

理解:

一个 RDD 可以简单的理解为一个分布式的元素集合.RDD 表示只读的分区的数据集,对 RDD 进行改动,只能通过 RDD 的转换操作, 然后得到新的 RDD, 并不会对原 RDD 有任何的影响在 Spark 中, 所有的工作要么是创建 RDD, 要么是转换已经存在 RDD 成为新的 RDD, 要么在 RDD 上去执行一些操作来得到一些计算结果.每个 RDD 被切分成多个分区(partition), 每个分区可能会在集群中不同的节点上进行计算.

每个 RDD 都有如下几个成员:

  •     分区的集合;(partition)
  •     用来基于分区进行计算的函数(算子);
  •     依赖(与其他 RDD)的集合;
  •     对于键-值型的 RDD 的散列分区器(可选);
  •     对于用来计算出每个分区的地址集合(可选,如 HDFS 上的块存储的地址)。

可以简单的理解为RDD就是一种高级的数据抽象(数据模型)、一种高级的数据结构,是对数据的高级封装,其本质上就是一个类,一个对象,所谓的Spark编程也是围绕底层RDD这一数据结构而操作,因此掌握RDD非常重要。

如下图所示,RDD_0 根据 HDFS 上的块地址生成,块地址集合是 RDD_0 的成员变量,RDD_1由 RDD_0 与转换(transform)函数(算子)转换而成,该算子其实是 RDD_0 内部成员。从这个角度上来说,RDD_1 依赖于 RDD_0,这种依赖关系集合也作为 RDD_1 的成员变量而保存。

在 Spark 源码中,RDD 是一个抽象类,根据具体的情况有不同的实现,比如 RDD_0 可以是 MapPartitionRDD,而 RDD_1 由于产生了 Shuffle,则是 ShuffledRDD。源码如下

// 表示RDD之间的依赖关系的成员变量
@transient private var deps: Seq[Dependency[_]]
// 分区器成员变量
@transient val partitioner: Option[Partitioner] = None
// 该RDD所引用的分区集合成员变量
@transient private var partitions_ : Array[Partition] = null
// 得到该RDD与其他RDD之间的依赖关系
protected def getDependencies: Seq[Dependency[_]] = deps
// 得到该RDD所引用的分区
protected def getPartitions: Array[Partition]
// 得到每个分区地址
protected def getPreferredLocations(split: Partition): Seq[String] = Nil
// distinct算子
def distinct(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T] = 
withScope  {
    map(x => (x, null)).reduceByKey((x, y) => x, numPartitions).map(_._1)
}

其中,需要关注这一行代码:

@transient private var partitions_ : Array[Partition] = null

它说明了一个重要的问题,RDD 是分区的集合,本质上还是一个集合,所以在理解时,你可以用分区之类的概念去理解,但是在使用时,就可以忘记这些,把其当做是一个普通的集合。 RDD是分区的抽象,DStream是RDD的抽象,理解如下图所示。

那么一个RDD有多少个分区呢?这个是你与你加载文件时,文件的个数相关的,如数据源是HDFS,有多少个block块个数就有多少个分区数,加载的是HBASE表,HBASE表有多少个region个数就有多少个分区数。

  • 注意:
  • textFile方法底层封装的是读取MR读取文件的方式,读取文件之前先split,默认split大小是一个block大小。

1.2 RDD核心属性

  • 分区列表

RDD数据结构中存在分区列表,用于执行任务时并行计算,是实现分布式计算的重要属性。

  • 分区计算函数

Spark在计算时,是使用分区函数对每一个分区进行计算 

  • RDD之间的依赖关系

RDD是计算模型的封装,当需求中需要将多个计算模型进行组合时,就需要将多个RDD建立依赖关系

 

  • 分区器(可选)

当数据为KV类型数据时,可以通过设定分区器自定义数据的分区

  • 首选位置(可选)

计算数据时,可以根据计算节点的状态选择不同的节点位置进行计算

1.3 RDD的特点

1.3.1 弹性

  • 存储的弹性:内存与磁盘的自动切换;
  • 容错的弹性:数据丢失可以自动恢复;
  • 计算的弹性:计算出错重试机制;
  • 分片的弹性:可根据需要重新分片。

理解:从薯片的加工流程看 RDD

在很久很久以前,有个生产桶装薯片的工坊,工坊的规模较小,工艺也比较原始。为了充
分利用每一颗土豆、降低生产成本,工坊使用 3 条流水线来同时生产 3 种不同尺寸的桶装
薯片。3 条流水线可以同时加工 3 颗土豆,每条流水线的作业流程都是一样的,分别是清
洗、切片、烘焙、分发和装桶。其中,分发环节用于区分小、中、大号 3 种薯片,3 种不
同尺寸的薯片分别被发往第 1、2、3 条流水线。具体流程如下图所示。
RDD的

看得出来,这家工坊制作工艺虽然简单,倒也蛮有章法。从头至尾,除了分发环节,3 条流水线没有任何交集。在分发环节之前,每条流水线都是专心致志、各顾各地开展工作:把土豆食材加载到流水线上,再进行清洗、切片、烘焙;在分发环节之后,3 条流水线也是各自装桶,互不干涉、互不影响。流水线的作业方式提供了较强的容错能力,如果某个加工环节出错,工人们只需要往出错的流水线上重新加载一颗新的土豆,整个流水线就能够恢复生产。好了,故事讲完了。如果我们把每一条流水线看作是分布式运行环境的计算节点,用薯片生产的流程去类比 Spark 分布式计算,会有哪些有趣的发现呢?仔细观察,我们发现:刚从地里挖出来的土豆食材、清洗过后的干净土豆、生薯片、烤熟的薯片,流水线上这些食材的不同形态,就像是 Spark 中 RDD 对于不同数据集合的抽象。沿着流水线的纵深方向,也就是图中从左向右的方向,每一种食材形态都是在前一种食材之上用相应的加工方法进行处理得到的。每种食材形态都依赖于前一种食材,这就像是 RDD 中 dependencies 属性记录的依赖关系,而不同环节的加工方法,对应的刚好就是RDD 的 compute 属性。

横看成岭侧成峰,再让我们从横向的角度来重新审视上面的土豆加工流程,也就是图中从上至下的方向,让我们把目光集中在流水线开端那 3 颗带泥的土豆上。这 3 颗土豆才从地里挖出来,是原始的食材形态,正等待清洗。如图所示,我们把这种食材形态标记为
potatosRDD,那么,这里的每一颗土豆就是 RDD 中的数据分片,3 颗土豆一起对应的就是 RDD 的 partitions 属性

带泥土豆经过清洗、切片和烘焙之后,按照大小个儿被分发到下游的 3 条流水线上,这 3条流水线上承载的 RDD 记为 shuffledBakedChipsRDD。很明显,这个 RDD 对于partitions 的划分是有讲究的,根据尺寸的不同,即食薯片会被划分到不同的数据分片
中。像这种数据分片划分规则,对应的就是 RDD 中的 partitioner 属性。 在分布式运行环境中,partitioner 属性定义了 RDD 所封装的分布式数据集如何划分成数据分片。 总的来说,我们发现,薯片生产的流程和 Spark 分布式计算是一一对应的,一共可以总结
为 6 点:

  • 土豆工坊的每条流水线就像是分布式环境中的计算节点;
  • 不同的食材形态,如带泥的土豆、土豆切片、烘烤的土豆片等等,对应的就是 RDD;
  • 每一种食材形态都会依赖上一种形态,如烤熟的土豆片依赖上一个步骤的生土豆切片。
  • 这种依赖关系对应的就是 RDD 中的 dependencies 属性;(依赖属性)
  • 不同环节的加工方法对应 RDD 的 compute 属性;(计算属性,转换算子
  • 同一种食材形态在不同流水线上的具体实物,就是 RDD 的 partitions 属性;(分片属性
  • 食材按照什么规则被分配到哪条流水线,对应的就是 RDD 的 partitioner 属性。(分片规则)

RDD 的核心特征和属性
通过刚才的例子,我们知道 RDD 具有 4 大属性,分别是 partitions、partitioner、dependencies 和 compute 属性。正因为有了这 4 大属性的存在,让 RDD 具有分布式和容错性这两大最突出的特性。要想深入理解 RDD,我们不妨从它的核心特性和属性入
手。首先,我们来看 partitions、partitioner 属性。在分布式运行环境中,RDD 封装的数据在物理上散落在不同计算节点的内存或是磁盘中,这些散落的数据被称“数据分片”,RDD 的分区规则决定了哪些数据分片应该散落到哪些节点中去RDD 的 partitions 属性对应着 RDD 分布式数据实体中所有的数据分片,而partitioner 属性则定义了划分数据分片的分区规则,如按哈希取模或是按区间划分等。不难发现,partitions 和 partitioner 属性刻画的是 RDD 在跨节点方向上的横向扩展,所以我们把它们叫做 RDD 的“横向属性”。然后,我们再来说说 dependencies 和 compute 属性。在 Spark 中,任何一个 RDD 都不是凭空产生的,每个 RDD 都是基于某种计算逻辑从某个“数据源”转换而来。RDD 的 dependencies 属性记录了生成 RDD 所需的“数据源”,术语叫做父依赖(或父 RDD),compute 方法则封装了从父 RDD 到当前 RDD 转换的计算逻辑。基于数据源和转换逻辑,无论 RDD 有什么差池(如节点宕机造成部分数据分片丢失),在dependencies 属性记录的父 RDD 之上,都可以通过执行 compute 封装的计算逻辑再次得到当前的 RDD,如下图所示。 

dependencies 和 compute 属性提供的容错能力,为 Spark 分布式内存计算的稳定性打下了坚实的基础,这也正是 RDD 命名中 Resilient 的由来。接着观察上图,我们不难发现,不同的 RDD 通过 dependencies 和 compute 属性链接在一起,逐渐向纵深延展,构建了一张越来越深的有向无环图,也就是我们常说的 DAG。 由此可见,dependencies 属性和 compute 属性负责 RDD 在纵深方向上的延展,因此我们不妨把这两个属性称为“纵向属性”

 总的来说,RDD 的 4 大属性又可以划分为两类,横向属性和纵向属性。其中,横向属性锚定数据分片实体,并规定了数据分片在分布式集群中如何分布;纵向属性用于在纵深方向构建 DAG,通过提供重构 RDD 的容错能力保障内存计算的稳定性。

1.3.2  分区

RDD 逻辑上是分区的,每个分区的数据是抽象存在的,计算的时候会通过一个compute函数得到每个分区的数据。

如果 RDD 是通过已有的文件系统构建,则compute函数是读取指定文件系统中的数据,如果 RDD 是通过其他 RDD 转换而来,则 compute函数是执行转换逻辑将其他 RDD 的数据进行转换。

1.3.3 只读

RDD 是只读的,要想改变 RDD 中的数据,只能在现有 RDD 基础上创建新的 RDD。

由一个 RDD 转换到另一个 RDD,可以通过丰富的转换算子实现,不再像 MapReduce 那样只能写mapreduce了。

RDD 的操作算子包括两类,

  • 一类叫做transformation,它是用来将 RDD 进行转化,构建 RDD 的血缘关系;
  • 另一类叫做action,它是用来触发 RDD 进行计算,得到 RDD 的相关计算结果或者 保存 RDD 数据到文件系统中

 1.3.4 依赖(血缘)

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

如下图所示,依赖包括两种,

  • 一种是窄依赖,RDDs 之间分区是一一对应的,
  • 另一种是宽依赖,下游 RDD 的每个分区与上游 RDD(也称之为父RDD)的每个分区都有关,是多对多的关系。

1.3.5 缓存

如果在应用程序中多次使用同一个 RDD,可以将该 RDD 缓存起来,该 RDD 只有在第一次计算的时候会根据血缘关系得到分区的数据,在后续其他地方用到该 RDD 的时候,会直接从缓存处取而不用再根据血缘关系计算,这样就加速后期的重用。

如下图所示,RDD-1 经过一系列的转换后得到 RDD-n 并保存到 hdfs,RDD-1 在这一过程中会有个中间结果,如果将其缓存到内存,那么在随后的 RDD-1 转换到 RDD-m 这一过程中,就不会计算其之前的 RDD-0 了。

 1.3.6 容错(checkpoint)

虽然 RDD 的血缘关系天然地可以实现容错,当 RDD 的某个分区数据计算失败或丢失,可以通过血缘关系重建。

但是对于长时间迭代型应用来说,随着迭代的进行,RDDs 之间的血缘关系会越来越长,一旦在后续迭代过程中出错,则需要通过非常长的血缘关系去重建,势必影响性能。

为此,RDD 支持checkpoint 将数据保存到持久化的存储中,这样就可以切断之前的血缘关系,因为checkpoint 后的 RDD 不需要知道它的父 RDDs 了,它可以从 checkpoint 处拿到数据。

2 RDD的编程模型理解

结论:RDD的执行模型和IO流的执行模型是一致的,都是使用装饰者模式,进行包装和功能功能扩展,都是懒执行。

2.1 IO流的执行模型

(1)FileInputStream类读取单个字节,由于效率太低进行功能扩展进行包装使用BufferedInputStream类进行增强,读取一个串,为了更直观读取,读取我们需要的中文字符,上层再进行封装使用BufferedReader进行读取。

(2)只有执行readLine方法的时候,才真正的开始加载读取数据进行逻辑分析

具体解释如下:

2.2 RDD执行模型

基本代码如下:

//创建spark上下文
        val sc = new SparkContext(conf)
        val fileRDD = sc.textFile("input")
        val wordRDD: RDD[String] = fileRDD.flatMap(_.split(""))
        val groupRDD: RDD[(String, Iterable[String])] = wordRDD.groupBy(word => word)
        val mapRDD: RDD[(String, Int)] = groupRDD.map {
            case (word, iter) => {
                (word, iter.size)
            }
        }

 执行模型如下:

因此对于RDD的执行模型理解与IO流的执行模型理解是一致的,区别在于RDD中只保存编程逻辑,但不保存数据,对于IO而言由于由buffer的概念,因而可以临时保存数据


3 RDD 的创建

在 Spark 中创建 RDD 的方式可以分为 3 种:

  • 从集合中创建 RDD
  • 从外部存储创建 RDD
  • 从其他 RDD 转换得到新的 RDD。

3.1 从集合中创建 RDD

  1. 使用parallelize函数创建
scala> val arr = Array(10,20,30,40,50,60)
arr: Array[Int] = Array(10, 20, 30, 40, 50, 60)

scala> val rdd1 = sc.parallelize(arr)
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:26

   2.使用makeRDD函数创建

makeRDD和parallelize是一样的.

scala> val rdd1 = sc.makeRDD(Array(10,20,30,40,50,60))
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at makeRDD at <console>:24

说明:

  • 一旦 RDD 创建成功, 就可以通过并行的方式去操作这个分布式的数据集了.
  • parallelizemakeRDD还有一个重要的参数就是把数据集切分成的分区数.
  • Spark 会为每个分区运行一个任务(task). 正常情况下, Spark 会自动的根据你的集群来设置分区数

3.2 从外部存储创建 RDD

Spark 也可以从任意 Hadoop 支持的存储数据源来创建分布式数据集.

可以是本地文件系统, HDFS, Cassandra, HVase, Amazon S3 等等.

Spark 支持 文本文件, SequenceFiles, 和其他所有的 Hadoop InputFormat.

scala> var distFile = sc.textFile("words.txt")
distFile: org.apache.spark.rdd.RDD[String] = words.txt MapPartitionsRDD[1] at textFile at <console>:24

scala> distFile.collect
res0: Array[String] = Array(atguigu hello, hello world, how are you, abc efg)

//val spark: SparkSession = .......
val rdd = spark.sparkcontext.textFile("hdfs://namenode:8020/user/me/wiki.txt")

说明:

  • url可以是本地文件系统文件, hdfs://..., s3n://...等等
  • 如果是使用的本地文件系统的路径, 则必须每个节点都要存在这个路径
  • 所有基于文件的方法, 都支持目录, 压缩文件, 和通配符(*). 例如:

    textFile("/my/directory"), textFile("/my/directory/*.txt"), and textFile("/my/directory/*.gz").

   textFile还可以有第二个参数, 表示分区数. 默认情况下, 每个块对应一个分区(对 HDFS 来说, 块大小默认是 128M). 可以传递一个大于块数的分区数, 但是不能传递一个比块数小的分区数.

   Spark 从 MySQL 中读取数据返回的 RDD 类型是 JdbcRDD,顾名思义,是基于 JDBC 读取数据的,这点与 Sqoop 是相似的,但不同的是 JdbcRDD 必须手动指定数据的上下界,也就是以 MySQL 表某一列的最值作为切分分区的依据。

//val spark: SparkSession = .......
val lowerBound = 1
val upperBound = 1000
val numPartition = 10
val rdd = new JdbcRDD(spark.sparkcontext,() => {
       Class.forName("com.mysql.jdbc.Driver").newInstance()
       DriverManager.getConnection("jdbc:mysql://localhost:3306/db", "root", "123456")
   },
   "SELECT content FROM mysqltable WHERE ID >= ? AND ID <= ?",
   lowerBound, 
   upperBound, 
   numPartition,
   r => r.getString(1)
)

既然是基于 JDBC 进行读取,那么所有支持 JDBC 的数据库都可以通过这种方式进行读取,也包括支持 JDBC 的分布式数据库,但是需要注意的是,从代码可以看出,这种方式的原理是利用多个 Executor 同时查询互不交叉的数据范围,从而达到并行抽取的目的。但是这种方式的抽取性能受限于 MySQL 的并发读性能,单纯提高 Executor 的数量到某一阈值后,再提升对性能影响不大。

上面介绍的是通过 JDBC 读取数据库的方式,对于 HBase 这种分布式数据库来说,情况有些不同,HBase 这种分布式数据库,在数据存储时也采用了分区的思想,HBase 的分区名为 Region,那么基于 Region 进行导入这种方式的性能就会比上面那种方式快很多,是真正的并行导入。

//val spark: SparkSession = .......
val sc = spark.sparkcontext
val tablename = "your_hbasetable"
val conf = HBaseConfiguration.create()
conf.set("hbase.zookeeper.quorum", "zk1,zk2,zk3")
conf.set("hbase.zookeeper.property.clientPort", "2181")
conf.set(TableInputFormat.INPUT_TABLE, tablename)
val rdd= sc.newAPIHadoopRDD(conf, classOf[TableInputFormat],
classOf[org.apache.hadoop.hbase.io.ImmutableBytesWritable],
classOf[org.apache.hadoop.hbase.client.Result]) 
// 利用HBase API解析出行键与列值
rdd_three.foreach{case (_,result) => {
    val rowkey = Bytes.toString(result.getRow)
    val value1 = Bytes.toString(result.getValue("cf".getBytes,"c1".getBytes))
}

获取hbase列信息的rdd—>获取hbase数据rdd(Row)—>取列rdd的第一条列信息构建schema

public static void getHbaseDataset(SparkSession sparkSession, JavaSparkContext jsc, String hbaseTableName){
        Configuration hbaseConfiguration = new SparkHbaseUtils().getConfiguration();
        hbaseConfiguration.set(TableInputFormat.INPUT_TABLE,hbaseTableName);
        JavaPairRDD<ImmutableBytesWritable, Result> hbaseRDD = jsc.newAPIHadoopRDD(hbaseConfiguration, TableInputFormat.class, ImmutableBytesWritable.class, Result.class);
        //获取列信息
        JavaRDD<List<String>> hbaseColumnRDD = hbaseRDD.map(new Function<Tuple2<ImmutableBytesWritable, Result>, List<String>>() {
            @Override
            public List<String> call(Tuple2<ImmutableBytesWritable, Result> tuple) throws Exception {
                List<String> recordColumnList = new ArrayList();
                Result result = tuple._2;
                Cell[] cells = result.rawCells();
                for (Cell cell :
                        cells) {
                    recordColumnList.add(new String(CellUtil.cloneQualifier(cell)));
                }
                return recordColumnList;
            }
        });
        //获取数据
        JavaRDD<Row> dataRDD = hbaseRDD.map(new Function<Tuple2<ImmutableBytesWritable, Result>, Row>() {
            @Override
            public Row call(Tuple2<ImmutableBytesWritable, Result> tuple) throws Exception {
                List<String> recordList = new ArrayList();
                Result result = tuple._2;
                Cell[] cells = result.rawCells();
                for (Cell cell :
                        cells) {
                    recordList.add(new String(CellUtil.cloneValue(cell)));
                }
                return (Row) RowFactory.create(recordList.toArray());
            }
        });
        //设置即将创建表的字段信息
        ArrayList<StructField> structFields = new ArrayList<>();
        List<String> fieldsList = hbaseColumnRDD.first();
        for (String field :
                fieldsList) {
            structFields.add(DataTypes.createStructField(field, DataTypes.StringType, true));
        }
        //新建列schema
        StructType schema = DataTypes.createStructType(structFields);
        Dataset hbaseDataset = sparkSession.createDataFrame(dataRDD,schema);
        //注册关系型表
        hbaseDataset.createOrReplaceTempView(hbaseTableName);
		//打印表视图信息
        hbaseDataset.printSchema();
}

值得一提的是 HBase 有一个第三方组件叫 Phoenix,可以让 HBase 支持 SQL 和 JDBC,在这个组件的配合下,第一种方式也可以用来抽取 HBase 的数据,此外,Spark 也可以读取 HBase 的底层文件 HFile,从而直接绕过 HBase 读取数据。说这么多,无非是想告诉你,读取数据的方法有很多,可以根据自己的需求进行选择。

通过第三方库的支持,Spark 几乎能够读取所有的数据源,例如 Elasticsearch,所以你如果要尝试的话,尽量选用 Maven 来管理依赖。

3.3 从其他 RDD 转换得到新的 RDD

     就是通过 RDD 的各种转换算子来得到新的 RDD。如PairRDD,PairRDD 与其他 RDD 并无不同,只不过它的数据类型是 Tuple2[K,V],表示键值对,因此这种 RDD 也被称为 PairRDD,泛型为 RDD[(K,V)],而普通 RDD 的数据类型为 Int、String 等。这种数据结构决定了 PairRDD 可以使用某些基于键的算子,如分组、汇总等。PairRDD 可以由普通 RDD 转换得到:

//val spark: SparkSession = .......
val a = spark.sparkcontext.textFile("/user/me/wiki").map(x => (x,x))

4 小 结

  本文围绕SparkRDD基本概念进行讲解,对SparkRDD的概念进行了剖析,其本质就是Spark的最基本的数据结构、基本对象,是对底层数据的高级封装,它的目的就是隔离分布式数据集的复杂性。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值