Flink-CEP

在实际应用中,有一类需求是要检测以特定顺序先后发生的一组事件,进行统计或作报警提示。

多个事件的组合我们将其称为复杂事件。对于多个复杂事件的处理,由于涉及到事件的严格顺序,有时还有时间约束,很难直接用SQL或者DataStream API来完成。可以用最强悍的工具——底层的处理函数,对于非常复杂的组合事件,可能需要设置很多状态、定时器,并在代码中定义各种条件分支逻辑来处理,复杂度很高,可能会使代码失去可读性。Flink提供了专门用于处理复杂事件的库——CEP,可以让我们更加轻松地解决这类棘手的问题

基本概念

CEP介绍

CEP是复杂事件处理(Complex Event Processing)的缩写。Flink CEP是Flink实现的一个用于复杂事件处理的库

CEP是针对流处理而言的,分析的是低延迟、频繁产生的事件流。主要目的,就是在无界流中检测出特定的数据组合,方便我们掌握数据中重要的高阶特征

复杂事件处理:在事件流里,检测到特定的事件组合并进行处理。具体的过程是,把事件流中的一个个简单事件,通过一定的规则匹配组合起来,然后基于这些满足规则的一组组复杂事件进行转换处理,得到想要的结果进行输出

复杂事件处理的流程可分为三个步骤:

  • 1、定义一个匹配规则
  • 2、将匹配规则应用到事件流上,检测满足规则的复杂事件
  • 3、对检测到的复杂事件进行处理,得到结果进行输出

输入:不同形状的事件流

匹配规则:在圆形后面紧跟着三角形

输出:针对检测到的复杂事件,处理之后输出一个提示或报警信息

模式

模式:CEP第一步所定义的匹配规则

模式的定义主要是两部分内容:

  • 每个简单事件的特征
  • 简单事件之间的组合关系

进一步扩展模式的功能,比如:匹配检测的时间限制;每个简单事件是否可以重复出现;对于事件可重复出现的模式,遇到一个匹配后是否可以跳过后面的匹配等

事件之间的组合关系,也就是事件发生的顺序,我们称之为近邻关系。

严格的近邻关系:两个事件之间不能有任何其他事件

宽松的近邻关系:相对顺序正确,中间可以由其他事件

另外,也可以进行反向定义,例如,谁后面不能跟谁

CEP在流上进行模式匹配,根据模式的近邻关系条件不同,可以检测连续的事件或不连续但先后发生的事件;模式还可以设定在时间范围内没有满足匹配条件,就会导致模式匹配超时(timeout)

应用场景

CEP主要用于实时流数据的分析处理,CEP可以帮助在复杂的、看似不相关的事件流中找出哪些有意义的事件组合,进而可以接近实时地进行分析判断、输出通知信息或报警,这在企业项目的风控管理、用户画像、运维监控中都有重要应用

  • 风险控制:设定一些行为模式,针对用户的异常行为进行实时检测。当一个用户行为符合了异常行为模式,比如短时间内频繁登录并失败、大量下单却不支付(刷单),就可以向用户发送通知信息,或是进行报警提示、由人工进一步判定用户是否有违规操作的嫌疑。这样就可以有效地控制用户个人和平台的风险
  • 用户画像:利用 CEP 可以用预先定义好的规则,对用户的行为轨迹进行实时跟踪,从而检测出具有特定行为习惯的一些用户,做出相应的用户画像。基于用户画像可以进行精准营销,即对行为匹配预定义规则的用户实时发送相应的营销推广
  • 运维监控:对于企业服务的运维管理,利用CEP灵活配置多指标、多依赖来实现更复杂的监控模式

CEP 的应用场景非常丰富。很多大数据框架,如 Spark、Samza、Beam 等都提供了不同的CEP 解决方案,但没有专门的库(library)。而 Flink 提供了专门的 CEP 库用于复杂事件处理,可以说是目前 CEP 的最佳解决方案

快速上手

引入依赖

在代码中使用Flink CEP,需要在项目的pom文件中添加相关依赖:

        <dependency>
            <groupId>org.apache.flink</groupId>
            <artifactId>flink-cep_${scala.binary.version}</artifactId>
            <version>${flink.version}</version>
        </dependency>

为了精简和避免依赖冲突,Flink会保持尽量少的核心依赖,核心依赖中不包括任何的连接器和库,库中友SQL、CEP、ML等等。若想要在Flink集群中提交运行CEP作业,将依赖的jar包放在/lib目录下

简单实例

例子:检测用户行为,如果连续三次登录失败,就输出报警信息

定义一个登录事件POJO类

public class LoginEvent {

    public String userId;
    public String ipAddress;
    public String eventType;
    public Long timestamp;

    public LoginEvent() {
    }

    public LoginEvent(String userId, String ipAddress, String eventType, Long timestamp) {
        this.userId = userId;
        this.ipAddress = ipAddress;
        this.eventType = eventType;
        this.timestamp = timestamp;
    }

