Kafka总结

主题

内容

备注

概念

  • 消息:Record。Kafka是消息引擎嘛,这里的消息就是指Kafka处理的主要对象。

  • 主题:Topic。主题是承载消息的逻辑容器,在实际使用中多用来区分具体的业务。

  • 分区:Partition。一个有序不变的消息序列。每个主题下可以有多个分区。

  • 消息位移:Offset。表示分区中每条消息的位置信息,是一个单调递增且不变的值。

  • 副本:Replica。Kafka中同一条消息能够被拷贝到多个地方以提供数据冗余,这些地方就是所谓的副本。副本还分为领导者副本和追随者副本,各自有不同的角色划分。副本是在分区层级下的,即每个分区可配置多个副本实现高可用。

  • 生产者:Producer。向主题发布新消息的应用程序。

  • 消费者:Consumer。从主题订阅新消息的应用程序。

  • 消费者位移:Consumer Offset。表征消费者消费进度,每个消费者都有自己的消费者位移。

  • 消费者组:Consumer Group。多个消费者实例共同组成的一个组,同时消费多个分区以实现高吞吐。

  • 重平衡:Rebalance。消费者组内某个消费者实例挂掉后,其他消费者实例自动重新分配订阅主题分区的过程。Rebalance是Kafka消费者端实现高可用的重要手段。

  • Broker:一个独立的Kafka服务器,broker 接收来自生产者的消息,为消息设置偏移 量,并提交消息到磁盘保存。 broker 为消费者提供服务,对读取分区的请求作出响应,返 回已经提交到磁盘上的消息。根据特定的硬件及其性能特征,单个 broker 可以轻松处理数 千个分区以及每秒百万级的消息量。

 

 

 

 

拉取模型

消息消费模型有两种:推送模型push和拉取模型pull

基于推送模型的消息系统,由消费代理记录消费者的消费状态。消息代理将消息推送到消费者后,标记这条信息为已消费,但这种方式无法很好的保证消息的处理语义。比如,消费者代理把消息发出去后,当消费进程挂掉,就有可能造成消息丢失。如果要保证消息的处理语义,消息代理发送完消息后,要设置状态为“已发送”,只有收到消费者的确认请求后再更新为“已消费”,这就需要在消息代理中记录所有消息的消费状态。

Kafka采用的是拉取模型pull,由消费者自己记录消费状态,每个消费者相互独立地顺序读取各个分区的消息。

拉取模型的优点:消费者可以按照任意的顺序消费消息。比如,消费者可以充值到旧的偏移量,重新处理之前已经消费过的消息,或者直接跳到最近的位置,从当前时刻开始消费。

 

 

 

Kafka顺序保证

Kafka比传统消息系统有更强的顺序性保证,它使用主题的分区作为消息处理的并行单元。Kafka以分区为最小的粒度,将每个分区分配给消费组中不同的而且唯一的消费者,并确保一个分区只属于一个消费者,即这个消费者就是这个分区的唯一读取线程。那么只要分区的消息是有序的,消费者处理的消息顺序就有保证。

 

Kafka副本Replica

Kafka每个主题的多个分区日志分布式地存储在Kafka集群上,同时为了故障容错,每个分区都会以副本的方式复制到多个消息代理阶段上。其中一个节点作为主副本(Leader),其他节点作为备份副本(Follower)。

主副本会负责所有客户端的写操作,追随者副本仅仅从主副本同步数据。当主副本出现故障时,备份副本中的一个副本会被选择为新的主副本。

​为什么追随者Follower Replica只是同步数据,不提供服务?

1.方便实现“Read-your-writes”。

所谓Read-your-writes,顾名思义就是,当你使用生产者API向Kafka成功写入消息后,马上使用消费者API去读取刚才生产的消息。

如果允许追随者副本对外提供服务,由于副本同步是异步的,因此有可能出现追随者副本还没有从领导者副本那里拉取到最新的消息,从而使得客户端看不到最新写入的消息。

2.方便实现单调读(Monotonic Reads)。

什么是单调读呢?就是对于一个消费者用户而言,在多次消费消息时,它不会看到某条消息一会儿存在一会儿不存在。

如果允许追随者副本提供读服务,那么假设当前有2个追随者副本F1和F2,它们异步地拉取领导者副本数据。倘若F1拉取了Leader的最新消息而F2还未及时拉取,那么,此时如果有一个消费者先从F1读取消息之后又从F2拉取消息,它可能会看到这样的现象:第一次消费时看到的最新消息在第二次消费时不见了,这就不是单调读一致性。但是,如果所有的读请求都是由Leader来处理,那么Kafka就很容易实现单调读一致性。

