跳转至

概念:流计算中的At Most Once、At Least Once、Exactly Once

目前市面上使用较多的流计算系统有 Apache StormApache FlinkHeronApache KafkaKafka Streams )和 Apache SparkSpark Streaming )。关于流计算系统有个被广泛讨论的特性是 Exactly Once 语义,很多系统宣称已经支持了这一特性。但是,到底什么是 Exactly Once ,怎么样才算是实现了 Exactly Once ,人们存在很多误解和歧义。接下来我们做下分析。

1 背景

流处理(有时称为事件处理)可以简单的描述为是对无界数据或事件的连续处理。流或事件处理应用程序可以或多或少的描述为有向图,并且通常被描述为有向无环图( DAG )。在这样的图中,每个边表示数据或事件流,每个顶点表示运算符,会使用程序中定义的逻辑处理来自邻边的数据或事件。有两种特殊类型的顶点,通常称为 sourcessinkssources 读取外部数据或事件到应用程序中,而 sinks 通常会收集应用程序生成的结果。下图是流式应用程序的示例。

流处理引擎通常允许用户指定可靠性模式或处理语义,以指示它将为整个应用程序中的数据处理提供哪些保证。这些保证是有意义的,因为你始终会遇到由于网络,机器等可能导致数据丢失的故障。流处理引擎通常为应用程序提供了三种数据处理语义:最多一次、至少一次和精确一次。

如下是对这些不同处理语义的宽松定义:

1.1 最多一次(At Most Once)

这本质上是一 尽力而为 的方法。保证数据或事件最多由应用程序中的所有算子处理一次

1.2 至少一次(At Least Once)

应用程序中的所有算子都保证数据或事件至少被处理一次

下图的例子描述了这种情况:第一个算子最初未能成功处理事件,然后在重试时成功,接着在第二次重试时也成功了,其实是没有必要的。

1.3 精确一次(Exactly Once)

即使是在各种故障的情况下,流应用程序中的所有算子都保证事件只被 精确一次 的处理

通常使用两种流行的机制来实现 精确一次 处理语义。

  • 分布式快照或状态检查点

  • 至少一次事件传递和对重复数据去重

实现 精确一次 的分布式快照或状态检查点方法受到 Chandy-Lamport 分布式快照算法的启发。通过这种机制,流应用程序中每个算子的所有状态都会定期做 checkpoint 。如果是在系统中的任何地方发生失败,每个算子的所有状态都回滚到最新的全局一致 checkpoint 点。在回滚期间,将暂定所有处理。源也会重置为最近 checkpoint 相对应的正确偏移量。整个流应用程序基本上是回到最近一次的一致状态,然后程序可以从该状态重新启动。下图描述了这种 checkpoint 机制的基础知识。

在上图中,流应用程序在 T1 时间处正常工作,并且做了 checkpoint 。然而,在时间 T2 ,算子未能处理输入的数据。此时, S=4 的状态值已保存到持久存储器中,而状态值 S=12 保存在算子的内存中。为了修复这种差异,在时间 T3 ,处理程序将回滚到 S=4重放 流中的每个连续状态直到最近,并处理每个数据。最终结果是有些数据已被处理了多次,但这没关系,因为无论执行了多少次回滚,结果状态都是相同的。

另一种实现 精确一次 的方法是:在每个算子上实现至少一次事件传递和对重复数据去重。使用此方法的流处理引擎将重放失败事件,以便在事件进入算子中的用户定义逻辑之前,进一步尝试处理并移除每个算子的重复事件。此机制要求为每个算子维护一个事务日志,以跟踪它已处理的事件。利用这种机制的引擎有 GoogleMillWheelApache Kafka Streams 。下图说明了这种机制的要点。

2 “精确一次” 是真正的 “精确一次” 吗?

现在让我们重新审视 精确一次 处理语义真正对最终用户的保证。 精确一次 这个术语在描述正好处理一次时会让人产生误导。

有些人可能认为 精确一次 描述了事件处理的保证,其中流中的每个事件只被处理一次。实际上,没有引擎能够保证正好只处理一次。在面对任意故障时,不可能保证每个算子中的用户定义逻辑在每个事件只执行一次,因为用户代码被部分执行的可能性是永远存在的。

考虑具有流处理运算符的场景,该运算符执行打印传入事件的 ID 的映射操作,然后返回事件不变。下面的伪代码说明了这个操作:

Java
1
2
3
4
Map (Event event) {
    Print "Event ID: " + event.getId()
    Return event
}

