聊聊 flink data stream apI的简单应用

我正在参与掘金创作者训练营第5期, 点击了解活动详情

flink 同时具有处理有界(bounded)和无界(unbounded)的数据的能力,也就是同时的处理流式数据(streaming)和批次数据(batch)的能力。在本文中,我们目标是讨论 stream的api:

image.png

Data stream api 简介

 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
 DataStreamSource<Item> items = env.fromCollection(data);

Data stream api的目标是为了能够处理流式数据,所以它直接的暴露了flink的核心部分的模块: stream,time和state等等,主要是提供给数据工程师,平台搭建团队等等去使用。

  • 如何实现
    • 通过构造管道(pipeline)来变换数据
    • 使用高阶的方法(例如map/fliter/process)去实现业务逻辑
    • 可以拥有定制化的输出,依靠时间化的处理方法,强大的算子拓扑处理
    • 方法经常是使用jar来提交的
  • 挑战性
    • 需要对于flink的概念有着很好的了解(watermar:水位线,state access 状态访问机制,serializers : 串行处理器)
    • 示例的代码非常的少
    • 需要外部实现的手册来理解api
  • 优点
    • 拥有非常稳定的拓扑结构,并且可以自定义的实现算子
    • 非常高效的状态和时间窗口使用
    • 可以很好的控制你的状态演化和自定义的各种算子实现

Data stream api 样例

首先我们利用flink 的数据源样例代码编写一个可以生产数据的流: 我们需要继承RichParallelSourceFunction方法来编写我们自定义的数据Source

public class SensorSource extends RichParallelSourceFunction<SensorReading> {

    // 标志整个数据流还在不在运行
    private boolean running = true;

    /** run() 方法通过SourceContext 持续的发送SensorReadings数据. */
    @Override
    public void run(SourceContext<SensorReading> srcCtx) throws Exception {

        // 随机数生成器
        Random rand = new Random();
        // 查看并行任务的指标
        int taskIdx = this.getRuntimeContext().getIndexOfThisSubtask();

        // 初始化传感器的id和温度的数值
        String[] sensorIds = new String[10];
        double[] curFTemp = new double[10];
        for (int i = 0; i < 10; i++) {
            sensorIds[i] = "sensor_" + (taskIdx * 10 + i);
            curFTemp[i] = 65 + (rand.nextGaussian() * 20);
        }

        while (running) {

            // 获取时间
            long curTime = Calendar.getInstance().getTimeInMillis();

            // 发送 SensorReadings 
            for (int i = 0; i < 10; i++) {
                // 更新 现在的温度 
                curFTemp[i] += rand.nextGaussian() * 0.5;
                // 发送数据
                srcCtx.collect(new SensorReading(sensorIds[i], curTime, curFTemp[i]));
            }

            // 等待100ms
            Thread.sleep(100);
        }
    }

    /** Cancels this SourceFunction. */
    @Override
    public void cancel() {
        this.running = false;
    }
}

然后我们就可以编写消费这个数据源的 data Stream 程序了。

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

    // StreamExecutionEnvironment 是所有 Flink 程序的基础。首先初始化Environment
    StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

    //在方法中使用EventTime
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
    // 配置 watermark 的周期
    env.getConfig().setAutoWatermarkInterval(1000L);

    // 导入 传感器数据流 
    DataStream<SensorReading> sensorData = env
        // 数据源 生成了随机的温度数据 
        .addSource(new SensorSource())
        // 事件时间需要分配timestamps 和watermarks 
        .assignTimestampsAndWatermarks(new SensorTimeAssigner());

    DataStream<SensorReading> avgTemp = sensorData
        // 通过内部的map转化华氏温度为摄氏温度
        .map( r -> new SensorReading(r.id, r.timestamp, (r.temperature - 32) * (5.0 / 9.0)))
        // 通过传感器的id去分组
        .keyBy(r -> r.id)
        // 把一秒内的时间窗口的所有数据分组
        .timeWindow(Time.seconds(1))
        // 用自定义的方法计算平均温度
        .apply(new TemperatureAverager());

    //打印结果
    avgTemp.print();

    // 启动应用
    env.execute("Compute average sensor temperature");
}

/**
 *  自定义的 WindowFunction 用来计算平均温度
 */
public static class TemperatureAverager implements WindowFunction<SensorReading, SensorReading, String, TimeWindow> {

