Kafka事务实现

介绍事务之前先说一下消息传输保障:

一、消息传输保障

一般而言,消息中间件的消息传输保障有3 个层级,分别如下。
( 1 ) at most once:至多一次。消息可能会丢失,但绝对不会重复传输。
( 2 ) at least once : 最少一次。消息绝不会丢失,但可能会重复传输。
( 3 ) exactly once :恰好一次。每条消息肯定会被传输一次且仅传输一次。

Kafka 的消息传输保障机制非常直观。当生产者向Kafka发送消息时,一旦消息被成功提交到日志文件,由于多副本机制的存在,这条消息就不会丢失。如果生产者发送消息到Kafka之后,遇到了网络问题而造成通信中断,那么生产者就无法判断该消息是否己经提交。虽然Kafka无法确定网络故障期间发生了什么,但生产者可以进行多次重试来确保消息已经写入Kafka,这个重试的过程中有可能会造成消息的重复写入,所以这里Kafka提供的消息传输保障为at least once 。

对消费者而言,消费者处理消息和提交消费位移的顺序在很大程度上决定了消费者提供哪一种消息传输保障。如果消费者在拉取完消息之后,应用逻辑先处理消息后提交消费位移,那么在消息处理之后且在位移提交之前消费者看机了,待它重新上线之后,会从上一次位移提交的位置拉取,这样就出现了重复消费,因为有部分消息已经处理过了只是还没来得及提交消费位移,此时就对应at least once。如果消费者在拉完消息之后,应用逻辑先提交消费位移后进行消息处理,那么在位移提交之后且在消息处理完成之前消费者岩机了,待它重新上线之后,会从己经提交的位移处开始重新消费,但之前尚有部分消息未进行消费,如此就会发生消息丢失,此时就对应at most once 。

Kafka 从0.11.0.0 版本开始引入了军等和事务这两个特性,以此来实现EOS ( exactly once
semantics ,精确一次处理语义) 。

二、幂等

所谓的幕等,简单地说就是对接口的多次调用所产生的结果和调用一次是一致的。生产者在进行重试的时候有可能会重复写入消息,而使用Kafka的幕等性功能之后就可以避免这种情况。

开启幕等性功能的方式很简单,只需要显式地将生产者客户端参数enab le.idempotence设置为true
即可(这个参数的默认值为false ),参考如下: properties.put(ProducerConfig.ENABLE_IDEMPOTENCECONFIG, true);
#或者
properties.put (“enable.idempotence”, true);

不仅仅这些配置,其他配置这里就不赘述了

三、事务

幂等性并不能跨多个分区运作,而事务可以弥补这个缺陷。事务可以保证对多个分区写入操作的原子性。操作的原子性是指多个操作要么全部成功,要么全部失败,不存在部分成功、部分失败的可能。

对流式应用( Stream Processing Applications )而言, 一个典型的应用模式为“ consumetransform-produce ” 。在这种模式下消费和生产并存: 应用程序从某个主题中消费消息, 然后经过一系列转换后写入另一个主题,消费者可能在提交消费位移的过程中出现问题而导致重复消费, 也有可能生产者重复生产消息。Kafka 中的事务可以使应用程序将消费消息、生产消息、提交消费位移当作原子操作来处理,同时成功或失败,即使该生产或消费会跨多个分区。

