Flink学习笔记(四):Flink 四大基石之 Window 和 Time

1、 概述

Flink 基石

  • 窗口 Window

    • 流数据计算中一般对数据尽心操作之前都会先进行开窗,即基于一个什么样的窗口上做这个计算
    • Flink 提供了开箱即用的各种窗口,比如滑动窗口、滚动窗口、会话窗口以及非常灵活的自定义窗口
  • 时间 Time

    • Flink 中窗口计算,基本都是基于时间窗口设置
    • Flink 实现了 Watermark 的机制,能够支持基于事件时间的处理,能够容忍迟到、乱序的数据
  • 状态 State

    • Flink计算引擎,自身就是基于状态计算框架,默认情况下程序自己管理状态
    • 提供一致性的语义,使得用户在编程时能够更轻松、更容易地去管理状态
    • 提供一套非常简单明了的 State API,包括ValueState、ListState、MapState,BroadcastState
  • 检查点 Checkpoint

    • Flink Checkpoint 检查点:保存状态数据
    • 基于 Chandy-Lamport 算法实现了一个分布式的一致性的快照,从而提供了一致性的语义
    • 进行 Checkpoint 后,可以设置自动进行故障恢复
    • 保存点 Savepoint,人工进行 Checkpoint 操作,进行程序恢复执行

2、 Flink 的 Window 和 Time

2.1、Window API

在 Flink 流计算中,提供 Window 窗口 API 分为 2 种:

  • 针对 KeyedStream 窗口 API
    Window 先对数据流 DataStream 进行分组 keyBy ,再设置窗口 Window,最后进行聚合 apply 操作。
    • 第一步、数据流 DataStream 调用 keyBy 函数分组,获取 KeyedStream
    • 第二步、KeyedStream.window 设置窗口
    • 第三步、聚合操作,对窗口中数据进行聚合统计,函数:reduce、aggregate、apply() 等。
stream.keyBy(...)          <-  keyed versus non-keyed windows
       .window(...)         <-  required: "assigner"
      [.trigger(...)]       <-  optional: "trigger" (else default trigger)
      [.evictor(...)]       <-  optional: "evictor" (else no evictor)
      [.allowedLateness()]  <-  optional, else zero
       .reduce/fold/apply() <-  required: "function"
  • 针对 KeyedStream 窗口 API
    • 直接调用窗口函数:windowAll,然后再对窗口所有数据进行处理,未进行分组;
    • 聚合操作,对窗口中数据进行聚合统计,函数:reduce、aggregate、apply() 等。
stream.windowAll(...)      <-  required: "assigner"
      [.trigger(...)]       <-  optional: "trigger" (else default trigger)
      [.evictor(...)]       <-  optional: "evictor" (else no evictor)
      [.allowedLateness()]  <-  optional, else zero
       .reduce/fold/apply() <-  required: "function"

方括号 [ ] 内的命令是可选的,这表明 Flink 允许根据需求自定义 window 逻辑。使用 keyBy 的流,应该使用 window 方法,未使用 keyBy 的流,应该调用 windowAll 方法

2.1.1、WindowAssigner

window/windowAll 方法接收的输入是一个 WindowAssigner, WindowAssigner 负责将每条输入的数据分发到正确的 window 中。如果需要自己定制数据分发策略,则可以实现一个 class,继承自 WindowAssigner。

2.1.2、Trigger

trigger 用来判断一个窗口是否需要被触发,每个 WindowAssigner 都自带一个默认的 trigger,如果默认的 trigger 不能满足你的需求,则可以自定义一个类,继承自Trigger 即可。

  • onElement()
  • onEventTime()
  • onProcessingTime()

此抽象类的这三个方法会返回一个 TriggerResult, TriggerResult 有如下几种可能的选择:

  • CONTINUE 不做任何事情
  • FIRE 触发 window
  • PURGE 清空整个 window 的元素并销毁窗口
  • FIRE_AND_PURGE 触发窗口,然后销毁窗口

2.1.3、Evictor

evictor 主要用于做一些数据的自定义操作,可以在执行用户代码之前,也可以在执行用户代码之后。本接口提供了两个重要的方法,即 evicBeforeevicAfter两个方法。

Flink 提供了如下三种通用的 evictor:

  • CountEvictor 保留指定数量的元素
  • TimeEvictor 设定一个阈值 interval,删除所有不再 max_ts - interval 范围内的元素,其中 max_ts 是窗口内时间戳的最大值
  • DeltaEvictor 通过执行用户给定的 DeltaFunction 以及预设的 theshold,判断是否删
    除一个元素。

2.2、窗口类型

Flink Window 窗口的结构中,有两个必须的两个操作:

  • 第一、窗口分配器(WindowAssigner):将数据流中的元素分配到对应的窗口。
  • 第二、窗口函数(Window Function):当满足窗口触发条件后,对窗口内的数据使用窗口处理函数(Window Function)进行处理,常用的有 reduce、aggregate、process。

在 Flink 窗口计算中,无论时间窗口还是计数窗口,都可以分为 2 种类型:滚动 Tumbling滑动 Sliding 窗口

  • 滚动窗口(Tumbling Window)
    条件:窗口大小 size = 滑动间隔 slide

  • 滚动窗口(Tumbling Window)
    条件:窗口大小 != 滑动间隔,通常条件【窗口大小 size > 滑动间隔 slide

