SparkCore

SparkCore总结

1、RDD概述

1、什么是RDD?

​ RDD是弹性分布式数据集,代表的是抽象类、弹性的、不可变、分布式、可分区、分区元素可并行计算的集合。、分布式、可分区、分区元素可并行计算的集合。
弹性:
​ 存储的弹性: 内存与磁盘的动态切换[RDD的中间结果是保存在内存中,如果内存不足会自动将数据保存到磁盘]。
​ 容错的弹性: 数据丢失会自动恢复[某个RDD某个分区的数据丢失之后会根据RDD依赖关系找到第一个RDD重新读取数据重新开始计算最终得到丢失的数据]
​ 计算的弹性: 计算出错会重试
​ 分区的弹性: RDD的分区会根据文件的切片动态规划
不可变:
​ rdd里面只封装了数据的处理逻辑,如果想要重新改变数据,需要生成新的RDD,新RDD中封装新的处理逻辑。
分布式:
​ 数据是分布式存储,计算的时候也是分布式的

不存储数据:
rdd里面只封装了数据的处理逻辑,不存储数据,每个rdd数据处理完成之后会传递给下一个rdd。

可分区与分区元素可并行计算:
rdd为了分布式计算,会根据文件的切片规划分区,一般文件有多少切片,RDD就有多少分区,rdd的分区是分布式并行计算。
RDD是惰性的,只有触发collect这类算子才会触发任务的计算。

2、RDD五大特性

一组分区列表: RDD会有多个分区
作用在每个分区上计算函数: RDD的多个分区是并行计算的,计算逻辑是一样的,计算数据不一样。
**一组其他rdd的依赖关系:**RDD如果计算出错需要根据依赖关系找到父RDD,所以每个RDD都会将父RDD保存。
分区器[可选]: 用于shuffle规划数据放在哪个分区的组件,分区器只能用在元素为KV类型的RDD上。
优先位置[可选]: spark在分配task的时候会考虑将task分配到数据所在的位置,便于数据的拉取。

2、RDD的编程

1、RDD的创建

1、通过集合创建
sc.makeRDD(集合)
sc.parallelize(集合)
2、通过读取文件创建
sc.textFile(path)
3、通过其他rdd衍生
val rdd = rdd1.map/flatMap/…

2、RDD分区数

1、通过集合创建RDD的分区数:

val rdd = sc.parallelize(集合[,numSlices=defaultParallelism])

​ 1、如果创建rdd的时候有指定numSlices参数,此时创建的rdd分区数 = 指定的numSlices的值
​ 2、如果创建rdd的时候没有指定numSlices参数,此时创建的rdd分区数 = 默认值【defaultParallelism】
​ 1、如果在创建sparkcontext的时候有再sparkconf中设置spark.default.parallelism参数,此时defaultParallelism=spark.default.parallelism参数值。
​ 2、如果在创建sparkcontext的时候没有在sparkconf中设置spark.default.parallelism参数
​ 1、master=local,defaultParallelism=1
​ 2、master=local[N],defaultParallelism=N
​ 3、master=local[*],defaultParallelism=cpu个数
​ 4、master=spark://… , defaultParallelism = max(所有executor总核数,2)

2、通过读取文件创建RDD的分区数:

sc.textFile(path[,minPartitions=defaultMinPartitions])

​ 1、如果创建rdd的时候有指定minPartitions参数,此时rdd的分区数>=指定的minPartitions的值
​ 2、如果创建rdd的时候没有指定minPartitions参数,此时rdd的分区数>=min(defaultParallelism,2)
​ 读取文件创建的rdd的分区数最终还是有文件的切片数决定。
​ 3、通过其他rdd衍生出新RDD的分区数 = 依赖的第一个rdd的分区数

3、spark算子

spark算子分为两类:

1、Transformation转换算子: 不会触发任务计算,只会封装数据的处理逻辑,调用之后会生成新的RDD

1. map ※※※※※※

map(func: RDD元素类型=>B): 一对一映射[原RDD一个元素映射新RDD一个元素]

​ map里面的函数是针对每个元素操作,RDD有多少元素,函数就调用多少次
​ map生成的新RDD元素个数 = 原RDD元素个数
​ map的应用场景: 数据类型/值的转换

2. mapPartitions ※※※※※※

