The What, Where, When and How of Data Processing

这一章节继续深入讲解数据处理模式,介绍鲁棒的去数据乱序数据的核心概念,这些概念的应用是流式系统超越批系统的关键所在。

路线图


第一章中讲述了两个重要的概念

  • 事件时间VS处理时间:只有在事件时间维度对数据进行处理,才能保证计算结果的准确性。
  • 窗口:处理无界数据的通用方法。
    接下介绍三个同样重要的概念:
  • 触发器(Triggers):触发器是决定某个窗口何时触发的机制,可以理解为类似相机的快门,允许你去决定将数据计算为快照的时间。触发器也可以观察到一个窗口多次的机型结果,因此可以实现对延迟数据的处理。
  • 水位(Watermarks):是描述事件时间完整性的概念,一个在X的时间点的水位线表示X时间点以前的数据都被处理过了。本章会粗浅介绍基本概念,第三章节会进行深入讲解。
  • 累积(Accumulation):累积模式可以描述出相同窗口下不同结果,有些结果可以完成不相关,有些可能会重叠,不同的累积模式也有不同的语义和执行代价,需要根据实际的用例情况来选择合适的累积模式。
    接下来抛出处理无界数据的4个问题:
  • 计算什么结果(What results are calculated?), 这个问题是由实际的任务流程定义的,比如求和、求直方图、机器学习等,这也是批处理系统所解决的经典问题
  • 在事件时间的哪个地方计算结果(Where in event time are results calculated)?这个问题是由实际的任务流程中所使用事件时间窗口所定义的。可以是上一章介绍的Fixed/Sliding/Session Window, 也可以是更负责类型的窗口,比如限时拍卖。也可以是处理时间窗口,如果你可以将事件时间分配给数据到达系统的时间。
    在什么处理时间点,可以输出结果(When in processing time are results materialized)?触发器和水位可以处理这个问题,这个主题有很多变种,最常用的模式是处理重复的更新的场景(例如物化视图语义)。利用一个水位线来指示一个窗口的输入数据已经完整了。
  • 如何更新结果(How do refinements of results relate)?三种方式可以解决这个问题:discarding,accumulating和accumulating and retracting。下文会对这三种模式做更详细介绍。

批处理的基础:What&Where


What:Transformations

批处理中,用变换(Transformations)解决 “Whatresults are calculated?”这个问题。
接下来用一个实例来说明。假设我们要算一次电子游戏比赛中,某一队的总得分。这个例子的特点:对输入数据,在主键上,进行求和计算。具体数据如下:



各列数据含义:

  • Score:队中每个队员得分
  • EventTime:队员得分时间
  • ProcTime:数据进入系统进行计算的时间
    对数据以EventTime和ProcessTime作图,如下所示:



    我们用Beam伪代码来实现这个示例,如果你之前用过Flink或Spark,那么代码理解起来应该相对简单。首先介绍一下Beam的基本知识,Beam中有两类基本操作:

  • PCollections:可以被并发处理的数据集
  • PTransforms:对数据集进行的操作。比如group/aggregate等,读如PCollection并产生新的PCollection。


PCollection raw = IO.read(...);  //读入原始数据
//将原始数据解析成格式划数据,其中Team为String类型,是主键。score是整型。
PCollection> scores =
input.apply(Sum.integersPerKey()); // 在每个主键上,对score做求和操作

我们通过一个时序图来看看以上代码是如何处理这些数据的:



图中,X轴是EventTime,Y轴是Processing Time,黑色的线表示随着时间推移对所有数据进行计算,前三幅图白色的数字(12,30,48)为该processing time时间点上,计算的中间结果,在批处理中,这些中间结果会被保存下来。最后一幅图是指整个计算完整个数据集之后,输出最终结果48。这就是整个经典批处理的处理过程。由于数据是有界的,因此在process time上处理完所有数据后,就能得到正确结果。但是如果数据集是无界数据的话,这样处理就有问题。接下来我们讨论"Where in event time are results calculated?"这个问题。

Where: Windowing


上一章我们讨论了3中常用的窗口:固定窗口(又称为滚动窗口),滑动窗口和会话窗口。窗口将无界数据源沿着临时边界,切分成一个个有界数据块。
以下是用在Beam中,代码中用窗口如何实现之前整数求和的例子:

 PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))) 
  .apply(Sum.integersPerKey());

理论上批数据是流数据的子集,因此Beam在模型层面对批流做了统一。我们通过时序图看一下在传统批处理引擎中,以上代码是如何执行的:



从时序图上可以看出,在事件时间上,以2分钟为步长,将数据切分到不同的窗口。然后每个窗口的输出进行累加就得到最终结果。
以上我们回顾了时间域(事件时间和处理时间的关系)和窗口的相关知识,接下来看一下触发器,watermark和accumulation这三个概念。