Window 的生命周期是什么?
简单的说,当有第一个属于该 window 元素到达时就创建了一个 window,当时间或事件触发该 windowremoved 的时候则结束。每个 window 都有一个 Trigger 和 一个 Function,function用于计算,tigger 用于触发 window 条件。同时也可以使用 Evictor 在 Trigger 触发前后对 window 的元素进行处理。

2.2.1、Tumbling Windows

滚动窗口分配器(Tumbling windows assigner)将每个元素分配给指定窗口大小的窗口。滚动窗口具有固定大小,不会重叠。例如,如果指定大小为 5 分钟的滚动窗口,则将评估当前窗口,并且每 5 分钟启动一个新窗口,如下图所示:
滑动窗口
示例代码:

动态数据源:

package com.leo.source;

import org.apache.flink.streaming.api.functions.source.SourceFunction;
import java.util.Random;

/**
 * 模拟动态数据源
 */
public class SimulateDynamicDataSource implements SourceFunction<String> {

    private volatile boolean isRunning = true;

    @Override
    public void run(SourceContext<String> ctx) throws Exception {
        Random random = new Random();
        while (isRunning) {
            String key = "key" + random.nextInt(3);
            int value = random.nextInt(10);
            String simData = key.concat(",").concat(String.valueOf(value));
            ctx.collect(simData);
            // 每秒产生一条新数据
            Thread.sleep(1000);
        }
    }

    @Override
    public void cancel() {
        isRunning = false;
    }
}

滚动窗口示例代码:

package com.leo.window;

import com.leo.env.EnvironmentUtil;
import com.leo.source.SimulateDynamicDataSource;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;

/**
 * @Classname TumblingWindowsDemo
 * @Description
 *  滚动窗口分配器(Tumbling windows assigner)将每个元素分配给指定窗口大小的窗口。滚动窗口具有固定大小,不会重叠。
 *  例如,如果指定大小为 5 分钟的滚动窗口,则将评估当前窗口,并且每 5 分钟启动一个新窗口。
 * @Date 2023/11/6 14:43
 * @Created by Leo825
 */
public class TumblingWindowsDemo {
    public static void main(String[] args) throws Exception {

        // 获取执行环境
        StreamExecutionEnvironment env = EnvironmentUtil.getLocalEnv();

        // 添加数据源
        DataStreamSource<String> inputStream = env.addSource(new SimulateDynamicDataSource());

        // 1、对数据进行转换处理: 过滤脏数据,解析封装到二元组中
        SingleOutputStreamOperator<Tuple2<String, Integer>> mapStream = inputStream
                // a、过滤符合条件的数据
                .filter(line -> line.trim().split(",").length == 2)
                // b、将数据解析封装
                .map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String line) throws Exception {
                        System.out.println("item: " + line);
                        String[] array = line.trim().split(",");
                        Tuple2<String, Integer> tuple = Tuple2.of(array[0], Integer.parseInt(array[1]));
                        // 返回
                        return tuple;
                    }
                });

        // 2.窗口计算,每隔 5 秒钟计算最近 5 秒各个卡口流量
        SingleOutputStreamOperator<String> windowStream = mapStream
                // a、设置分组 key,按照卡口分组
                .keyBy(tuple -> tuple.f0)
                // b、设置窗口,并且为滚动窗口:size = slide
                .window(
                        TumblingProcessingTimeWindows.of(Time.seconds(5))
                )
                // c、窗口计算,窗口函数
                .apply(new WindowFunction<Tuple2<String, Integer>, String, String, TimeWindow>() {
                    // 定义变量,对当前时间数据进行转换
                    private FastDateFormat fastDateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");

                    @Override
                    public void apply(String key,
                                      TimeWindow window,
                                      Iterable<Tuple2<String, Integer>> input,
                                      Collector<String> out) {

                        // 获取窗口时间信息:开始和结束时间
                        String winStart = this.fastDateFormat.format(window.getStart());
                        String winEnd = this.fastDateFormat.format(window.getEnd());

                        // 对窗口中数据进行统计:求和
                        int sum = 0;
                        for (Tuple2<String, Integer> tuple2 : input) {
                            sum += tuple2.f1;
                            // 打印 窗口里面的数据对象
                            System.out.println("window: [" + winStart + " ~ " + winEnd + "], [key=" + key + ", value="+tuple2.f1+"]");
                        }
                        // 输出结果数据
                        String output = "window: [" + winStart + " ~ " + winEnd + "], " + key + " = " + sum;
                        out.collect(output);
                    }
                });

        //3、打印输出
        windowStream.print().setParallelism(1);

        //4、启动执行
        env.execute("TumblingWindowsDemo");
    }
}

结果日志如下:

item: key2,4
window: [2023-11-06 17:18:10 ~ 2023-11-06 17:18:15], [key=key2, value=4]
window: [2023-11-06 17:18:10 ~ 2023-11-06 17:18:15], key2 = 4
item: key1,9
item: key1,6
item: key0,1
item: key2,6
item: key2,6
window: [2023-11-06 17:18:15 ~ 2023-11-06 17:18:20], [key=key2, value=6]
window: [2023-11-06 17:18:15 ~ 2023-11-06 17:18:20], [key=key2, value=6]
window: [2023-11-06 17:18:15 ~ 2023-11-06 17:18:20], [key=key0, value=1]
window: [2023-11-06 17:18:15 ~ 2023-11-06 17:18:20], [key=key1, value=9]
window: [2023-11-06 17:18:15 ~ 2023-11-06 17:18:20], [key=key1, value=6]
window: [2023-11-06 17:18:15 ~ 2023-11-06 17:18:20], key1 = 15
window: [2023-11-06 17:18:15 ~ 2023-11-06 17:18:20], key0 = 1
window: [2023-11-06 17:18:15 ~ 2023-11-06 17:18:20], key2 = 12
item: key0,1
item: key2,8
item: key1,3
item: key2,8
item: key0,2
window: [2023-11-06 17:18:20 ~ 2023-11-06 17:18:25], [key=key1, value=3]
window: [2023-11-06 17:18:20 ~ 2023-11-06 17:18:25], [key=key2, value=8]
window: [2023-11-06 17:18:20 ~ 2023-11-06 17:18:25], [key=key0, value=1]
window: [2023-11-06 17:18:20 ~ 2023-11-06 17:18:25], [key=key0, value=2]
window: [2023-11-06 17:18:20 ~ 2023-11-06 17:18:25], [key=key2, value=8]
window: [2023-11-06 17:18:20 ~ 2023-11-06 17:18:25], key2 = 16
window: [2023-11-06 17:18:20 ~ 2023-11-06 17:18:25], key1 = 3
window: [2023-11-06 17:18:20 ~ 2023-11-06 17:18:25], key0 = 3

2.2.2、Sliding Windows

滑动窗口分配器(sliding windows assigner)将元素分配给固定长度的窗口。与滚动窗口分配器类似,窗口的大小由窗口大小参数配置。窗口滑动参数控制滑动窗口的启动频率。因此,如果 sliding小于size,则滑动窗口可能会重叠。在这种情况下,元素被分配给多个窗口。例如,可以有大小为 10 分钟的窗口,该窗口滑动 5 分钟。这样,您每 5 分钟就会得到一个窗口,其中包含过去 10 分钟内到达的事件,如下图所示:
滑动窗口
示例代码:

package com.leo.window;

import com.leo.env.EnvironmentUtil;
import com.leo.source.SimulateDynamicDataSource;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.SlidingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.assigners.TumblingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;

/**
 * @Classname SlidingWindowsDemo
 * @Description
 *  滑动窗口分配器(sliding windows assigner)将元素分配给固定长度的窗口。
 *  与滚动窗口分配器类似,窗口的大小由窗口大小参数配置。窗口滑动参数控制滑动窗口的启动频率。
 *  因此,如果 sliding小于size,则滑动窗口可能会重叠。在这种情况下,元素被分配给多个窗口。
 *  例如,可以有大小为 10 分钟的窗口,该窗口滑动 5 分钟。这样,您每 5 分钟就会得到一个窗口,其中包含过去 10 分钟内到达的事件
 * @Date 2023/11/6 14:43
 * @Created by Leo825
 */
public class SlidingWindowsDemo {
    public static void main(String[] args) throws Exception {

        // 获取执行环境
        StreamExecutionEnvironment env = EnvironmentUtil.getLocalEnv();

        // 添加数据源
        DataStreamSource<String> inputStream = env.addSource(new SimulateDynamicDataSource());

        // 1、对数据进行转换处理: 过滤脏数据,解析封装到二元组中
        SingleOutputStreamOperator<Tuple2<String, Integer>> mapStream = inputStream
                // a、过滤符合条件的数据
                .filter(line -> line.trim().split(",").length == 2)
                // b、将数据解析封装
                .map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String line) throws Exception {
                        System.out.println("item: " + line);
                        String[] array = line.trim().split(",");
                        Tuple2<String, Integer> tuple = Tuple2.of(array[0], Integer.parseInt(array[1]));
                        // 返回
                        return tuple;
                    }
                });

        // 2.窗口计算,每隔 5 秒钟计算最近 10 秒各个卡口流量
        SingleOutputStreamOperator<String> windowStream = mapStream
                // a、设置分组 key,按照卡口分组
                .keyBy(tuple -> tuple.f0)
                // b、设置窗口,并且为滚动窗口:size != slide, 窗口大小 10 秒,滑动窗口大小 5 秒
                .window(
                        SlidingProcessingTimeWindows.of(Time.seconds(10),Time.seconds(5))
                )
                // c、窗口计算,窗口函数
                .apply(new WindowFunction<Tuple2<String, Integer>, String, String, TimeWindow>() {
                    // 定义变量,对当前时间数据进行转换
                    private FastDateFormat fastDateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");

                    @Override
                    public void apply(String key,
                                      TimeWindow window,
                                      Iterable<Tuple2<String, Integer>> input,
                                      Collector<String> out) {

                        // 获取窗口时间信息:开始和结束时间
                        String winStart = this.fastDateFormat.format(window.getStart());
                        String winEnd = this.fastDateFormat.format(window.getEnd());

                        // 对窗口中数据进行统计:求和
                        int sum = 0;
                        for (Tuple2<String, Integer> tuple2 : input) {
                            sum += tuple2.f1;
                            // 打印 窗口里面的数据对象
                            System.out.println("window: [" + winStart + " ~ " + winEnd + "], [key=" + key + ", value="+tuple2.f1+"]");
                        }
                        // 输出结果数据
                        String output = "window: [" + winStart + " ~ " + winEnd + "], " + key + " = " + sum;
                        out.collect(output);
                    }
                });

        //3、打印输出
        windowStream.print().setParallelism(1);

        //4、启动执行
        env.execute("SlidingWindowsDemo");
    }
}

