Spark Streaming

Spark Streaming

一.Spark Streaming介绍

(一)概述

    Spark Streaming是一个基于Spark Core之上的实时计算框架,可从Kafka、Flume、Twitter等很多数据源消费数据并对数据进行秒级实时的处理。

(二)基本原理

    Spark Streaming 是基于spark的流式批处理引擎,其基本原理是把输入数据以某一时间间隔批量的处理(微批次),当批处理间隔缩短到秒级时,便可以用于处理实时数据流。其实就是把流式计算当作一系列连续的小规模批处理来对待, 每个输入批次都形成一个RDD!
    Spark Streaming中,会有一个接收器组件Receiver,作为一个长期运行的task跑在一个Executor上。
在这里插入图片描述

概念很多,没总结完…


二.Spark Streaming实战

(一)WordCount
1.准备
#在linux服务器上安装nc工具(nc命令是netcat命令的简称,原本是用来设置路由器.,我们可以利用它向某个端口发送数据)
yum install -y nc

#启动一个服务端并开发9999端口,等一下往这个端口发数据
nc -lk 9999
2.Demo01[入门]

注意:

    1. 源码中要求: spark.master should be set as local[n],n > 1,StreamingContext的cores(核)配置, 一个StreamingContext创建多个input Dstream,会创建多个Receiver,Spark会为每个Receiver 分配一个core用于其运行。其余的core用于真正的计算。

import org.apache.spark.streaming.dstream.{DStream, ReceiverInputDStream}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.{SparkConf, SparkContext}

//使用Spark监听Soket:node01:9999发送过来的数据, 并做wordcount
object MyWordCount {
  def main(args: Array[String]): Unit = {
    //spark.master should be set as local[n], n > 1
    val conf: SparkConf = new SparkConf()
    .setAppName("wc")
    .setMaster("local[*]")
    val sc: SparkContext = new SparkContext(conf)
    sc.setLogLevel("WARN")

    //创建Spark StreamingContext(Spark流式处理上下文对象), 
    // Seconds(5)表示每5秒对数据进行切分形成一个RDD
    val ssc: StreamingContext = new StreamingContext(sc,Seconds(5))
    //监听Socket接收数据
    //ReceiverInputStream就是接收到的所有的数据组成的RDD,封装成了DStream
    val dataDStream: ReceiverInputDStream[String] = 
    ssc.socketTextStream("node01",9999)
    //接下来对DStream进行操作
    val word_count: DStream[(String, Int)] = dataDStream.flatMap(_.split(" ")).map((_,1)).reduceByKey(_+_)
    word_count.print()
    ssc.start()//开启
    ssc.awaitTermination()//等待优雅停止
  }
}
  • 问题: 用案例一方式每次得到WordCount的结果, 没有与上一次的结果做累加。
3.Demo02[累加]

注意:

  1. 设置ssc.checkpoint("./wc")用来存储历史累加数据的容错目录, 开发中一般设置在hdfs;
  2. DS.updateStateByKey(updateFunc), 函数中传入的是元祖的value组成的Seq;
  3. Option[泛型]知识点回顾。
import org.apache.spark.streaming.dstream.{DStream, ReceiverInputDStream}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.{SparkConf, SparkContext}

