Kafka (3) - Kafka消息的可靠性保障以及选举

  Kafka最优秀的特点除了高吞吐,还有一个就是可靠性保证,那么本节就通过kafka的可靠性保证来聊一聊相关的问题。

一、Kafka怎么保证消息的可靠性

  常见的可靠性保障主要可以分为一下三种情况:

  • 至少一次:消息不回丢,但是可能重复
  • 最多一次:消息可能会丢,但是绝对不会重复
  • 精确一次:不会丢,也不会重复

  那么这里根据不同的保障语意对应了不同的处理方式,首先我们说一下至少一次和最多一次两种语意。

至少一次/最多一次:  

  这两种语意主要是靠客户端的acks参数来进行保证的,我们知道在创建KafkaProducer对象的时候,可以设置acks这个参数,那么这个参数有三个值可选:

  • 0:producer不等待broker的ack返回值继续执行后续的流程,这种情况下如果broker出现故障,很可能导致消息的丢失
  • 1:在分区的leader落盘后,broker返回ack,但是如果数据在没有同步到follower时leader挂了,那么就可能导致已经提交的消息丢失
  • -1:  等待ISR集合中全部Replica都成功落盘后,broker才会返回ack。这里的问题是在broker返回ack之前leader挂了,也就是说这个消息其实已经是被成功接收,但是producer却没有收到ack返回,所以会任务消息发送失败,然后会再次提交消息,最终造成消息重复。同时如果此时ISR中只有leader节点,那么此时就会导致消息丢失。如果是ISR中节点比较多,如果其中某个follower因为网络原因没有能及时返回ack,那么会将这个followe踢出ISR,然后broker直接给producer返回ack即可。

  可以看到这个参数是在Producer层面上进行的处理,实际中还会结合重试次数来进行可靠性语意的保障。但是根据Server端不同的情况还是可能会出现消息重复和消息丢失的问题,所以如果只在Producer层面只能实现至少一次(acks=-1)和最多一次(acks=0)

精确一次:

  通过上面的说明,我们看到无论是哪种情况,都不是完美的。而在一些比较特别的场景下,我们需要的是精确一次处理的可靠性保证,这样可以减少下游系统对消息去重的消耗。那么Kafka是怎么处理这种情况的呢?Kafka是提供了两种方式:幂等和事务。

  • 幂等

    这里的幂等性指Producer发送的消息,在Server端只被持久化一次,数据不丢失不重复。看起来很不错是吧,不过这个特性是在一定条件下的:

    1. 只能保证在单会话内的幂等性(后面会提到,会话中断后无法获取之前的PID)
    2. 只能保证在单分区内的幂等性,无法跨Topic-Partition

    在客户端上开启幂等也非常简单,只需要设置 :ENABLE_IDEMPOTENCE_CONFIG 即可,后acks参数其实是可以省略的,因为只要是开启了幂等,这个acks默认是要设置为all,如果强制设置成其他值在启动的时候就会报错提示。

props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put("acks", "all");

    这里可以看到kafka客户端其实是非常友好的,幂等的细节都帮我们屏蔽掉了。但是我们还是需要了解一下实现的细节,这样有助于我们在实际应用场景中更好的去选择。

    因为这个幂等性是限制的单个会话,单个分区中的,所以我们可以猜到用于控制幂等的唯一ID应该是一个局部的,也就是说在不同分区唯一ID是互不干扰的。那么Kafka是怎么处理的呢?

    每个Kakfa Producer在初始化的时候,会向Server申请一个PID,用于标识Producer,因为对于同一个分区不同的客户端之前的幂等是互不干扰的。在申请了PID之后,那么Producer在向每个不同的分区提交消息的时候,需要携带这个PID和sequence numbers,这个sequence numbers只在当前PID下是生效的,所以完全可以从0开始计数。也就是说到server端接收到的消息后,会根据PID获取对应的sequence numbers,然后判断sequence numbers是否大于当前保存的最大值,如果小于那就说名这个消息已经被提交过,而直接丢弃掉当前的提交即可。

    那么到这里也就能说明为什么Kafka的幂等是只支持单会话的了,因为PID和sequence numbers信息是存储在Producer中的,会话丢失之后是无法获取之前的PID以及sequence numbers信息的,所以无法继续之前的处理,只能重新去申请PID已经开启新的sequence numbers。

      

  • 事务

    事务的出现主要是为了解决幂等无法解决的跨Topic-partition的场景。所以相比于幂等性,事务也存在几个保证:

    1. 跨会话的幂等性保障,即使中间出现故障,也可以保证事务的完整性
    2. 跨会话的事务恢复,即使应用挂了,下一个应用依然可以继续完成事务的提交和abort
    3. 跨Topic的消息写入保证事务特性

    开启事务的方式也比较简单:

props.put("transactional.id", "test-transactional");
props.put("acks", "all");

    只需要设置transactional.id即可,同样acks参数默认就是all,如果设置成别的同样会报错。使用也比较简单,相比于普通的消息提交,就是多了几条控制语句:

KafkaProducer producer = new KafkaProducer(props);
producer.initTransactions();

try {
    String msg = "matt test";
    producer.beginTransaction();
    producer.send(new ProducerRecord(topic, "0", msg.toString()));
    producer.send(new ProducerRecord(topic, "1", msg.toString()));
    producer.send(new ProducerRecord(topic, "2", msg.toString()));
    producer.commitTransaction();
} catch (ProducerFencedException e1) {
    e1.printStackTrace();
    producer.close();
} catch (KafkaException e2) {
    e2.printStackTrace();
    producer.abortTransaction();
}
producer.close();

  事务的实现原理稍微有点复杂,这里我们先简单说一下核心流程,具体的细节后面找时间我会补充一篇详细的。

  我们知道事务相比于幂等最大的特性就是可以跨Topic-partition保证消息的准确一次处理。但是消息对于每个分区的写入都是分开的,所以这时就需要一个协调者来统计每个分区的提交情况,只有所有分区都提交成功了,才会让整个事务提交,这里熟悉2PC的同学可能更好的理解。

  那么在Kafka中也存在一个协调者,就是TransactionCoordinator,于之前聊的GroupCoordinator一样,这个线程也是存在于每个broker上的。那么我们提交完之后之后,这个coordinator就是负责集中处理每个分区的执行情况,以及最终事务的提交和中止操作。

  那么Kafka还有一个特性,就是可以跨会话,这个特性又是怎么实现的呢?其实看上一段的描述可能有的同学会有这样的疑问,如果事务是一个长事务,那么在执行期间coordinator出现了故障挂了,这个时候怎么办呢?其实在这个场景中最重要的问题就是之前的事务状态信息怎么进行传递,也就是说当前负责的coordinator挂了,那么重新选举出来的coordinator怎么继续处理之前的事务,必须要知道之前的状态才行是吧。这里就体现出Kafka的优势了,Kafka内部把事务的状态与transactional.id关联了起来,然后在内部通过 __transaction_state的Topic进行状态的传递。这样就算是当前的coordinator挂了,那么新选举出来的协调者也能够继续之前的处理进度继续进行而不丢失。

 

二、消息的重复消费以及消息丢失

  上面我们提到了消息丢失的场景,但是真正丢失的原因是什么呢?下面我们从底层操作系统的层面上来看一下。首先我们知道数据在应用层写操作之后,是先把数据存储的Linux的Page Cache中的,只有在执行了fsync操作之后,数据才真正的被写到磁盘上,也才算是真正的持久化了。但是Kafak为了提高吞吐量,采用的是异步执行fsync的方式,那么就有可能出现在还没来得及执行fsync的时候,系统挂了,那么对应还没有写入到磁盘上的消息就丢失了。然后Kafka并没有提供fsync的策略方式,也就是说理论上是无法避免数据丢失的。

  那既然在底层无法完全避免,那么我们可以采用与应用层结合的方式来进行组合处理。还是结合上一小节的内容,我们需要将acks设置为all,这里有两个原因,一个是需要所有的ISR都成功写入后,才会给客户端返回ack,第二个原因就是数据是只写入到leader replica的,但是如果想要ISR中的其他follower能够同步到这个消息,就必须要将消息持久化到磁盘上。这样就算是出现大规模的断电等极端情况,至少消息是会存在于leader磁盘上的,这里我们假设所有的ISR节点同步过来的数据都存在于Page Cache中,还没来得及fsync到磁盘上。

  下面我们再从生产者和消费者两端来聊一下消息丢失的情况:

Producer:

  Producer生产者为了提高效率,采用异步提交的方式进行数据的提交。然后根据消息的大小和时间来执行提交操作,然消息本身就保存在Producer的buffer中,那么如果这个时候Producer突然挂掉,那么保存在buffer中的消息还没来得及提交,那这部分数据就丢失了。但是这里有个问题需要说明一下,Kafka的可靠性保证指的是已经完成提交的消息,也就是说在server端通过Replica的方式进行冗余数据的存储,通过这种方式保证消息的可靠性,像客户端的这种情况消息都没有发送到Kafka服务端,也就谈不上消息的丢失。

   这里我们多说一句KafkaProducer通过send的方式提交数据,默认都是异步的方式,然后通过callback函数通知提交的结果,只是如果我们如果想要实现同步的效果,可以通过返回的Future的get方法将线程阻塞住,我们看一下实现:

    /**
     * Asynchronously send a record to a topic. Equivalent to <code>send(record, null)</code>.
     * See {@link #send(ProducerRecord, Callback)} for details.
     */
    @Override
    public Future<RecordMetadata> send(ProducerRecord<K, V> record) {
        return send(record, null);
    }

这个是不带callback参数的实现,我们可以通过通过返回值 Future 对象来获取提交结果

    @Override
    public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
        // intercept the record, which can be potentially modified; this method does not throw exceptions
        ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record);
        return doSend(interceptedRecord, callback);
    }

这是另外一种重载的实现,在Producer收到Server端ack的通知之后,调用callback函数执行后续动作。

这里统一说一下这两个方式的实现:都是异步的发送一个Record消息,消息是被存储在发送缓冲区,而不会等待每一条消息的返回。结合一下上面提到的acks参数,这个acks参数配置的是Server端在申请情况下返回消息已提交。结合Producer发送,又一个特殊的情况,就是如果acks设置为0 ,那么在Server端是不等待leader replica执行落盘操作的,直接就返回给Producer客户端,然后客户端会进行一个判断是否为acks设置为0的情况,然后构造一个Response对象返回,这个后面第四篇会从源码的角度说一下,这里就先贴一段源码

            } else {
                // this is the acks = 0 case, just complete all requests
                for (ProducerBatch batch : batches.values()) {
                    completeBatch(batch, new ProduceResponse.PartitionResponse(Errors.NONE), correlationId, now, 0L);
                }
            }

 

  铺垫了这么多,最后说一下在Producer的角度上是怎么看待消息重复和消息丢失:

    如果Producer客户端没有开启幂等和事务,那么不管acks参数如何设置,都无法避免消息重复,因为在Server端给返回结果的过程中,网络因素我们无法避免。所以想要解决消息重复的解决方法就是根据业务需求开启幂等或者事务。那么消息丢失呢?其实只要我们acks参数设置的不是0,那么出现消息丢失的概率就不是很大,但是这个也需要结合实际的业务场景,最好的解决方式就是acks设置为-1,这样只要Replica在集群中有一台机器存活,消息就不会丢失。而上面也提到了,如果开启了幂等或者事务,acks参数默认就是开启-1模式,所以是完全可以在Producer端解决消息重复和消息丢失的。

Consumer : 

  再来说说消费端,在消费端其实就是只存在一个问题,就是消息的重复消费问题,在聊这个问题之前咱们也是先聊一下前置条件:消费者提交offset的方式?

从提交的主体来划分可以分为自动提交和手动提交两种方式,然后手动提交里面又可以分为同步提交和异步提交两种方式。

自动提交:

  与自动提交相关的是下面两个参数,KafkaConsumer默认的offset提交方式就是自动提交,就是根据设置的时间间隔,自动的发起一个offset的提交操作,这个时间默认是5s

props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "1000");

