05 Spark 的 WordCount

前言 

呵呵 想以 Spark 的 WordCount 来梳理一下 流程已经很久了 

从最开始的 在学校的源码调试, 记录了一系列的 文档 

 

到后来 再次重新 调试了一下这一系列的东西, 呵呵 还是需要整理下来, 呵呵 中间应该梳理了几次吧, 只是 现在懒得去找了 

 

呵呵 本文将以 WordCount, 来调试, 了解 着整个流程 

我本地没有 集群, 因此是直接使用的 local 模式来进行调试 

以下调试基于 jdk1.8.0_211 + scala2.11.12 + spark-2.4.5

 

 

测试代码

package com.hx.test

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

/**
  * Test01WordCount
  *
  * @author Jerry.X.He <970655147@qq.com>
  * @version 1.0
  * @date 2020-04-12 11:00
  */
object Test01WordCount {

  // Test01WordCount
  def main(args : Array[String]): Unit = {

    val logFile = "resources/Test01WordCount.txt"
    val conf = new SparkConf().setAppName("Test01WordCount").setMaster("local[1]")
    val sc = new SparkContext(conf)

    val result = sc
      .textFile(logFile,2)
      .flatMap(line => line.split(" "))
      .map(word => (word, 1))
      .reduceByKey((left, right) => left + right)

    result
      .collect()
      .foreach(entry => println(entry._1, entry._2))

    sc.stop()

  }

}

 

 Test01WordCount.txt 里面的内容如下 

234 345 123 346 234
123 124

 

执行结果如下 

 

 

然后我们再来看下各个 函数执行之后的结果, 为了体现结果, 使用了 collect 来触发 action, 提交 job 

sc.textFile(logFile,2) 

 

sc.textFile(logFile,2).flatMap(line => line.split(" "))

 

sc.textFile(logFile, 2).flatMap(line => line.split(" ")).map(word => (word, 1))

 

sc.textFile(logFile, 2).flatMap(line => line.split(" ")).map(word => (word, 1)).reduceByKey((left, right) => left + right)

 

 

然后我们查看一下 spark UI, 查看任务的情况 

可以看到, 提交了一个 job, 里面包含了两个 Stage, rdd 的分区数是 2, 因此创建了 四个 task 

 

我们再看一下这个 job 的情况 

这个 job 根据 ShuffleDependency 划分了两个 Stage, rdd 的分区数是 2, 因此创建了 四个 task 

stage0 包含了textFile(logFile, 2).flatMap(line => line.split(" ")).map(word => (word, 1)) 

stage1 包含了 reduceByKey((left, right) => left + right)

 

我们看一下 stage0 的情况 

两个 partition 对应了两个 ShuffleMapTask 

task0 输入为 27 byte, 输出为 112 byte, 输入记录数为 1, 输出记录数为 4

task1 输入为 14 byte, 输出为 98 byte, 输入记录数为 1, 输出记录数为 2

其他的大多数是一些 时间统计, 汇总统计, 百分比统计  

 

我们再来看一下 stage1 的情况 

两个 partition 对应了两个 ResultTask 

task0 输入为 105 byte, 输入记录数为 3, 输出为 result.collect 对应的结果(我们上面看到的 Array[Tuple2])的一部分 

task1 输入为 105 byte, 输入记录数为 3, 输出为 result.collect 对应的结果(我们上面看到的 Array[Tuple2])的一部分 

其他的大多数是一些 时间统计, 汇总统计, 百分比统计 

 

本文的目的就是 期望梳理出 WordCount 的执行流程, 以及上面的这些数据统计对应于这个场景的意义 

当然 本文设定 您对 spark 有一定的了解, 因此 一些 基础的东西 不会过于详细的表述 

 

 

Driver 提交 Job

runjob 的参数1

从 result.collect 开始 

这里提交 job 传入了两个东西, 一个是需要计算的 rdd

另外的一个是一个函数, 表示的是 当前 job 对应的 ResultTask 主要干什么事情, 比如这里的 两个 partition, 最后 parition0 计算的结果是 [(345, 1), (123,2)], parition1的计算结果为 [(234,2), (346,1), (124,1)] 

这里传入的 func 是吧 partition 中的内容转换为数组, 也就是上面看到的 results[0], results[1](Tuple2的数组) 