//将监听到到的数据累加, 并做wordcount
object MyWordCount2 {
  def main(args: Array[String]): Unit = {
    //spark.master should be set as local[n], n > 1
    val conf: SparkConf = new SparkConf().setAppName("wc").setMaster("local[*]")
    val sc: SparkContext = new SparkContext(conf)
    sc.setLogLevel("WARN")
    val ssc: StreamingContext = new StreamingContext(sc,Seconds(5))

    /*  
    	不设置容错会报错: requirement failed: ....Please set it by StreamingContext.checkpoint().
        注意:我们在下面使用到了updateStateByKey对当前数据和历史数据进行累加
        那么历史数据存在哪?我们需要给他设置一个checkpoint目录
        "./wc"的实际目录是:"D:\idea\Spark_20190804\wc_test\"
    */
    ssc.checkpoint("./wc")//开发中写的是HDFS的文件目录

    val dataDStream: ReceiverInputDStream[String] = ssc.socketTextStream("node01",9999)
    val word_count_DS: DStream[(String, Int)] = dataDStream.flatMap(_.split(" ")).map((_,1))

    /*=================使用updateStateByKey对当前数据和历史数据进行累加=================*/
    val wordAndCount: DStream[(String, Int)] = word_count_DS.updateStateByKey(updateFunc)

    wordAndCount.print()
    ssc.start()//开启
    ssc.awaitTermination()//等待优雅停止
  }

  //   currentValues:当前批次的value值,如:1,1,1 (以测试数据中的hadoop为例)
  //   historyValue:之前累计的历史值,第一次没有值是0,第二次是3
  //  目标是把当前数据+历史数据返回作为新的结果(下次的历史数据)
  def updateFunc(currentValues:Seq[Int],historyValue:Option[Int]):Option[Int]={
    val result: Int = currentValues.sum+historyValue.getOrElse(0)
    Some(result)
  }
}
4.Demo03[滑动窗口]

在这里插入图片描述
注意:

  1. windowDuration(窗口长度)和slideDuration(滑动间隔)必须是batchDuration(切分批次)的倍数;
  2. windowDuration=slideDuration:数据不会丢失也不会重复计算==开发中会使用;
  3. windowDuration>slideDuration:数据会重复计算==开发中会使用;
  4. windowDuration<slideDuration:数据会丢失。

源码解析:

def reduceByKeyAndWindow(//函数名
  reduceFunc : scala.Function2[V, V, V], //聚合函数, 此需求也是当前批数据与历史数据相加
  windowDuration : org.apache.spark.streaming.Duration, //窗口长度/宽度
  slideDuration : org.apache.spark.streaming.Duration)//窗口滑动间隔
:org.apache.spark.streaming.dstream.DStream[scala.Tuple2[K, V]]//返回值还是DStream
= {/* compiled code */ }//函数体
import org.apache.spark.streaming.dstream.{DStream, ReceiverInputDStream}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.{SparkConf, SparkContext}

//每隔5秒计算最近10秒的数据
object MyWordCount3 {
  def main(args: Array[String]): Unit = {
    //spark.master should be set as local[n], n > 1
    val conf: SparkConf = new SparkConf().setAppName("wc").setMaster("local[*]")
    val sc: SparkContext = new SparkContext(conf)
    sc.setLogLevel("WARN")
    val ssc: StreamingContext = new StreamingContext(sc,Seconds(5))

    val dataDStream: ReceiverInputDStream[String] = ssc.socketTextStream("node01",9999)
    val word_count_DS: DStream[(String, Int)] = dataDStream.flatMap(_.split(" ")).map((_,1))

    /*=====================使用窗口函数进行WordCount计数=====================*/
    val wordAndCount: DStream[(String, Int)] = word_count_DS.reduceByKeyAndWindow((a:Int, b:Int) =>a+b,Seconds(10),Seconds(5))

    wordAndCount.print()
    ssc.start()//开启
    ssc.awaitTermination()//等待优雅停止
  }
}

三.模拟百度热搜排行榜

需求: 模拟百度热搜排行榜统计最近10s的热搜词Top3,每隔5秒计算一次

  • 测试数据
香港机场实施出入管制 香港机场实施出入管制 香港机场实施出入管制 香港机场实施出入管制
太阳下吵架半小时双双中暑 太阳下吵架半小时双双中暑 太阳下吵架半小时双双中暑
首个台风红色预警
告全体香港青年 告全体香港青年 告全体香港青年 告全体香港青年 告全体香港青年
  • 代码演示

