轻松入门进阶Flink第九课 项目实战-Flink CEP 实时预警系统

86 篇文章 49 订阅

第35讲:项目背景和 Flink CEP 简介

从这一课时开始我们将进入“Flink CEP 实时预警系统”的学习,本课时先介绍项目的背景、架构设计。

背景

我们在第 11 课时“Flink CEP 复杂事件处理”已经介绍了 Flink CEP 的原理,它是 Flink 提供的复杂事件处理库,也是 Flink 提供的一个非常亮眼的功能,当然更是 Flink 中最难以理解的部分之一。

Complex Event Processing(CEP)允许我们在源源不断的数据中通过自定义的模式(Pattern)检测并且获取需要的数据,还可以对这些数据做对应的处理。Flink 提供了非常丰富的 API 来帮助我们实现非常复杂的模式进行数据匹配。

Flink CEP 应用场景

CEP 在互联网各个行业都有应用,例如金融、物流、电商等行业,具体的作用如下。

  • 实时监控:我们需要在大量的订单交易中发现那些虚假交易,在网站的访问日志中寻找那些使用脚本或者工具“爆破”登录的用户,或者在快递运输中发现那些滞留很久没有签收的包裹等。

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

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

当然了,还要很多其他的场景比如智能交通、物联网行业等,可以应用的场景不胜枚举。

Flink CEP 的原理

如果你对 CEP 的理论基础非常感兴趣,推荐一篇论文“Efficient Pattern Matching over Event Streams”。

Flink CEP 在运行时会将用户提交的代码转化成 NFA Graph,Graph 中包含状态(Flink 中 State 对象),以及连接状态的边(Flink 中 StateTransition 对象)。

Flink 中的每个模式都包含多个状态,我们进行模式匹配的过程就是进行状态转换的过程,在实际应用 Flink CEP 时,首先需要创建一系列的 Pattern,然后利用 NFACompiler 将 Pattern 进行拆分并且创建出 NFA,NFA 包含了 Pattern 中的各个状态和各个状态间转换的表达式。

我们用官网中的一个案例来讲解 Flink CEP 的应用:

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)); 
        } 
    }); 

在这个案例中可以看到程序结构如下:

  • 第一步,定义一个模式 Pattern,在这里定义了一个这样的模式,即在所有接收到的事件中匹配那些以 ID 等于 42 的事件,然后匹配 volume 大于 10.0 的事件,继续匹配一个 name 等于 end 的事件;

  • 第二步,匹配模式并且发出报警,根据定义的 pattern 在输入流上进行匹配,一旦命中我们的模式,就发出一个报警。

整体架构

image (3).png

我们在项目中定义特定事件附带各种上下文信息进入 Kafka,Flink 首先会消费这些信息过滤掉不需要的信息,然后会被我们定义好的模式进行处理,接着触发对应的规则;同时把触发规则的数据输出进行存储。

整个项目的设计可以分为下述几个部分:

  • Flink CEP 源码解析和自定义消息事件

  • 自定义 Pattern 和报警规则

  • Flink 调用 CEP 实现报警功能

总结

本节课我们主要讲解了 Flink CEP 的应用场景和基本原理,在实际工作中,如果你的需求涉及从数据流中通过一定的规则识别部分数据,可以考虑使用 CEP。在接下来的课程中我们会分不同的课时来一一讲解这些知识点并进行实现。


第36讲:自定义消息事件

我们在上一课时中讲了 CEP 的基本原理并且用官网的案例介绍了 CEP 的简单应用。在 Flink CEP 中存在多个比较晦涩的概念,如果你对于这些概念理解有困难,我们可以把:创建系列 Pattern,然后利用 NFACompiler 将 Pattern 进行拆分并且创建出 NFA,NFA 包含了 Pattern 中的各个状态和各个状态间转换的表达式。这整个过程我们可以把 Flink CEP 的使用类比为正则表达式的使用。CEP 中定义的 Pattern 就是正则表达式,而 DataStream 是需要进行匹配的原数据,Flink CEP 通过 DataStream 和 Pattern 进行匹配,然后生成一个经过正则过滤后的 DataStream。

Flink CEP 源码解析

Flink CEP 被设计成 Flink 中的算子,而不是单独的引擎。那么当一条数据到来时,Flink CEP 是如何工作的呢?

Flink DataStream 和 PatternStream