每个事件都有一个 GUID (全局唯一 ID )。如果用户逻辑的精确执行一次得到保证,那么事件 ID 将只输出一次。但是,这是无法保证的,因为在用户定义的逻辑的执行过程中,随时都可能发生故障。引擎无法自行确定执行用户定义的处理逻辑的时间点。因此,不能保证任意用户定义的逻辑只执行一次。这也意味着,在用户定义的逻辑中实现的外部操作(如写数据库)也不能保证只执行一次。此类操作仍然需要以幂等的方式执行。

上面描述的两种机制都使用持久的后端存储作为真实性的来源,可以保存每个算子的状态并自动向其提交更新。对于 机制1 (分布式快照或状态检查点),此持久后端状态用于保存流应用程序的全局一致状态检查点(每个算子的检查点状态)。对于 机制2 (至少一次事件传递加上重复数据删除),持久后端状态用于存储每个算子的状态以及每个算子的事务日志,该日志跟踪它已经完全处理的所有事件。

提交状态或对作为真实来源的持久后端应用更新可以被描述为恰好发生一次。然而,如上所述,计算状态的更新或更改,即处理在事件上执行任意用户定义逻辑的事件,如果发生故障,则可能不止一次的发生。换句话说,

那么,当引擎声明 精确一次 处理语义时,它能保证什么呢?如果不能保证用户逻辑只执行一次,那么什么逻辑只执行一次?当引擎声明 精确一次 处理语义时,它实际上是在说,它可以保证引擎管理的状态更新只提交一次到持久的后端存储。

3 分布式快照与至少一次事件传递和重复数据删除的比较

从语义的角度来看,分布式快照和至少一次事件传递以及重复数据删除机制都提供了相同的保证。然而,由于两种机制之间的实现差异,存在显著的性能差异。

  • 机制1 (分布式快照或状态检查点)的性能开销是最小的,因为引擎实际上是往流应用程序中的所有算子一起发送常规事件和特殊事件,而状态检查点可以在后台异步执行。但是,对于大型流应用程序,故障可能会更频繁的发生,导致引擎需要暂停应用程序并回滚所有算子的状态,这反过来又会影响性能。流式应用程序越大,故障发生的可能性就越大,因此也越频繁,反过来,流式应用程序的性能受到的影响也就越大。然而,这种机制是非侵入性的,运行时需要的额外资源影响很小。

  • 机制2 (至少一次事件传递加重复数据删除)可能需要更多资源,尤其是存储。使用此机制,引擎需要能够跟踪每个算子实例已完全处理的每个元组,以执行重复数据删除,以及为每个事件执行重复数据删除本身。这意味着需要跟踪大量的数据,尤其是在流应用程序很大或者有许多应用程序在运行的情况下。执行重复数据删除的每个算子上的每个事件都会产生性能开销。但是,使用这种机制,流应用程序的性能不太可能受到应用程序大小的影响。对于 机制1 ,如果任何算子发生故障,则需要发生全局暂停和状态回滚;对于 机制2 ,失败的影响更加局部性。当在算子中发生故障时,可能尚未完全处理的事件仅从上游源重放或重传。性能影响与流应用程序中发生故障的位置是隔离的,并且对流应用程序中其他算子的性能几乎没有影响。

分布式快照或状态检查点的优缺点:

  • 优点:

    • 较小的性能和资源开销
  • 缺点:

    • 对性能的影响很大

    • 拓扑越大,对性能的潜在影响越大

至少一次事件传递以及重复数据删除机制的优缺点:

  • 优点:

    • 故障对性能的影响是局部的

    • 故障的影响不一定会随着拓扑的大小而增加

  • 缺点:

    • 可能需要大量的存储和基础设施来支持

    • 每个算子的每个事件的性能开销

虽然从理论上讲,分布式快照和至少一次事件传递加重复数据删除机制之间存在差异,但两者都可以简化为至少一次处理加幂等性。对于这两种机制,当放生故障时(至少实现一次),事件将被重放或重传,并且通过状态回滚或事件重复数据删除,算子在更新内部管理状态时本质上是幂等的。

4 结论

在这篇博客文章中,我希望能够让你相信 精确一次 这个词是非常具有误导性的。提供 精确一次 的处理语义实际上意味着流处理引擎管理的算子状态的不同更新只反映一次。 精确一次 并不能保证事件的处理,即任意用户定义逻辑的执行,只会发生一次。我们更喜欢用 有效一次Effectively Once )这个术语来表示这种保证,因为处理不一定保证只发生一次,但是对引擎管理的状态的影响只反映一次。两种流行的机制,分布式快照和重复数据删除,被用来实现精确或有效的一次性处理语义。这两种机制为消息处理和状态更新提供了相同的语义保证,但是在性能上存在差异。这篇文章并不是要让你相信任何一种机制都优于另一种,因为它们各有利弊。