In-sync Replicas(ISR)

Kafka引入了In-sync Replicas,也就是所谓的ISR副本集合。ISR中的副本都是与Leader同步的副本,相反,不在ISR中的追随者副本就被认为是与Leader不同步的。ISR不只是追随者副本集合,它必然包括Leader副本。甚至在某些情况下,ISR只有Leader这一个副本。

 

持久化数据

生产者保存到服务器:生产者如果每发送一条消息都直接通过网络发送到服务端,势必会造成过多的网络请求。如果我们能够将多条消息按照分区进行分组,并采用批量的方式一次发送一个消息集,并且对消息集进行压缩,就可以减少网络传输的带宽,进一步提高数据的传输效率。

消费者读取数据:使用“零拷贝技术(zero-copy)”只需要将磁盘文件的数据复制到页面缓存一次,然后将数据从页面缓存直接发送到网络中(发送给不同的消费者,都可以重复使用同一个页面缓存),避免了重复的复制操作。

 

持久化数据

Kafka使用消息日志(Log)来保存数据,一个日志就是磁盘上一个只能追加写(Append-only)消息的物理文件。因为只能追加写入,故避免了缓慢的随机I/O操作,改为性能较好的顺序I/O写操作,这也是实现Kafka高吞吐量特性的一个重要手段。不过如果你不停地向一个日志写入消息,最终也会耗尽所有的磁盘空间,因此Kafka必然要定期地删除消息以回收磁盘。怎么删除呢?简单来说就是通过日志段(Log Segment)机制。在Kafka底层,一个日志又进一步细分成多个日志段,消息被追加写到当前最新的日志段中,当写满了一个日志段后,Kafka会自动切分出一个新的日志段,并将老的日志段封存起来。Kafka在后台还有定时任务会定期地检查老的日志段是否能够被删除,从而实现回收磁盘空间的目的。

 

生产者和消费者客户端与服务端完成一次网络通信的步骤

  1. 生产者客户端应用程序产生消息

  2. 客户端连接对象将消息包装大搜请求中,发送到服务端

  3. 服务端连接对象负责接收请求,并将消息以文件形式存储

  4. 服务端返回响应结果给生产者客户端

  5. 消费者客户端应用程序消费消息

  6. 客户端连接对象将消息信息包装到请求中,发送给服务端。

  7. 服务端从文件存储系统中取出消息

  8. 服务端返回响应结果给消费者客户端

  9. 客户端将响应结果还原成消息,并开始处理消息。

 

生产者发送消息

KafkaProducer对象代表一个生产者客户端进程,发送消息步骤:

  1. 客户端把消息放到队列中

  2. 消息发送线程从队列中拉取消息,以批量的方式发送给服务器

记录收集器RecordAccumulator负责缓存生产者客户端产生的消息,发送线程Sender负责读取记录收集器的批量消息,通过网络发送给服务端。为了保证客户端网络请求的快速响应,Kafka使用选择器Selector处理网络连接和读写处理,使用网络连接NewworkClient处理客户端网络请求。

 

同步/异步发送消息

Kafka可以同步也可以异步发送消息,异步发送消息,发送完消息之后,不需要等结果,直接发送下一条消息,服务端处理完之后调用回调方法。同步发行消息,需要等生产者发送完一条消息,才可以发送下一条。

 

发送消息并行处理

Kafka通过将主题分成多个分区来实现并行处理,生产者可以将一批信息分成多个分区,每个分区写入不同的服务端节点,生产者客户端采用这种分区并行发送的方式,从而提升生产者客户端写入性能。

 

 

 

客户端记录收集器

RecordAccumulator:收集消息,当批记录已满才会发送消息。

追加消息到记录收集器是按照分区进行分组,并放到batches集合中,每个分区的队列都保存了即将发送到这个分区对应节点的批记录

 

客户端消息发送线程

Sender线程迭代batches的每个分区,获取分区对应的主副节点,取出分区对应的队列中的批记录,然后发送消息。

发送消息时,会把属于同一节点的所有分区放到一起,然后发送。

 

 

 

客户端网络连接对象

NetworkClient 3个方法:

ready()、send()、poll()

 

分区策略

所谓分区策略是决定生产者将消息发送到哪个分区的算法。Kafka为我们提供了默认的分区策略,同时它也支持你自定义分区策略。分区策略又:轮询策略、随机策略、按消息键保序策略(key-order策略)、基于地理位置的分区策略

 

 

 

轮询策略

也称Round-robin策略,即顺序分配。比如一个主题下有3个分区,那么第一条消息被发送到分区0,第二条被发送到分区1,第三条被发送到分区2,以此类推。当生产第4条消息时又会重新开始,即将其分配到分区0,就像下面这张图展示的那样。

 

 