结果日志如下:

item: key0,5
item: key1,8
window: [2023-11-06 17:24:10 ~ 2023-11-06 17:24:20], [key=key0, value=5]
window: [2023-11-06 17:24:10 ~ 2023-11-06 17:24:20], [key=key1, value=8]
window: [2023-11-06 17:24:10 ~ 2023-11-06 17:24:20], key1 = 8
window: [2023-11-06 17:24:10 ~ 2023-11-06 17:24:20], key0 = 5
item: key2,8
item: key0,3
item: key0,4
item: key1,7
item: key2,0
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], [key=key1, value=8]
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], [key=key1, value=7]
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], [key=key0, value=5]
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], [key=key0, value=3]
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], [key=key0, value=4]
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], [key=key2, value=8]
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], [key=key2, value=0]
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], key0 = 12
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], key1 = 15
window: [2023-11-06 17:24:15 ~ 2023-11-06 17:24:25], key2 = 8
item: key2,4
item: key1,3
item: key0,9
item: key2,2
item: key1,1
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key0, value=3]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key0, value=4]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key2, value=8]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key2, value=0]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key2, value=4]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key2, value=2]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key1, value=7]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key0, value=9]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key1, value=3]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], [key=key1, value=1]
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], key0 = 16
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], key1 = 11
window: [2023-11-06 17:24:20 ~ 2023-11-06 17:24:30], key2 = 14
item: key2,6
item: key0,8
item: key2,6
item: key1,8
item: key2,8
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key1, value=3]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key1, value=1]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key1, value=8]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key0, value=9]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key2, value=4]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key2, value=2]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key0, value=8]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key2, value=6]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key2, value=6]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], [key=key2, value=8]
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], key1 = 12
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], key2 = 26
window: [2023-11-06 17:24:25 ~ 2023-11-06 17:24:35], key0 = 17
item: key2,4
item: key0,4
item: key0,1
Disconnected from the target VM, address: '127.0.0.1:59033', transport: 'socket'
item: key0,3

2.2.3、Session Windows

会话窗口分配器(session windows assigner)按活动会话对元素进行分组。与滚动窗口和滑动窗口相比,会话窗口不重叠,也没有固定的开始和结束时间。相反,当会话窗口在一段时间内未收到元素时(即,当出现不活动间隙时),会话窗口将关闭。会话窗口分配器可以配置静态会话间隙或会话间隙提取器功能,该函数定义不活动时间的时间。当此时间段到期时,当前会话将关闭,后续元素将分配给新的会话窗口。
Session Windows
图1

示例代码:

package com.leo.window;

import com.leo.env.EnvironmentUtil;
import com.leo.source.SimulateDynamicDataSource;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.AllWindowFunction;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.ProcessingTimeSessionWindows;
import org.apache.flink.streaming.api.windowing.assigners.SlidingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;

/**
 * @Classname SessionWindowsDemo
 * @Description
 *  会话窗口分配器(session windows assigner)按活动会话对元素进行分组。
 *  与滚动窗口和滑动窗口相比,会话窗口不重叠,也没有固定的开始和结束时间。
 *  相反,当会话窗口在一段时间内未收到元素时(即,当出现不活动间隙时),会话窗口将关闭。
 *  会话窗口分配器可以配置静态会话间隙或会话间隙提取器功能,该函数定义不活动时间的时间。
 *  当此时间段到期时,当前会话将关闭,后续元素将分配给新的会话窗口
 * @Date 2023/11/6 14:43
 * @Created by Leo825
 */
public class SessionWindowsDemo {
    public static void main(String[] args) throws Exception {

        // 获取执行环境
        StreamExecutionEnvironment env = EnvironmentUtil.getLocalEnv();

        // 添加数据源
        DataStreamSource<String> inputStream = env.addSource(new SimulateDynamicDataSource());

        // 1、对数据进行转换处理: 过滤脏数据,解析封装到二元组中
        SingleOutputStreamOperator<Tuple2<String, Integer>> mapStream = inputStream
                // a、过滤符合条件的数据
                .filter(line -> line.trim().split(",").length == 2)
                // b、将数据解析封装
                .map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String line) throws Exception {
                        System.out.println("item: " + line);
                        String[] array = line.trim().split(",");
                        Tuple2<String, Integer> tuple = Tuple2.of(array[0], Integer.parseInt(array[1]));
                        // 返回
                        return tuple;
                    }
                });

        // 2.窗口计算,每隔 5 秒钟计算最近 10 秒各个卡口流量
        SingleOutputStreamOperator<String> windowStream = mapStream
                // a、设置窗口:会话窗口,超时时间为 5 秒
                .windowAll(
                        ProcessingTimeSessionWindows.withGap(Time.seconds(5))
                )
                // c、窗口计算,窗口函数
                .apply(new AllWindowFunction<Tuple2<String, Integer>, String, TimeWindow>() {
                    // 定义变量,对当前时间数据进行转换
                    private FastDateFormat fastDateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");

                    @Override
                    public void apply(TimeWindow window, Iterable<Tuple2<String, Integer>> input, Collector<String> out) throws Exception {
                        // 获取窗口时间信息:开始和结束时间
                        String winStart = this.fastDateFormat.format(window.getStart());
                        String winEnd = this.fastDateFormat.format(window.getEnd());

                        // 对窗口中数据进行统计:求和
                        int sum = 0;
                        for (Tuple2<String, Integer> tuple2 : input) {
                            sum += tuple2.f1;
                            // 打印 窗口里面的数据对象
                            System.out.println("window: [" + winStart + " ~ " + winEnd + "], [key=" + tuple2.f0 + ", value="+tuple2.f1+"]");
                        }
                        // 输出结果数据
                        String output = "window: [" + winStart + " ~ " + winEnd + "], " + sum;
                        out.collect(output);
                    }
                });

        //3、打印输出
        windowStream.print().setParallelism(1);

        //4、启动执行
        env.execute("SessionWindowsDemo");
    }
}