注意:

  1. sortedDStream.print()是DStream的Output/Action操作, 必须要有, 否则报错: No output operations registered, so nothing to execute
  2. DStreame没有排序方法, 所以要把DStreame转成RDD再降序排序。
import org.apache.spark.rdd.RDD
import org.apache.spark.streaming.dstream.{DStream, ReceiverInputDStream}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.{SparkConf, SparkContext}

//拟百度热搜排行榜统计最近10s的热搜词Top3,每隔5秒计算一次
object BaiduTopN {
  def main(args: Array[String]): Unit = {
    val conf: SparkConf = new SparkConf().setAppName("wc").setMaster("local[*]")
    val sc: SparkContext = new SparkContext(conf)
    sc.setLogLevel("WARN")
    val ssc: StreamingContext = new StreamingContext(sc,Seconds(5))

    val dataDStream: ReceiverInputDStream[String] = ssc.socketTextStream("node01",9999)
    val word_count_DS: DStream[(String, Int)] = dataDStream.flatMap(_.split(" ")).map((_,1))

    /*=====================使用窗口函数进行WordCount计数=====================*/
    val wordAndCount: DStream[(String, Int)] = word_count_DS.reduceByKeyAndWindow((a:Int, b:Int) =>a+b,Seconds(10),Seconds(5))

    //排序操作(有个foreachRDD方法更好用): DStreame没有排序方法, 所以要把DStreame转成RDD再降序排序
    val sortedDStream: DStream[(String, Int)] = wordAndCount.transform(rdd => {
      val sortedRDD: RDD[(String, Int)] = rdd.sortBy(_._2, false)
      println("↓↓↓↓↓↓↓↓↓↓TOP-3↓↓↓↓↓↓↓↓↓↓")
      sortedRDD.take(3).foreach(println)
      println("↑↑↑↑↑↑↑↑↑↑TOP-3↑↑↑↑↑↑↑↑↑↑")
      sortedRDD
    })

    sortedDStream.print(3)//No output operations registered, so nothing to execute
    ssc.start()//开启
    ssc.awaitTermination()//等待优雅停止
  }
}

三.Spark Streaming整合kafka

(一)整合Kafka两种模式

在spark1.3版本后,kafkaUtils里面提供了两种创建DStream的方法:

①Receiver接收方式:不适合在开发中使用

    KafkaUtils.createDstream, 会有一个Receiver作为常驻的Task运行在Executor等待数据, 但是一个Receiver效率低,需要开启多个,再手动合并数据,再进行处理,很麻烦;

    如果运行Receiver的某台机器宕机,部分数据会丢失, 所以需要开启WAL(预写日志)保证数据安全,那么效率又会降低!Receiver方式是通过zookeeper来连接kafka队列,调用Kafka高阶API,offset存储在zookeeper,由Receiver维护,Spark获取数据存入executor中, spark在消费的时候为了保证数据不丢也会在Checkpoint中存一份offset,可能会出现数据不一致。

    所以不管从何种角度来说,Receiver模式都不适合在开发中使用

②Direct直连方式: 开发中使用,要求掌握

    Direct方式调用Kafka低阶API直接连接kafka分区来获取数据, 从每个分区直接读取数据提高了并行能力;

    offset默认由Spark自己在checkpoint中存储和维护(开发中自己手动维护,把offset存在mysql、redis中),消除了与zk不一致的情况。

    另外, 借助Direct模式的特点+手动操作可以保证数据的Exactly once 精准一次。

扩展: 关于消息语义

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rBVky6tM-1576328225528)(C:/MarkDown_Photo/图片8.png)]

注意:

  1. 开发中SparkStreaming和kafka集成有两个版本:0.8及0.10+
  2. 0.8版本有Receiver和Direct模式(但是这个的0.8版本生产环境问题较多,在Spark2.3之后不支持0.8版本了)
  3. 0.10以后只保留了direct模式(Reveiver模式不适合生产环境),并且API有变化(更加强大)
(二)代码演示

启动kafka集群

