Spark从星火到燎原Ⅰ

Spark

简介

Apache Spark是一个分布式、内存级计算框架,是专为大规模数据处理而设计的

特点

  • 高性能: Spark 比 Hadoop 的 MapReduce 快 100 倍以上,无论是基于内存还是硬盘的运算都快 10 倍以上。它采用高效的 DAG 执行引擎,能够有效处理数据流。
  • 易用性: Spark 提供 Java、Python、R 和 Scala 的 API,以及超过 80 种高级算法,使用户能够快速构建各种应用。此外,支持交互式的 Python 和 Scala shell,方便用户验证解决问题的方法。
  • 通用性: Spark 提供了统一的解决方案,可用于批处理、交互式查询(Spark SQL)、实时流处理(Spark Streaming)、机器学习(Spark MLlib)和图计算(GraphX),可以在同一个应用中无缝使用。
  • 兼容性: Spark 能与其他开源产品轻松融合。它可以使用 Hadoop 的 YARN 和 Apache Mesos 作为资源管理和调度器,并处理所有 Hadoop 支持的数据,包括 HDFS、HBase 和 Cassandra 等。对于已部署 Hadoop 集群的用户来说,这尤为重要,因为可以无需数据迁移即可利用 Spark 的强大处理能力。

Spark为什么这么快

  • 内存计算。Spark优先将数据加载到内存中,数据可以被快速处理,并可启用缓存。
  • shuffle过程优化。和Mapreduce的shuffle过程中间文件频繁落盘不同,Spark对Shuffle机制进行了优化, 降低中间文件的数量并保证内存优先。
  • RDD计算模型。Spark具有高效的DAG调度算法,同时将RDD计算结果存储在内存中,避免重复计算。

Spark的生态体系

spark生态体系

Spark体系包含Spark CoreSpark SQLSpark StreamingSpark MLlibSpark Graphx。其中Spark Core为核心组件,提供RDD计算模型。在其基础上的众组件分别提供查询分析实时计算机器学图计算等功能。

部署方式

Local: 运行在单台机器上,通常用于练习和测试。

Standalone: 构建基于 Master+Slaves 的资源调度集群,Spark 任务提交给 Master 运行,是 Spark 自身的调度系统。

Yarn: Spark 客户端直接连接 Yarn,无需额外构建 Spark 集群。有 yarn-client 和 yarn-cluster 两种模式,主要区别在于 Driver 程序的运行节点。

Mesos: 在国内使用较少

Spark RDD

RDD(Resilient Distributed Dataset)叫做弹性分布式数据集,是 Spark 中最基本的数据抽象,代表一个不可变、可分区、里面的元素可并行计算的集合。

Resilient :它是弹性的,RDD 里面的中的数据可以保存在内存中或者磁盘里面;

Distributed : 它里面的元素是分布式存储的,可以用于分布式计算;

Dataset: 它是一个集合,可以存放很多元素

Spark中的血统概念(RDD)

在Spark中,RDD的血统(lineage)指的是每个RDD如何从原始数据集或其他RDD中经过一系列转换操作得到的依赖关系链。血统图记录了这些转换关系,是一个有向无环图(DAG)。血统概念保证了Spark的容错性、优化性和惰性计算。通过血统信息,Spark能够重新计算丢失的分区数据、进行优化操作,并延迟转换操作的执行,提高了计算的效率和灵活性。

容错性(Fault Tolerance):RDD的血统信息是弹性的,即使某个RDD分区丢失,Spark也能根据RDD的血统信息重新计算出丢失的分区数据,从而保证计算的容错性。

优化(Optimization):Spark可以根据RDD的血统信息进行优化,例如通过重新排序RDD转换操作的执行顺序或者选择合适的执行策略来提高计算性能。

惰性计算(Lazy Evaluation):RDD的转换操作并不会立即执行,而是会生成一个新的RDD,并记录其父RDD的血统信息。只有当行动(Action)操作触发时,Spark才会根据RDD的血统信息执行转换操作并计算结果

RDD宽窄变换

针对不同的函数转换,RDD之间的依赖关系分为宽依赖和窄依赖。宽依赖会产生shuffle行为,经历map输出、中间文件落地和reduce聚合等过程。