在用外面的 Array.concat 处理一层, 其意义就是 获取 rdd 中的每个分区的数据元素(func), 然后 在汇总一下(Array.concat), 得到一个 rdd 中的所有的元素的一个 Array 

 

又比如我们看 rdd.count 来提交 job, func 传入的是 Utils.getIteratorSize _(获取迭代器中元素的数量) 

所以 我们这里得到的 result[0], result[1] 分别为 2, 3 代表的是 paritiion0, partition1 中的元素的数量, 合计起来就是 rdd 中的元素的数量  

 

runjob 的参数2

这里传入了一个 “(index, res) => results(index) = res”, 指代的是 ResultTask 计算了结果之后, 对于结果 怎么处理 

比如这里 runJob 里面的就是, 把结果放入 results 中 

parittion0 计算的结果为 [(345, 1), (123,2)], 把他放入 result[0] 中 

parittion1 计算的结果为 [(234,2), (346,1), (124,1)]  把他放入 result[1] 中 

这的 partitions 是需要计算的 paritition 的列表, 默认为当前 rdd 的所有的 paritition 

 

submitJob 之后 

 

然后 进行 submitJob(创建 Job, 划分Stage, 提交Job, 创建Task, TaskScheduler进行调度, SchedulerBackend进行调度, 发送任务到Executor执行), 之后等待任务 在"集群"中执行完成 

执行完成之后 进行回调的处理, 还有这里的 打印日志信息 等等 

 

划分 Stage - finalStage  

可以看出 这个 finalStage 需要计算的 rdd 为一个 ShuffleRDD, 并且依赖于一个 ShuffleMapStage 

 

我们再来看一下这个 ShuffleMapStage 的情况 

需要计算的 rdd 为 一个 MapPartitionsRDD(map), 这个 rdd 的计算依赖于一个 MapPartitionsRDD(flatMap), 这个 rdd 的计算又依赖于一个 MapPartitionsRDD(map), 这个 rdd 的计算依赖于一个 HadoopRDD(textFile) 

划分 Stage 的细节, 我们这里暂时不详细介绍, 只需要了解, 根据 ShuffleDependency 来划分 Stage 就行 

 

发现这些 RDD 的关系 

    val result = sc
      .textFile(logFile,2)
      .flatMap(line => line.split(" "))
      .map(word => (word, 1))
      .reduceByKey((left, right) => left + right)

 

sc.textFile 

 ​

 ​

 

rdd.map/flatMap 

 ​

 

rdd.reduceByKey 

看了这么一圈, 上面的 stage 中的各个 rdd 的关系应该就比较清楚了 

 

提交 Stage 

 ​如果还有没有执行的 依赖的 Stage 先提交依赖的 Stage, 如果依赖的 Stage 均已经完成, 提交当前 Stage 

在这里, 因为 ResultStage1 依赖于 ShuffleMapStage0, 并且 ShuffleMapStage0 还未执行, 因此 先执行 ShuffleMapStage0, ShuffleMapStage0 完成之后 在执行 ResultStage1 

 

ShuffleMapStage0 执行完成之后, 通知 ResultStage1 的执行  

 ​

 

提交 Task

根据 Stage 创建 Task, 多少个需要计算的 partition 就会创建多少个 Task 

ShuffleMapStage 创建的是 ShuffleMapTask 

然后 将创建的 task 列表封装成 TaskSet 提交给 TaskScheduler 

 

ResultStage 创建的是 ResultTask  

 

TaskScheduler 处理任务 

封装 TaskSetManager, 注册信息, 然后通知 backend 活儿来了 

 

我们这里 暂时只关注 LocalSchedulerBackend 

获取可用的 Executor(offer) 的信息, 然后经过 taskScheduler 调度获取任务, 然后 将任务发送到 Executor 执行 

 

 

"集群"执行任务

Executor 执行任务

 ​

 

将 Task 反序列化, 然后 执行, Task 是 ShuffleMapTask 或者是 ResultTask 

 

ShuffleMapTask 的执行

 

