kafka的幂等生产者和事务生产者

1,697 阅读13分钟

背景

在很多系统中消息重复是不被允许的,例如一些业务结算平台(如物流平台、银行结算平台等)

为了解决重试导致的消息重复、乱序问题,Kafka提供了生产者发送消息的幂等性和事务特性来阻止消息的重复,这两种方式均适用于不同的应用场景,其中:

消息的幂等性

适用于消息在写入到服务器日志后,由于网络故障,生产者没有及时收到服务端的ACK消息,生产者误以为消息没有持久化到服务端,导致生产者重复发送该消息,造成了消息的重复现象,而幂等性就是为了解决该问题。 image.png

生产者事务

生产者事务有两种典型的用途,一种是将多个消息的提交操作作为一个原子操作,要么全部提交成功,要么全部提交失败,还有一种场景就是用于防止consume -> processor -> produce场景下的消息重复问题,即主题A的消息在被消费后,在processor进行处理后,再通过生产者将处理后的消息发送到主题B。仅仅依靠幂等性只能够保证主题A的生产者不重复发送消息,而无法保证消息在processor处理后并且发送到主题B后,如果当前processor宕机,并且在此之前没有及时提交主题A的消费位移,在processor再次启动后,主题A的消息被processor重复消费,并再次进行处理,从而导致主题B有重复的消息。 image.png 上图中,由于消费位移没有及时提交给主题A,导致processor重启后,会再次拉取消息X,并重复上述流程。Kafka的事务就是为了解决这种场景导致的主题B中消息Y重复的问题。

什么是幂等

“幂等”这个词原是数学领域中的概念,指的是某些操作或函数能够被执行多次,但每次得到的结果都是不变的。我来举几个简单的例子说明一下。比如在乘法运算中,让数字乘以 1 就是一个幂等操作,因为不管你执行多少次这样的运算,结果都是相同的。再比如,取整函数(floor 和 ceiling)是幂等函数,那么运行 1 次 floor(3.4) 和 100 次 floor(3.4),结果是一样的,都是 3。相反地,让一个数加 1 这个操作就不是幂等的,因为执行一次和执行多次的结果必然不同。

在计算机领域中,幂等性的含义稍微有一些不同:

1.在命令式编程语言(比如 C)中,若一个子程序是幂等的,那它必然不能修改系统状态。这样不管运行这个子程序多少次,与该子程序关联的那部分系统状态保持不变。

2.在函数式编程语言(比如 Scala 或 Haskell)中,很多纯函数(pure function)天然就是幂等的,它们不执行任何的 side effect。

幂等性有很多好处,其最大的优势在于我们可以安全地重试任何幂等性操作,反正它们也不会破坏我们的系统状态。如果是非幂等性操作,我们还需要担心某些操作执行多次对状态的影响,但对于幂等性操作而言,我们根本无需担心此事。

幂等性 Producer

消息幂等是为了防止生产者没有及时收到ACK情况下重复发送消息导致队列中消息的重复。注意这里的ACK并非是TCP协议的ACK,而是Kafka在完成消息的持久化后,向生产者发送的应用层的ACK,表示已经收到消息并完成了同步,TCP层的ACK只能满足消息已经正确送达,不能保证Kafka已经将消息持久化到日志,下文中的ACK均表示应用层的ACK。

为了解决上述问题,Kafka提供了幂等机制,简单说就是对接口的多次调用所产生的结果和调用一次是一致的。

在 Kafka 中,Producer 默认不是幂等性的,但我们可以创建幂等性 Producer。它其实是 0.11.0.0 版本引入的新功能。在此之前,Kafka 向分区发送数据时,可能会出现同一条消息被发送了多次,导致消息重复的情况。在 0.11 之后,指定 Producer 幂等性的方法很简单,仅需要设置一个参数即可,即 props.put(“enable.idempotence”, ture),或 props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true)。

enable.idempotence 被设置成 true 后,Producer 自动升级成幂等性 Producer,(即acks自动变为-1,max.in.flight.requests.per.connection<=5),其他所有的代码逻辑都不需要改变。Kafka 自动帮你做消息的重复去重。底层具体的原理很简单,就是经典的用空间去换时间的优化思路,即在 Broker 端多保存一些字段。当 Producer 发送了具有相同字段值的消息后,Broker 能够自动知晓这些消息已经重复了,于是可以在后台默默地把它们“丢弃”掉。

幂等原理解析

精确一次(Exactly Once) = 幂等性 + 至少一次( ack=-1 + 分区副本数>=2 + ISR最小副本数量>=2) 。

为了实现Producer的幂等性,Kafka引入了Producer ID(即PID)和Sequence Number。

PID。每个新的Producer在初始化的时候会被分配一个唯一的PID,这个PID对用户是不可见的。

Sequence Numbler。(对于每个PID,该Producer发送数据的每个<Topic, Partition>都对应一个从0开始单调递增的Sequence Number)