首先,Spark官网中对于宽依赖和窄依赖的定义:

  • 宽依赖: 父RDD每个分区被多个子RDD分区使用
  • 窄依赖: 父RDD每个分区被子RDD的一个分区使用

常见的窄转换操作包括 mapflatMapfiltermapPartitionsmapPartitionsWithIndexfilter 等。常见的宽转换操作包括 groupByreduceByKeyjoincogroupsortByKey 等。

总的来说,窄转换是高效的,适用于局部处理数据的情况,而宽转换是需要跨分区处理数据的情况下使用,但是它会涉及到数据的重新分区和重新组织,因此会更加昂贵。在设计 Spark 作业时,应该尽量避免过多的宽转换操作,以提高作业的性能和效率。

下面我们结合示意图,分别列出宽依赖和窄依赖存在的四种情况:

  • 窄依赖(一个父RDD对应一个子RDD:map/filter、union算子) img
  • 窄依赖(多个父RDD对应一个子RDD:co-partioned join算子) img
  • 宽依赖(一个父RDD对应多个非全部子RDD: groupByKey算子等) 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
  • 宽依赖(一个父RDD对应全部子RDD: not co-partioned join算子) img

RDD分区

在 Spark 中,RDD 分区决定了数据在集群中的并行处理方式。它们允许数据分布到集群的多个节点上,并且决定了任务的划分和调度方式。RDD 分区的设计考虑了数据本地性、任务划分和容错性。通常情况下,Spark 会根据数据源和集群配置自动确定 RDD 的分区方式,但用户也可以通过手动调整分区数量和分布方式来优化计算性能。

一般情况下,Spark 会根据数据源的特性和集群的配置自动确定 RDD 的分区方式。用户也可以通过 repartitioncoalesce 等方法来手动调整 RDD 的分区数量和分布方式,以满足具体的计算需求和性能优化目标。

// 案例1: coalesce(numPartitions) 改变分区数,用于大数据集过滤后,提高小数据集的执行效率。
val rdd = sc.parallelize(1 to 10, 4)
val coalesceRDD = rdd.coalesce(2)
coalesceRDD.partitions.size -> 2

// 案例2: repartition(numPartitions) 根据分区数,重新通过网络随机shuffle所有数据。
val rdd = sc.parallelize(1 to 16, 4)
val rerdd = rdd.repartition(2)

// 案例3: partitionBy 对 pairRDD 进行分区操作,如果原有的 partionRDD 和现有的 partionRDD 是一致的话就不进行分区,否则会生成数据 shuffle,会产生 shuffle 过程。
val rdd = sc.parallelize(Array((1,"aaa"),(2,"bbb"),(3,"ccc"),(4,"ddd")), 4)
rdd.partitions.size
var rdd2 = rdd.partitionBy(new org.apache.spark.HashPartitioner(2))
rdd2.partitions.size

/*coalesce 和 repartition 的区别:
coalesce 重新分区,可以选择是否进行 shuffle 过程。由参数 shuffle: Boolean = false/true 决定。
repartition 实际上是调用的 coalesce,默认是进行 shuffle 的。源码如下:*/
def repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T] =
withScope {
  coalesce(numPartitions, shuffle = true)
}

RDD属性

  • 分片列表: 数据集的基本组成单位,决定并行度。在创建 RDD 时可以指定分片个数,如果未指定则使用默认值。
  • 计算每个分片的函数: Spark 中 RDD 的计算以分片为单位进行,该函数会应用于每个分片。
  • 依赖其他 RDD 的列表: RDD 的转换会生成新的 RDD,形成前后依赖关系。Spark 的容错机制可以通过这些依赖关系重新计算丢失的分片数据。
  • 分区函数: 对于键值对类型的 RDD,可能会有一个分区函数。默认情况下为 HashPartitioner。
  • 最佳位置: 存储每个分片优先位置的列表。在任务调度时,Spark 会尽可能选择存有数据的节点进行计算,遵循"移动数据不如移动计算"的理念。