Going Streaming: When & How


切换到流式引擎是一个正确方向,批系统需要等待所有的输入数据,这在无界数据中是行不同的,接下来介绍触发器和水位。

When: The wonderful thing about triggers, is triggers are wonderful things!

触发器解决了‘When in processing time are results materialized?’这个问题。触发器决定了窗口的输出,每个窗口的输出称为窗口的窗格(pane of the window)。接下介绍两种十分有用的触发器。

  • 重复更新触发器(Repeated update triggers),为窗口周期性的生成窗口的窗格。可以为每一条新日志生成窗格也可以每隔一段时间来生成窗格。选择重复更新触发器是一个主要是选择来平衡延迟和成本。
  • 完整性触发器(Completeness triggers),仅当窗口的所有的数据输入完成后才会生成一个窗格,这跟批系统很类似,但是它不需要等系统的所有数据输入后就可以触发计算。
    重复更新触发器是在流式系统中最常用的方法,十分易理解且好实现,是一个十分有用的语义,与数据库物化视图的语义很相近。完整性触发器提供了与批系统很接近的语义,提供了可以归因缺失数据或延迟数据的语义。Watermark是驱动Completeness Triggers被触发的原语。接下来我们会重点介绍watermark。
    我们先看个重复更新触发器的代码示例片段,这个片段实现了每个元素都触发的功能。
 PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))
                .triggering(Repeatedly(AfterCount(1))));
  .apply(Sum.integersPerKey());

从图中看,对于每一个窗口我们可以输出很多个窗格,这可以为我们提供持续性的正确结果,但从另一方面在大数据量的情况下,每一条数据都输出一个窗格会有下游系统造成很大的负担,在实际情况中会定义一个时间延迟,来周期性输出窗格,例如每2分钟。
触发器中处理时间延时有两种方式:

  • 对齐延时:为每一个Key,每一个窗口都分割成固定大小的区域。
  • 非对齐延时:时间延时是有窗口内的数据来决定的,简单来说是触发时间=进入系统的时间 + 延时触发时间。
    对齐延时的伪代码片段如下:
 PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))
               .triggering(Repeatedly(AlignedDelay(TWO_MINUTES)))
  .apply(Sum.integersPerKey());

时序图:



上图表示,Process Time上,每两分钟各个窗口都输出一次数据。Spark streaming中micro-batch就是对齐延时的一种实现。好处是会定期输出结果。缺点是如果数据有负载高峰,在tps很高的时候,系统的负载也会很高。会导致延时。非对齐延时的代码实现如下:

 PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))
               .triggering(Repeatedly(UnalignedDelay(TWO_MINUTES))
  .apply(Sum.integersPerKey());

时序图:



上图中,每个Event Time窗口中,当窗口中有数据时,会在数据的Process Time上,被切成2min大小的数据块。没有数据时,这个窗口是不进行计算的。每个窗口的输出时间是不同的。也就是所谓的每个窗口的输出‘非对齐’模式。这种模式与对齐模式相比的好处是:在每个窗口上,负载更均衡。比如某个event time窗口中出现流量高峰,会立即进行计算输出结果,而不会依赖其他窗口的情况。但最终,两种模式的延时是相同的。
重复更新触发器使用和理解起来非常简单,但不能保证计算结果的正确性,无法处理late event。而Completeness triggers(完整性触发器)能解决这个问题。我们先来了解一下watermark。


When: Watermarks

水位线为”When in processing time are results materialized“的问题提供了答案。watermark是某个event time窗口中所有数据都到齐的标志。一旦窗口的watermark到了,那么这个event time窗口里的数据就到齐了,可以进行计算了。下图是event time和process time的关系。图中的红线就是watermark。Event Time和Process Time的关系可以表示为:F(P)->E,F这个公式就是watermark。



有两种类型的Watermark:

  • 完美的Watermark:完美的了解所有的输入数据,所有的数据均会在时间X之前处理完成。
  • 启发式的Watermark:对于很多分布式组件来说,了解所有的数据信息是不现实的。无法确定所有的数据是否可以在时间X之前完成,所以需要启发式Watermark,启发式Watermark会根据条件推断出数据会在时间X之前全部到齐。但是推断可能是错误的,可能会产生late data。后边将会介绍如何处理late data。
    watermark标志着Event Time窗口中的数据是否完整,是Completeness triggers的基础。下面看个completeness triggers的示例代码:
  PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))
               .triggering(AfterWatermark()))
  .apply(Sum.integersPerKey());

我们注意到,代码中watermark是个Function(AfterWatermark)。这个function可以有多种实现方式,比如如果能确切知道数据是否完整,就可以用Prefect Watermark。如果不能,则要使用启发式watermark。下图是在同一个数据集上使用两种不同的watermark的行为,左边是perfect watermark,右边是启发式的watermark。



