kafka-稳定性-事务

21 篇文章 1 订阅

稳定性

事务

一、事务场景

  1. 如producer发的多条消息组成一个事务这些消息需要对consumer同时可见或者同时不可见 。
  2. producer可能会给多个topic,多个partition发消息,这些消息也需要能放在一个事务里面,这就形成了一个典型的分布式事务。
  3. kafka的应用场景经常是应用先消费一个topic,然后做处理再发到另一个topic,这个consume-transform-produce过程需要放到一个事务里面,比如在消息处理或者发送的过程中如果失败了,消费偏移量也不能提交。
  4. producer或者producer所在的应用可能会挂掉,新的producer启动以后需要知道怎么处理之前未完成的事务 。
  5. 在一个原子操作中,根据包含的操作类型,可以分为三种情况,前两种情况是事务引入的场景,最后一种没用。
  6. 只有Producer生产消息;
  7. 消费消息和生产消息并存,这个是事务场景中最常用的情况,就是我们常说的consume-transform-produce 模式
  8. 只有consumer消费消息,这种操作其实没有什么意义,跟使用手动提交效果一样,而且也不是事务属性引入的目的,所以一般不会使用这种情况
    二、几个关键概念和推导
  9. 因为producer发送消息可能是分布式事务,所以引入了常用的2PC,所以有事务协调者(Transaction Coordinator)。Transaction Coordinator和之前为了解决脑裂和惊群问题引入的Group Coordinator在选举上类似。
  10. 事务管理中事务日志是必不可少的,kafka使用一个内部topic来保存事务日志,这个设计和之前使用内部topic保存偏移量的设计保持一致。事务日志是Transaction Coordinator管理的状态的持久化,因为不需要回溯事务的历史状态,所以事务日志只用保存最近的事务状态。
    __transaction_state
  11. 因为事务存在commit和abort两种操作,而客户端又有read committed和read uncommitted两种隔离级别,所以消息队列必须能标识事务状态,这个被称作Control Message。
  12. producer挂掉重启或者漂移到其它机器需要能关联的之前的未完成事务所以需要有一个唯一标
    识符来进行关联,这个就是TransactionalId,一个producer挂了,另一个有相同TransactionalId的producer能够接着处理这个事务未完成的状态。kafka目前没有引入全局序,所以也没有transaction id,这个TransactionalId是用户提前配置的。
  13. TransactionalId能关联producer,也需要避免两个使用相同TransactionalId的producer同时存在,所以引入了producer epoch来保证对应一个TransactionalId只有一个活跃的producer

事务语义

多分区原子写入

事务能够保证Kafka topic下每个分区的原子写入。事务中所有的消息都将被成功写入或者丢弃。首先,我们来考虑一下原子 读取-处理-写入 周期是什么意思。简而言之,这意味着如果某个应用程序在某个topic tp0的偏移量X处读取到了消息A,并且在对消息A进行了一些处理(如B = F(A))之后将消息B写入topic tp1,则只有当消息A和B被认为被成功地消费并一起发布,或者完全不发布时,整个读取过程写入操作是原子的。

现在,只有当消息A的偏移量X被标记为已消费,消息A才从topic tp0消费,消费到的数据偏移量(record offset)将被标记为提交偏移量(Committing offset)。在Kafka中,我们通过写入一个名为offsets topic的内部Kafka topic来记录offset commit。消息仅在其offset被提交给offsets topic时才被认为成功消费。

由于offset commit只是对Kafkatopic的另一次写入,并且由于消息仅在提交偏移量时被视为成功消费,所以跨多个主题和分区的原子写入也启用原子 读取-处理-写入 循环:提交偏移量X到offset topic和消
息B到tp1的写入将是单个事务的一部分,所以整个步骤都是原子的

粉碎“僵尸实例”

我们通过为每个事务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最终只能消费非事务性消息或已提交事务性消息。它将保留来
自未完成事务的消息,并过滤掉已中止事务的消息

事务消息定义

生产者可以显式地发起事务会话,在这些会话中发送(事务)消息,并提交或中止事务。有如下要求:

  1. 原子性:消费者的应用程序不应暴露于未提交事务的消息中。
  2. 持久性:Broker不能丢失任何已提交的事务。
  3. 排序:事务消费者应在每个分区中以原始顺序查看事务消息。
  4. 交织:每个分区都应该能够接收来自事务性生产者和非事务生产者的消息
  5. 事务中不应有重复的消息。
    如果允许事务性和非事务性消息的交织,则非事务性和事务性消息的相对顺序将基于附加(对于非事务性消息)和最终提交(对于事务性消息)的相对顺序
    在这里插入图片描述

