Flink基础学习(四)

DataStream API 开发

1.Time 与 Window

1.1 Time

在 Flink 的流式处理中,会涉及到时间的不同概念,如下图所示:
在这里插入图片描述
Event Time:是事件创建的时间。它通常由事件中的时间戳描述,例如采集的日志数据中, 每一条日志都会记录自己的生成时间,Flink 通过时间戳分配器访问事件时间戳。
Ingestion Time:是数据进入 Flink 的时间。
Processing Time:是每一个执行基于时间操作的算子的本地系统时间,与机器相关,默认 的时间属性就是 Processing Time。
例如,一条日志进入 Flink 的时间为 2019-08-12 10:00:00.123,到达 Window 的系统时间 为2019-08-12 10:00:01.234, 日志的内容如下: 2019-08-02 18:37:15.624 INFO Fail over to rm2 对于业务来说,要统计 1min 内的故障日志个数,哪个时间是最有意义的?—— eventTime, 因为我们要根据日志的生成时间进行统计。

1.2 Window

Window 概述

streaming 流式计算是一种被设计用于处理无限数据集的数据处理引擎,而无限数据集 是指一种不断增长的本质上无限的数据集,而 window 是一种切割无限数据为有限块进行处 理的手段。Window 是无限数据流处理的核心,Window 将一个无限的 stream 拆分成有限大 小的”buckets”桶,我们可以在这些桶上做计算操作。

Window 类型

Window 可以分成两类:

  1. CountWindow:按照指定的数据条数生成一个 Window,与时间无关。
  2. TimeWindow:按照时间生成 Window。 对于 TimeWindow,可以根据窗口实现原理的不同分成三类:
    滚动窗口(Tumbling Window)、 滑动窗口(Sliding Window)和会话窗口(Session Window)。
    滚动窗口(Tumbling Windows) 将数据依据固定的窗口长度对数据进行切片。 特点:时间对齐,窗口长度固定,没有重叠。 滚动窗口分配器将每个元素分配到一个指定窗口大小的窗口中,滚动窗口有一个固定的大 小,并且不会出现重叠。例如:如果你指定了一个 5 分钟大小的滚动窗口,窗口的创建 如下图所示:
    在这里插入图片描述
    滑动窗口(Sliding Windows) 滑动窗口是固定窗口的更广义的一种形式,滑动窗口由固定的窗口长度和滑动间隔组成。 特点:时间对齐,窗口长度固定,有重叠。
    滑动窗口分配器将元素分配到固定长度的窗口中,与滚动窗口类似,窗口的大小由窗口 大小 参数来配置,另一个窗口滑动参数控制滑动窗口开始的频率。因此,滑动窗口如果滑 动参数小于 窗口大小的话,窗口是可以重叠的,在这种情况下元素会被分配到多个窗口中。 例如,你有 10 分钟的窗口和 5 分钟的滑动,那么每个窗口中 5 分钟的窗口里包含着上个 10 分钟产生的数据。 如下图所示:
    在这里插入图片描述
    会话窗口(Session Windows) 由一系列事件组合一个指定时间长度的 timeout 间隙组成,类似于 web 应用的 session, 也 就是一段时间没有接收到新数据就会生成新的窗口。 特点:时间无对齐。
    session 窗口分配器通过 session 活动来对元素进行分组,session 窗口跟滚动窗口 和滑动 窗口相比,不会有重叠和固定的开始时间和结束时间的情况,相反,当它在一个固 定的时间周期内不再收到元素,即非活动间隔产生,那个这个窗口就会关闭。一个 session 窗口通过一个 session 间隔来配置,这个 session 间隔定义了非活跃周期的长度,当这个 非活跃周期产生,那么当前的 session 将关闭并且后续的元素将被分配到新的 session 窗 口中去。如下图所示:
    在这里插入图片描述

1.3 Window API

CountWindow

CountWindow 根据窗口中相同 key 元素的数量来触发执行,执行时只计算元素数量达到窗 口大小的 key 对应的结果。
注意:CountWindow 的 window_size 指的是相同 Key 的元素的个数,不是输入的所有元素 的总数。
滚动窗口 默认的 CountWindow 是一个滚动窗口,只需要指定窗口大小即可,当元素数量达到窗口大 小时,就会触发窗口的执行。
步骤:
1.获取执行环境
2.创建 SocketSource
3.对 stream 进行处理并按 key 聚合
4.countWindow 操作
5.执行聚合操作
6.将聚合数据输出
7.执行程序