mapPartitions(func: Iterator[RDD元素类型]=>Iterator[B]): 一对一映射[原RDD一个分区映射新RDD一个分区]

​ mapPartitions里面的函数是针对每个分区操作,RDD有多少分区,函数就调用多少次
​ mapPartitions里面的函数的参数是一个分区所有数据的迭代器
​ mapPartitions的应用场景: 用于读取mysql/hbase/redis等存储介质的数据,此时可以减少链接创建与销毁的次数。

3. mapPartitions与map的区别: ※※※※※※

1、函数的参数不一样
map里面的函数是针对每个元素操作,RDD有多少元素,函数就调用多少次
mapPartitions里面的函数是针对每个分区操作,RDD有多少分区,函数就调用多少次
2、函数的返回值类型不一样
map里面的函数返回的是新RDD的一个元素,所以map生成新RDD元素个数 = 原RDD元素个数
mapPartitions里面的函数的返回值是新RDD一个分区所有数据的迭代器,所以mapPartitions生成的新RDD元素个数不一定等于原RDD元素个数。
3、元素内存回收的时机不一样
map里面的函数是针对每个元素操作,所以元素操作完成之后,元素就可以回收了
mapPartitions里面的函数是针对分区所有数据的迭代器操作,当某个元素处理完成之后,不会立即回收必须等到该分区迭代器中所有数据全部处理完成才会统一回收,所以如果分区中数据量比较大,可能出现内存溢出的情况,此时可以使用map代替。

4.mapPartitionsWithIndex

mapPartitionsWithIndex(func: (Int,Iterator[RDD元素类型])=> Iterator[B]): 一对一映射[原RDD一个分区映射新RDD一个分区]

​ mapPartitionsWithIndex里面的函数是针对每个分区操作,RDD有多少分区,函数就调用多少次
​ mapPartitionsWithIndex里面的函数的第一个参数代表分区号
​ mapPartitionsWithIndex里面的函数的第二个参数代表分区号对应分区的所有数据的迭代器。
​ mapPartitionsWithIndex与mapPartitions的区别:
​ mapPartitionsWithIndex里面的函数的参数相对mapPartitions来说多了个分区号。

5.flatMap ※※※※※※

flatMap(func: RDD元素类型=>集合) = map + flatten 处理数据之后压平

​ flatMap里面的函数是针对每个元素操作,RDD有多少元素,函数就调用多少次
​ flatMap生成的新RDD元素个数>=原RDD元素个数
​ flatMap的应用场景: 一对多

6.glom

​ glom就是将每个分区所有数据转换成一个数组
​ glom生成新RDD元素个数 = 分区数,新RDD里面的元素类型是Array

7.groupBy ※※※※※※

groupBy(func: RDD元素类型=>K ): 按照指定字段分组
groupBy里面的函数是针对每个元素操作
groupBy是按照函数的返回值对原RDD所有元素进行分组
groupBy生成新RDD元素类型KV键值对,K是函数的返回值,V是一个集合,集合中装在的是K对应原RDD中的所有元素
groupBy会产生shuffle操作

8.filter ※※※※※※

filter(func: RDD元素类型=>Boolean): 按照指定条件过滤
filter里面的函数是针对每个元素操作,RDD有多少元素,函数就调用多少次
filter保留的是函数返回值为true的数据。

9.sample ※※※※※※

sample(withRelements,fraction): 采样
withRelements:代表同一元素是否可以被多次采样[true代表同一个元素可能被采样多次,false代表同一个元素最多被采样一次]<工作中设置为false>
fraction
withRelements=true, fraction代表元素期望被采样的次数[>0]。
withRelements=false,fraction代表每个元素被采样的概率[0,1] <fraction工作中一般设置为0.1-0.2>
sample一般只用于数据倾斜场景,发生数据倾斜之后,一般通过采样的样本数据确定哪个key出现了数据倾斜,然后针对性进行处理。

10.distinct ※※※※※※

distinct: 去重
distinct会产生shuffle操作。

11.coalesce ※※※※※※

coalesce(分区数[,shuffle=false]): 合并分区
coalesce默认只能减少分区数,此时没有shuffle操作
coalesce如果想要增大分区数,必须设置shuffle=true,但是此时会产生shuffle操作。
coalesce一般搭配filter使用减少分区数[filter之后分区数据量变小了]

