【Flink】CEP

298 阅读9分钟

「这是我参与2022首次更文挑战的第26天,活动详情查看:2022首次更文挑战

一、概述

在实际生产中,随着数据的实时性要求越来越高,实时数据的量也在不断膨胀,在某些业务场景中需要根据连续的实时数据,发现其中有价值的那些事件

CEPComplex Event Processing)复杂事件处理, Flink CEP 是在 Flink 中实现的复杂时间处理(CEP)库。

处理事件的规则, 被叫做“模式”(Pattern), Flink CEP 提供了 Pattern API, 用于对输入流数据进行复杂事件规则定义, 用来提取符合规则的事件序列。

CEP 一篇论文“Efficient Pattern Matching over Event Streams”。

Flink CEP 应用场景: CEP 在互联网各个行业都有应用, 例如金融、物流、电商、智能交通、物联网行业等行业:

  • 实时监控:

    1. 在网站的访问日志中寻找那些使用脚本或者工具“爆破”登录的用户;
    2. 在大量的订单交易中发现那些虚假交易(超时未支付)或发现交易活跃用户;
    3. 在快递运输中发现那些滞留很久没有签收的包裹等。
  • 风险控制:

    比如金融行业可以用来进行风险控制和欺诈识别, 从交易信息中寻找那些可能存在的危险交易和非法交易。

  • 营销广告:

    跟踪用户的实时行为, 指定对应的推广策略进行推送, 提高广告的转化率。

(1)NFA : 非确定有限自动机

Flink CEP 在运行时会将用户的逻辑转化成这样的一个 NFA Graph (nfa对象)。

所以有限状态机的工作过程, 就是从开始状态,根据不同的输入, 自动进行状态转换的过程。

举个栗子,检测二进制数是否含有偶数个 0 ,如下图所示:

2021-04-2413-17-00.png

从图上可以看出, 输入只有 1 和 0 两种。

S1 状态开始, 只有输入 0 才会转换到 S2 状态, 同样 S2 状态下只有输入 0 才会转换到 S1。所以, 二进制数输入完毕, 如果满足最终状态, 也就是最后停在 S1 状态, 那么输入的二进制数就含有偶数个 0。

(2)Pattern API

Pattern API 大致分为三种: 基本模式组合模式,、模式分组

1)基本模式

基本模式(Individual Pattern)分为两类:单例模式(Singleton)和 循环模式(Looping)

  • 单例模式(Singleton):只读入单个事件

    单例模式可以通过量词转化成循环模式,如 timesoneOrMore

  • 循环模式(Looping):读入多个事件

  1. 量词 API
API说明案例
times()模式发生次数pattern.times(2, 4): 模式发生 2、3、4次
timesOrMoreoneOrMore模式发生大于等于 Npattern.timesOrMore(): 模式发生大于等于 2次
optional()模式可以不匹配pattern.times(2).optional(): 模式发生2次或者0次
greedy()模式发生越多越好pattern.times(2).greedy(): 模式发生2次且重复次数越来越多
  1. 条件 API
API说明案例
where()模式的条件pattern.where(event => event.getId == 1):模式的条件为 id = 1
or()模式的条件或者pattern.where(event => event.getId == 1).or(event => event.getId == 2): 模式的条件为 id=1 或 id=2
util()模式的条件直至pattern.oneOrMore().until(event => event.getId == 1): 模式发生一次或者多次,直至 id=1

2)组合模式

基本模式通过不同的连接方式可以组成成更复杂的模式。