    /**
     * apply()方法在每个window触发一次
     *
     * @param sensorId  window的主键34
     * @param window window的元数据 
     * @param 调用out的 collector 方法来发送方法的结果
     */
    @Override
    public void apply(String sensorId, TimeWindow window, Iterable<SensorReading> input, Collector<SensorReading> out) {

        // 计算平均的温度
        int cnt = 0;
        double sum = 0.0;
        for (SensorReading r : input) {
            cnt++;
            sum += r.temperature;
        }
        double avgTemp = sum / cnt;

        // 发送平均温度的SensorReading结果
        out.collect(new SensorReading(sensorId, window.getEnd(), avgTemp));
    }
}

然后我们启动两个方法来尝试一下计算。

截屏2022-07-22 23.54.08.png 在console可以看到完成了一个成功的流式计算,以每秒为单位的window输出结果。 然后我们来简单分布解析一下这个应用的源码

Data stream api 代码解析

StreamExecutionEnvironment 解析

StreamExecutionEnvironment 是flink最基础的运行环境,包括了很多的配置,并行度配置等等,这里只简单探讨一下运行环境的建立过程。

/**
 * The StreamExecutionEnvironment is the context in which a streaming program is executed. A
 * {@link LocalStreamEnvironment} will cause execution in the current JVM, a
 * {@link RemoteStreamEnvironment} will cause execution on a remote setup.
 *
 * <p>The environment provides methods to control the job execution (such as setting the parallelism
 * or the fault tolerance/checkpointing parameters) and to interact with the outside world (data access).
 *
 */
@Public
public abstract class StreamExecutionEnvironment 

在最开始我们来看一看flink的最基础最重要的一个类StreamExecutionEnvironment,简单翻译一下apache官方对于 StreamExecutionEnvironment的一些解释:是执行流式程序的上下文,同时支持在本jvm以及多个机器或者集群之间传递, environment 提供了一些控制整个任务执行的方法。比如说设定并行度,并且包括了错误处理,checkpointing的机制控制,并且负责整个外部数据的引入部分(data access)。

整个 StreamExecutionEnvironment包括非常多的方法,我们先看看本次任务中使用到的部分: 首先是getExecutionEnvironment()方法。在flink中有三种类型方式创建 StreamExecutionEnvironment,分别是getExecutionEnvironment(), .createLocalEnvironment(),以及createRemoteEnvironment(). 一般都是用getExecutionEnvironment 方法来创建上下文。

/**
 * 创建一个给现有运行中的程序提供上下文的执行环境
 * 如果说当前flink 程序是以standalone的方式运行的
 * 这个方法就会自动的返回createLocalEnvironment
 * {@link #createLocalEnvironment()}.
 *
 * @return 
 */
public static StreamExecutionEnvironment getExecutionEnvironment() {
	return getExecutionEnvironment(new Configuration());
}

public static StreamExecutionEnvironment getExecutionEnvironment(Configuration configuration) {
	       // 首先检查当前上下文是否存在可用的 EnvironmentFactory
	return Utils.resolveFactory(threadLocalContextEnvironmentFactory, contextEnvironmentFactory)
			// 若当前上下文存在可用的 EnvironmentFactory,则基于该工厂类创建 ExecutionEnvironment
			.map(factory -> factory.createExecutionEnvironment(configuration))
			// 若工厂类未能创建 ExecutionEnvironment ,则调用 createLocalEnvironment(configuration) 方法创建 LocalStreamEnvironment
			.orElseGet(() -> StreamExecutionEnvironment.createLocalEnvironment(configuration));
}

public static LocalStreamEnvironment createLocalEnvironment(Configuration configuration) {
	// 会判断是否有设置默认并行度
	if (configuration.getOptional(CoreOptions.DEFAULT_PARALLELISM).isPresent()) {
		// 若有设置,则基于配置中的并行度创建 LocalStreamEnvironment
		return new LocalStreamEnvironment(configuration);
	} else {
		// 否则将基于 defaultLocalParallelism 创建 LocalStreamEnvironment
		// 其中,defaultLocalParallelism 为程序运行节点的核数
		Configuration copyOfConfiguration = new Configuration();
		copyOfConfiguration.addAll(configuration);
		copyOfConfiguration.set(CoreOptions.DEFAULT_PARALLELISM, defaultLocalParallelism);
		return new LocalStreamEnvironment(copyOfConfiguration);
	}
}



这里可以看到两个关键的类:接口StreamExecutionEnvironmentFactory 和方法ExecutionEnvironment,首先在初始化的时候会先去查看工厂方法是不是已经存在,如果已经存在就调用createExecutionEnvironment直接返回已经构建好的StreamExecutionEnvironment

可以看到分为两个EnvironmentFactory,一个是ThreadLocal的运行环境,当ThreadLocal线程中存在运行环境时,会直接返回该环境。一种情况是当是本地IDE直接运行任务main方法时,ThreadLocal中获取到的StreamExecutionEnvironmentFactory为空,此时生成本地运行环境LocalStreamEnvironment