    @Override
    public String toString() {
        return "LoginEvent{" +
                "userId='" + userId + '\'' +
                ", ipAddress='" + ipAddress + '\'' +
                ", eventType='" + eventType + '\'' +
                ", timestamp=" + timestamp +
                '}';
    }
}

业务逻辑的编写。Flink CEP在代码中主要通过Pattern API来实现,分三步:

  • 1、定义一个模式
  • 2、将Pattern应用到DataStream上,检测满足规则的复杂事件,得到一个PatternStream
  • 3、将PatternStream进行转换处理,将检测到的复杂事件提取出来,包装成报警信息输出
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.cep.CEP;
import org.apache.flink.cep.PatternSelectFunction;
import org.apache.flink.cep.PatternStream;
import org.apache.flink.cep.pattern.Pattern;
import org.apache.flink.cep.pattern.conditions.SimpleCondition;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

import java.util.List;
import java.util.Map;

public class LoginFailDetectExample {

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

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        //1、获取登录事件流,并提取时间戳、生成水位线
        KeyedStream<LoginEvent, String> loginEventstream = env.fromElements(
                new LoginEvent("user_1", "192.168.0.1", "fail", 2000L),
                new LoginEvent("user_1", "192.168.0.2", "fail", 3000L),
                new LoginEvent("user_2", "192.168.1.29", "fail", 4000L),
                new LoginEvent("user_1", "171.56.23.10", "fail", 5000L),
                new LoginEvent("user_2", "192.168.1.29", "fail", 7000L),
                new LoginEvent("user_2", "192.168.1.29", "fail", 8000L),
                new LoginEvent("user_2", "192.168.1.29", "success", 6000L)
        ).assignTimestampsAndWatermarks(WatermarkStrategy.<LoginEvent>forMonotonousTimestamps()
                .withTimestampAssigner(
                        new SerializableTimestampAssigner<LoginEvent>() {
                            @Override
                            public long extractTimestamp(LoginEvent loginEvent, long l) {
                                return loginEvent.timestamp;
                            }
                        }
                )
        ).keyBy(r -> r.userId);

        //2、定义模式,连续三次登录失败
        Pattern<LoginEvent, LoginEvent> pattern = Pattern.<LoginEvent>begin("first") // 第一次登录失败事件
                .where(new SimpleCondition<LoginEvent>() {
                    @Override
                    public boolean filter(LoginEvent value) throws Exception {
                        return value.eventType.equals("fail");
                    }
                })
                .next("second") //紧跟着第二次登录失败事件
                .where(new SimpleCondition<LoginEvent>() {
                    @Override
                    public boolean filter(LoginEvent value) throws Exception {
                        return value.eventType.equals("fail");
                    }
                })
                .next("third") //紧跟着第三次登录失败事件
                .where(new SimpleCondition<LoginEvent>() {
                    @Override
                    public boolean filter(LoginEvent value) throws Exception {
                        return value.eventType.equals("fail");
                    }
                });

        //3.将模式应用到数据流上,检测复杂事件
        PatternStream<LoginEvent> patternStream = CEP.pattern(loginEventstream.keyBy(event -> event.userId), pattern);


        //4.将检测到的复杂事件提取出来,进行处理得到报警信息输出
        SingleOutputStreamOperator<String> warningStream = patternStream.select(new PatternSelectFunction<LoginEvent, String>() {
            @Override
            public String select(Map<String, List<LoginEvent>> map) throws Exception {
                // 提取复杂事件中的三次登录失败事件
                LoginEvent firstFailEvent = map.get("first").get(0);
                LoginEvent secondFailEvent = map.get("second").get(0);
                LoginEvent thirdFailEvent = map.get("third").get(0);

                return firstFailEvent.userId + " 连续三次登录失败! 登录时间 " +
                        firstFailEvent.timestamp + ", " +
                        secondFailEvent.timestamp + ", " +
                        thirdFailEvent.timestamp;
            }
        });

        //打印输出
        warningStream.print();

        env.execute();
    }
}

模式API

Flink CEP的核心是复杂事件的模式匹配。Flink CEP库中提供了Pattern类,基于它可以调用一系列方法来定义匹配模式,这就是所谓模式API

个体模式

基于流中事件的匹配是有先后顺序的,一个匹配规则可以表达成先后发生的一个个简单事件,按顺序串联组合在一起。这里的每一个简单事件都有一定的条件规则,称为个体模式

每个个体模式都是以连接词开始定义的,比如begin、next等,这些是Pattern对象的一个方法,返回还是Pattern对象。连接词方法有一个String类型参数,这时当前个体模式唯一的名字,在之后检测到匹配事件时,以这个名字指代匹配事件

个体模式需要一个过滤条件,用来指定具体的匹配规则,这个条件一般是通过调用where()方法实现,具体的过滤逻辑则是通过传入SimpleCondition内的filter()方法定义

另外,个体模式可以匹配接收一个或多个事件。给个体模式增加一个量词就可以让其循环匹配,接收多个事件

量词

个体模式后面可以跟一个量词,用来指定循环的次数。在这个角度分类,个体模式可分为单例模式、循环模式

  • 单例模式:匹配接收一个事件
  • 循环模式:匹配接收多个事件