RDD 是一个数据集的表示,不仅表示了数据集,还表示了这个数据集从哪来,如何计算

RDD创建

//第一种方式:基于现有数据形成RDD
val rdd1 = sc.parallelize(Array(1,2,3,4,5,6,7))
val rdd2 = sc.makeRDD(Array(1,2,3,4,5,6,7,8))
//第二种:由外部存储系统的数据集创建,包括本地的文件系统,还有所有 Hadoop 支持的数据集,比如 HDFS、Cassandra、HBase 等
val rdd1 =
sc.textFile("hdfs://hadoop5/shopping_data/customer_details/customer_details.csv")

RDD算子

RDD 的算子分为两类:

  • Transformation转换操作:返回一个新的 RDD
  • Action动作操作:返回值不是 RDD(无返回值或返回其他的)

RDD 不存储实际数据,而是记录数据位置和转换关系。所有转换操作都是惰性求值/延迟执行的,只有在执行 Action 动作时才会真正运行。
之所以使用惰性求值/延迟执行,是因为这样可以在 Action 时对 RDD 操作形成 DAG有向无环图进行 Stage 的划分和并行优化,这种设计让 Spark 更加有效率地运行。

Action动作操作不延迟

//案例 rdd2的值为多少?
val data = Array(100, 200, 300, 400) //定义一个数组
val rdd1 = sc.parallelize(data) //定义rdd1
val rdd2 = rdd1.map(x => x / 100) //通过rdd1 生成rdd2
data(2) = 900 //修改 data的值
rdd2.take(4) 
/* take触发执行,rdd2 -> 1 2 3 4
   如果没有action操作,rdd2的数据保持不变
   data值的修改跟rdd2的数据没关系
*/

Transformation

MAP

基于RDD进行数据变换,返回的结果是一个新的RDD

al str = "aa bb cc"
val rdd1 = rdd1.parallelize(str)
val data1 = rdd1.map(s => s.split(" ").map(s => (s,1))) 
//Array((aa,1),(bb,1),(cc,1))
val data2 = rdd2.map(s => s.split(" ")).map(s => (s,1)) 
//Array((aa,1),(bb,1),(cc,1))
/*
在这两种写法中,第二种写法更优,因为它只进行了一次字符串拆分操作。
*/
glom

glom作用:将每个分区的数据放到一个数组

//需求:假设您想要在给定的RDD中找出最大值。现在你可以使用map和reduce操作来完成它,如下所示。
val dataList = List(50.0,40.0,40.0,70.0)
val dataRDD = sc.makeRDD(dataList)
val maxValue = dataRDD.reduce (_ max _)
//尽管它起作用,但分区之间会有很多混洗进行比较。这些不好,特别是对于大数据。
/*我们可以,比较分区之间的最大值以获得最终的最大值。
现在我们需要一种方法来比较给定分区中的所有值。这可以使用glom轻松完成,如下所示*/
val maxValue = dataRDD.glom().map((value:Array[Double]) => value.max).reduce(_ max
_)

map((value:Array[Double]) => value.max) 对每个分区的数据进行映射操作,返回每个分区中的最大值。

reduce(_ max _) 对所有分区的最大值进行比较,得出最终的最大值。

这种方法避免了不必要的混洗操作,因为每个分区的最大值是在本地计算的,而不是在不同分区之间进行比较。这样可以提高性能,特别是对于大数据集来说。

val data = List(1, 2, 3, 4, 5, 6, 7, 8)
val rdd = sc.parallelize(data, 3) // 创建一个包含 3 个分区的 RDD
val glommedRDD = rdd.glom() // 对 RDD 进行 glom() 转换
val collectedData = glommedRDD.collect() // 收集 glom 后的结果
collectedData.foreach(arr => println(arr.mkString(","))) // 打印每个分区的数据
结果:
1,2
3,4
5,6,7,8
flatMap

flatMap 是 Spark 中的一个转换操作,它类似于 map,但是可以将每个输入元素映射(转换)为零个或多个输出元素。flatMap 操作将函数应用于 RDD 中的每个元素,并将结果扁平化为单个 RDD。

val data = List("hello world", "how are you")
val rdd = sc.parallelize(data)