这就是所谓的轮询策略。轮询策略是Kafka Java生产者API默认提供的分区策略。如果你未指定partitioner.class参数,那么你的生产者程序会按照轮询的方式在主题的所有分区间均匀地“码放”消息。

轮询策略有非常优秀的负载均衡表现,它总是能保证消息最大限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略,也是我们最常用的分区策略之一。

 

随机策略

也称Randomness策略。所谓随机就是我们随意地将消息放置到任意一个分区上,如下面这张图所示。

 

如果要实现随机策略版的partition方法,很简单,只需要两行代码即可:

List partitions = cluster.partitionsForTopic(topic); return ThreadLocalRandom.current().nextInt(partitions.size());

先计算出该主题总的分区数,然后随机地返回一个小于它的正整数。

本质上看随机策略也是力求将数据均匀地打散到各个分区,但从实际表现来看,它要逊于轮询策略,所以如果追求数据的均匀分布,还是使用轮询策略比较好。事实上,随机策略是老版本生产者使用的分区策略,在新版本中已经改为轮询了。

 

按消息键保序策略

也称Key-ordering策略。有点尴尬的是,这个名词是我自己编的,Kafka官网上并无这样的提法。

Kafka允许为每条消息定义消息键,简称为Key。这个Key的作用非常大,它可以是一个有着明确业务含义的字符串,比如客户代码、部门编号或是业务ID等;也可以用来表征消息元数据。特别是在Kafka不支持时间戳的年代,在一些场景中,工程师们都是直接将消息创建时间封装进Key里面的。一旦消息被定义了Key,那么你就可以保证同一个Key的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略,如下图所示。

实现这个策略的partition方法同样简单,只需要下面两行代码即可:

List partitions = cluster.partitionsForTopic(topic); return Math.abs(key.hashCode()) % partitions.size();

前面提到的Kafka默认分区策略实际上同时实现了两种策略:如果指定了Key,那么默认实现按消息键保序策略;如果没有指定Key,则使用轮询策略。

我曾经给一个国企进行过Kafka培训,当时碰到的一个问题就是如何实现消息的顺序问题。这家企业发送的Kafka的消息是有因果关系的,故处理因果关系也必须要保证有序性,否则先处理了“果”后处理“因”必然造成业务上的混乱。

当时那家企业的做法是给Kafka主题设置单分区,也就是1个分区。这样所有的消息都只在这一个分区内读写,因此保证了全局的顺序性。这样做虽然实现了因果关系的顺序性,但也丧失了Kafka多分区带来的高吞吐量和负载均衡的优势。

后来经过了解和调研,我发现这种具有因果关系的消息都有一定的特点,比如在消息体中都封装了固定的标志位,后来我就建议他们对此标志位设定专门的分区策略,保证同一标志位的所有消息都发送到同一分区,这样既可以保证分区内的消息顺序,也可以享受到多分区带来的性能红利。

这种基于个别字段的分区策略本质上就是按消息键保序的思想,其实更加合适的做法是把标志位数据提取出来统一放到Key中,这样更加符合Kafka的设计思想。经过改造之后,这个企业的消息处理吞吐量一下提升了40多倍,从这个案例你也可以看到自定制分区策略的效果可见一斑。

分区策略

关于无序/有序

虽然每个Partition的消息是有序的,但是每个主题可以有多个Partition,这样消费者消费消息的时候,就会无序。

有些业务场景需要保证消息的有序,有几种做法:

  • 申请一个Partition,这样肯定是有序的

  • 如果不是所有消息都要有序,只是几类消息需要有序,比如下单锁库存,取消订单解锁库存,履约完成后解锁wms库存并扣减wms库存,这就是需要保证3类消息有序,正确做法:只要每个订单的3种消息都发到同一个Partition,就可以保证有序,所以可以修改分区策略的方式来解决(一般来说,只需要保证每个分区的有序性,再对消息加上键来保证相同键的所有消息落入同一个分区,就可以满足绝大多数的应用)

 

消费者消费消息

步骤

  • consumer对象通过配置文件创建消费者连接器

  • 根据客户端传入的topicCountMap构造对应的队列和消息流,消息流引入了队列

  • 连接ZK集群或者位移主题

  • 执行初始化操作,触发再平衡,为消费者分配分区,拉取线程会拉取消息放到队列中。

  • 返回消息流列表,队列中有数据时,客户端就可以从消息流中迭代读取消息。

  • 提交偏移量

消费者客户端线程模型:消费者线程、队列、消息流,这三者关系都是一一对应的。