为了实现事务,应用程序必须提供唯一的transactionalld ,这个transactionalld 通过客户端参数transactional.id 来显式设置,参考如下:
properties.put(ProducerConfig.TRANSACTIONAL ID CONFIG,”transaction Id” ) ;
#或者
properties.put ("transactional.id”,”transactionid”),

事务要求生产者开启幕等特性,因此通过将transactional.id 参数设置为非空从而开启事务特性的同时需要将enable.idempotence 设置为true ( 如果未显式设置,则KafkaProducer 默认会将它的值设置为true ) ,如果用户显式地将enable.idempotence 设置为false,则会报出ConfigException:
org.apache.kafka.common.config.ConfigException:Cannot set a transactional.id without also enabling idempotence

transactionalld 与PID 一一对应,两者之间所不同的是transactionalld 由用户显式设置, 而PID 是由Kafka 内部分配的。另外,为了保证新的生产者启动后具有相同transactionalld 的旧生产者能够立即失效,每个生产者通过transactionalld 获取PID 的同时,还会获取一个单调递增的producer epoch ( 对应下面要讲述的KafkaProducer.initTransactions()方法〉。如果使用同一个transactionalld 开启两个生产者,那么前一个开启的生产者会报出如下的错误:

org.apache.kafka.common.errors.ProducerFencedException:Producer attempted an operation with an old epoch . Either there is a newer producer with the same transactionalid , or the producer ’ s transaction has been expired by the broker .

producer epoch 同PID 和序列号一样在5.2.5 节中就讲过了,对应v2 版的日志格式中RecordBatch 的pr oducer epoch 字段(参考图5 -7 )。

从生产者的角度分析,通过事务, Kafka 可以保证跨生产者会话的消息幕等发送,以及跨生产者会话的事务恢复。前者表示具有相同transactionalld 的新生产者实例被创建且工作的时候,旧的且拥有相同transactionalld 的生产者实例将不再工作。后者指当某个生产者实例君机后,新的生产者实例可以保证任何未完成的旧事务要么被提交( Commit ),要么被中止( Abo时),如此可以使新的生产者实例从一个正常的状态开始工作。

而从消费者的角度分析, 事务能保证的语义相对偏弱。出于以下原因, Kafka 并不能保证
己提交的事务中的所有消息都能够被消费:

  • 对采用日志压缩策略的主题而言,事务中的某些消息有可能被清理(相同key 的消息,后写入的消息会覆盖前面写入的消息)。
  • 事务中消息可能分布在同一个分区的多个日志分段( LogSegment )中,当老的日志分段被删除时,对应的消息可能会丢失。
  • 消费者可以通过seekO方法访问任意offset 的消息,从而可能遗漏事务中的部分消息。
  • 消费者在消费时可能没有分配到事务内的所有分区,如此它也就不能读取事务中的所
    有消息。

KafkaProducer 提供了5 个与事务相关的方法,详细如下:

  • void initTransactions();
  • void beginTransaction() throws ProducerFencedException ;
  • void sendOffsetsToTransaction(Map<Top 工cPart 工tion , OffsetAndMetadata> offsets ,String consumerGroupid) throws ProducerFencedException;
  • void commitTransaction() throws ProducerFencedException ;
  • void abortTransaction() throws ProducerFencedException ;

initTransactions()
方法用来初始化事务,这个方法能够执行的前提是配置了transactionalld,如果没有则会报出IllegalStateException:

  • beginTransaction() 方法用来开启事务
  • sendOffsetsToTransaction() 方法为消费者提供在事务内的位移提交的操作;
  • commitTransaction() 方法用来提交事务
  • abortTransaction() 方法用来中止事务,类似于事务回滚。

一个典型的事务消息发送的操作如代码清单7 -2 所示。

代码清单7.2 事务消息发送示例

Properties properties= new Properties();
properties.put(ProducerConfig.KEY_SERIALIZER一CLASS_CONFIG,StringSerializer.class . getName()) ;
properties.put(ProducerConfig . VALUE SERIALIZER_CLASS_CONFIG ,StringSerializer.class . getName());
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG , brokerList);
properties.put(ProducerConfig.TRANSACTIONALIDCONFIG , transactionid);

KafkaProducer<String , String> producer= new KafkaProducer<>(properties );
producer.initTransactions ();
producer.beginTransaction();

try {
	// 处理业务逻辑并创建ProducerRecord
	ProducerRecord<String, String> recordl =new ProducerRecord<>(topic,”msgl ”);
	producer.send(recordl) ;
	ProducerRecord<String, String> record2 =new ProducerRecord<>(topic,”msg2 ”);
	producer.send(record2);
	ProducerRecord<String, String> record3 =new ProducerRecord<>(topic,”msg3 ”);
	producer.send(record3);
	// 处理一些其他逻辑
	producer.commitTransaction() ;
} catch (ProducerFencedException e) {
	producer.abortTransaction() ;
}
producer.close();

在消费端有一个参数isolation.level ,与事务有着莫大的关联,这个参数的默认值为“read uncommitted”,意思是说消费端应用可以看到(消费到)未提交的事务, 当然对于己提交的事务也是可见的。这个参数还可以设置为“read committed ”,表示消费端应用不可以看到尚未提交的事务内的消息。举个例子,如果生产者开启事务并向某个分区值发送3条消息msgl 、msg2 和msg3,在执行commitTransaction()或abortTransaction()方法前,设置为“read_committed”的消费端应用是消费不到这些消息的,不过在KafkaConsumer内部会缓存这些消息,直到生产者执行commitTransaction()方法之后它才能将这些消息推送给消费端应用。反之,如果生产者执行了abortTransaction()方法,那么KafkaConsumer会将这些缓存的消息丢弃而不推送给消费端应用。

日志文件中除了普通的消息,还有一种消息专门用来标志一个事务的结束,它就是控制消息( Contro!Batch )。控制消息一共有两种类型: COMMIT 和ABORT ,分别用来表征事务己经成功提交或己经被成功中止。KafkaConsumer 可以通过这个控制消息来判断对应的事务是被提交了还是被中止了,然后结合参数isolation.level 配置的隔离级别来决定是否将相应的消息返回给消费端应用,如图7-19 所示。注意Contro!Batch 对消费端应用不可见,后面还会对它有更加详细的介绍。
在这里插入图片描述本节开头就提及了consume-transform-produce 这种应用模式,这里还涉及在代码清单7-2中尚未使用的s巳ndOffsetsToTransaction()方法。该模式的具体结构如图7-20 所示。与此对应的应用示例如代码清单7-3 所示。
在这里插入图片描述代码清单 7-3 消费一转换一生产模式示例

public c l ass TransactionConsumeTransformProduce {
	public static final String brokerList = ”localhost:9092;
	public static Properties getCosumerProperties() {
		Properties props= new Properties();
		props.put(ConsumerConfig.BOOTSTRAP_SERVERS CONFIG, brokerList) ;
		props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG ,StringDeserializer.class.getName()) ;
		props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS CONFIG ,StringDeserializer.class.getName());
		props.put(ConsumerConfig ENABLE AUTO_COMMI T一CONFIG , false) ;
		props.put(ConsumerConfig.GROUP_ID_CONFIG,”group Id” ) ;
		return props;
	}
	
	public static Properties getProducerProperties() {
		Properties props= new Properties ();
		props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG ,brokerList) ;
		props.put(ProducerConfig.KEYSERIALIZER_CLASS_CONFIG ,
		StringSerializer.class.getName());
		props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG ,
		StringSerializer.class.getName()) ;
		props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG,”transactionalId”) ;
		return props ;
	}
		
	public static void main(String[] args ) (
		//初始化生产者和消费者
		KafkaConsumer<String , String> consumer =new KafkaConsumer<>(getConsumerProperties()) ;
		consumer.subscribe(Collections.singletonList(”topic-source"));
		KafkaProducer <String , String> producer = new KafkaProducer<>(getProducerProperties()) ;
		//初始化事务
		producer.initTransactions( );
		while (true ) {
		ConsumerRecords<String , String> records = consumer.poll(Duration.ofMillis (1000));
		if (records. isEmpty () ) {
		Map<TopicPartition,OffsetAndMetadata> offsets = new HashMap<>();
		//开启事务
		producer.beginTransaction() ;
		try {
			for (TopicPartition partition : records . partitions () ) {
				List<ConsumerRecord<String , String> partitionRecords = records.records(partition) ;
				for (ConsumerRecord<String , String> record : partitionRecords) {
					//do some logical processing .
					ProducerRecord<String, String> producerRecord = new ProducerRecord<>(”topic-sink”,record.key() ,record.value()) ;
					// 消费一生产模型
					producer.send(producerRecord);
				}
				long lastConsumedOffset = partitionRecords.get(partitionRecords.size()-1).offset();
				offsets.put(partition ,new OffsetAndMetadata(lastConsumedOffset + 1));
			}
			// 提交消费位移
			producer.sendOffsetsToTransaction(offsets ,”groupid”);
			// 提交事务
			producer.commitTransaction ();
		} catch (ProducerFencedException e) {
			//log the except 工0口
			// 中止事务
			producer.abortTransaction();
		}
	}

}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值