// 使用flatMap将每个字符串拆分为单词,并扁平化结果
val flatMappedRDD = rdd.flatMap(_.split(" "))

// 打印扁平化后的结果
flatMappedRDD.collect().foreach(println)

在这个示例中,我们创建了一个包含两个字符串的 RDD,并使用 flatMap 将每个字符串拆分为单词。然后,我们使用 collect() 将扁平化后的结果收集到驱动程序节点,并通过 foreach 方法打印每个单词。

假设 data 中的数据是 ["hello world", "how are you"] ,那么经过 flatMap 操作后,得到的扁平化的结果将会是 ["hello", "world", "how", "are", "you"]

filter

filter 是 Spark 中的一个转换操作,它用于筛选出满足特定条件的元素,返回一个包含满足条件的元素的新 RDD。具体来说,filter 接受一个函数作为参数,该函数返回一个布尔值,用于确定是否保留每个输入元素。

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

// 使用filter筛选出大于5的元素
val filteredRDD = rdd.filter(_ > 5)

// 打印筛选后的结果
filteredRDD.collect().foreach(println)

sample

sample 是 Spark 中的一个转换操作,用于从 RDD 中随机抽样出一部分元素。它可以用于从大数据集中获取一个较小的样本,以便进行测试或分析。

sample 操作有几种不同的形式,最常用的形式是接受三个参数:withReplacementfractionseed。具体说明如下:

  • withReplacement: 一个布尔值,表示抽样时是否允许重复抽取同一个元素。如果为 true,则允许重复抽取;如果为 false,则不允许重复抽取。
  • fraction: 一个双精度浮点数,表示要抽样的比例,取值范围为 [0, 1]。例如,如果 fraction 为 0.1,则表示抽样 10% 的数据。
  • seed: 一个长整型数值,用于指定随机数生成器的种子,以便可重现性。
val data = 1 to 100
val rdd = sc.parallelize(data)

// 从 RDD 中随机抽样出一部分元素
val sampledRDD = rdd.sample(false, 0.1, 42) // 不允许重复抽取,抽样比例为 0.1,种子为 42

// 打印抽样后的结果
sampledRDD.collect().foreach(println)

distinct

distinct 是 Spark 中的一个转换操作,用于去除 RDD 中的重复元素,返回一个新的 RDD,其中包含了去重后的元素。

distinct 操作会将 RDD 中的所有元素进行扫描,并将重复的元素去除,保留唯一的元素。它是一种常用的操作,用于处理数据中的重复记录或重复值。

val data = List(1, 2, 3, 1, 2, 4, 5, 3)
val rdd = sc.parallelize(data)

// 对 RDD 进行去重操作
val distinctRDD = rdd.distinct()

// 打印去重后的结果
distinctRDD.collect().foreach(println)

假设 data 中的数据是 [1, 2, 3, 1, 2, 4, 5, 3] ,经过 distinct 操作后,去除了重复的元素,得到的结果将会是 [1, 2, 3, 4, 5]

reduceByKey

reduceByKey 是 Spark 中的一个转换操作,用于对键值对 (key, value) 形式的 RDD 进行聚合操作,按键对相同键的值进行聚合。它对具有相同键的值进行分组,并对每组的值应用一个指定的聚合函数,然后返回一个新的键值对 RDD,其中每个键对应于一个聚合后的结果。

val data = List(("a", 1), ("b", 2), ("a", 3), ("b", 4), ("c", 5))
val rdd = sc.parallelize(data)

// 使用 reduceByKey 对具有相同键的值进行聚合操作,求和
val reducedRDD = rdd.reduceByKey(_ + _)

// 打印聚合后的结果
reducedRDD.collect().foreach(println)

(a, 4)
(b, 6)
(c, 5)

groupBy,groupByKey

groupBygroupByKey 都是 Spark 中的转换操作,用于对键值对 (key, value) 形式的 RDD 进行分组操作。它们都根据键对 RDD 中的元素进行分组,但在某些方面有所不同。