我们还是用官网中的案例来进行讲解:

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)); 
        } 

我们知道,Flink 中的 DataStream 由相同类型的事件或者元素构成,可以经过复杂的算子比如 Map、Filter 等进行转换。

那么 CEP 是如何和 DataStream 无缝衔接的呢?我们注意到 CEP 的 pattern 方法:

public class CEP { 
    public CEP() { 
    } 
    public static <T> PatternStream<T> pattern(DataStream<T> input, Pattern<T, ?> pattern) { 
        return new PatternStream(input, pattern); 
    } 
    public static <T> PatternStream<T> pattern(DataStream<T> input, Pattern<T, ?> pattern, EventComparator<T> comparator) { 
        PatternStream<T> stream = new PatternStream(input, pattern); 
        return stream.withComparator(comparator); 
    } 
} 

PatternStream 是 Flink CEP 对模式匹配后流的抽象和定义,它把 DataStream 和 Pattern 组合到一起,并且基于 PatternStream 提供了一系列的方法,比如 select、process 等。

我们在 PatternStream 上调用 select 或者 process 方法时,会继续调用到下面的方法:

@Internal 
final class PatternStreamBuilder<IN> { 
   ... 
    <OUT, K> SingleOutputStreamOperator<OUT> build(TypeInformation<OUT> outTypeInfo, PatternProcessFunction<IN, OUT> processFunction) { 
        Preconditions.checkNotNull(outTypeInfo); 
        Preconditions.checkNotNull(processFunction); 
        TypeSerializer<IN> inputSerializer = this.inputStream.getType().createSerializer(this.inputStream.getExecutionConfig()); 
        boolean isProcessingTime = this.inputStream.getExecutionEnvironment().getStreamTimeCharacteristic() == TimeCharacteristic.ProcessingTime; 
        boolean timeoutHandling = processFunction instanceof TimedOutPartialMatchHandler; 
        NFAFactory<IN> nfaFactory = NFACompiler.compileFactory(this.pattern, timeoutHandling); 
        CepOperator<IN, K, OUT> operator = new CepOperator(inputSerializer, isProcessingTime, nfaFactory, this.comparator, this.pattern.getAfterMatchSkipStrategy(), processFunction, this.lateDataOutputTag); 
        SingleOutputStreamOperator patternStream; 
        if (this.inputStream instanceof KeyedStream) { 
            KeyedStream<IN, K> keyedStream = (KeyedStream)this.inputStream; 
            patternStream = keyedStream.transform("CepOperator", outTypeInfo, operator); 
        } else { 
            KeySelector<IN, Byte> keySelector = new NullByteKeySelector(); 
            patternStream = this.inputStream.keyBy(keySelector).transform("GlobalCepOperator", outTypeInfo, operator).forceNonParallel(); 
        } 
        return patternStream; 
    } 
    ... 
} 

这其中 NFACompiler.compileFactory,会帮我们完成 Pattern 到 State 的转换。

public static <T> NFACompiler.NFAFactory<T> compileFactory(Pattern<T, ?> pattern, boolean timeoutHandling) { 
    if (pattern == null) { 
        return new NFACompiler.NFAFactoryImpl(0L, Collections.emptyList(), timeoutHandling); 
    } else { 
        NFACompiler.NFAFactoryCompiler<T> nfaFactoryCompiler = new NFACompiler.NFAFactoryCompiler(pattern); 
        nfaFactoryCompiler.compileFactory(); 
        return new NFACompiler.NFAFactoryImpl(nfaFactoryCompiler.getWindowTime(), nfaFactoryCompiler.getStates(), timeoutHandling); 
    } 
} 

其中,compileFactory 方法会生成 State,也就是说把 Pattern 转化为 NFA 中的状态信息,状态会不断地向后追加,所以需要分别先后创建 EndState、MiddleState 和 StartState。这里 Pattern 中专门定义了一个 getPrevious 方法用来获取前一个状态。

void compileFactory() { 
    if (this.currentPattern.getQuantifier().getConsumingStrategy() == ConsumingStrategy.NOT_FOLLOW) { 
        throw new MalformedPatternException("NotFollowedBy is not supported as a last part of a Pattern!"); 
    } else { 
        this.checkPatternNameUniqueness(); 
        this.checkPatternSkipStrategy(); 
        State<T> sinkState = this.createEndingState(); 
        sinkState = this.createMiddleStates(sinkState); 
        this.createStartState(sinkState); 
    } 
} 
Event 事件处理

