flink 流式api简单使用

967 阅读9分钟

流式api使用

flink和mapreduce,以及spark,都是现在最常用的大数据处理引擎。

Apache Flink提供了可以用于构建健壮、有状态的流应用程序的数据流API。它提供了基于状态和时间的细粒度控制来实现高级事件驱动系统。在本分步指南中,您将学习如何使用Flink的DataStream API构建有状态的流应用程序。

在这篇文章中将构建一个欺诈检测系统,用于模拟对可疑信用卡交易发出异常警报。通过使用一组简单规则,来尝试模拟Flink是如何实现高级业务逻辑和实时操作的。

系统需求

  • Java 11
  • Maven 文章将使用一个maven的原型快速创建程序所需要的flink 架构, 这样就可以把主要的精力投入到实现业务逻辑的部分,这些依赖包括所有flink流api的核心依赖:flink-streaming-java和产生演示数据的依赖:flink-walkthrough-common
$ mvn archetype:generate \
    -DarchetypeGroupId=org.apache.flink \
    -DarchetypeArtifactId=flink-walkthrough-datastream-java \
    -DarchetypeVersion=1.15.0 \
    -DgroupId=frauddetection \
    -DartifactId=frauddetection \
    -Dversion=0.1 \
    -Dpackage=spendreport \
    -DinteractiveMode=false

groupIdartifactId 和 package都是可以更改的. 利用这些参数,maven将会创建一个名字叫frauddetection的包含所有的依赖的文件夹,这个文件夹可以支持这次项目的所有需要。用idea import刚刚创建的maven项目的pom文件之后,你可以找到一个文件FraudDetectionJob.java (或者 FraudDetectionJob.scala),就是下文所展示的代码。这个代码是可以直接使用ide运行的,你可以在代码中打几个端点来debug一下代码是如何工作的。

package spendreport;

import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.walkthrough.common.sink.AlertSink;
import org.apache.flink.walkthrough.common.entity.Alert;
import org.apache.flink.walkthrough.common.entity.Transaction;
import org.apache.flink.walkthrough.common.source.TransactionSource;

public class FraudDetectionJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        DataStream<Transaction> transactions = env
            .addSource(new TransactionSource())
            .name("transactions");
        
        DataStream<Alert> alerts = transactions
            .keyBy(Transaction::getAccountId)
            .process(new FraudDetector())
            .name("fraud-detector");

        alerts
            .addSink(new AlertSink())
            .name("send-alerts");

        env.execute("Fraud Detection");
    }
}
package spendreport;

import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;
import org.apache.flink.walkthrough.common.entity.Alert;
import org.apache.flink.walkthrough.common.entity.Transaction;

public class FraudDetector extends KeyedProcessFunction<Long, Transaction, Alert> {

    private static final long serialVersionUID = 1L;

    private static final double SMALL_AMOUNT = 1.00;
    private static final double LARGE_AMOUNT = 500.00;
    private static final long ONE_MINUTE = 60 * 1000;

    @Override
    public void processElement(
            Transaction transaction,
            Context context,
            Collector<Alert> collector) throws Exception {

        Alert alert = new Alert();
        alert.setId(transaction.getAccountId());

        collector.collect(alert);
    }
}

点击main方法就可以直接运行 如果遇到这个异常:

错误: 无法初始化主类 spendreport.FraudDetectionJob\
原因: java.lang.NoClassDefFoundError: org/apache/flink/streaming/api/functions/source/SourceFunction

在idea中修改配置

屏幕快照 2022-07-05 下午2.59.54.png

分步解析

首先让我们分布解析一下这些代码的内容,FraudDetectionJob定义了应用的数据流,FraudDetector定义了方法的有关检测欺诈交易的业务逻辑。 首先来看一下任务是如何在FraudDetectionJob类的mai方法中组装的。

执行环境方法

第一行的代码是设置你的StreamExecutionEnvironment,执行环境是用来设置任务的配置属性,创建资源,并且最后触发整个任务的执行的方法。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

创建一个资源

数据源从外部系统引入数据,比如说kafka,Rabbit MQ,Apache Pulsar,进入flink的任务。这次的测试任务使用一个无限生产的信用卡交易数据流作为待处理的数据源。每个交易包含一个账户的id(accountId),一个交易发生时间的时间戳(timestamp),一个美元单位的交易额(amount)。附加到源代码的name仅用于调试目的,这样就可以容易的知道错误的来源。