在Flink CEP中,可以使用不同的方法指定循环模式,主要有:

  • oneOrMore():匹配事件出现一次或多次。假如a是一个个体模式,a.oneOrMore()表示可以匹配1个或多个a的事件组合
  • times(times):匹配事件发生特定次数(times),例如a.times(3)表示aaa
  • times(fromTimes,toTimes):指定匹配事件出现的次数范围,最小次数为fromTimes,最大次数为toTimes
  • greedy():只能用在循环模式后,使当前循环模式变得贪心。尽可能多的去匹配
  • optional():当前模式为可选,可以满足这个匹配条件,也可以不满足

对于一个个体模式pattern来说,后面所有可以添加的量词如下:

// 匹配事件出现 4 次
pattern.times(4);
// 匹配事件出现 4 次,或者不出现
pattern.times(4).optional();
// 匹配事件出现 2, 3 或者 4 次
pattern.times(2, 4);
// 匹配事件出现 2, 3 或者 4 次,并且尽可能多地匹配
pattern.times(2, 4).greedy();
// 匹配事件出现 2, 3, 4 次,或者不出现
pattern.times(2, 4).optional();
// 匹配事件出现 2, 3, 4 次,或者不出现;并且尽可能多地匹配
pattern.times(2, 4).optional().greedy();
// 匹配事件出现 1 次或多次
pattern.oneOrMore();
// 匹配事件出现 1 次或多次,并且尽可能多地匹配
pattern.oneOrMore().greedy();
// 匹配事件出现 1 次或多次,或者不出现
pattern.oneOrMore().optional();
// 匹配事件出现 1 次或多次,或者不出现;并且尽可能多地匹配
pattern.oneOrMore().optional().greedy();
// 匹配事件出现 2 次或多次
pattern.timesOrMore(2);
// 匹配事件出现 2 次或多次,并且尽可能多地匹配
pattern.timesOrMore(2).greedy();
// 匹配事件出现 2 次或多次,或者不出现
pattern.timesOrMore(2).optional()
// 匹配事件出现 2 次或多次,或者不出现;并且尽可能多地匹配
pattern.timesOrMore(2).optional().greedy();

条件

对于每个个体模式,匹配事件的核心在于定义匹配条件,选取事件的规则,Flink CEP会按照这个规则对流中的事件进行筛选,判断是否接受当前事件

主要通过调用Pattern调用的where()实现,主要可以分为简单条件、迭代条件、复合条件、终止条件几种类型。此外,也可以调用Pattern对象的subtype()方法来限定匹配事件的子类型

  • 限定子类型:调用subtype()方法wield当前模式增加子类型限制条件

    pattern.subtype(SubEvent.class);
    
  • 简单条件:最简单的匹配规则,根据当前的事件特征来决定是否接受它,本质上是一个filter操作

    pattern.where(new SimpleCondition<Event>() {
     	@Override
         public boolean filter(Event value) {
            return value.user.startsWith("A");//要求匹配事件的属性以“A”开头
         }
    });
    
  • 迭代条件:依靠之前事件作判断

    middle.oneOrMore()
         .where(new IterativeCondition<Event>() {
         @Override
         public boolean filter(Event value, Context<Event> ctx) throws Exception {
             // 事件中的 user 必须以 A 开头
             if (!value.user.startsWith("A")) {
                 return false;
             }
    
         int sum = value.amount;
         // 获取当前模式之前已经匹配的事件,求所有事件 amount 之和
         for (Event event : ctx.getEventsForPattern("middle")) {
            sum += event.amount;
         }
         // 在总数量小于 100 时,当前事件满足匹配规则,可以匹配成功
         return sum < 100;
         }
         });
    
  • 组合条件:独立定义多个条件,在外部把它们连接起来,构成一个组合条件。逻辑与where().where,逻辑或where().or()。子类型限定条件也可以和其他条件结合,成为组合条件

    pattern.subtype(SubEvent.class)
        .where(new SimpleCondition<SubEvent>() {
             @Override
             public boolean filter(SubEvent value) {
                 return ... // some condition
             }
    	});
    
  • 终止条件:遇到某个特定事件时当前模型不再继续循环匹配了,调用模式对象的until()。终止条件只与oneOrMore()或者oneOrMore().optional()结合使用

组合模式

多个个体模式组合起来的完整模式,叫做组合模式,也称序列模式

Pattern<Event, ?> pattern = Pattern
     .<Event>begin("start").where(...)
     .next("next").where(...)
     .followedBy("follow").where(...)
     ...

1、初始模式(Initial Pattern)

组合模式都必须以初始模式开头,初始模式必须调用Pattern的静态方法begin()来创建

Pattern<Event, ?> start = Pattern.<Event>begin("start");

Pattern有两个泛型参数,第一个是检测事件的基本类型Event,第二个是当前模式例事件的子类,由于子类型限制条件指定,这里用类型通配符(?)替代

2、近邻条件(Contiguity Conditions)