分区p0和p1接收事务X1和X2的消息,以及非事务性消息。时间线是消息到达Broker的
时间。由于首先提交了X2,所以每个分区都将在X1之前公开来自X2的消息。由于非事务性消息在X1和
X2的提交之前到达,因此这些消息将在来自任一事务的消息之前公开

事务配置

1、创建消费者代码,需要:
将配置中的自动提交属性(auto.commit)进行关闭
而且在代码里面也不能使用手动提交commitSync( )或者commitAsync( )
设置isolation.level:READ_COMMITTED或READ_UNCOMMITTED

2、创建生成者,代码如下,需要:
配置transactional.id属性
配置enable.idempotence属性

事务概览

生产者将表示事务开始/结束/中止状态的事务控制消息发送给使用多阶段协议管理事务的高可用事
务协调器。生产者将事务控制记录(开始/结束/中止)发送到事务协调器,并将事务的消息直接发送到
目标数据分区。消费者需要了解事务并缓冲每个待处理的事务,直到它们到达其相应的结束(提交/中
止)记录为止。
事务组
事务组中的生产者
事务组的事务协调器
Leader brokers(事务数据所在分区的Broker)
事务的消费者

事务组

事务组用于映射到特定的事务协调器(基于日志分区数字的哈希)。该组中的生产者需要配置为该组事务生产者。由于来自这些生产者的所有事务都通过此协调器进行,因此我们可以在这些事务生产者之间实现严格的有序

生产者ID和事务组状态

事务生产者需要两个新参数:生产者ID和生产组。
需要将生产者的输入状态与上一个已提交的事务相关联。这使事务生产者能够重试事务(通过为该事务重新创建输入状态;在我们的用例中通常是偏移量的向量)

可以使用消费者偏移量管理机制来管理这些状态。消费者偏移量管理器将每个键(
consumergroup-topic-partition )与该分区的最后一个检查点偏移量和元数据相关联。在事务生产
者中,我们保存消费者的偏移量,该偏移量与事务的提交点关联。此偏移提交记录(在
__consumer_offsets 主题中)应作为事务的一部分写入。即,存储消费组偏移量的
__consumer_offsets 主题分区将需要参与事务。因此,假定生产者在事务中间失败(事务协调器随后
到期);当生产者恢复时,它可以发出偏移量获取请求,以恢复与最后提交的事务相关联的输入偏移
量,并从该点恢复事务处理。
为了支持此功能,我们需要对偏移量管理器和压缩的 __consumer_offsets 主题进行一些增强。
首先,压缩的主题现在还将包含事务控制记录。我们将需要为这些控制记录提出剔除策略。
其次,偏移量管理器需要具有事务意识;特别是,如果组与待处理的事务相关联,则偏移量提取请
求应返回错误

事务协调器

事务协调器是 __transaction_state 主题特定分区的Leader分区所在的Broker。它负责初始
化、提交以及回滚事务。事务协调器在内存管理如下的状态:
对应正在处理的事务的第一个消息的HW。事务协调器周期性地将HW写到ZK。
事务控制日志中存储对应于日志HW的所有正在处理的事务:
事务消息主题分区的列表。
事务的超时时间。
与事务关联的Producer ID。
需要确保无论是什么样的保留策略(日志分区的删除还是压缩),都不能删除包含事务HW的日志分段。

事务流程
  • 初始阶段
  1. Producer:计算哪个Broker作为事务协调器。
  2. Producer:向事务协调器发送BeginTransaction(producerId, generation, partitions… )请
    求,当然也可以发送另一个包含事务过期时间的。如果生产者需要将消费者状态作为事务的一
    部分提交事务,则需要在BeginTransaction中包含对应的 __consumer_offsets 主题分区信息。
  3. Broker:生成事务ID
  4. Coordinator:向事务协调主题追加BEGIN(TxId, producerId, generation, partitions…)消息,
    然后发送响应给生产者。
  5. Producer:读取响应(包含了事务ID:TxId)
  6. Coordinator (and followers):在内存更新当前事务的待确认事务状态和数据分区信息
  • 发送阶段
    Producer:发送事务消息给主题Leader分区所在的Broker。每个消息需要包含TxId和TxCtl字段。
    TxCtl仅用于标记事务的最终状态(提交还是中止)。生产者请求也封装了生产者ID,但是不追加到日志
    中。
  • 结束阶段 (生产者准备提交事务)
  1. Producer:发送OffsetCommitRequest请求提交与事务结束状态关联的输入状态(如下一个
    事务输入从哪儿开始)
  2. Producer:发送CommitTransaction(TxId, producerId, generation)请求给事务协调器并等待
    响应。(如果响应中没有错误信息,表示将提交事务)
  3. Coordinator:向事务控制主题追加PREPARE_COMMIT(TxId)请求并向生产者发送响应。
    4 Coordinator:向事务涉及到的每个Leader分区(事务的业务数据的目标主题)的Broker发送
    一个CommitTransaction(TxId, partitions…)请求。
  4. 事务业务数据的目标主题相关Leader分区Broker
    . 如果是非 __consumer_offsets 主题的Leader分区:一收到
    CommitTransaction(TxId, partition1, partition2, …)请求就会向对应的分区Broker
    发送空(null)消息(没有key/value)并给该消息设置TxId和TxCtl(设置为
    COMMITTED)字段。Leader分区的Broker给协调器发送响应。
  5. 如果是 __consumer_offsets 主题的Leader分区:追加消息,该消息的key是 GLAST-COMMIT ,value就是 TxId 的值。同时也应该给该消息设置TxId和TxCtl字段。
    Broker向协调器发送响应。
  6. Coordinator:向事务控制主题发送COMMITTED(TxId)请求。 __transaction_state
  7. Coordinator (and followers):尝试更新HW。