12.repartition ※※※※※※

repartition(分区数): 重分区
repartition既可以增大分区数也可以减少分区数,但是都会产生shuffle操作
repartition底层就是使用的coalesce(分区数,shuffle=true)
repartition一般用于增大分区数,因为使用简单

13.coalesce与repartition的区别: ※※※※※※

​ coalesce默认只能减少分区数,此时没有shuffle操作
​ repartition既可以增大分区数也可以减少分区数,但是都会产生shuffle操作

14.sortBy ※※※※※※

sortBy(func: RDD元素类型=>K): 按照指定字段排序
sortBy里面的函数是针对每个元素操作,RDD有多少元素,函数就调用多少次
sortBy后续是按照函数的返回值对原RDD所有元素排序
sortBy会产生shuffle操作

15.pipe(path): 调用脚本

​ pipe调用的时候是每个分区调用一次
​ pipe调用脚本的时候可以在脚本中通过echo返回新RDD一个分区的 数据。

16.intersection: 交集

​ intersection会产生shuffle

17.subtract: 差集

​ subtract会产生shuffle

18.union: 并集

​ union不会产生shuffle
​ union生成新RDD分区数 = 依赖的两个父RDD分区数之和

19.zip: 拉链

​ zip不会产生shuffle
​ 两个RDD要想拉链必须要求分区数和元素个数都一样。

20.partitionBy(p:Partitioner): 根据指定的分区器重分区

​ 自定义分区器:
​ 1、定义一个class继承Partitioner
​ 2、重写抽象方法

21.groupByKey: 根据key分组 ※※※

​ groupByKey的生成的新RDD元素类型是KV键值对,K是分组的key,V是key对应原RDD的所有value值的集合。

22.reduceByKey ※※※※※※

reduceByKey(func: (Value值类型,Value值类型)=>Value值类型): 根据key分组,对每个组所有value值聚合
reduceByKey与groupByKey的区别:
reduceByKey存在combiner预计算过程,combiner之后数据量变小,shuffle性能更高
groupByKey没有预计算过程

23.combineByKey

combineByKey(createCombiner: Value值类型=>C,mergeValue: (C,Value值类型)=>C,mergeCombine: (C,C)=>C )
createCombiner: 是在combiner阶段对每个组第一个value值进行转换
mergeValue: combiner预聚合计算逻辑,针对每个组第一次计算的时候,函数第一个参数的初始值 = 第一个函数对该组第一个value值的转换结果
mergeCombine: reducer计算逻辑,针对每个组第一次计算的时候,函数第一个参数的初始值 = 该组第一个value值

24.foldByKey

foldByKey(默认值)(func: (Value值类型,Value值类型)=>Value值类型 ): 根据key分组,对每个组所有value值聚合

25.aggregateByKe

aggregateByKey(默认值)(seqOp: (默认值类型,Value类型)=>默认值类型,comOp: (默认值类型,默认值类型)=>默认值类型):根据key分组,对每个组所有value值聚合

26.reduceByKey、foldByKey、combineByKey、aggregateByKey的区别:

​ reduceByKey: combiner与reducer计算逻辑一样, combiner函数针对每个组第一次计算的时候,函数第一个参数的初始值 = 该组第一个value值
​ foldByKey: combiner与reducer计算逻辑一样, combiner函数针对每个组第一次计算的时候,函数第一个参数的初始值 = 默认值
​ combineByKey: combiner与reducer计算逻辑可以不一样,combiner函数针对每个组第一次计算的时候,函数第一个参数的初始值 = 第一个函数对该组第一个value值的转换结果
​ aggregateByKey: combiner与reducer计算逻辑可以不一样,combiner函数针对每个组第一次计算的时候,函数第一个参数的初始值 = 默认值

27.sortByKey([ascending=true]): 根据key对数据排序 ※※※※※※
28.mapValues

mapValues(func: value值类型=>B): 一对一映射[原一个value值映射生成新RDD一个value值,key不变]
mapValues里面的函数是针对每个元素的value值操作,元素有多少个,函数就执行多少次

29.join