每个KafkaProducer在构造时都会被分配出一个Producer ID,每个分区都有一个序列号,从0开始递增,生产者发送一条消息到分区后,就会将[Producer ID, Partition ID](Partition ID为分区号)对应的序列号的值加上1,这个序列号由服务端维护。

Broker端为每一对[Producer ID, Partition ID]维护了一个序列号,当Broker端收到一个生产者发送的消息时,只有当该消息的序列号值比当前的值大1,才会接收它。如果差值小于1则说明消息被重复发送了,则会丢弃这条消息。如果差值大于1,则说明中间有消息尚未被写入,此时生产者会抛出OutOfOrderSequenceException异常。

重复数据的判断标准: 具有《PID, Partition, SeqNumber》相同主键的消息提交时,Broker只会持久化一条。其中PID是Kafka每次重启都会分配一个新的;Partition 表示分区号;Sequence Number是单调自增的。

所以幂等性只能保证的是在单分区单会话内不重复

例子

示例1(未引入幂等机制): Kafka在引入幂等性之前,Producer向Broker发送消息,然后Broker将消息追加到消息流中后给Producer返回Ack信号值。实现流程如下:

image.png 上图的实现流程是一种理想状态下的消息发送情况,但是实际情况中,会出现各种不确定的因素,比如在Producer在发送给Broker的时候出现网络异常。比如以下这种异常情况的出现:

image.png 上图这种情况,当Producer第一次发送消息给Broker时,Broker将消息(x2,y2)追加到了消息流中,但是在返回Ack信 号给Producer时失败了(比如网络异常) 。此时,Producer端触发重试机制,将消息(x2,y2)重新发送给Broker, Broker接收到消息后,再次将该消息追加到消息流中,然后成功返回Ack信号给Producer。这样下来,消息流中就被 重复追加了两条相同的(x2,y2)的消息。

示例2(引入幂等机制): 面对这样的问题,Kafka引入了幂等性。那么幂等性是如何解决这类重复发送消息的问题的呢?下面我们可以先来看 看流程图:

image.png

同样,这是一种理想状态下的发送流程。实际情况下,会有很多不确定的因素,比如Broker在发送Ack信号给Producer时出现网络异常,导致发送失败。异常情况如下图所示:

image.png

当Producer发送消息(x2,y2)给Broker时,Broker接收到消息并将其追加到消息流中。此时,Broker返回Ack信号给 Producer时,发生异常导致Producer接收Ack信号失败。对于Producer来说,会触发重试机制,将消息(x2,y2)再次 发送,但是,由于引入了幂等性,在每条消息中附带了PID(ProducerID)和SequenceNumber。相同的PID和 SequenceNumber发送给Broker,而之前Broker缓存过之前发送的相同的消息,那么在消息流中的消息就只有一条 (x2,y2),不会出现重复发送的情况。

总结:

看上去,幂等性 Producer 的功能很酷,使用起来也很简单,仅仅设置一个参数就能保证消息不重复了,但实际上,我们必须要了解幂等性 Producer 的作用范围

首先,它只能保证单分区上的幂等性,即一个幂等性 Producer 能够保证某个主题的一个分区上不出现重复消息,它无法实现多个分区的幂等性。其次,它只能实现单会话上的幂等性,不能实现跨会话的幂等性。这里的会话,你可以理解为 Producer 进程的一次运行。当你重启了 Producer 进程之后,这种幂等性保证就丧失了。

那么你可能会问,如果我想实现多分区以及多会话上的消息无重复,应该怎么做呢?答案就是事务(transaction)或者依赖事务型 Producer。这也是幂等性 Producer 和事务型 Producer 的最大区别!

事务性Producer

其实事务可以这样简化的说:生产者的事务能够保证一条消息仅仅会保存在kafka的某一个分区上,不会出现在多个分区上,另外,能够保证多条消息原子性的发送到多个分区。也就是说它只保证了从producer端到broker端消息不丢失不重复。但对于consumer端,由于偏移量的提交和消息处理的顺序有前有后,依然可能导致重复消费或者消息丢失消费,如果要实现消费者消费的精确一次,还需要通过额外机制在消费端实现偏移量提交和消息消费的事务处理。

正如上面所讲的一样,事务的典型使用场景就是consume -> processor -> produce,在这种模式下消费和生产并存,消费者在提交消费位移的过程中可能出现问题导致重复消费消息。在这种场景下,使用事务可以将以下三步操作合并为一个原子操作:

从主题A的某个分区消费消息X 处理主题A的消息X后,得到消息Y,并将消息Y提交到主题B的某个分区 提交主题A该分区的消费位移 上述三个操作要么全部成功,要么全部失败。

配置事务

说明:开启事务,必须开启幂等性。 而幂等性默认为true。