事务的中止

当事务生产者发送业务消息的时候如果发生异常,可以中止该事务。如果事务提交超时,事务协调
器也会中止当前事务。
Producer:向事务协调器发送AbortTransaction(TxId)请求并等待响应。(一个没有异常的响应
表示事务将会中止)
Coordinator:向事务控制主题追加PREPARE_ABORT(TxId)消息,然后向生产者发送响应。
Coordinator:向事务业务数据的目标主题的每个涉及到的Leader分区Broker发送
AbortTransaction(TxId, partitions…)请求。(收到Leader分区Broker响应后,事务协调器中止
动作跟上面的提交类似。)

基本事务流程的失败

生产者发送BeginTransaction(TxId):的时候超时或响应中包含异常,生产者使用相同的TxId重
试。
生产者发送数据时的Broker错误:生产者应中止(然后重做)事务(使用新的TxId)。如果生
产者没有中止事务,则协调器将在事务超时后中止事务。仅在可能已将请求数据附加并复制到
Follower的错误的情况下才需要重做事务。例如,生产者请求超时将需要重做,而
NotLeaderForPartitionException不需要重做。
生产者发送CommitTransaction(TxId)请求超时或响应中包含异常,生产者使用相同的TxId重
试事务。此时需要幂等性

主题的压缩

压缩主题在压缩过程中会丢弃具有相同键的早期记录。如果这些记录是事务的一部分,这合法吗?
这可能有点怪异,但可能不会太有害,因为在主题中使用压缩策略的理由是保留关键数据的最新更新。
如果该应用程序正在(例如)更新某些表,并且事务中的消息对应于不同的键,则这种情况可能导
致数据库视图不一致

事务相关配
  • Broker
    在这里插入图片描述

在这里插入图片描述

  • 生产者
    在这里插入图片描述

  • 消费者
    在这里插入图片描述

幂等性

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

在这里插入图片描述
生产中,会出现各种不确定的因素,比如在Producer在发送给Broker的时候出现网络异常。比如以下这种异常情况的出现
在这里插入图片描述
上图这种情况,当Producer第一次发送消息给Broker时,Broker将消息(x2,y2)追加到了消息流中,
但是在返回Ack信号给Producer时失败了(比如网络异常) 。此时,Producer端触发重试机制,将消息
(x2,y2)重新发送给Broker,Broker接收到消息后,再次将该消息追加到消息流中,然后成功返回Ack信
号给Producer。这样下来,消息流中就被重复追加了两条相同的(x2,y2)的消息

幂等性

保证在消息重发的时候,消费者不会重复处理。即使在消费者收到重复消息的时候,重复处理,也
要保证最终结果的一致性。
所谓幂等性,数学概念就是: f(f(x)) = f(x) 。f函数表示对消息的处理。
比如,银行转账,如果失败,需要重试。不管重试多少次,都要保证最终结果一定是一致的
幂等性实现
添加唯一ID,类似于数据库的主键,用于唯一标记一个消息。
Kafka为了实现幂等性,它在底层设计架构中引入了ProducerID和SequenceNumber

  • ProducerID:在每个新的Producer初始化时,会被分配一个唯一的ProducerID,这个ProducerID对客户端使用者是不可见的。
    SequenceNumber:对于每个ProducerID,Producer发送数据的每个Topic和Partition都对应一个从0开始单调递增的SequenceNumber值

同样,这是一种理想状态下的发送流程。实际情况下,会有很多不确定的因素,比如Broker在发送
Ack信号给Producer时出现网络异常,导致发送失败。异常情况如下图所示
在这里插入图片描述
当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<String, String> producer = new KafkaProducer<>(props);

