Flink state状态 与 ProcessFunction API

1.简介

2、Flink 搭建

3、Flink 运行架构

4、程序与数据流(DataFlow)

5、Flink 流处理API

6、Window 窗口机制

7、时间语义与watermark

8、状态管理State

8.1、状态概述

Flink_Flink中的状态

Flink状态管理详解:Keyed State和Operator List State深度解析 <= 不错的文章,建议阅读

  • 流式计算分为无状态和有状态:

    • 无状态计算:对单次请求的处理,不依赖其他请求即可得到输出;例如一些报警和监控。
    • 有状态计算:它会在自身保存一些数据,先后的请求是有关联的基于多个事件输出结果。比如说计算过去一个小时的平均温度等等。
  • 无状态流处理分别接收每条数据记录(图中的黑条),然后根据最新输入的数据生成输出数据(白条)。

  • 有状态流处理会维护状态(根 据每条输入记录进行更新),并基于最新输入的记录和当前的状态值生成输出记录(灰条)。

  • 在这里插入图片描述

  • 任何稍微复杂一点的计算,都涉及到状态。

  • 由一个任务维护,并且用来计算某个结果的所有数据,都属于这个任务的状态(例如,聚合计算或者模式匹配)。有状态的运算符使用输入的事件以及内部保存的状态来计算得到输出。

  • 可以认为状态就是一个本地变量,可以被任务的业务逻辑直接访问

  • 在 Flink 中,状态始终与特定算子相关联算子需要预先注册其状态

  • Flink 会进行状态管理,包括状态一致性、故障处理以及高效存储和访问,以便开发人员可以专注于应用程序的逻辑。

8.2. 类型

在这里插入图片描述

  • 算子状态(Operator State)

    • 算子状态的作用范围限定为算子任务
    • 实现 ListCheckpoint 接口 或 CheckpointFunction接口
  • 键控状态(Keyed State)

    • 根据输入数据流中定义的键(key)来维护和访问
    • 实现Rich Function富函数,通过getRuntimeContext()获取
  • 在这里插入图片描述

  • 每种状态又有两种存在形式,分别为:

    • managed(托管状态)
    • raw(原生状态)
  • Flink推荐使用managed state。

  • 在这里插入图片描述

8.3. 算子状态(Operator State)

  • 算子状态的作用范围限定为算子任务。状态对于同一个任务而言是共享的(每一个并行的(Task)子任务共享一个状态);
  • 算子状态不能由相同或不同算子的另一个任务访问(相同算子的不同任务之间也不能访问);
8.3.2. 算子状态的数据结构
  • 列表状态(List state)将状态表示为一组数据的列表

    • 会根据并行度的调整把之前的状态重新分组重新分配

    • public interface ListCheckpointed<T extends Serializable> { 
          /*** 获取某个算子实例的当前状态,该状态包括该算子实例之前被调用时的所有结果 
          * 以列表的形式返回一个函数状态的快照 
          * Flink触发生成检查点时调用该方法 
          * @param checkpointId checkpoint的ID,是一个唯一的、单调递增的值 
          * @param timestamp Job Manager触发checkpoint时的时间戳 
          * @return 返回一个operator state list,如果为null时,返回空list 
          * @throws Exception 
          */ 
          List<T> snapshotState(long checkpointId, long timestamp) throws Exception;
          /*** 初始化函数状态时调用,可能是在作业启动时或者故障恢复时 
          * 根据提供的列表恢复函数状态 
          * 注意:当实现该方法时,需要在RichFunction#open()方法之前调用该方法 
          * @param state 被恢复算子实例的state列表 ,可能为空 
          * @throws Exception */ 
          void restoreState(List<T> state) throws Exception; }
      
  • 联合列表状态(Union list state),也将状态表示为数据的列表

    • 区别在于, 在发生故障时,或者从保存点(savepoint)启动应用程序时如何恢复(把之前的每一个状态广播到对应的每个算子中)。
  • 广播状态(Broadcast state)

    • 如果一个算子有多项任务,而它的每项任务状态又都相同,那 么这种特殊情况最适合应用广播状态(把同一个状态广播给所有算子子任务)。