#三台机运行
cd /export/servers/kafka_2.11-1.0.0
nohup bin/kafka-server-start.sh config/server.properties 2>&1 &

#node01创建topic
 /export/servers/kafka_2.11-1.0.0/bin/kafka-topics.sh --create --zookeeper node01:2181 --replication-factor 3 --partitions 3 --topic spark_kafka
 
 #node01启动生产者
 /export/servers/kafka_2.11-1.0.0/bin/kafka-console-producer.sh --broker-list node01:9092,node02:9092,node03:9092 --topic spark_kafka
 
 #三台机同时关闭
cd /export/servers/kafka_2.11-1.0.0
bin/kafka-server-stop.sh

测试数据

香港机场实施出入管制 香港机场实施出入管制 香港机场实施出入管制 香港机场实施出入管制
太阳下吵架半小时双双中暑 太阳下吵架半小时双双中暑 太阳下吵架半小时双双中暑
首个台风红色预警
告全体香港青年 告全体香港青年 告全体香港青年 告全体香港青年 告全体香港青年

源码:

def createDirectStream[K, V](
  ssc : StreamingContext, 
  //位置策略,源码强烈推荐使用该策略,会让Spark的Executor和Kafka的Broker均匀对应
  locationStrategy : LocationStrategy,
  //消费策略,源码强烈推荐使用该策略
  consumerStrategy :ConsumerStrategy[K, V]
):InputDStream[ConsumerRecord[K, V] //返回值为InputDStream
= { /* compiled code */ }

Kafka的auto.offset.reset参数说明:

    此配置参数表示当此groupId下的消费者,在没有offset值时,consumer应该从哪个offset开始消费.

1. earliest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
2. latest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据
3. none:topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
	//这里配置latest自动重置偏移量为最新的偏移量,即如果有偏移量从偏移量位置开始消费,没有偏移量从新来的数据开始消费

代码(多练!)

import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.streaming.dstream.{DStream, InputDStream, ReceiverInputDStream}
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, KafkaUtils, LocationStrategies}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.{SparkConf, SparkContext}

//使用Spark-Kafka-0-10版本整合
object MySparkKafka {
  def main(args: Array[String]): Unit = {
    val conf: SparkConf = new SparkConf().setAppName("wc").setMaster("local[*]")
    val sc: SparkContext = new SparkContext(conf)
    sc.setLogLevel("WARN")
    val ssc: StreamingContext = new StreamingContext(sc,Seconds(5))

    //1. 准备连接kafka的参数
    val kafkaParams = Map[String, Object](
      "bootstrap.servers" -> "node01:9092,node02:9092,node03:9092",
      "key.deserializer" -> classOf[StringDeserializer],
      "value.deserializer" -> classOf[StringDeserializer],
      "group.id" -> "SparkKafkaDemo",
      "auto.offset.reset" -> "latest",
      //false表示关闭自动提交.由spark帮你提交到Checkpoint或程序员手动维护
      "enable.auto.commit" -> (false: java.lang.Boolean)
    )
    val topic = Array("spark_kafka")

    //2. 使用KafkaUtil连接kafka获取数据
    val recordDStream: InputDStream[ConsumerRecord[String, String]] = KafkaUtils.createDirectStream(
      ssc,
      LocationStrategies.PreferConsistent,
      ConsumerStrategies.Subscribe[String, String](topic, kafkaParams) //要加上泛型!!
    )

    //3. 操作数据
    //注意: recordDStream里的元素是ConsumerRecord[String, String])类型
    val func=(a:ConsumerRecord[String, String]) => a.value()//value方法返回的是字符串
    val lineDStream: DStream[String] = recordDStream.map(func)//得到的lineDStream里封装的是一行数据
    val wordAndOneDStream = lineDStream.flatMap(_.split(" ")).map((_,1))
    val wordAndCount: DStream[(String, Int)] = wordAndOneDStream.reduceByKeyAndWindow((a:Int, b:Int)=>a+b,Seconds(10),Seconds(5))
    val sortedDStream: DStream[(String, Int)] = wordAndCount.transform(rdd => {
      rdd.sortBy(_._2, false)
    })

    sortedDStream.print()
    ssc.start()
    ssc.awaitTermination()
  }
}
(三)Kafka手动维护偏移量