package cn.czxy.stream.window 
import org.apache.flink.api.java.tuple.Tuple 
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.streaming.api.scala._ 
import org.apache.flink.streaming.api.windowing.windows.GlobalWindow 

object StreamCountWindow { 
	def main(args: Array[String]): Unit = { 
		// 获取执行环境 
		val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment 
		//创建 SocketSource 
		val socketDataStream: DataStream[String] = env.socketTextStream("node01", 9999) 
		//对 stream 进行处理并按 key 聚合 
		val keyByStream: KeyedStream[(String, Int), Tuple] = socketDataStream.flatMap(x => x.split(" ")).map((_, 1)).keyBy(0) 
		//这里的 5 指的是 5 个相同 key 的元素计算一次 
		val streamWindow: WindowedStream[(String, Int), Tuple, GlobalWindow] = keyByStream.countWindow(5) 
		// 执行聚合操作 
		val reduceStream: DataStream[(String, Int)] = streamWindow.reduce((v1, v2) => (v1._1, v1._2 + v2._2)) 
		// 将聚合数据输出 
		reduceStream.print(this.getClass.getSimpleName)
		// 执行程序 
		env.execute("StreamCountWindow")
	}
 }
TimeWindow

TimeWindow 是将指定时间范围内的所有数据组成一个 window,一次对一个 window 里面的 所有数据进行计算。
滚动窗口
Flink 默认的时间窗口根据 Processing Time 进行窗口的划分,将 Flink 获取到的数据根 据进入 Flink 的时间划分到不同的窗口中。
步骤:
1.获取执行环境
2.创建你 socket 链接获取数据
3.进行数据转换处理并按 key 聚合
4.引入 timeWindow
5.执行聚合操作
6.输出打印数据
7.执行程序

package cn.czxy.stream.window import org.apache.flink.api.java.tuple.Tuple 

import org.apache.flink.streaming.api.scala._ 
import org.apache.flink.streaming.api.windowing.time.Time 
import org.apache.flink.streaming.api.windowing.windows.TimeWindow

object StreamTimeWindow { 
	def main(args: Array[String]): Unit = {
	 //1.获取执行环境 
	 val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment 
	 //2.创建你socket链接获取数据 
	 val socketSource: DataStream[String] = env.socketTextStream("node01", 9999) 
	 //3.进行数据转换处理并按 key 聚合 
	 val keyByStream: KeyedStream[(String, Int), Tuple] = socketSource.flatMap(x => x.split(" ")).map((_, 1)).keyBy(0) 
	 //4.引入滚动窗口 
	 val timeWindowStream: WindowedStream[(String, Int), Tuple, TimeWindow] = keyByStream.timeWindow(Time.seconds(5)) 
	 //5.执行聚合操作 
	 val reduceStream: DataStream[(String, Int)] = timeWindowStream.reduce((item1, item2) => (item1._1, item1._2 + item2._2)) 
	 //6.输出打印数据 
	 reduceStream.print() 
	 //7.执行程序 
	 env.execute("StreamTimeWindow") 
	 } 
}
Window Apply

apply 方法可以进行一些自定义处理,通过匿名内部类的方法来实现。当有一些复杂计算时 使用。
用法

  1. 实现一个 WindowFunction 类
  2. 指定该类的泛型为 [输入数据类型, 输出数据类型, keyBy 中使用分组字段的类型, 窗 口类型]
    步骤
  3. 获取流处理运行环境
  4. 构建 socket 流数据源,并指定 IP 地址和端口号
  5. 对接收到的数据转换成单词元组
  6. 使用 keyBy 进行分流(分组)
  7. 使用 timeWinodw 指定窗口的长度(每 3 秒计算一次)
  8. 实现一个 WindowFunction 匿名内部类 a. apply 方法中实现聚合计算 b. 使用 Collector.collect 收集数据
  9. 打印输出
  10. 启动执行
  11. 在 Linux 中,使用 nc -lk 端口号 监听端口,并发送单词
    参考代码