在以上两种情况中,每次watermark经过event time窗口时,窗口都会输出计算结果。区别是perfect watermark的结果是正确的,但推断型watermark的结果是错误的,少了第一个窗口中‘9’这个数据。

在两个流的outer join场景中,如何判断输入数据是否完整?是否能做join?如果采用在process time上延时的重复更新型触发器进行计算,如果数据在event time有较大延时或者数据有乱序,那么计算结果就错了。在这种场景下,event time上的watermark对处理late event,保证结果正确性,就非常关键了。

当然,没有完美的设计,watermark也有两个明显的缺点:

输出太慢:如果数据流中有晚到数据,越趋近于perfect watermark的watermark,将会一直等这个late event,而不会输出结果,这回导致输出的延时增加。如上图左边的一侧所示。在[12:00,12:02)这个窗口的输出,与窗口第一个元素的event time相比,晚了将近7分钟。对延时非常敏感的业务没办法忍受这么长的延时。
输出太快:启发式watermark的问题是输出太快,会导致结果不准。比如上图中右边一侧所示,对late event ‘9’,被忽略了。
因此,水印并不能同时保证无界数据处理过程中的低延时和正确性。既然重复更新触发器(Repeated update triggers)可以保证低延时,完整性触发器(Completeness triggers),能保证结果正确。那能不能将两者结合起来呢?


When: early/on-time/late triggers FTW!

重复更新触发器和完整性(Completeness/Watermark)触发器,在很多场景下单独使用是不足够的,通常需要结合起来。Beam模型意识到这一点,为了将两种优势触发式优势结合, 在标志完整性触发器基础上进行了扩展,即允许在watermark之前/之时/之后使用标准的重复更新触发器。就产生了3种新的触发器:early/on-time/late trigger。

  • zero or more early panes:在水位线到来之前进行重复性更新触发,可以随着数据的到来提供推测的结果,可以解决完整性触发器结果输出太慢的问题。
  • A single on-time pane:仅在Watermark到达时对窗口进行一次计算,可以认为数据是准确的。
  • Zero or more late panes:可以针对在水位线到达之后的数据进行周期的重复触发计算,在使用perfect watermark的时候可以理解为Zero late pane,在heuristic watermark(启发式水位)的场景中,可以为late data进行触发计算,从而解决因提前计算导致的结果不正确问题。
    接下来一块看一个例子,一个两分钟的固定窗口,在Watermark前使用定长1分钟触发、在Watermark之后每一条数据进行触发。这样既保证了准确性,还不增加延时。Beam代码如下:
PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))
               .triggering(AfterWatermark()
                             .withEarlyFirings(AlignedDelay(ONE_MINUTE))
                             .withLateFirings(AfterCount(1))))
  .apply(Sum.integersPerKey());

这个版本有两个明显的提升:

  • 针对完美触发器结果太慢:这个case中第二个窗口[12:02, 12:04),如果没加earlyFiring,第一个数据在12:02生成在12:09左右才会输出,将近延迟了7分钟,这在业务中是不可接受的,加了earlyFiring之后第一次输出时间是12:06。 在使用启发式水位的场景中也是类似。
  • 针对启发式水位输出太快的问题,在这个case第一个窗口,当9这个元素晚到了之后,系统也可以立即进行触发计算,更正之前的错误结果,保证了数据的正确性。
    完美型watermark和推断型watermark一个非常大的区别是,在完美型watermark例子中,当watermark经过窗口结束边界时,这个窗口里的数据一定是完整的,因此得出该窗口计算结果之后,就可以吧这个窗口的数据全部删除。但启发式watermark中,由于late event的存在,为了保证结果的正确性,需要把窗口的数据保存一段时间。但其实我们根本不知道要把这个窗口的状态保存多长时间。这就引出了一个新的概念:允许延时(allowed lateness)。

When: Allowed Lateness (i.e., Garbage Collection)

举一个实际使用场景的例子,常驻的乱序数据流式系统,例如垃圾收集。在使用启发式水位的情境下,需要对整个生命周期中的每一个窗口的状态进行持久化。在实际生产过程中,状态不可能被无线的保存下去。一个简洁的做法是定义一个horizon(地平线)来表示系统可以allow lateness。超过整个时间的窗口和状态将会被清掉, 也就是说之后的late event不会被系统处理。

Measuring Lateness

在本书中,watermark是指low watermarks, 表示的系统中最后一个未被处理的记录的event time。,也就说无论数据的延迟程度,都可以用其来保证数据的准确性。在其他的系统中可能会有其他的含义。例如在Spark Structured Streaming中是high watermarks,表示的是系统处理第一条记录的事件时间。系统会直接抛弃任何超过用户定义的延迟阈值。也就说系统允许你定义一个你所能接受的最大的时间差,然后扔掉超过这个时间差的其他数据。其对于正确性的保证要低于low watermark语义。