指明先后事件之间的近邻关系,Flink CEP中提供了三种近邻关系:

  • 严格近邻(Strict Contiguity):匹配的事件严格按顺序出现,中间不会有任何其他事件,next()
  • 宽松近邻(Relaxed Contiguity):两个匹配事件的相对顺序,默认采用,followedBy()
  • 非确定性宽松近邻(Non-Deterministic Relaxed Contiguity):可以重复使用之前已经匹配过的事件;以同一个事件作为开始匹配,followedByAny()

3、其他限制条件

我们还可以用否定的连接词来组合个体模式

  • notNext():事件后面不能紧跟某事件
  • notFollowedBy():两个事件中间不会出现某种事件

Flink CEP还可以为模式指定一个时间限制,调用within()方法传入时间参数,模式序列中第一个事件到最后一个事件之间的最大时间间隔。一个模式序列中只能有一个时间限制,调用within()位置不限,多次调用取最小时间间隔

// 严格近邻条件
Pattern<Event, ?> strict = start.next("middle").where(...);
// 宽松近邻条件
Pattern<Event, ?> relaxed = start.followedBy("middle").where(...);
// 非确定性宽松近邻条件
Pattern<Event, ?> nonDetermin = start.followedByAny("middle").where(...);
// 不能严格近邻条件
Pattern<Event, ?> strictNot = start.notNext("not").where(...);
// 不能宽松近邻条件
Pattern<Event, ?> relaxedNot = start.notFollowedBy("not").where(...);
// 时间限制条件
middle.within(Time.seconds(10));

4、循环模式中的近邻条件

  • consecutive():为循环模式中的匹配事件增加严格的近邻条件,保证所有匹配事件是严格连续的

    // 1. 定义 Pattern,登录失败事件,循环检测 3 次
    Pattern<LoginEvent, LoginEvent> pattern = Pattern
         .<LoginEvent>begin("fails")
         .where(new SimpleCondition<LoginEvent>() {
             @Override
             public boolean filter(LoginEvent loginEvent) throws Exception {
             	return loginEvent.eventType.equals("fail");
             }
         }).times(3).consecutive();
    
  • allowCombinations():非确定性宽松近邻条件,表示可以重复使用已经匹配的事件,调用allowCombinations(),效果与followedByAny()相同

模式组

在一些非常复杂的场景中,可能需要划分多个阶段,每个阶段又有一连串的匹配规则,为了应对这样的需求,Flink CEP 允许我们以嵌套的方式来定义模式

在模式组中,每一个模式序列被当做某一阶段的匹配条件,返回的类型是一个GroupPatter。GroupPattern是Pattern的子类

// 以模式序列作为初始模式
Pattern<Event, ?> start = Pattern.begin(
    Pattern.<Event>begin("start_start").where(...)
    	.followedBy("start_middle").where(...)
);
// 在 start 后定义严格近邻的模式序列,并重复匹配两次
Pattern<Event, ?> strict = start.next(
    Pattern.<Event>begin("next_start").where(...)
    	.followedBy("next_middle").where(...)
).times(2);
// 在 start 后定义宽松近邻的模式序列,并重复匹配一次或多次
Pattern<Event, ?> relaxed = start.followedBy(
    Pattern.<Event>begin("followedby_start").where(...)
    	.followedBy("followedby_middle").where(...)
).oneOrMore();
//在 start 后定义非确定性宽松近邻的模式序列,可以匹配一次,也可以不匹配
Pattern<Event, ?> nonDeterminRelaxed = start.followedByAny(
    Pattern.<Event>begin("followedbyany_start").where(...)
    	.followedBy("followedbyany_middle").where(...)
).optional();
匹配后跳过策略

匹配后跳过策略(After Match Skip Strategy),专门用来精准控制循环模式的匹配结果,该策略在Pattern的初始模式定义中,作为begin()的第二个参数传入

Pattern.begin("start", AfterMatchSkipStrategy.noSkip())
    .where(...)
    ...

下面举例来说明不同的跳过策略,例如:开始是事件a,可以重复一次或多次,后面跟着一个事件b,a和b直接可以有其他事件,简写形式为a+followedBy b

Pattern.<Event>begin("a").where(new SimpleCondition<Event>() {
     @Override
     public boolean filter(Event value) throws Exception {
         return value.user.equals("a");
 }
}).oneOrMore()
.followedBy("b").where(new SimpleCondition<Event>() {
     @Override
     public boolean filter(Event value) throws Exception {
         return value.user.equals("b");
     }
});

输入事件序列:a a a b,区分不同a事件记为 a1 a2 a3 b,检测匹配6个结果:(a1 a2 a3 b),(a1 a2 b),(a1 b),(a2 a3 b),(a2 b),(a3 b)

初始模式的量词.oneOrMore()后加上.greedy()定义为贪心匹配,那么结果就是:(a1 a2 a3 b)、(a2 a3 b)、(a3 b)