Flink CEP 提供如下三种模式:

  1. 严紧邻模式(Strict Contigity: 紧邻的两个模式必须按照先后顺序匹配,且都匹配。

    方法有:next()notNext()

    如图:cep1.png

  2. 松紧邻模式(Relaxed Contigity:忽略两个模式均不匹配的事件,从输入中找到符合两个模式的事件流。

    方法有:followBy()notFollowBy() 如图:cep2.png

  3. 非确定型松紧邻模式(Non-Deterministic Relaxed Contiguity: 可以忽略某些匹配事件。

    方法有:followedByAny()

举个栗子:

输入有:a、c、b1、b2

其中,b1、b2,均为 b事件,只是用来表示顺序。

需求:要匹配出 a b 的模式,即 a 后面有 b。

  1. 严紧邻模式:匹配结果为 空

  2. 松紧邻模式:匹配结果为 {a b1}

  3. 非确定型松紧邻模式:匹配结果为 {a b1}{a b2}

3)模式分组

Flink CEP 引入模式分组机制,以提升模式编程的灵活度。

所谓分组是将 beginfollowedByfollowedByAnynext 的序列组合成一个基本模式。

例如,5秒内, 连续登录失败:

 Pattern<LoginBean, LoginBean> pattern = Pattern.<LoginBean>begin("start").where(new IterativeCondition<LoginBean>() {
            @Override
            public boolean filter(LoginBean loginBean, Context<LoginBean> context) {
                return loginBean.getState().equals("fail");
            }
        }).next("next").where(new IterativeCondition<LoginBean>() {
            @Override
            public boolean filter(LoginBean loginBean, Context<LoginBean> context) {
                return loginBean.getState().equals("fail");
            }
        }).within(Time.seconds(5));

二、实战案例

添加依赖:

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-cep_2.12</artifactId>
    <version>1.11.1</version>
</dependency>

编程模板

Flink CEP 的程序结构主要分为两个步骤:

  • 定义模式
  • 匹配结果

官网中 Flink 提供的案例:

DataStream<Event> input = ...
Pattern<Event, ?> pattern = Pattern.<Event>begin("start").where(
        new SimpleCondition<Event>() {
            @Override
            public boolean filter(Event event) {
                return event.getId() == 42;
            }
        }
    ).next("middle").subtype(SubEvent.class).where(
        new SimpleCondition<SubEvent>() {
            @Override
            public boolean filter(SubEvent subEvent) {
                return subEvent.getVolume() >= 10.0;
            }
        }
    ).followedBy("end").where(
         new SimpleCondition<Event>() {
            @Override
            public boolean filter(Event event) {
                return event.getName().equals("end");
            }
         }
    );
    
PatternStream<Event> patternStream = CEP.pattern(input, pattern);

DataStream<Alert> result = patternStream.process(
    new PatternProcessFunction<Event, Alert>() {
        @Override
        public void processMatch(
                Map<String, List<Event>> pattern,
                Context ctx,
                Collector<Alert> out) throws Exception {
            out.collect(createAlertFrom(pattern));
        }
    });

在这个案例中可以看到程序结构分别是:

  1. 定义一个模式 Pattern:即在所有接收到的事件中匹配那些以 id 等于 42 的事件,然后匹配 volume 大于 10.0 的事件,继续匹配一个 name 等于 end 的事件;
  2. 匹配模式并且发出报警,根据定义的 pattern 在输入流上进行匹配,一旦命中模式,就发出一个报警。

(1)案例一:搜索数据

模拟电商网站用户搜索的数据来作为数据的输入源,然后查找其中重复搜索某一个商品的人。

步骤:

  1. 定义一个数据源
  2. 模拟用户的搜索数据
  3. 定义自己的 Pattern

这个模式的特点就是连续两次搜索商品 “帽子” ,然后进行匹配,发现匹配后输出一条提示信息,直接打印在控制台上。

代码如下:

import org.apache.flink.api.java.tuple.Tuple3;
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.DataStreamSource;
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 CepDemo {

    public static void main(String[] args) throws Exception{
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        DataStreamSource source = env.fromElements(
                //浏览记录
                Tuple3.of("Marry", "外套", 1L),
                Tuple3.of("Marry", "帽子",1L),
                Tuple3.of("Marry", "帽子",2L),
                Tuple3.of("Marry", "帽子",3L),
                Tuple3.of("Ming", "衣服",1L),
                Tuple3.of("Marry", "鞋子",1L),
                Tuple3.of("Marry", "鞋子",2L),
                Tuple3.of("LiLei", "帽子",1L),
                Tuple3.of("LiLei", "帽子",2L),
                Tuple3.of("LiLei", "帽子",3L)
        );
        //定义Pattern,寻找连续搜索帽子的用户
        Pattern<Tuple3<String, String, Long>, Tuple3<String, String, Long>> pattern = Pattern
                .<Tuple3<String, String, Long>>begin("start")
                .where(new SimpleCondition<Tuple3<String, String, Long>>() {
                    @Override
                    public boolean filter(Tuple3<String, String, Long> value) throws Exception {
                        return value.f1.equals("帽子");
                    }
                }) //.timesOrMore(3);
                .next("middle")
                .where(new SimpleCondition<Tuple3<String, String, Long>>() {
                    @Override
                    public boolean filter(Tuple3<String, String, Long> value) throws Exception {
                        return value.f1.equals("帽子");
                    }
                });

        KeyedStream keyedStream = source.keyBy(0);
        PatternStream patternStream = CEP.pattern(keyedStream, pattern);
        SingleOutputStreamOperator matchStream = patternStream.select(new PatternSelectFunction<Tuple3<String, String, Long>, String>() {
            @Override
            public String select(Map<String, List<Tuple3<String, String, Long>>> pattern) throws Exception {
                List<Tuple3<String, String, Long>> middle = pattern.get("middle");
                return middle.get(0).f0 + ":" + middle.get(0).f2 + ":" + "连续搜索两次帽子!";
            }
        });
        matchStream.printToErr();
        env.execute("execute cep");
    }
}

输出结果如下:

Marry:2:连续搜索两次帽子!
Marry:3:连续搜索两次帽子!
LiLei:2:连续搜索两次帽子!
LiLei:3:连续搜索两次帽子!

(2)案例二:恶意登录检测

Flink CEP 开发流程:

  1. DataSource 中的数据转换为 DataStream; watermarkkeyby
  2. 定义 Pattern, 并将 DataStreamPattern 组合转换为 PatternStream;
  3. PatternStream 经过 selectprocess 等算子转换为 DataStream;
  4. 再次转换的 DataStream 经过处理后, sink 到目标库。

需求: 找出5秒内, 连续登录失败的账号。

思路:

  1. 获取数据源
  2. 在数据源上做出 watermark (允许一些乱序数据)
  3. watermark 上根据 id 分组 keyby
  4. 做出模式 pattern
  5. 在数据流上进行模式匹配
  6. 提取匹配成功的数据

代码如下:

import org.apache.flink.api.common.eventtime.*;
import org.apache.flink.cep.CEP;
import org.apache.flink.cep.PatternStream;
import org.apache.flink.cep.functions.PatternProcessFunction;
import org.apache.flink.cep.pattern.Pattern;
import org.apache.flink.cep.pattern.conditions.IterativeCondition;
import org.apache.flink.streaming.api.TimeCharacteristic;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
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 org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;

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

/**
 * @author donald
 * @date 2021/04/23
 */
public class LoginCepDemo {

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

        DataStreamSource<LoginBean> data = env.fromElements(new LoginBean(1L, "fail", 1597905234000L),
                new LoginBean(1L, "success", 1597905235000L),
                new LoginBean(2L, "fail", 1597905236000L),
                new LoginBean(2L, "fail", 1597905237000L),
                new LoginBean(2L, "fail", 1597905238000L),
                new LoginBean(3L, "fail", 1597905239000L),
                new LoginBean(3L, "success", 1597905240000L));


        // 2. 在数据源上作出 watermark
        SingleOutputStreamOperator<LoginBean> watermarks =
                data.assignTimestampsAndWatermarks(new WatermarkStrategy<LoginBean>() {
                    @Override
                    public WatermarkGenerator<LoginBean> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
                        return new WatermarkGenerator<LoginBean>() {
                            long maxTimeStamp = Long.MIN_VALUE;

                            @Override
                            public void onEvent(LoginBean event, long eventTimestamp, WatermarkOutput output) {
                                maxTimeStamp = Math.max(maxTimeStamp, event.getTs());
                            }

                            long maxOutOfOrderness = 500L;

                            @Override
                            public void onPeriodicEmit(WatermarkOutput output) {
                                output.emitWatermark(new Watermark(maxTimeStamp - maxOutOfOrderness));
                            }
                        };
                    }
                }.withTimestampAssigner((element, recordTimestamp) -> element.getTs()));

        // 3. 在 watermark 上根据 id 分组 keyby
        KeyedStream<LoginBean, Long> keyedStream = watermarks.keyBy(LoginBean::getId);

        // 4. 做出模式 Pattern
        Pattern<LoginBean, LoginBean> pattern = Pattern.<LoginBean>begin("start").where(new IterativeCondition<LoginBean>() {
            @Override
            public boolean filter(LoginBean loginBean, Context<LoginBean> context) {
                return loginBean.getState().equals("fail");
            }
        }).next("next").where(new IterativeCondition<LoginBean>() {
            @Override
            public boolean filter(LoginBean loginBean, Context<LoginBean> context) {
                return loginBean.getState().equals("fail");
            }
        }).within(Time.seconds(5));


        // 5. 在数据流上进行模式匹配
        PatternStream<LoginBean> patternStream = CEP.pattern(keyedStream, pattern);

        // 6. 提取匹配成功的数据
        SingleOutputStreamOperator<String> process = patternStream.process(new PatternProcessFunction<LoginBean, String>() {
            @Override
            public void processMatch(Map<String, List<LoginBean>> match, Context ctx,
                                     Collector<String> out) {
                List<LoginBean> start = match.get("start");
                List<LoginBean> next = match.get("next");
                String res = "start:" + start + "...next:" + next;
                out.collect(res + start.get(0).getId());
            }
        });

        process.print();

        env.execute();
    }
}