给定的 rdd 的迭代器的获取, 这个 MapPartitionsRDD(map), 这个 rdd 的计算依赖于一个 MapPartitionsRDD(flatMap), 这个 rdd 的计算又依赖于一个 MapPartitionsRDD(map), 这个 rdd 的计算依赖于一个 HadoopRDD(textFile) 

 

给定的 rdd 的数据 迭代

如下图的数据为 paritition0, 最底层的 HadoopRDD 拿到的是 第一行的数据 "234 345 123 346 234" 

 

下面的几行函数 需要注意一下 

分别对应的是 SparkContext.textFile 中的 map(pair => pair._2.toString)

Test01WordCount 里面的 .flatMap(line => line.split(" "))

Test01WordCount 里面的 .map(word => (word, 1))

hasNext:409, Iterator$$anon$11 (scala.collection)
hasNext:440, Iterator$$anon$12 (scala.collection)
hasNext:409, Iterator$$anon$11 (scala.collection)

 

然后再经历一次 mapSideCombine, 所以 第一个 ShuffleMapTask 最后的结果如下 

 

同理 第二个 ShuffleMapTask 最后的结果如下 

 

ShuffleMapTask 结果的输出 

向 shuffleBlockResolver 解析当前 paritition 对应的 ShuffleMapTask 应该写到什么文件, 向该文件写出了 当前 Task 的数据结果信息 

 

呵呵 因为之前整理好了一份, 我这里直接贴当时的整理结果了 

	ShuffleMapTask 执行过程, 里面 ExternalSorter.writePartitionedFile
	blockManager 里面根据 shuffleId, mapId, reduceId(NONE_REDUCE_ID) 来计算应该存放的临时文件, 这个计算出来的结果是固定(hash%localDir.length, (hash/localDir.length)%localSubDir.length)
	参见 LZ4BlockOutputStream
	magic bytes[8] + token[1] + compressed length[4] + decompressed length[4] + checksum[4] + body
	4C5A3442 6C6F636B 150E0000 000E0000 006B3E4D 03030131 32B30202 03013334 B502024C 5A34426C 6F636B15 00000000 00000000 00000000
		4C5A3442 6C6F636B : magic bytes : LZ4Block
		15 : token(COMPRESSION_METHOD_RAW | compressLevel) : 21
		0E0000 00 : compressed length : 14
		0E0000 00 : decompressed length : 14
		6B3E4D 03 : checksum
		03 : idx of java.lang.String
		01 : represent not null
		31 32B3 : "123", 最后一个字节 b3, 原来的数据为 33(|0x80 标记结束)
		02 : idx of int
		02 : 1, 记录的数据未 (value << 1) ^ (value >> 31)
		03 : idx of java.lang.String
		01 : represent not null
		3334 B5 : "345"
		02 : idx of int
		02 : 1
		4C 5A34426C 6F636B : magic bytes : LZ4Block
		15 : token : 21
		00000000 : compressed lenth
		00000000 : decompressed lenth
		00000000 : checksum
	4C5A3442 6C6F636B 150E0000 000E0000 00055AAA 0E030132 33B40204 03013334 B602024C 5A34426C 6F636B15 00000000 00000000 00000000
		4C5A3442 6C6F636B : magic bytes : LZ4Block
		15 : token(COMPRESSION_METHOD_RAW | compressLevel) : 21
		0E0000 00 : compressed length : 14
		0E0000 00 : decompressed length : 14
		6B3E4D 03 : checksum
		03 : idx of java.lang.String
		01 : represent not null
		32 33B4 : "234"
		02 : idx of int
		04 : 2
		03 : idx of java.lang.String
		01 : represent not null
		3334 B6 : "346"
		02 : idx of int
		02 : 1
		4C 5A34426C 6F636B : magic bytes : LZ4Block
		15 : token : 21
		00000000 : compressed lenth
		00000000 : decompressed lenth
		00000000 : checksum

	ShuffleMapTask 输出了两个文件, 每个里面对应两个 partition
		task0 : partition0[(123, 1), (345, 1)], partition1[(234, 2), (346, 1)]
		task1 : parittion0[(123, 1)], partition1[(124, 1)]

	index file 里面存放了 00000000 00000000 00000000 00000038 00000000 00000070
		00000000 00000000 : start
		00000000 00000038 : offset0
		00000000 00000070 : offset1

 