8.4. 键控状态(Keyed State)

  • Keyed State只能由作用在KeyedStream上面的函数使用,
  • 根据输入数据流中定义的键(key)来维护和访问的
  • Flink 为每个键值维护一个状态实例一个key只能访问它自己的状态,不同key之间也不能互相访问
8.4.2. 键控状态的数据结构
  • Flink 的 Keyed State 支持以下数据类型:
    1. 值状态 ValueState[T],保存单个的值
      • get 操作: ValueState.value()
      • set 操作: ValueState.update(value: T)
    2. 列表状态 ListState[T],保存一组数据的列表(存多个状态)。基本操作如下:
      • ListState.add(value: T)
      • ListState.addAll(values: java.util.List[T])
      • ListState.get()返回 Iterable[T]
      • ListState.update(values: java.util.List[T])
    3. 映射状态 MapState[K, V],保存 Key-Value 键值对
      • MapState.get(key: K)
      • MapState.put(key: K, value: V)
      • MapState.contains(key: K)
      • MapState.remove(key: K)
    4. 聚合状态 ReducingState[T]保存一个用于聚合操作的列表
      • Reduce输入输出类型是不能变的;
    5. 聚合状态 AggregatingState[I, O],一个用于聚合操作的列表;
      • Aggregate可得到数据类型完全不一样的结果;

8.3. 状态的挑战

  • 状态管理
    • 系统需要高效的管理状态,并保证针对状态的并发更新,不会产生竞争条件(race condition)。
  • 状态分区
    • 并行会带来复杂性。因为计算结果同时取决于已经保存的状态和输入的事件流。
    • 幸运的是,大多数情况下,我们可以使用Key来对状态进行分区,然后独立的管理每一个分区。
    • 例如,当我们处理一组传感器的测量事件流时,我们可以使用分区的运算符状态来针对不同的 传感器独立的保存状态。
  • 状态恢复
    • 状态的运算符如何保证状态可以恢复,即使出现任务失败的情况,计算也是正确的。

8.4. 任务失败

  • 流任务中的运算符状态是很宝贵的,也需要抵御任务失败带来的问题。

  • 流系统在失败的情况下需要保证结果的准确性。

  • 事件处理失败的情景

    1.接收事件,并将事件存储在本地的缓存中;

    2.可能会更新内部状态;

    3.产生输出记录。

    这些步骤都能失败,而系统必须对于在失败的场景下如何处理有清晰的定义。

8.5、状态后端State Backends

  • 流计算中在以下场景中需要保存状态:
    1. 窗口操作
    2. 使用了KV操作的函数
    3. 继承了CheckpointedFunction的函数

当检查点(checkpoint)机制启动时,状态将在检查点中持久化来应对数据丢失以及恢复。

而状态在内 部是如何表示的、状态是如何持久化到检查点中以及持久化到哪里都取决于选定的State Backend。

  • Flink 在保存状态时,支持3种存储方式,如下:
    • MemoryStateBackend 默认
    • FsStateBackend
    • RocksDBStateBackend
8.5.1. MemoryStateBackend

键控状态作为内存中的对象进行管理,将它们存储在 TaskManager 的 JVM 堆上;而将 checkpoint 存储在 JobManager 的内存中

使用异步的方式进行快照,避免阻塞,现在默认就是异步。

  • 异步快照方式时,operator操作符在做快照的同时也会处理新流入的数据,默认异步方式
  • 同步快照方式:operator操作符在做快照的时候,不会处理新流入的数据,同步快照会增加数据处 理的延迟度。
//如果不希望异步,可以在构造的时候传入false,
new MemoryStateBackend(MAX_MEM_STATE_SIZE, false);
  • 策略的限制
    • 单次状态大小最大默认被限制为5MB,这个值可以通过构造函数来更改。
    • 无论单次状态大小最大被限制为多少,都不可用大过akka的frame大小
    • 聚合的状态都会写入JM的内存
  • 适合场景:
    • 本地开发和调试。
    • 状态比较少的作业
8.5.2. FsStateBackend