手动提交:

  其实在生产环境中或者是对数据消费比较敏感的情况下,我们都是会选择手动提交的,因为一条消息我们从Server端拉取到之后,只要在真正处理完才会认为是消费成功了。但是如果采用自动提交的方式,可能消息我们刚获取到,但是处理流程还没有走完,但是offset已经提交了,这个时候如果我们的程序出现异常,则当前的这条消息就丢失了,offset也已经提交了,除非手动干预,否则没法再处理这条消息了,基于这些考虑,我们来看一下手动提交:

一、同步提交 :  commitSync

  同步提交会阻塞住当前线程,提交失败会进行重试,直到提交成功或者遇到未知错误,同步提交是一个强一致性的提交方式。

二、异步提交:  commitAsync

  异步提交如果提交失败,会反应到回调方法中,但是不会进行重试操作,因为如果进行重试,就有可能出现位移提交覆盖,导致数据重复消费的情况发生。

总结一下:

  其实无论采用哪种提交当时,都有可能出现offset提交不成功的情况,更差的情况是如果在提交的过程中发生Rebalance操作,那么就会出现消息重复消费的问题,所以这个问题是在单纯Kafka层面上无法完全解决的。最好是结合下游系统,进行幂等的处理,比如可以将offset和数据库的唯一ID进行关联。

 

三、ISR以及Replica Leader选举相关

   这小节的内容比较的分散,都是一些小的知识点,但是也都是理解Kafka内部处理流程比较重要的。

ISR相关:

  首先是在Replica的背景下存在一下几个概念:

  • AR:Assigned Replicas,是指每个partition下所有replica的统称
  • ISR:In-Sync Replicas的缩写,副本同步队列
  • LEO:LogEndOffset的缩写,是指每个partition中每个replica最后一条log的offset
  • HW:高水位线,在consumer中能看到此partition这个位置的log,取partition中ISR里面最小的LEO作为整个partition的HW

  首先AR的概念我们比较好理解,但是ISR是怎么来的呢?我们知道一个消息被写入到leader replica之后,回根据客户端的acks参数来确定什么时候给producer客户端返回消息。但是在server内部的所有replica,除了leader之外,其他的副本都要从leader中复制消息到本地,来保障消息的可靠性存储。那么根据网络条件和磁盘以及GC等因素的影响,就会出现不同Broker上的replica与leader的进度出现差异。

  基于这个问题,Kafka给出了下面一个参数replica.lag.time.max.ms(在0.10之后的版本就只剩下这一个参数了),用于区分所有非leader replica的状态,超过这个时间没有给leader发送FetchRequest请求,那么该replica就会被踢出ISR。

  而LEO的概念很好理解,就是每个replica最后一条日志的offset,然后HW的概念是基于ISR的,也就是说一条消息被写入到leder replica之后,还不能够被消费者看到和消费,必须要等待所有的ISR都同步了这个消息之后,HW才会被更新,消费者才能消费这个消息,否则如果leader挂掉,那么这个消息就是丢失的了。

选举相关:  

  一个Partition存在多个Replica,但是只有一个Replica是作为leader来接收读写请求的。那这个leader replica是怎么选举出来的,如果挂了之后,那其他replica是怎么再选举出leader?

  首先所有的Replica是分布在不同的Broker上面的,所有的Broker在启动的时候都会在ZK中进行注册,然后在所有Broker中选择一个座位Controller,具体的Controller会有一下三个指责:

  1. 当某个分区的leader副本出现故障时,由控制器负责为该分区选举新的leader副本。
  2. 当检测到某个分区的ISR集合发生变化时,由控制器负责通知所有broker更新其元数据信息。
  3. 当使用kafka-topics.sh脚本为某个topic增加分区数量时,同样还是由控制器负责让新分区被其他节点感知到。

  也就是说Controller Broker是集群管理的基础,那么首先看一下Controller是如何选举出来的呢?Broker启动的时候会向ZK的/controller节点进行注册,在所有Broker中只有一个能够注册成功,那么这个节点就会被选择为Controller。同时Controller会监控brokers下面的所有的broker,一旦发现某个broker挂掉了,就会去找到该broker有多少partion是leader并发起对该partition的选举。

  而在ZK中也根据Topic维护了一个ISR的列表,也就是说如果在leader挂了之后,会首先从这个集合中选择新的leader。

posted @ 2021-12-27 19:23  SyrupzZ  阅读(336)  评论(0)    收藏  举报