传统的批处理拥有巨大 吞吐量 的优势,但是随之而来的是极其 高延迟 的缺陷。
随着大数据系统的不断发展,传统的批处理已然无法全部满足对 时效性 要求愈加严苛的业务需求。
为了适应逐渐变得 「实时」 的年代,大数据系统架构也由简单的批处理转向批流混合的Lambda架构,最后可能会逐渐演变成只有流计算的 高精准高时效 的Kappa架构。
无论是看起来像是过渡期产物的批流混合,还是感觉像是 「终结者」 的纯流式计算,都离不开最核心的计算组件:流式计算系统。
做为当今最火热的流式计算引擎,Flink以其卓越的性能、高度可信的正确性等种种特性收获了大量粉丝。
本文作为学习Flink的前置知识,将从 时域、窗口、时间推理工具、强正确性方案 等方面讨论流式计算系统的核心概念,为初学者揭开其神秘面纱。
从本文中你将了解到:
值得注意的是,本文并不涉及任何具体的流式计算引擎,这意味着本文中的所有概念在几乎所有流式计算系统中都是通用的(Flink、SparkStreaming、StructuredStreaming等),因为大部分流式计算系统的抽象模型大体一致。
在进行正文描述之前,我们先规定流式计算系统中的基本术语,正文内容将会基于这些术语进行讨论。
无限数据是一种不断增长的,本质上 无限的数据集。
也常被称为 流数据,但是我们这里只用 无限数据 这个概念来描述它。
因为流数据语义上 与流式计算强制绑定,但是实际上无限数据也经常使用批处理工具来计算,比如在一个源源不断增长的数据集上进行 T+1天 的计算。
数据是一个无限增长的数据集,但是处理工具是批处理,每次只处理前一天的数据。
如果这里用流数据来描述可能经常会让人误以为其是一个流式计算系统处理的数据集。
数据乱序是指 服务端接收的数据顺序并不是客户端数据产生的顺序 的现象。
互联网中的数据流并 不会按照人们事先预想的顺序进行传输,这是现实生活中的真实体现。
不同的客户端按顺序发出的数据包可能因为各种原因的影响,服务端接受到的时候有极大的可能顺序是和客户端发送顺序不一致的,这就是数据的乱序。
批处理是一种通过将无限数据 划分成最终一致的有限批次数据 的处理方式。
如前文描述,T+1的批处理将一个无限数据集按天划分成一批批的数据集,每个批次中的数据都是 不可变的、有限的。
现实中有很多用批处理系统来处理无限数据的场景,对于乱序的数据,批处理通过 拉长时间窗口 的做法来保持 结果的正确性。
比如T+1每天一个时间窗口,那么除非数据延迟超过一天,否则人们认为这个批次处理的结果是正确的、没有遗漏的:
当然这种做法也并不是百分百正确,在划分时间界限的附近仍然可能存在乱序的数据,时间窗口越长正确性越高。
流处理是一种 持续的数据处理模式、设计用于无限数据处理的执行引擎。
传统的流处理器经常存在系统不可靠、数据易丢失、结果不准确等缺陷,导致了曾经的一段时间内,流就代表了 「约等于」 的处理结果。
但是随着流处理器的不断发展,现代化的流处理器依托 State、Checkpoint、WAL 等机制支持 准确一次,基本都具备了与批处理平起平坐 正确性:
一些先进的流处理器还会提供让系统可以游刃有余地 应对真实世界中错乱数据 的工具,这就是 超越批处理的时间推理能力:
准确一次 是事件在流处理器中 只被准确地处理一次 的描述。
本文中的 准确一次 ,与经常被提及的 精准一次、Exactly Once 等概念描述上有点区别,精准一次表示事件在流处理器中 只被精确地处理了一次,不多不少正好一次。
但是现实生活中,能够真正做到精准一次的效果是非常难的。
即使数据源、计算引擎、存储系统都能够支持精准一次的语义,但是在某些复合指标的计算过程中(如5分钟内的PV),计算系统进行到一半因为特殊原因奔溃后重启,虽然其将自动将上次计算过程产生的副作用消除,并从数据源重新拉取数据进行计算并输出,看起来就像什么问题都没发生过一样。
但是对于上次到本次计算过程中的某些数据来说,它们确确实实 被计算了两次,只是第一次计算作废且始终保证最终结果是正确的,看起来就像只被处理了一次一样。
所以本文中用 准确一次 的概念来描述这个语义,对于数据结果来说,事件在流处理器中 只被准确的处理了一次。
时域是学习流处理系统的第一门课,大多数从事批处理系统相关工作的同学在第一次接触流处理系统时经常会有疑惑或者概念混淆,其原因大部分是因为没有 时域 的概念。
在批处理系统中,时域可能就只是一个划分处理数据集的工具,并没有其他特殊之处。
但是在流处理系统中,时域是一个最基本的概念,流处理系统的所有计算过程都将围绕着时域来构建。
流处理与批处理最大的不同在于流处理中对时间类别划分比批处理更丰富,且用不同时间类别计算出的数据,结果与意义可能全然不同。
事件时间是 事件真实发生的时间。
由于数据乱序的原因,服务端收到数据时的时间和事件本身的时间可能是相差极大的。
正是因为这种差异,服务端做基于事件时间的计算是 最复杂的,需要对乱序的数据流做处理以 「还原」 真实世界的情况,需要依赖一定的数据缓存。
达到时间是 系统接收到事件的时间,即服务端接收到事件的时间。
达到时间比较少被使用。
处理时间是 系统开始处理到达事件的时间。
在某些场景下,处理时间等于达到时间。
因为处理时间 没有乱序 的问题,所以服务端做基于处理时间的计算是比较简单的,无迟到与乱序数据。
从时间类别的划分上来看,只有事件时间会有乱序的困扰。
在最理想的状态下,事件时间=达到时间=处理时间,在批处理系统中的简单粗暴默认三者相等,所以批处理没有乱序的烦恼。
但是在流处理系统中,要达到这种理想状态 几乎是不可能的,事件时间与处理时间总是会有误差,如下图所示。
现实生活中造成时间乱序的原因有很多,基本都是不可避免的,比如以下几种因素:
等等。
举一个简单的场景,在联网的游戏程序中,游戏结束时会将本地的数据上传到服务器进行排名、得分等结果统计。
某个比较倒霉的哥们,可能在地铁或者隧道等信号不好的场所中,数据发送的过程可能因为外部环境因素而发生意外情况(信号不好、甚至无信号)导致延迟发送甚至无法发送。
在这种情况下,可能原本应该于9点发送的数据包,服务端到10点多才收到,甚至永远收不到。
那么服务端在基于事件时间统计9-10点时间段内游戏的排行时,因为该用户数据迟迟未到,马上计算的话结果将是不正确的(因为少了一个用户的数据),而选择等待的话没人知道该用户的数据何时到来。
这就是基于事件时间计算时,时间乱序带来的困扰。
而如果基于处理时间计算,那么事情将变得十分简单,只需要处理9-10点范围内服务端收到的所有数据即可,但是输出的结果并不是真正正确的结果。
流处理器定义完时域之后,接着需要定义在时域之上的操作,所有流处理器的操作都可以分为两种类型:与时间无关的和与时间有关的。
这种类型的操作往往是最简单的,因为不管是什么类别的时间,都对这类操作 没有任何影响。
比如 过滤、转换 等简单映射,来一条就可以处理一条,处理完一条就可以直接输出,和时间没有任何关系。
基于各类时间的窗口处理 是流处理器中主要的与时间有关的操作。
对拥有时域概念的数据流做操作,就必定会用到窗口这个工具,它的本质就是将无限数据集 沿着时间的边界切分成有限数据集。
在批处理中,窗口就是定义的多久处理一次,每次处理的数据就是根据这个窗口时间(一般都是处理时间)划分出来的有限数据集。
在流处理中,根据不同的时间类别,划分出来的窗口性质也不同:
基于处理时间划分
基于事件时间划分
不论是基于事件时间的窗口还是基于处理时间的窗口,都会有不同的窗口类型可以使用,常见的如:固定窗口、滑动窗口、会话窗口 等。
按照固定的时间片划分数据流,将数据流 分割成具有固定大小的片段。
如图所示:
假设 window-size=1
那么window1、window2、window3等各个窗口的大小永远固定是1,且 各个窗口不会重叠也不会有间隙。
固定窗口是最简单也是最常见的窗口类型。
在固定窗口的基础上,滑动窗口增加了 滑动步长 的定义。
滑动窗口由 固定窗口长度、窗口滑动步长 确定,如下图所示:
假设 window-size=1 & window-slide=0.5
那么表示 窗口长度为1单位且每0.5个单位就向前滑动一个新窗口。
滑动窗口经常被用来统计诸如 每5分钟统计过去10分钟的访问量 的需求,窗口长度为10分钟,滑动步长为5分钟。
滑动窗口的窗口长度和滑动步长的关系如下:
和固定窗口、滑动窗口不一样,会话窗口没有固定的窗口大小定义。
会话窗口的大小由 用户活动事件频率 决定,长度不能被事先定义而取决于实际数据。
比如Web服务器中Session的概念,用户在一定时间内没有后续活动的话Session将会过期,如果用户一直保持活跃的操作,那么Session将一直保留。
会话窗口的划分也类似Session的定义,如下图所示:
每个用户都可能产生多个会话窗口,每个会话窗口的大小取决于该用户是否持续产生活动事件。
会话窗口是批处理引擎不擅长处理的类型,通常用于 分析一段时间内的用户行为。
有了时域和窗口的概念后,基本上我们就拥有了上手流处理程序开发的条件了。
但是此时我们仍然无法了解到,先进的流处理器核心思想到底先进在哪里?它是如何做到和批处理器一样的正确性甚至拥有超越批处理的能力?
本节先从 时间推理工具 的角度来讨论流处理器拥有的 能够正确处理乱序数据的超能力,使其成为超越批处理的事实标准。
在本节中,我们会尝试在这三个问题的回答上更好的理解流处理器的时间推理工具:
这个问题也是经典批处理需要回答的问题,即想得到什么样的数据运算结果,将会被定义在程序代码中。
比如简单的转换操作、复杂的窗口操作,以及是否做聚合、join等,比较具有代表性的计算结果有 计算总和、构建直方图、训练模型 等。
比较简单的问题,可以理解为用户的业务需求。
从事件时间的维度上看,流处理器执行代码获取计算结果时,必定需要 取某个事件时间范围内的数据进行计算。
假设用一坐标轴表示无限数据,坐标轴上 以事件时间为x轴、以处理时间为y轴 画图,我们可以得到:
以x轴上的事件时间点做切分,将会把坐标图(无限数据) 划分成一片片有界限的数据集。
是不是很眼熟?这就是窗口的作用,将无限数据集 沿着时间的边界切分成有限数据集。
我们在事件时间的维度上定义窗口,就是定义了各个数据片的 数据区域与位置,流数据将会 按照自身携带的事件时间被划分到指定的时间窗口中,流处理器将会取其中某个位置的数据进行计算。
如果是与时间无关的操作则在事件时间的任意位置都能计算。
在事件时间的哪个位置计算 由窗口决定,窗口定义了事件时间的计算位置(区域)。
用窗口在事件时间的维度上定义好计算位置后,流处理器还需要在处理时间的维度上知道,什么时候触发计算。
有些同学到这里会出现一些概念上的混淆,我们不是已经定义过事件时间了吗,为什么还要定义处理时间?
事件时间和处理时间两个管的维度不一样,事件时间是定义 切分数据集的时间边界,而 程序真正要触发计算 需要在处理时间上定义。
可以理解为 到达某个处理时间后,程序取指定事件时间范围内的数据进行计算。
在事件时间的维度上定义了一个个的数据窗口,流数据将会按照自身携带的事件时间被划分到指定的时间窗口中。
我们还需要定义在处理时间的什么时候触发计算,也就是说,什么时候我们才能说某个窗口的数据已经都到了,是个完整的数据集,可以进行计算了。
只有事件时间的流处理中 缺乏对窗口数据完整性的判断。
所以在处理时间的维度上,流处理器需要额外借助一些工具辅助程序 判断某个事件时间窗口是否已经完整,以及是否触发计算。
Watermark 是描述 「事件时间」的输入完整性 的概念,是系统根据当前处理数据的 「事件时间」 判断 「处理进度和完整性」 的工具。
在事件时间维度上划分的各个窗口原本都是 未封闭的,表示 数据还没全部达到。
Watermark 的作用就是给各个窗口 「盖上盖子」,使其成为一个封闭的窗口,表示数据已经全部达到。
如下图所示:
在图中,Watermark 出现表示当前事件时间窗口已完整。
那么用户如何去定义 Watermark ,程序又是怎么判断 Watermark 到了需要关闭窗口进行计算呢?
我们通过一个例子来说明 Watermark 的作用。
设事件时间窗口大小 size=5s,在事件时间的维度上可以划分以下窗口:
窗口序号 | 事件时间范围 |
---|---|
0-5 | 20:10:00 - 20:10:05 |
6-10 | 20:10:06 - 20:10:10 |
定义数据携带的Watermark为 当前事件时间-2s,(Flink中通过SourceFunction的emitWatermark设置,每条数据都会携带一个Watermark)。
数据接收情况如下:
数据序号 | 事件时间 | 所属的窗口 | 携带的Watermark | 当前的Watermark | 备注 |
---|---|---|---|---|---|
第1条 | 20:10:00 | 0-5 | 20:09:58 | 20:09:58 | 第一条直接取携带的WK为系统的WK |
第2条 | 20:10:01 | 0-5 | 20:09:59 | 20:09:59 | 携带的WK比当前WK大,取携带的WK为当前的WK |
第3条 | 20:10:02 | 0-5 | 20:10:00 | 20:10:00 | 同上 |
第4条 | 20:10:00 | 0-5 | 20:09:58 | 20:10:00 | 携带的WK比当前WK小,故继续使用当前WK |
第5条 | 20:10:05 | 0-5 | 20:10:03 | 20:10:03 | 同第3条 |
第6条 | 20:10:06 | 6-10 | 20:10:04 | 20:10:04 | 同上 |
第7条 | 20:10:08 | 6-10 | 20:10:06 | 20:10:06 | 此时 WK已经>=事件时间窗口(0-5),表示第1个窗口已经完整,WK为20:10:06,在当前处理时间维度上「画上」水位线,表示在这之前的数据已经都达到了,可以触发计算 |
第8条 | 20:10:03 | 0-5 | 20:10:01 | 20:10:06 | 这是一条延迟「很久」的数据,0-5的窗口已经关闭 |
可以看到,每条数据过来,都会更新程序中最新的 Watermark。
在第7条数据到达时,其携带的 Watermark 已经 超过了 0-5 这个窗口的边界,那么此时我们可以认为 0-5 这个窗口的所有数据已经达到,可以进行计算。
用户可以根据业务与数据情况自定义每条数据应该携带怎样的 Watermark,而系统接收到数据时,根据当前 Watermark 是否超出某事件时间的窗口边界来判断该事件时间窗口是否完整。
那么用户该 如何定义具体的 Watermark 的值 呢。
下面我们来介绍两种定义 Watermark 的方式,来帮助用户设置 Watermark 的值。
完美式的 Watermark 是在用户 完全了解输入数据的前提下,构建出完美的水位线,不会有数据超过水位线。
也就是说,在完美式的 Watermark 中,不会有任何数据被遗漏,所有数据在完美式的 Watermark 下都能够准时达到。
这是最完美的一种情况,但是真实业务场景中使用完美式的 Watermark 往往要付出比较大的代价。
因为其要兼顾所有数据,注定了 Watermark 会在比较晚的时间后才能到来。
比如 当前事件时间-10m,在窗口大小为10s的程序中,这意味着第一个窗口要 等到10分钟之后的数据出现 才可能会被关闭。
但是正因为较大的 Watermark 值,只要某窗口中迟到的数据在其窗口边界10m之内达到,都是不会被遗漏的。
在实际应用中,完全了解输入数据是不切实际的,且数据的乱序延迟现象总比用户想象的要糟糕。
因而,完美式的 Watermark 往往是一个比较大的值,但在某些高时效性要求的系统中,完美式的 Watermark 带来的高延迟往往是不能被接受的。
所以我们需要另外一种启发式的 Watermark,其 能够在保持低延迟的同时,最大可能的保持窗口的完整性。
启发式的 Watermark 一般都是用户根据数据情况,比如 分区、分区内排序、文件增长率等 提供尽可能准确的进度估计,设置一个较为理想的值。
有了 Watermark 之后,虽然用户可以以此来判定 窗口是否完整,但窗口完整并不意味着要触发计算,只能说满足了触发计算的条件。
真正决定在处理时间的什么时候触发计算的是 Trigger,其是描述 何时「计算窗口」的机制 。
Trigger 的触发计算信号可以从以下几个维度来定义:
触发器可以是简单的触发器,即以上任意一种,也可是是复合的触发器,即以上 多种触发条件的组合。
有了 Trigger 的定义之后,我们再来看看 完美式Watermark 和 启发式Watermark 中的缺点如何通过 Trigger 的组合来避免。
对于完美式的Watermark,可以通过 窗口+固定处理时间 多重触发器组合的方式,在 Watermark 到来之前,提前或周期触发计算并输出,达到低延迟的效果,Watermark 到来后也会触发一次计算。
对于启发式的Watermark,通过 窗口+LastestDelay 多重触发器组合的方式,定义 LastestDelay 的大小,可以延迟计算处理迟到数据。LastestDelay 为最大允许的延迟时间,可以在窗口关闭之后将迟到的数据划入特定空间中等待补充计算。但是 LastestDelay 本身也有大小限制,仍然可能遗漏极端延迟的数据。
由于 Watermark 本身存在严重的缺陷,数据完整性与低延迟不可兼得,且在极端情况下仍然 不可保证所有数据都被处理到。所以,只根据 Watermark 来决定是否开始处理数据是比较不精准的。
通过 Trigger 的定义可以做到让事件时间窗口尽可能的完整,且延迟尽可能的低。
现在,我们来总结一下关于流处理器的时间推理工具的三个问题:
现在,你知道流处理器的时间推理工具是什么了吗?
时间推理工具让流处理器站在了批处理器的之上,使其能够真正地处理现实世界中的乱序问题。
但是流处理器中还有一个问题未解决,那就是 正确性如何保证?
在批处理器中,同一批数据、同一个程序重复计算的结果应该是 始终一致 的,这样一来即使批处理器执行过程中挂了,用户也可以通过一些补数的手段重跑,以 保证最后结果的正确性。
那么对于流处理器来说,流处理器执行过程中宕机重启之后 是否能够保持结果数据的正确性与一致性 是现代流处理器的基本素质。
抛开时间推理工具不说,能够保持强正确性的流处理器可以直接取代系统中的批处理器,而不会出现结果不一致的情况。
下面我们来讨论现代流处理器中,常见的保持强正确性的工具。
State 即为状态,流处理器中常用来缓存窗口数据、程序运行时状态、数据源偏移量等信息。
可以简单理解为流处理器中的一块内存区域(或者使用了外部数据库来存储)。
为什么流处理器需要 State?
用户的计算需求中经常使用到状态的场景:
状态的存储实现一般有以下几种:
State不仅给用户提供了高性能实现计算需求的方案,也是流处理器保持强正确性的工具之一。
除了 State 之外,流处理器还需要一种 可以将 State 中的数据进行备份与恢复的机制 才能保证 任何时刻流处理器的宕机重启都不会影响最后的正确结果。
这种机制就是 Checkpoint(分布式全域一致快照),其包含流处理器全链路中的信息:
通过 Checkpoint,流处理器可以定时的备份系统中的状态与数据,并在必要时刻提供帮助。
以 Flink 中 Checkpoint 为例,其一个生成周期如下:
Checkpoint 生成之后,如果需要状态恢复与故障容错,则所有节点从hdfs中读取 「上次的数据位置」 来重置消息队列,并从 「上次的状态」 开始重新计算。
Checkpoint 机制对数据源有一定要求,即数据源的必要条件为 支持重放。
通过 State 与 Checkpoint 的结合使用,流处理器可以保持结果数据的强一致性。
从以上我们讨论 State、Checkpoint 等机制来看,它们只能保证在 流处理器内部的准确一次。
Sink在使用了外部存储的情况下,在本次 Checkpoint 和上次 Checkpoint 之间源源不断写数据到外部存储中。
即使流处理器宕机从恢复点重启了,那么 之前处理的数据实际上已经写到了外部存储中,这种情况下就不能称之为端到端的准确一次了。
借助可重放的数据源、State/Checkpoint的流处理器,我们可以保证数据源到计算引擎的准确一次,那么使用外部存储的情况下如何保证端到端的准确一次?