Title

kafka11-事务介绍,幂等性,事务测试用例

1 稳定性-事务

1.1 事务场景

  1. 如producer发的多条消息组成一个事务这些消息需要对consumer同时可见或者同时不可见 。
  2. producer可能会给多个topic,多个partition发消息,这些消息也需要能放在一个事务里面, 这就形成了一个典型的分布式事务。
  3. kafka的应用场景经常是应用先消费一个topic,然后做处理再发到另一个topic,这个 consume-transform-produce过程需要放到一个事务里面,比如在消息处理或者发送的过程 中如果失败了,消费偏移量也不能提交。
  4. producer或者producer所在的应用可能会挂掉,新的producer启动以后需要知道怎么处理之 前未完成的事务 。
  5. 在一个原子操作中,根据包含的操作类型,可以分为三种情况,前两种情况是事务引入的场景
    1. 只有Producer生产消息;
    2. 消费消息和生产消息并存,这个是事务场景中最常用的情况,就是我们常说的 consume-transform-produce 模式
    3. 只有consumer消费消息,这种操作其实没有什么意义,跟使用手动提交效果一样, 而且也不是事务属性引入的目的,所以一般不会使用这种情况

1.2 几个关键概念和推导

  1. 因为producer发送消息可能是分布式事务,所以引入了常用的2PC,所以有事务协调者 (Transaction Coordinator)。Transaction Coordinator和之前为了解决脑裂和惊群问题引入 的Group Coordinator在选举上类似。
  2. 事务管理中事务日志是必不可少的,kafka使用一个内部topic来保存事务日志,这个设计和之 前使用内部topic保存偏移量的设计保持一致。事务日志是Transaction Coordinator管理的状态的持久化,因为不需要回溯事务的历史状态,所以事务日志只用保存最近的事务状态。 __transaction_state
  3. 因为事务存在commit和abort两种操作,而客户端又有read committed和read uncommitted两种隔离级别,所以消息队列必须能标识事务状态,这个被称作Control Message。
  4. producer挂掉重启或者漂移到其它机器需要能关联的之前的未完成事务所以需要有一个唯一 标识符来进行关联,这个就是TransactionalId,一个producer挂了,另一个有相同 TransactionalId的producer能够接着处理这个事务未完成的状态。kafka目前没有引入全局序,所以也没有transaction id,这个TransactionalId是用户提前配置的。
  5. TransactionalId能关联producer,也需要避免两个使用相同TransactionalId的producer同时存在,所以引入了producer epoch来保证对应一个TransactionalId只有一个活跃的producer

1.3 事务语义

1.3.1 解决僵尸实例

我们通过为每个事务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.3.2 事务消息定义

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

有如下要求:

  1. 原子性:消费者的应用程序不应暴露于未提交事务的消息中。

  2. 持久性:Broker不能丢失任何已提交的事务。

  3. 排序:事务消费者应在每个分区中以原始顺序查看事务消息。

  4. 交织:每个分区都应该能够接收来自事务性生产者和非事务生产者的消息

  5. 事务中不应有重复的消息。 如果允许事务性和非事务性消息的交织,则非事务性和事务性消息的相对顺序将基于附加(对于非 事务性消息)和最终提交(对于事务性消息)的相对顺序。

1.4 事务配置

1、创建消费者代码,需要:

​ 将配置中的自动提交属性(auto.commit)进行关闭

​ 而且在代码里面也不能使用手动提交commitSync( )或者commitAsync( ) 设置isolation.level:READ_COMMITTED或READ_UNCOMMITTED

2、创建生成者,代码如下,需要:

​ 配置transactional.id属性

​ 配置enable.idempotence属性

1.5 事务概览

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

  • 事务组
  • 事务组中的生产者
  • 事务组的事务协调器
  • Leader brokers(事务数据所在分区的Broker)
  • 事务的消费者

1.6 事务组

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

1.7 生产者ID和事务组状态

生产者ID:用于唯一标志一个生产者

类似消费组有有消费组主题(_consumer_offsets),事务组也有事务组主题( __transaction_state)

事务组状态以生产者id为key,保存状态

1.8 事务组协调器

  • 事务协调器是 __transaction_state 主题特定分区的Leader分区所在的Broker。它负责初 始化、提交以及回滚事务。事务协调器在内存管理如下的状态:
    • 对应正在处理的事务的第一个消息的HW。事务协调器周期性地将HW写到ZK。
    • 事务控制日志中存储对应于日志HW的所有正在处理的事务
    • 事务消息主题分区的列表
      • 事务的超时时间。
      • 与事务关联的Producer ID。

