原文链接:https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/
精确一次消息语义(Exactly-once semantics)是可以实现的:让我们看看Kafka是怎么实现的。
我很兴奋,我们到达了Kafka社区一直以来期待的令人激动的里程碑:我们在Apache Kafka 0.11 release版本和Confluent Platform 3.3中引入了精确一次消息语义。在这篇文章中,我会告诉你Apache Kafka中的精确一次语义是什么意思,为什么这是一个难以实现的问题,还有Kafka中的幂等(idempotence)和事务(transactions)新特性是如何保证使用Kafka Stream API来正确地进行精确一次流处理的(exactly-once stream processing)。
我知道你们中的一些人在想什么。你们可能会认为精确一次投递(exactly-once delivery)是不可能的, 它的代价太高而无法在实际中使用,或者认为我完全错了! 不仅仅只有你这么想。 我的一些业内同事承认,精确一次性交付是分布式系统中最难解决的问题之一。
Mathias Verraes说,分布式系统中最难解决的两个问题是:1.消息顺序保证(Guaranteed order of messages)。2.消息的精确一次投递(Exactly-once delivery)。
还有一些人直接坦白地说,精确一次投递根本不可能实现。
我并不否认引入一次性交付语义,并且只支持一次流处理,是一个真正难以解决的问题。 但我也见证了Confluent公司的机智的分布式系统工程师在开源社区努力工作了一年多,以便在Apache Kafka中解决这个问题。 因此,让我们直奔主题,先来了解消息传递语义的概述。
在一个分布式发布订阅消息系统中,组成系统的计算机总会由于各自的故障而不能工作。在Kafka中,一个单独的broker,可能会在生产者发送消息到一个topic的时候宕机,或者出现网络故障,从而导致生产者发送消息失败。根据生产者如何处理这样的失败,产生了不同的语义:
为了描述为了支持精确一次消息投递语义而引入的挑战,让我们从一个简单的例子开始。
假设有一个单进程生产者程序,发送了消息“Hello Kafka“给一个叫做“EoS“的单分区Kafka topic。然后有一个单实例的消费者程序在另一端从topic中拉取消息,然后打印。在没有故障的理想情况下,这能很好的工作,“Hello Kafka“只被写入到EoS topic一次。消费者拉取消息,处理消息,提交偏移量来说明它完成了处理。然后,即使消费者程序出故障重启也不会再收到“Hello Kafka“这条消息了。
然而,我们知道,我们不能总认为一切都是顺利的。在上规模的集群中,即使最不可能发生的故障场景都可能最终发生。比如:
在0.11版本之前,Apache Kafka支持最少一次交付语义,和分区内有序交付。从上面的例子可以知道,生产者重试可能会造成重复消息。在新的精确一次语义特性中,我们以三个不同且相互关联的方式加强了Kafka软件的处理语义。
一个幂等性的操作就是一种被执行多次造成的影响和只执行一次造成的影响一样的操作。现在生产者发送的操作是幂等的了。如果出现导致生产者重试的错误,同样的消息,仍由同样的生产者发送多次,将只被写到kafka broker的日志中一次。对于单个分区,幂等生产者不会因为生产者或broker故障而发送多条重复消息。想要开启这个特性,获得每个分区内的精确一次语义,也就是说没有重复,没有丢失,并且有序的语义,只需要设置producer配置中的"enable.idempotence=true"。
这个特性是怎么实现的呢?在底层,它和TCP的工作原理有点像,每一批发送到Kafka的消息都将包含一个序列号,broker将使用这个序列号来删除重复的发送。和只能在瞬态内存中的连接中保证不重复的TCP不同,这个序列号被持久化到副本日志,所以,即使分区的leader挂了,其他的broker接管了leader,新leader仍可以判断重新发送的是否重复了。这种机制的开销非常低:每批消息只有几个额外的字段。你将在这篇文章的后面看到,这种特性比非幂等的生产者只增加了可忽略的性能开销。
Kafka现在通过新的事务API支持跨分区原子写入。这将允许一个生产者发送一批到不同分区的消息,这些消息要么全部对任何一个消费者可见,要么对任何一个消费者都不可见。这个特性也允许你在一个事务中处理消费数据和提交消费偏移量,从而实现端到端的精确一次语义。下面是的代码片段演示了事务API的使用:
producer.initTransactions();
try {
producer.beginTransaction();
producer.send(record1);
producer.send(record2);
producer.commitTransaction();
} catch(ProducerFencedException e) {
producer.close();
} catch(KafkaException e) {
producer.abortTransaction();
}
上面的代码片段演示了你可以如何使用新生产者API来原子性地发送消息到topic的多个partition。值得注意的是,一个Kafka topic的分区中的消息,可以有些是在事务中,有些不在事务中。
因此在消费者方面,你有两种选择来读取事务性消息,通过隔离等级“isolation.level”消费者配置表示:
read_commited
:除了读取不属于事务的消息之外,还可以读取事务提交后的消息。read_uncommited
:按照偏移位置读取所有消息,而不用等事务提交。这个选项类似Kafka消费者的当前语义。为了使用事务,需要配置消费者使用正确的隔离等级,使用新版生产者,并且将生产者的“transactional.id”配置项设置为某个唯一ID。 需要此唯一ID来提供跨越应用程序重新启动的事务状态的连续性。
构建于幂等性和原子性之上,精确一次流处理现在可以通过Apache Kafka的流处理API实现了。使Streams应用程序使用精确一次语义所需要的就是设置配置“processing.guarantee = exact_once”。 这可以保证所有处理恰好发生一次; 包括处理和由写回Kafka的处理作业创建的所有具体状态的精确一次。
这就是为什么Kafka的Streams API提供的精确一次性保证是迄今为止任何流处理系统提供的最强保证。 它为流处理应用程序提供端到端的一次性保证,从Kafka读取的数据,Streams应用程序物化到Kafka的任何状态,到写回Kafka的最终输出。 仅依靠外部数据系统来实现状态支持的流处理系统对于精确一次的流处理提供了较少的保证。 即使他们使用Kafka作为流处理的源并需要从失败中恢复,他们也只能倒回他们的Kafka偏移量来重建和重新处理消息,但是不能回滚外部系统中的关联状态,导致状态不正确,更新不是幂等的。
让我再详细解释一下。 流处理系统的关键问题是“我的流处理应用程序是否得到了正确的答案,即使其中一个实例在处理过程中崩溃了?”。在恢复失败的实例时,恢复到崩溃前相同的状态进行处理是很关键的。
现在,流处理只不过是对Kafka topic的读取-处理-写入操作; 消费者从Kafka topic读取消息,一些处理逻辑转换这些消息或修改由处理程序维护的状态,然后生产者将结果消息写入另一个Kafka topic。精确一次的流处理只是一种保证仅执行一次读-处理-写操作的能力。在这种情况下,“获得正确答案”意味着不会丢失任何输入消息或产生任何重复输出。 这是用户期望从精确一次性流处理器中获得的行为。
除了我们到目前为止讨论的简单场景之外,还有许多其他失败场景需要考虑:
应用失败和重新启动,尤其是与非确定性操作相结合时,并且应用程序计算的持久状态的更改时,可能不仅会导致重复,还可能会导致错误的结果。 例如,如果一个处理阶段正在计算所看到的事件数量,那么上游处理阶段中的重复可能导致下游的错误计数。 因此,我们必须限定短语“精确一次流处理。”它指的是从topic消费,生成中间状态到Kafka topic中,并把结果写到另一个Kafka topic中,而不是使用Streams API对消息进行的所有可能的计算。某些计算(例如,取决于外部服务或从多个源topic消费)从根本上是不确定的。