01 场景
(1) producer发多条消息组成一个事务这些消息需要对consumer同时消费或不消费
(2) producer可能会给多个topic多个分区发消息,这些消息需要放在一个事务里面,典型的分布式事务。
(3) 应用先消费一个topic的消息,再发消息到另一个topic,这个consume-transform-produce过程需放到一个事务里面
(4) producer或producer所在应用可能会挂掉,新的producer启动后需要知道怎么处理之前未完成的事务 。
只有consumer消费消息,加事务没意义,跟使用手动提交效果一样,且也不是事务属性引入的目的,一般不使用这种情况
02 概念和推导
(1) 因为producer发送消息可能是分布式事务,引入了2PC事务协调者(Transaction Coordinator)。Transaction Coordinator和之前为了解决脑裂和惊群问题引入的Group Coordinator在选举上类似。
(2) 事务管理中事务日志是必不可少的,kafka用内部topic(名字是__transaction_state)保存事务日志,事务日志是Transaction Coordinator管理的状态的持久化,因为不需要回溯事务的历史状态,所以事务日志只用保存最近的事务状态。
(3) 因为事务存在commit和abort两种操作,而客户端又有read committed(提交了消费者才能看到)和read uncommitted(比提交消费者也能看到)两种隔离级别,所以消息队列必须能标识事务状态,这个被称作Control Message。
(4) producer挂掉重启或漂移到其它机器需要能关联之前未完成事务所以需有个唯一标识符进行关联,就是TransactionalId,一个producer挂了,另一个有相同TransactionalId的producer能够接着处理这个事务未完成的状态。kafka目前没有引入全局序,所以也没有transactionId,这个TransactionalId是用户提前配置的。
(5) TransactionalId能关联producer,也需要避免两个使用相同TransactionalId的producer同时存在,所以引入了producer epoch来保证对应一个TransactionalId只有一个活跃的producer
03 粉碎“僵尸实例”
为每个事务Producer分配一个称为transactional.id的唯一标识符来解决僵尸实例问题。在进程重新启动时能够识别相同的Producer实例。
API要求事务性Producer的第一个操作应该是在Kafka集群中显示注册transactional.id。 注册时Kafka broker用给定的transactional.id检查打开的事务且完成处理。 Kafka也增加了一个与transactional.id相关的epoch。Epoch存储每个transactional.id内部元数据。
一旦epoch被触发,任何具有相同的transactional.id和旧的epoch的生产者被视为僵尸,Kafka拒绝来自这些生产者的后续事务性写入。
Kafka可以保证Consumer最终只能消费非事务性消息或已提交事务性消息。它将保留来自未完成事务的消息,并过滤掉已中止事务的消息。
05 事务消息定义
生产者可以显式地发起事务会话,在这些会话中发送(事务)消息,并提交或中止事务。有如下要求:
- 原子性:消费者的应用程序不应暴露于未提交事务的消息中。
- 持久性:Broker不能丢失任何已提交的事务。
- 排序:事务消费者应在每个分区中以原始顺序查看事务消息。
- 交织:每个分区都应该能够接收来自事务性生产者和非事务生产者的消息
- 事务中不应有重复的消息。
不管是事务消息还是非事务消息,都以最后的提交时间为准来决定顺序
01 事务配置
(1 )创建消费者代码,需要:
将配置中的自动提交属性(auto.commit)进行关闭
而且在代码里面也不能使用手动提交commitSync( )或者commitAsync( )
设置isolation.level:READ_COMMITTED或READ_UNCOMMITTED
(2) 创建生成者,代码如下,需要:
配置transactional.id属性
配置enable.idempotence(幂等)属性
02 事务概览
生产者将表示事务开始/结束/中止状态的事务控制消息发送给使用多阶段协议管理事务的高可用事务协调器。生产者将事务控制记录(开始/结束/中止)发送到事务协调器,并将事务的消息直接发送到目标数据分区。消费者需要了解事务并缓冲每个待处理的事务,直到它们到达其相应的结束(提交/中止)记录为止。
事务组: 事务组中的生产者有相同的transactionId
事务组中的生产者
事务组的事务协调器
Leader brokers(事务数据所在分区的Broker)
事务的消费者
03 事务组
用于映射到特定的事务协调器(基于日志分区数字的哈希)。该组中的生产者需要配置为该组事务生产者。由于来自这些生产者的所有事务都通过此协调器进行,因此我们可以在这些事务生产者之间实现严格的有序。
事务协调器就是一个broker
04 生产者ID和事务组状态
生产者Id唯一标识一个生产者
事务生产者需要两个新参数:生产者ID和生产组。需将生产者的输入状态与上一个已提交的事务相关联。这使事务生产者能够重试事务(通过为该事务重新创建输入状态;在我们的用例中通常是偏移量的向量)。
可以使用消费者偏移量管理机制来管理这些状态。消费者偏移量管理器将每个键(consumergroup-topic-partition)与该分区的最后一个检查点偏移量和元数据相关联。在事务生产者中,我们保存消费者的偏移量,该偏移量与事务的提交点关联。此偏移提交记录(在__consumer_offsets主题中)应作为事务的一部分写入。即,存储消费组偏移量的__consumer_offsets 主题分区将需要参与事务。因此,假定生产者在事务中间失败(事务协调器随后到期);当生产者恢复时,它可以发出偏移量获取请求,以恢复与最后提交的事务相关联的输入偏移量,并从该点恢复事务处理。
为了支持此功能,我们需要对偏移量管理器和压缩的 __consumer_offsets 主题进行一些增强。首先,压缩的主题现在还将包含事务控制记录。我们将需要为这些控制记录提出剔除策略。其次,偏移量管理器需要具有事务意识;特别是,如果组与待处理的事务相关联,则偏移量提取请求应返回错误。
05 事务协调器
事务协调器是 __transaction_state主题特定分区的Leader副本所在的Broker。它负责初始化/提交/回滚事务。事务协调器在内存管理如下的状态:
对应正在处理的事务的第一个消息的HW。事务协调器周期性地将HW写到ZK。
事务控制日志中存储对应于日志HW的所有正在处理的事务:
事务消息主题分区的列表。
事务的超时时间。
与事务关联的Producer ID。
需要确保无论是什么样的保留策略(日志分区的删除还是压缩),都不能删除包含事务HW的日志分段。
06 事务流程
初始阶段
- Producer:计算哪个Broker作为事务协调器。
- Producer:向事务协调器发送BeginTransaction(producerId, generation, partitions… )请求,当然也可以发送另一个包含事务过期时间的。如果生产者需要将消费者状态作为事务的一部分提交事务,则需要在BeginTransaction中包含对应的 __consumer_offsets 主题分区信息。
- Broker:生成事务ID
- Coordinator:向事务协调主题追加BEGIN(TxId, producerId, generation, partitions…)消息,然后发送响应给生产者。
- Producer:读取响应(包含了事务ID:TxId)
- Coordinator (and followers):在内存更新当前事务的待确认事务状态和数据分区信息。
发送阶段
Producer:发送事务消息给主题Leader分区所在的Broker。每个消息需要包含TxId和TxCtl字段。TxCtl仅用于标记事务的最终状态(提交还是中止)。生产者请求也封装了生产者ID,但是不追加到日志中。
结束阶段(生产者准备提交事务)
- Producer:发送OffsetCommitRequest请求提交与事务结束状态关联的输入状态(如下一个事务输入从哪儿开始)
- Producer:发送CommitTransaction(TxId, producerId, generation)请求给事务协调器并等待响应。(如果响应中没有错误信息,表示将提交事务)
- Coordinator:向事务控制主题追加PREPARE_COMMIT(TxId)请求并向生产者发送响应。
- Coordinator:向事务涉及到的每个Leader分区(事务的业务数据的目标主题)的Broker发送一个CommitTransaction(TxId, partitions…)请求。
- 事务业务数据的目标主题相关Leader分区Broker:
1. 如果是非 __consumer_offsets 主题的Leader分区:一收到CommitTransaction(TxId, partition1, partition2, …)请求就会向对应的分区Broker发送空(null)消息(没有key/value)并给该消息设置TxId和TxCtl(设置为COMMITTED)字段。Leader分区的Broker给协调器发送响应。
2. 如果是 __consumer_offsets 主题的Leader分区:追加消息,该消息的key是 GLAST-COMMIT ,value就是 TxId 的值。同时也应该给该消息设置TxId和TxCtl字段。Broker向协调器发送响应。 - Coordinator:向事务控制主题发送COMMITTED(TxId)请求。 __transaction_state
- Coordinator (and followers):尝试更新HW。
07 事务的中止
当事务生产者发送业务消息的时候如果发生异常,可以中止该事务。如果事务提交超时,事务协调器也会中止当前事务。
Producer:向事务协调器发送AbortTransaction(TxId)请求并等待响应。(一个没有异常的响应表示事务将会中止)
Coordinator:向事务控制主题追加PREPARE_ABORT(TxId)消息,然后向生产者发送响应。
Coordinator:向事务业务数据的目标主题的每个涉及到的Leader分区Broker发送AbortTransaction(TxId, partitions…)请求。(收到Leader分区Broker响应后,事务协调器中止动作跟上面的提交类似)
08 基本事务流程的失败
生产者发送BeginTransaction(TxId):的时候超时或响应中包含异常,生产者使用相同的TxId重试。
生产者发送数据时的Broker错误:生产者应中止(然后重做)事务(使用新的TxId)。如果生产者没有中止事务,则协调器将在事务超时后中止事务。仅在可能已将请求数据附加并复制到Follower的错误的情况下才需要重做事务。例如,生产者请求超时将需要重做,而NotLeaderForPartitionException不需要重做。
生产者发送CommitTransaction(TxId)请求超时或响应中包含异常,生产者使用相同的TxId重试事务。此时需要幂等性。
09 主题的压缩
压缩主题在压缩过程中会丢弃具有相同键的早期记录。如果这些记录是事务的一部分,这合法吗?这可能有点怪异,但可能不会太有害,因为在主题中使用压缩策略的理由是保留关键数据的最新更新。
如果该应用程序正在(例如)更新某些表,并且事务中的消息对应于不同的键,则这种情况可能导致数据库视图不一致。
10 相关配置
Broker configs
Producer configs
Consumer configs
--------------------------------------------------------------------------------这这以发送多条消息为例
public static void main(String[] args) {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "120.77.206.207:9092");
configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 提供生产者client.id
configs.put(ProducerConfig.CLIENT_ID_CONFIG, "tx_producer");
// 设置事务ID
configs.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "my_tx_id_1");
// 需要ISR全体确认消息
configs.put(ProducerConfig.ACKS_CONFIG, "all");
KafkaProducer<String, String> producer = new KafkaProducer<String, String>(configs);
// 初始化事务
producer.initTransactions();
try {
// 开启事务
producer.beginTransaction();
// 发送事务消息
producer.send(new ProducerRecord<>("topicName", "key1", "message1"));
producer.send(new ProducerRecord<>("topicName", "key2", "message2"));
producer.send(new ProducerRecord<>("topicName", "key3", "message3"));
int i = 1 / 0;
// 提交事务
producer.commitTransaction();
} catch (Exception e) {
e.printStackTrace();
// 事务回滚
producer.abortTransaction();
} finally {
// 关闭生产者
producer.close();
}
}
先介绍消息,再发送消息,整体是一个事务
public class MyTransactional {
public static KafkaProducer<String, String> getProducer() {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "120.77.206.207:9092");
configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 设置client.id
configs.put(ProducerConfig.CLIENT_ID_CONFIG, "tx_producer_01");
// 设置事务id
configs.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "tx_id_02");
// 需要所有的ISR副本确认
configs.put(ProducerConfig.ACKS_CONFIG, "all");
// 启用幂等性
configs.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
KafkaProducer<String, String> producer = new KafkaProducer<String, String>(configs);
return producer;
}
public static KafkaConsumer<String, String> getConsumer(String consumerGroupId) {
Map<String, Object> configs = new HashMap<>();
configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "120.77.206.207:9092");
configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
configs.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
// 设置消费组ID
configs.put(ConsumerConfig.GROUP_ID_CONFIG, "consumer_grp_02");
// 不启用消费者偏移量的自动确认,也不要手动确认
configs.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
configs.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer_client_02");
configs.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
// 只读取已提交的消息
// configs.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(configs);
return consumer;
}
public static void main(String[] args) {
String consumerGroupId = "consumer_grp_id_101";
KafkaProducer<String, String> producer = getProducer();
KafkaConsumer<String, String> consumer = getConsumer(consumerGroupId);
// 事务的初始化,设置事务id
producer.initTransactions();
// 订阅主题
consumer.subscribe(Collections.singleton("topicName"));
// 先消费
final ConsumerRecords<String, String> records = consumer.poll(1_000);
// 开启事务
producer.beginTransaction();
try {
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
for (ConsumerRecord<String, String> record : records) {
System.out.println(record);
// 发送消息
producer.send(new ProducerRecord<String, String>("topicName1", record.key(), record.value()));
// value是下一条消费消息的偏移量
offsets.put(new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset() + 1));
}
// 将该消息的偏移量提交作为事务的一部分,事务提交就提交偏移量,事务回滚就不提交偏移量
producer.sendOffsetsToTransaction(offsets, consumerGroupId);
// 如果有一样,发送到topicName1的消息将会失败
// int i = 1 / 0;
// 提交事务
producer.commitTransaction();
} catch (Exception e) {
e.printStackTrace();
// 回滚事务
producer.abortTransaction();
} finally {
// 关闭资源
producer.close();
consumer.close();
}
}
}
用下面的命令监听topicName1主题的消息,这里要加事务的选项表示只接收已经提交的消息
kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic topicName1 --isolation-level read_committed --from-beginning