FsStateBackend 通过文件系统的URL来设置,如下:

  • hdfs://namenode:40010/flink/checkpoints
  • file:///data/flink/checkpoints
  1. FsStateBackend时,会先将数据保存在任务管理器( Task Manager)的内存中。
  2. checkpointing的时候,会将状态快照写入文件,保存在文件系统。
  3. 少量的元数据会保存在JM的内存 中。

默认情况下,FsStateBackend配置为提供异步快照,避免阻塞处理管道。

//通过将构造函数中相应的boolean标志设置为false来禁用该功能
new FsStateBackend(path, false);

适用场景: 状态比较大,窗口比较长,大的KV状态 需要做HA的场景

8.5.3. RocksDBStateBackend

将所有状态序列化后,存入本地的 RocksDB数据库 中存储。 这个数据库保存在TaskManager的数据目录 中。

  • 注意:RocksDB 的支持并不直接包含在 flink 中,需要引入依赖;
  • RocksDB,它是一个高性能的Key-Value数据库。数据会放到先内存当中,在一定条件下触发写到磁盘文件上。

在 checkpoint时,整个RocksDB数据库的数据会快照一份,然后存到配置的文件系统中(一般是 hdfs)。同时,Flink将一些最小的元数据存储在JobManager的内存或Zookeeper中(对于高 可用性情况)。

RocksDB默认配置为执行异步快照

适合场景: 非常大的状态,长窗口,大的KV状态 需要HA的场景 RocksDBStateBackend是目前唯一可用于支持有状态流处理应用程序的增量检查点。

注意:增量的checkpoint指的是在保存快照时,快照里的数据只要保存差异数据就好

RocksDBStateBackend方式能够持有的状态的多少只取决于可使用的磁盘大小;这个策略的吞吐量会受限。

9、ProcessFunction API(底层API)

我们之前学习的转换算子是无法访问事件的时间戳信息和水位线信息的。而这在一些应用场景下,极为重要。例如MapFunction这样的map转换算子就无法访问时间戳或者当前事件的事件时间。

​ 基于此,DataStream API提供了一系列的Low-Level转换算子。可以访问时间戳watermark以及注册定时事件。还可以输出特定的一些事件,例如超时事件等。Process Function用来构建事件驱动的应用以及实现自定义的业务逻辑(使用之前的window函数和转换算子无法实现)。例如,FlinkSQL就是使用Process Function实现的

Flink提供了8个Process Function:

  • ProcessFunction
  • KeyedProcessFunction
  • CoProcessFunction
  • ProcessJoinFunction
  • BroadcastProcessFunction
  • KeyedBroadcastProcessFunction
  • ProcessWindowFunction
  • ProcessAllWindowFunction

9.1 KeyedProcessFunction

​ 这个是相对比较常用的ProcessFunction,根据名字就可以知道是用在keyedStream上的

KeyedProcessFunction用来操作KeyedStream。KeyedProcessFunction会处理流的每一个元素,输出为0个、1个或者多个元素。所有的Process Function都继承自RichFunction接口,所以都有open()close()getRuntimeContext()等方法。而KeyedProcessFunction<K, I, O>还额外提供了两个方法:

  • processElement(I value, Context ctx, Collector<O> out),流中的每一个元素都会调用这个方法,调用结果将会放在Collector数据类型中输出。Context可以访问元素的时间戳,元素的 key ,以及TimerService 时间服务。 Context 还可以将结果输出到别的流(side outputs)。
  • onTimer(long timestamp, OnTimerContext ctx, Collector<O> out),是一个回调函数。当之前注册的定时器触发时调用。参数timestamp 为定时器所设定的触发的时间戳。Collector 为输出结果的集合。OnTimerContext和processElement的Context 参数一样,提供了上下文的一些信息,例如定时器触发的时间信息(事件时间或者处理时间)。
测试代码

设置一个获取数据后第5s给出提示信息的定时器。

package processfunction;

import apitest.beans.SensorReading;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

/**
 * @author : Ashiamd email: ashiamd@foxmail.com
 * @date : 2021/2/3 12:30 AM
 */