join: 相当于sql内连接,结果数据为: 两个rdd元素key相同才能join上
join生成新RDD里面元素类型是KV键值对
K是join的key,也就是元素的key
V是二元元组,二元元组第一个值是左RDD key对应的value值,二元元组第二个值是右RDD key对应的value值

30.leftOuterJoin

leftOuterJoin: 相当于sql左连接,结果数据为: 两个rdd元素key相同join的数据 + 左RDD不能join的数据
leftOuterJoin生成新RDD里面元素类型是KV键值对
K是join的key,也就是元素的key
V是二元元组,二元元组第一个值是左RDD key对应的value值,二元元组第二个值是Option(右RDD key对应的value值)

31.rightOuterJoin

rightOuterJoin: 相当于sql右连接,结果数据为: 两个rdd元素key相同join的数据 + 右RDD不能join的数据
rightOuterJoin生成新RDD里面元素类型是KV键值对
K是join的key,也就是元素的key
V是二元元组,二元元组第一个值是Option(左RDD key对应的value值),二元元组第二个值是右RDD key对应的value值

32.fullOuterJoin

fullOuterJoin:相当于sql全连接,结果数据为: 两个rdd元素key相同join的数据 + 右RDD不能join的数据 + 左RDD不能join的数据
fullOuterJoin生成新RDD里面元素类型是KV键值对
K是join的key,也就是元素的key
V是二元元组,二元元组第一个值是Option(左RDD key对应的value值),二元元组第二个值是Option(右RDD key对应的value值)

33.cogroup

cogroup: 先对两个rdd执行groupByKey,然后将两个rdd groupByKey的结果进行全外连接

2、Action行动算子: 触发任务计算,调用之后生成的是具体的结果数据

1.reduce(func: (RDD元素类型,RDD元素类型)=>RDD元素类型): 对RDD所有元素聚合

​ 聚合原理: 先在每个分区中对分区所有数据聚合,然后将每个分区的聚合结果发给Driver进行汇总
​ reduce没有shuffle

2.collect: 收集RDD每个分区的数据发给Driver以数组的形式封装 ※※※※※※

​ 如果RDD分区数据比较大,Driver内存默认只有1G,所以可能出现内存溢出
​ 所以工作中一般需要设置Driver的内存大小为5-10G。
​ 可以通过bin/spark-submit --driver-memory 设置Driver内存大小

3.​count: 统计RDD元素个数
4.first: 获取RDD第一个元素

​ first首先会启动一个Job从0号分区获取第一个元素,如果0号分区没有数据,会再启动一个Job从其他分区获取数据。

5.​take: 获取RDD前N个元素

​ take首先会启动一个Job从0号分区获取前N个元素,如果0号分区N个数据,会再启动一个Job从其他分区获取剩余的数据。

6.takeOrdered: 对RDD数据排序之后取前N个元素
7.fold(默认值)(func: (RDD元素类型,RDD元素类型)=>RDD元素类型 ): 对RDD所有元素聚合

​ 聚合原理: 先在每个分区中对分区所有数据聚合,然后将每个分区的聚合结果发给Driver进行汇总
​ 在每个分区内对分区的数据第一次计算的时候,函数第一个参数的初始值 = 默认值
​ 在Driver中第一次汇总的时候,函数第一个参数的初始值 = 默认值

8.aggregate(默认值)(seqOp: (默认值类型,RDD元素类型)=>默认值类型, comOp: (默认值类型,默认值类型)=>默认值类型): 对RDD所有元素聚合

​ 聚合原理: 先在每个分区中对分区所有数据聚合,然后将每个分区的聚合结果发给Driver进行汇总
​ seqOp: 用于分区内的聚合逻辑
​ seqOp在每个分区中第一次聚合的时候,函数第一个参数的初始值 = 默认值
​ comOp: 用于Driver汇总逻辑
​ comOp在Driver中第一次汇总的时候,函数第一个参数的初始值 = 默认值
​ countByKey: 统计每个key出现的总次数 *********

7.countByKey一般结合sample算子一起使用

​ 出现数据倾斜之后,一般是先使用sample算子会RDD数据采样,然后使用countByKey对样本数据中每个key进行统计,确定哪个key出现了数据倾斜。

8.foreach(func: RDD元素类型=> Unit):Unit : 对元素遍历

​ foreach里面的函数是针对RDD每个元素遍历,元素有多少个,函数就执行多少次