那么,当一条消息进入 Flink CEP 中,是如何被处理的呢?

当 Flink 的事件属性为 EventTime 时,关键代码如下:

public void processElement(StreamRecord<IN> element) throws Exception { 
    long currentTime; 
    if (this.isProcessingTime) { 
        if (this.comparator == null) { 
            NFAState nfaState = this.getNFAState(); 
            long timestamp = this.getProcessingTimeService().getCurrentProcessingTime(); 
            this.advanceTime(nfaState, timestamp); 
            this.processEvent(nfaState, element.getValue(), timestamp); 
            this.updateNFA(nfaState); 
        } else { 
            currentTime = this.timerService.currentProcessingTime(); 
            this.bufferEvent(element.getValue(), currentTime); 
            this.timerService.registerProcessingTimeTimer(VoidNamespace.INSTANCE, currentTime + 1L); 
        } 
    } else { 
        currentTime = element.getTimestamp(); 
        IN value = element.getValue(); 
        if (currentTime > this.lastWatermark) { 
            this.saveRegisterWatermarkTimer(); 
            this.bufferEvent(value, currentTime); 
        } else if (this.lateDataOutputTag != null) { 
            this.output.collect(this.lateDataOutputTag, element); 
        } 
    } 
} 

当 EventTime 大于上一次的 Watermark 时,会把当前的数据加入 elementQueueState 队列中,不符合条件的数据会直接丢弃,关键代码如下:

currentTime = element.getTimestamp(); 
IN value = element.getValue(); 
if (currentTime > this.lastWatermark) { 
    this.saveRegisterWatermarkTimer(); 
    this.bufferEvent(value, currentTime); 
} else if (this.lateDataOutputTag != null) { 
    this.output.collect(this.lateDataOutputTag, element); 
} 

满足条件的数据加入队列后,会在 onEventTime 方法中判断是否触发计算:

public void onEventTime(InternalTimer<KEY, VoidNamespace> timer) throws Exception { 
    PriorityQueue<Long> sortedTimestamps = this.getSortedTimestamps(); 
    NFAState nfaState; 
    long timestamp; 
    for(nfaState = this.getNFAState(); !sortedTimestamps.isEmpty() && (Long)sortedTimestamps.peek() <= this.timerService.currentWatermark(); this.elementQueueState.remove(timestamp)) { 
        timestamp = (Long)sortedTimestamps.poll(); 
        this.advanceTime(nfaState, timestamp); 
        Stream<IN> elements = this.sort((Collection)this.elementQueueState.get(timestamp)); 
        Throwable var7 = null; 
        try { 
            elements.forEachOrdered((event) -> { 
                try { 
                    this.processEvent(nfaState, event, timestamp); 
                } catch (Exception var6) { 
                    throw new RuntimeException(var6); 
                } 
            }); 
        } catch (Throwable var16) { 
            var7 = var16; 
            throw var16; 
        } finally { 
            if (elements != null) { 
                if (var7 != null) { 
                    try { 
                        elements.close(); 
                    } catch (Throwable var15) { 
                        var7.addSuppressed(var15); 
                    } 
                } else { 
                    elements.close(); 
                } 
            } 
        } 
    } 
    this.advanceTime(nfaState, this.timerService.currentWatermark()); 
    this.updateNFA(nfaState); 
    if (!sortedTimestamps.isEmpty() || !this.partialMatches.isEmpty()) { 
        this.saveRegisterWatermarkTimer(); 
    } 
    this.updateLastSeenWatermark(this.timerService.currentWatermark()); 
} 

到此为止,我们可以看到一条数据进入 Flink CEP 中处理的逻辑大概可以分为以下几个步骤:

  • DataSource 中的数据转换为 DataStream;

  • 定义 Pattern,并将 DataStream 和 Pattern 组合转换为 PatternStream;

  • PatternStream 经过 select、process 等算子转换为 DataStraem;

  • 再次转换的 DataStream 经过处理后,sink 到目标库。

自定义消息事件

我们在后面的案例中会分别举几个不同的场景,那么我们需要定义几个不同的消息源。

  • 第一个场景,连续登录场景