结果日志如下:

item: key1,8
window: [2023-11-06 18:12:40 ~ 2023-11-06 18:12:45], [key=key1, value=8]
window: [2023-11-06 18:12:40 ~ 2023-11-06 18:12:45], 8
item: key1,5
window: [2023-11-06 18:12:46 ~ 2023-11-06 18:12:51], [key=key1, value=5]
window: [2023-11-06 18:12:46 ~ 2023-11-06 18:12:51], 5
item: key1,1
window: [2023-11-06 18:12:52 ~ 2023-11-06 18:12:57], [key=key1, value=1]
window: [2023-11-06 18:12:52 ~ 2023-11-06 18:12:57], 1
item: key0,1
window: [2023-11-06 18:12:58 ~ 2023-11-06 18:13:03], [key=key0, value=1]
window: [2023-11-06 18:12:58 ~ 2023-11-06 18:13:03], 1
item: key2,3
window: [2023-11-06 18:13:04 ~ 2023-11-06 18:13:09], [key=key2, value=3]
window: [2023-11-06 18:13:04 ~ 2023-11-06 18:13:09], 3
item: key2,8

2.2.4、Global Windows

全局窗口分配器(global windows assigner)将具有相同键的所有元素分配给同一个全局窗口。只有自己自定义触发器的时候该窗口才能使用。否则,将不会执行任何计算,因为全局窗口没有一个自然的终点,我们可以在该端点处理聚合元素。
Global Windows
示例代码:

package com.leo.window;

import com.leo.env.EnvironmentUtil;
import com.leo.source.SimulateDynamicDataSource;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.AllWindowFunction;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.SlidingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.GlobalWindow;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;

/**
 * @Classname GlobalWindowsDemo
 * @Description
 * 全局窗口分配器(global windows assigner)将具有相同键的所有元素分配给同一个全局窗口。
 * 只有自己自定义触发器的时候该窗口才能使用。否则,将不会执行任何计算,因为全局窗口没有一个自然的终点,我们可以在该端点处理聚合元素
 * @Date 2023/11/6 14:43
 * @Created by Leo825
 */
public class GlobalWindowsDemo {
    public static void main(String[] args) throws Exception {

        // 获取执行环境
        StreamExecutionEnvironment env = EnvironmentUtil.getLocalEnv();

        // 添加数据源
        DataStreamSource<String> inputStream = env.addSource(new SimulateDynamicDataSource());

        // 1、对数据进行转换处理: 过滤脏数据,解析封装到二元组中
        SingleOutputStreamOperator<Tuple2<String, Integer>> mapStream = inputStream
                // a、过滤符合条件的数据
                .filter(line -> line.trim().split(",").length == 2)
                // b、将数据解析封装
                .map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String line) throws Exception {
                        System.out.println("item: " + line);
                        String[] array = line.trim().split(",");
                        Tuple2<String, Integer> tuple = Tuple2.of(array[0], Integer.parseInt(array[1]));
                        // 返回
                        return tuple;
                    }
                });

        // 2.窗口计算,每隔 5 秒钟计算最近 10 秒各个卡口流量
        SingleOutputStreamOperator<String> windowStream = mapStream
                // a、设置窗口,滚动计数窗口
                .countWindowAll(5)
                // c、窗口计算,窗口函数
                .apply(new AllWindowFunction<Tuple2<String, Integer>, String, GlobalWindow>() {
                    // 定义变量,对当前时间数据进行转换
                    private FastDateFormat fastDateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");

                    @Override
                    public void apply(GlobalWindow globalWindow, Iterable<Tuple2<String, Integer>> input, Collector<String> out) {
                        // 对窗口中数据进行统计:求和
                        int sum = 0;
                        for (Tuple2<String, Integer> tuple2 : input) {
                            sum += tuple2.f1;
                            // 打印 窗口里面的数据对象
                            System.out.println("[key=" + tuple2.f0 + ", value="+tuple2.f1+"]");
                        }
                        // 输出结果数据
                        String output = "GlobalWindow: " + sum;
                        out.collect(output);
                    }
                });

        //3、打印输出
        windowStream.print().setParallelism(1);

        //4、启动执行
        env.execute("GlobalWindowsDemo");
    }
}

结果日志如下:

item: key2,2
item: key0,8
item: key2,4
item: key2,8
item: key1,2
[key=key2, value=2]
[key=key0, value=8]
[key=key2, value=4]
[key=key2, value=8]
[key=key1, value=2]
GlobalWindow: 24
item: key2,2
item: key0,1
item: key0,6
item: key2,3
item: key1,2
[key=key2, value=2]
[key=key0, value=1]
[key=key0, value=6]
[key=key2, value=3]
[key=key1, value=2]
GlobalWindow: 14

2.3、Time 时间语义

  • 处理时间 ProcessingTime:对于某个算子来说,Processing Time 指算子使用当前机器的系统时钟时间。在 Processing Time 的时间窗口场景下,无论事件什么时候发生,只要该事件在某个时间段到达了某个算子,就会被归结到该窗口下,不需要 Watermark 机制。
  • 摄入时间 IngestionTime:Ingestion Time是事件到达 Flink Source 的时间。从 Source 到下游各个算子中间可能有很多计算环节,任何一个算子的处理速度快慢可能影响到下游算子的 Processing Time 。而 Ingestion Time 定义的是数据流最早进入 Flink 的时间,因此不会被算子处理速度影响。
  • 事件时间 EventTime:事件真真正正发生产生的时间,比如订单数据中订单时间表示订单产生的时间;

Time分类

设置方式如下:

// 设置事件时间语义
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
// 设置处理时间语义
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
// 设置注入时间语义
env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);

需要注意:1.12 版本开始,1.11 版本时间语义的设置方法被废弃,1.12 版本开始默认的时间语义就是事件时间,所以 setStreamTimeCharacteristic() 方法被废弃,如果需要使用 setAutoWatermarkInterval() 方法设置自动水印发射的时间间隔,如果间隔设为 0 则认为使用处理时间。

基于事件时间 EventTime 窗口分析,指定事件时间字段,使用 assignTimestampsAndWatermarks 方法,类型必须为 Long 类型。

// 3-1. 过滤脏数据和指定事件时间字段字段
		SingleOutputStreamOperator<String> timeStream = inputStream
			.filter(line -> line.trim().split(",").length == 3)
			// todo: step1、指定事件时间字段,并且数据类型为Long类型
			.assignTimestampsAndWatermarks(
				WatermarkStrategy
					// 暂不考虑数据乱序和延迟
					.<String>forBoundedOutOfOrderness(Duration.ofSeconds(0))
					// 指定事件时间字段
					.withTimestampAssigner(
						new SerializableTimestampAssigner<String>() {
							private FastDateFormat format = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");

							@SneakyThrows
							@Override
							public long extractTimestamp(String element, long recordTimestamp) {
								// 2022-04-01 09:00:01,a,1 -> 2022-04-01 09:00:01 -> 1648774801000
								System.out.println("element -> " + element);
								// 分割字符串
								String[] array = element.split(",");
								// 获取事件时间
								String eventTime = array[0];
								// 转换格式
								Date eventDate = format.parse(eventTime);
								// z转换Long类型并返回
								return eventDate.getTime();
							}
						}
					)
			);

默认情况下(不考虑乱序和延迟),当数据事件时间EventTime >= 窗口结束时间,触发窗口数据计算
基于事件时间 EventTime 窗口分析,如果不考虑数据延迟乱序,当窗口被触发计算以后,延迟乱序到达的数据将不会被计算,而是直接丢弃。

窗口起始时间计算方式:
timestamp - (timestamp - offset + wondowsize) % windowsize
其中,timestamp 为最早时间的记录时间戳,offset 为参数偏移时间,windowSize 为窗口大小。

举例:以毫秒为单位的时间戳,窗口大小为 5000 毫秒,偏移量为 0 毫秒。

当前时间戳为 1699596777000(毫秒,2023-11-10 14:12:57),那么根据公式计算出的窗口起始时间为 
1699596777000 - (1699596777000 - 0 + 5000) % 5000 = 1699256857644(毫秒,2023-11-10 14:12:55)

这个计算结果表示,以当前时间戳1699596777000(毫秒,2023-11-10 14:12:57),往前偏移0秒,时间窗口为 5000 毫秒在这个例子中,窗口起始时间为1699256857644(毫秒,2023-11-10 14:12:55)

2.4、乱序和延迟数据处理

  • Watermark 水印机制
    在实际业务数据中,数据乱序到达流处理程序,属于正常现象,原因在于网络延迟导致数据延迟,无法避免的,所以应该可以允许数据乱序达到(在某个时间范围内),依然参与窗口计算。

我们也可以使用 assignTimestampsAndWatermarks() 来分配时间戳和水位线。该方法主要依赖于 WatermarkStrategy 接口,通过 WatermarkStrategy 我们可以为每个元素抽取时间戳并生成 Watermark

DataStream<MyType> withTimestampsAndWatermarks = stream
        .assignTimestampsAndWatermarks(
            WatermarkStrategy
                .forGenerator(...)
                .withTimestampAssigner(...)
        );

可以看到 WatermarkStrategy.ForGenerator (...). WithTimestampAssigner (...) 链式调用了两个方法,forGenerator () 方法用来生成 Watermark,本质是返回了一个 WatermarkGenerator ,它的源码如下:


public interface WatermarkGenerator<T> {  