每个线程对应一个队列,每个队列都对应一个消息流,只要队列中有数据,就能从消息流中迭代读取消息。

消费者重平衡

重平衡Rebalance,Kafka一个分区只被一个消费者消费。一个消费组有多个消费者,因此消费组需要维护所有的消费者。

什么情况会重平衡Rebalance

  • 当有消费者加入或者退出消费组

  • 生产者的Partition发生变化

  • 调用方法直接触发

在Rebalance过程中,所有Consumer实例共同参与,在协调者组件的帮助下,完成订阅主题分区的分配。但是,在整个过程中,所有实例都不能消费任何消息,因此它对Consumer的TPS影响很大。

 

消费进度

虽然分区是以消费者级别被消费的,但分区的消费进度要保存成消费组级别的。

消费者对分区的消费进度通常保存在外部存储系统中,比如ZK或者Kafka内部主题(_consumer_offsets)。

 

位移主题

位移主题_consumer_offsets:将Consumer的位移数据作为一条条普通的Kafka消息,提交到__consumer_offsets中。可以这么说,__consumer_offsets的主要作用是保存Kafka消费者的位移信息。它要求这个提交过程不仅要实现高持久性,还要支持高频的写操作。显然,Kafka的主题设计天然就满足这两个条件,因此,使用Kafka主题来保存位移这件事情,实际上就是一个水到渠成的想法了。

 

消息会丢失么?

Kafka只对“已提交”的消息(committed message)做有限度的持久化保证。

  • 生产者程序丢失消息

Kafka Producer是异步发送消息的,也就是说如果你调用的是producer.send(msg)这个API,那么它通常会立即返回,但此时你不能认为消息发送已成功完成。如果用这个方式,可能会有哪些因素导致消息没有发送成功呢?其实原因有很多,例如网络抖动,导致消息压根就没有发送到Broker端;或者消息本身不合格导致Broker拒绝接收(比如消息太大了,超过了Broker的承受能力)等。

解决此问题的方法非常简单:Producer永远要使用带有回调通知的发送API,也就是说不要使用producer.send(msg),而要使用producer.send(msg, callback)。不要小瞧这里的callback(回调),它能准确地告诉你消息是否真的提交成功了。一旦出现消息提交失败的情况,你就可以有针对性地进行处理。

  • 消费者丢失程序

解决方式:先消费消息,再更新位移(如果消费完消息,更新位移失败,这时就会出现消息重复消费的问题)

producer.send(msg)这种发送方式有个有趣的名字,叫“fire and forget”,翻译一下就是“发射后不管”。这个术语原本属于导弹制导领域,后来被借鉴到计算机领域中,它的意思是,执行完一个操作后不去管它的结果是否成功。调用producer.send(msg)就属于典型的“fire and forget”,因此如果出现消息丢失,我们是无法知晓的。这个发送方式挺不靠谱吧,不过有些公司真的就是在使用这个API发送消息。

消息不丢失实践

  1. 不要使用producer.send(msg),而要使用producer.send(msg, callback)。记住,一定要使用带有回调通知的send方法。

  2. 设置acks = all。acks是Producer的一个参数,代表了你对“已提交”消息的定义。如果设置成all,则表明所有副本Broker都要接收到消息,该消息才算是“已提交”。这是最高等级的“已提交”定义。

  3. 设置retries为一个较大的值。这里的retries同样是Producer的参数,对应前面提到的Producer自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了retries > 0的Producer能够自动重试消息发送,避免消息丢失。

  4. 设置unclean.leader.election.enable = false。这是Broker端的参数,它控制的是哪些Broker有资格竞选分区的Leader。如果一个Broker落后原先的Leader太多,那么它一旦成为新的Leader,必然会造成消息的丢失。故一般都要将该参数设置成false,即不允许这种情况的发生。

  5. 设置replication.factor >= 3。这也是Broker端的参数。其实这里想表述的是,最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余。

  6. 设置min.insync.replicas > 1。这依然是Broker端参数,控制的是消息至少要被写入到多少个副本才算是“已提交”。设置成大于1可以提升消息持久性。在实际环境中千万不要使用默认值1。

  7. 确保replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成replication.factor = min.insync.replicas + 1。

  8. 确保消息消费完成再提交。Consumer端有个参数enable.auto.commit,最好把它设置成false,并采用手动提交位移的方式。就像前面说的,这对于单Consumer多线程处理的场景而言是至关重要的。

 

幂等性Producer