在这个场景中,我们模拟生产环境中可能出现的“爆破登录”现象,模拟用户的登录请求信息:

public class LogInEvent { 
    private Long userId; 
    private String isSuccess; 
    private Long timeStamp; 
    public Long getUserId() { 
        return userId; 
    } 
    public void setUserId(Long userId) { 
        this.userId = userId; 
    } 
    public String getIsSuccess() { 
        return isSuccess; 
    } 
    public void setIsSuccess(String isSuccess) { 
        this.isSuccess = isSuccess; 
    } 
    public Long getTimeStamp() { 
        return timeStamp; 
    } 
    public void setTimeStamp(Long timeStamp) { 
        this.timeStamp = timeStamp; 
    } 
} 

其中 userId 为用户的 ID,isSuccess 表示用户本次登录是否成功,timeStamp 表示用户登录时间戳。

  • 第二个场景,超时未支付

在这个场景中,我们要检测出那些用户下单后 5 分钟还没有支付的信息:

public class PayEvent { 
    private Long userId; 
    private String action; 
    private Long timeStamp; 
    public Long getUserId() { 
        return userId; 
    } 
    public void setUserId(Long userId) { 
        this.userId = userId; 
    } 
    public String getAction() { 
        return action; 
    } 
    public void setAction(String action) { 
        this.action = action; 
    } 
    public Long getTimeStamp() { 
        return timeStamp; 
    } 
    public void setTimeStamp(Long timeStamp) { 
        this.timeStamp = timeStamp; 
    } 
} 

同样的,其中 userId 为用户的 ID,action 表示用户的操作事件枚举比如下单、支付等,timeStamp 表示用户操作的时间戳。

  • 高频交易,找出活跃账户

在这个场景中,我们模拟账户交易信息中,那些高频的转账支付信息,希望能发现其中的风险或者活跃的用户:

public class TransactionEvent { 
    private String accout; 
    private Double amount; 
    private Long timeStamp; 
    public String getAccout() { 
        return accout; 
    } 
    public void setAccout(String accout) { 
        this.accout = accout; 
    } 
    public Double getAmount() { 
        return amount; 
    } 
    public void setAmount(Double amount) { 
        this.amount = amount; 
    } 
    public Long getTimeStamp() { 
        return timeStamp; 
    } 
    public void setTimeStamp(Long timeStamp) { 
        this.timeStamp = timeStamp; 
    } 
} 

其中 account 表是账户信息,amount 为转账金额,timeStamp 是交易时的时间戳信息。

总结

本节课我们详细讲解了 Flink CEP 的源码实现,逐步讲解了一条数据进入 Flink CEP 中处理的逻辑步骤,你可以根据需要进一步查看实现原理。最后我们模拟了 3 种实际生产环境中的场景,定义了消息事件,为我们后面的课程做准备。


第37讲:自定义 Pattern 和报警规则

在上一课时提过,PatternStream 是 Flink CEP 对模式匹配后流的抽象和定义,它把 DataStream 和 Pattern 组合到一起,并且基于 PatternStream 提供了一系列的方法,比如 select、process 等。

Flink CEP 的核心在于模式匹配,对于不同模式匹配特性的支持,往往决定相应的 CEP 框架是否能够得到广泛应用。那么 Flink CEP 对模式提供了哪些支持呢?

Pattern 分类

Flink CEP 提供了 Pattern API 用于对输入流数据进行复杂事件规则的定义,用来提取符合规则的事件序列。

Flink 中的 Pattern 分为单个模式、组合模式、模式组 3 类。

单个模式

复杂规则中的每一个单独的模式定义,就是个体模式。我们既可以定义一个给定事件出现的次数(量词),也可以定义一个条件来决定一个进来的事件是否被接受进入这个模式(条件)。

例如,我们对一个命名为 start 的模式,可以定义如下量词:

// 期望出现4次 
start.times(4); 
// 期望出现0或者4次 
start.times(4).optional(); 
// 期望出现2、3或者4次 
start.times(2, 4); 
// 期望出现2、3或者4次,并且尽可能地重复次数多 
start.times(2, 4).greedy(); 
// 期望出现0、2、3或者4次 
start.times(2, 4).optional(); 
// 期望出现0、2、3或者4次,并且尽可能地重复次数多 
start.times(2, 4).optional().greedy(); 
// 期望出现1到多次 
start.oneOrMore(); 
// 期望出现1到多次,并且尽可能地重复次数多 
start.oneOrMore().greedy(); 
// 期望出现0到多次 
start.oneOrMore().optional(); 
// 期望出现0到多次,并且尽可能地重复次数多 
start.oneOrMore().optional().greedy(); 
// 期望出现2到多次 
start.timesOrMore(2); 
// 期望出现2到多次,并且尽可能地重复次数多 
start.timesOrMore(2).greedy(); 
// 期望出现0、2或多次 
start.timesOrMore(2).optional(); 
// 期望出现0、2或多次,并且尽可能地重复次数多 
start.timesOrMore(2).optional().greedy();  

我们还可以定义需要的匹配条件:

start.where(new SimpleCondition<Event>() { 
    @Override 
    public boolean filter(Event value) { 
        return value.getName().startsWith("foo"); 
    } 
}); 

上面代码展示了定义一个以“foo”开头的事件。

当然我们还可以把条件组合到一起,例如,可以通过依次调用 where 来组合条件:

pattern.where(new SimpleCondition<Event>() { 
    @Override 
    public boolean filter(Event value) { 
        return ... // 一些判断条件 
    } 
}).or(new SimpleCondition<Event>() { 
    @Override 
    public boolean filter(Event value) { 
        return ... // 一些判断条件 
    } 
}); 
组合模式

我们把很多单个模式组合起来,就形成了组合模式。Flink CEP 支持事件之间如下形式的连续策略:

  • 严格连续,期望所有匹配的事件严格的一个接一个出现,中间没有任何不匹配的事件;

  • 松散连续

  • 不确定的松散连续

我们直接参考官网给出的案例:

// 严格连续 
Pattern<Event, ?> strict = start.next("middle").where(...); 
// 松散连续 
Pattern<Event, ?> relaxed = start.followedBy("middle").where(...); 
// 不确定的松散连续 
Pattern<Event, ?> nonDetermin = start.followedByAny("middle").where(...); 
// 严格连续的NOT模式 
Pattern<Event, ?> strictNot = start.notNext("not").where(...); 
// 松散连续的NOT模式 
Pattern<Event, ?> relaxedNot = start.notFollowedBy("not").where(...); 

对于上述的松散连续不确定的松散连续这里举个例子来说明一下,例如,我们定义了模式“a b”,对于一个事件序列“a,c,b1,b2”,那么会产生下面的结果:

  • “a”和“b”之间严格连续则返回: {} (没有匹配)

  • “a”和“b”之间松散连续则返回: {a b1}

  • “a”和“b”之间不确定的松散连续则返回: {a b1}、{a b2}

模式组

将一个模式作为条件嵌套在单个模式里,就是模式组。我们举例如下:

Pattern<Event, ?> start = Pattern.begin( 
    Pattern.<Event>begin("start").where(...).followedBy("start_middle").where(...) 
); 
// 严格连续 
Pattern<Event, ?> strict = start.next( 
    Pattern.<Event>begin("next_start").where(...).followedBy("next_middle").where(...) 
).times(3); 
// 松散连续 
Pattern<Event, ?> relaxed = start.followedBy( 
    Pattern.<Event>begin("followedby_start").where(...).followedBy("followedby_middle").where(...) 
).oneOrMore(); 
// 不确定松散连续 
Pattern<Event, ?> nonDetermin = start.followedByAny( 
    Pattern.<Event>begin("followedbyany_start").where(...).followedBy("followedbyany_middle").where(...) 
).optional(); 

自定义 Pattern

上一课时定义了几个不同的消息源,我们分别根据需求自定义匹配模式。

  • 第一个场景,连续登录场景

在这个场景中,我们需要找出那些 5 秒钟内连续登录失败的账号,然后禁止用户再次尝试登录需要等待 1 分钟。

Pattern.<LogInEvent>begin("start").where(new IterativeCondition<LogInEvent>() { 
    @Override 
    public boolean filter(LogInEvent value, Context<LogInEvent> ctx) throws Exception { 
        return value.getIsSuccess().equals("fail"); 
    } 
}).next("next").where(new IterativeCondition<LogInEvent>() { 
    @Override 
    public boolean filter(LogInEvent value, Context<LogInEvent> ctx) throws Exception { 
        return value.getIsSuccess().equals("fail"); 
    } 
}).within(Time.seconds(5)); 
  • 第二个场景,超时未支付