public class ProcessTest1_KeyedProcessFunction {
  public static void main(String[] args) throws Exception{
    StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
    env.setParallelism(1);

    // socket文本流
    DataStream<String> inputStream = env.socketTextStream("localhost", 7777);

    // 转换成SensorReading类型
    DataStream<SensorReading> dataStream = inputStream.map(line -> {
      String[] fields = line.split(",");
      return new SensorReading(fields[0], new Long(fields[1]), new Double(fields[2]));
    });

    // 测试KeyedProcessFunction,先分组然后自定义处理
    dataStream.keyBy("id")
      .process( new MyProcess() )
      .print();

    env.execute();
  }

  // 实现自定义的处理函数
  public static class MyProcess extends KeyedProcessFunction<Tuple, SensorReading, Integer> {
    ValueState<Long> tsTimerState;

    @Override
    public void open(Configuration parameters) throws Exception {
      tsTimerState =  getRuntimeContext().getState(new ValueStateDescriptor<Long>("ts-timer", Long.class));
    }

    @Override
    public void processElement(SensorReading value, Context ctx, Collector<Integer> out) throws Exception {
      out.collect(value.getId().length());

      // context
      // Timestamp of the element currently being processed or timestamp of a firing timer.
      ctx.timestamp();
      // Get key of the element being processed.
      ctx.getCurrentKey();
      //            ctx.output();
      ctx.timerService().currentProcessingTime();
      ctx.timerService().currentWatermark();
      // 在5处理时间的5秒延迟后触发
      ctx.timerService().registerProcessingTimeTimer( ctx.timerService().currentProcessingTime() + 5000L);
      tsTimerState.update(ctx.timerService().currentProcessingTime() + 1000L);
      //            ctx.timerService().registerEventTimeTimer((value.getTimestamp() + 10) * 1000L);
      // 删除指定时间触发的定时器
      //            ctx.timerService().deleteProcessingTimeTimer(tsTimerState.value());
    }

    @Override
    public void onTimer(long timestamp, OnTimerContext ctx, Collector<Integer> out) throws Exception {
      System.out.println(timestamp + " 定时器触发");
      ctx.getCurrentKey();
      //            ctx.output();
      ctx.timeDomain();
    }

    @Override
    public void close() throws Exception {
      tsTimerState.clear();
    }
  }
}

启动本地socket

nc -lk 7777

输入

sensor_1,1547718207,36.3

输出

8
1612283803911 定时器触发

9.2 TimerService和定时器(Timers)

​ Context 和OnTimerContext 所持有的TimerService 对象拥有以下方法:

  • long currentProcessingTime() 返回当前处理时间

  • long currentWatermark() 返回当前watermark 的时间戳

  • void registerProcessingTimeTimer( long timestamp) 会注册当前key的processing time的定时器。当processing time 到达定时时间时,触发timer。

  • void registerEventTimeTimer(long timestamp) 会注册当前key 的event time 定时器。当Watermark水位线大于等于定时器注册的时间时,触发定时器执行回调函数。

  • void deleteProcessingTimeTimer(long timestamp) 删除之前注册处理时间定时器。如果没有这个时间戳的定时器,则不执行。

  • void deleteEventTimeTimer(long timestamp) 删除之前注册的事件时间定时器,如果没有此时间戳的定时器,则不执行。

当定时器timer 触发时,会执行回调函数onTimer()。注意定时器timer 只能在keyed streams 上面使用。

测试代码

下面举个例子说明KeyedProcessFunction 如何操作KeyedStream。