文件系统中的数据结果 

 

 

Executor 反馈 ShuffleMapTask 结果给 Driver 

Executor 封装计算结果信息

如果序列化之后的结果 超过了 maxResultSize(默认1g), 丢弃执行结果 

如果是 超过了可以直接进行网络交互的大小, 先暂存在 blockManager, 返回 InDirectTaskResult 包含元数据信息 

如果可以直接传输, 封装 DirectTaskResult 返回 

 

 

Driver 收到 ShuffleMapTask 任务状态更新

SchedulerBackend 收到任务状态更新 

通知 taskScheduler, 之后维护资源信息, 主动尝试获取可执行的任务 

TaskScheduler 获取结果信息(如果是 InDirectTaskResult 从 blockManager 获取结果), 并处理 

 

TaskSchedulerManager 更新 task, stage 的相关信息, 并通知 DagScheduler 任务完成, 所有任务完成通知 TaskScheduler 

 

DagScheduler 处理 ShuffleMapTask 任务完成, 向 mapOutputTracker 注册 ShuffleMapTask 的输出结果信息 

 

 

ResultStage 的执行 

ShuffleMapStage0 两个 ShuffleMapTask 执行完成之后, 会通知到 ResultStage1 的执行 

 

ShuffleRDD 读取 ShuffleMapTask 的输出结果 

创建 ShuffleBlockFetcherIterator 的时候, 传入了 需要获取 block 列表信息, 这两个都属于 localBlocks 

 

partition0 的 ResultTask 从本地获取这两个 blocks 的数据信息, partition0 的 ShuffleMapTask 的输出文件获取 0 - 55 字节, partition1 的 ShuffleMapTask 的输出文件获取 0 - 48 字节 ([(123, 1), (345, 1)] + [(123, 1)])

对应的 partition1 的 ResultTask 从本地获取这两个 blocks 的数据信息, partition0 的 ShuffleMapTask 的输出文件获取 56 - 112 字节, partition1 的 ShuffleMapTask 的输出文件获取 49 - 98 字节 ([(234, 2), (346, 1)] + [(124, 1)])

 

 

获取数据的 偏移, 数据量, 实际获取数据是 参照 index file 中的存储的数据, 获取当前 reduceId 需要截取的部分 

 

ShuffleRDD 的迭代计算 

这里的 iter 是在 ShuffleBlockFetcherIterator 获取的数据的 迭代器的基础上面增加了 flatter 多个 blockId 上面的结果, 增加统计信息的计算, 可中断特性 

这里的 mergeCombiner 来自于 Test01WordCount 的 reduceByKey((left, right) => left + right)

 

ResultTask 的 Executor 封装计算结果 

partition0 对应的 ResultTask 返回的数据为 [(345,1), (123,2)](一个 Tuple2 数组, 包含两个元素, 是通过 collect 方法中传入的 func "(iter: Iterator[T]) => iter.toArray" 处理之后的结果)

 