LoginBean.java 如下:

// 省略:get、set、toString 方法
public class LoginBean {

    private Long id;
    private String state;
    private Long ts;

    public LoginBean() {

    }

    public LoginBean(Long id, String state, Long ts) {
        this.id = id;
        this.state = state;
        this.ts = ts;
    }
}

输出结果如下:

{start=[LoginBean{id=2, state='fail', ts=1597905236000}], next=[LoginBean{id=2, state='fail', ts=1597905237000}]}
start:[LoginBean{id=2, state='fail', ts=1597905236000}]...next:[LoginBean{id=2, state='fail', ts=1597905237000}]2
{start=[LoginBean{id=2, state='fail', ts=1597905237000}], next=[LoginBean{id=2, state='fail', ts=1597905238000}]}
start:[LoginBean{id=2, state='fail', ts=1597905237000}]...next:[LoginBean{id=2, state='fail', ts=1597905238000}]2

(3)案例三:超时未支付

需求: 找出下单后10分钟没有支付的订单。

思路:

  1. 数据源

  2. 转化 watermark

  3. keyby 转化

  4. 做出 Pattern (下单以后10分钟内未支付)

    注意:下单为 create 支付为 pay, createpay 之间不需要是严格临近, 所以选择 followedBy

  5. 模式匹配

  6. 取出匹配成功的数据