package cn.czxy.stream.window

import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.scala.function.RichWindowFunction
import org.apache.flink.streaming.api.windowing.time.Time 
import org.apache.flink.streaming.api.windowing.windows.TimeWindow 
import org.apache.flink.util.Collector

/**
* 使用 apply 实现单词统计 
* * apply 方法可以进行一些自定义处理,通过匿名内部类的方法来实现。当有一些复杂计 算时使用。 
*/ 
object StreamApplyWindow { 
	def main(args: Array[String]): Unit = { 
	//1. 获取流处理运行环境 
	val env = StreamExecutionEnvironment.getExecutionEnvironment 
	//2. 构建 socket 流数据源,并指定 IP 地址和端口号 
	val textDataStream = env.socketTextStream("node01", 9999).flatMap(_.split(" ")) 
	//3. 对接收到的数据转换成单词元组 
	val wordDataStream = textDataStream.map((_, 1)) 
	//4. 使用 keyBy 进行分流(分组) 
	val groupedDataStream: KeyedStream[(String, Int), String] = wordDataStream.keyBy(_._1) 
	//5. 使用 timeWindow 指定窗口的长度(每 3 秒计算一次) 
	val windowDataStream: WindowedStream[(String, Int), String, TimeWindow] = groupedDataStream.timeWindow(Time.seconds(3))
	//6. 实现一个 WindowFunction 匿名内部类 
	val reduceDatStream: DataStream[(String, Int)] = windowDataStream.apply(new RichWindowFunction[(String, Int), (String, Int), String, TimeWindow] { 
	 	//在 apply 方法中实现数据的聚合 
	 	override def apply(key: String, window: TimeWindow, input: Iterable[(String, Int)], out: Collector[(String, Int)]): Unit = { 
	 	val tuple = input.reduce((t1, t2) => { (t1._1, t1._2 + t2._2) })
	 	//将要返回的数据收集起来,发送回去 
		 out.collect(tuple)
		}})
	//打印输出
	reduceDatStream.print() 
	//启动执行
	env.execute("StreamApplyWindow")
 	} 
}

2.EventTime 与 Window

2.1 EventTime 的引入

在 Flink 的流式处理中,绝大部分的业务都会使用 eventTime,一般只在 eventTime 无法使用时,才会被迫使用 ProcessingTime 或者 IngestionTime。
如果要使用 EventTime,那么需要引入 EventTime 的时间属性,引入方式如下所示:

val env = StreamExecutionEnvironment.getExecutionEnvironment 
// 从调用时刻开始给 env 创建的每一个 stream 追加时间特征 
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

2.2 Watermark

我们知道,流处理从事件产生,到流经 source,再到 operator,中间是有一个过程和 时间的,虽然大部分情况下,流到 operator 的数据都是按照事件产生的时间顺序来的,但 是也不排除由于网络、背压等原因,导致乱序的产生,所谓乱序,就是指 Flink 接收到的 事件的先后顺序不是严格按照事件的 EventTime 顺序排列的。如图所示:
在这里插入图片描述
那么此时出现一个问题,一旦出现乱序,如果只根据 eventTime 决定 window 的运行, 我们不能明确数据是否全部到位,但又不能无限期的等下去,此时必须要有个机制来保证一 个特定的 时间后,必须触发 window 去进行计算了,这个特别的机制,就是 Watermark。 Watermark 是一种衡量 Event Time 进展的机制,它是数据本身的一个隐藏属性,数据本身 携 带着对应的 Watermark。
Watermark 是用于处理乱序事件的,而正确的处理乱序事件,通常用 Watermark 机制 结合 window 来实现。
数据流中的 Watermark 用于表示 timestamp 小于 Watermark 的数据,都已经到达了, 因此, window 的执行也是由 Watermark 触发的。
Watermark 可以理解成一个延迟触发机制,我们可以设置 Watermark 的延时时长 t, 每次系统会校验已经到达的数据中最大的 maxEventTime,然后认定 eventTime 小于 maxEventTime - t 的所有数据都已经到达,如果有窗口的停止时间等于 maxEventTime – t, 那么这个窗口被触发执行。
有序流的 Watermarker 如下图所示:(Watermark 设置为 0)
当 Flink 接收到每一条数据时,都会产生一条 Watermark,这条 Watermark 就等于当 前所有到达数据中的 maxEventTime - 延迟时长,也就是说,Watermark 是由数据携带的,
一旦数据携带的 Watermark 比当前未触发的窗口的停止时间要晚,那么就会触发相应窗口 的执行。由于 Watermark 是由数据携带的,因此,如果运行过程中无法获取新的数据,那 么没有被触发的窗口将永远都不被触发。
上图中,我们设置的允许最大延迟到达时间为 2s,所以时间戳为 7s 的事件对应的 Watermark 是 5s,时间戳为 12s 的事件的 Watermark 是 10s,如果我们的窗口 1 是 1s~5s,窗口 2 是 6s~10s,那么时间戳为 7s 的事件到达时的 Watermarker 恰好触发窗口 1,时间戳为 12s 的事件到达时的 Watermark 恰好触发窗口 2。