groupBy groupBy 方法将 RDD 中的每个元素应用于一个函数,该函数返回一个分组键,然后根据这些键对元素进行分组。返回的结果是一个元组 (key, Iterable[T]) 的 RDD,其中 key 是分组键,Iterable[T] 是具有相同分组键的元素的迭代器。

val data = List(("a", 1), ("b", 2), ("a", 3), ("b", 4), ("c", 5))
val rdd = sc.parallelize(data)

val groupedRDD = rdd.groupBy(_._1)
groupedRDD.collect().foreach(println)
---------------
(a,CompactBuffer((a,1), (a,3)))
(b,CompactBuffer((b,2), (b,4)))
(c,CompactBuffer((c,5)))

groupByKey groupByKey 方法仅用于键值对 RDD,它根据键对 RDD 中的元素进行分组,并将具有相同键的值进行聚合。返回的结果是一个元组 (key, Iterable[V]) 的 RDD,其中 key 是分组键,Iterable[V] 是具有相同分组键的值的迭代器

val data = List(("a", 1), ("b", 2), ("a", 3), ("b", 4), ("c", 5))
val rdd = sc.parallelize(data)

val groupByKeyRDD = rdd.groupByKey()
groupByKeyRDD.collect().foreach(println)
-------
(a,CompactBuffer(1, 3))
(b,CompactBuffer(2, 4))
(c,CompactBuffer(5))

不同之处在于,groupBy 返回的结果是元组 (key, Iterable[T]) 的 RDD,而 groupByKey 返回的结果是元组 (key, Iterable[V]) 的 RDD,其中 V 是键对应的值的类型。

reduceByKey & groupByKey优势比较
  • reduceByKey 相比于 groupByKey 更具优势,特别是在处理大数据集时。因为 reduceByKey 在每个分区内先进行局部聚合,然后在分区间进行全局聚合。可以减少数据传输量和计算量,从而提高了性能。
  • 另外,由于 groupByKey 返回的结果是一个包含迭代器的列表,可能会导致内存消耗过大,特别是当某个键对应的值非常多时。而 reduceByKey 返回的结果是一个键对应一个聚合后的值,对内存的消耗相对较小。
  • 因此,通常情况下建议使用 reduceByKey,除非需要保留每个键对应的所有值的列表,才会使用 groupByKey
sortBy&sortByKe

sortBysortByKey 都是 Spark 中的转换操作,用于对 RDD 进行排序。它们可以对 RDD 中的元素按照指定的排序规则进行排序,并返回一个新的排序后的 RDD。

sortBy

  • sortBy 方法对 RDD 中的元素进行排序,并返回一个新的 RDD,其中元素按照指定的排序规则进行排序。
  • 排序规则可以是任何可以比较的类型,例如数字、字符串等。
  • sortBy 方法需要传递一个函数作为参数,该函数用于为每个元素生成一个排序键,然后根据这些排序键对元素进行排序。
  • 可以选择是否升序或降序排序,默认为升序排序。
val rdd = sc.parallelize(List(3, 1, 2, 5, 4))
val sortedRDD = rdd.sortBy(x => x, ascending = false)
//x => x 表示一个以 x 作为参数的函数,它返回参数 x 自身的值。换句话说,这个函数会将传入的参数原封不动地返回
sortedRDD.collect().foreach(println)

--------
5
4
3
2
1

sortByKey

  • sortByKey 方法专门用于对键值对 RDD 进行排序,它会根据键对 RDD 中的元素进行排序。
  • 返回的结果是一个元组 (key, value) 的 RDD,其中元素按照键的顺序进行排序。
  • 默认情况下,按照键进行升序排序,但也可以通过传递参数来指定排序顺序。
val pairRDD = sc.parallelize(List(("b", 2), ("a", 1), ("c", 3)))
val sortedRDD = pairRDD.sortByKey()
sortedRDD.collect().foreach(println)
--------------------
(a,1)
(b,2)
(c,3)
mapValues

mapValues 是 Spark 中用于对键值对 RDD 进行转换的一种操作。它类似于 map,但是仅对值部分进行转换,而键部分保持不变。这意味着,mapValues 只对 RDD 中的值部分应用指定的函数,而键部分保持不变。