9***.foreachPartition(it: Iterator[RDD元素类型]=>Unit ):Unit : 对分区遍历 ※※※※※※***

​ foreachPartition里面的函数是针对RDD每个分区操作,分区有多少个,函数就执行多少次
​ foreachPartition一般用于将数据保存到mysql/hbase/redis等存储介质中,可以减少资源连接的创建与销毁

4、序列化

序列化的原因:

​ spark算子里面的函数是在Executor task中执行的,spark算子函数外面的代码是在Driver中执行的,所以如果spark算子函数体中使用了Driver定义的对象,此时spark会将Driver定义的对象序列化传给Task使用,所以要求该对象必须能够序列化。

spark序列化方式:

spark序列化方式有两种: Java序列化、Kryo序列化
​ Kryo序列化的性能比Java序列化高10倍左右,所以工作中一般推荐使用Kryo序列化
​如何配置spark使用Kryo序列化:
​ 1、在定义sparkconf的时候配置spark序列化方式: new SparkConf().set(“spark.serializer”,“org.apache.spark.serializer.KryoSerializer”)
​ 2、注册使用Kryo序列化的类[可选]: new SparkConf().registerKryoClasses(Array( classOf[类名],classOf[类名],… ))
​注册使用Kryo序列化的类可做可不做,区别在于: 如果类注册了,序列化的时候不会序列化全类名,反之会序列化全类名

5、依赖关系

血缘关系:

​ 指一个Job中一些列RDD的依赖链条
​ 可以通过 rdd.toDebugString 查看血缘关系

依赖关系: 指父子RDD的关系

​ 可以通过rdd.dependencys查看依赖关系
​ RDD的依赖关系分为两种:
​ 宽依赖: 有shuffle的称之为宽依赖
​ 窄依赖: 没有shuffle的称之为窄依赖
​ ​Application: 应用[一个SparkContext称之为一个应用]
​ Job: 任务[ 一般一个action算子产生一个job]
​ stage: 阶段[一个job中stage个数 = shuffle个数+1]
​ task: 子任务 [一个stage中task个数 = stage最后一个rdd的分区数]
​一个Application中多个Job之间是串行的
​一个job中多个stage之间是串行的
​一个stage中多个task之间是并行的
​ job中stage的切分流程[从后往前查询切分]: 通过最后一个调用action算子的rdd的依赖关系查询父RDD,然后根据父RDD的依赖再查询父RDD,循环往复,一直查询到第一个RDD为止,在查询的过程中遇到宽依赖则切分stage
​ job中stage的执行[从前往后执行]: 先执行前面的stage,在执行后面的stage[因为后面stage的输入数据是前面stage的输出数据]

6、持久化

场景:

​ 1、多个Job中共用一个RDD
​ 好处: 在第一个job执行的过程中如果能够将共用RDD数据保存,后续job在执行的时候就可以直接拿取保存的RDD数据使用不用重复计算了[避免了数据的重复处理]
​ 2、一个Job中RDD的依赖链条太长
​ 好处: 如果RDD计算出错,在恢复数据的时候不用从头计算了,可以拿取保存RDD的数据再计算得到出错的数据[避免时间的浪费]

RDD的持久化方式:
1、缓存

​ 数据保存位置: task所在主机的内存/本地磁盘中
​ 使用方式: rdd.cache/ rdd.persist
​ cache与persit的区别:
​ cache其实就是persist(StorageLevel.MEMORY_ONLY),将数据只保存到内存中
​ persist可以自己指定存储级别,将数据保存到内存/磁盘中
​ 常用的存储级别:
​ StorageLevel.MEMORY_ONLY: 将数据只保存到内存中[一般用于小数据量场景]
​ StorageLevel.MEMORY_AND_DISK:一部分数据保存到内存中,一部分保存到磁盘中[用于大数据量场景]

2、checkpoint