Watermark 的引入
val env = StreamExecutionEnvironment.getExecutionEnvironment 
// 从调用时刻开始给 env 创建的每一个 stream 追加时间特征 
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime) val stream = env.readTextFile("eventTest.txt").assignTimestampsAndWatermarks( new BoundedOutOfOrdernessTimestampExtractor[String](Time.milliseconds(200 )) { 
override def extractTimestamp(t: String): Long = { 
		// EventTime 是日志生成时间,我们从日志中解析 
		EventTime t.split(" ")(0).toLong 
	} 
})

3.Flink 的容错

3.1 Checkpoint 介绍

Checkpoint 是 Flink 实现容错机制最核心的功能,也是 flink 的四大基石之一,它 能够根据配置周期性地基于 Stream 中各个 Operator/task 的状态来生成快照,从而将这 些状态数据定期 持久化存储下来,当 Flink 程序一旦意外崩溃时,重新运行程序时可以有 选择地从这些快照进行 恢复,从而修正因为故障带来的程序数据异常。
快照的核心概念之一是 barrier。这些 barrier 被注入数据流并与记录一起作为数据 流的一部分向下流动。 barriers 永远不会超过记录,数据流严格有序,barrier 将数据流 中的记录隔离成一系列的记录集合,并将一些集合中的数据加入到当前的快照中,而另一些 数据加入到下一个快照中。
每个 barrier 都带有快照的 ID,并且 barrier 之前的记录都进入了该快照。barriers 不会中断流处理,非常轻量级。来自不同快照的多个 barrier 可以同时在流中出现,这意 味着多个快照可能并发地发生。
每 个 需 要 checkpoint 的 应 用 在 启 动 时 , Flink 的 JobManager 为 其 创 建 一 个 CheckpointCoordinator(检查点协调器),CheckpointCoordinator 全权负责本应 用的快照制作。
在这里插入图片描述

  1. CheckpointCoordinator 周期性的向该流应用的所有 source 算子发送 barrier。
  2. 当某个 source 算子收到一个 barrier 时,便暂停数据处理过程,然后将自己的当前状 态制作成快照,并保存到指定的持久化存储中,最后向 CheckpointCoordinator 报告 自己 快照制作情况,同时向自身所有下游算子广播该 barrier,恢复数据处理 。
  3. 下游算子收到 barrier 之后,会暂停自己的数据处理过程,然后将自身的相关状态制作 成快照,并保存到指定的持久化存储中,最后向 CheckpointCoordinator 报告自身 快照情 况,同时向自身所有下游算子广播该 barrier,恢复数据处理。
  4. 每个算子按照步骤 3 不断制作快照并向下游广播,直到最后 barrier 传递到 sink 算 子,快照制作完成。
  5. 当 CheckpointCoordinator 收到所有算子的报告之后,认为该周期的快照制作成功; 否 则,如果在规定的时间内没有收到所有算子的报告,则认为本周期快照制作失败。
  6. 如果一个算子有两个输入源,则暂时阻塞先收到 barrier 的输入源,等到第二个输入 源相同编号的 barrier 到来时,再制作自身快照并向下游广播该 barrier。具体如下图所 示两个输入源 checkpoint 过程:
    在这里插入图片描述
    6.1 假设算子 C 有 A 和 B 两个输入源
    6.2 在第 i 个快照周期中,由于某些原因(如处理时延、网络时延等)输入源 A 发出的 barrier 先到来,这时算子 C 暂时将输入源 A 的输入通道阻塞,仅收输入源 B 的数据。
    6.3 当 输 入 源 B 发 出 的 barrier 到 来 时 , 算 子 C 制 作 自 身 快 照 并 向 CheckpointCoordinator 报告自身的快照制作情况,然后将两个 barrier 合并为一个,向 下游所有的算子广播。
    6.4 当由于某些原因出现故障时,CheckpointCoordinator 通知流图上所有算子统一恢复到某 个周期的 checkpoint 状态,然后恢复数据流处理。分布式 checkpoint 机制保证了数据仅 被处理一次(Exactly Once)。