val data = List(("a", 1), ("b", 2), ("c", 3))
val pairRDD = sc.parallelize(data)

val transformedRDD = pairRDD.mapValues(_ * 2)

transformedRDD.collect().foreach(println)
----
(a,2)
(b,4)
(c,6)
union

union 是 Spark 中的一个转换操作,用于将两个 RDD 合并为一个 RDD。它将两个 RDD 中的元素合并到一个新的 RDD 中,不去重,保留所有元素

val data1 = sc.parallelize(List(1, 2, 3))
val data2 = sc.parallelize(List(4, 5, 6))
val rdd1 = data1.map(x => ("A", x))
val rdd2 = data2.map(x => ("B", x))

val unionRDD = rdd1.union(rdd2)

unionRDD.collect().foreach(println)
-----
(A,1)
(A,2)
(A,3)
(B,4)
(B,5)
(B,6)

Action

collect() 以数组的形式返回 RDD 中的所有元素到驱动程序。这个操作通常用于在本地处理 RDD 中的数据。

count() 返回 RDD 中的元素个数。

first() 返回 RDD 中的第一个元素。

take(n: Int) 返回 RDD 中的前 n 个元素

saveAsTextFile(path: String) 将 RDD 中的内容保存为文本文件。

saveAsObjectFile(path: String) 将 RDD 中的内容保存为序列化对象文件。

saveAsSequenceFile(path: String) 将 RDD 中的内容保存为 Hadoop SequenceFile。

)

(A,1)
(A,2)
(A,3)
(B,4)
(B,5)
(B,6)


### Action

**`collect()`:** 以数组的形式返回 RDD 中的所有元素到驱动程序。这个操作通常用于在本地处理 RDD 中的数据。

**`count()`:** 返回 RDD 中的元素个数。

**`first()`:** 返回 RDD 中的第一个元素。

**`take(n: Int)`:** 返回 RDD 中的前 n 个元素

**`saveAsTextFile(path: String)`:** 将 RDD 中的内容保存为文本文件。

**`saveAsObjectFile(path: String)`:** 将 RDD 中的内容保存为序列化对象文件。

**`saveAsSequenceFile(path: String)`:** 将 RDD 中的内容保存为 Hadoop SequenceFile。

**`foreach(func: T => Unit)`:** 对 RDD 中的每个元素应用指定的函数 func。
  • 28
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Spark从读取文件到Shuffle的过程可以概括为以下几个步骤: 1. 文件读取:Spark首先会将文件分割成若干个块(blocks),每个块的大小通常为HDFS或本地文件系统的块大小(默认为128MB)。每个块都会被存储在集群中的不同节点上。 2. 数据划分:接下来,Spark会将文件的每个块划分为若干行数据,并将这些数据分发到集群中的不同节点上。这个过程称为数据划分(Partitioning)。 3. 任务分配:Spark将一个或多个任务(Task)分配给每个节点,每个任务负责处理一个或多个数据划分的数据。任务的数量通常与集群中的节点数量相匹配。 4. 数据处理:每个节点上的任务会对其负责的数据进行处理,例如进行过滤、转换、聚合等操作。这些操作通常是在内存中进行,以提高计算效率。 5. Shuffle准备:在某些操作(例如groupByKey、reduceByKey等)中,需要对数据进行重新分区,以便在后续阶段进行合并或聚合操作。在Shuffle准备阶段,Spark会对需要重新分区的数据进行排序和分组,以便在后续阶段更高效地进行数据交换。 6. Shuffle数据交换:在Shuffle数据交换阶段,Spark会将每个节点上的数据按照指定的分区规则进行打包,并将其发送到其他节点上。这个过程会产生大量的数据传输,因此对于大规模数据集的Shuffle操作可能会成为性能瓶颈。 7. Shuffle合并:接收到Shuffle数据的节点会按照分区规则进行数据合并和聚合,以生成最终结果。这个过程通常涉及大量的磁盘IO和计算操作。 需要注意的是,Shuffle是一个开销较高的操作,因为它需要大量的数据传输和磁盘IO。因此,在Spark应用程序中,尽量减少Shuffle操作的次数和数据量,可以显著提高性能。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值