需求:监控温度传感器的温度值,如果温度值在10 秒钟之内(processing time)连续上升,则报警。

  • java代码

    package processfunction;
    
    import apitest.beans.SensorReading;
    import org.apache.flink.api.common.state.ValueState;
    import org.apache.flink.api.common.state.ValueStateDescriptor;
    import org.apache.flink.api.common.time.Time;
    import org.apache.flink.configuration.Configuration;
    import org.apache.flink.streaming.api.datastream.DataStream;
    import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
    import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
    import org.apache.flink.util.Collector;
    
    /**
     * @author : Ashiamd email: ashiamd@foxmail.com
     * @date : 2021/2/3 1:02 AM
     */
    public class ProcessTest2_ApplicationCase {
    
      public static void main(String[] args) throws Exception {
        // 创建执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        // 设置并行度为1
        env.setParallelism(1);
        // 从socket中获取数据
        DataStream<String> inputStream = env.socketTextStream("localhost", 7777);
        // 转换数据为SensorReading类型
        DataStream<SensorReading> sensorReadingStream = inputStream.map(line -> {
          String[] fields = line.split(",");
          return new SensorReading(fields[0], new Long(fields[1]), new Double(fields[2]));
        });
        // 如果存在连续10s内温度持续上升的情况,则报警
        sensorReadingStream.keyBy(SensorReading::getId)
          .process(new TempConsIncreWarning(Time.seconds(10).toMilliseconds()))
          .print();
        env.execute();
      }
    
      // 如果存在连续10s内温度持续上升的情况,则报警
      public static class TempConsIncreWarning extends KeyedProcessFunction<String, SensorReading, String> {
    
        public TempConsIncreWarning(Long interval) {
          this.interval = interval;
        }
    
        // 报警的时间间隔(如果在interval时间内温度持续上升,则报警)
        private Long interval;
    
        // 上一个温度值
        private ValueState<Double> lastTemperature;
        // 最近一次定时器的触发时间(报警时间)
        private ValueState<Long> recentTimerTimeStamp;
    
        @Override
        public void open(Configuration parameters) throws Exception {
          lastTemperature = getRuntimeContext().getState(new ValueStateDescriptor<Double>("lastTemperature", Double.class));
          recentTimerTimeStamp = getRuntimeContext().getState(new ValueStateDescriptor<Long>("recentTimerTimeStamp", Long.class));
        }
    
        @Override
        public void close() throws Exception {
          lastTemperature.clear();
          recentTimerTimeStamp.clear();
        }
    
        @Override
        public void processElement(SensorReading value, Context ctx, Collector<String> out) throws Exception {
          // 当前温度值
          double curTemp = value.getTemperature();
          // 上一次温度(没有则设置为当前温度)
          double lastTemp = lastTemperature.value() != null ? lastTemperature.value() : curTemp;
          // 计时器状态值(时间戳)
          Long timerTimestamp = recentTimerTimeStamp.value();
    
          // 如果 当前温度 > 上次温度 并且 没有设置报警计时器,则设置
          if (curTemp > lastTemp && null == timerTimestamp) {
            long warningTimestamp = ctx.timerService().currentProcessingTime() + interval;
            ctx.timerService().registerProcessingTimeTimer(warningTimestamp);
            recentTimerTimeStamp.update(warningTimestamp);
          }
          // 如果 当前温度 < 上次温度,且 设置了报警计时器,则清空计时器
          else if (curTemp <= lastTemp && timerTimestamp != null) {
            ctx.timerService().deleteProcessingTimeTimer(timerTimestamp);
            recentTimerTimeStamp.clear();
          }
          // 更新保存的温度值
          lastTemperature.update(curTemp);
        }
    
        // 定时器任务
        @Override
        public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
          // 触发报警,并且清除 定时器状态值
          out.collect("传感器" + ctx.getCurrentKey() + "温度值连续" + interval + "ms上升");
          recentTimerTimeStamp.clear();
        }
      }
    }
    
  • 启动本地socket,之后输入数据

    nc -lk 7777
    
    • 输入

      sensor_1,1547718199,35.8
      sensor_1,1547718199,34.1
      sensor_1,1547718199,34.2
      sensor_1,1547718199,35.1
      sensor_6,1547718201,15.4
      sensor_7,1547718202,6.7
      sensor_10,1547718205,38.1
      sensor_10,1547718205,39  
      sensor_6,1547718201,18  
      sensor_7,1547718202,9.1
      
    • 输出

      传感器sensor_1温度值连续10000ms上升
      传感器sensor_10温度值连续10000ms上升
      传感器sensor_6温度值连续10000ms上升
      传感器sensor_7温度值连续10000ms上升
      