在Kafka中,Producer默认不是幂等性的,但我们可以创建幂等性Producer。它其实是0.11.0.0版本引入的新功能。在此之前,Kafka向分区发送数据时,可能会出现同一条消息被发送了多次,导致消息重复的情况。在0.11之后,指定Producer幂等性的方法很简单,仅需要设置一个参数即可,即props.put(“enable.idempotence”, ture),或props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true)。

底层具体的原理很简单,就是经典的用空间去换时间的优化思路,即在Broker端多保存一些字段。当Producer发送了具有相同字段值的消息后,Broker能够自动知晓这些消息已经重复了,于是可以在后台默默地把它们“丢弃”掉。当然,实际的实现原理并没有这么简单,但你大致可以这么理解。

但是它只能保证单分区上的幂等性,即一个幂等性Producer能够保证某个主题的一个分区上不出现重复消息,它无法实现多个分区的幂等性。其次,它只能实现单会话上的幂等性,不能实现跨会话的幂等性。这里的会话,你可以理解为Producer进程的一次运行。当你重启了Producer进程之后,这种幂等性保证就丧失了。

 

事务型Producer

事务型Producer能够保证将消息原子性地写入到多个分区中。这批消息要么全部写入成功,要么全部失败。另外,事务型Producer也不惧进程的重启。Producer重启回来后,Kafka依然保证它们发送消息的精确一次处理。

设置事务型Producer的方法也很简单,满足两个要求即可:

  • 和幂等性Producer一样,开启enable.idempotence = true。

  • 设置Producer端参数transactional. id。最好为其设置一个有意义的名字。

此外,你还需要在Producer代码中做一些调整,如这段代码所示:

producer.initTransactions(); try { producer.beginTransaction(); producer.send(record1); producer.send(record2); producer.commitTransaction(); } catch (KafkaException e) { producer.abortTransaction(); }

和普通Producer代码相比,事务型Producer的显著特点是调用了一些事务API,如initTransaction、beginTransaction、commitTransaction和abortTransaction,它们分别对应事务的初始化、事务开始、事务提交以及事务终止。

这段代码能够保证Record1和Record2被当作一个事务统一提交到Kafka,要么它们全部提交成功,要么全部写入失败。实际上即使写入失败,Kafka也会把它们写入到底层的日志中,也就是说Consumer还是会看到这些消息。因此在Consumer端,读取事务型Producer发送的消息也是需要一些变更的。修改起来也很简单,设置isolation.level参数的值即可。当前这个参数有两个取值:

  1. read_uncommitted:这是默认值,表明Consumer能够读取到Kafka写入的任何消息,不论事务型Producer提交事务还是终止事务,其写入的消息都可以读取。很显然,如果你用了事务型Producer,那么对应的Consumer就不要使用这个值。

  2. read_committed:表明Consumer只会读取事务型Producer成功提交事务写入的消息。当然了,它也能看到非事务型Producer写入的所有消息。

 

 

producer幂等性和事务型总结

简单来说,幂等性Producer和事务型Producer都是Kafka社区力图为Kafka实现精确一次处理语义所提供的工具,只是它们的作用范围是不同的。幂等性Producer只能保证单分区、单会话上的消息幂等性;而事务能够保证跨分区、跨会话间的幂等性。从交付语义上来看,自然是事务型Producer能做的更多。

不过,切记天下没有免费的午餐。比起幂等性Producer,事务型Producer的性能要更差,在实际使用过程中,我们需要仔细评估引入事务的开销,切不可无脑地启用事务。

 

控制器

控制器组件(Controller),是Apache Kafka的核心组件。它的主要作用是在Apache ZooKeeper的帮助下管理和协调整个Kafka集群,群中任意一台Broker都能充当控制器的角色,但是,在运行过程中,只能有一个Broker成为控制器,行使其管理和协调的职责。

  1. 主题管理(创建、删除、增加分区):这里的主题管理,就是指控制器帮助我们完成对Kafka主题的创建、删除以及分区增加的操作。换句话说,当我们执行kafka-topics脚本时,大部分的后台工作都是控制器来完成的。

  2. 分区重分配:分区重分配主要是指,kafka-reassign-partitions脚本提供的对已有主题分区进行细粒度的分配功能。

  3. Preferred领导者选举:Preferred领导者选举主要是Kafka为了避免部分Broker负载过重而提供的一种换Leader的方案。

  4. 集群成员管理(新增Broker、Broker主动关闭、Broker宕机):包括自动检测新增Broker、Broker主动关闭及被动宕机。这种自动检测是依赖于前面提到的Watch功能和ZooKeeper临时节点组合实现的。

  5. 数据服务

 

控制器保存的数据

 

控制器总结

 

 

​

 
posted @ 2021-07-18 18:05  刘尊礼  阅读(116)  评论(0)    收藏  举报