需要确保无论是什么样的保留策略(日志分区的删除还是压缩),都不能删除包含事务HW的 日志分段

1.9 事务流程

image-20210821213208430

初始阶段

  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...)请求。

  5. 事务业务数据的目标主题相关Leader分区Broker:

    1. 如果是非 __consumer_offsets 主题的Leader分区:一收到 CommitTransaction(TxId, partition1, partition2, ...)请求就会向对应的分区Broker 发送空(null)消息(没有key/value)并给该消息设置TxId和TxCtl(设置为 COMMITTED)字段。Leader分区的Broker给协调器发送响应。
    2. 如果是 __consumer_offsets 主题的Leader分区:追加消息,该消息的key是 G-LAST-COMMIT ,value就是 TxId 的值。同时也应该给该消息设置TxId和TxCtl字段。 Broker向协调器发送响应
  6. Coordinator:向事务控制主题发送COMMITTED(TxId)请求。 __transaction_state

  7. Coordinator (and followers):尝试更新HW。

1.10 事务终止

当事务生产者发送业务消息的时候如果发生异常,可以中止该事务。如果事务提交超时,事务协调 器也会中止当前事务。

  • Producer:向事务协调器发送AbortTransaction(TxId)请求并等待响应。
  • Coordinator:向事务控制主题追加PREPARE_ABORT(TxId)消息,然后向生产者发送响应。
  • Coordinator:向事务业务数据的目标主题的每个涉及到的Leader分区Broker发送 AbortTransaction(TxId, partitions...)请求。

1.11 事务流程的失败

  • 生产者发送BeginTransaction(TxId):的时候超时或响应中包含异常,生产者使用相同的TxId重 试。
  • 生产者发送数据时的Broker错误:生产者应中止(然后重做)事务(使用新的TxId)
  • 生产者发送CommitTransaction(TxId)请求超时或响应中包含异常,生产者使用相同的TxId重 试事务。此时需要幂等性。

1.12 主题压缩

压缩主题在压缩过程中会丢弃具有相同键的早期记录。如果这些记录是事务的一部分,这合法吗? 这可能有点怪异,但可能不会太有害,因为在主题中使用压缩策略的理由是保留关键数据的最新更新。

如果该应用程序正在(例如)更新某些表,并且事务中的消息对应于不同的键,则这种情况可能导 致数据库视图不一致。

1.13 配置

  • broker config

    配置 说明
    transactional.id.timeout.ms 在ms中,事务协调器在生产者 TransactionalId提前过期之前等待的最长 时间,并且没有从该生产者 TransactionalId接收到任何事务状态更 新。默认是604800000(7天)。这允许每周 一次的生产者作业维护它们的id
    max.transaction.timeout.ms 事务允许的最大超时。如果客户端请求的 事务时间超过此时间,broke将在 InitPidRequest中返回 InvalidTransactionTimeout错误。这可以 防止客户机超时过大,从而导致用户无法 从事务中包含的主题读取内容。 默认值为900000(15分钟)。这是消息事务 需要发送的时间的保守上限。
    transaction.state.log.replication.factor 事务状态topic的副本数量。默认值:3
    transaction.state.log.num.partitions 事务状态主题的分区数。默认值:50
    transaction.state.log.min.isr 事务状态主题的每个分区ISR最小数量。默 认值:2
    transaction.state.log.segment.bytes 事务状态主题的segment大小。默认 值:104857600字节(100M)
  • producer config

    配置 说明
    enable.idempotence 开启幂等
    transaction.timeout.ms 事务超时时间 事务协调器在主动中止正在进行的事务之前等待生产者更新事务 状态的最长时间。这个配置值将与InitPidRequest一起发送到事 务协调器。如果该值大于max.transaction.timeout。在broke中 设置ms时,请求将失败,并出现InvalidTransactionTimeout错 误。 默认是60000。这使得交易不会阻塞下游消费超过一分钟,这在 实时应用程序中通常是允许的。
    transactional.id 用于事务性交付的TransactionalId。这支持跨多个生产者会话的 可靠性语义,因为它允许客户端确保使用相同TransactionalId的 事务在启动任何新事务之前已经完成。如果没有提供 TransactionalId,则生产者仅限于幂等交付。
  • consumer config

    配置 说明
    isolation.level - read_uncommitted:以偏移顺序使用已提交和未提交的消息。 - read_committed:仅以偏移量顺序使用非事务性消息或已提交事务性消 息。为了维护偏移排序,这个设置意味着我们必须在使用者中缓冲消息, 直到看到给定事务中的所有消息。