9.3 侧输出流(SideOutput)

  • 一个数据可以被多个window包含,只有其不被任何window包含的时候(包含该数据的所有window都关闭之后),才会被丢到侧输出流。
  • 简言之,如果一个数据被丢到侧输出流,那么所有包含该数据的window都由于已经超过了"允许的迟到时间"而关闭了,进而新来的迟到数据只能被丢到侧输出流!

  • 大部分的DataStream API 的算子的输出是单一输出,也就是某种数据类型的流。除了split 算子,可以将一条流分成多条流,这些流的数据类型也都相同。

  • processfunction 的side outputs 功能可以产生多条流,并且这些流的数据类型可以不一样。

  • 一个side output 可以定义为OutputTag[X]对象,X 是输出流的数据类型。

  • processfunction 可以通过Context 对象发射一个事件到一个或者多个side outputs。

测试代码

场景:温度>=30放入高温流输出,反之放入低温流输出

  • java代码

    package processfunction;
    
    import apitest.beans.SensorReading;
    import org.apache.flink.streaming.api.datastream.DataStream;
    import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
    import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
    import org.apache.flink.streaming.api.functions.ProcessFunction;
    import org.apache.flink.util.Collector;
    import org.apache.flink.util.OutputTag;
    
    /**
     * @author : Ashiamd email: ashiamd@foxmail.com
     * @date : 2021/2/3 2:07 AM
     */
    public class ProcessTest3_SideOuptCase {
      public static void main(String[] args) throws Exception {
        // 创建执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        // 设置并行度 = 1
        env.setParallelism(1);
        // 从本地socket读取数据
        DataStream<String> inputStream = env.socketTextStream("localhost", 7777);
        // 转换成SensorReading类型
        DataStream<SensorReading> dataStream = inputStream.map(line -> {
          String[] fields = line.split(",");
          return new SensorReading(fields[0], new Long(fields[1]), new Double(fields[2]));
        });
    
        // 定义一个OutputTag,用来表示侧输出流低温流
        // An OutputTag must always be an anonymous inner class
        // so that Flink can derive a TypeInformation for the generic type parameter.
        OutputTag<SensorReading> lowTempTag = new OutputTag<SensorReading>("lowTemp"){};
    
        // 测试ProcessFunction,自定义侧输出流实现分流操作
        SingleOutputStreamOperator<SensorReading> highTempStream = dataStream.process(new ProcessFunction<SensorReading, SensorReading>() {
          @Override
          public void processElement(SensorReading value, Context ctx, Collector<SensorReading> out) throws Exception {
            // 判断温度,大于30度,高温流输出到主流;小于低温流输出到侧输出流
            if (value.getTemperature() > 30) {
              out.collect(value);
            } else {
              ctx.output(lowTempTag, value);
            }
          }
        });
    
        highTempStream.print("high-temp");
        highTempStream.getSideOutput(lowTempTag).print("low-temp");
    
        env.execute();
      }
    }
    
  • 本地启动socket

    • 输入

      sensor_1,1547718199,35.8
      sensor_6,1547718201,15.4
      sensor_7,1547718202,6.7
      sensor_10,1547718205,38.1
      
    • 输出

      high-temp> SensorReading{id='sensor_1', timestamp=1547718199, temperature=35.8}
      low-temp> SensorReading{id='sensor_6', timestamp=1547718201, temperature=15.4}
      low-temp> SensorReading{id='sensor_7', timestamp=1547718202, temperature=6.7}
      high-temp> SensorReading{id='sensor_10', timestamp=1547718205, temperature=38.1}
      

9.4 CoProcessFunction

  • 对于两条输入流,DataStream API 提供了CoProcessFunction 这样的low-level操作。CoProcessFunction 提供了操作每一个输入流的方法: processElement1()processElement2()
  • 类似于ProcessFunction,这两种方法都通过Context 对象来调用这个Context对象可以访问事件数据,定时器时间戳,TimerService,以及side outputs
  • CoProcessFunction 也提供了onTimer()回调函数

10、容错机制CheckPoint

13、Flink 反压机制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值