3.2 持久化存储

目前,Checkpoint 持久化存储可以使用如下三种:

3.2.1 MemStateBackend(默认)

该持久化存储主要将快照数据保存到 JobManager 的内存中,仅适合作为测试以及快照 的数据量非常小时使用,并不推荐用作大规模商业部署。
MemoryStateBackend 的局限性(建议不要使用):
默认情况下,每个状态的大小限制为 5 MB。可以在 MemoryStateBackend 的构造函数中增 加此值。无论配置的最大状态大小如何,状态都不能大于 akka 帧的大小(请参阅配置)。 聚合状态必须适合 JobManager 内存。
建议 MemoryStateBackend 用于:
本地开发和调试状态很少的作业,例如仅包含一次记录功能的作业(Map,FlatMap, Filter,…),kafka 的消费者需要很少的状态。
注意:建议不要将快照保存到内存中,因为会导致数据丢失或者 jobmanager 服务器的压力 增加

3.2.2 FsStateBackend(建议使用)

该持久化存储主要将快照数据保存到文件系统中,目前支持的文件系统主要是 HDFS 和 本地文件。如果使用 HDFS,则初始化 FsStateBackend 时,需要传入以 “hdfs://”开头 的路径(即: new FsStateBackend(“hdfs:///hacluster/checkpoint”)), 如果使用本地文 件,则需要传入以 “file://”开头的路径(即:new FsStateBackend(“file:///Data”))。 在分布式情况下,不推荐使用本地文件。如果某 个算子在节点 A 上失败,在节点 B 上恢 复,使用本地文件时,在 B 上无法读取节点 A 上的数据,导致状态恢复失败。 建议 FsStateBackend: 具有大状态,长窗口,大键 / 值状态的作业。 所有高可用性设置。

3.2.3 RocksDBStateBackend

RocksDBStatBackend 介于本地文件和 HDFS 之间,平时使用 RocksDB 的功能,将数 据持久化 到本地文件中,当制作快照时,将本地数据制作成快照,并持久化到 FsStateBackend 中 (FsStateBackend 不必用户特别指明,只需在初始化时传入 HDFS 或本 地 路 径 即 可 , 如 new RocksDBStateBackend(“hdfs:///hacluster/checkpoint”) 或 newRocksDBStateBackend(“file:///Data”))。
如果用户使用自定义窗口(window),不推荐用户使用 RocksDBStateBackend。在自 定 义窗口中,状态以 ListState 的形式保存在 StatBackend 中,如果一个 key 值中有多 个 value 值,则 RocksDB 读取该种 ListState 非常缓慢,影响性能。用户可以根据应用 的具 体情况选择 FsStateBackend+HDFS 或 RocksStateBackend+HDFS。

语法
val env = StreamExecutionEnvironment.getExecutionEnvironment() 
// start a checkpoint every 1000 ms 
env.enableCheckpointing(1000) 
// advanced options: 
// 设置 checkpoint 的执行模式,最多执行一次或者至少执行一次 
env.getCheckpointConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE) 
// 设置 checkpoint 的超时时间 env.getCheckpointConfig.setCheckpointTimeout(60000) 
// 如果在只做快照过程中出现错误,是否让整体任务失败:true 是 false 不是 
env.getCheckpointConfig.setFailTasksOnCheckpointingErrors(false) 
//设置同一时间有多少 个 checkpoint 可以同时执行 
env.getCheckpointConfig.setMaxConcurrentCheckpoints(1)

3.3 Checkpoint 的高级选项

默认 checkpoint 功能是 disabled 的,想要使用的时候需要先启用 checkpoint 开启 之后,默认的 checkPointMode 是 Exactly-once

//配置一秒钟开启一个 checkpoint env.enableCheckpointing(1000) 
//指定 checkpoint 的执行模式 
env.getCheckpointConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE)

