Kafka Streams
是一个客户端库,用于处理和分析存储在 Kafka 中数据。它建立在重要的流处理概念之上,例如正确区分事件时间和处理时间、窗口支持、简单而高效的管理和应用程序状态的实时查询。
Kafka Streams 的进入门槛很低(low barrier to entry
):可以在一台机器上快速编写和运行一个小规模的概念验证;只需要在多台机器上运行应用程序的其他实例,即可扩展到大容量的生产工作负载。Kafka 流通过利用 Kafka 的并行模型透明地处理同一应用程序的多个实例的负载平衡。
Kafka Streams 的一些特性:
设计为一个简单而轻量级的客户端库(simple and lightweight client library
),可以轻松地嵌入到任何 Java 应用程序中,并与用户为其流应用程序提供的任何现有打包、部署和操作工具集成;
除了 Apache Kafka 本身作为内部消息传递层之外,它对其他系统没有外部依赖性(no external dependencies on systems other than Apache Kafka itself
);值得注意的是,它使用 Kafka 的分区模型来水平扩展处理,同时保持强大的有序保证;
支持容错本地状态(fault-tolerant local state
),它支持非常快速和高效的状态操作,如窗口连接和聚合;
支持精确的一次(exactly-once
)处理语义,以确保即使在处理过程中 Streams 客户端或 Kafka 代理出现故障,也只处理一次记录;
采用一次一条记录的处理(one-record-at-a-time processing
)来实现毫秒级的处理延迟,当有记录失序到达时,并支持基于事件时间的窗口操作(event-time based windowing operations
);
提供必要的流处理原语,以及高级流 DSL(high-level Streams DSL
) 和低级处理器 API(low-level Processor API
)。
下面总结了 Kafka Streams 的主要概念:
Stream Processing Topology
)流(stream
)是 Kafka Streams 提供的最重要的抽象:它表示一个无限的、持续更新的数据集。一个流是一个不可变数据记录的有序、可重放和容错的序列,其中数据记录(data record
)被定义为键值对;
流处理应用程序(stream processing application
)是指使用 Kafka Streams 库的任何程序。它通过一个或多个处理器拓扑(processor topologies
)定义其计算逻辑,其中一个处理器拓扑是指一个由流(边)连接的流处理器(节点)的图;
流处理器是处理器拓扑中的一个节点;它表示一个处理步骤,即通过一次从拓扑中的上游处理器接收一个输入记录,将其操作应用于流中来转换数据,并且随后可以向下游处理器生成一个或多个输出记录。
拓扑中有两个特殊处理器:
源处理器(Source Processor
):它是一种特殊类型的流处理器,没有任何上游处理器;它通过消费一个或多个 Kafka 主题中的记录并将其转发到下游处理器,从而从一个或多个 Kafka 主题生成一个输入流到其拓扑;
汇聚处理器(Slink Processor
):它是一种特殊类型的流处理器,没有下游处理器;它将从其上游处理器接收到的任何记录发送到指定的 Kafka 主题。
注意:在正常的处理器节点中,在处理当前记录时也可以访问其他远程系统;因此,处理后的结果既可以流回 Kafka,也可以写入外部系统。
Kafka Streams 提供两种定义流处理拓扑的方法:Kafka Streams DSL提供了最常见的数据转换操作,如即时的映射(map
)、过滤(filter
)、连接(join
)和聚合(aggregations
);较低级别的处理器 API (Processor API)允许开发人员定义和连接自定义处理器以及与状态存储(state stores)交互。
处理器拓扑只是流处理代码的逻辑抽象。在运行时,逻辑拓扑被实例化并复制到应用程序中以进行并行处理(参见Stream Partitions and Tasks)。
Time
)流处理的一个关键方面是时间的概念,以及它是如何建模和集成的。例如,某些操作(如窗口)是基于时间边界定义的。流中时间的常见概念包括:
事件时间(Event time
):事件或数据记录发生的时间点,即最初在“源”创建的时间点。例如,如果事件是由汽车中的 GPS 传感器报告的地理位置更改,则关联的事件时间将是 GPS 传感器捕获位置更改的时间。
处理时间(Processing time
):事件或数据记录碰巧被流处理应用程序处理的时间点,即记录被消费的时间点。处理时间可能比原始事件时间晚毫秒、小时或天等。例如,假设一个分析应用程序读取和处理从汽车传感器报告的地理位置数据,并将其呈现给车队管理仪表盘。这里,分析应用程序中的处理时间可能是毫秒或秒(例如,对于基于 Apache Kafka 和 Kafka Streams 的实时管道)或事件时间之后的小时(例如,对于基于 Apache Hadoop 或 Apache Spark 的批处理管道)。
摄取时间(Ingestion time
):Kafka 代理将事件或数据记录存储在主题分区中的时间点,其与事件时间的区别在于,此摄取时间戳是在 Kafka 代理将记录附加到目标主题时生成的,而不是在“源”创建记录时生成的;其与处理时间的区别在于,处理时间是流处理应用程序处理记录的时间。例如,如果一个记录从未被处理过,那么它就没有处理时间的概念,但是它仍然有一个摄取时间。
事件时间和摄取时间之间的选择实际上是通过 Kafka (而不是 Kafka Streams)的配置完成的:从 Kafka 0.10.x 开始,时间戳自动被嵌入 Kafka 消息中。根据 Kafka 的配置,这些时间戳表示事件时间或摄取时间。可以在代理级别或每个主题上指定相应的 Kafka 配置设置。Kafka Streams 中的默认时间戳提取器将按原样检索这些嵌入的时间戳。因此,应用程序的有效时间语义取决于这些嵌入时间戳的有效 Kafka 配置。
Kafka Streams 通过TimestampExtractor
接口为每个数据记录分配一个时间戳(timestamp
)。这些每记录时间戳描述流相对于时间的进度,并由依赖时间的操作(如窗口操作)利用。因此,只有当新记录到达处理器时,此时间才会向前。我们将此数据驱动时间称为应用程序的流时间(stream time
),用以区别此应用程序实际执行时的墙时钟时间(wall-clock time
)。TimestampExtractor
接口的具体实现将为流时间定义提供不同的语义。例如,基于数据记录的实际内容(例如嵌入的时间戳字段)检索或计算时间戳以提供事件时间语义,并返回当前墙时钟时间,从而将处理时间语义产生为流时间。因此,开发人员可以根据他们的业务需求强制执行不同的时间概念。
最后,每当 Kafka Streams 应用程序向 Kafka 写入记录时,它也会为这些新记录分配时间戳。时间戳的分配方式取决于上下文:
当通过处理一些输入记录生成新的输出记录时,例如,context.forward()
在process()
函数调用中触发,输出记录时间戳直接从输入记录时间戳继承;
当通过周期函数,如Punctuator#punctuate()
生成新的输出记录时,输出记录时间戳被定义为流任务的当前内部时间(通过context.timestamp()
获得);
对于聚合,结果更新记录时间戳将是导致结果的所有输入记录的最大时间戳。
注意:可以通过调用#foaward()
时显式地为输出记录分配时间戳来更改处理器 API 中的描述默认行为。
Aggregations
)聚合操作接受一个输入流或表,并通过将多个输入记录合并到一个输出记录中来生成新表。聚合的例子是计算计数或和。
在 Kafka Streams DSL
中,聚合(aggregation
)的输入流可以是一个 KStream 或 KTable,但输出流始终是 KTable。这允许 Kafka Streams 在值产生和发出后,在进一步记录的无序到达时更新聚合值。当这种无序到达发生时,聚合 KStream 或 KTable 会发出一个新的聚合值。因为输出是 KTable,所以在后续的处理步骤中,新值被视为用相同的键覆盖旧值。
Windowing
)窗口化允许您控制如何将具有相同键的记录分组,以便执行状态操作(如聚合(aggregations
)或连接(joins
)所谓的窗口)。每个记录密钥跟踪窗口。
Kafka Streams DSL
中提供了窗口操作(Windowing operations
)。使用窗口时,可以为该窗口指定宽限期(grace period
)。此宽限期控制 Kafka Streams 等待给定窗口的无序数据记录的时间。如果某个记录在某个窗口的宽限期过后到达,则该记录将被丢弃,并且不会在该窗口中处理。具体地说,如果记录的时间戳指示它属于某个窗口,但当前流时间大于该窗口的结束时间加上宽限期,则会丢弃该记录。
在现实世界中,无序记录总是可能的,应该在您的应用程序中得到适当的解释。它取决于如何处理无序记录的有效时间语义(time semantics
)。在处理时间的情况下,语义是“当记录被处理时”,这意味着无序记录的概念不适用,因为根据定义,任何记录都不能无序。因此,无序记录只能视为事件时间的无序记录。在这两种情况下,Kafka Streams 都能够正确处理无序记录。
Duality of Streams and Tables
)在实践中实现流处理用例时,通常需要流(streams
)和数据库(databases
)。一个在实践中非常常见的示例用例是一个电子商务应用程序,它使用来自数据库表的最新客户信息来丰富传入的客户事务流。换句话说,流无处不在,但数据库也无处不在。
因此,任何流处理技术都必须为流和表提供一流的支持(first-class support for streams and tables
)。Kafka 的 Streams API 通过它对streams
和tables
的核心抽象提供了这样的功能,我们将在稍后讨论。现在,一个有趣的观察是,流和表之间实际上有着密切的关系(close relationship between streams and tables
),即所谓的流表二元性。Kafka 在很多方面利用了这种二元性:例如,使应用程序具有弹性(elastic
),支持容错状态处理(fault-tolerant stateful processing
),或者对应用程序的最新处理结果运行交互式查询(interactive queries
)。而且,除了内部使用之外,Kafka Streams API 还允许开发人员在自己的应用程序中利用这种二元性。
在讨论 Kafka Streams 中的聚合(aggregations
)等概念之前,我们必须首先更详细地介绍tables
,并讨论前面提到的流表二元性。本质上,这种二元性意味着流可以看作表,而表可以看作流。
States
)一些流处理应用程序不需要状态,这意味着消息的处理独立于所有其他消息的处理。然而,能够维护状态为复杂的流处理应用程序打开了许多可能性:您可以连接输入流,或者分组和聚合数据记录。Kafka Streams DSL提供了许多这样的有状态运算符。
Kafka Streams 提供了所谓的状态存储(state stores
),流处理应用程序可以使用它来存储和查询数据。这是实现有状态操作时的一项重要功能。Kafka Streams 中的每个任务都嵌入一个或多个状态存储,这些状态存储可以通过 APIs 访问,以存储和查询处理所需的数据。这些状态存储可以是持久的键值存储、内存中的 hashmap 或其他方便的数据结构。Kafka Streams 为本地状态存储提供容错和自动恢复功能。
Kafka Streams 允许通过创建状态存储的流处理应用程序外部的方法、线程、进程或应用程序直接对状态存储进行只读查询。这是通过一个称为交互式查询(Interactive Queries
)的特性提供的。所有存储都是命名的,交互查询只公开底层实现的读取操作。
PROCESSING GUARANTEES
)在流处理中,最常被问到的一个问题是“我的流处理系统是否保证每个记录只被处理一次,即使在处理过程中遇到一些失败?”对于许多不能容忍任何数据丢失或数据重复的应用程序来说,不能保证流处理只有一次是一个交易破坏者,在这种情况下,除了流处理管道之外,通常还使用面向批处理的框架(称为Lambda体系结构Lambda Architecture)。在 0.11.0.0 之前,Kafka 只提供至少一次交付保证,因此任何利用它作为后端存储的流处理系统都不能保证端到端的语义完全是一次。事实上,即使是那些声称只支持一次处理的流处理系统,只要它们是作为源/汇从 Kafka 读/写到 Kafka,它们的应用程序实际上也不能保证在整个管道中不会生成重复项。自 0.11.0.0 发布以来,Kafka 增加了支持,允许其生产者以事务性和幂等的方式(transactional and idempotent manner
)将消息发送到不同的主题分区,因此 Kafka Streams 通过利用这些特性添加了端到端的一次处理语义。更具体地说,它保证对于从源 Kafka 主题读取的任何记录,其处理结果将在输出 Kafka 主题以及有状态操作的状态存储中精确地反映一次。注意,Kafka Streams 端到端完全一次性保证与其他流处理框架声称的保证之间的关键区别在于,Kafka Streams 与底层 Kafka 存储系统紧密集成,并确保对输入主题偏移的提交、状态存储的更新、对输出主题的写入将以原子方式完成,而不是将 Kafka 视为可能有副作用的外部系统。为了阅读更多关于如何在 Kafka Streams 中完成这项工作的细节,建议读者阅读KIP-129。为了在运行 Kafka Streams 应用程序时实现精确的一次语义,用户可以简单地设置processing.guarantee
配置值精确到一次(exactly_once
)(默认值为至少一次(at_least_once
))。更多详细信息可以在Kafka Streams Configs部分找到。
Out-of-Order Handling
)除了保证每一条记录将被精确地处理一次之外,许多流处理应用程序将面临的另一个问题是如何处理可能影响其业务逻辑的无序数据(out-of-order data
)。在 Kafka Streams 中,有两个原因可能导致数据到达相对于其时间戳的无序:
在主题分区中,记录的时间戳可能不会随偏移量单调增加。由于 Kafka Streams 总是试图处理主题分区内的记录以遵循偏移顺序,因此它可能导致具有较大时间戳(但偏移量较小)的记录比相同主题分区中具有较小时间戳(但偏移量较大)的记录处理得更早;
在可能正在处理多个主题分区的流任务(stream task
)中,如果用户将应用程序配置为不等待所有分区包含一些缓冲数据,并从时间戳最小的分区中选择以处理下一条记录,则稍后在为其他主题分区提取某些记录时,它们的时间戳可能小于从另一个主题分区获取的已处理记录。
对于无状态操作,无序数据不会影响处理逻辑,因为一次只考虑一条记录,而不查看过去处理过的记录的历史;但是对于aggregations
和joins
等有状态操作,无序数据可能会导致处理逻辑不正确。如果用户想要处理这样的无序数据,通常他们需要允许他们的应用程序等待更长的时间,同时在等待时间内记录他们的状态,即在延迟、成本和正确性之间做出权衡决定。特别是在 Kafka Streams 中,用户可以为窗口聚合配置窗口操作符,以实现这种权衡(详细信息可以在Developer Guide中找到)。至于Joins
,用户必须意识到,一些无序的数据无法通过增加 Streams 中的延迟和成本来处理:
对于流流连接(Stream-Stream joins
),所有三种类型(内部、外部、左侧)都正确处理无序记录,但生成的流可能包含不必要的leftRecord null
(用于左连接)和leftRecord null
或null rightRecord
(用于外部连接);
对于流表联接(Stream-Table joins
),不处理无序记录(即,流应用程序不检查无序记录,只按偏移顺序处理所有记录),因此可能会产生不可预测的结果;
对于表表联接(Table-Table joins
),不处理无序记录(即,流应用程序不检查无序记录,只是按偏移顺序处理所有记录)。然而,连接结果是一个 changelog 流,因此最终将是一致的。
[1] Core Concepts