①在MySQL创建下表

#在MySQL创建下表
#使用了联合主键, 新数据重新计算的结果会覆盖历史数据
USE sparkdb;
CREATE TABLE `t_offset` (
      `topic` varchar(255) NOT NULL,
      `partition` int(11) NOT NULL,
      `groupid` varchar(255) NOT NULL,
      `offset` bigint(20) DEFAULT NULL,
      PRIMARY KEY (`topic`,`partition`,`groupid`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

启动kafka集群

#三台机运行
cd /export/servers/kafka_2.11-1.0.0
nohup bin/kafka-server-start.sh config/server.properties 2>&1 &

#node01创建topic
 /export/servers/kafka_2.11-1.0.0/bin/kafka-topics.sh --create --zookeeper node01:2181 --replication-factor 3 --partitions 3 --topic spark_kafka
 
 #node01启动生产者
 /export/servers/kafka_2.11-1.0.0/bin/kafka-console-producer.sh --broker-list node01:9092,node02:9092,node03:9092 --topic spark_kafka
 
 #三台机同时关闭
cd /export/servers/kafka_2.11-1.0.0
bin/kafka-server-stop.sh

测试数据

香港机场实施出入管制 香港机场实施出入管制 香港机场实施出入管制 香港机场实施出入管制
太阳下吵架半小时双双中暑 太阳下吵架半小时双双中暑 太阳下吵架半小时双双中暑
首个台风红色预警
告全体香港青年 告全体香港青年 告全体香港青年 告全体香港青年 告全体香港青年

②入口类代码演示

注意事项:

import java.sql.{DriverManager, ResultSet}
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.streaming.dstream.InputDStream
import org.apache.spark.streaming.kafka010.{OffsetRange, _}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.{SparkConf, SparkContext}
import scala.collection.mutable

//Desc 使用Spark-Kafka-0-10版本整合,并手动提交偏移量,维护到MySQL中(还可以维护到redis中, 本次未演示)
object KafkaSparkMySQL {
  def main(args: Array[String]): Unit = {
    val conf = new SparkConf().setAppName("wc").setMaster("local[*]")
    val sc = new SparkContext(conf)
    sc.setLogLevel("WARN")
    val ssc = new StreamingContext(sc,Seconds(5))//5表示5秒中对数据进行切分形成一个RDD
    //1.准备连接Kafka的参数
    val kafkaParams = Map[String, Object](
      "bootstrap.servers" -> "node01:9092,node02:9092,node03:9092",
      "key.deserializer" -> classOf[StringDeserializer],
      "value.deserializer" -> classOf[StringDeserializer],
      "group.id" -> "SparkKafkaDemo",
      "auto.offset.reset" -> "latest",
      "enable.auto.commit" -> (false: java.lang.Boolean)
    )
    val topics = Array("spark_kafka")

    /* 2.使用KafkaUtil连接Kafak获取数据
       注意:
         如果MySQL中没有记录offset,则直接连接,从latest开始消费
         如果MySQL中有记录offset,则应该从该offset处开始消费*/
    val offsetMap: mutable.Map[TopicPartition, Long] = OffsetUtil.getOffsetMap("SparkKafkaDemo","spark_kafka")
    val recordDStream: InputDStream[ConsumerRecord[String, String]] = 
    if(offsetMap.size > 0){//有记录offset
      println("MySQL中记录了offset,则从该offset处开始消费\r\n")
      KafkaUtils.createDirectStream[String, String](ssc,
        LocationStrategies.PreferConsistent,
        ConsumerStrategies.Subscribe[String, String](topics, kafkaParams,offsetMap))
    }else{//没有记录offset
      println("没有记录offset,则直接连接,从latest开始消费")
      KafkaUtils.createDirectStream[String, String](ssc,
        LocationStrategies.PreferConsistent,
        ConsumerStrategies.Subscribe[String, String](topics, kafkaParams))
    }

    /*3.操作数据
        注意:我们的目标是要自己手动维护偏移量,也就意味着,消费了一小批数据就应该提交一次offset
        而这一小批数据在DStream的表现形式就是RDD,所以我们需要对DStream中的RDD进行操作获取offset
        对DStream中的RDD进行操作的API有transform(转换)和foreachRDD(动作) */
    recordDStream.foreachRDD(rdd=>{
      if(rdd.count() > 0){//当前这一时间批次有数据
        rdd.foreach(record => println("接收到的Kafk发送过来的数据为:" + record))
        /*接收到的Kafk发送过来的数据为:
          ConsumerRecord(topic = spark_kafka, partition = 1, offset = 6, CreateTime = 1565400670211, checksum = 1551891492,
                         serialized key size = -1, serialized value size = 43, key = null, value = hadoop spark ...)
          ☆通过打印接收到的消息可以看到,里面有我们需要维护的offset,和要处理的数据
          ☆所以接下来把offset相关从rdd中取出来, 通过OffsetUtil维护(保存)到mySQL中
          ☆如何取呢???
            为了方便我们对offset的维护/管理,spark提供了一个HasOffsetRanges类,帮我们封装offset的数据*/
        val offsetRanges: Array[OffsetRange] = rdd.asInstanceOf[HasOffsetRanges].offsetRanges

        println("\r\n↓↓↓↓↓↓下面打印的是HasOffsetRanges封装的和offset相关的信息↓↓↓↓↓↓")
        for (o <- offsetRanges){
          println(s"topic=${o.topic},partition=${o.partition},fromOffset=${o.fromOffset},untilOffset=${o.untilOffset}")
        }

        //我们写了一个工具类, 让工具类ffsetUtil来完成维护(保存)到mySQL中
        OffsetUtil.saveOffsetRanges("SparkKafkaDemo",offsetRanges)
        //我们还可以用下面的方式手动提交offset,默认维护到Checkpoint中
        //recordDStream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)
      }
    })

    ssc.start()//开启
    ssc.awaitTermination()//等待优雅停止
  }
  
  
  //手动维护offset的工具类
  object OffsetUtil {
    //从数据库读取偏移量
    def getOffsetMap(groupid: String, topic: String) = {
      val connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/sparkdb?characterEncoding=UTF-8", "root", "123456")
      val pstmt = connection.prepareStatement("select * from t_offset where groupid=? and topic=?")
      pstmt.setString(1, groupid)
      pstmt.setString(2, topic)
      val rs: ResultSet = pstmt.executeQuery()
      val offsetMap = mutable.Map[TopicPartition, Long]()
      while (rs.next()) {
        offsetMap += new TopicPartition(rs.getString("topic"), rs.getInt("partition")) -> rs.getLong("offset")
      }
      rs.close()
      pstmt.close()
      connection.close()
      offsetMap
    }

    //将偏移量保存到数据库
    def saveOffsetRanges(groupid: String, offsetRange: Array[OffsetRange]) = {
      val connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/sparkdb?characterEncoding=UTF-8", "root", "123456")
      //replace into表示之前有就替换,没有就插入
      val pstmt = connection.prepareStatement("replace into t_offset (`topic`, `partition`, `groupid`, `offset`) values(?,?,?,?)")
      for (o <- offsetRange) {
        pstmt.setString(1, o.topic)
        pstmt.setInt(2, o.partition)
        pstmt.setString(3, groupid)
        pstmt.setLong(4, o.untilOffset)
        pstmt.executeUpdate()
      }
      pstmt.close()
      connection.close()
    }
  }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值