Flink 的每个模式包含多个状态,模式匹配的过程就是状态转换的过程,每个状态(state)可以理解成由Pattern构成,为了从当前的状态转换成下一个状态,用户可以在Pattern上指定条件,用于状态的过滤和转换。
实际上Flink CEP 首先需要用户创建定义一个个pattern,然后通过链表将由前后逻辑关系的pattern串在一起,构成模式匹配的逻辑表达。然后需要用户利用NFACompiler,将模式进行分拆,创建出NFA(非确定有限自动机)对象,NFA包含了该次模式匹配的各个状态和状态间转换的表达式。整个示意图就像如下:
(1)Take: 表示事件匹配成功,将当前状态更新到新状态,并前进到“下一个”状态;
(2)Procceed: 当事件来到的时候,当前状态不发生变化,在状态转换图中事件直接“前进”到下一个目标状态;
(3)IGNORE: 当事件来到的时候,如果匹配不成功,忽略当前事件,当前状态不发生任何变化。
为了更好地理解上述概念,本文利用下面的代码,构建一个NFA:
//构建链接
patterns Pattern pattern = Pattern.begin("start").where(new SimpleCondition() {
private static final long serialVersionUID = 5726188262756267490L;
@Override public boolean filter(Event value) throws Exception {
return value.getName().equals("c");
}
}).followedBy("middle").where(new SimpleCondition() {
private static final long serialVersionUID = 5726188262756267490L;
@Override public boolean filter(Event value) throws Exception {
return value.getName().equals("a");
}
}).optional();
//创建nfa
NFA nfa = NFACompiler.compile(pattern, Event.createTypeSerializer(), false);
加入数据源如下:
Event event0 = new Event(40, "x", 1.0);
Event event1 = new Event(40, "c", 1.0);
Event event2 = new Event(42, "b", 2.0);
Event event3 = new Event(43, "a", 2.0);
Event event4 = new Event(44, "c", 2.0);
Event event5 = new Event(45, "b", 5.0);
理论上的输出结果:[event1] 和 [event1,event3]。
分析:当第一条消息event0来的时候,由于start状态只有Take状态迁移边,这时event0匹配失败,消息被丢失,start状态不发生任何变化;当第二条消息event1来的时候,匹配成功,这时用event1更新start当前状态,并且进入下一个状态,即mid状态。而这时我们发现mid状态存在Proceed状态迁移边,意味着事件到来时,可以直接进入下一个状态,即endstat状态,说明匹配结束,存在第一个匹配结果[event1];当第三条消息event2到来时,由于之前我们已经进入了mid状态,所以nfa会让我们先匹配mid的条件,匹配失败,由于mid状态存在Ingore状态迁移边,所以当前mid状态不发生变化,event2继续往回匹配start的条件,匹配失败,这时event2被丢弃。当第四条消息event3来临时,匹配mid的条件成功,更新当前mid状态,并且进入“下一个状态”,那就是endstat状态,说明匹配结束,存在第二个匹配结果[event1, event3]。
在引入SharedBuffer概念之前,我们先把上图的例子改一下,将原先的连接关系由followedBy,改成followedByAny。
//构建链接
patterns Pattern pattern = Pattern.begin("start").where(new SimpleCondition() {
private static final long serialVersionUID = 5726188262756267490L;
@Override public boolean filter(Event value) throws Exception {
return value.getName().equals("c");
}
}).followedByAny("middle").where(new SimpleCondition() {
private static final long serialVersionUID = 5726188262756267490L;
@Override public boolean filter(Event value) throws Exception {
return value.getName().equals("b");
}
});
followedByAny是非严格的匹配连接关系。表示前一个pattern匹配的事件和后面一个pattern的事件间可以间隔多个任意元素。所以上述的例子输出结果是[event1, event2]、[event4, event5]和[event1, event5]。当匹配event1成功后,由于event2还没到来,需要将event1保存到state1,这样每个状态需要缓冲堆栈来保存匹配成功的事件,我们把各个状态的对应缓冲堆栈集称之为缓冲区。由于上述例子有三种输出,理论上我们需要创建三个独立的缓冲区。
做三个独立的缓冲区实现上是没有问题,但是我们发现缓冲区3状态stat1的堆栈和缓冲区1状态stat1的堆栈是一样的,我们完全没有必要分别占用内存。而且在实际的模式匹配场景下,每个缓冲区独立维护的堆栈中可能会有大量的数据重叠。随着流事件的不断流入,为每个匹配结果独立维护缓存区占用内存会越来越大。所以Flink CEP 提出了共享缓存区的概念(SharedBuffer),就是用一个共享的缓存区来表示上面三个缓存区。
在共享缓冲区实现里头,Flink CEP设计了一个带版本的共享缓冲区。它会给每一次匹配分配一个版本号并使用该版本号来标记在这次匹配中的所有指针。但这里又会面临另一个问题:无法为某次匹配预分配版本号,因为任何非确定性的状态都能派生出新的匹配。而解决这一问题的技术是采用杜威十进制分类法[^1]来编码版本号,它以(.)?(1≤j≤t)的形式动态增长,这里t关联着当前状态。直观地说,它表示这次运行从状态开始被初始化然后到达状态,并从中分割出的实例,这被称之为祖先运行。这种版本号编码技术也保证一个运行的版本号v跟它的祖先运行的版本号兼容。具体而言也就是说:① v包含了v’作为前缀;② v与v’仅最后一个数值不同,而对于版本v而言要大于版本v’。根据对这段话的理解,上述共享区从e5往回查找数据,可以达到两条路径分别是[e4,e5]和[e1, e5]。
学了这么久的Flink,令我感触最深的有如下几点:① 基于事件驱动流处理;② 处理乱序的watermark机制;③ 容错机制,以及与kafka恰好一次的完美兼容;④ 基于sql的流式处理;⑤ 提供了处理复杂事件的CEP库。
Flink也就告一段落了,后面我会在业余时间不断学习相关知识。打好基础,为了解源码做准备。