DataStream<Transaction> transactions = env
    .addSource(new TransactionSource())
    .name("transactions");

划分事件和检测欺诈

 transactions流包含了大量的用户的交易数据,因此需要同时多个欺诈检测任务并行处理。由于欺诈的原因是每个账户各不相同的,因此必须确保同一账户的所有交易都由欺诈检测操作算子的同一并行任务处理。 为了确保同一物理任务处理特定密钥的所有记录,可以使用DataStream#keyBy对流进行分区。process()调用添加了一个运算符,该运算符将函数应用于流中的每个分区元素。在keyBy(在本例中为FraudDetector)在 分区后的上下文 内执行后立即执行运算符。

DataStream<Alert> alerts = transactions
    .keyBy(Transaction::getAccountId)
    .process(new FraudDetector())
    .name("fraud-detector");

输出结果

sink 方法会把 DataStream数据流写入外部系统; 就如as Apache Kafka, Cassandra, 或者 AWS Kinesis.  AlertSink 日志使用日志级别记录每个 Alert 记录,作为写入持久化存储的替代, 这样可以更简单的看见输出的结果.

alerts.addSink(new AlertSink());

欺诈检测器 

  KeyedProcessFunction方法是欺诈检测器的实现. 方法 KeyedProcessFunction#processElement 用来处理每一个事务事件.第一的版本会对每个事务都产生警报。

public class FraudDetector extends KeyedProcessFunction<Long, Transaction, Alert> {

    private static final double SMALL_AMOUNT = 1.00;
    private static final double LARGE_AMOUNT = 500.00;
    private static final long ONE_MINUTE = 60 * 1000;

    @Override
    public void processElement(
            Transaction transaction,
            Context context,
            Collector<Alert> collector) throws Exception {
  
        Alert alert = new Alert();
        alert.setId(transaction.getAccountId());

        collector.collect(alert);
    }
}

完成一个真正的欺诈检测方法 (v1) 

第一个版本,欺诈检测器会为任何在进行小交易后立即进行大交易的账户进行警报,当欺诈检测器为特定账户处理以下交易流就可能出现问题:

Fraud Transaction

交易3和4应标记为欺诈,因为这是一笔小额交易,金额为0.09美元,其次是大额交易,金额为510美元。但是交易7、8和9不会标记为欺诈,因为0.02美元的小金额后面不是大额交易,有一个中间的交易额度打破了这种模式。

为了解决这个问题,欺诈检测器必须记住跨事件的信息,并且只有在前一笔交易规模较小的情况下,大额交易才是欺诈。跨事件记住信息需要状态state,这就是为什么我们决定使用KeyedProcessFunction。它提供了对状态和时间的细粒度控制,这将允许在整个计算过程中以更复杂的需求去发展我们的算法。 最直接的实现是利用一个布尔标志,发生小额交易时设置。当发生大额交易时,只需检查该账户有没有小额消费的标志。

当然,仅仅用 FraudDetector 中的一个变量来实现这个标志是行不通的。Flink会同时处理多个账户的交易信息,每个交易信息都有FraudDetector这个实例的方法,意味着在同时 a和b账户都通过FraudDetector这个方法的计算,当a账户把标志设为true的时候,b账户收到影响,导致了警报。当然我们可以用map结构来处理并跟踪单个帐户的密钥。但是,一个简单的成员变量会更加的容易出错,也会因为一次失败就丢失掉全部的信息。因此,当整个欺诈检测程序必须重新启动才能恢复运行的时候,简单的map结构会导致本来的警告丢失。

为了解决这些问题,Flink提供了容错状态的原语,这些原语几乎与常规成员变量一样易于使用。 flink中最基础的状态原语是ValueState,这是为了对它包装的变量提供容错性的数据类型。ValueState 是 keyed state的一种形式,这意味着它仅在应用于keyed context的运算符中,在任何DataStream#keyBy后紧跟的运算符都可以使用ValueState。运算符的keyed state自动限定为当前处理的记录的键,在这个例子中这个key是正在交易中的账户的id,这样FraudDetector 在keyBy()之后就会给每个账户保留了一个单独的状态。 ValueState 是使用ValueStateDescriptor创建的,其中包含关于Flink应该如何管理变量的元数据。状态应该在函数开始处理数据之前就注册好。启动的部分是open()方法。