partition0 对应的 ResultTask 需要写出的数据拆解如下 

	resultSerialzie
		TC_ARRAY TC_CLASSDESC classDesc[2-27] TC_BLOCKEND superClassDesc lengthOf(tuple) TC_OBJECT classDesc
	valueBuffer : -84 -19 0 5 117 114 0 15 91 76 115 99 97 108 97 46 84 117 112 108 101 50 59 46 -52 0 -33 -47 79 -41 -64 2 0 0 120 112 0 0 0 2
		115 114 0 12 115 99 97 108 97 46 84 117 112 108 101 50 46 -108 102 125 91 -110 -7 -11 2 0 2 76 0 2 95 49 116 0 18 76 106 97 118 97 47 108 97 110 103 47 79 98 106 101 99 116 59 76 0 2 95 50 113 0 126 0 3 120 112
		116 0 3 51 52 53 115 114 0 17 106 97 118 97 46 108 97 110 103 46 73 110 116 101 103 101 114 18 -30 -96 -92 -9 -127 -121 56 2 0 1 73 0 5 118 97 108 117 101
		120 114 0 16 106 97 118 97 46 108 97 110 103 46 78 117 109 98 101 114 -122 -84 -107 29 11 -108 -32 -117 2 0 0 120 112
		0 0 0 1 115 113 0 126 0 2 116 0 3 49 50 51 115 113 0 126 0 6 0 0 0 2
		0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
	需要写出的数据为 : value = {Tuple2[2]@6640}
                0 = {Tuple2@6657} "(345,1)"
                1 = {Tuple2@6658} "(123,2)"
	# 拆解一下整个字节数组
		$streamMagic : -84 -19
		$streamVersion : 0 5
		TC_ARRAY : 117
		$TC_CLASSDESC : 114
		$nameLen : 0 15
		$className : 91 76 115 99 97 108 97 46 84 117 112 108 101 50 59 : [Lscala.Tuple2;
		$serialVersionUid : 46 -52 0 -33 -47 79 -41 -64 : 3372071182283036608
		$flags : 2 : $SC_SERIALIZABLE
		$fieldsLen : 0 0
		TC_ENDBLOCKDATA : 120
		$superClassDesc : 112 : null
		$arrayLength : 0 0 0 2
			TC_OBJECT : 115
			$TC_CLASSDESC : 114
			$nameLen : 0 12
			$className : 115 99 97 108 97 46 84 117 112 108 101 50 : scala.Tuple2
			$serialVersionUid : 46 -108 102 125 91 -110 -7 -11 : 3356420310891166197
			$flags : 2 : $SC_SERIALIZABLE
			$fieldsLen : 0 2
				$typeOf 'Ljava/lang/String;' : 76
				$fieldLen : 0 2
				$fieldName : 95 49 : _1
				$TC_STRING, $len, Ljava/lang/String; : 116 0 18 76 106 97 118 97 47 108 97 110 103 47 79 98 106 101 99 116 59
				$typeOf 'Ljava/lang/String;' : 76
				$fieldLen : 0 2
				$fieldName : 95 50 : _2
				$TC_REFERENCE $handleOf'Ljava/lang/String;' : 113 0 126 0 3
			$TC_ENDBLOCKDATA : 120
			$superDesc[recursely] : 112

			_1, $TC_STRING, $len, $345 : 116 03 51 52 53 : 345
			TC_OBJECT : 115
			$TC_CLASSDESC : 114
			$nameLen : 0 17
			$className : 106 97 118 97 46 108 97 110 103 46 73 110 116 101 103 101 114 : java.lang.Integer
			$serialVersionUid : 18 -30 -96 -92 -9 -127 -121 56
			$flags : 2 : $SC_SERIALIZABLE
			$fieldsLen : 0 1
				$typeOf 'Ljava/lang/Integer;' : 73
				$fieldLen : 0 5
				$fieldName : 118 97 108 117 101 : value
			$TC_ENDBLOCKDATA : 120
			$TC_CLASSDESC : 114
			$nameLen : 0 16
			$className : 106 97 118 97 46 108 97 110 103 46 78 117 109 98 101 114 : java.lang.Number
			$serialVersionUid : -122 -84 -107 29 11 -108 -32 -117
			$flags : 2 : $SC_SERIALIZABLE
			$fieldsLen : 0 0
			$TC_ENDBLOCKDATA : 120
			$superClassDesc : 112 : null
			_2 : 0 0 0 1

			TC_OBJECT : 115
			$TC_REFERENCE $handleOf'scala.Tuple2' : 113 0 126 0 2
			_1, $TC_STRING, $len, $123 : 116 0 3 49 50 51 : 123
			TC_OBJECT : 115
			$TC_REFERENCE $handleOf'java.lang.Integer' : 113 0 126 0 6
			_2 : 0 0 0 2

 

 

Driver 对应 ResultTask 任务完成的处理 

DagScheduler 的处理 

更新job的相关信息, 通知到 jobListener(就是最开始 submitJob 创建的 JobWaiter)

 

JobWaiter 的处理 

JobWaiter 这边会使用 resultHandler 来处理结果, 如果所有的任务都完成了, 通知到 jobPromise 

 

然后回到 DagScheduler 之后, 继续走后面的流程 

 

 

Spark UI 上面的统计信息 

回顾一下 文章最开始的几张图, 我们来看一下 其中的一部分数据 

job 的概览信息如下 

 

stage0 的概览信息如下 

 

stage1 的概览信息如下 

为了便于理解, 我们先从 stage 的详细信息开始 

 

stage0 的概览信息

先看一下左上角, 描述了 当前这个 stage 的那些 transformation 

sc.textFile(func).flatMap(func).map(func) 

textFile 中包含了 一个 hadoopFile 的操作 和一个 map 的操作, 在 Test01WordCount 的 21 行 

然后 之后有一个 flatMap 的操作, 在 Test01WordCount 的 22 行 

然后 之后有一个 map 的操作, 在 Test01WordCount 的 23 行 

 

然后我们再来看一下 Task 的信息 

index 为 0 的 task, 读取了 27 字节, 读取了 1 条记录, shuffle 的时候 写出了 112 字节, 写出了 4 条记录 

index 为 1 的 task, 读取了 14 字节, 读取了 1 条记录, shuffle 的时候 写出了 98 字节, 写出了 2 条记录 

 

 

我们剖析一下这个数据 

index 为 0 的 task 对应的是 partition0 的 ShuffleMapTask, 操作的数据是 第一行的数据 "234 345 123 346 234", 总共是 19 个字符 

根据 splitLength 来往 buffer 里面填充数据, 第一个批次 13 个字节, 里面没得 CR/LF, 继续第二个批次(读取了14字节), 读到第 6 个字节之后遇到了 CR/LF, 返回 text, 这里读取是 以字节的形式读取的, 然后 text 解码为字符串(编码为 utf8) 

Text 的编码, 解码方式 

 

那么这里的 index 为 0 的 task, 读取了 27 字节, 是指的这个 13 + 14 么 ?

我们来看一下 metrics 中更新 读取字节数 的地方 

获取读取的字节数的方式, 从 FileSystem 的统计信息中获取的 

 

我们再来看一下 FileSystem 更新统计信息的地方 

原来是有一个 BufferedInputStream 里面的 buffer 的长度是 65536, 因此会填充 min(available, 65536) 个字节, 对于我们这里 有 27 个字节, 因此 index 为 0 的 task, 读取了 27 字节 

 

再看一下 index 为 0 的 task 记录的输出, 可以参见 上面 "ShuffleMapTask 结果的输出" 

可以 清晰的看到, 这 112 个字节是什么, 有什么逻辑意义, 然后 输出的 4 条记录 是什么 

 

同样的道理, 可以剖析出 

index 为 1 的 task, 读取了 14 字节, 读取了 1 条记录, shuffle 的时候 写出了 98 字节, 写出了 2 条记录 

 

stage1 的概览信息

这里的业务操作 是一个 reduceByKey(func) 

 

然后我们再来看一下 Task 的信息 

index 为 0 的 task, 读取了 105 字节, 读取了 3 条记录, 

index 为 1 的 task, 读取了 105 字节, 读取了 3 条记录 

 

partition0 的 ResultTask 从本地获取这两个 blocks 的数据信息, partition0 的 ShuffleMapTask 的输出文件获取 0 - 55 字节, partition1 的 ShuffleMapTask 的输出文件获取 0 - 48 字节 ([(123, 1), (345, 1)] + [(123, 1)])

对应的 partition1 的 ResultTask 从本地获取这两个 blocks 的数据信息, partition0 的 ShuffleMapTask 的输出文件获取 56 - 112 字节, partition1 的 ShuffleMapTask 的输出文件获取 49 - 98 字节 ([(234, 2), (346, 1)] + [(124, 1)])

因此  上面的信息 大致就是这样, 56 + 49 = 105 字节, 三条记录 

 

job 的概览信息 

当前 job 有两个 Stage, 根据 ShuffleDependecy 划分的 Stage 

一个 ShuffleMapStage 和 一个 ResultStage 

ShuffleMapStage 中包含 transformation : sc.textFile(func).flatMap(func).map(func)

ResultStage : redunceByKey(func) 

 

下面是这两个 Stage 的详情  

ShuffleMapStage 对应两个 Task, 读取的是 27 + 14 字节, shuffle 输出 112 + 98 字节  

ResultStage 对应两个 Task, 读取 210 字节 

 

 

呵呵 写了一天多, 真够多的, 愿你去调试, 愿你有所收获, 记录于 2020.09.20 

 

 

完 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值