讨论不同跳过策略对匹配结果的影响:

  • 不跳过NO_SKIP:AfterMatchSkipStrategy.noSkip(),默认策略。所有可能得匹配都会输出,也就是完整的6个匹配
  • 跳至下一个SKIP_TO_NEXT:AfterMatchSkipStrategy.skipToNext(),找到a1开始的最大匹配,直接开始a2的最大匹配,依次类推。结果为(a1 a2 a3 b)、(a2 a3 b)、(a3 b)
  • 跳过所有子匹配SKIP_PAST_LAST_EVENT:找到a1开始的匹配(a1 a2 a3 b),直接跳过所有a1至a3开头的匹配,最终得到(a1 a2 a3 b),最为精简的跳过策略
  • 跳至第一个SKIP_TO_FIRST[A]:AfterMatchSkipStrategy.skipToFirst(“a”),得到以a1为开始的匹配,最终得到(a1 a2 a3 b)、(a1 a2 b)、(a1 b)
  • 跳至最后一个SKIP_TO_LAST[a]:AfterMatchSkipStrategy.skipToLast(“a”),得到以a3为结束的匹配,最终得到(a1 a2 a3 b)、(a2 a3 b)、(a3 b)

模式的检测处理

将模式应用到事件流上、检测提取匹配的复杂事件并定义处理转换的方法,最终得到想要的输出信息

将模式应用到流上
DataStream<Event> inputStream = ...
Pattern<Event, ?> pattern = ...
    
PatternStream<Event> patternStream = CEP.pattern(inputStream, pattern);d

DataStream也可以通过keyBy进行按键分区得到KeyedSream,接下来对于复杂事件的检测就会针对不同的key单独进行

模式中定义的复杂事件,发送有先后顺序。默认采用事件时间语义,按各自的时间戳;处理时间语义,数据到达顺序。若时间戳相同或同时到达的事件,可传入一个比较器作为第三个参数来排序

// 可选的事件比较器
EventComparator<Event> comparator = ...
PatternStream<Event> patternStream = CEP.pattern(input, pattern, comparator);
处理匹配事件

PatternStream的转换操作主要可以分成两种:简单便捷的选择提取(select)、更加通用强大的处理(process),具体实现是在调用API时传入一个函数类。

1、匹配事件的选择提取

基于PatternStream直接调用select()方法,传入一个PatternSelectFunction参数

PatternStream<Event> patternStream = CEP.pattern(inputStream, pattern);
DataStream<String> result = patternStream.select(new MyPatternSelectFunction());

这里的事件名称就对应着模式中定义的每个个体模式的名称,个体可以是循环模式,一个名称会对应多个事件,最终会保存在Map里的value就是一个事件的列表(List)

class MyPatternSelectFunction implements PatternSelectFunction<Event, String>{
     @Override
     public String select(Map<String, List<Event>> pattern) throws Exception {
         Event startEvent = pattern.get("start").get(0);
         Event middleEvent = pattern.get("middle").get(0);
         return startEvent.toString() + " " + middleEvent.toString();
     }
}

基于PatternFlatSelectFunction,扁平化版本,通过收集器Collertor,调用collect()方法就可以实现多次发送输出数据

patternStream.flatSelect(new PatternFlatSelectFunction<LoginEvent, String>() {
    @Override
    public void flatSelect(Map<String, List<LoginEvent>> map,
        Collector<String> out) throws Exception {
        LoginEvent first = map.get("fails").get(0);
        LoginEvent second = map.get("fails").get(1);
        LoginEvent third = map.get("fails").get(2);
        out.collect(first.userId + " 连续三次登录失败!登录时间:" + first.timestamp +
        ", " + second.timestamp + ", " + third.timestamp);
}
}).print("warning");

2、匹配事件的通用处理

自1.8版本之后,引入了匹配事件的通用检测处理方式,直接调用PatternStream的process()方法,传入PatternProcessFunction,该功能丰富、调用灵活。PatternSelectFunction 和 PatternFlatSelectFunction在CEP内部执行都会被转为PatternProcessFunction

// 3. 将匹配到的复杂事件选择出来,然后包装成报警信息输出
patternStream.process(new PatternProcessFunction<LoginEvent, String>() {
    @Override
    public void processMatch(Map<String, List<LoginEvent>> map, Context ctx,
        Collector<String> out) throws Exception {
        LoginEvent first = map.get("fails").get(0);
        LoginEvent second = map.get("fails").get(1);
        LoginEvent third = map.get("fails").get(2);
        out.collect(first.userId + " 连续三次登录失败!登录时间:" + first.timestamp +
        ", " + second.timestamp + ", " + third.timestamp);
}
}).print("warning");
处理超时事件

复杂事件的检测结果一般只有两种:要么匹配,要么不匹配。检测处理的过程具体如下:

  • (1)当前事件复合模式匹配的条件,接受该事件,保存到对应的Map中
  • (2)在模式序列定义中,当前事件后面还应该有其他事件,就继续读取事件流进行检测;如果模式序列的定义已经全部满足,那么就成功检测到了一组匹配的复杂事件,调用PatternProcessFunction 的 processMatch()方法进行处理
  • (3)当前事件不符合模式匹配的条件,丢弃该事件
  • (4)当前事件破坏模式序列中定义的限制条件,如不满足严格近邻要求,那么当前已检测的一组部分匹配事件丢弃,重新开始检查