在这个场景中,我们需要找出那些下单后 10 分钟内没有支付的订单。

Pattern.<PayEvent> 
        begin("begin") 
        .where(new IterativeCondition<PayEvent>() { 
            @Override 
            public boolean filter(PayEvent payEvent, Context context) throws Exception { 
                return payEvent.getAction().equals("create"); 
            } 
        }) 
        .next("next") 
        .where(new IterativeCondition<PayEvent>() { 
            @Override 
            public boolean filter(PayEvent payEvent, Context context) throws Exception { 
                return payEvent.getAction().equals("pay"); 
            } 
        }) 
        .within(Time.seconds(600)); 
OutputTag<PayEvent> orderTiemoutOutput = new OutputTag<PayEvent>("orderTimeout") {}; 

在这里我们使用了侧输出流,并且将正常的订单流和超时未支付的超时流分开:

SingleOutputStreamOperator selectResult = patternStream.select(orderTiemoutOutput, 
        (PatternTimeoutFunction<PayEvent, ResultPayEvent>) (map, l) -> new ResultPayEvent(map.get("begin").get(0).getUserId(), "timeout"), 
        (PatternSelectFunction<PayEvent, ResultPayEvent>) map -> new ResultPayEvent(map.get("next").get(0).getUserId(), "success") 
); 
DataStream timeOutSideOutputStream = selectResult.getSideOutput(orderTiemoutOutput) 
  • 第三个场景,找出交易活跃用户

在这个场景下,我们需要找出那些 24 小时内至少 5 次有效交易的账户。

Pattern.<TransactionEvent>begin("start").where( 
        new SimpleCondition<TransactionEvent>() { 
            @Override 
            public boolean filter(TransactionEvent transactionEvent) { 
                return transactionEvent.getAmount() > 0; 
            } 
        } 
).timesOrMore(5) 
 .within(Time.hours(24)); 

总结

本一课时我们讲解了 Flink CEP 的模式匹配种类,并且基于上一课时的三个场景自定义了 Pattern,我们在实际生产中可以根据需求定义更为复杂的模式。


第38讲:Flink 调用 CEP 实现报警功能

在上一课时中,我们详细讲解了 Flink CEP 中 Pattern 的分类,需要根据实际生产环境来选择单个模式、组合模式或者模式组。

在前面的课程中我们提到的三种典型场景下,分别根据业务需要实现了 Pattern 的定义,也可以根据自定义的 Pattern 检测到异常事件。那么接下来就需要根据检测到的异常事件发送告警,这一课将从这三种场景入手,来讲解完整的代码实现逻辑。

连续登录场景

在这个场景中,我们需要找出那些 5 秒钟内连续登录失败的账号,然后禁止用户,再次尝试登录需要等待 1 分钟。

我们定义的 Pattern 规则如下:

Pattern.<LogInEvent>begin("start").where(new IterativeCondition<LogInEvent>() {
    @Override
    public boolean filter(LogInEvent value, Context<LogInEvent> ctx) throws Exception {
        return value.getIsSuccess().equals("fail");
    }
}).next("next").where(new IterativeCondition<LogInEvent>() {
    @Override
    public boolean filter(LogInEvent value, Context<LogInEvent> ctx) throws Exception {
        return value.getIsSuccess().equals("fail");
    }
}).within(Time.seconds(5));

从登录消息 LogInEvent 可以得到用户登录是否成功,当检测到 5 秒钟内用户连续两次登录失败,则会发出告警消息,提示用户 1 分钟以后再试,或者这时候就需要前端输入验证码才能继续尝试。

首先我们模拟读取 Kafka 中的消息事件:

DataStream<LogInEvent> source = env.fromElements(
        new LogInEvent(1L, "fail", 1597905234000L),
        new LogInEvent(1L, "success", 1597905235000L),
        new LogInEvent(2L, "fail", 1597905236000L),
        new LogInEvent(2L, "fail", 1597905237000L),
        new LogInEvent(2L, "fail", 1597905238000L),
        new LogInEvent(3L, "fail", 1597905239000L),
        new LogInEvent(3L, "success", 1597905240000L)
).assignTimestampsAndWatermarks(new BoundedOutOfOrdernessGenerator()).keyBy(new KeySelector<LogInEvent, Object>() {
    @Override
    public Object getKey(LogInEvent value) throws Exception {
        return value.getUserId();
    }
});