private static StreamExecutionEnvironmentFactory contextEnvironmentFactory = null;

private static final ThreadLocal<StreamExecutionEnvironmentFactory>
        threadLocalContextEnvironmentFactory = new ThreadLocal<>();

那么当集群环境中我们提交jar时候是如何获取到这个StreamExecutionEnvironmentFactory创建的运行环境的呢: 通过 flink run  命令提交jar包到集群运行命令时,该脚本会调用 org.apache.flink.client.cli.CliFrontend  来运行用户程序,如下

exec $JAVA_RUN $JVM_ARGS $FLINK_ENV_JAVA_OPTS "${log_setting[@]}" -classpath "`manglePathList "$CC_CLASSPATH:$INTERNAL_HADOOP_CLASSPATHS"`" org.apache.flink.client.cli.CliFrontend "$@

其中会调用CliFrontend方法中的executeProgram()。而其中会调用 StreamContextEnvironment.unsetAsContext()来创建运行环境

public static void executeProgram(PipelineExecutorServiceLoader executorServiceLoader, Configuration configuration, PackagedProgram program, boolean enforceSingleJobExecution, boolean suppressSysout) throws ProgramInvocationException {

        StreamContextEnvironment.setAsContext(executorServiceLoader, configuration, userCodeClassLoader, enforceSingleJobExecution, suppressSysout);

        try {
            program.invokeInteractiveModeForExecution();
        } finally {
            ContextEnvironment.unsetAsContext();
            StreamContextEnvironment.unsetAsContext();

StreamContextEnvironment继承自StreamExecutionEnvironmentsetAsContext()方法如下:

public static void setAsContext(PipelineExecutorServiceLoader executorServiceLoader, Configuration configuration, ClassLoader userCodeClassLoader, boolean enforceSingleJobExecution, boolean suppressSysout) {
    StreamExecutionEnvironmentFactory factory = (conf) -> {
        Configuration mergedConfiguration = new Configuration();
        mergedConfiguration.addAll(configuration);
        mergedConfiguration.addAll(conf);
        return new StreamContextEnvironment(executorServiceLoader, mergedConfiguration, userCodeClassLoader, enforceSingleJobExecution, suppressSysout);
    };
    initializeContextEnvironment(factory);
}

创建生成运行环境的工厂类实例,在initializeContextEnvironment()方法中把实例放到StreamExecutionEnvironment类的静态属性threadLocalContextEnvironmentFactory 中 :

protected static void initializeContextEnvironment(StreamExecutionEnvironmentFactory ctx) {
    contextEnvironmentFactory = ctx;
    threadLocalContextEnvironmentFactory.set(contextEnvironmentFactory);
}

protected static void resetContextEnvironment() {
    contextEnvironmentFactory = null;
    threadLocalContextEnvironmentFactory.remove();
}

这样在获取运行环境时候,这个StreamExecutionEnvironmentFactory中获取到的就是前文中通过运行脚本调用setAsContext()方法所生成的Environment了。

LocalStreamEnvironmentStreamContextEnvironment最大的区别就在于因为生成的Configuration的来源并不一致,所以集群环境,yarn环境的flink job的运行环境可以配置的参数多了很多,也会影响各种方面。

watermark 解析

首先简单的介绍一下什么是flink的 watermark:

数据在自然环境中其实是不存在批次这样的来源的,即所有的数据都是以流的方式随着时间出现的。打个比方:用户的信用卡交易信息,每一笔订单会随着时间不断的到来,而我们要为用户的账户做风险控制。对于这样一个最典型而又简单的应用场景,flink的window(窗口),state(状态),watermark(水位线)都可以起到非常重要的作用。

我们在使用聚合来处理事务的时候,处理的时间和事件的时间往往都不是完全一致的。我们需要确定一个等待时间,在等待时间到了以后,算子会接收到另一个含有时间T的水位线,算子就会认为这一段时间内所发生的事件都已经全部观察到了,可以触发计算了。 水位线提供了一种结果可信度和延时之间的妥协方案。激进的水位线设置可以低延迟,但会降低结果的准确性,如果过于宽松的水位线,会导致高准确性,和更多的延时。

对于信用卡交易我们设置一个每秒为单位的水位线毫无疑问是比较合适的,单笔的交易时间是不会超过秒的单位的。

然后我们来看看接下来的设置


// configure watermark interval
env.getConfig().setAutoWatermarkInterval(1000L);

setAutoWatermarkInterval()的方法是用来在方法中生成一秒为单位的水位线。


/**
 * 手动设置自动水位线发射的间隔
 * 水位线是用于在整个流数据处理过程中用来跟踪时间的进程的
 * 例如,它们用于基于时间的窗口。
 *
 */
@PublicEvolving
public ExecutionConfig setAutoWatermarkInterval(long interval) {
    Preconditions.checkArgument(interval >= 0, "Auto watermark interval must not be negative.");
    this.autoWatermarkInterval = interval;
    return this;
}

接下来是处理数据源的水位线:

// 导入传感器的数据流
DataStream<SensorReading> sensorData = env
    .addSource(new SensorSource())
         // 事件时间需要分配timestamps 和watermarks 
    .assignTimestampsAndWatermarks(
    //指定Watermark生成策略,最大延迟长度5毫秒
            WatermarkStrategy.<SensorReading>forBoundedOutOfOrderness(Duration.ofMillis(5))
                    .withTimestampAssigner(
                            //SerializableTimestampAssigner接口中实现了extractTimestamp方法来指定如何从事件数据中抽取时间戳
                            new SerializableTimestampAssigner<SensorReading>() {
                                @Override
                                public long extractTimestamp(SensorReading event, long recordTimestamp) {
                                    return event.timestamp;
                                }
                            }));

在flink 1.11版本后不再使用原来的TimeAssigner 方法,而是转而使用WatermarkStrategy(Watermark生成策略)生成水位线。创建DataStream对象后,使用 assignTimestampsAndWatermarks()来指定策略,我们只需要 实现WatermarkGenerator<T>接口即可。

@Public
public interface WatermarkGenerator<T> {

    /**
     * 每一个元素事件都会触发调用,event是接收的数据,允许水位线生成器记住数据的时间戳
     * 或根据事件本身发出水位线
     *如果我们想依赖每个元素生成一个水位线,然后发射到下游(可选,就是看是否用output来收集水位线)
     *我们可以实现这个方法.
    void onEvent(T event, long eventTimestamp, WatermarkOutput output);

    /**
     * onPeriodicEmit方法会周期性的生成水位线,效率比每个元素生成一个Watermark高
     *
     */
    void onPeriodicEmit(WatermarkOutput output);
}

简单的实现一个周期性的发射水位线的例子:

DataStream<Tuple2<String,Long>> valWithTimestampsAndWatermarks =  env
       .addSource(new Tuple2Source()).assignTimestampsAndWatermarks(
       new WatermarkStrategy<Tuple2<String,Long>>(){
           @Override
           public WatermarkGenerator<Tuple2<String,Long>> createWatermarkGenerator(
                   WatermarkGeneratorSupplier.Context context){
               return new WatermarkGenerator<Tuple2<String,Long>>(){
                   private long maxTimestamp;
                   private long delay = 500;
                   @Override
                   public void onEvent(
                           Tuple2<String,Long> event,
                           long eventTimestamp,
                           WatermarkOutput output){
                       maxTimestamp = Math.max(maxTimestamp, event.f1);
                   }
                  // 周期性的生成固定的水位线 获取传入事件的时间戳和当前最大时间戳比较,然后输出
                   @Override
                   public void onPeriodicEmit(WatermarkOutput output){
                       output.emitWatermark(new Watermark(maxTimestamp - delay));
                   }
               };
           }
       });

而我们上文代码中实现的例子是另一种,利用forBoundedOutOfOrderness()方法,我们想从我们的自己的数据中抽取eventTime,这个就需要TimestampAssigner,使用的时候我们主要就是从我们自己的元素中直接抽取

.assignTimestampsAndWatermarks( //指定Watermark生成策略,最大延迟长度5毫秒
        WatermarkStrategy.<SensorReading>forBoundedOutOfOrderness(Duration.ofMillis(5))
.withTimestampAssigner()

withTimestampAssigner()内部实现自己的的时间戳抽取逻辑,一般是告知Flink具体哪个字段为时间戳字段。数据流的每个元素为event,而这里event.timestamp就是我们所指定的时间戳字段:

.withTimestampAssigner(
        //SerializableTimestampAssigner接口中实现了extractTimestamp方法来指定如何从事件数据中抽取时间戳
        new SerializableTimestampAssigner<SensorReading>() {
            @Override
            public long extractTimestamp(SensorReading event, long recordTimestamp) {
                return event.timestamp;
            }
        }));

总结

在文中首先要我们一步步的实现了一个简单的计算信用卡的flink流的简单的例子,然后讨论了StreamExecutionEnvironment:flink的运行环境, watermark :水位线的初步认识。 文章内容主要还是针对我们最开始的简单例子进行代码上的一些阅读。很多可以展开的内容都没有得以充分的阐述。尤其是flink精妙的水位线机制,在后面的文章中会更加的多的去探讨这些部分的实现。

猜你喜欢

转载自juejin.im/post/7125076434883706893