精确一次交付保证是关于消息传递最具争议性的话题之一,因此也是最复杂的任务之一。然而,几年前,Kafka团队宣布他们实现了这一目标,让我们深入研究一下他们的实现方式以及存在的限制。
首先,值得定义一下这些交付语义是什么。通常有三种使用的语义:
•至少一次:系统保证消息被接收,但不能保证只接收一次。•至多一次:系统不保证消息被接收,但如果接收到,则只接收一次。•精确一次:综合了前两种保证,即消息被接收且只接收一次。
当然,“精确一次”是最理想的,但同时也是最难实现的,只有在生产者、代理和消费者共同合作的情况下才可能实现。这个概念在我之前的文章中有所解释。
Kafka Streams
一个非常重要但经常被忽略的细节是,Kafka仅在Kafka Streams中支持精确一次交付。要启用它,只需将配置选项processing.guarantee从默认选项at_least_once更改为exactly_once_v2。
但是,即使在Streams应用程序中也存在限制。如果您的消费者从Kafka中读取事件并在关系型数据库中进行更改,Kafka不会回滚它。如果您的消费者发送短信通知,即使使用Kafka Streams库,Kafka也无法回滚这些通知。这些都是开发人员应始终记住的限制。
为什么要谈论“回滚”变更?这是因为处理消息的精确一次的唯一方法是在一个事务中完成。
那么,什么是Kafka Streams以及为什么可以使其具有事务性?Kafka Streams是用于构建应用程序和服务的客户端库,其中输入和输出数据存储在Kafka集群中[1]。这就是关键所在。
Kafka Streams应用程序循环
Kafka Streams应用程序实现了读取-处理-写入的循环,具体步骤如下:
1.从输入主题读取消息。2.调用处理函数以处理接收到的消息,更新内部状态。3.生成输出消息并将其发送到输出主题(或多个主题)。4.等待来自Kafka的
输出消息确认。
1.提交输入主题的偏移量,表示消息已成功处理。
您可能知道,所有偏移量都存储在隐藏的Kafka主题中,Streams应用程序的内部状态也存储在名为状态存储的内部Kafka存储中。因此,所有的更改都存储在同一个Kafka集群中,并且可以在单个事务内进行管理和回滚。
Streams应用程序数据存储
这里的状态存储有些过于简化,实际上更为复杂,因为它包含了日志变更主题和RocksDB实例,但我们可以在这里忽略这些细节。关于这个内部存储的更多详细信息可以在Kafka维基中找到。
幂等生产者
让我们回到开始的地方。一切可能出错的第一阶段是消息的生产。生产者将消息发送到代理并接收确认,表示代理已成功接收。如果没有接收到确认,生产者会再次发送相同的消息。
Kafka生产者无法接收确认
上面的图中,您可以看到三种情况,生产者没有从代理那里接收到确认,并决定再次发送消息的情况:
1.代理没有接收到消息,因此显然没有确认。2.代理接收到了消息,但是发送确认失败。3.代理接收到了消息并成功发送了确认,但是这超过了生产者的等待超时时间。
生产者在所有这些情况下都会进行重试,但在其中两种情况(2和3)中,会导致重复。
无论是我还是Kafka的开发人员,都不知道如何解决生产者端的这个问题。因此,所有的去重工作都由代理完成,代理保证消息只会写入日志一次。为了实现这一点,消息被分配了一个序列号(我在关于幂等消费者模式的文章中描述了类似的方法)。所以,准确地说,并不是幂等的生产者,而是智能的代理完成了消息的去重工作。
要在Kafka中启用此功能,只需将生产者配置为enable.idempotence=true。
Kafka事务的工作原理
在消息被写入Kafka日志并且代理保证没有重复之后,应该在一个事务中处理消息并写入下一个主题。但是如何做到呢?
Kafka事务是写入日志的一组更改,日志本身存储在内部
Kafka主题中。此日志由一个名为事务协调器(Transaction Coordinator)的特殊实体管理。要调用事务,必须完成以下几个步骤:
1.消费者找到事务协调器。这是在应用程序启动时发生的。它将其配置的事务ID(如果存在)发送到协调器,并接收生产者ID。这在应用程序重新启动并尝试使用相同的事务ID进行注册时非常重要。当重新启动的应用程序启动新事务时,事务协调器会中止前一个实例启动的所有挂起事务。2.当应用程序消费新消息时,它启动事务。3.当应用程序将消息写入其他任何主题时,它将此信息发送给其事务协调器。协调器在其内部主题中存储有关所有更改的分区的信息。
这是一个重要的细节。使用Kafka Streams API,您不必手动将这些消息发送到协调器,Streams库将为您完成。但是,如果您直接将消息写入主题,则不会将其写入事务日志,即使此主题位于同一个集群中。
有关事务的另一个重要事项是,所有在事务期间写入的消息在事务提交之前都不会对消费者暴露。
1.事务提交或失败。如果中止,则协调器向内部主题的事务添加“中止”标记以及在事务期间写入的所有消息。2.当事务提交时,过程几乎相同。协调器向事务和所有消息添加了一个“提交”标记。该标记使得这些消息对消费者可用。
您不要忘记消费者的偏移量也存储在它们自己的主题中。这意味着提交偏移量与将消息写入输出主题相同。并且该消息也可以被标记为“中止”或“提交”,这将影响是否会再次消费相同的消息。显然,当标记为“提交”时,不会再次消费该消息;而当标记为“中止”时,整个事务将从头开始——消费消息。
事务协调器是否也是事务性的?
我尽量在文章中不过多地使用细节,以使其尽可能简单和清晰。但是还有一个值得一提的细节。事务协调器如何执行确切的事务提交?它应该更新事务、消息偏移量和输出消息,将其标记为“提交”。但是,如果在此过程中出现问题怎么办?当然,Kafka不会将一半的消息保留在已提交状态,另一半保留在挂起状态。
为了使提交更改一致,事务协调器会
首先将提交的消息写入分区协调器(Partition Coordinator)。这是另一个特殊实体,每个分区都有一个,负责维护分区的状态和偏移量。
分区协调器实现了与事务协调器相同的协议,并将消息更改存储在内部主题中。分区协调器只有在它们获得事务协调器的明确确认之后才能提交更改。否则,它们会将消息更改标记为“中止”,以便将来重试。
这种“两阶段提交”(Two-Phase Commit)的机制确保了事务的一致性。如果在提交更改之前出现问题,分区协调器不会提交更改,保持原子性。这种机制确保了Kafka的精确一次交付语义。
总结
Kafka通过结合幂等的生产者、事务和分区协调器等机制来实现精确一次交付的语义。这使得在消息的生产、处理和写入过程中能够保持一致性,并避免重复处理。
Kafka Streams提供了更简单的方式来使用精确一次交付,因为它将所有的状态和消息都存储在Kafka集群中,并利用了事务来确保处理的一致性。
然而,开发人员仍然需要注意一些限制和特殊情况,例如不能回滚外部系统的更改以及事务提交过程中的故障处理。
希望这篇文章对理解Kafka的精确一次交付机制有所帮助!