    // 数据流中的每个元素流入后都会调用onEvent()方法 
    // Punctunated方式下,一般根据数据流中的元素是否有特殊标记来判断是否需要生成Watermark 
    // Periodic方式下,一般用于记录各元素的Event Time时间戳
    void onEvent(T event, long eventTimestamp, WatermarkOutput output);  

    // 每隔固定周期调用onPeriodicEmit()方法 
    // 一般主要用于Periodic方式 
    // 固定周期用 ExecutionConfig#setAutoWatermarkInterval() 方法设置
    void onPeriodicEmit(WatermarkOutput output);  
}

withTimestampAssigner() 方法用来为数据流的每个元素设置时间戳。WatermarkGenerator 提供了两种方式,onPeriodicEmit() 方法实现周期性的生成水位线,默认周期是 200 毫秒。可以使用 ExecutionConfig.setAutoWatermarkInterval() 方法进行设置。

Flink 本身已经帮我们封装好了这样的代码,常见的两个实现是 BoundedOutOfOrdernessWatermarksAscendingTimestampsWatermarks

基本用法:

DataStream.assignTimestampsAndWatermarks(WatermarkStrategy  
        .<Event>forBoundedOutOfOrderness(Duration.ZERO)  
        .withTimestampAssigner((SerializableTimestampAssigner<Event>)  
                (element, recordTimestamp) -> element.timeStamp))

使用示例:
Watermark水印机制

  • Allowed Lateness 允许延迟
    默认情况下,当 watermark 超过 end-of-window 之后,再有之前的数据到达时,这些数据会被删除。为了避免有些迟到的数据被删除,因此产生了 allowedLateness 的概念。
    允许延迟

  • 乱序数据:Watermark,窗口数据计算等一下

    • 使用水位线 Watermark,给每条数据加上一个时间戳
    • Watermark = 数据事件时间 - 最大允许乱序时间
    • 当数据的 Watermark >= 窗口结束时间,并且窗口内有数据,触发窗口数据计算
  • 延迟数据:AllowedLateness,窗口计算状态保存一段时间

    • 设置方法参数:allowedLateness,表示允许延迟数据最多可以迟到多久,还可以进行计算(保存窗口,并且触发窗口计算)
    • 当某个窗口触发计算以后,继续等待多长时间,如果在等待时间范围内,有数据达到时,依然会触发窗口计算。如果到达等待时长以后,没有数据达到,销毁窗口数据信息。

真正迟到的数据默认会被丢弃,可通过侧边流输出到文件:
侧边输出

  • 1、窗口 window 的作用是为了周期性的获取数据
  • 2、watermark 作用是防止数据出现乱序(经常),事件时间内获取不到指定的全部数据,做的一种保险方法;
  • 3、allowLateNess 是将窗口关闭时间再延迟一段时间
  • 4、sideOutPut 是最后兜底操作,所有过期延迟数据,指定窗口已经彻底关闭,就会把数据放到侧输出流

2.5、综合案例

package com.leo.time;

import com.leo.env.EnvironmentUtil;
import com.leo.source.SimulateDynamicDataSource;
import lombok.SneakyThrows;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.restartstrategy.RestartStrategies;
import org.apache.flink.api.java.tuple.Tuple3;
import org.apache.flink.runtime.state.hashmap.HashMapStateBackend;
import org.apache.flink.streaming.api.CheckpointingMode;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.CheckpointConfig;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import org.apache.flink.util.OutputTag;

import java.time.Duration;

/**
 * @Classname BoundedOutOfOrdernessWatermarksDemo
 * @Description WatermarkStrategy.ForGenerator (...). WithTimestampAssigner (...) 链式调用了两个方法,forGenerator () 方法用来生成 Watermark,本质是返回了一个 WatermarkGenerator
 * Flink 本身已经帮我们封装好了这样的代码,常见的两个实现是 BoundedOutOfOrdernessWatermarks 和 AscendingTimestampsWatermarks。
 * @Date 2023/11/6 14:43
 * @Created by Leo825
 */
public class TimeWatermarksDemo {

    /**
     * 定时时间格式
     */
    private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";