public class FraudDetector extends KeyedProcessFunction<Long, Transaction, Alert> {

    private static final long serialVersionUID = 1L;

    private transient ValueState<Boolean> flagState;

    @Override
    public void open(Configuration parameters) {
        ValueStateDescriptor<Boolean> flagDescriptor = new ValueStateDescriptor<>(
                "flag",
                Types.BOOLEAN);
        flagState = getRuntimeContext().getState(flagDescriptor);
    }

ValueState是一个包装类,类似于Java标准库中的AtomicReferenceAtomicLong 。 ValueState提供了三种与内容交互的方法;update设置状态, value 获取当前值,clear 删除其内容。如果特定键的状态为空,例如在应用程序开始时或调用ValueState#clear之后,则 ValueState#value将返回null。对 ValueState#value返回的对象的修改并不能保证被系统识别,因此所有更改都必须使用ValueState#update执行。这样的话,Flink可以自动管理程序错误,也可以像处理任何标准变量一样与它交互。

下面的代码就是使用标志状态跟踪潜在欺诈交易的示例。

@Override
public void processElement(
        Transaction transaction,
        Context context,
        Collector<Alert> collector) throws Exception {

    // Get the current state for the current key
    Boolean lastTransactionWasSmall = flagState.value();

    // Check if the flag is set
    if (lastTransactionWasSmall != null) {
        if (transaction.getAmount() > LARGE_AMOUNT) {
            // Output an alert downstream
            Alert alert = new Alert();
            alert.setId(transaction.getAccountId());

            collector.collect(alert);            
        }

        // Clean up our state
        flagState.clear();
    }

    if (transaction.getAmount() < SMALL_AMOUNT) {
        // Set the flag to true
        flagState.update(true);
    }
}

对于每笔交易,欺诈检测器都会检查该账户标志的状态。ValueState始终作用于当前key,即交易的帐户。如果状态标志为非空,则该账户的最后一笔交易很小,因此如果现在进行的交易的金额很大,检测器就会输出欺诈警报。 在检查之后,标志的状态会自动被清除。如果当前交易导致欺诈警报,并且模式已结束,或者当前交易未导致警报,模式已损坏并且需要重新启动,这两种情况都会清除标记状态。

最后,检查进行中交易金额是否较小。如果是,则设置该标志,以便下一个事件可以对其进行检查。注意,ValueState<Boolean>有三个状态,未设置(null)、truefalse,因为所有的ValueState都可以为null。此样例仅使用unset(null)和true来检查是否设置了标志。

欺诈检测器v2:状态+时间

骗子不会等很长的时间再去消费大笔的金额,以降低他们测试的那笔消费被发现的几率。举例子,如果你想设置一个一分钟的过期时间对于你的欺诈检测器,例如,在前一个示例中,交易3和4只有在相互间隔1分钟内发生时才被视为欺诈。Flink的KeyedProcessFunction允许你去设置计时器,以便在将来的某个时间点触发回调来调用方法。

新的三个要求:

-每当标志设置为“true”时,也将计时器设置为1分钟。

-计时器触发时,通过清除其状态来重置标志。

-如果清除了标志,则应取消计时器。

要取消计时器,必须记住它设置的时间,因此必须首先创建一个计时器状态以及相关联的的标志状态。

private transient ValueState<Boolean> flagState;
private transient ValueState<Long> timerState;

@Override
public void open(Configuration parameters) {
    ValueStateDescriptor<Boolean> flagDescriptor = new ValueStateDescriptor<>(
            "flag",
            Types.BOOLEAN);
    flagState = getRuntimeContext().getState(flagDescriptor);

    ValueStateDescriptor<Long> timerDescriptor = new ValueStateDescriptor<>(
            "timer-state",
            Types.LONG);
    timerState = getRuntimeContext().getState(timerDescriptor);
}

KeyedProcessFunction#processElement 就是包含计时器服务的Context,计时器服务可以被用来查询当前的时间,注册计时器和删除计时器。利用这个方法就可以在设置一个1分钟的计时器,并将时间戳存储在timerState中。

if (transaction.getAmount() < SMALL_AMOUNT) {
    // set the flag to true
    flagState.update(true);

    // set the timer and timer state
    long timer = context.timerService().currentProcessingTime() + ONE_MINUTE;
    context.timerService().registerProcessingTimeTimer(timer);
    timerState.update(timer);
}

当计时器触发时,调用的是KeyedProcessFunction#onTimer. 重写此方法就如何实现回调用来重置标志。

public void onTimer(long timestamp, OnTimerContext ctx, Collector<Alert> out) {
    // remove flag after 1 minute
    timerState.clear();
    flagState.clear();
}

最后,如果要取消计时器,需要删除已注册的计时器并删除计时器状态。可以将其包装在helper方法中,并调用此方法,而不是直接调用 `flagState.clear()。

private void cleanUp(Context ctx) throws Exception {
    // delete timer
    Long timer = timerState.value();
    ctx.timerService().deleteProcessingTimeTimer(timer);

    // clean up all state
    timerState.clear();
    flagState.clear();
}

这就是一个完整的flink方法

Final Application 

import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;
import org.apache.flink.walkthrough.common.entity.Alert;
import org.apache.flink.walkthrough.common.entity.Transaction;

public class FraudDetector extends KeyedProcessFunction<Long, Transaction, Alert> {

    private static final long serialVersionUID = 1L;

    private static final double SMALL_AMOUNT = 1.00;
    private static final double LARGE_AMOUNT = 500.00;
    private static final long ONE_MINUTE = 60 * 1000;

    private transient ValueState<Boolean> flagState;
    private transient ValueState<Long> timerState;

    @Override
    public void open(Configuration parameters) {
        ValueStateDescriptor<Boolean> flagDescriptor = new ValueStateDescriptor<>(
                "flag",
                Types.BOOLEAN);
        flagState = getRuntimeContext().getState(flagDescriptor);

        ValueStateDescriptor<Long> timerDescriptor = new ValueStateDescriptor<>(
                "timer-state",
                Types.LONG);
        timerState = getRuntimeContext().getState(timerDescriptor);
    }

    @Override
    public void processElement(
            Transaction transaction,
            Context context,
            Collector<Alert> collector) throws Exception {

        // Get the current state for the current key
        Boolean lastTransactionWasSmall = flagState.value();

        // Check if the flag is set
        if (lastTransactionWasSmall != null) {
            if (transaction.getAmount() > LARGE_AMOUNT) {
                //Output an alert downstream
                Alert alert = new Alert();
                alert.setId(transaction.getAccountId());

                collector.collect(alert);
            }
            // Clean up our state
            cleanUp(context);
        }

        if (transaction.getAmount() < SMALL_AMOUNT) {
            // set the flag to true
            flagState.update(true);

            long timer = context.timerService().currentProcessingTime() + ONE_MINUTE;
            context.timerService().registerProcessingTimeTimer(timer);

            timerState.update(timer);
        }
    }

    @Override
    public void onTimer(long timestamp, OnTimerContext ctx, Collector<Alert> out) {
        // remove flag after 1 minute
        timerState.clear();
        flagState.clear();
    }

    private void cleanUp(Context ctx) throws Exception {
        // delete timer
        Long timer = timerState.value();
        ctx.timerService().deleteProcessingTimeTimer(timer);

        // clean up all state
        timerState.clear();
        flagState.clear();
    }
}

Expected Output 

调用TransactionSource代码就可以看到为了用户3所做出的欺诈警报,下面就是运行日志里面的输出日志:

2019-08-19 14:22:06,220 INFO  org.apache.flink.walkthrough.common.sink.AlertSink - Alert{id=3}
2019-08-19 14:22:11,383 INFO  org.apache.flink.walkthrough.common.sink.AlertSink - Alert{id=3}
2019-08-19 14:22:16,551 INFO  org.apache.flink.walkthrough.common.sink.AlertSink - Alert{id=3}
2019-08-19 14:22:21,723 INFO  org.apache.flink.walkthrough.common.sink.AlertSink - Alert{id=3}
2019-08-19 14:22:26,896 INFO  org.apache.flink.walkthrough.common.sink.AlertSink - Alert{id=3}