事务型 Producer 能够保证将消息原子性地写入到多个分区中。这批消息要么全部写入成功,要么全部失败。另外,事务型 Producer 也不惧进程的重启。Producer 重启回来后,Kafka 依然保证它们发送消息的精确一次处理。

设置事务型 Producer 的方法也很简单,满足两个要求即可:

1.和幂等性 Producer 一样,开启 enable.idempotence = true。

2.设置 Producer 端参数 transactional.id。最好为其设置一个有意义的名字。

KafkaProducer提供了5个与事务相关的方法:

void initTransactions():初始化事务,需要在构造时配置好事务ID,否则会抛出IllegalStateException void beginTransaction():开启一个事务 void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets, String consumerGroupId):向ID为consumerGroupId的消费者组提交消费位移,包含多个分区的不同位移信息 void commitTransaction():提交事务 void abortTransaction():回滚事务

consume -> processor -> produce示例代码

KafkaConsumer<String, String> consumer = ...;  //构造消费者
consumer.subscribe(Collections.singletion("topic-A"));  //订阅主题topic-A

// 设置事务 id(必须),事务 id 任意起名
properties.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG,"transaction_id_0");


KafkaProducer<String, String> producer = ...;  //构造生产者

producer.initTransactions();  //初始化事务
producer.beginTransaction();  //开启事务
while (!Thread.currentThread().isInterrupted()) {
	ConsumerRecords<String> records = consumer.poll(1L);
	Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();  //待提交的消费位移
	
	try {
		for (TopicPartition partition: records.partitions()) {
			long offset = -1L;
			for (ConsumerRecord<String, String> record : records.records(partition)) {
				//提交处理后的消息
				ProducerRecord<String, String> newRecord = new ProducerRecord("topic-B", /*省略内容*/);
				producer.send(newRecord);
				offset = record.offset();  //取最新的消息位移
			}
			
			if (offset != -1) {
				offsets.put(partition, new OffsetAndMetadata(offset + 1));
			}
		}
		//提交消费位移
		producer.sendOffsetsToTransaction(offsets, "consumer-group-id");
		producer.commitTransaction(); //提交事务
	} catch (Exception e) {
		producer.abortTransaction(); //遇到异常回滚事务
	}finally {
                producer.close();// 5. 关闭资源

        }
}

注意初始化KafkaConsumer的时候需要将enable.auto.commit设置为false,即禁止自动提交消费位移。

隔离级别

事务型producer代码,能够保证消息被当作一个事务统一提交到kafka,要么它们全部提交成功,要么全部写入失败。实际上即使写入失败,Kafka 也会把它们写入到底层的日志中,也就是说 Consumer 还是会看到这些消息。因此在 Consumer 端,读取事务型 Producer 发送的消息也是需要一些变更的。修改起来也很简单,设置 isolation.level 参数的值即可。即事务隔离级别,当前这个参数有两个取值:

1.read_uncommitted:这是默认值,表明 Consumer 能够读取到 Kafka 写入的任何消息,不论事务型 Producer 提交事务还是终止事务,其写入的消息都可以读取。很显然,如果你用了事务型 Producer,那么对应的 Consumer 就不要使用这个值。

2.read_committed:表明 Consumer 只会读取事务型 Producer 成功提交事务写入的消息。当然了,它也能看到非事务型 Producer 写入的所有消息。

总结

  1. 幂等的意思是多次重试是对数据不影响,因为broker 已经帮我们做了去重,所以消费者不需要做变动

  2. 事务的意思是读取数据的时候不要读取事务失败的消息,但是因为数据已经写进去去了,所有consumer 要设置不能读取这些数据,从而保证对业务对下游没有影响

3.不论是幂等producer 还是事务producer都可能发送重复消息,而且同样broker端有一套机制来去重(幂等性依赖seq number机制,事务依赖各种marker来标记),区别就是这个去重是不是broker来做的

4.幂等性 Producer 和事务型 Producer 都是 Kafka 社区力图为 Kafka 实现精确一次处理语义所提供的工具,只是它们的作用范围是不同的。

5.幂等性 Producer 只能保证单分区、单会话上的消息幂等性;而事务能够保证跨分区、跨会话间的幂等性。从交付语义上来看,自然是事务型 Producer 能做的更多。

6.比起幂等性 Producer,事务型 Producer 的性能要更差,在实际使用过程中,我们需要仔细评估引入事务的开销,切不可无脑地启用事务。

7.对于consumer端,由于偏移量的提交和消息处理的顺序有前有后,依然可能导致重复消费或者消息丢失消费,如果要实现消费者消费的精确一次,还需要通过额外机制在消费端实现偏移量提交和消息消费的事务处理

8.kafka 的事务不只支持跨回话还支持跨topic的,也就是支持在一个producer 中同时写多个topic

两者关系

幂等生产者是 kafka 事务的必要不充分条件,即:

开启幂等生长者,不一定需要开启事务;

开始 kafka 事务,必须要开启幂等生产者;