在有时间限制的情况下,用within()指定了模式检测的时间间隔,超出这个时间当前这组检测就应该失败,然而这种超时失败跟真正的匹配失败不同,它是一种部分成功匹配。只有在开头能够正常匹配的前提下,没有等到后续的匹配事件才会超时,故往往不应该丢弃,而使需要输出一个提示或报警信息。这要求我们有能力捕获并处理超时事件

1、使用 PatternProcessFunction 的侧输出流

TimedOutPartialMatchHandler接口实现processTimedOutMatch()方法,将超时的、已检测到的部分匹配事件放在一个Map中,作为方法的第一个参数,方法的第二个参数为Context

class MyPatternProcessFunction extends PatternProcessFunction<Event, String>
    implements TimedOutPartialMatchHandler<Event> {
    // 正常匹配事件的处理
    @Override
     public void processMatch(Map<String, List<Event>> match, Context ctx,
    	Collector<String> out) throws Exception{
     	...
     }
 // 超时部分匹配事件的处理
 @Override
 public void processTimedOutMatch(Map<String, List<Event>> match, Context ctx) throws Exception{
     Event startEvent = match.get("start").get(0);
     OutputTag<Event> outputTag = new OutputTag<Event>("time-out"){};
     ctx.output(outputTag, startEvent);
  }
}

在 processTimedOutMatch()方法中定义了一个输出标签(OutputTag),调用 ctx.output() 方法,就可以将超时的部分匹配事件输出到标签所标识的侧输出流了

2、使用PatternTimeoutFunction

PatternTimeoutFunction是早期版本中用于捕获超时事件的接口。需要实现一个timeout()方法,会将部分匹配的事件放在一个Map中作为参数传入,此外还有一个参数是当前的时间戳。提取部分匹配事件进行处理转换后,可以将通知或报警信息输出

调用select()方法后会得到唯一的DataStream,正常匹配事件的处理结果会进入转换后得到的DataStream,超时事件的处理结果则会进入侧输出流,侧输出流需要另外传如一个侧输出标签来指定

PatternStream的select()方法需要传入三个参数:侧输出标签、超时事件处理函数、匹配事件提取函数

// 定义一个侧输出流标签,用于标识超时侧输出流
OutputTag<String> timeoutTag = new OutputTag<String>("timeout"){};
// 将匹配到的,和超时部分匹配的复杂事件提取出来,然后包装成提示信息输出
SingleOutputStreamOperator<String> resultStream = patternStream 
    .select(timeoutTag,
    // 超时部分匹配事件的处理
     new PatternTimeoutFunction<Event, String>() {
     @Override
 	 public String timeout(Map<String, List<Event>> pattern, long timeoutTimestamp) throws Exception {
         Event event = pattern.get("start").get(0);
         return "超时:" + event.toString();
         }
 	},
// 正常匹配事件的处理
 new PatternSelectFunction<Event, String>() {
 @Override
 public String select(Map<String, List<Event>> pattern) throws Exception
    {
    ...
     }
 }
);
// 将正常匹配和超时部分匹配的处理结果流打印输出
resultStream.print("matched");
resultStream.getSideOutput(timeoutTag).print("timeout");

在超时事件处理的过程中,Map里只能取到已经检测到匹配的哪些事件,如果取可能未匹配的事件并调用它的对象方法,则可能会报空指针异常

3、应用实例

在电商平台中,最终创造收入和利润的是用户下单购买的环节。用户下单的行为可以表明用户对商品的需求,但在现实中,并不是每次下单都会被用户立刻支付。当拖延一段时间后,用户支付的意愿会降低。所以为了让用户更有紧迫感从而提高支付转化率,同时也为了防范订单支付环节的安全风险,电商网站往往会对订单状态进行监控,设置一个失效时间(比如 15 分钟),如果下单后一段时间仍未支付,订单就会被取消

定义POJO类OrderEvent

public class OrderEvent {
    public String userId;
    public String orderId;
    public String eventType;
    public Long timestamp;

    public OrderEvent() {
    }

    public OrderEvent(String userId, String orderId, String eventType, Long timestamp) {
        this.userId = userId;
        this.orderId = orderId;
        this.eventType = eventType;
        this.timestamp = timestamp;
    }

    @Override
    public String toString() {
        return "OrderEvent{" +
                "userId='" + userId + '\'' +
                ", orderId='" + orderId + '\'' +
                ", eventType='" + eventType + '\'' +
                ", timestamp=" + timestamp +
                '}';
    }
}
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.cep.CEP;
import org.apache.flink.cep.PatternStream;
import org.apache.flink.cep.functions.PatternProcessFunction;
import org.apache.flink.cep.functions.TimedOutPartialMatchHandler;
import org.apache.flink.cep.pattern.Pattern;
import org.apache.flink.cep.pattern.conditions.SimpleCondition;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;
import org.apache.flink.util.OutputTag;