    public static void main(String[] args) throws Exception {

        // 获取执行环境
        StreamExecutionEnvironment env = EnvironmentUtil.getLocalEnv();
        // 设置周期性水印,单位毫秒,默认200ms
        env.getConfig().setAutoWatermarkInterval(500);
        // 设置Checkpoint
        setEnvCheckpoint(env);
        // 设置重启策略
        env.setRestartStrategy(RestartStrategies.fixedDelayRestart(3, 10000));
        // 添加数据源
        DataStreamSource<String> dataStreamSource = env.addSource(new SimulateDynamicDataSource());

        // 1、对数据进行转换处理: 过滤脏数据,解析封装到二元组中
        SingleOutputStreamOperator<Tuple3<String, Integer, Long>> transDataStream = dataStreamSource
                // a、过滤符合条件的数据
                .filter(line -> line.trim().split(",").length == 3)
                // b、将数据解析封装
                .map(new MapFunction<String, Tuple3<String, Integer, Long>>() {
                    @Override
                    public Tuple3<String, Integer, Long> map(String line) throws Exception {
                        String[] array = line.trim().split(",");
                        Tuple3<String, Integer, Long> tuple = Tuple3.of(array[0], Integer.parseInt(array[1]), Long.parseLong(array[2]));
                        // 返回
                        return tuple;
                    }
                });

        /**
         *  2、设置事件时间和水位线 Watermark
         *
         *  由于乱序流中需要等待迟到的数据到齐,所以必须设置一个固定量的延迟时间(Fixed Amount of Lateness)。
         *  这时生成的水位线的时间戳就是当前数据流中最大的时间戳减去延迟的结果,相当于把表调慢,当前时钟会滞后于数据的最大时间戳。
         *  调用 WatermarkStrategy. forBoundedOutOfOrderness()方法就可以实现
         *  TimestampAssigner:主要负责从流中数据元素的某个字段中提取时间戳,并分配给元素。时间戳的分配是生成水位线的基础
         *
         * 有序流水位线生成器:
         * forMonotonousTimestamps 是一种时间策略,它假设数据流中的事件时间戳是单调递增的。这意味着每个事件的时间戳都比前一个事件的时间戳要大。这种策略在水流数据是单调递增时间戳的情况下非常有用。
         *
         * 乱序流水位线生成器:
         * forBoundedOutOfOrderness 是一种时间策略,它假设数据流中可能存在一定范围内的乱序事件。在这种策略下,watermark 是通过比较当前事件的时间戳与 watermark 生成器维护的最大允许时间戳(称为“迟到时间”)来生成的。
         */
        SingleOutputStreamOperator<Tuple3<String, Integer, Long>> timeDataStreamSource = transDataStream
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy.<Tuple3<String, Integer, Long>>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                                .withTimestampAssigner(
                                        new SerializableTimestampAssigner<Tuple3<String, Integer, Long>>() {
                                            // 定义时间格式
                                            private FastDateFormat format = FastDateFormat.getInstance(DATE_FORMAT);

                                            @SneakyThrows
                                            @Override
                                            public long extractTimestamp(Tuple3<String, Integer, Long> element, long l) {
                                                // 数据源
                                                System.out.println("element: " + element);
                                                // z转换Long类型并返回
                                                return element.f2;
                                            }
                                        }
                                )
                );


        // 3.窗口计算,窗口设置及处理数据
        OutputTag<Tuple3<String, Integer, Long>> lateOutputTag = new OutputTag<Tuple3<String, Integer, Long>>("late-order") {
        };
        SingleOutputStreamOperator<String> windowStream = transDataStream
                // a、设置分组 key,按照卡口分组
                .keyBy(tuple -> tuple.f0)
                // b、设置窗口:10s,滚动窗口
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                // c、设置最大允许延迟时间
                .allowedLateness(Time.seconds(3))
                // d、设置延迟很久数据侧边输出
                .sideOutputLateData(lateOutputTag)
                // e、设置窗口函数,进行计算
                .apply(new WindowFunction<Tuple3<String, Integer, Long>, String, String, TimeWindow>() {
                    // 定义变量,对当前时间数据进行转换
                    private FastDateFormat fastDateFormat = FastDateFormat.getInstance(DATE_FORMAT);

                    @Override
                    public void apply(String key,
                                      TimeWindow window,
                                      Iterable<Tuple3<String, Integer, Long>> input,
                                      Collector<String> out) {

                        // 获取窗口时间信息:开始和结束时间
                        String winStart = this.fastDateFormat.format(window.getStart());
                        String winEnd = this.fastDateFormat.format(window.getEnd());

                        // 对窗口中数据进行统计:求和
                        int sum = 0;
                        for (Tuple3<String, Integer, Long> tuple2 : input) {
                            sum += tuple2.f1;
                            // 打印 窗口里面的数据对象
                            System.out.println("window: [" + winStart + " ~ " + winEnd + "], [key=" + key + ", value=" + tuple2.f1 + "]");
                        }
                        // 输出结果数据
                        String output = "window: [" + winStart + " ~ " + winEnd + "], " + key + " = " + sum;
                        out.collect(output);
                    }
                });


        //4、打印输出或者 Sink
        windowStream.print().setParallelism(1);

        // 获取侧边流中延迟数据
        DataStream<Tuple3<String, Integer, Long>> lateOrderStream = windowStream.getSideOutput(lateOutputTag);
        lateOrderStream.printToErr("late>");

        //5、启动执行
        env.execute("TumblingWindowsDemo");
    }


    /**
     * 流式应用Checkpoint检查点设置
     */
    private static void setEnvCheckpoint(StreamExecutionEnvironment env) {
        // 1. 启动Checkpoint
        env.enableCheckpointing(10000);
        // 2.设置StateBackend
        env.setStateBackend(new HashMapStateBackend());
        // 3.设置Checkpoint存储
        env.getCheckpointConfig().setCheckpointStorage("file:///D:/flink-checkpoints/");
        // 4. 设置相邻Checkpoint至少时间间隔
        env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);
        // 5. 设置Checkpoint最大失败次数
        env.getCheckpointConfig().setTolerableCheckpointFailureNumber(3);
        // 6. 设置取消job时Checkpoint是删除还是保留
        env.getCheckpointConfig().enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
        // 7.设置Checkpoint超时时间
        env.getCheckpointConfig().setCheckpointTimeout(10 * 60 * 1000);
        // 8. 设置Checkpoint最大并发次数
        env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
        // 9. 设置模式
        env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
    }
}

源代码:https://gitee.com/leo825/Flink-learning-demos.git

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

leo825...

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值