文章目录
- 精确一次确实很难实现(Exactly-once is a really hard problem)
- 消息系统语义概述(Overview of messaging system semantics)
- 至少一次语义(At least once semantics)
- 至多一次语义(At most once semantics)
- 精确一次语义(Exactly once semantics)
- 必须被处理的故障(Failures that must be handled)
- broker可能故障
- producer到broker的RPC调用可能失败
- 客户端可能会故障
- Apache Kafka中的精确一次语义(Exactly-once semantics in Apache Kafka, explained)
- 幂等性
- 事务
- 真实案例:Apache Kafka中的精确一次流处理(The real deal: Exactly-once stream processing in Apache Kafka)
精确一次消息语义(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 is a really hard problem)
我知道你们中的一些人在想什么。你们可能会认为精确一次投递(exactly-once delivery)是不可能的, 它的代价太高而无法在实际中使用,或者认为我完全错了! 不仅仅只有你这么想。 我的一些业内同事承认,精确一次性交付是分布式系统中最难解决的问题之一。
Mathias Verraes说,分布式系统中最难解决的两个问题是:
- 消息顺序保证(Guaranteed order of messages)。
- 消息的精确一次投递(Exactly-once delivery)。
还有一些人直接坦白地说,精确一次投递根本不可能实现。
我并不否认引入一次性交付语义,并且只支持一次流处理,是一个真正难以解决的问题。 但我也见证了Confluent公司的机智的分布式系统工程师在开源社区努力工作了一年多,以便在Apache Kafka中解决这个问题。 因此,让我们直奔主题,先来了解消息传递语义的概述。
消息系统语义概述(Overview of messaging system semantics)
在一个分布式发布订阅消息系统中,组成系统的计算机总会由于各自的故障而不能工作。在Kafka中,一个单独的broker,可能会在生产者发送消息到一个topic的时候宕机,或者出现网络故障,从而导致生产者发送消息失败。根据生产者如何处理这样的失败,产生了不通的语义:
至少一次语义(At least once semantics)
如果生产者收到了Kafka broker的确认(acknowledgement,ack),并且生产者的acks配置项设置为all(或-1),这就意味着消息已经被精确一次写入Kafka topic了。然而,如果生产者接收ack超时或者收到了错误,它就会认为消息没有写入Kafka topic而尝试重新发送消息。如果broker恰好在消息已经成功写入Kafka topic后,发送ack前,出了故障,生产者的重试机制就会导致这条消息被写入Kafka两次,从而导致同样的消息会被消费者消费不止一次。每个人都喜欢一个兴高采烈的给予者,但是这种方式会导致重复的工作和错误的结果。
至多一次语义(At most once semantics)
如果生产者在ack超时或者返回错误的时候不重试发送消息,那么消息有可能最终并没有写入Kafka topic中,因此也就不会被消费者消费到。但是为了避免重复处理的可能性,我们接受有些消息可能被遗漏处理。
精确一次语义(Exactly once semantics)
即使生产者重试发送消息,也只会让消息被发送给消费者一次。精确一次语义是最令人满意的保证,但也是最难理解的。因为它需要消息系统本身和生产消息的应用程序还有消费消息的应用程序一起合作。比如,在成功消费一条消息后,你又把消费的offset重置到之前的某个offset位置,那么你将收到从那个offset到最新的offset之间的所有消息。这解释了为什么消息系统和客户端程序必须合作来保证精确一次语义。
必须被处理的故障(Failures that must be handled)
为了描述为了支持精确一次消息投递语义而引入的挑战,让我们从一个简单的例子开始。
假设有一个单进程生产者程序,发送了消息“Hello Kafka“给一个叫做“EoS“的单分区Kafka topic。然后有一个单实例的消费者程序在另一端从topic中拉取消息,然后打印。在没有故障的理想情况下,这能很好的工作,“Hello Kafka“只被写入到EoS topic一次。消费者拉取消息,处理消息,提交偏移量来说明它完成了处理。然后,即使消费者程序出故障重启也不会再收到“Hello Kafka“这条消息了。
然而,我们知道,我们不能总认为一切都是顺利的。在上规模的集群中,即使最不可能发生的故障场景都可能最终发生。比如:
broker可能故障
Kafka是一个高可用、持久化的系统,每一条写入一个分区的消息都会被持久化并且多副本备份(假设有n个副本)。所以,Kafka可以容忍n-1哥broker故障,意味着一个分区只要至少有一个broker可用,分区就可用。Kafka的副本协议保证了只要消息被成功写入了主副本,它就会被复制到其他所有的可用副本(ISR)。
producer到broker的RPC调用可能失败
Kafka的持久性依赖于生产者接收broker的ack。没有接收成功ack不代表生产请求本身失败了。broker可能在写入消息后,发送ack给生产者的时候挂了。甚至broker也可能在写入消息前就挂了。由于生产者没有办法知道错误是什么造成的,所以它就只能认为消息没写入成功,并且会重试发送。在一些情况下,这会造成同样的消息在Kafka分区日志中重复,进而造成消费端多次收到这条消息。
客户端可能会故障
精确一次交付也必须考虑客户端故障。但是我们如何知道一个客户端已经故障而不是暂时和brokers断开,或者经历一个程序短暂的暂停?区分永久性故障和临时故障是很重要的,为了正确性,broker应该丢弃僵住的生产这发送来的消息,同样,也应该不向已经僵住的消费者发送消息。一旦一个新的客户端实例启动,它应该能够从失败的实例留下的任何状态中恢复,从一个安全点开始处理。这意味着,消费的偏移量必须始终与生产的输出保持同步。
Apache Kafka中的精确一次语义(Exactly-once semantics in Apache Kafka, explained)
在0.11版本之前,Apache Kafka支持最少一次交付语义,和分区内有序交付。从上面的例子可以知道,生产者重试可能会造成重复消息。在新的精确一次语义特性中,我们以三个不同且相互关联的方式加强了Kafka软件的处理语义。
幂等性
每个分区中精确一次且有序(Idempotence: Exactly-once in order semantics per partition)
一个幂等性的操作就是一种被执行多次造成的影响和只执行一次造成的影响一样的操作。现在生产者发送的操作是幂等的了。如果出现导致生产者重试的错误,同样的消息,仍由同样的生产者发送多次,将只被写到kafka broker的日志中一次。对于单个分区,幂等生产者不会因为生产者或broker故障而发送多条重复消息。想要开启这个特性,获得每个分区内的精确一次语义,也就是说没有重复,没有丢失,并且有序的语义,只需要设置producer配置中的enable.idempotence=true
。
这个特性是怎么实现的呢?在底层,它和TCP的工作原理有点像,每一批发送到Kafka的消息都将包含一个序列号,broker将使用这个序列号来删除重复的发送。和只能在瞬态内存中的连接中保证不重复的TCP不同,这个序列号被持久化到副本日志,所以,即使分区的leader挂了,其他的broker接管了leader,新leader仍可以判断重新发送的是否重复了。这种机制的开销非常低:每批消息只有几个额外的字段。你将在这篇文章的后面看到,这种特性比非幂等的生产者只增加了可忽略的性能开销。
事务
跨分区原子写入(Transactions: Atomic writes across multiple partitions)
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中的精确一次流处理(The real deal: Exactly-once stream processing in Apache Kafka)
构建于幂等性和原子性之上,精确一次流处理现在可以通过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获取输入,并且跨多个源topic的消息顺序在多次运行中是不确定的。 因此,如果重新运行从多个源topic获取输入的流处理器,可能会产生不同的结果。
- 同样,流处理器可以生成到多个目标topic的输出。 如果生产者无法跨多个topic进行原子写入,那么如果对某些(但不是所有)分区的写入失败,则生产者输出可能不正确。
- 流处理器可以使用Streams API提供的状态管理工具在多个输入之间聚合或连接数据。 如果流处理器的其中一个实例失败,那么你需要能够回滚由流处理器的该实例保存的状态。 在重新启动实例时,你还需要能够恢复处理并重新创建其状态。
- 流处理器可能会查找外部数据库中浓缩的信息,或者通过调用外面的服务来查找信息。 依赖外部服务使流处理器从根本上不确定; 如果外部服务在两次运行流处理器之间更改其内部状态,则会导致下游的结果不正确。 但是,如果处理得当,这不应导致完全错误的结果。 它应该只导致流处理器输出属于一组合法输出。 稍后将在博客中详细介绍。
应用失败和重新启动,尤其是与非确定性操作相结合时,并且应用程序计算的持久状态的更改时,可能不仅会导致重复,还可能会导致错误的结果。 例如,如果一个处理阶段正在计算所看到的事件数量,那么上游处理阶段中的重复可能导致下游的错误计数。 因此,我们必须限定短语“精确一次流处理。”它指的是从topic消费,生成中间状态到Kafka topic中,并把结果写到另一个Kafka topic中,而不是使用Streams API对消息进行的所有可能的计算。某些计算(例如,取决于外部服务或从多个源topic消费)从根本上是不确定的。