我们模拟了用户的登录信息,其中可以看到 ID 为 2 的用户连续三次登录失败。
时间戳和水印提取器的代码如下:

private static class BoundedOutOfOrdernessGenerator implements AssignerWithPeriodicWatermarks<LogInEvent>{
        private final long maxOutOfOrderness = 5000L;
        private long currentTimeStamp;
        @Nullable
        @Override
        public Watermark getCurrentWatermark() {
            return new Watermark(currentTimeStamp - maxOutOfOrderness);
        }
        @Override
        public long extractTimestamp(LogInEvent element, long previousElementTimestamp) {
            Long timeStamp = element.getTimeStamp();
            currentTimeStamp = Math.max(timeStamp, currentTimeStamp);
            System.err.println(element.toString() + ",EventTime:" + timeStamp + ",watermark:" + (currentTimeStamp - maxOutOfOrderness));
            return timeStamp;
        }
    }

我们调用 Pattern.CEP 方法将 Pattern 和 Stream 结合在一起,在匹配到事件后先在控制台打印,并且向外发送。

PatternStream<LogInEvent> patternStream = CEP.pattern(source, pattern);
SingleOutputStreamOperator<AlertEvent> process = patternStream.process(new PatternProcessFunction<LogInEvent, AlertEvent>() {
    @Override
    public void processMatch(Map<String, List<LogInEvent>> match, Context ctx, Collector<AlertEvent> out) throws Exception {
        List<LogInEvent> start = match.get("start");
        List<LogInEvent> next = match.get("next");
        System.err.println("start:" + start + ",next:" + next);
    out.collect(<span class="hljs-keyword">new</span> AlertEvent(String.valueOf(start.get(<span class="hljs-number">0</span>).getUserId()), <span class="hljs-string">"出现连续登陆失败"</span>));
}

});
process.printToErr();
env.execute(“execute cep”);

我们右键运行,查看结果,如下图所示:

Drawing 0.png

可以看到报警事件的触发,其中 AlertEvent 是我们定义的报警事件。

public class AlertEvent {
    private String id;
    private String message;
    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    public String getMessage() {
        return message;
    }
    public void setMessage(String message) {
        this.message = message;
    }
    public AlertEvent(String id, String message) {
        this.id = id;
        this.message = message;
    }
    @Override
    public String toString() {
        return "AlertEvent{" +
                "id='" + id + '\'' +
                ", message='" + message + '\'' +
                '}';
    }
}

对于 PatternProcessFunction 中的 processMatch 方法,第一个参数 Map<String, List> match,可以在源码中看到以下的注释:

Drawing 1.png

match 这个 Map 的 Key 则是我们在 Pattern 中定义的 start 和 next。

交易活跃用户

在这个场景下,我们需要找出那些 24 小时内至少 5 次有效交易的账户。

在上一课时中我们的定义的规则如下:

Pattern.<TransactionEvent>begin("start").where(
        new SimpleCondition<TransactionEvent>() {
            @Override
            public boolean filter(TransactionEvent transactionEvent) {
                return transactionEvent.getAmount() > 0;
            }
        }
).timesOrMore(5)
 .within(Time.hours(24));

首先,我们模拟读取 Kafka 中的消息事件:

DataStream<TransactionEvent> source = env.fromElements(
        new TransactionEvent("100XX", 0.0D, 1597905234000L),
        new TransactionEvent("100XX", 100.0D, 1597905235000L),
        new TransactionEvent("100XX", 200.0D, 1597905236000L),
        new TransactionEvent("100XX", 300.0D, 1597905237000L),
        new TransactionEvent("100XX", 400.0D, 1597905238000L),
        new TransactionEvent("100XX", 500.0D, 1597905239000L),
        new TransactionEvent("101XX", 0.0D, 1597905240000L),
        new TransactionEvent("101XX", 100.0D, 1597905241000L)
).assignTimestampsAndWatermarks(new BoundedOutOfOrdernessGenerator()).keyBy(new KeySelector<TransactionEvent, Object>() {
    @Override
    public Object getKey(TransactionEvent value) throws Exception {
        return value.getAccout();
    }
});