CheckpointingMode.EXACTLY_ONCE:默认值 CheckpointingMode.AT_LEAST_ONCE:至少一次 一般情况下选择 CheckpointingMode.EXACTLY_ONCE,除非场景要求极低的延迟(几毫秒) 注意:如果需要保证 EXACTLY_ONCE,source 和 sink 要求必须同时保证 EXACTLY_ONCE

//如果程序被 cancle,保留以前做的 checkpoint 
env.getCheckpointConfig.enableExternalizedCheckpoints(ExternalizedCheckpointCleanu p. RETAIN_ON_CANCELLATION)

默认情况下,检查点不被保留,仅用于在故障中恢复作业,可以启用外部持久化检查点,同 时指定保留策略:
ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION:在作业取消时保留检查点,注 意,在这种情况下,您必须在取消后手动清理检查点状态
ExternalizedCheckpointCleanup.DELETE_ON_CANCELLATION:当作业在被 cancel 时,删除 检查点,检查点仅在作业失败时可用 //设置 checkpoint 超时时间

env.getCheckpointConfig.setCheckpointTimeout(60000)

Checkpointing 的超时时间,超时时间内没有完成则被终止

env.getCheckpointConfig.setMinPauseBetweenCheckpoints(500)

Checkpointing 最小时间间隔,用于指定上一个 checkpoint 完成之后最小等多久可以触发 另一个

//设置同一个时间是否可以有多个 checkpoint 执行 
env.getCheckpointConfig.setMaxConcurrentCheckpoints(1)

指定运行中的 checkpoint 最多可以有多少个

env.getCheckpointConfig.setFailOnCheckpointingErrors(true)

用于指定在 checkpoint 发生异常的时候,是否应该 fail 该 task,默认是 true,如果设 置为 false,则 task 会拒绝 checkpoint 然后继续运行

3.4 Flink 的重启策略

Flink 支持不同的重启策略, 这些重启策略控制着 job 失败后如何重启。 集群 可以通过默认的重启策略来重启, 这个默认的重启策略通常在未指定重启策略的情况 下使用,而如果 Job 提交的时候指定了重启策略, 这个重启策略就会覆盖掉集群的默 认重启策略。
默认的重启策略是通过 Flink 的 flink-conf.yaml 来指定的, 这个配置参数 restart-strategy 定义了哪种策略会被采用。 如果 checkpoint 未启动, 就会采用 no restart 策略, 如果启动了 checkpoint 机制,但是未指定重启策略的话, 就会采用 fixed-delay 策略, 重试 Integer.MAX_VALUE 次。 请参考下面的可用重启策略来了解哪些值是支持的。
每个重启策略都有自己的参数来控制它的行为, 这些值也可以在配置文件中设置, 每个重启策略的描述都包含着各自的配置值信息。
在这里插入图片描述
除了定义一个默认的重启策略之外, 你还可以为每一个 Job 指定它自己的重启策 略, 这个重启策略可以在 ExecutionEnvironment 中调用 setRestartStrategy()方法来程序 化地调用,主意这种方式同样适用于 StreamExecutionEnvironment。
下面的例子展示了我们如何为我们的 Job 设置一个固定延迟重启策略, 一旦有失 败, 系统就会尝试每 10 秒重启一次, 重启 3 次。

val env = ExecutionEnvironment.getExecutionEnvironment() 
env.setRestartStrategy(RestartStrategies.fixedDelayRestart(3, 
// 重启次数 
Time.of(10, TimeUnit.SECONDS) 
// 延迟时间间隔 
))
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值