import java.time.Duration;
import java.util.List;
import java.util.Map;

public class OrderTimeoutDetectExample {

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

        //  1.获取数据流
        SingleOutputStreamOperator<OrderEvent> orderEventStream = env.fromElements(
                new OrderEvent("user_1", "order_1", "create", 1000L),
                new OrderEvent("user_2", "order_2", "create", 2000L),
                new OrderEvent("user_1", "order_1", "modify", 10 * 1000L),
                new OrderEvent("user_1", "order_1", "pay", 60 * 1000L),
                new OrderEvent("user_2", "order_3", "create", 10 * 60 * 1000L),
                new OrderEvent("user_2", "order_3", "pay", 20 * 60 * 1000L)
        ).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ZERO)
                .withTimestampAssigner(new SerializableTimestampAssigner<OrderEvent>() {
                    @Override
                    public long extractTimestamp(OrderEvent element, long recordTimestamp) {
                        return element.timestamp;
                    }
                }));

        //  2.定义模式
        Pattern<OrderEvent, OrderEvent> pattern = Pattern.<OrderEvent>begin("create")
                .where(new SimpleCondition<OrderEvent>() {
                    @Override
                    public boolean filter(OrderEvent value) throws Exception {
                        return value.eventType.equals("create");
                    }
                })
                .followedBy("pay")
                .where(new SimpleCondition<OrderEvent>() {
                    @Override
                    public boolean filter(OrderEvent value) throws Exception {
                        return value.eventType.equals("pay");
                    }
                })
                .within(Time.minutes(15));

        //  3.将模式应用到订单数据流上
        PatternStream<OrderEvent> patternStream = CEP.pattern(orderEventStream.keyBy(event -> event.orderId), pattern);

        //  4.定义一个侧输出流标签
        OutputTag<String> timeoutTag = new OutputTag<String>("timeout") {
        };

        //  5.将完全匹配和超时部分匹配的复杂事件提取出来,进行处理
        SingleOutputStreamOperator<String> result = patternStream.process(new OrderPayMatch());

        //  打印输出
        result.print("payed:");
        result.getSideOutput(timeoutTag).print("timeout:");

        env.execute();
    }

    //  自定义PatternProcessFunction
    public static class OrderPayMatch extends PatternProcessFunction<OrderEvent, String> implements TimedOutPartialMatchHandler<OrderEvent> {
        @Override
        public void processMatch(Map<String, List<OrderEvent>> map, Context context, Collector<String> collector) throws Exception {
            //获取当前的支付事件
            OrderEvent payEvent = map.get("pay").get(0);
            collector.collect("用户" + payEvent.userId + " 订单 " + payEvent.orderId + " 已支付");
        }

        @Override
        public void processTimedOutMatch(Map<String, List<OrderEvent>> map, Context context) throws Exception {
            OrderEvent createEvent = map.get("create").get(0);
            OutputTag<String> timeoutTag = new OutputTag<String>("timeout") {
            };
            context.output(timeoutTag, "用户" + createEvent.userId + " 订单 " + createEvent.orderId + " 超时未支付");
        }
    }
}
处理迟到的数据

Flink CEP沿用了通过设置水位线延迟来处理乱序数据的做法。当一个事件到来时,并不会立即做检测匹配处理,而是先放入一个缓冲区(buffer)。缓冲区内的数据会按照时间戳由小到大排序;当一个水位线到来时,就会将缓冲区中所有时间戳小于水位线的事件依次取出,进行检测匹配。这样就保证了匹配事件的顺序和事件时间的进展一致,处理的 顺序就一定是正确的。这里水位线的延迟时间,也就是事件在缓冲区等待的最大时间

水位线延迟时间不可能保证将所有乱序数据完美包括进来,总 会有一些事件延迟比较大,以至于等它到来的时候水位线早已超过了它的时间戳。借鉴窗口的做法,Flink CEP 同样提供了将迟到事件输出到侧输出流的方式: 我们可以基于 PatternStream 直接调 用.sideOutputLateData()方法,传入一个 OutputTag,将迟到数据放入侧输出流另行处理

PatternStream<Event> patternStream = CEP.pattern(input, pattern);
// 定义一个侧输出流的标签
OutputTag<String> lateDataOutputTag = new OutputTag<String>("late-data"){};
    SingleOutputStreamOperator<ComplexEvent> result = patternStream
     .sideOutputLateData(lateDataOutputTag) // 将迟到数据输出到侧输出流
     .select(
	// 处理正常匹配数据
     new PatternSelectFunction<Event, ComplexEvent>() {...}
     );
	// 从结果中提取侧输出流
DataStream<String> lateData = result.getSideOutput(lateDataOutputTag);

CEP的状态机实现

通过分析CEP的检测处理流程,可以认为检测匹配事件的过程中会有初始(没有任何匹配)、检查中(部分匹配成功)、匹配成功、匹配失败等不同的状态。随着每个事件的到来,都会改变当前检测的状态;而这种改变跟当前事件的特性有关、也跟当前所处的状态有关。这样的系统其实就是一个状态机,这也是正则表达式底层引擎的实现原理