然后调用 Pattern.CEP 方法将 Pattern 和 Stream 结合在一起,我们在匹配到事件后先在控制台打印,并且向外发送。

PatternStream<TransactionEvent> patternStream = CEP.pattern(source, pattern);
    SingleOutputStreamOperator<AlertEvent> process = patternStream.process(new PatternProcessFunction<TransactionEvent, AlertEvent>() {
        @Override
        public void processMatch(Map<String, List<TransactionEvent>> match, Context ctx, Collector<AlertEvent> out) throws Exception {
            List<TransactionEvent> start = match.get("start");
            List<TransactionEvent> next = match.get("next");
            System.err.println("start:" + start + ",next:" + next);
            out.collect(new AlertEvent(start.get(0).getAccout(), "连续有效交易!"));
        }
    });
    process.printToErr();
    env.execute("execute cep");
}

最后右键运行查看结果,如下图所示:

Drawing 2.png

超时未支付

在这个场景中,我们需要找出那些下单后 10 分钟内没有支付的订单。

在上一课时中我们的定义的规则如下:

Pattern.<PayEvent>
        begin("begin")
        .where(new IterativeCondition<PayEvent>() {
            @Override
            public boolean filter(PayEvent payEvent, Context context) throws Exception {
                return payEvent.getAction().equals("create");
            }
        })
        .next("next")
        .where(new IterativeCondition<PayEvent>() {
            @Override
            public boolean filter(PayEvent payEvent, Context context) throws Exception {
                return payEvent.getAction().equals("pay");
            }
        })
        .within(Time.seconds(600));
OutputTag<PayEvent> orderTiemoutOutput = new OutputTag<PayEvent>("orderTimeout") {};

首先我们模拟读取 Kafka 中的消息事件:

DataStream<PayEvent> source = env.fromElements(
        new PayEvent(1L, "create", 1597905234000L),
        new PayEvent(1L, "pay", 1597905235000L),
        new PayEvent(2L, "create", 1597905236000L),
        new PayEvent(2L, "pay", 1597905237000L),
        new PayEvent(3L, "create", 1597905239000L)

).assignTimestampsAndWatermarks(new BoundedOutOfOrdernessGenerator()).keyBy(new KeySelector<PayEvent, Object>() {
@Override
public Object getKey(PayEvent value) throws Exception {
return value.getUserId();
}
});

然后对匹配的结果进行分流,select 在这里有三个参数,第一个是超时消息的侧输出 Tag,第二个参数是超时消息的处理逻辑,第三个参数是正常的订单消息。

OutputTag<PayEvent> orderTimeoutOutput = new OutputTag<PayEvent>("orderTimeout") {};
Pattern<PayEvent, PayEvent> pattern = Pattern.<PayEvent>
        begin("begin")
        .where(new IterativeCondition<PayEvent>() {
            @Override
            public boolean filter(PayEvent payEvent, Context context) throws Exception {
                return payEvent.getAction().equals("create");
            }
        })
        .next("next")
        .where(new IterativeCondition<PayEvent>() {
            @Override
            public boolean filter(PayEvent payEvent, Context context) throws Exception {
                return payEvent.getAction().equals("pay");
            }
        })
        .within(Time.seconds(600));
PatternStream<PayEvent> patternStream = CEP.pattern(source, pattern);
SingleOutputStreamOperator<PayEvent> result = patternStream.select(orderTimeoutOutput, new PatternTimeoutFunction<PayEvent, PayEvent>() {
    @Override
    public PayEvent timeout(Map<String, List<PayEvent>> map, long l) throws Exception {
        return map.get("begin").get(0);
    }
}, new PatternSelectFunction<PayEvent, PayEvent>() {
    @Override
    public PayEvent select(Map<String, List<PayEvent>> map) throws Exception {
        return map.get("next").get(0);
    }
});
DataStream<PayEvent> sideOutput = result.getSideOutput(orderTimeoutOutput);
sideOutput.printToErr();
env.execute("execute cep");

最后右键运行查看结果,如下图所示:

Drawing 3.png

到此为止,我们三种场景的完整代码实现就完成了。

总结

这一课时我们分别对连续登录、交易活跃用户、超时未支付三种业务场景进行了完整的代码实现,我们实际业务场景中可以根据本节课的内容灵活处理。


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

办公模板库 素材蛙

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

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

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

打赏作者

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

抵扣说明:

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

余额充值