2 幂等性处理

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

生产中,会出现各种不确定的因素,比如在Producer在发送给Broker的时候出现网络异常。比如 以下这种异常情况的出现:

image-20210821220444503

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

幂等性:保证在消息重发的时候,消费者不会重复处理。即使在消费者收到重复消息的时候,重复处理,也 要保证最终结果的一致性。

幂等性实现

添加唯一ID,类似于数据库的主键,用于唯一标记一个消息。

Kafka为了实现幂等性,它在底层设计架构中引入了ProducerID和SequenceNumber。

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

image-20210821220630869

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

3 事务操作

在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);

// 提交事务
void commitTransaction() throws ProducerFencedException;

// 放弃事务,类似于回滚事务的操作
void abortTransaction() throws ProducerFencedException;

测试用例1 - 只有生产者

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;

/**
 * @Author GcWel
 * @Description
 */
public class Producer02 {
    public static void main(String[] args) {
        Map<String, Object> configs = new HashMap<>();
        // 设置连接Kafka的初始连接用到的服务器地址
        configs.put("bootstrap.servers", "lew1:9092");
        // 设置key的序列化器
        configs.put("key.serializer", StringSerializer.class);
        // 设置value的序列化器
        configs.put("value.serializer", StringSerializer.class);
        // 提供客户端ID
        configs.put(ProducerConfig.CLIENT_ID_CONFIG, "tran_producer");
        // 事务ID
        configs.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "trans_test1");
        // 要求ISR都确认
        configs.put(ProducerConfig.ACKS_CONFIG, "all");

        KafkaProducer<String, String> producer = new KafkaProducer<String, String>(configs);
        //初始化事务 生成事务id
        producer.initTransactions();
        try {
            //开启事务
            producer.beginTransaction();
            producer.send(new ProducerRecord<String, String>("trab=ns_test_tp","11","11"));
            producer.send(new ProducerRecord<String, String>("trab=ns_test_tp","22","22"));
            producer.send(new ProducerRecord<String, String>("trab=ns_test_tp","3","33"));
            //提交事务
            producer.commitTransaction();
        }catch (Exception e){
            //终止事务
            producer.abortTransaction();
            e.printStackTrace();
        }finally {
            //关闭生产者
            producer.close();
        }

    }
}

测试用例1 - (消费-转换-生产)

  • 创建消费主题和生产主题,并在控制台消费生产主题