代码如下:

import org.apache.flink.api.common.eventtime.*;
import org.apache.flink.cep.CEP;
import org.apache.flink.cep.PatternSelectFunction;
import org.apache.flink.cep.PatternStream;
import org.apache.flink.cep.PatternTimeoutFunction;
import org.apache.flink.cep.pattern.Pattern;
import org.apache.flink.cep.pattern.conditions.IterativeCondition;
import org.apache.flink.streaming.api.TimeCharacteristic;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
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 org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.OutputTag;

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

public class PayDemo {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
        environment.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
        environment.setParallelism(1);
        DataStreamSource<PayBean> data = environment.fromElements(
                new PayBean(1L, "create", 1597905234000L),
                new PayBean(1L, "pay", 1597905235000L),
                new PayBean(2L, "create", 1597905236000L),
                new PayBean(2L, "pay", 1597905237000L),
                new PayBean(3L, "create", 1597905239000L)
        );

        SingleOutputStreamOperator<PayBean> watermarks = data.assignTimestampsAndWatermarks(new WatermarkStrategy<PayBean>() {
            @Override
            public WatermarkGenerator<PayBean> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
                return new WatermarkGenerator<PayBean>() {
                    long maxTimeStamp = Long.MIN_VALUE;
                    long maxOutOfOrderness = 500L;

                    @Override
                    public void onEvent(PayBean event, long eventTimestamp, WatermarkOutput output) {
                        maxTimeStamp = Math.max(maxTimeStamp, event.getTs());
                    }

                    @Override
                    public void onPeriodicEmit(WatermarkOutput output) {
                        output.emitWatermark(new Watermark(maxTimeStamp - maxOutOfOrderness));
                    }
                };
            }
        }.withTimestampAssigner((element, recordTimestamp) -> element.getTs()));

        KeyedStream<PayBean, Long> keyedStream = watermarks.keyBy(PayBean::getId);

        Pattern<PayBean, PayBean> pattern = Pattern.<PayBean>begin("start").where(new IterativeCondition<PayBean>() {
            @Override
            public boolean filter(PayBean payBean, Context<PayBean> context) {
                return payBean.getState().equals("create");
            }
        }).followedBy("next").where(new IterativeCondition<PayBean>() {
            @Override
            public boolean filter(PayBean payBean, Context<PayBean> context) {
                return payBean.getState().equals("pay");
            }
        }).within(Time.seconds(600));

        PatternStream<PayBean> patternStream = CEP.pattern(keyedStream, pattern);

        OutputTag<PayBean> outoftime = new OutputTag<PayBean>("outoftime"){};

        SingleOutputStreamOperator<PayBean> result = patternStream.select(outoftime, new PatternTimeoutFunction<PayBean, PayBean>() {
            @Override
            public PayBean timeout(Map<String, List<PayBean>> map, long l) {
                return map.get("start").get(0);
            }
        }, new PatternSelectFunction<PayBean, PayBean>() {
            @Override
            public PayBean select(Map<String, List<PayBean>> map) {
                return map.get("start").get(0);
            }
        });

        DataStream<PayBean> sideOutput = result.getSideOutput(outoftime);

        sideOutput.print();

        environment.execute();
    }
}

PayBean.java如下:

// 省略:get、set、toString 方法
public class PayBean {
    private Long id;
    private String state;
    private Long ts;

    public PayBean() {
    }

    public PayBean(Long id, String state, Long ts) {
        this.id = id;
        this.state = state;
        this.ts = ts;
    }
}

输出结果如下:

PayBean{id=3, state='create', ts=1597905239000}