我们看个allow lataness的例子:

 PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))
               .triggering(
                 AfterWatermark()
                   .withEarlyFirings(AlignedDelay(ONE_MINUTE))
                   .withLateFirings(AfterCount(1)))
               .withAllowedLateness(ONE_MINUTE)) 
 .apply(Sum.integersPerKey());

时序图如下:



可以看到我们定义的允许延迟为1分钟,在元素6之后9的到达要超过1分钟,在6被处理完之后1分钟后,该窗口的状态就会被清理,late data 9也就不会被处理。
关于allow lateness的两个重点

  • 如果可能保证使用perfect watermark则不需要出来late data, allow lateness horizon会被优化为0秒。
  • 如果对有限个key做全局聚合则不需要考虑allow lateness问题。因为可以做增量计算,不必要保存所有数据。

How: Accumulation
随着时间推移,我们会为同一个窗口触发出来多个窗格结果,所以我们要面对最后一个问题:”How do refinements of result relate“ , 如何对这些结果进行关联提取。有三种不同的累积模式:

  • Discarding:当一个窗口被生成后,之前存储的状态就可以直接抛弃。这种模式在下游消费者仅做一系列聚合操作的时候非常适用。
  • Accumulating:当一个窗口被生成后,窗口的历史状态都会被保存,每次late event到了之后,都会触发重新计算,更新之前计算结果。这种方式适合下游是可更新的数据存储,比如HBase/带主键的RDS table等。
  • Accumulating and retracting:Accumulating与第二点一样,即保存窗口的所有历史状态。撤回是指,late event到来之后,出了触发重新计算之外,还会把之前窗口的输出撤回。以下两个case非常适合用这种方式:
  1. 如果窗口下游是分组逻辑,并且分组的key已经变了,那late event的最新数据下去之后,不能保证跟之前的数据在同一个分组,因此,需要撤回之前的结果。
  2. 动态窗口中,由于窗口合并,很难知道窗口之前emit的老数据落在了下游哪些窗口中。因此需要撤回之前的结果。

以例子中第二个窗口[12:02,12:04)为例,我们分别看看三种模式的输出结果:

Discarding Accumulating Accumulating&Retracting
Pane 1:inputs=[3] 3 3 3
Pane 2: inputs=[8,1] 9 12 12,-1
Value of final normal pane 9 12 12
Sum of all panes 12 15 12

Discarding(抛弃):同一个窗口的每次输出,都与之前的输出完全独立。本例子中,要算求和的话,只需要把窗口的每次输出都加起来即可。因此Discarding 模式对下游是聚合(SUM/AGG)等场景非常何时。
Accumulating(累积):窗口的会把之前所有state都保存,因此同一个窗口的每个输出,都是之前所有数据的累积值。本例子中,该窗口第一次输出是10,第二次输入是8,之前的状态是10,所以输出是18。如果下游计算直接把两次输出加起来,结果就是错的。
Accumulating & Retracting(累积&撤回):窗口的每个输出,都有一个累积值和一个撤回值。本例中,第一次输出10,第二次输出的是[18,-10],因此下游把窗口的所有输出求和,会减去之前的重复值,得到正确结果18.
Discarding 模式的代码示例如下:

 PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))
               .triggering(
                 AfterWatermark()
                   .withEarlyFirings(AlignedDelay(ONE_MINUTE))
                   .withLateFirings(AtCount(1)))
               .discardingFiredPanes())
  .apply(Sum.integersPerKey());

使用启发式水印,在流计算引擎中,上述代码对应的时序图如下:

Accumulating&Retraction示例代码:

 PCollection> scores = input
  .apply(Window.into(FixedWindows.of(TWO_MINUTES))
               .triggering(
                 AfterWatermark()
                   .withEarlyFirings(AlignedDelay(ONE_MINUTE))
                   .withLateFirings(AtCount(1)))
               .accumulatingAndRetractingFiredPanes())
  .apply(Sum.integersPerKey());

时序图如下:

三种模式时序图放在一起比较如下:

三个图从左到右分别为discarding,accumulation,accumulation&retraction三种模式的时序图。在计算消耗(单作业使用的资源)和存储消耗上,从左到右依次增加。

总结


以下为本章的主要概念:

  • Event time vs processing time(事件时间 vs. 处理时间)
  • 窗口
  • 触发器
  • Watermarks
  • Accumulation
    本章主要解决的四个问题:
    What results are calculated? = transformations.
    Where in event time are results calculated? = windowing.
    When in processing time are results materialized? = triggers + watermarks.
    How do refinements of results relate? = accumulation.
    流计算的本质,就是平衡正确性,延时和资源这三者的关系。

你可能感兴趣的:(The What, Where, When and How of Data Processing)