##创建分区为1 副本为1的主题 consumer_test1
kafka-topics.sh --zookeeper lew1:2181 --create --topic consumer_test1 --partitions 1 --replication-factor 1
##创建分区为1 副本为1的主题 producer_test1
kafka-topics.sh --zookeeper lew1:2181 --create --topic producer_test1 --partitions 1 --replication-factor 1
  • 产生数据

    # 向consumer_test1生产数据  略过
    # 查看consumer_test1数据
    [root@lew1 ~]# kafka-console-consumer.sh --bootstrap-server lew1:9092 --topic consumer_test1 --from-beginning
    gcWell llewgc0
    gcWell llewgc1
    gcWell llewgc2
    gcWell llewgc3
    gcWell llewgc4
    gcWell llewgc5
    gcWell llewgc6
    gcWell llewgc7
    gcWell llewgc8
    gcWell llewgc9
    
  • 启动控制台消费者

    ## 需指定隔离级为 read_committed 
    kafka-console-consumer.sh --bootstrap-server lew1:9092 --topic producer_test1 --isolation-level read_committed --from-beginning
    
  • 事务测试1

    从consumer_test1消费数据,经过处理,再写入producer_test1中
    处理: 1.对key末尾加上字符串GcWell 2.当有key的值为GcWell 抛出异常(模拟操作中出现异常)
    事务下:有异常时消费数据的位移不提交,生产数据不提交
    
    import org.apache.kafka.clients.consumer.ConsumerConfig;
    import org.apache.kafka.clients.consumer.ConsumerRecords;
    import org.apache.kafka.clients.consumer.KafkaConsumer;
    import org.apache.kafka.clients.consumer.OffsetAndMetadata;
    import org.apache.kafka.clients.producer.KafkaProducer;
    import org.apache.kafka.clients.producer.ProducerConfig;
    import org.apache.kafka.clients.producer.ProducerRecord;
    import org.apache.kafka.common.TopicPartition;
    import org.apache.kafka.common.serialization.StringDeserializer;
    import org.apache.kafka.common.serialization.StringSerializer;
    import java.util.Arrays;
    import java.util.HashMap;
    import java.util.Map;
    
    /**
     * @Author GcWell
     * @Description
     */
    public class Transaction02 {
        public static KafkaProducer<String, String> getProducer() {
            Map<String, Object> configs = new HashMap<>();
            configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "lew1: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, "trans_client_test02");
            // 设置事务id
            configs.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "trans_id_test02");
            // 需要所有的ISR副本确认
            configs.put(ProducerConfig.ACKS_CONFIG, "all");
            // 启用幂等性
            configs.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
            return new KafkaProducer<>(configs);
        }
        public static KafkaConsumer<String, String> getConsumer() {
            Map<String, Object> configs = new HashMap<>();
            configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "lew1: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");
            return new KafkaConsumer<>(configs);
        }
    
        public static void main(String[] args) {
            String topic = "consumer_test1";
            KafkaConsumer<String, String> consumer = getConsumer();
            KafkaProducer<String, String> producer = getProducer();
            consumer.subscribe(Arrays.asList(topic));
            ConsumerRecords<String, String> records = consumer.poll(1000);
            //初始化事务
            producer.initTransactions();
            Map<TopicPartition, OffsetAndMetadata> consumerOffset = new HashMap<>();
            try {
                //开启事务
                producer.beginTransaction();
                records.forEach(x -> {
                    //生产 转换后数据
                    // 转换操作  1.对key末尾加上字符串GcWell 2.当有key的值为GcWell 抛出异常(模拟操作中出现异常)
                    if(x.key() != null && "GcWell".equals(x.key())){
                        throw new RuntimeException("出现不可知异常");
                    }
                    producer.send(new ProducerRecord<String, String>(x.topic(), x.key() + "GcWell", x.value()));
                    //此处offset需要 + 1
                    consumerOffset.put(new TopicPartition(x.topic(), x.partition()),
                            new OffsetAndMetadata(x.offset() + 1));
                });
                //将该消息的偏移量提交作为事务的一部分,随事务提交和回滚(不提交消费偏移量)
                producer.sendOffsetsToTransaction(consumerOffset, "consumer_grp_02");
                //提交事务
                producer.commitTransaction();
            }catch (Exception e){
                //终止事务
                producer.abortTransaction();
                e.printStackTrace();
            }finally {
                producer.close();
                consumer.close();
            }
        }
    }
    
  • 启动上一步程序

    # 控制台消费producer_test1 可以看到数据
    [root@lew1 ~]# kafka-console-consumer.sh --bootstrap-server lew1:9092 --topic producer_test1 --isolation-level read_committed --from-beginning
    gcWell llewgc0
    gcWell llewgc1
    gcWell llewgc2
    gcWell llewgc3
    gcWell llewgc4
    gcWell llewgc5
    gcWell llewgc6
    gcWell llewgc7
    gcWell llewgc8
    gcWell llewgc9
    
  • 向consumer_test1中生产特殊数据(key 为GcWell)

    public static void main(String[] args) throws ExecutionException, InterruptedException {
            Map<String, Object> configs = new HashMap<>();
            // 设置连接Kafka的初始连接用到的服务器地址
            // 如果是集群,则可以通过此初始连接发现集群中的其他broker 一般设置2-3个即可
            configs.put("bootstrap.servers", "10.10.1.135:9092");
            // 设置key的序列化器
            configs.put("key.serializer", StringSerializer.class);
            // 设置value的序列化器
            configs.put("value.serializer", StringSerializer.class);
    
            KafkaProducer<String, String> producer = new KafkaProducer<>(configs);
            //设置自定义消息头
            List<Header> header = new ArrayList<>();
            header.add(new RecordHeader("biz.name", "producer".getBytes()));
    
                ProducerRecord<String, String> record = new ProducerRecord<String, String>(
                        "consumer_test1",
                        0,
                        "GcWell",
                        "gcWell llewgc",
                        header
                );
                producer.send(record);
            //关闭生产者
            producer.close();
        }
    
  • 再启动事务

    1. 此时控制台不会看到数据增加
    2. producer_test1的消费组偏移量也不会被改变
    
posted @ 2021-08-21 23:43  apeGcWell  阅读(418)  评论(0编辑  收藏  举报