Flink中事件时间和水位线是做基于时间的处理计算的基础。之前我们已经了解了Flink中基本的聚合操作。在流处理中,我们往往需要面对的是连续不断、无休无止的无界流,不可能等到所有所有数据都到齐了才开始处理。所以聚合计算其实只能针对当前已有的数据——之后再有数据到来,就需要继续叠加、再次输出结果。这样似乎很“实时”,但现实中大量数据一般会同时到来,需要并行处理,这样频繁地更新结果就会给系统带来很大负担了。更加高效的做法是,把无界流进行切分,每一段数据分别进行聚合,结果只输出一次。这就相当于将无界流的聚合转化为了有界数据集的聚合,这就是所谓的“窗口”(Window)聚合操作。窗口聚合其实是对实时性和处理效率的一个权衡。在实际应用中,往往更关心一段时间内数据的统计结果,比如在过去的1分钟内有多少用户点击了网页。在这种情况下,我们就可以定义一个窗口,收集最近一分钟内的所有用户点击数据,然后进行聚合统计,最终输出一个结果就可以了。
1、窗口是什么
Flink是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这就是所谓的“窗口”(Window)。在Flink中, 窗口就是用来处理无界流的核心。我们很容易把窗口想象成一个固定位置的“框”,数据源源不断地流过来,到某个时间点窗口该关闭了,就停止收集数据、触发计算并输出结果。例如,我们定义一个时间窗口,每10秒统计一次数据,那么就相当于把窗口放在那里,从0秒开始收集数据;到10秒时,处理当前窗口内所有数据,输出一个结果,然后清空窗口继续收集数据;到20秒时,再对窗口内所有数据进行计算处理,输出结果;依次类推,如下图所示:
这里注意为了明确数据划分到哪一个窗口,定义窗口都是包含起始时间、不包含结束时间的,用数学符号表示就是一个左闭右开的区间,例如0~10秒的窗口可以表示为[0, 10),这里单位为秒。对于处理时间下的窗口而言,这样理解似乎没什么问题。因为窗口的关闭是基于系统时间的,赶不上这班车的数据,就只能坐下一班车了——正如上图中,0~10秒的窗口关闭后,可能还有时间戳为9的数据会来,它就只能进入10~20秒的窗口了。这样会造成窗口处理结果的不准确。然而如果我们采用事件时间语义,就会有些费解了。由于有乱序数据,我们需要设置一个延迟时间来等所有数据到齐。比如上面的例子中,我们可以设置延迟时间为2秒,如下图所示,这样0~10秒的窗口会在时间戳为12的数据到来之后,才真正关闭计算输出结果,这样就可以正常包含迟到的9秒数据了。
但是这样一来,0~10秒的窗口不光包含了迟到的9秒数据,连11秒和12秒的数据也包含进去了。我们为了正确处理迟到数据,结果把早到的数据划分到了错误的窗口——最终结果都是错误的。所以在Flink中,窗口其实并不是一个“框”,流进来的数据被框住了就只能进这一个窗口。相比之下,我们应该把窗口理解成一个“桶”,如下图所示。在Flink中,窗口可以把流切割成有限大小的多个“存储桶”(bucket);每个数据都会分发到对应的桶中,当到达窗口结束时间时,就对每个桶中收集的数据进行计算处理。
我们可以梳理一下事件时间语义下,之前例子中窗口的处理过程:
(1)第一个数据时间戳为2,判断之后创建第一个窗口[0, 10),并将2秒数据保存进去;
(2)后续数据依次到来,时间戳均在[0, 10)范围内,所以全部保存进第一个窗口;
(3)11秒数据到来,判断它不属于[0, 10)窗口,所以创建第二个窗口[10, 20),并将11秒的数据保存进去。由于水位线设置延迟时间为2秒,所以现在的时钟是9秒,第一个窗口也没有到关闭时间;
(4)之后又有9秒数据到来,同样进入[0, 10)窗口中;
(5)12秒数据到来,判断属于[10, 20)窗口,保存进去。这时产生的水位线推进到了10秒,所以[0, 10)窗口应该关闭了。第一个窗口收集到了所有的7个数据,进行处理计算后输出结果,并将窗口关闭销毁;
(6)同样的,之后的数据依次进入第二个窗口,遇到20秒的数据时会创建第三个窗口[20, 30)并将数据保存进去;遇到22秒数据时,水位线达到了20秒,第二个窗口触发计算,输出结果并关闭。
这里需要注意的是,Flink中窗口并不是静态准备好的,而是动态创建——当有落在这个窗口区间范围的数据达到时,才创建对应的窗口。另外,这里我们认为到达窗口结束时间时,窗口就触发计算并关闭,事实上“触发计算”和“窗口关闭”两个行为也可以分开,这部分内容我们会在后面详述。
2、窗口的分类
按照驱动类型分类
窗口本身是截取有界数据的一种方式,所以窗口一个非常重要的信息其实就是“怎样截取数据”。换句话说,就是以什么标准来开始和结束数据的截取,我们把它叫作窗口的“驱动类型”。我们最容易想到的就是按照时间段去截取数据,这种窗口就叫作“时间窗口”(Time Window)。这在实际应用中最常见,之前所举的例子也都是时间窗口。除了由时间驱动之外,窗口其实也可以由数据驱动,也就是说按照固定的个数,来截取一段数据集,这种窗口叫作“计数窗口”(Count Window),
时间窗口(Time Window)
时间窗口以时间点来定义窗口的开始(start)和结束(end),所以截取出的就是某一时间段的数据。到达结束时间时,窗口不再收集数据,触发计算输出结果,并将窗口关闭销毁。所以可以说基本思路就是“定点发车”。用结束时间减去开始时间,得到这段时间的长度,就是窗口的大小(window size)。这里的时间可以是不同的语义,所以我们可以定义处理时间窗口和事件时间窗口。Flink中有一个专门的类来表示时间窗口,名称就叫作TimeWindow。这个类只有两个私有属性:start和end,表示窗口的开始和结束的时间戳,单位为毫秒。
@PublicEvolving public class TimeWindow extends Window { private final long start; private final long end;
可以调用公有的getStart()和getEnd()方法直接获取这两个时间戳。另外,TimeWindow还提供了一个maxTimestamp()方法,用来获取窗口中能够包含数据的最大时间戳。
/** * Gets the largest timestamp that still belongs to this window. * * <p>This timestamp is identical to {@code getEnd() - 1}. * * @return The largest timestamp that still belongs to this window. * @see #getEnd() */ @Override public long maxTimestamp() { return end - 1; }
很明显,窗口中的数据,最大允许的时间戳就是end -1,这也就代表了我们定义的窗口时间范围都是左闭右开的区间[start,end)。或许有较真的读者会问,为什么不把窗口区间定义成左开右闭、包含上结束时间呢?这样maxTimestamp跟end一致,不就可以省去一个方法的定义吗?这主要是为了方便判断窗口什么时候关闭。对于事件时间语义,窗口的关闭需要水位线推进到窗口的结束时间;而我们知道,水位线Watermark(t)代表的含义是“时间戳小于等于t的数据都已到齐,不会再来了”。为了简化分析,我们先不考虑乱序流设置的延迟时间。那么当新到一个时间戳为t的数据时,当前水位线的时间推进到了t –1(还记得乱序流里生成水位线的减一操作吗?)。所以当时间戳为end的数据到来时,水位线推进到了end -1;如果我们把窗口定义为不包含end,那么当前的水位线刚好就是maxTimestamp,表示窗口能够包含的数据都已经到齐,我们就可以直接关闭窗口了。所以有了这样的定义,我们就不需要再去考虑那烦人的“减一”了,直接看到时间戳为end的数据,就关闭对应的窗口。如果为乱序流设置了水位线延迟时间delay,也只需要等到时间戳为end + delay的数据,就可以关窗了。
计数窗口(Count Window)
计数窗口基于元素的个数来截取数据,到达固定的个数时就触发计算并关闭窗口。这相当于座位有限、“人满就发车”,是否发车与时间无关。每个窗口截取数据的个数,就是窗口的大小。计数窗口相比时间窗口就更加简单,我们只需指定窗口大小,就可以把数据分配到对应的窗口中了。在Flink内部也并没有对应的类来表示计数窗口,底层是通过“全局窗口”(Global Window)来实现的。
按照窗口分配数据的规则分类
小圆点表示流中的数据,我们对数据按照userId做了分区。当固定了窗口大小之后,所有分区的窗口划分都是一致的;窗口没有重叠,每个数据只属于一个窗口。滚动窗口应用非常广泛,它可以对每个时间段做聚合统计,很多BI分析指标都可以用它来实现。
滑动窗口(Sliding Windows)
我们可以看到,当滑动步长小于窗口大小时,滑动窗口就会出现重叠,这时数据也可能会被同时分配到多个窗口中。而具体的个数,就由窗口大小和滑动步长的比值(size/slide)来决定。如上图所示,滑动步长刚好是窗口大小的一半,那么每个数据都会被分配到2个窗口里。比如我们定义的窗口长度为1小时、滑动步长为30分钟,那么对于8点55分的数据,应该同时属于[8点, 9点)和[8点半, 9点半)两个窗口;而对于8点10分的数据,则同时属于[8点, 9点)和[7点半, 8点半)两个窗口。所以,滑动窗口其实是固定大小窗口的更广义的一种形式;换句话说,滚动窗口也可以看作是一种特殊的滑动窗口——窗口大小等于滑动步长(size = slide)。当然,我们也可以定义滑动步长大于窗口大小,这样的话就会出现窗口不重叠、但会有间隔的情况;这时有些数据不属于任何一个窗口,就会出现遗漏统计。所以一般情况下,我们会让滑动步长小于窗口大小,并尽量设置为整数倍的关系。在一些场景中,可能需要统计最近一段时间内的指标,而结果的输出频率要求又很高,甚至要求实时更新,比如股票价格的24小时涨跌幅统计,或者基于一段时间内行为检测的异常报警。这时滑动窗口无疑就是很好的实现方式。
会话窗口(Session Windows)
会话窗口顾名思义,是基于“会话”(session)来来对数据进行分组的。这里的会话类似Web应用中session的概念,不过并不表示两端的通讯过程,而是借用会话超时失效的机制来描述窗口。简单来说,就是数据来了之后就开启一个会话窗口,如果接下来还有数据陆续到来,那么就一直保持会话;如果一段时间一直没收到数据,那就认为会话超时失效,窗口自动关闭。这就好像我们打电话一样,如果时不时总能说点什么,那说明还没聊完;如果陷入了尴尬的沉默,半天都没话说,那自然就可以挂电话了。与滑动窗口和滚动窗口不同,会话窗口只能基于时间来定义,而没有“会话计数窗口”的概念。这很好理解,“会话”终止的标志就是“隔一段时间没有数据来”,如果不依赖时间而改成个数,就成了“隔几个数据没有数据来”,这完全是自相矛盾的说法。而同样是基于这个判断标准,这“一段时间”到底是多少就很重要了,必须明确指定。对于会话窗口而言,最重要的参数就是这段时间的长度(size),它表示会话的超时时间,也就是两个会话窗口之间的最小距离。如果相邻两个数据到来的时间间隔(Gap)小于指定的大小(size),那说明还在保持会话,它们就属于同一个窗口;如果gap大于size,那么新来的数据就应该属于新的会话窗口,而前一个窗口就应该关闭了。在具体实现上,我们可以设置静态固定的大小(size),也可以通过一个自定义的提取器(gap extractor)动态提取最小间隔gap的值。考虑到事件时间语义下的乱序流,这里又会有一些麻烦。相邻两个数据的时间间隔gap大于指定的size,我们认为它们属于两个会话窗口,前一个窗口就关闭;可在数据乱序的情况下,可能会有迟到数据,它的时间戳刚好是在之前的两个数据之间的。这样一来,之前我们判断的间隔中就不是“一直没有数据”,而缩小后的间隔有可能会比size还要小——这代表三个数据本来应该属于同一个会话窗口。所以在Flink底层,对会话窗口的处理会比较特殊:每来一个新的数据,都会创建一个新的会话窗口;然后判断已有窗口之间的距离,如果小于给定的size,就对它们进行合并(merge)操作。在Window算子中,对会话窗口会有单独的处理逻辑。
我们可以看到,与前两种窗口不同,会话窗口的长度不固定,起始和结束时间也是不确定的,各个分区之间窗口没有任何关联。如图6-19所示,会话窗口之间一定是不会重叠的,而且会留有至少为size的间隔(session gap)。在一些类似保持会话的场景下,往往可以使用会话窗口来进行数据的处理统计。
全局窗口(Global Windows)
还有一类比较通用的窗口,就是“全局窗口”。这种窗口全局有效,会把相同key的所有数据都分配到同一个窗口中;说直白一点,就跟没分窗口一样。无界流的数据永无止尽,所以这种窗口也没有结束的时候,默认是不会做触发计算的。如果希望它能对数据进行计算处理,还需要自定义“触发器”(Trigger)
可以看到,全局窗口没有结束的时间点,所以一般在希望做更加灵活的窗口处理时自定义使用。Flink中的计数窗口(Count Window),底层就是用全局窗口实现的。