​ 有了缓存还需要checkpoint的原因: 缓存是将数据保存在内存/本地磁盘中,数据可能丢失,所以需要将数据保存到可靠的存储介质中
​ 数据保存位置: HDFS
​ 使用方式:
​ 1、设置数据保存位置: sc.setCheckpointDir(path)
​ 2、保存数据: rdd.checkpoint
​ checkpoint会触发一次job执行: 某个rdd要checkpoint,当该RDD所在的一个job执行完成之后,会检查是否有checkpoint操作,如果有则触发一次job,该job用于收集checkpoint rdd的数据保存到HDFS.
​ 某个rdd要checkpoint,checkpoint会触发job执行,该RDD之前的数据处理又会重复执行,所以为了避免数据的重复处理,一般会将checkpoint与缓存结合使用: rdd.cache ; rdd.checkpoint

3、缓存与checkpoint的区别

​ 1、数据保存位置不一样
​ 缓存是将数据保存在内存/本地磁盘中
​ checkpoint是将数据保存到HDFS
​ 2、RDD的依赖关系是否切除不一样
​ 缓存是将数据保存在内存/本地磁盘中,数据可能丢失,丢失之后必须根据依赖关系重新计算得到数据,所以依赖关系必须保留。
​ checkpoint是将数据保存到HDFS,数据不会丢失,checkpoint之前的依赖关系会切掉。

7、分区器

​ spark分区器是shuffle的时候决定数据落入子RDD哪个分区中
​ HashPartitioner
​ 分区规则: (key.hashCode % 子RDD分区数) < 0 ? (key.hashCode % 子RDD分区数) + 子RDD分区数 : key.hashCode % 子RDD分区数
​ RangePartitioner
​ 分区规则:
​ 1、首先对RDD数据采样,最终得到(子RDD分区数-1)个key[假设子RDD分区数为3,确定的key为1,5]
​ 2、通过这几个key确定子RDD每个分区的边界[0号分区的变量: key<=1 ;1号分区的边界:key>1 and key<=5; 2号分区的边界: key>5]
​ 3、后续拿到数据的key之后与每个分区边界对比,key处于哪个分区边界范围内,则将数据放入哪个分区中

8、数据读取与保存

​ 读取数据
​ 读取文本数据: sc.textFile
​ 读取对象文件数据: sc.objectFile[数据类型]
​ 读取序列文件数据: sc.sequenceFile[K的类型,V的类型]
​ 通过指定InputFormat读取hadoop文件: sc.hadoopFile/sc.newHadoopFile
​ 保存数据
​ 保存为文本: rdd.saveAsTextFile
​ 保存为对象文件: rdd.saveAsObjectFile
​ 保存为序列文件: rdd.saveAsSequenceFile
​ 通过指定的OutputFormat保存数据: rdd.saveAsHadoopFile/saveAsNewApiHadoopFile

3、累加器

聚合原理: 现在每个分区中对分区内的数据聚合,然后每个分区的聚合结果发给Driver进行全局汇总
场景:用于聚合场景,而且是聚合结果不太大的场景【因为最终汇总是在Driver中,Driver内存不会特别大】
​好处:能够一定程度上避免shuffle
​自定义累加器:
​ 1、定义class继承AccumulatorV2[IN,OUT]
​ IN: 代表分区中累加的元素类型
​ OUT: 代表累加器最终结果类型
​ 2、重写抽象方法
​自定义累加器的使用:
​ 1、创建自定义累加器对象: val acc = new XXX()
​ 2、注册到sparkcontext: sc.register(acc,“累加器名称”)
​ 3、在spark算子中使用累加器累加数据: x=> acc.add(…)
​ 4、获取最终累加结果: acc.value

4、广播变量

场景:

1、spark算子中需要使用到Drvier数据,而且该数还有一定大小[有几十上百兆]

​ 好处: 程序运行过程中数据占用的内存变少
​ 默认情况下,算子函数中使用Driver数据的时候,该数据在程序运行过程中占用的总内存大小 = task个数 * 数据大小。
​ 默认情况下占用的内存会过大,所以需要将Driver数据广播出去,广播给Executor,此时数据在程序运行过程中占用的总内存大小 = executor个数 * 数据大小

2、大表 join 小表

​ 好处: 能够减少shuffle
​ 只需要使用collcet将小表的数据收集到Driver,然后将小表数据广播出去,后续对大表数据使用map算子,在算子中获取广播小表数据对大表数据进行转换。

使用广播变量

​ 1、广播数据: val bc = sc.broadcast(数据)
​ 2、task使用广播数据: bc.value [bc应该是在算子函数体中使用]

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值