在org.apache.kafka.clients.producer.internals.Sender类中,在run()中有一个
maybeWaitForPid()方法,用来生成一个ProducerID,实现代码如下

private void maybeWaitForPid() {
if (transactionState == null)
return;
while (!transactionState.hasPid()) {
try {
Node node = awaitLeastLoadedNodeReady(requestTimeout);
if (node != null) {
ClientResponse response =
sendAndAwaitInitPidRequest(node);
if (response.hasResponse() && (response.responseBody()
instanceof InitPidResponse)) {
InitPidResponse initPidResponse = (InitPidResponse)
response.responseBody();
transactionState.setPidAndEpoch(initPidResponse.producerId(),
initPidResponse.epoch());
} else {
log.error("Received an unexpected response type for
an InitPidRequest from {}. " +
"We will back off and try again.", node);
}
} else {
log.debug("Could not find an available broker to send
InitPidRequest to. " +
"We will back off and try again.");
}
} catch (Exception e) {
log.warn("Received an exception while trying to get a pid.
Will back off and retry.", e);
} l
og.trace("Retry InitPidRequest in {}ms.", retryBackoffMs);
time.sleep(retryBackoffMs);
metadata.requestUpdate();
}
}

事务操作

在Kafka事务中,一个原子性操作,根据操作类型可以分为3种情况。情况如下:
只有Producer生产消息,这种场景需要事务的介入;
消费消息和生产消息并存,比如Consumer&Producer模式,这种场景是一般Kafka项目中比较常见的模式,需要事务介入;
只有Consumer消费消息,这种操作在实际项目中意义不大,和手动Commit Offsets的结果一样,而且这种场景不是事务的引入目的

// 初始化事务,需要注意确保transation.id属性被分配
void initTransactions();
// 开启事务
void beginTransaction() throws ProducerFencedException;
// 为Consumer提供的在事务内Commit Offsets的操作
void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata>
offsets,
String consumerGroupId) throws
ProducerFencedException;
// 提交事务
void commitTransaction() throws ProducerFencedException;
// 放弃事务,类似于回滚事务的操作
void abortTransaction() throws ProducerFencedException;
案例1:单个Producer,使用事务保证消息的仅一次发送

public class MyTransactionalProducer {
public static void main(String[] args) {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "node1:9092");
configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
StringSerializer.class);
configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
StringSerializer.class);
// 提供客户端ID
configs.put(ProducerConfig.CLIENT_ID_CONFIG, "tx_producer");
// 事务ID
configs.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "my_tx_id");
// 要求ISR都确认
configs.put(ProducerConfig.ACKS_CONFIG, "all");
KafkaProducer<String, String> producer = new KafkaProducer<String,
String>(configs);
// 初始化事务
producer.initTransactions();
// 开启事务
producer.beginTransaction();
try {
// producer.send(new ProducerRecord<>("tp_tx_01", "tx_msg_01"));
producer.send(new ProducerRecord<>("tp_tx_01", "tx_msg_02"));
// int i = 1 / 0;
// 提交事务
producer.commitTransaction();
} catch (Exception ex) {
// 中止事务
producer.abortTransaction();
} finally {
// 关闭生产者
producer.close();
}
}
}
案例2:在 消费-转换-生产 模式,使用事务保证仅一次发送
public class MyTransactional {
public static KafkaProducer<String, String> getProducer() {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "node1: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;
} p
ublic static KafkaConsumer<String, String> getConsumer(String
consumerGroupId) {
Map<String, Object> configs = new HashMap<>();
configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "node1: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;
}
p
ublic static void main(String[] args) {
String consumerGroupId = "consumer_grp_id_101";
KafkaProducer<String, String> producer = getProducer();
KafkaConsumer<String, String> consumer =
getConsumer(consumerGroupId);
// 事务的初始化
producer.initTransactions();
//订阅主题
consumer.subscribe(Collections.singleton("tp_tx_01"));
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>
("tp_tx_out_01", record.key(), record.value()));
offsets.put(
new TopicPartition(record.topic(),
record.partition()),
new OffsetAndMetadata(record.offset() + 1)); // 偏
移量表示下一条要消费的消息
} /
/ 将该消息的偏移量提交作为事务的一部分,随事务提交和回滚(不提交消费偏移
量)
producer.sendOffsetsToTransaction(offsets, consumerGroupId);
// int i = 1 / 0;
// 提交事务
producer.commitTransaction();
} catch (Exception e) {
e.printStackTrace();
// 回滚事务
producer.abortTransaction();
} finally {
// 关闭资源
producer.close();
consumer.close();
}
}
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值