Flink CEP的底层工作原理与正则表达式是一致的,是一个非确定有限状态自动机(Nondeterministic Finite Automaton,NFA)

import org.apache.flink.api.common.functions.RichFlatMapFunction;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.util.Collector;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

import java.io.Serializable;

import static org.apache.flink.util.Preconditions.checkNotNull;

public class NFAExample {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        // 获取登录事件流,这里与时间无关,就不生成水位线了
        KeyedStream<LoginEvent, String> stream = env.fromElements(
                new LoginEvent("user_1", "192.168.0.1", "fail", 2000L),
                new LoginEvent("user_1", "192.168.0.2", "fail", 3000L),
                new LoginEvent("user_2", "192.168.1.29", "fail", 4000L),
                new LoginEvent("user_1", "171.56.23.10", "fail", 5000L),
                new LoginEvent("user_2", "192.168.1.29", "success", 6000L),
                new LoginEvent("user_2", "192.168.1.29", "fail", 7000L),
                new LoginEvent("user_2", "192.168.1.29", "fail", 8000L)
        ).keyBy(r -> r.userId);
        // 将数据依次输入状态机进行处理
        DataStream<String> alertStream = stream.flatMap(new StateMachineMapper());
        alertStream.print("warning");
        env.execute();
    }

    @SuppressWarnings("serial")
    public static class StateMachineMapper extends RichFlatMapFunction<LoginEvent, String> {
        // 声明当前用户对应的状态
        private ValueState<State> currentState;

        @Override
        public void open(Configuration conf) {
            // 获取状态对象
            currentState = getRuntimeContext().getState(new ValueStateDescriptor<>("state", State.class));
        }

        @Override
        public void flatMap(LoginEvent event, Collector<String> out) throws Exception {
            // 获取状态,如果状态为空,置为初始状态
            State state = currentState.value();
            if (state == null) {
                state = State.Initial;
            }
            // 基于当前状态,输入当前事件时跳转到下一状态
            State nextState = state.transition(event.eventType);
            if (nextState == State.Matched) {
                // 如果检测到匹配的复杂事件,输出报警信息
                out.collect(event.userId + " 连续三次登录失败");
                // 需要跳转回 S2 状态,这里直接不更新状态就可以了
            } else if (nextState == State.Terminal) {
                // 如果到了终止状态,就重置状态,准备重新开始
                currentState.update(State.Initial);
            } else {
                // 如果还没结束,更新状态(状态跳转),继续读取事件
                currentState.update(nextState);
            }
        }
    }

    // 状态机实现
    public enum State {
        Terminal, // 匹配失败,当前匹配终止
        Matched, // 匹配成功
        // S2 状态
        S2(new Transition("fail", Matched), new Transition("success", Terminal)),
        // S1 状态
        S1(new Transition("fail", S2), new Transition("success", Terminal)),
        // 初始状态
        Initial(new Transition("fail", S1), new Transition("success", Terminal));
        private final Transition[] transitions; // 状态转移规则

        // 状态的构造方法,可以传入一组状态转移规则来定义状态
        State(Transition... transitions) {
            this.transitions = transitions;
        }

        // 状态的转移方法,根据当前输入事件类型,从定义好的转移规则中找到下一个状态
        public State transition(String eventType) {
            for (Transition t : transitions) {
                if (t.getEventType().equals(eventType)) {
                    return t.getTargetState();
                }
            }
            // 如果没有找到转移规则,说明已经结束,回到初始状态
            return Initial;
        }
    }

    // 定义状态转移类,包括两个属性:当前事件类型和目标状态
    public static class Transition implements Serializable {
        private static final long serialVersionUID = 1L;
        // 触发状态转移的当前事件类型
        private final String eventType;
        // 转移的目标状态
        private final NFAExample.State targetState;

        public Transition(String eventType, NFAExample.State targetState) {
            this.eventType = checkNotNull(eventType);
            this.targetState = checkNotNull(targetState);
        }

        public String getEventType() {
            return eventType;
        }

        public NFAExample.State getTargetState() {
            return targetState;
        }
    }

}

总结

Flink CEP 是 Flink 对复杂事件处理提供的强大而高效的应用库,在大数据分析方面,应用场景主要可以分为统计分析和逻辑分析。企业的报表统计、商业决策都离不开统计分析,这部分需求在目前企业的分析指标中占了很大的比重,实时的流数据统计可以通过 Flink SQL 方便地实现;而逻辑分析可以进一步细分为风险控制、数据挖掘、用户画像、精准推荐等各个应用场景,如今对实时性要求也越来越高,Flink CEP 就可以作为对流数据进行逻辑分析、进行实时风控和推荐的有力工具

DataStream API和处理函数是Flink应用的基石,而SQL和CEP就是Flink大厦顶层扩展的两大工具。Flink SQL也提供了与SQL相结合的模式识别语句——MATCH_RECOGNIZE,可以支持在SQL语句中进行复杂事件处理

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值