初识Kafka
Kafka之所以受到越来越多的青睐,与它所“扮演”的三大角色是分不开的:
- 消息系统:Kafka 和传统的消息系统(也称作消息中间件)都具备系统解耦、冗余存储、流量削峰、缓冲、异步通信、扩展性、可恢复性等功能。与此同时,Kafka 还提供了大多数消息系统难以实现的消息顺序性保障及回溯消费的功能。
- 存储系统:Kafka 把消息持久化到磁盘,相比于其他基于内存存储的系统而言,有效地降低了数据丢失的风险。也正是得益于Kafka 的消息持久化功能和多副本机制,我们可以把Kafka作为长期的数据存储系统来使用,只需要把对应的数据保留策略设置为“永久”或启用主题的日志压缩功能即可。
- 流式处理平台:Kafka 不仅为每个流行的流式处理框架提供了可靠的数据来源,还提供了一个完整的流式处理类库,比如窗口、连接、变换和聚合等各类操作。
基本概念
一个典型的 Kafka 体系架构包括若干 Producer、若干 Broker、若干 Consumer,以及一个ZooKeeper集群。其中ZooKeeper是Kafka用来负责集群元数据的管理、控制器的选举等操作的。Producer将消息发送到Broker,Broker负责将收到的消息存储到磁盘中,而Consumer负责从Broker订阅并消费消息。整个Kafka体系结构中引入了以下3个术语:
- Producer:生产者,也就是发送消息的一方。生产者负责创建消息,然后将其投递到Kafka中。
- Consumer:消费者,也就是接收消息的一方。消费者连接到Kafka上并接收消息,进而进行相应的业务逻辑处理。
- Broker:服务代理节点。对于Kafka而言,Broker可以简单地看作一个独立的Kafka服务节点或Kafka服务实例。大多数情况下也可以将Broker看作一台Kafka服务器,前提是这台服务器上只部署了一个Kafka实例。一个或多个Broker组成了一个Kafka集群。一般而言,我们更习惯使用首字母小写的broker来表示服务代理节点。
在Kafka中还有两个特别重要的概念—**主题(Topic)与分区(Partition)**。Kafka中的消息以主题为单位进行归类,生产者负责将消息发送到特定的主题(发送到Kafka集群中的每一条消息都要指定一个主题),而消费者负责订阅主题并进行消费。
主题是一个逻辑上的概念,它还可以细分为多个分区,一个分区只属于单个主题,很多时候也会把分区称为主题分区(Topic-Partition)。同一主题下的不同分区包含的消息是不同的,分区在存储层面可以看作一个可追加的日志(Log)文件,消息在被追加到分区日志文件的时候都会分配一个特定的偏移量(offset)。offset是消息在分区中的唯一标识,Kafka通过它来保证消息在分区内的顺序性,不过offset并不跨越分区,也就是说,Kafka保证的是分区有序而不是主题有序。
Kafka中的分区可以分布在不同的服务器(broker)上,也就是说,一个主题可以横跨多个broker,以此来提供比单个broker更强大的性能。
每一条消息被发送到broker之前,会根据分区规则选择存储到哪个具体的分区。如果分区规则设定得合理,所有的消息都可以均匀地分配到不同的分区中。
Kafka 为分区引入了多副本(Replica)机制,通过增加副本数量可以提升容灾能力。同一分区的不同副本中保存的是相同的消息(在同一时刻,副本之间并非完全一样),副本之间是“一主多从”的关系,其中leader副本负责处理读写请求,follower副本只负责与leader副本的消息同步。副本处于不同的broker中,当leader副本出现故障时,从follower副本中重新选举新的leader副本对外提供服务。Kafka通过多副本机制实现了故障的自动转移,当Kafka集群中某个broker失效时仍然能保证服务可用。
生产者和消费者只与leader副本进行交互,而follower副本只负责消息的同步,很多时候follower副本中的消息相对leader副本而言会有一定的滞后。
Kafka 消费端也具备一定的容灾能力。Consumer 使用拉(Pull)模式从服务端拉取消息,并且保存消费的具体位置,当消费者宕机后恢复上线时可以根据之前保存的消费位置重新拉取需要的消息进行消费,这样就不会造成消息丢失。
分区中的所有副本统称为AR(Assigned Replicas)。所有与leader副本保持一定程度同步的副本(包括leader副本在内)组成ISR(In-Sync Replicas),ISR集合是AR集合中的一个子集。消息会先发送到leader副本,然后follower副本才能从leader副本中拉取消息进行同步,同步期间内follower副本相对于leader副本而言会有一定程度的滞后。前面所说的“一定程度的同步”是指可忍受的滞后范围,这个范围可以通过参数进行配置。与leader副本同步滞后过多的副本(不包括leader副本)组成**OSR(Out-of-Sync Replicas)**,由此可见,AR=ISR+OSR。在正常情况下,所有的follower 副本都应该与 leader 副本保持一定程度的同步,即AR=ISR,OSR集合为空。
leader副本负责维护和跟踪ISR集合中所有follower副本的滞后状态,当follower副本落后太多或失效时,leader副本会把它从ISR集合中剔除。如果OSR集合中有follower副本“追上”了leader副本,那么leader副本会把它从OSR集合转移至ISR集合。默认情况下,当leader副本发生故障时,只有在ISR集合中的副本才有资格被选举为新的leader,而在OSR集合中的副本则没有任何机会(不过这个原则也可以通过修改相应的参数配置来改变)。
ISR与HW和LEO也有紧密的关系。HW是High Watermark的缩写,俗称高水位,它标识了一个特定的消息偏移量(offset),消费者只能拉取到这个offset之前的消息。
LEO是Log End Offset的缩写,它标识当前日志文件中下一条待写入消息的offset,LEO的大小相当于当前日志分区中最后一条消息的offset值加1。分区ISR集合中的每个副本都会维护自身的LEO,而ISR集合中最小的LEO即为分区的HW,对消费者而言只能消费HW之前的消息。(说白了就是没同步的消息还不允许被消费)
由此可见,Kafka 的复制机制既不是完全的同步复制,也不是单纯的异步复制。事实上,同步复制要求所有能工作的 follower 副本都复制完,这条消息才会被确认为已成功提交,这种复制方式极大地影响了性能。而在异步复制方式下,follower副本异步地从leader副本中复制数据,数据只要被leader副本写入就被认为已经成功提交。在这种情况下,如果follower副本都还没有复制完而落后于leader副本,突然leader副本宕机,则会造成数据丢失。Kafka使用的这种ISR的方式则有效地权衡了数据可靠性和性能之间的关系。
安装与配置
ZooKeeper安装与配置
ZooKeeper是安装Kafka集群的必要组件,Kafka通过ZooKeeper来实施对元数据信息的管理,包括集群、broker、主题、分区等内容。(不过新版本kafka已经摆脱了zk的依赖,而是把元数据存储到了特定的topic里面)
Kafka的安装与配置
修改broker的配置文件$KAFKA_HOME/conf/server.properties。主要关注以下几个配置参数即可:
如果是单机模式,那么修改完上述配置参数之后就可以启动服务。如果是集群模式,那么只需要对单机模式的配置文件做相应的修改即可:确保集群中每个broker的broker.id配置参数的值不一样,以及listeners配置参数也需要修改为与broker对应的IP地址或域名,之后就可以各自启动服务。注意,在启动 Kafka 服务之前同样需要确保 zookeeper.connect参数所配置的ZooKeeper服务已经正确启动。
生产与消费
Kafka提供了许多实用的脚本工具,存放在$KAFKA_HOME的bin目录下,其中与主题有关的就是 kafka-topics.sh 脚本,下面我们用它演示创建一个分区数为 4、副本因子为 3 的主题topic-demo,示例如下:
其中–zookeeper指定了Kafka所连接的ZooKeeper服务地址,–topic指定了所要创建主题的名称,–replication-factor 指定了副本因子,–partitions 指定了分区个数,–create是创建主题的动作指令。
服务端参数配置
下面挑选一些重要的服务端参数来做细致的说明,这些参数都配置在$KAFKA_HOME/config/server.properties文件中。
- zookeeper.connect:该参数指明broker要连接的ZooKeeper集群的服务地址(包含端口号),没有默认值,且此参数为必填项。可以配置为localhost:2181,如果ZooKeeper集群中有多个节点,则可以用逗号将每个节点隔开,类似于 localhost1:2181,localhost2:2181,localhost3:2181这种格式。最佳的实践方式是再加一个chroot路径,这样既可以明确指明该chroot路径下的节点是为Kafka所用的,也可以实现多个Kafka集群复用一套ZooKeeper集群,这样可以节省更多的硬件资源。包含 chroot 路径的配置类似于 localhost1:2181,localhost2:2181,localhost3:2181/kafka这种,如果不指定chroot,那么默认使用ZooKeeper的根路径。
- listeners:该参数指明broker监听客户端连接的地址列表,即为客户端要连接broker的入口地址列表,配置格式为protocol1://hostname1:port1,protocol2://hostname2:port2,其中protocol代表协议类型,Kafka当前支持的协议类型有PLAINTEXT、SSL、SASL_SSL等,如果未开启安全认证,则使用简单的PLAINTEXT即可。hostname代表主机名,port代表服务端口,此参数的默认值为null
- broker.id:该参数用来指定Kafka集群中broker的唯一标识,默认值为-1。如果没有设置,那么Kafka会自动生成一个。这个参数还和meta.properties文件及服务端参数broker.id.generation.enable和reserved.broker.max.id有关
- log.dir和log.dirs:Kafka 把所有的消息都保存在磁盘上,而这两个参数用来配置Kafka 日志文件存放的根目录。一般情况下,log.dir 用来配置单个根目录,而 log.dirs 用来配置多个根目录(以逗号分隔),但是Kafka并没有对此做强制性限制,也就是说,log.dir和log.dirs都可以用来配置单个或多个根目录。log.dirs 的优先级比 log.dir 高,但是如果没有配置log.dirs,则会以 log.dir 配置为准。默认情况下只配置了 log.dir 参数,其默认值为/tmp/kafka-logs。
- message.max.bytes:该参数用来指定broker所能接收消息的最大值,默认值为1000012(B),约等于976.6KB。如果 Producer 发送的消息大于这个参数所设置的值,那么(Producer)就会报出RecordTooLargeException的异常。如果需要修改这个参数,那么还要考虑max.request.size (客户端参数)、max.message.bytes(topic端参数)等参数的影响。为了避免修改此参数而引起级联的影响,建议在修改此参数之前考虑分拆消息的可行性。
生产者
客户端开发
消息对象 ProducerRecord
这里有必要单独说明的是构建的消息对象 ProducerRecord,它并不是单纯意义上的消息,它包含了多个属性,原本需要发送的与业务相关的消息体只是其中的一个 value 属性,比如“Hello,Kafka!”只是ProducerRecord对象中的一个属性。ProducerRecord类的定义如下(只截取成员变量):
其中topic和partition字段分别代表消息要发往的主题和分区号。headers字段是消息的头部,Kafka 0.11.x版本才引入这个属性,它大多用来设定一些与应用相关的信息,如无需要也可以不用设置。key是用来指定消息的键,它不仅是消息的附加信息,还可以用来计算分区号进而可以让消息发往特定的分区。前面提及消息以主题为单位进行归类,而这个key可以让消息再进行二次归类,同一个key的消息会被划分到同一个分区中,有key的消息还可以支持日志压缩的功能。value是指消息体,一般不为空,如果为空则表示特定的消息—墓碑消息。timestamp是指消息的时间戳,它有CreateTime和LogAppendTime两种类型,前者表示消息创建的时间,后者表示消息追加到日志文件的时间。
必要的参数配置
在Kafka生产者客户端KafkaProducer中有3个参数是必填的:
- bootstrap.servers:该参数用来指定生产者客户端连接Kafka集群所需的broker地址清单,具体的内容格式为host1:port1,host2:port2,可以设置一个或多个地址,中间以逗号隔开,此参数的默认值为“”。注意这里并非需要所有的broker地址,因为生产者会从给定的broker里查找到其他broker的信息。不过建议至少要设置两个以上的broker 地址信息,当其中任意一个宕机时,生产者仍然可以连接到 Kafka集群上。
- key.serializer 和 value.serializer:broker 端接收的消息必须以字节数组(byte[])的形式存在。key.serializer和value.serializer这两个参数分别用来指定key和value序列化操作的序列化器,这两个参数无默认值。注意这里必须填写序列化器的全限定名,如org.apache.kafka.common.serialization.StringSerializer。
KafkaProducer中的参数众多,我们可以直接使用客户端中的org.apache.kafka.clients.producer.ProducerConfig类来做一定程度上的预防措施,每个参数在 ProducerConfig 类中都有对应的名称。
KafkaProducer是线程安全的,可以在多个线程中共享单个KafkaProducer实例,也可以将KafkaProducer实例进行池化来供其他线程调用。
发送消息
创建生产者实例和构建消息之后,就可以开始发送消息了。发送消息主要有三种模式:**发后即忘(fire-and-forget)、同步(sync)及异步(async)**。
KafkaProducer 的 send()方法并非是 void 类型,而是 Future<RecordMetadata>类型,send()方法有2个重载方法,具体定义如下:
KafkaProducer中一般会发生两种类型的异常:可重试的异常和不可重试的异常。对于可重试的异常,如果配置了 retries 参数,那么只要在规定的重试次数内自行恢复了,就不会抛出异常。retries参数的默认值为0。
同步发送的方式可靠性高,要么消息被发送成功,要么发生异常。如果发生异常,则可以捕获并进行相应的处理,而不会像“发后即忘”的方式直接造成消息的丢失。不过同步发送的方式的性能会差很多,需要阻塞等待一条消息发送完之后才能发送下一条。
我们再来了解一下异步发送的方式,一般是在send()方法里指定一个Callback的回调函数,Kafka在返回响应时调用该函数来实现异步的发送确认。有读者或许会有疑问,send()方法的返回值类型就是Future,而Future本身就可以用作异步的逻辑处理。这样做不是不行,只不过Future里的 get()方法在何时调用,以及怎么调用都是需要面对的问题,消息不停地发送,那么诸多消息对应的Future对象的处理难免会引起代码处理逻辑的混乱。使用Callback的方式非常简洁明了,Kafka有响应时就会回调,要么发送成功,要么抛出异常。
对于同一个分区而言,如果消息record1于record2之前先发送(参考上面的示例代码),那么KafkaProducer就可以保证对应的callback1在callback2之前调用,也就是说,回调函数的调用也可以保证分区有序。
通常,一个KafkaProducer不会只负责发送单条消息,更多的是发送多条消息,在发送完这些消息之后,需要调用KafkaProducer的close()方法来回收资源。close()方法会阻塞等待之前所有的发送请求完成后再关闭KafkaProducer。与此同时,KafkaProducer还提供了一个带超时时间的close()方法。
序列化
生产者需要用序列化器(Serializer)把对象转换成字节数组才能通过网络发送给Kafka。而在对侧,消费者需要用反序列化器(Deserializer)把从 Kafka 中收到的字节数组转换成相应的对象(所以说如果生产者和消费者序列化方式不一样,就无法正确的解析消息)。
如果 Kafka 客户端提供的几种序列化器都无法满足应用需求,则可以选择使用如 Avro、JSON、Thrift、ProtoBuf和Protostuff等通用的序列化工具来实现,或者使用自定义类型的序列化器来实现。
分区器
消息在通过send()方法发往broker的过程中,有可能需要经过拦截器(Interceptor)、序列化器(Serializer)和分区器(Partitioner)的一系列作用之后才能被真正地发往 broker。消息经过序列化之后就需要确定它发往的分区,如果消息ProducerRecord中指定了partition字段,那么就不需要分区器的作用,因为partition代表的就是所要发往的分区号。
如果消息ProducerRecord中没有指定partition字段,那么就需要依赖分区器,根据key这个字段来计算partition的值,分区器的作用就是为消息分配分区。
Kafka中提供的默认分区器是org.apache.kafka.clients.producer.internals.DefaultPartitioner,它实现了org.apache.kafka.clients.producer.Partitioner接口,如图所示:
其中partition()方法用来计算分区号,返回值为int类型。partition()方法中的参数分别表示主题、键、序列化后的键、值、序列化后的值,以及集群的元数据信息,通过这些信息可以实现功能丰富的分区器。close()方法在关闭分区器的时候用来回收一些资源。
在默认分区器 DefaultPartitioner 的实现中,close()是空方法,而在 partition()方法中定义了主要的分区分配逻辑。如果key 不为 null,那么默认的分区器会对 key 进行哈希(采用MurmurHash2算法,具备高运算性能及低碰撞率),最终根据得到的哈希值来计算分区号,拥有相同key的消息会被写入同一个分区。如果key为null,那么消息将会以轮询的方式发往主题内的各个可用分区。
在不改变主题分区数量的情况下,key与分区之间的映射可以保持不变。不过,一旦主题中增加了分区,那么就难以保证key与分区之间的映射关系了。
了使用 Kafka 提供的默认分区器进行分区分配,还可以使用自定义的分区器,只需同DefaultPartitioner一样实现Partitioner接口即可。默认的分区器在key为null时不会选择非可用的分区,我们可以通过自定义的分区器DemoPartitioner来打破这一限制。
生产者拦截器
生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。
生产者拦截器的使用也很方便,主要是自定义实现org.apache.kafka.clients.producer.ProducerInterceptor接口。ProducerInterceptor接口中包含3个方法。KafkaProducer在将消息序列化和计算分区之前会调用生产者拦截器的onSend()方法来对消息进行相应的定制化操作,KafkaProducer 会在消息被应答(Acknowledgement)之前或消息发送失败时调用生产者拦截器的 onAcknowledgement()方法,优先于用户设定的 Callback 之前执行。
KafkaProducer中不仅可以指定一个拦截器,还可以指定多个拦截器以形成拦截链。拦截链会按照 interceptor.classes 参数配置的拦截器的顺序来一一执行(配置的时候,各个拦截器之间使用逗号隔开)。
在拦截链中,如果某个拦截器执行失败,那么下一个拦截器会接着从上一个执行成功的拦截器继续执行。(拦截器失败一个不影响其他的执行)
原理分析
整体架构
生产者客户端的整体架构图:
整个生产者客户端由两个线程协调运行,这两个线程分别为主线程和Sender线程(发送线程)。在主线程中由KafkaProducer创建消息,然后通过可能的拦截器、序列化器和分区器的作用之后缓存到消息累加器(RecordAccumulator,也称为消息收集器) 中。Sender 线程负责从RecordAccumulator中获取消息并将其发送到Kafka中。
RecordAccumulator 主要用来缓存消息以便 Sender 线程可以批量发送,进而减少网络传输的资源消耗以提升性能。RecordAccumulator 缓存的大小可以通过生产者客户端参数buffer.memory 配置,默认值为 33554432B,即 32MB。如果生产者发送消息的速度超过发送到服务器的速度,则会导致生产者空间不足,这个时候KafkaProducer的send()方法调用要么被阻塞,要么抛出异常,这个取决于参数max.block.ms的配置,此参数的默认值为60000,即60秒。
主线程中发送过来的消息都会被追加到RecordAccumulator的某个双端队列(Deque)中,在 RecordAccumulator 的内部为每个分区都维护了一个双端队列,队列中的内容就是ProducerBatch,即 Deque<ProducerBatch>。消息写入缓存时,追加到双端队列的尾部;Sender读取消息时,从双端队列的头部读取。注意ProducerBatch不是ProducerRecord,ProducerBatch中可以包含一至多个 ProducerRecord。通俗地说,ProducerRecord 是生产者中创建的消息,而ProducerBatch是指一个消息批次,ProducerRecord会被包含在ProducerBatch中,这样可以使字节的使用更加紧凑。与此同时,将较小的ProducerRecord拼凑成一个较大的ProducerBatch,也可以减少网络请求的次数以提升整体的吞吐量。ProducerBatch和消息的具体格式有关,如果生产者客户端需要向很多分区发送消息,则可以将buffer.memory参数适当调大以增加整体的吞吐量。
消息在网络上都是以字节(Byte)的形式传输的,在发送之前需要创建一块内存区域来保存对应的消息。在Kafka生产者客户端中,通过java.io.ByteBuffer实现消息内存的创建和释放。不过频繁的创建和释放是比较耗费资源的,在RecordAccumulator的内部还有一个BufferPool,它主要用来实现ByteBuffer的复用,以实现缓存的高效利用。不过BufferPool只针对特定大小的ByteBuffer进行管理,而其他大小的ByteBuffer不会缓存进BufferPool中,这个特定的大小由batch.size参数来指定,默认值为16384B,即16KB。我们可以适当地调大batch.size参数以便多缓存一些消息。
Sender 从 RecordAccumulator 中获取缓存的消息之后,会进一步将原本<分区,Deque<ProducerBatch>>的保存形式转变成<Node,List< ProducerBatch>的形式,其中Node表示Kafka集群的broker节点。对于网络连接来说,生产者客户端是与具体的broker节点建立的连接,也就是向具体的 broker 节点发送消息,而并不关心消息属于哪一个分区;而对于 KafkaProducer的应用逻辑而言,我们只关注向哪个分区中发送哪些消息,所以在这里需要做一个应用逻辑层面到网络I/O层面的转换。
在转换成<Node,List<ProducerBatch>>的形式之后,Sender 还会进一步封装成<Node,Request>的形式,这样就可以将Request请求发往各个Node了,这里的Request是指Kafka的各种协议请求,对于消息发送而言就是指具体的ProduceRequest。
请求在从Sender线程发往Kafka之前还会保存到InFlightRequests中,InFlightRequests保存对象的具体形式为 Map<NodeId,Deque<Request>>,它的主要作用是缓存了已经发出去但还没有收到响应的请求(NodeId 是一个 String 类型,表示节点的 id 编号)。与此同时,InFlightRequests还提供了许多管理类的方法,并且通过配置参数还可以限制每个连接(也就是客户端与Node之间的连接)最多缓存的请求数。这个配置参数为max.in.flight.requests.per.connection,默认值为 5,即每个连接最多只能缓存 5 个未响应的请求,超过该数值之后就不能再向这个连接发送更多的请求了,除非有缓存的请求收到了响应(Response)。通过比较Deque<Request>的size与这个参数的大小来判断对应的Node中是否已经堆积了很多未响应的消息,如果真是如此,那么说明这个 Node 节点负载较大或网络连接有问题,再继续向其发送请求会增大请求超时的可能。
元数据的更新
InFlightRequests还可以获得leastLoadedNode,即所有Node中负载最小的那一个。这里的负载最小是通过每个Node在InFlightRequests中还未确认的请求决定的,未确认的请求越多则认为负载越大。对于图中的InFlightRequests 来说,图中展示了三个节点Node0、Node1和Node2,很明显Node1的负载最小。也就是说,Node1为当前的leastLoadedNode。选择leastLoadedNode发送请求可以使它能够尽快发出,避免因网络拥塞等异常而影响整体的进度。leastLoadedNode的概念可以用于多个应用场合,比如元数据请求、消费者组播协议的交互。
KafkaProducer要将此消息追加到指定主题的某个分区所对应的leader副本之前,首先需要知道主题的分区数量,然后经过计算得出(或者直接指定)目标分区,之后KafkaProducer需要知道目标分区的leader副本所在的broker 节点的地址、端口等信息才能建立连接,最终才能将消息发送到 Kafka,在这一过程中所需要的信息都属于元数据信息。
我们了解了bootstrap.servers参数只需要配置部分broker节点的地址即可,不需要配置所有broker节点的地址,因为客户端可以自己发现其他broker节点的地址,这一过程也属于元数据相关的更新操作。与此同时,分区数量及leader副本的分布都会动态地变化,客户端也需要动态地捕捉这些变化。
元数据是指Kafka集群的元数据,这些元数据具体记录了集群中有哪些主题,这些主题有哪些分区,每个分区的leader副本分配在哪个节点上,follower副本分配在哪些节点上,哪些副本在AR、ISR等集合中,集群中有哪些节点,控制器节点又是哪一个等信息。
当客户端中没有需要使用的元数据信息时,比如没有指定的主题信息,或者超过metadata.max.age.ms 时间没有更新元数据都会引起元数据的更新操作。客户端参数metadata.max.age.ms的默认值为300000,即5分钟。元数据的更新操作是在客户端内部进行的,对客户端的外部使用者不可见。当需要更新元数据时,会先挑选出leastLoadedNode,然后向这个Node发送MetadataRequest请求来获取具体的元数据信息。这个更新操作是由Sender线程发起的,在创建完MetadataRequest之后同样会存入InFlightRequests,之后的步骤就和发送消息时的类似。元数据虽然由Sender线程负责更新,但是主线程也需要读取这些信息,这里的数据同步通过synchronized和final关键字来保障。
重要的生产者参数
下面挑选一些重要的参数进行讲解:
acks
这个参数用来指定分区中必须要有多少个副本收到这条消息,之后生产者才会认为这条消息是成功写入的。acks 是生产者客户端中一个非常重要的参数,它涉及消息的可靠性和吞吐量之间的权衡。acks参数有3种类型的值(都是字符串类型)。
- acks=1。默认值即为1。生产者发送消息之后,只要分区的leader副本成功写入消息,那么它就会收到来自服务端的成功响应。如果消息无法写入leader副本,比如在leader 副本崩溃、重新选举新的 leader 副本的过程中,那么生产者就会收到一个错误的响应,为了避免消息丢失,生产者可以选择重发消息。如果消息写入leader副本并返回成功响应给生产者,且在被其他follower副本拉取之前leader副本崩溃,那么此时消息还是会丢失,因为新选举的leader副本中并没有这条对应的消息。acks设置为1,是消息可靠性和吞吐量之间的折中方案。
- acks=0。生产者发送消息之后不需要等待任何服务端的响应。如果在消息从发送到写入Kafka的过程中出现某些异常,导致Kafka并没有收到这条消息,那么生产者也无从得知,消息也就丢失了。在其他配置环境相同的情况下,acks 设置为 0 可以达到最大的吞吐量。
- acks=-1或acks=all。生产者在消息发送之后,需要等待ISR中的所有副本都成功写入消息之后才能够收到来自服务端的成功响应。在其他配置环境相同的情况下,acks 设置为-1(all)可以达到最强的可靠性。但这并不意味着消息就一定可靠,因为ISR中可能只有leader副本,这样就退化成了acks=1的情况。要获得更高的消息可靠性需要配合 min.insync.replicas 等参数的联动。
max.request.size
这个参数用来限制生产者客户端能发送的消息的最大值,默认值为1048576B,即 1MB。一般情况下,这个默认值就可以满足大多数的应用场景了。并不建议盲目地增大这个参数的配置值,尤其是在对Kafka整体脉络没有足够把控的时候。因为这个参数还涉及一些其他参数的联动,比如broker端的message.max.bytes参数,如果配置错误可能会引起一些不必要的异常。比如将broker端的message.max.bytes参数配置为10,而max.request.size参数配置为20,那么当我们发送一条大小为15B的消息时,生产者客户端就会报出异常。
retries和retry.backoff.ms
retries参数用来配置生产者重试的次数,默认值为0,即在发生异常的时候不进行任何重试动作。消息在从生产者发出到成功写入服务器之前可能发生一些临时性的异常,比如网络抖动、leader副本的选举等,这种异常往往是可以自行恢复的,生产者可以通过配置retries大于0的值,以此通过内部重试来恢复而不是一味地将异常抛给生产者的应用程序。如果重试达到设定的次数,那么生产者就会放弃重试并返回异常。不过并不是所有的异常都是可以通过重试来解决的,比如消息太大,超过max.request.size参数配置的值时,这种方式就不可行了。
重试还和另一个参数retry.backoff.ms有关,这个参数的默认值为100,它用来设定两次重试之间的时间间隔,避免无效的频繁重试。在配置 retries 和 retry.backoff.ms之前,最好先估算一下可能的异常恢复时间,这样可以设定总的重试时间大于这个异常恢复时间,以此来避免生产者过早地放弃重试。
如果将acks参数配置为非零值,并且max.in.flight.requests.per.connection参数配置为大于1的值,那么就会出现错序的现象:如果第一批次消息写入失败,而第二批次消息写入成功,那么生产者会重试发送第一批次的消息,此时如果第一批次的消息写入成功,那么这两个批次的消息就出现了错序。一般而言,在需要保证消息顺序的场合建议把参数max.in.flight.requests.per.connection配置为1,而不是把acks配置为0,不过这样也会影响整体的吞吐。
compression.type
这个参数用来指定消息的压缩方式,默认值为“none”,即默认情况下,消息不会被压缩。该参数还可以配置为“gzip”“snappy”和“lz4”。对消息进行压缩可以极大地减少网络传输量、降低网络I/O,从而提高整体的性能。
connections.max.idle.ms
这个参数用来指定在多久之后关闭限制的连接,默认值是540000(ms),即9分钟。
linger.ms
这个参数用来指定生产者发送 ProducerBatch 之前等待更多消息(ProducerRecord)加入ProducerBatch 的时间,默认值为 0。生产者客户端会在 ProducerBatch 被填满或等待时间超过linger.ms 值时发送出去。增大这个参数的值会增加消息的延迟,但是同时能提升一定的吞吐量。这个linger.ms参数与TCP协议中的Nagle算法有异曲同工之妙。
receive.buffer.bytes
这个参数用来设置Socket接收消息缓冲区(SO_RECBUF)的大小,默认值为32768(B),即32KB。如果设置为-1,则使用操作系统的默认值。如果Producer与Kafka处于不同的机房,则可以适地调大这个参数值。
send.buffer.bytes
这个参数用来设置Socket发送消息缓冲区(SO_SNDBUF)的大小,默认值为131072(B),即128KB。与receive.buffer.bytes参数一样,如果设置为-1,则使用操作系统的默认值。
request.timeout.ms
这个参数用来配置Producer等待请求响应的最长时间,默认值为30000(ms)。请求超时之后可以选择进行重试。注意这个参数需要比broker端参数replica.lag.time.max.ms的值要大,这样可以减少因客户端重试而引起的消息重复的概率。
消费者
消费者与消费组
消费者(Consumer)负责订阅Kafka中的主题(Topic),并且从订阅的主题上拉取消息。与其他一些消息中间件不同的是:在Kafka的消费理念中还有一层消费组(Consumer Group)的概念,每个消费者都有一个对应的消费组。当消息发布到主题后,只会被投递给订阅它的每个消费组中的一个消费者。每个消费者只能消费所分配到的分区中的消息。换言之,每一个分区只能被一个消费组中的一个消费者所消费。
消费者与消费组这种模型可以让整体的消费能力具备横向伸缩性,我们可以增加(或减少)消费者的个数来提高(或降低)整体的消费能力。对于分区数固定的情况,一味地增加消费者并不会让消费能力一直得到提升,如果消费者过多,出现了消费者的个数大于分区个数的情况,就会有消费者分配不到任何分区。(感觉这种设计纯粹是为了保证分区消息的有序性)。以上分配逻辑都是基于默认的分区分配策略进行分析的,可以通过消费者客户端参数partition.assignment.strategy 来设置消费者与订阅主题之间的分区分配策略。
消费组是一个逻辑上的概念,它将旗下的消费者归为一类,每一个消费者只隶属于一个消费组。每一个消费组都会有一个固定的名称,消费者在进行消费前需要指定其所属消费组的名称,这个可以通过消费者客户端参数group.id来配置,默认值为空字符串。消费者并非逻辑上的概念,它是实际的应用实例,它可以是一个线程,也可以是一个进程。同一个消费组内的消费者既可以部署在同一台机器上,也可以部署在不同的机器上。
客户端开发
必要的参数配置
在Kafka消费者客户端KafkaConsumer中有4个参数是必填的:
- bootstrap.servers:该参数的释义和生产者客户端KafkaProducer 中的相同,用来 指 定 连 接 Kafka 集 群 所 需 的broker 地 址 清 单,具 体 内 容 形 式 为host1:port1,host2:post,可以设置一个或多个地址,中间用逗号隔开,此参数的默认值为“”。注意这里并非需要设置集群中全部的broker地址,消费者会从现有的配置中查找到全部的Kafka集群成员。这里设置两个以上的broker地址信息,当其中任意一个宕机时,消费者仍然可以连接到Kafka集群上
- group.id:消费者隶属的消费组的名称,默认值为“”。如果设置为空,则会报出异常:Exception in thread "main"org.apache.kafka.common.errors.InvalidGroupIdException:The configured groupId is invalid。一般而言,这个参数需要设置成具有一定的业务意义的名称。
- key.deserializer 和 value.deserializer:与生产者客户端KafkaProducer中的key.serializer和value.serializer参数对应。消费者从broker端获取的消息格式都是字节数组(byte[])类型,所以需要执行相应的反序列化操作才能还原成原有的对象格式。这两个参数分别用来指定消息中key和value所需反序列化操作的反序列化器,这两个参数无默认值。注意这里必须填写反序列化器类的全限定名,比如示例中的org.apache.kafka.common.serialization.StringDeserializer,单单指定StringDeserializer是错误的
订阅主题与分区
在创建好消费者之后,我们就需要为该消费者订阅相关的主题了。一个消费者可以订阅一个或多个主题,我们可以使用subscribe()方法订阅了一个主题,对于这个方法而言,既可以以集合的形式订阅多个主题,也可以以正则表达式的形式订阅特定模式的主题。
对于消费者使用集合的方式(subscribe(Collection))来订阅主题而言,比较容易理解,订阅了什么主题就消费什么主题中的消息。如果前后两次订阅了不同的主题,那么消费者以最后一次的为准。
如果消费者采用的是正则表达式的方式(subscribe(Pattern))订阅,在之后的过程中,如果有人又创建了新的主题,并且主题的名字与正则表达式相匹配,那么这个消费者就可以消费到新添加的主题中的消息。如果应用程序需要消费多个主题,并且可以处理不同的类型,那么这种订阅方式就很有效。在Kafka 和其他系统之间进行数据复制时,这种正则表达式的方式就显得很常见。(可以进行正则名称匹配)
消费者不仅可以通过KafkaConsumer.subscribe()方法订阅主题,还可以直接订阅某些主题的特定分区,在KafkaConsumer中还提供了一个assign()方法来实现这些功能。这个方法只接受一个参数partitions,用来指定需要订阅的分区集合。这里补充说明一下TopicPartition类,在Kafka的客户端中,它用来表示分区,这个类的部分内容如下所示:
TopicPartition类只有2个属性:topic和partition,分别代表分区所属的主题和自身的分区编号,这个类可以和我们通常所说的主题—分区的概念映射起来。KafkaConsumer 中的partitionsFor()方法可以用来查询指定主题的元数据信息。其中PartitionInfo类型即为主题的分区元数据信息,此类的主要结构如下:
PartitionInfo类中的属性topic表示主题名称,partition代表分区编号,leader代表分区的leader副本所在的位置,replicas代表分区的AR集合,inSyncReplicas代表分区的ISR集合,offlineReplicas代表分区的OSR集合。
既然有订阅,那么就有取消订阅,可以使用 KafkaConsumer 中的unsubscribe()方法来取消主题的订阅。这个方法既可以取消通过 subscribe(Collection)方式实现的订阅,也可以取消通过subscribe(Pattern)方式实现的订阅,还可以取消通过 assign(Collection)方式实现的订阅。注意,如果将subscribe(Collection)或assign(Collection)中的集合参数设置为空集合,那么作用等同于unsubscribe()方法,因为新的订阅会覆盖旧的。
通过 subscribe()方法订阅主题具有消费者自动再均衡的功能,在多个消费者的情况下可以根据分区分配策略来自动分配各个消费者与分区的关系。当消费组内的消费者增加或减少时,分区分配关系会自动调整,以实现消费负载均衡及故障自动转移。而通过assign()方法订阅分区时,是不具备消费者自动均衡的功能的,其实这一点从assign()方法的参数中就可以看出端倪,两种类型的subscribe()都有ConsumerRebalanceListener类型参数的方法,而assign()方法却没有。
消息消费
Kafka中的消费是基于拉模式的。消息的消费一般有两种模式:推模式和拉模式。推模式是服务端主动将消息推送给消费者,而拉模式是消费者主动向服务端发起请求来拉取消息。
Kafka中的消息消费是一个不断轮询的过程,消费者所要做的就是重复地调用poll()方法,而poll()方法返回的是所订阅的主题(分区)上的一组消息。对于poll()方法而言,如果某些分区中没有可供消费的消息,那么此分区对应的消息拉取的结果就为空;如果订阅的所有分区中都没有可供消费的消息,那么poll()方法返回为空的消息集合。
注意到poll()方法里还有一个超时时间参数timeout,用来控制poll()方法的阻塞时间,在消费者的缓冲区里没有可用数据时会发生阻塞。注意这里 timeout 的类型是 Duration,它是JDK8中新增的一个与时间有关的类型。timeout的设置取决于应用程序对响应速度的要求,比如需要在多长时间内将控制权移交给执行轮询的应用线程。可以直接将timeout设置为0,这样poll()方法会立刻返回,而不管是否已经拉取到了消息。如果应用线程唯一的工作就是从Kafka中拉取并消费消息,则可以将这个参数设置为最大值Long.MAX_VALUE。
消费者消费到的每条消息的类型为ConsumerRecord(注意与ConsumerRecords的区别),这个和生产者发送的消息类型ProducerRecord相对应,不过ConsumerRecord中的内容更加丰富,具体的结构参考如下代码:
opic 和 partition 这两个字段分别代表消息所属主题的名称和所在分区的编号。offset 表示消息在所属分区的偏移量。timestamp 表示时间戳,与此对应的timestampType 表示时间戳的类型。timestampType 有两种类型:CreateTime 和LogAppendTime,分别代表消息创建的时间戳和消息追加到日志的时间戳。headers表示消息的头部内容。key 和 value 分别表示消息的键和消息的值,一般业务应用要读取的就是value,checksum是CRC32的校验值。
poll()方法的返回值类型是 ConsumerRecords,它用来表示一次拉取操作所获得的消息集,内部包含了若干ConsumerRecord,它提供了一个iterator()方法来循环遍历消息集内部的消息。
ConsumerRecords类提供了一个records(TopicPartition)方法来获取消息集中指定分区的消息。ConsumerRecords 类中并没提供与 partitions()类似的 topics()方法来查看拉取的消息集中所包含的主题列表,如果要按照主题维度来进行消费,那么只能根据消费者订阅主题时的列表来进行逻辑处理了。
到目前为止,可以简单地认为poll()方法只是拉取一下消息而已,但就其内部逻辑而言并不简单,它涉及消费位移、消费者协调器、组协调器、消费者的选举、分区分配的分发、再均衡的逻辑、心跳等内容,在后面的章节中会循序渐进地介绍这些内容。
位移提交
对于Kafka中的分区而言,它的每条消息都有唯一的offset,用来表示消息在分区中对应的位置。对于消费者而言,它也有一个offset的概念,消费者使用offset来表示消费到分区中某个消息所在的位置。单词“offset”可以翻译为“偏移量”,也可以翻译为“位移”,读者可能并没有过多地在意这一点:在很多中文资料中都会交叉使用“偏移量”和“位移”这两个词,并没有很严谨地进行区分。笔者对offset做了一些区分:对于消息在分区中的位置,我们将offset称为“偏移量”;对于消费者消费到的位置,将offset 称为“位移”,有时候也会更明确地称之为“消费位移”。
在每次调用poll()方法时,它返回的是还没有被消费过的消息集(当然这个前提是消息已经存储在Kafka 中了,并且暂不考虑异常情况的发生),要做到这一点,就需要记录上一次消费时的消费位移。并且这个消费位移必须做持久化保存,而不是单单保存在内存中,否则消费者重启之后就无法知晓之前的消费位移。再考虑一种情况,当有新的消费者加入时,那么必然会有再均衡的动作,对于同一分区而言,它可能在再均衡动作之后分配给新的消费者,如果不持久化保存消费位移,那么这个新的消费者也无法知晓之前的消费位移。
在新消费者客户端中,消费位移存储在Kafka内部的主题__consumer_offsets中。这里把将消费位移存储起来(持久化)的动作称为“提交”,消费者在消费完消息之后需要执行消费位移的提交。不过需要非常明确的是,当前消费者需要提交的消费位移并不是x,而是 x+1。读者可能看过一些相关资料,里面所讲述的内容可能是提交的消费位移就是当前所消费到的消费位移,即提交的是x,这明显是错误的。类似的错误还体现在对LEO(Log End Offset)的解读上。
KafkaConsumer 类提供了 position(TopicPartition)和committed(TopicPartition)两个方法来分别获取上面所说的position和committed offset的值。
对于位移提交的具体时机的把握也很有讲究,有可能会造成重复消费和消息丢失的现象。当前一次poll()操作所拉取的消息集为[x+2,x+7],x+2代表上一次提交的消费位移,说明已经完成了x+1之前(包括x+1在内)的所有消息的消费,x+5表示当前正在处理的位置。如果拉取到消息之后就进行了位移提交,即提交了x+8,那么当前消费x+5的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从x+8开始的。也就是说,x+5至x+7之间的消息并未能被消费,如此便发生了消息丢失的现象。再考虑另外一种情形,位移提交的动作是在消费完所有拉取到的消息之后才执行的,那么当消费x+5的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从x+2开始的。也就是说,x+2至x+4之间的消息又重新消费了一遍,故而又发生了重复消费的现象。
在 Kafka 中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数enable.auto.commit 配置,默认值为 true。当然这个默认的自动提交不是每消费一条消息就提交一次,而是定期提交,这个定期的周期时间由客户端参数auto.commit.interval.ms配置,默认值为5秒,此参数生效的前提是enable.auto.commit参数为true。
在默认的方式下,消费者每隔5秒会将拉取到的每个分区中最大的消息位移进行提交。自动位移提交的动作是在poll()方法的逻辑里完成的,在每次真正向服务端发起拉取请求之前会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移。
在Kafka消费的编程逻辑中位移提交是一大难点,自动提交消费位移的方式非常简便,它免去了复杂的位移提交逻辑,让编码更简洁。但随之而来的是重复消费和消息丢失的问题。假设刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者崩溃了,那么又得从上一次位移提交的地方重新开始消费,这样便发生了重复消费的现象(对于再均衡的情况同样适用)。我们可以通过减小位移提交的时间间隔来减小重复消息的窗口大小,但这样并不能避免重复消费的发送,而且也会使位移提交更加频繁。
自动位移提交的方式在正常情况下不会发生消息丢失或重复消费的现象,但是在编程的世界里异常无可避免,与此同时,自动位移提交也无法做到精确的位移管理。在Kafka中还提供了手动位移提交的方式,这样可以使得开发人员对消费位移的管理控制更加灵活。很多时候并不是说拉取到消息就算消费完成,而是需要将消息写入数据库、写入本地缓存,或者是更加复杂的业务处理。在这些场景下,所有的业务处理完成才能认为消息被成功消费,手动的提交方式可以让开发人员根据程序的逻辑在合适的地方进行位移提交。开启手动提交功能的前提是消费者客户端参数enable.auto.commit配置为false。
手动提交可以细分为同步提交和异步提交,对应于KafkaConsumer 中的 **commitSync()和commitAsync()**两种类型的方法。我们这里先讲述同步提交的方式:
commitSync()方法会根据poll()方法拉取的最新位移来进行提交,只要没有发生不可恢复的错误(Unrecoverable Error),它就会阻塞消费者线程直至位移提交完成。对于不可恢复的错误,比如CommitFailedException、WakeupException、InterruptException、AuthenticationException、AuthorizationException等,我们可以将其捕获并做针对性的处理。
对于采用 commitSync()的无参方法而言,它提交消费位移的频率和拉取批次消息、处理批次消息的频率是一样的,如果想寻求更细粒度的、更精准的提交,那么就需要使用commitSync()的另一个含参方法,该方法提供了一个 offsets 参数,用来提交指定分区的位移。无参的 commitSync()方法只能提交当前批次对应的 position 值。如果需要提交一个中间值,比如业务每消费一条消息就提交一次位移,那么就可以使用这种方式。(这个是最不会出错的方式了,同时也是消费性能最低的方式)。
与commitSync()方法相反,异步提交的方式(commitAsync())在执行的时候消费者线程不会被阻塞,可能在提交消费位移的结果还未返回之前就开始了新一次的拉取操作。异步提交可以使消费者的性能得到一定的增强。commitAsync方法有三个不同的重载方法,具体定义如下:
第一个无参的方法和第三个方法中的offsets都很好理解,对照commitSync()方法即可。关键的是这里的第二个方法和第三个方法中的callback参数,它提供了一个异步提交的回调方法,当位移提交完成后会回调 OffsetCommitCallback 中的 onComplete()方法。
commitAsync()提交的时候同样会有失败的情况发生,那么我们应该怎么处理呢?读者有可能想到的是重试,问题的关键也就在这里了。如果某一次异步提交的消费位移为 x,但是提交失败了,然后下一次又异步提交了消费位移为 x+y,这次成功了。如果这里引入了重试机制,前一次的异步提交的消费位移在重试的时候提交成功了,那么此时的消费位移又变为了 x。如果此时发生异常(或者再均衡),那么恢复之后的消费者(或者新的消费者)就会从x处开始消费消息,这样就发生了重复消费的问题。(所以说重要业务重试最好设计一个重试队列,不一定还是消息队列,自己设计一个补偿机制即可)。
为此我们可以设置一个递增的序号来维护异步提交的顺序,每次位移提交之后就增加序号相对应的值。在遇到位移提交失败需要重试的时候,可以检查所提交的位移和序号的值的大小,如果前者小于后者,则说明有更大的位移已经提交了,不需要再进行本次重试;如果两者相同,则说明可以进行重试提交。除非程序编码错误,否则不会出现前者大于后者的情况。
如果位移提交失败的情况经常发生,那么说明系统肯定出现了故障,在一般情况下,位移提交失败的情况很少发生,不重试也没有关系,后面的提交也会有成功的。重试会增加代码逻辑的复杂度,不重试会增加重复消费的概率。如果消费者异常退出,那么这个重复消费的问题就很难避免,因为这种情况下无法及时提交消费位移;如果消费者正常退出或发生再均衡的情况,那么可以在退出或再均衡执行之前使用同步提交的方式做最后的把关。
控制或关闭消费
KafkaConsumer 提供了对消费速度进行控制的方法,在有些应用场景下我们可能需要暂停某些分区的消费而先消费其他分区,当达到一定条件时再恢复这些分区的消费。KafkaConsumer中使用**pause()和resume()**方法来分别实现暂停某些分区在拉取操作时返回数据给客户端和恢复某些分区向客户端返回数据的操作。KafkaConsumer还提供了一个无参的paused()方法来返回被暂停的分区集合。
指定位移消费
在 Kafka 中每当消费者查找不到所记录的消费位移时,就会根据消费者客户端参数auto.offset.reset的配置来决定从何处开始进行消费,这个参数的默认值为“latest”,表示从分区末尾开始消费消息。如果将auto.offset.reset参数配置为 “earliest”,那么消费者会从起始处,也就是0开始消费。auto.offset.reset参数还有一个可配置的值—“none”,配置为此值就意味着出现查到不到消费位移的时候,既不从最新的消息位置处开始消费,也不从最早的消息位置处开始消费,此时会报出NoOffsetForPartitionException异常。
如果能够找到消费位移,那么配置为“none”不会出现任何异常。如果配置的不是“latest”、“earliest”和“none”,则会报出ConfigException异常。
提供的auto.offset.reset 参数也只能在找不到消费位移或位移越界的情况下粗粒度地从开头或末尾开始消费。有些时候,我们需要一种更细粒度的掌控,可以让我们从特定的位移处开始拉取消息,而KafkaConsumer 中的 seek()方法正好提供了这个功能,让我们得以追前消费或回溯消费。
seek()方法中的参数partition表示分区,而offset参数用来指定从分区的哪个位置开始消费。seek()方法只能重置消费者分配到的分区的消费位置,而分区的分配是在 poll()方法的调用过程中实现的。也就是说,在执行seek()方法之前需要先执行一次poll()方法,等到分配到分区之后才可以重置消费位置。
如果对未分配到的分区执行seek()方法,那么会报出IllegalStateException的异常。类似在调用subscribe()方法之后直接调用seek()方法。
如果消费组内的消费者在启动的时候能够找到消费位移,除非发生位移越界,否则auto.offset.reset参数并不会奏效,此时如果想指定从开头或末尾开始消费,就需要seek()方法的帮助了(或者干脆换一个消费组的名称也可以临时解决这个问题)。
beginningOffsets()方法中的参数内容和含义都与 endOffsets()方法中的一样,配合这两个方法我们就可以从分区的开头或末尾开始消费。其实KafkaConsumer中直接提供了seekToBeginning()方法和seekToEnd()方法来实现这两个功能。
有时候我们并不知道特定的消费位置,却知道一个相关的时间点,比如我们想要消费昨天8点之后的消息,这个需求更符合正常的思维逻辑。此时我们无法直接使用seek()方法来追溯到相应的位置。KafkaConsumer同样考虑到了这种情况,它提供了一个offsetsForTimes()方法,通过timestamp来查询与此对应的分区位置。
offsetsForTimes()方法的参数timestampsToSearch是一个Map类型,key为待查询的分区,而 value 为待查询的时间戳,该方法会返回时间戳大于等于待查询时间的第一条消息对应的位置和时间戳,对应于OffsetAndTimestamp中的offset和timestamp字段。
seek()方法为我们提供了从特定位置读取消息的能力,我们可以通过这个方法来向前跳过若干消息,也可以通过这个方法来向后回溯若干消息,这样为消息的消费提供了很大的灵活性。seek()方法也为我们提供了将消费位移保存在外部存储介质中的能力,还可以配合再均衡监听器来提供更加精准的消费能力。
再均衡
再均衡是指分区的所属权从一个消费者转移到另一消费者的行为,它为消费组具备高可用性和伸缩性提供保障,使我们可以既方便又安全地删除消费组内的消费者或往消费组内添加消费者。不过在再均衡发生期间,消费组内的消费者是无法读取消息的。也就是说,在再均衡发生期间的这一小段时间内,消费组会变得不可用。另外,当一个分区被重新分配给另一个消费者时,消费者当前的状态也会丢失。比如消费者消费完某个分区中的一部分消息时还没有来得及提交消费位移就发生了再均衡操作,之后这个分区又被分配给了消费组内的另一个消费者,原来被消费完的那部分消息又被重新消费一遍,也就是发生了重复消费。一般情况下,应尽量避免不必要的再均衡的发生。
消费者拦截器
消费者拦截器主要在消费到消息或在提交消费位移时进行一些定制化的操作。与生产者拦截器对应的,消费者拦截器需要自定义实现org.apache.kafka.clients.consumer.ConsumerInterceptor接口。
在消费者中也有拦截链的概念,和生产者的拦截链一样,也是按照interceptor.classes参数配置的拦截器的顺序来一一执行的(配置的时候,各个拦截器之间使用逗号隔开)。同样也要提防“副作用”的发生。如果在拦截链中某个拦截器执行失败,那么下一个拦截器会接着从上一个执行成功的拦截器继续执行。
多线程实现
KafkaProducer是线程安全的,然而KafkaConsumer却是非线程安全的。KafkaConsumer中定义了一个 acquire()方法,用来检测当前是否只有一个线程在操作,若有其他线程正在操作则会抛出ConcurrentModifcationException异常,KafkaConsumer中的每个公用方法在执行所要执行的动作之前都会调用这个acquire()方法,只有wakeup()方法是个例外。
acquire()方法和我们通常所说的锁(synchronized、Lock等)不同,它不会造成阻塞等待,我们可以将其看作一个轻量级锁,它仅通过线程操作计数标记的方式来检测线程是否发生了并发操作,以此保证只有一个线程在操作。acquire()方法和release()方法成对出现,表示相应的加锁和解锁操作。(cas的设计模式,判断线程数量直接)。
KafkaConsumer 非线程安全并不意味着我们在消费消息的时候只能以单线程的方式执行。如果生产者发送消息的速度大于消费者处理消息的速度,那么就会有越来越多的消息得不到及时的消费,造成了一定的延迟。除此之外,由于Kafka 中消息保留机制的作用,有些消息有可能在被消费之前就被清理了,从而造成消息的丢失。我们可以通过多线程的方式来实现消息消费,多线程的目的就是为了提高整体的消费能力。多线程的实现方式有多种,第一种也是最常见的方式:线程封闭,即为每个线程实例化一个KafkaConsumer对象。一个线程对应一个KafkaConsumer实例,我们可以称之为消费线程。一个消费线程可以消费一个或多个分区中的消息,所有的消费线程都隶属于同一个消费组。这种实现方式的并发度受限于分区的实际个数,根据之前介绍的消费者与分区数的关系,当消费线程的个数大于分区数时,就有部分消费线程一直处于空闲的状态。(这也是spring的kafka客户端采用的策略)。
与此对应的第二种方式是多个消费线程同时消费同一个分区,这个通过 assign()、seek()等方法实现,这样可以打破原有的消费线程的个数不能超过分区数的限制,进一步提高了消费的能力。不过这种实现方式对于位移提交和顺序控制的处理就会变得非常复杂,实际应用中使用得极少,笔者也并不推荐。一般而言,分区是消费线程的最小划分单位。(所以说如果预计数据量很大的话,又要求消费的速度,提前设置好合理的较大的分区数量)。
重要的消费者参数
fetch.min.bytes
该参数用来配置Consumer在一次拉取请求(调用poll()方法)中能从Kafka中拉取的最小数据量,默认值为1(B)。Kafka在收到Consumer的拉取请求时,如果返回给Consumer的数据量小于这个参数所配置的值,那么它就需要进行等待,直到数据量满足这个参数的配置大小。可以适当调大这个参数的值以提高一定的吞吐量,不过也会造成额外的延迟(latency),对于延迟敏感的应用可能就不可取了。
fetch.max.bytes
它用来配置Consumer在一次拉取请求中从Kafka中拉取的最大数据量,默认值为 52428800(B),也就是 50MB。该参数设定的不是绝对的最大值,如果在第一个非空分区中拉取的第一条消息大于该值,那么该消息将仍然返回,以确保消费者继续工作。也就是说,上面问题的答案是可以正常消费。与此相关的,Kafka中所能接收的最大消息的大小通过服务端参数message.max.bytes(对应于主题端参数max.message.bytes)来设置。
fetch.max.wait.ms
这个参数也和fetch.min.bytes参数有关,如果Kafka仅仅参考fetch.min.bytes参数的要求,那么有可能会一直阻塞等待而无法发送响应给 Consumer,显然这是不合理的。fetch.max.wait.ms参数用于指定Kafka的等待时间,默认值为500(ms)。如果Kafka中没有足够多的消息而满足不了fetch.min.bytes参数的要求,那么最终会等待500ms。这个参数的设定和Consumer与Kafka之间的延迟也有关系,如果业务应用对延迟敏感,那么可以适当调小这个参数。
max.partition.fetch.bytes
这个参数用来配置从每个分区里返回给Consumer的最大数据量,默认值为1048576(B),即1MB。这个参数与 fetch.max.bytes 参数相似,只不过前者用来限制一次拉取中每个分区的消息大小,而后者用来限制一次拉取中整体消息的大小。同样,如果这个参数设定的值比消息的大小要小,那么也不会造成无法消费,Kafka 为了保持消费逻辑的正常运转不会对此做强硬的限制。
max.poll.records
这个参数用来配置Consumer在一次拉取请求中拉取的最大消息数,默认值为500(条)。如果消息的大小都比较小,则可以适当调大这个参数值来提升一定的消费速度。
connections.max.idle.ms
这个参数用来指定在多久之后关闭限制的连接,默认值是540000(ms),即9分钟。
exclude.internal.topics
Kafka中有两个内部的主题:__consumer_offsets和__transaction_state。exclude.internal.topics用来指定Kafka中的内部主题是否可以向消费者公开,默认值为true。如果设置为true,那么只能使用subscribe(Collection)的方式而不能使用subscribe(Pattern)的方式来订阅内部主题,设置为false则没有这个限制。
receive.buffer.bytes
这个参数用来设置Socket接收消息缓冲区(SO_RECBUF)的大小,默认值为65536(B),即64KB。如果设置为-1,则使用操作系统的默认值。如果Consumer与Kafka处于不同的机房,则可以适当调大这个参数值。
send.buffer.bytes
这个参数用来设置Socket发送消息缓冲区(SO_SNDBUF)的大小,默认值为131072(B),即128KB。与receive.buffer.bytes参数一样,如果设置为-1,则使用操作系统的默认值。
request.timeout.ms
这个参数用来配置Consumer等待请求响应的最长时间,默认值为30000(ms)。
metadata.max.age.ms
这个参数用来配置元数据的过期时间,默认值为300000(ms),即5分钟。如果元数据在此参数所限定的时间范围内没有进行更新,则会被强制更新,即使没有任何分区变化或有新的broker加入。
reconnect.backoff.ms
这个参数用来配置尝试重新连接指定主机之前的等待时间(也称为退避时间),避免频繁地连接主机,默认值为50(ms)。这种机制适用于消费者向broker发送的所有请求。
retry.backoff.ms
这个参数用来配置尝试重新发送失败的请求到指定的主题分区之前的等待(退避)时间,避免在某些故障情况下频繁地重复发送,默认值为100(ms)。
isolation.level
这个参数用来配置消费者的事务隔离级别。字符串类型,有效值为“read_uncommitted”和“read_committed”,表示消费者所消费到的位置,如果设置为“read_committed”,那么消费者就会忽略事务未提交的消息,即只能消费到 LSO(LastStableOffset)的位置,默认情况下为“read_uncommitted”,即可以消费到HW(High Watermark)处的位置。
主题与分区
从Kafka的底层实现来说,主题和分区都是逻辑上的概念,分区可以有一至多个副本,每个副本对应一个日志文件,每个日志文件对应一至多个日志分段(LogSegment),每个日志分段还可以细分为索引文件、日志存储文件和快照文件等。
主题的管理
主题的管理包括创建主题、查看主题信息、修改主题和删除主题等操作。可以通过 Kafka提供的 kafka-topics.sh 脚本来执行这些操作,这个脚本位于$KAFKA_HOME/bin/目录下。主题的管理并非只有使用 kafka-topics.sh 脚本这一种方式,我们还可以通过KafkaAdminClient 的方式实现(这种方式实质上是通过发送 CreateTopicsRequest、DeleteTopicsRequest 等请求来实现的,甚至我们还可以通过直接操纵日志文件和ZooKeeper节点来实现。
创建主题
如果broker端配置参数auto.create.topics.enable设置为true(默认值就是true),那么当生产者向一个尚未创建的主题发送消息时,会自动创建一个分区数为num.partitions (默认值为1)、副本因子为default.replication.factor(默认值为1)的主题。除此之外,当一个消费者开始从未知主题中读取消息时,或者当任意一个客户端向未知主题发送元数据请求时,都会按照配置参数num.partitions和default.replication.factor的值来创建一个相应的主题。很多时候,这种自动创建主题的行为都是非预期的。除非有特殊应用需求,否则不建议将auto.create.topics.enable参数设置为true,这个参数会增加主题的管理与维护的难度。
更加推荐也更加通用的方式是通过kafka-topics.sh脚本来创建主题:
上面的示例中创建了一个分区数为 4、副本因子为 2 的主题。示例中的环境是一个包含 3个broker节点的集群。三个broker节点一共创建了8个文件夹,这个数字8实质上是分区数4与副本因子2的乘积。每个副本(或者更确切地说应该是日志,副本与日志一一对应)才真正对应了一个命名形式如<topic>-<partition>的文件夹。
主题、分区、副本和 Log(日志)的关系如图所示,主题和分区都是提供给上层用户的抽象,而在副本层面或更加确切地说是Log层面才有实际物理上的存在。同一个分区中的多个副本必须分布在不同的broker中,这样才能提供有效的数据冗余。对于示例中的分区数为4、副本因子为2、broker数为3的情况下,按照2、3、3的分区副本个数分配给各个broker是最优的选择。再比如在分区数为3、副本因子为3,并且broker数同样为3的情况下,分配3、3、3的分区副本个数给各个broker是最优的选择,也就是每个broker中都拥有所有分区的一个副本。
我们不仅可以通过日志文件的根目录来查看集群中各个broker的分区副本的分配情况,还可以通过ZooKeeper客户端来获取。当创建一个主题时会在ZooKeeper的/brokers/topics/目录下创建一个同名的实节点,该节点中记录了该主题的分区副本分配方案。
创建主题时对于主题名称的命名方式也很有讲究。首先是不能与已经存在的主题同名,如果创建了同名的主题就会报错。主题的命名同样不推荐(虽然可以这样做)使用双下画线“__”开头,因为以双下画线开头的主题一般看作Kafka的内部主题,比如__consumer_offsets和__transaction_state。主题的名称必须由大小写字母、数字、点号“.”、连接线“-”、下画线“_”组成,不能为空,不能只有点号“.”,也不能只有双点号“…”,且长度不能超过249。Kafka从0.10.x版本开始支持指定broker的机架信息(机架的名称)。如果指定了机架信息,则在分区副本分配时会尽可能地让分区副本分配到不同的机架上。指定机架信息是通过broker端参数broker.rack来配置的,比如配置当前broker所在的机架为“RACK1”:broker.rack=RACK1
。
分区副本的分配
在生产者和消费者中也都有分区分配的概念。生产者的分区分配是指为每条消息指定其所要发往的分区,消费者中的分区分配是指为消费者指定其可以消费消息的分区,而这里的分区分配是指为集群制定创建主题时的分区副本分配方案,即在哪个broker中创建哪些分区的副本。
之所以startIndex选择随机产生,是因为这样可以在多个主题的情况下尽可能地均匀分布分区副本,如果这里固定为一个特定值,那么每次的第一个副本都是在这个broker上,进而导致少数几个broker所分配到的分区副本过多而其余broker分配到的分区副本过少,最终导致负载不均衡。尤其是某些主题的副本数和分区数都比较少,甚至都为1的情况下,所有的副本都落到了那个指定的broker上。与此同时,在分配时位移量nextReplicaShift也可以更好地使分区副本分配得更加均匀。
分配副本时,除了处理第一个副本,其余的也调用 replicaIndex()方法来获得一个 broker,但这里和assignReplicasToBrokersRackUnaware()不同的是,这里不是简单地将这个broker添加到当前分区的副本列表之中,还要经过一层筛选,满足以下任意一个条件的broker不能被添加到当前分区的副本列表之中:
- 如果此broker所在的机架中已经存在一个broker拥有该分区的副本,并且还有其他的机架中没有任何一个broker拥有该分区的副本。
- 如果此broker中已经拥有该分区的副本,并且还有其他broker中没有该分区的副本。
查看主题
kafka-topics.sh脚本有5种指令类型:create、list、describe、alter和delete。其中list和describe指令可以用来方便地查看主题信息。
在使用 describe 指令查看主题信息时还可以额外指定 topics-with-overrides、under-replicated-partitions和unavailable-partitions这三个参数来增加一些附加功能。
增加topics-with-overrides参数可以找出所有包含覆盖配置的主题,它只会列出包含了与集群不一样配置的主题。注意使用topics-with-overrides参数时只显示原本只使用describe指令的第一行信息
under-replicated-partitions和unavailable-partitions参数都可以找出有问题的分区。通过 under-replicated-partitions 参数可以找出所有包含失效副本的分区。包含失效副本的分区可能正在进行同步操作,也有可能同步发生异常,此时分区的ISR集合小于 AR 集合。对于通过该参数查询到的分区要重点监控,因为这很可能意味着集群中的某个broker已经失效或同步效率降低等
修改主题
当一个主题被创建之后,依然允许我们对其做一定的修改,比如修改分区个数、修改配置等,这个修改的功能就是由kafka-topics.sh脚本中的alter指令提供的。
当主题中的消息包含key时(即key不为null),根据key计算分区的行为就会受到影响。当topic-config的分区数为1时,不管消息的key为何值,消息都会发往这一个分区;当分区数增加到3时,就会根据消息的key来计算分区号,原本发往分区0的消息现在有可能会发往分区1或分区2。如此还会影响既定消息的顺序,所以在增加分区数时一定要三思而后行。对于基于key计算的主题而言,建议在一开始就设置好分区数量,避免以后对其进行调整。
注意到在变更(增、删、改)配置的操作执行之后都会提示一段告警信息,指明了使用kafka-topics.sh脚本的alter指令来变更主题配置的功能已经过时(deprecated),将在未来的版本中删除,并且推荐使用kafka-configs.sh脚本来实现相关功能。
目前Kafka只支持增加分区数而不支持减少分区数。比如我们再将主题topic-config的分区数修改为1,就会报出InvalidPartitionException的异常。
为什么不支持减少分区?按照Kafka现有的代码逻辑,此功能完全可以实现,不过也会使代码的复杂度急剧增大。实现此功能需要考虑的因素很多,比如删除的分区中的消息该如何处理?如果随着分区一起消失则消息的可靠性得不到保障;如果需要保留则又需要考虑如何保留。直接存储到现有分区的尾部,消息的时间戳就不会递增,如此对于Spark、Flink这类需要消息时间戳(事件时间)的组件将会受到影响;如果分散插入现有的分区,那么在消息量很大的时候,内部的数据复制会占用很大的资源,而且在复制期间,此主题的可用性又如何得到保障?与此同时,顺序性问题、事务性问题,以及分区和副本的状态机切换问题都是不得不面对的。反观这个功能的收益点却是很低的,如果真的需要实现此类功能,则完全可以重新创建一个分区数较小的主题,然后将现有主题中的消息按照既定的逻辑复制过去即可。
配置管理
kafka-configs.sh 脚本是专门用来对配置进行操作的,这里的操作是指在运行状态下修改原有的配置,如此可以达到动态变更的目的。kafka-configs.sh脚本包含变更配置alter和查看配置describe这两种指令类型。同使用kafka-topics.sh脚本变更配置的原则一样,增、删、改的行为都可以看作变更操作,不过kafka-configs.sh脚本不仅可以支持操作主题相关的配置,还可以支持操作broker、用户和客户端这3个类型的配置。
kafka-configs.sh脚本使用entity-type参数来指定操作配置的类型,并且使用entity-name参数来指定操作配置的名称。比如查看主题topic-config的配置可以按如下方式执行:
–describe指定了查看配置的指令动作,–entity-type指定了查看配置的实体类型,–entity-name指定了查看配置的实体名称。entity-type只可以配置4个值:topics、brokers、clients和users。
使用alter指令变更配置时,需要配合add-config和delete-config这两个参数一起使用。add-config参数用来实现配置的增、改,即覆盖原有的配置;delete-config参数用来实现配置的删,即删除被覆盖的配置以恢复默认值。
使用kafka-configs.sh脚本来变更(alter)配置时,会在ZooKeeper中创建一个命名形式为/config/<entity-type>/<entity-name>的节点,并将变更的配置写入这个节点,比如对于主题topic-config而言,对应的节点名称为/config/topics/topic-config。
删除主题
如果确定不再使用一个主题,那么最好的方式是将其删除,这样可以释放一些资源,比如磁盘、文件句柄等。kafka-topics.sh脚本中的delete指令就可以用来删除主题。
可以看到在执行完删除命令之后会有相关的提示信息,这个提示信息和broker端配置参数delete.topic.enable 有关。必须将delete.topic.enable参数配置为true才能够删除主题,这个参数的默认值就是true,如果配置为false,那么删除主题的操作将会被忽略。在实际生产环境中,建议将这个参数的值设置为true。
使用kafka-topics.sh脚本删除主题的行为本质上只是在ZooKeeper中的/admin/delete_topics 路径下创建一个与待删除主题同名的节点,以此标记该主题为待删除的状态。与创建主题相同的是,真正删除主题的动作也是由Kafka的控制器负责完成的。
注意,删除主题是一个不可逆的操作。一旦删除之后,与其相关的所有消息数据会被全部删除,所以在执行这一操作的时候也要三思而后行。
初识KafkaAdminClient
一般情况下,我们都习惯使用kafka-topics.sh脚本来管理主题,但有些时候我们希望将主题管理类的功能集成到公司内部的系统中,打造集管理、监控、运维、告警为一体的生态平台,那么就需要以程序调用API的方式去实现。本节主要介绍KafkaAdminClient的基本使用方式,以及采用这种调用API方式下的创建主题时的合法性验证。
基本使用
KafkaAdminClient不仅可以用来管理broker、配置和ACL(Access Control List),还可以用来管理主题。KafkaAdminClient继承了org.apache.kafka.clients.admin.AdminClient抽象类,并提供了多种方法:
- 创建主题:CreateTopicsResult createTopics(Collection<NewTopic>newTopics)
- 删除主题:DeleteTopicsResult deleteTopics(Collection<String>topics
- 列出所有可用的主题:ListTopicsResult listTopics()
- 查看主题的信息:DescribeTopicsResult describeTopics(Collection<String>topicNames)
- 查询配置信息:DescribeConfigsResult describeConfigs(Collection<ConfigResource>resources)
- 修改配置信息:AlterConfigsResult alterConfigs(Map<ConfigResource,Config>configs)
- 增加分区:CreatePartitionsResult createPartitions(Map<String,NewPartitions>newPartitions)
分区的管理
优先副本的选举
分区使用多副本机制来提升可靠性,但只有leader副本对外提供读写服务,而follower副本只负责在内部进行消息的同步。如果一个分区的leader副本不可用,那么就意味着整个分区变得不可用,此时就需要Kafka从剩余的follower副本中挑选一个新的leader副本来继续对外提供服务。虽然不够严谨,但从某种程度上说,broker 节点中 leader 副本个数的多少决定了这个节点负载的高低。
在创建主题的时候,该主题的分区及副本会尽可能均匀地分布到Kafka 集群的各个broker节点上,对应的leader副本的分配也比较均匀。
针对同一个分区而言,同一个broker节点中不可能出现它的多个副本,即Kafka集群的一个broker中最多只能有它的一个副本,我们可以将leader副本所在的broker节点叫作分区的leader节点,而follower副本所在的broker节点叫作分区的follower节点。
随着时间的更替,Kafka 集群的broker 节点不可避免地会遇到宕机或崩溃的问题,当分区的leader节点发生故障时,其中一个follower节点就会成为新的leader节点,这样就会导致集群的负载不均衡,从而影响整体的健壮性和稳定性。当原来的leader节点恢复之后重新加入集群时,它只能成为一个新的follower节点而不再对外提供服务。
为了能够有效地治理负载失衡的情况,Kafka引入了优先副本(preferred replica)的概念。所谓的优先副本是指在 AR 集合列表中的第一个副本。 比如上面主题 topic-partitions 中分区 0的AR集合列表(Replicas)为[1,2,0],那么分区0的优先副本即为1。理想情况下,优先副本就是该分区的leader副本,所以也可以称之为preferred leader。Kafka要确保所有主题的优先副本在Kafka集群中均匀分布,这样就保证了所有分区的leader均衡分布。如果leader分布过于集中,就会造成集群负载不均衡。
所谓的优先副本的选举是指通过一定的方式促使优先副本选举为leader副本,以此来促进集群的负载均衡,这一行为也可以称为“分区平衡”。需要注意的是,分区平衡并不意味着Kafka集群的负载均衡,因为还要考虑集群中的分区分配是否均衡。更进一步,每个分区的leader副本的负载也是各不相同的,有些leader副本的负载很高,比如需要承载TPS为30000的负荷,而有些leader副本只需承载个位数的负荷。也就是说,就算集群中的分区分配均衡、leader 分配均衡,也并不能确保整个集群的负载就是均衡的,还需要其他一些硬性的指标来做进一步的衡量。
在 Kafka 中可以提供分区自动平衡的功能,与此对应的 broker 端参数是 auto.leader.rebalance.enable,此参数的默认值为true,即默认情况下此功能是开启的。如果开启分区自动平衡的功能,则Kafka 的控制器会启动一个定时任务,这个定时任务会轮询所有的broker节点,计算每个broker节点的分区不平衡率(broker中的不平衡率=非优先副本的leader个数/分区总数)是否超过leader.imbalance.per.broker.percentage参数配置的比值,默认值为 10%,如果超过设定的比值则会自动执行优先副本的选举动作以求分区平衡。执行周期由参数leader.imbalance.check.interval.seconds控制,默认值为300秒,即5分钟。
不过在生产环境中不建议将auto.leader.rebalance.enable设置为默认的true,因为这可能引起负面的性能问题,也有可能引起客户端一定时间的阻塞。因为执行的时间无法自主掌控,如果在关键时期(比如电商大促波峰期)执行关键任务的关卡上执行优先副本的自动选举操作,势必会有业务阻塞、频繁超时之类的风险。前面也分析过,分区及副本的均衡也不能完全确保集群整体的均衡,并且集群中一定程度上的不均衡也是可以忍受的,为防止出现关键时期“掉链子”的行为,笔者建议还是将掌控权把控在自己的手中,可以针对此类相关的埋点指标设置相应的告警,在合适的时机执行合适的操作,而这个“合适的操作”就是指手动执行分区平衡。
Kafka中kafka-perferred-replica-election.sh脚本提供了对分区leader副本进行重新平衡的功能。优先副本的选举过程是一个安全的过程,Kafka客户端可以自动感知分区leader副本的变更。
在实际生产环境中,一般使用 path-to-json-file 参数来分批、手动地执行优先副本的选举操作。尤其是在应对大规模的 Kafka 集群时,理应杜绝采用非 path-to-json-file参数的选举操作方式。同时,优先副本的选举操作也要注意避开业务高峰期,以免带来性能方面的负面影响。
分区重分配
当集群中的一个节点突然宕机下线时,如果节点上的分区是单副本的,那么这些分区就变得不可用了,在节点恢复前,相应的数据也就处于丢失状态;如果节点上的分区是多副本的,那么位于这个节点上的leader副本的角色会转交到集群的其他follower副本中。总而言之,这个节点上的分区副本都已经处于功能失效的状态,Kafka 并不会将这些失效的分区副本自动地迁移到集群中剩余的可用broker节点上,如果放任不管,则不仅会影响整个集群的均衡负载,还会影响整体服务的可用性和可靠性。
当集群中新增broker节点时,只有新创建的主题分区才有可能被分配到这个节点上,而之前的主题分区并不会自动分配到新加入的节点中,因为在它们被创建时还没有这个新节点,这样新节点的负载和原先节点的负载之间严重不均衡。
为了解决上述问题,需要让分区副本再次进行合理的分配,也就是所谓的分区重分配。Kafka提供了 kafka-reassign-partitions.sh 脚本来执行分区重分配的工作,它可以在集群扩容、broker节点失效的场景下对分区进行迁移。
kafka-reassign-partitions.sh 脚本的使用分为 3 个步骤:首先创建需要一个包含主题清单的JSON 文件,其次根据主题清单和broker 节点清单生成一份重分配方案,最后根据这份方案执行具体的重分配动作。除了让脚本自动生成候选方案,用户还可以自定义重分配方案,这样也就不需要执行第一步和第二步的操作了。
分区重分配的基本原理是先通过控制器为每个分区添加新副本(增加副本因子),新的副本将从分区的leader副本那里复制所有的数据。根据分区的大小不同,复制过程可能需要花一些时间,因为数据是通过网络复制到新副本上的。在复制完成之后,控制器将旧副本从副本清单里移除(恢复为原先的副本因子数)。注意在重分配的过程中要确保有足够的空间。
分区重分配对集群的性能有很大的影响,需要占用额外的资源,比如网络和磁盘。在实际操作中,我们将降低重分配的粒度,分成多个小批次来执行,以此来将负面的影响降到最低,这一点和优先副本的选举有异曲同工之妙。
还需要注意的是,如果要将某个broker下线,那么在执行分区重分配动作之前最好先关闭或重启broker。这样这个broker就不再是任何分区的leader节点了,它的分区就可以被分配给集群中的其他broker。这样可以减少broker间的流量复制,以此提升重分配的性能,以及减少对集群的影响。
复制限流
数据复制会占用额外的资源,如果重分配的量太大必然会严重影响整体的性能,尤其是处于业务高峰期的时候。减小重分配的粒度,以小批次的方式来操作是一种可行的解决思路。如果集群中某个主题或某个分区的流量在某段时间内特别大,那么只靠减小粒度是不足以应对的,这时就需要有一个限流的机制,可以对副本间的复制流量加以限制来保证重分配期间整体服务不会受太大的影响。
副本间的复制限流有两种实现方式:kafka-config.sh脚本和kafka-reassign-partitions.sh脚本。
kafka-config.sh脚本主要以动态配置的方式来达到限流的目的,在broker级别有两个与复制限流相关的配置参数:follower.replication.throttled.rate和leader.replication.throttled.rate,前者用于设置follower副本复制的速度,后者用于设置leader副本传输的速度,它们的单位都是B/s。
在主题级别也有两个相关的参数来限制复制的速度:leader.replication.throttled.replicas 和follower.replication.throttled.replicas,它们分别用来配置被限制速度的主题所对应的leader副本列表和follower副本列表。
修改副本因子
我们可以将其他分区的 replicas 内容也改成[0,1,2],这样每个分区的副本因子就都从 2增加到了3。注意增加副本因子时也要在log_dirs中添加一个“any”,这个log_dirs代表Kafka中的日志目录,对应于broker端的log.dir或log.dirs参数的配置值,如果不需要关注此方面的细节,那么可以简单地设置为“any”。
如何选择合适的分区数
分区数越多也会让Kafka的正常启动和关闭的耗时变得越长,与此同时,主题的分区数越多不仅会增加日志清理的耗时,而且在被删除时也会耗费更多的时间。对旧版的生产者和消费者客户端而言,分区数越多,也会增加它们的开销,不过这一点在新版的生产者和消费者客户端中有效地得到了抑制。
如果一定要给一个准则,则建议将分区数设定为集群中broker的倍数,即假定集群中有3个broker节点,可以设定分区数为3、6、9等,至于倍数的选定可以参考预估的吞吐量。(如果集群中的broker 节点数有很多,比如大几十或上百、上千,那么这种准则也不太适用,在选定分区数时进一步可以引入基架等参考因素。)
分区数量也不是越多越好,要有基于自己实际业务和性能基础测试的一个灵活判断
日志存储
文件目录布局
Kafka 中的消息是以主题为基本单位进行归类的,各个主题在逻辑上相互独立。每个主题又可以分为一个或多个分区,分区的数量可以在主题创建的时候指定,也可以在之后修改。每条消息在发送的时候会根据分区规则被追加到指定的分区中,分区中的每条消息都会被分配一个唯一的序列号,也就是通常所说的偏移量(offset)。
如果分区规则设置得合理,那么所有的消息可以均匀地分布到不同的分区中,这样就可以实现水平扩展。不考虑多副本的情况,一个分区对应一个日志(Log)。为了防止 Log 过大,Kafka又引入了日志分段(LogSegment)的概念,将Log切分为多个LogSegment,相当于一个巨型文件被平均分配为多个相对较小的文件,这样也便于消息的维护和清理。事实上,Log 和LogSegment 也不是纯粹物理意义上的概念,Log 在物理上只以文件夹的形式存储,而每个LogSegment 对应于磁盘上的一个日志文件和两个索引文件,以及可能的其他文件(比如以“.txnindex”为后缀的事务索引文件)。
向Log 中追加消息时是顺序写入的,只有最后一个 LogSegment 才能执行写入操作,在此之前所有的 LogSegment 都不能写入数据。为了方便描述,我们将最后一个 LogSegment 称为“activeSegment”,即表示当前活跃的日志分段。随着消息的不断写入,当activeSegment满足一定的条件时,就需要创建新的activeSegment,之后追加的消息将写入新的activeSegment。
为了便于消息的检索,每个LogSegment中的日志文件(以“.log”为文件后缀)都有对应的两个索引文件:偏移量索引文件(以“.index”为文件后缀)和时间戳索引文件(以“.timeindex”为文件后缀)。每个 LogSegment 都有一个基准偏移量 baseOffset,用来表示当前 LogSegment中第一条消息的offset。偏移量是一个64位的长整型数,日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为20位数字,没有达到的位数则用0填充。比如第一个LogSegment的基准偏移量为0,对应的日志文件为00000000000000000000.log。
注意每个LogSegment中不只包含“.log”“.index”“.timeindex”这3种文件,还可能包含“.deleted”“.cleaned”“.swap”等临时文件,以及可能的“.snapshot”“.txnindex”“leader-epoch-checkpoint”等文件。
在某一时刻,Kafka 中的文件目录布局如图 5-2 所示。每一个根目录都会包含最基本的 4个检查点文件(xxx-checkpoint)和meta.properties 文件。在创建主题的时候,如果当前 broker中不止配置了一个根目录,那么会挑选分区数最少的那个根目录来完成本次创建任务。
日志格式的演变
对一个成熟的消息中间件而言,消息格式(或者称为“日志格式”)不仅关系功能维度的扩展,还牵涉性能维度的优化。随着Kafka 的迅猛发展,其消息格式也在不断升级改进,从0.8.x版本开始到现在的2.0.0版本,Kafka的消息格式也经历了3个版本:v0版本、v1版本和v2版本。
每个分区由内部的每一条消息组成,如果消息格式设计得不够精炼,那么其功能和性能都会大打折扣。比如有冗余字段,势必会不必要地增加分区的占用空间,进而不仅使存储的开销变大、网络传输的开销变大,也会使Kafka的性能下降。反观如果缺少字段,比如在最初的Kafka消息版本中没有timestamp字段,对内部而言,其影响了日志保存、切分策略,对外部而言,其影响了消息审计、端到端延迟、大数据应用等功能的扩展。虽然可以在消息体内部添加一个时间戳,但解析变长的消息体会带来额外的开销,而存储在消息体(参考图5-3中的value字段)前面可以通过指针偏移量获取其值而容易解析,进而减少了开销(可以查看v1版本),虽然相比于没有 timestamp 字段的开销会大一点。由此可见,仅在一个字段的一增一减之间就有这么多门道,那么Kafka具体是怎么做的呢?本节只针对Kafka 0.8.x之上(包含)的版本做相应说明,对于之前的版本不做陈述。
消息压缩
常见的压缩算法是数据量越大压缩效果越好,一条消息通常不会太大,这就导致压缩效果并不是太好。而Kafka实现的压缩方式是将多条消息一起进行压缩,这样可以保证较好的压缩效果。在一般情况下,生产者发送的压缩数据在broker中也是保持压缩状态进行存储的,消费者从服务端获取的也是压缩的消息,消费者在处理消息之前才会解压消息,这样保持了端到端的压缩。
Kafka 日志中使用哪种压缩方式是通过参数 compression.type 来配置的,默认值为“producer”,表示保留生产者使用的压缩方式。这个参数还可以配置为“gzip”“snappy”“lz4”,分别对应 GZIP、SNAPPY、LZ4 这 3 种压缩算法。如果参数compression.type 配置为“uncompressed”,则表示不压缩。
压缩消息,英文是compress message,Kafka中还有一个compact message,常常被人们直译成压缩消息,需要注意两者的区别。compact message是针对日志清理策略而言的(cleanup.policy=compact),是指日志压缩(Log Compaction)后的消息
变长字段
Kafka从0.11.0版本开始所使用的消息格式版本为v2,这个版本的消息相比v0和v1的版本而言改动很大,同时还参考了Protocol Buffer[1]而引入了变长整型(Varints)和ZigZag编码。为了更加形象地说明问题,首先我们来了解一下变长整型。
Varints是使用一个或多个字节来序列化整数的一种方法。数值越小,其占用的字节数就越少。Varints中的每个字节都有一个位于最高位的msb位(most significant bit),除最后一个字节外,其余msb位都设置为1,最后一个字节的msb位为0。这个msb位表示其后的字节是否和当前字节一起来表示同一个整数。除msb位外,剩余的7位用于存储数据本身,这种表示类型又称为Base 128。通常而言,一个字节8位可以表示256个值,所以称为Base 256,而这里只能用7位表示,2的7次方即128。Varints中采用的是小端字节序,即最小的字节放在最前面。
ZigZag编码以一种锯齿形(zig-zags)的方式来回穿梭正负整数,将带符号整数映射为无符号整数,这样可以使绝对值较小的负数仍然享有较小的Varints编码值,比如-1编码为1,1编码为2,-2编码为3。
前面说过Varints中的一个字节中只有7位是有效数值位,即只能表示128个数值,转变成绝对值之后其实质上只能表示64个数值。比如对消息体长度而言,其值肯定是大于等于0的正整数,那么一个字节长度的Varints最大只能表示64。
v2版本
v2版本中消息集称为Record Batch,而不是先前的Message Set,其内部也包含了一条或多条消息,消息的格式参见图的中部和右部。在消息压缩的情形下,Record Batch Header部分(参见图5-7左部,从first offset到records count字段)是不被压缩的,而被压缩的是records字段中的所有内容。生产者客户端中的ProducerBatch对应这里的RecordBatch,而ProducerRecord对应这里的Record。
先讲述消息格式Record的关键字段,可以看到内部字段大量采用了Varints,这样Kafka可以根据具体的值来确定需要几个字节来保存。v2版本的消息格式去掉了crc字段,另外增加了length(消息总长度)、timestamp delta(时间戳增量)、offset delta(位移增量)和headers信息,并且attributes字段被弃用了,笔者对此做如下分析(key、key length、value、value length字段同v0和v1版本的一样,这里不再赘述):
- length:消息总长度。
- attributes:弃用,但还是在消息格式中占据1B的大小,以备未来的格式扩展。
- timestamp delta:时间戳增量。通常一个timestamp需要占用8个字节,如果像这里一样保存与RecordBatch的起始时间戳的差值,则可以进一步节省占用的字节数。
- offset delta:位移增量。保存与 RecordBatch起始位移的差值,可以节省占用的字节数。
- headers:这个字段用来支持应用级别的扩展,而不需要像v0和v1版本一样不得不将一些应用级别的属性值嵌入消息体。Header的格式如图5-7最右部分所示,包含key和value,一个Record里面可以包含0至多个Header。
对于 v1 版本的消息,如果用户指定的 timestamp 类型是LogAppendTime 而不是CreateTime,那么消息从生产者进入broker 后,timestamp 字段会被更新,此时消息的 crc值将被重新计算,而此值在生产者中已经被计算过一次。再者,broker 端在进行消息格式转换时(比如v1版转成v0版的消息格式)也会重新计算crc的值。在这些类似的情况下,消息从生产者到消费者之间流动时,crc的值是变动的,需要计算两次crc的值,所以这个字段的设计在 v0 和 v1 版本中显得比较“鸡肋”。在 v2 版本中将 crc 的字段从 Record 中转移到了RecordBatch中。
v2版本对消息集(RecordBatch)做了彻底的修改,除了刚刚提及的crc字段,还多了如下字段:
- first offset:表示当前RecordBatch的起始位移。
- length:计算从partition leader epoch字段开始到末尾的长度。
- partition leader epoch:分区leader纪元,可以看作分区leader的版本号或更新次数
- magic:消息格式的版本号,对v2版本而言,magic等于2。
- attributes:消息属性,注意这里占用了两个字节。低3位表示压缩格式,可以参考v0和v1;第4位表示时间戳类型;第5位表示此RecordBatch是否处于事务中,0表示非事务,1表示事务。第6位表示是否是控制消息(ControlBatch),0表示非控制消息,而1表示是控制消息,控制消息用来支持事务功能。
- last offset delta:RecordBatch中最后一个Record的offset与first offset的差值。主要被broker用来确保RecordBatch中Record组装的正确性。
- first timestamp:RecordBatch中第一条Record的时间戳。
- max timestamp:RecordBatch 中最大的时间戳,一般情况下是指最后一个 Record的时间戳,和last offset delta的作用一样,用来确保消息组装的正确性。
- producer id:PID,用来支持幂等和事务。
- producer epoch:和producer id一样,用来支持幂等和事务。
- first sequence:和 producer id、producer epoch 一样,用来支持幂等和事务。
- records count:RecordBatch中Record的个数。
这么看上去v2版本的消息好像要比之前版本的消息所占用的空间大得多,的确对单条消息而言是这样的,如果我们连续向主题msg_format_v2中再发送10条value长度为6、key为null的消息。本来应该占用740B大小的空间,实际上只占用了191B,在v0版本中这10条消息需要占用320B的空间大小,而v1版本则需要占用400B的空间大小,这样看来v2版本又节省了很多空间,因为它将多个消息(Record)打包存放到单个RecordBatch中,又通过Varints编码极大地节省了空间。v2版本的消息不仅提供了更多的功能,比如事务、幂等性等,某些情况下还减少了消息的空间占用,总体性能提升很大。
日志索引
本章开头就提及了每个日志分段文件对应了两个索引文件,主要用来提高查找消息的效率。偏移量索引文件用来建立消息偏移量(offset)到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置;时间戳索引文件则根据指定的时间戳(timestamp)来查找对应的偏移量信息。
Kafka 中的索引文件以稀疏索引(sparse index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。每当写入一定量(由 broker 端参数 log.index.interval.bytes指定,默认值为4096,即4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项,增大或减小log.index.interval.bytes的值,对应地可以增加或缩小索引项的密度。
稀疏索引通过MappedByteBuffer将索引文件映射到内存中,以加快索引的查询速度。偏移量索引文件中的偏移量是单调递增的,查询指定偏移量时,使用二分查找法来快速定位偏移量的位置,如果指定的偏移量不在索引文件中,则会返回小于指定偏移量的最大偏移量。时间戳索引文件中的时间戳也保持严格的单调递增,查询指定时间戳时,也根据二分查找法来查找不大于该时间戳的最大偏移量,至于要找到对应的物理文件位置还需要根据偏移量索引文件来进行再次定位。稀疏索引的方式是在磁盘空间、内存空间、查找时间等多方面之间的一个折中。
本章开头也提及日志分段文件达到一定的条件时需要进行切分,那么其对应的索引文件也需要进行切分。日志分段文件切分包含以下几个条件,满足其一即可:
- 当前日志分段文件的大小超过了 broker 端参数log.segment.bytes 配置的值。log.segment.bytes参数的默认值为1073741824,即1GB。
- 当前日志分段中消息的最大时间戳与当前系统的时间戳的差值大于 log.roll.ms或log.roll.hours参数配置的值。如果同时配置了log.roll.ms和log.roll.hours参数,那么log.roll.ms的优先级高。默认情况下,只配置了log.roll.hours参数,其值为168,即7天。
- 偏移量索引文件或时间戳索引文件的大小达到broker端参数log.index.size.max.bytes配置的值。log.index.size.max.bytes的默认值为10485760,即10MB。
- 追加的消息的偏移量与当前日志分段的偏移量之间的差值大于Integer.MAX_VALUE,即要追加的消息的偏移量不能转变为相对偏移量(offset-baseOffset>Integer.MAX_VALUE)。
对非当前活跃的日志分段而言,其对应的索引文件内容已经固定而不需要再写入索引项,所以会被设定为只读。而对当前活跃的日志分段(activeSegment)而言,索引文件还会追加更多的索引项,所以被设定为可读写。在索引文件切分的时候,Kafka 会关闭当前正在写入的索引文件并置为只读模式,同时以可读写的模式创建新的索引文件,索引文件的大小由broker端参数log.index.size.max.bytes 配置。Kafka 在创建索引文件的时候会为其预分配log.index.size.max.bytes 大小的空间,注意这一点与日志分段文件不同,只有当索引文件进行切分的时候,Kafka 才会把该索引文件裁剪到实际的数据大小。也就是说,与当前活跃的日志分段对应的索引文件的大小固定为 log.index.size.max.bytes,而其余日志分段对应的索引文件的大小为实际的占用空间。
偏移量索引
偏移量索引项的格式:每个索引项占用8个字节,分为两个部分:
- relativeOffset:相对偏移量,表示消息相对于baseOffset 的偏移量,占用4 个字节,当前索引文件的文件名即为baseOffset的值。
- position:物理地址,也就是消息在日志分段文件中对应的物理位置,占用4个字节。
消息的偏移量(offset)占用8个字节,也可以称为绝对偏移量。索引项中没有直接使用绝对偏移量而改为只占用4个字节的相对偏移量(relativeOffset=offset-baseOffset),这样可以减小索引文件占用的空间。举个例子,一个日志分段的 baseOffset 为 32,那么其文件名就是00000000000000000032.log,offset为35的消息在索引文件中的relativeOffset的值为35-32=3。
如果要查找偏移量为268的消息,那么应该怎么办呢?首先肯定是定位到baseOffset为251的日志分段,然后计算相对偏移量relativeOffset=268-251=17,之后再在对应的索引文件中找到不大于17的索引项,最后根据索引项中的position定位到具体的日志分段文件位置开始查找目标消息。那么又是如何查找baseOffset 为251的日志分段的呢?这里并不是顺序查找,而是用了跳跃表的结构。Kafka 的每个日志对象中使用了ConcurrentSkipListMap来保存各个日志分段,每个日志分段的baseOffset作为key,这样可以根据指定偏移量来快速定位到消息所在的日志分段。
还需要注意的是,Kafka 强制要求索引文件大小必须是索引项大小的整数倍,对偏移量索引文件而言,必须为8的整数倍。如果broker端参数log.index.size.max.bytes配置为67,那么Kafka在内部会将其转换为64,即不大于67,并且满足为8的整数倍的条件。
时间戳索引
每个索引项占用12个字节,分为两个部分:
- timestamp:当前日志分段最大的时间戳。
- relativeOffset:时间戳所对应的消息的相对偏移量。
时间戳索引文件中包含若干时间戳索引项,每个追加的时间戳索引项中的 timestamp 必须大于之前追加的索引项的 timestamp,否则不予追加。如果 broker 端参数 log.message.timestamp.type设置为LogAppendTime,那么消息的时间戳必定能够保持单调递增;相反,如果是 CreateTime 类型则无法保证。生产者可以使用类似 ProducerRecord(String topic,Integer partition,Long timestamp,K key,V value)的方法来指定时间戳的值。即使生产者客户端采用自动插入的时间戳也无法保证时间戳能够单调递增,如果两个不同时钟的生产者同时往一个分区中插入消息,那么也会造成当前分区的时间戳乱序。
与偏移量索引文件相似,时间戳索引文件大小必须是索引项大小(12B)的整数倍,如果不满足条件也会进行裁剪。同样假设broker端参数log.index.size.max.bytes配置为67,那么对应于时间戳索引文件,Kafka在内部会将其转换为60。
如果要查找指定时间戳targetTimeStamp=1526384718288开始的消息,首先是找到不小于指定时间戳的日志分段。这里就无法使用跳跃表来快速定位到相应的日志分段了,需要分以下几个步骤来完成:
- 将targetTimeStamp和每个日志分段中的最大时间戳largestTimeStamp逐一对比,直到找到不小于 targetTimeStamp 的 largestTimeStamp 所对应的日志分段。日志分段中的largestTimeStamp的计算是先查询该日志分段所对应的时间戳索引文件,找到最后一条索引项,若最后一条索引项的时间戳字段值大于0,则取其值,否则取该日志分段的最近修改时间。
- 找到相应的日志分段之后,在时间戳索引文件中使用二分查找算法查找到不大于targetTimeStamp的最大索引项,即[1526384718283,28],如此便找到了一个相对偏移量28。
- 在偏移量索引文件中使用二分算法查找到不大于28的最大索引项,即[26,838]。
- 从步骤1中找到日志分段文件中的838的物理位置开始查找不小于targetTimeStamp的消息。
也就是说通过时间戳索引找到相对偏移量,再通过偏移量索引找到真正的索引项,再去日志分段文件里面找到真正的消息。
日志清理
Kafka 将消息存储在磁盘中,为了控制磁盘占用空间的不断增加就需要对消息做一定的清理操作。Kafka 中每一个分区副本都对应一个 Log,而 Log 又可以分为多个日志分段,这样也便于日志的清理操作。Kafka提供了两种日志清理策略:
- 日志删除(Log Retention):按照一定的保留策略直接删除不符合条件的日志分段。
- 日志压缩(Log Compaction):针对每个消息的key进行整合,对于有相同key的不同value值,只保留最后一个版本。
我们可以通过broker端参数log.cleanup.policy来设置日志清理策略,此参数的默认值为“delete”,即采用日志删除的清理策略。如果要采用日志压缩的清理策略,就需要将log.cleanup.policy设置为“compact”,并且还需要将log.cleaner.enable(默认值为true)设定为true。通过将log.cleanup.policy参数设置为“delete,compact”,还可以同时支持日志删除和日志压缩两种策略。日志清理的粒度可以控制到主题级别,比如与log.cleanup.policy 对应的主题级别的参数为 cleanup.policy。
日志删除
在Kafka的日志管理器中会有一个专门的日志删除任务来周期性地检测和删除不符合保留条件的日志分段文件,这个周期可以通过broker端参数log.retention.check.interval.ms来配置,默认值为300000,即5分钟。当前日志分段的保留策略有3种:基于时间的保留策略、基于日志大小的保留策略和基于日志起始偏移量的保留策略。
基于时间
日志删除任务会检查当前日志文件中是否有保留时间超过设定的阈值(retentionMs)来寻找可删除的日志分段文件集合(deletableSegments)。retentionMs可以通过broker端参数log.retention.hours、log.retention.minutes和log.retention.ms来配置,其中 log.retention.ms 的优先级最高,log.retention.minutes 次之,log.retention.hours最低。默认情况下只配置了log.retention.hours参数,其值为168,故默认情况下日志分段文件的保留时间为7天。
查找过期的日志分段文件,并不是简单地根据日志分段的最近修改时间 lastModifiedTime来计算的,而是根据日志分段中最大的时间戳 largestTimeStamp 来计算的。因为日志分段的lastModifiedTime可以被有意或无意地修改,比如执行了touch操作,或者分区副本进行了重新分配,lastModifiedTime并不能真实地反映出日志分段在磁盘的保留时间。要获取日志分段中的最大时间戳 largestTimeStamp 的值,首先要查询该日志分段所对应的时间戳索引文件,查找时间戳索引文件中最后一条索引项,若最后一条索引项的时间戳字段值大于 0,则取其值,否则才设置为最近修改时间lastModifiedTime。
若待删除的日志分段的总数等于该日志文件中所有的日志分段的数量,那么说明所有的日志分段都已过期,但该日志文件中还要有一个日志分段用于接收消息的写入,即必须要保证有一个活跃的日志分段 activeSegment,在此种情况下,会先切分出一个新的日志分段作为activeSegment,然后执行删除操作。
删除日志分段时,首先会从Log对象中所维护日志分段的跳跃表中移除待删除的日志分段,以保证没有线程对这些日志分段进行读取操作。然后将日志分段所对应的所有文件添加上“.deleted”的后缀(当然也包括对应的索引文件)。最后交由一个以“delete-file”命名的延迟任务来删除这些以“.deleted”为后缀的文件,这个任务的延迟执行时间可以通过file.delete.delay.ms参数来调配,此参数的默认值为60000,即1分钟。
基于日志大小
日志删除任务会检查当前日志的大小是否超过设定的阈值(retentionSize)来寻找可删除的日志分段的文件集合(deletableSegments)。retentionSize可以通过broker端参数log.retention.bytes来配置,默认值为-1,表示无穷大。注意log.retention.bytes配置的是Log中所有日志文件的总大小,而不是单个日志分段(确切地说应该为.log日志文件)的大小。单个日志分段的大小由 broker 端参数 log.segment.bytes 来限制,默认值为1073741824,即1GB。
基于日志大小的保留策略与基于时间的保留策略类似,首先计算日志文件的总大小size和retentionSize的差值diff,即计算需要删除的日志总大小,然后从日志文件中的第一个日志分段开始进行查找可删除的日志分段的文件集合 deletableSegments。查找出deletableSegments 之后就执行删除操作,这个删除操作和基于时间的保留策略的删除操作相同,这里不再赘述。
基于日志起始偏移量
一般情况下,日志文件的起始偏移量 logStartOffset 等于第一个日志分段的 baseOffset,但这并不是绝对的,logStartOffset 的值可以通过 DeleteRecordsRequest 请求(比如使用KafkaAdminClient的deleteRecords()方法、使用kafka-delete-records.sh脚本)、日志的清理和截断等操作进行修改。
基于日志起始偏移量的保留策略的判断依据是某日志分段的下一个日志分段的起始偏移量baseOffset 是否小于等于logStartOffset,若是,则可以删除此日志分段。假设logStartOffset等于25,日志分段1的起始偏移量为0,日志分段2的起始偏移量为11,日志分段3的起始偏移量为23,通过如下动作收集可删除的日志分段的文件集合deletableSegments:
- 从头开始遍历每个日志分段,日志分段 1 的下一个日志分段的起始偏移量为 11,小于logStartOffset的大小,将日志分段1加入deletableSegments。
- 日志分段2的下一个日志偏移量的起始偏移量为23,也小于logStartOffset的大小,将日志分段2页加入deletableSegments。
- 日志分段3的下一个日志偏移量在logStartOffset的右侧,故从日志分段3开始的所有日志分段都不会加入deletableSegments。
日志压缩
Kafka中的Log Compaction是指在默认的日志删除(Log Retention)规则之外提供的一种清理过时数据的方式。如图所示,Log Compaction对于有相同key的不同value值,只保留最后一个版本。如果应用只关心key对应的最新value值,则可以开启Kafka的日志清理功能,Kafka会定期将相同key的消息进行合并,只保留最新的value值。
Log Compaction执行前后,日志分段中的每条消息的偏移量和写入时的偏移量保持一致。Log Compaction会生成新的日志分段文件,日志分段中每条消息的物理位置会重新按照新文件来组织。Log Compaction执行过后的偏移量不再是连续的,不过这并不影响日志的查询。
Kafka中的Log Compaction可以类比于Redis中的RDB的持久化模式(只保留最终态,不过redis的aof也会进行重写,否则谁也受不了那么多的数据)。试想一下,如果一个系统使用 Kafka 来保存状态,那么每次有状态变更都会将其写入 Kafka。在某一时刻此系统异常崩溃,进而在恢复时通过读取Kafka 中的消息来恢复其应有的状态,那么此系统关心的是它原本的最新状态而不是历史时刻中的每一个状态。如果 Kafka 的日志保存策略是日志删除(Log Deletion),那么系统势必要一股脑地读取Kafka中的所有数据来进行恢复,如果日志保存策略是 Log Compaction,那么可以减少数据的加载量进而加快系统的恢复速度。Log Compaction在某些应用场景下可以简化技术栈,提高系统整体的质量。
我们知道可以通过配置log.dir或log.dirs参数来设置Kafka日志的存放目录,而每一个日志目录下都有一个名为“cleaner-offset-checkpoint”的文件,这个文件就是清理检查点文件,用来记录每个主题的每个分区中已清理的偏移量。通过清理检查点文件可以将Log分成两个部分。通过检查点cleaner checkpoint来划分出一个已经清理过的clean部分和一个还未清理过的 dirty 部分。在日志清理的同时,客户端也可以读取日志中的消息。dirty部分的消息偏移量是逐一递增的,而 clean 部分的消息偏移量是断续的,如果客户端总能赶上dirty部分,那么它就能读取日志的所有消息,反之就不可能读到全部的消息。
为了防止日志不必要的频繁清理操作,Kafka 还使用了参数log.cleaner.min.cleanable.ratio(默认值为0.5)来限定可进行清理操作的最小污浊率。Kafka中用于保存消费者消费位移的主题__consumer_offsets使用的就是Log Compaction策略。
这里我们已经知道怎样选择合适的日志文件做清理操作,然而怎么对日志文件中消息的key进行筛选操作呢?Kafka中的每个日志清理线程会使用一个名为“SkimpyOffsetMap”的对象来构建 key与offset 的映射关系的哈希表。日志清理需要遍历两次日志文件,第一次遍历把每个key的哈希值和最后出现的offset都保存在SkimpyOffsetMap中,映射模型如图5-18所示。第二次遍历会检查每个消息是否符合保留条件,如果符合就保留下来,否则就会被清理。假设一条消息的offset为O1,这条消息的key在SkimpyOffsetMap中对应的offset为O2,如果O1大于等于O2即满足保留条件。
默认情况下,SkimpyOffsetMap使用MD5来计算key的哈希值,占用空间大小为16B,根据这个哈希值来从 SkimpyOffsetMap 中找到对应的槽位,如果发生冲突则用线性探测法处理。为了防止哈希冲突过于频繁,也可以通过 broker 端参数log.cleaner.io.buffer.load.factor(默认值为0.9)来调整负载因子。偏移量占用空间大小为8B,故一个映射项占用大小为24B。每个日志清理线程的SkimpyOffsetMap的内存占用大小为log.cleaner.dedupe.buffer.size/log.cleaner.thread,默认值为=128MB/1=128MB。所以默认情况下SkimpyOffsetMap可以保存128MB×0.9/24B ≈ 5033164 个 key 的记录。假设每条消息的大小为 1KB,那么这个SkimpyOffsetMap可以用来映射4.8GB的日志文件,如果有重复的key,那么这个数值还会增大,整体上来说,SkimpyOffsetMap极大地节省了内存空间且非常高效。
“SkimpyOffsetMap”的取名也很有意思,“Skimpy”可以直译为“不足的”,可以看出它最初的设计者也认为这种实现不够严谨。如果遇到两个不同的 key但哈希值相同的情况,那么其中一个key所对应的消息就会丢失。虽然说MD5这类摘要算法的冲突概率非常小,但根据墨菲定律,任何一个事件,只要具有大于0的概率,就不能假设它不会发生,所以在使用Log Compaction策略时要注意这一点。
Log Compaction会保留key相应的最新value值,那么当需要删除一个key时怎么办?Kafka提供了一个墓碑消息(tombstone)的概念,如果一条消息的key不为null,但是其value为null,那么此消息就是墓碑消息。日志清理线程发现墓碑消息时会先进行常规的清理,并保留墓碑消息一段时间。墓碑消息的保留条件是当前墓碑消息所在的日志分段的最近修改时间lastModifiedTime大于deleteHorizonMs。这个deleteHorizonMs的计算方式为clean部分中最后一个日志分段的最近修改时间减去保留阈值 deleteRetionMs(通过 broker 端参数log.cleaner.delete.retention.ms配置,默认值为86400000,即24小时)的大小。
Log Compaction执行过后的日志分段的大小会比原先的日志分段的要小,为了防止出现太多的小文件,Kafka 在实际清理过程中并不对单个的日志分段进行单独清理,而是将日志文件中offset从0至firstUncleanableOffset的所有日志分段进行分组,每个日志分段只属于一组,分组策略为:按照日志分段的顺序遍历,每组中日志分段的占用空间大小之和不超过segmentSize(可以通过broker端参数log.segment.bytes设置,默认值为1GB),且对应的索引文件占用大小之和不超过 maxIndexSize(可以通过broker 端参数log.index.interval.bytes设置,默认值为10MB)。同一个组的多个日志分段清理过后,只会生成一个新的日志分段。
以上是整个日志压缩(Log Compaction)过程的详解,读者需要注意将日志压缩和日志删除区分开,日志删除是指清除整个日志分段,而日志压缩是针对相同key的消息的合并清理。
磁盘存储
Kafka 在设计时采用了文件追加的方式来写入消息,即只能在日志文件的尾部追加新的消息,并且也不允许修改已写入的消息,这种方式属于典型的顺序写盘的操作,所以就算 Kafka使用磁盘作为存储介质,它所能承载的吞吐量也不容小觑。但这并不是让Kafka在性能上具备足够竞争力的唯一因素,我们不妨继续分析(kafka高性能的原因)。
页缓存
页缓存是操作系统实现的一种主要的磁盘缓存,以此用来减少对磁盘 I/O 的操作。具体来说,就是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问。为了弥补性能上的差异,现代操作系统越来越“激进地”将内存作为磁盘缓存,甚至会非常乐意将所有可用的内存用作磁盘缓存,这样当内存回收时也几乎没有性能损失,所有对于磁盘的读写也将经由统一的缓存。
当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页(page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物理磁盘的 I/O 操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性。
Linux操作系统中的vm.dirty_background_ratio参数用来指定当脏页数量达到系统内存的百分之多少之后就会触发pdflush/flush/kdmflush 等后台回写进程的运行来处理脏页,一般设置为小于10的值即可,但不建议设置为0。与这个参数对应的还有一个vm.dirty_ratio参数,它用来指定当脏页数量达到系统内存的百分之多少之后就不得不开始对脏页进行处理,在此过程中,新的 I/O 请求会被阻挡直至所有脏页被冲刷到磁盘中。对脏页有兴趣的读者还可以自行查阅vm.dirty_expire_centisecs、vm.dirty_writeback.centisecs等参数的使用说明。
对一个进程而言,它会在进程内部缓存处理所需的数据,然而这些数据有可能还缓存在操作系统的页缓存中,因此同一份数据有可能被缓存了两次。并且,除非使用Direct I/O的方式,否则页缓存很难被禁止。此外,用过Java的人一般都知道两点事实:对象的内存开销非常大,通常会是真实数据大小的几倍甚至更多,空间使用率低下;Java的垃圾回收会随着堆内数据的增多而变得越来越慢。基于这些因素,使用文件系统并依赖于页缓存的做法明显要优于维护一个进程内缓存或其他结构,至少我们可以省去了一份进程内部的缓存消耗,同时还可以通过结构紧凑的字节码来替代使用对象的方式以节省更多的空间。如此,我们可以在 32GB 的机器上使用28GB至30GB的内存而不用担心GC所带来的性能问题。此外,即使Kafka服务重启,页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为维护页缓存和文件之间的一致性交由操作系统来负责,这样会比进程内维护更加安全有效。
Kafka 中大量使用了页缓存,这是 Kafka 实现高吞吐的重要因素之一。虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的,但在Kafka中同样提供了同步刷盘及间断性强制刷盘(fsync)的功能,这些功能可以通过log.flush.interval.messages、log.flush.interval.ms 等参数来控制。同步刷盘可以提高消息的可靠性,防止由于机器掉电等异常造成处于页缓存而没有及时写入磁盘的消息丢失。不过笔者并不建议这么做,刷盘任务就应交由操作系统去调配,消息的可靠性应该由多副本机制来保障,而不是由同步刷盘这种严重影响性能的行为来保障。
Linux系统会使用磁盘的一部分作为swap分区,这样可以进行进程的调度:把当前非活跃的进程调入 swap 分区,以此把内存空出来让给活跃的进程。对大量使用系统页缓存的 Kafka而言,应当尽量避免这种内存的交换,否则会对它各方面的性能产生很大的负面影响。我们可以通过修改vm.swappiness参数(Linux系统参数)来进行调节。vm.swappiness参数的上限为 100,它表示积极地使用swap 分区,并把内存上的数据及时地搬运到 swap 分区中;vm.swappiness 参数的下限为 0,表示在任何情况下都不要发生交换(vm.swappiness=0的含义在不同版本的 Linux 内核中不太相同,这里采用的是变更后的最新解释),这样一来,当内存耗尽时会根据一定的规则突然中止某些进程。笔者建议将这个参数的值设置为 1,这样保留了swap的机制而又最大限度地限制了它对Kafka性能的影响。
其实是应该禁用的,作者比较偏保守了,对于大部分中间件,包括数据库和纯粹的jar应用而言,swap都是百害而无一利的,甚至会导致系统不可用。
磁盘I/O流程
从编程角度而言,一般磁盘I/O的场景有以下四种。
- 用户调用标准C库进行I/O操作,数据流为:应用程序buffer→C库标准IObuffer→文件系统页缓存→通过具体文件系统到磁盘。
- 用户调用文件 I/O,数据流为:应用程序 buffer→文件系统页缓存→通过具体文件系统到磁盘。
- 用户打开文件时使用O_DIRECT,绕过页缓存直接读写磁盘。
- 用户使用类似dd工具,并使用direct参数,绕过系统cache与文件系统直接写磁盘。
发起I/O请求的步骤可以表述为如下的内容(以最长链路为例):
- 写操作:用户调用fwrite把数据写入C库标准IObuffer后就返回,即写操作通常是异步操作;数据写入C库标准IObuffer后,不会立即刷新到磁盘,会将多次小数据量相邻写操作先缓存起来合并,最终调用write函数一次性写入(或者将大块数据分解多次write 调用)页缓存;数据到达页缓存后也不会立即刷新到磁盘,内核有pdflush 线程在不停地检测脏页,判断是否要写回到磁盘,如果是则发起磁盘I/O请求。
- 读操作:用户调用fread到C库标准IObuffer中读取数据,如果成功则返回,否则继续;到页缓存中读取数据,如果成功则返回,否则继续;发起 I/O 请求,读取数据后缓存buffer和C库标准IObuffer并返回。可以看出,读操作是同步请求。
- I/O请求处理:通用块层根据I/O请求构造一个或多个bio结构并提交给调度层;调度器将 bio 结构进行排序和合并组织成队列且确保读写操作尽可能理想:将一个或多个进程的读操作合并到一起读,将一个或多个进程的写操作合并到一起写,尽可能变随机为顺序(因为随机读写比顺序读写要慢),读必须优先满足,而写也不能等太久。
不同的磁盘调度算法(以及相应的I/O优化手段)对Kafka 这类依赖磁盘运转的应用的影响很大,建议根据不同的业务需求来测试并选择合适的磁盘调度算法。
从文件系统层面分析,Kafka 操作的都是普通文件,并没有依赖于特定的文件系统,但是依然推荐使用EXT4或XFS。尤其是对XFS而言,它通常有更好的性能,这种性能的提升主要影响的是Kafka的写入性能。
零拷贝
除了消息顺序追加、页缓存等技术,Kafka还使用零拷贝(Zero-Copy)技术来进一步提升性能。所谓的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。零拷贝大大提高了应用程序的性能,减少了内核和用户模式之间的上下文切换。对 Linux操作系统而言,零拷贝技术依赖于底层的 sendfile()方法实现。对应于 Java 语言,FileChannal.transferTo()方法的底层实现就是sendfile()方法。
正常的文件4次复制的过程:
- 调用read()时,文件A中的内容被复制到了内核模式下的Read Buffer中。
- CPU控制将内核模式数据复制到用户模式下。
- 调用write()时,将用户模式下的内容复制到内核模式下的Socket Buffer中。
- 将内核模式下的Socket Buffer的数据复制到网卡设备中传送。
从上面的过程可以看出,数据平白无故地从内核模式到用户模式“走了一圈”,浪费了 2次复制过程:第一次是从内核模式复制到用户模式;第二次是从用户模式再复制回内核模式,即上面4次过程中的第2步和第3步。而且在上面的过程中,内核和用户模式的上下文的切换也是4次。
如果采用了零拷贝技术,那么应用程序可以直接请求内核把磁盘中的数据传输给 Socket,如图所示:
零拷贝技术通过DMA(Direct Memory Access)技术将文件内容复制到内核模式下的Read Buffer 中。不过没有数据被复制到Socket Buffer,相反只有包含数据的位置和长度的信息的文件描述符被加到Socket Buffer中。DMA引擎直接将数据从内核模式中传递到网卡设备(协议引擎)。这里数据只经历了2次复制就从磁盘中传送出去了,并且上下文切换也变成了2次。零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝。
深入服务端
协议设计
在实际应用中,Kafka经常被用作高性能、可扩展的消息中间件。Kafka自定义了一组基于TCP的二进制协议,只要遵守这组协议的格式,就可以向Kafka发送消息,也可以从Kafka中拉取消息,或者做一些其他的事情,比如提交消费位移等。在目前的 Kafka 2.0.0 中,一共包含了 43 种协议类型,每种协议类型都有对应的请求(Request)和响应(Response),它们都遵守特定的协议模式。每种类型的Request都包含相同结构的协议请求头(RequestHeader)和不同结构的协议请求体(RequestBody)。
协议请求头中包含 4 个域(Field):api_key、api_version、correlation_id 和client_id:
不管是 follower 副本还是普通的消费者客户端,如果要拉取某个分区中的消息,就需要指定详细的拉取信息,也就是需要设定partition、fetch_offset、log_start_offset和max_bytes这4个域的具体值,那么对每个分区而言,就需要占用4B+8B+8B+4B=24B的空间。一般情况下,不管是 follower 副本还是普通的消费者,它们的订阅信息是长期固定的。也就是说,FetchRequest 中的 topics 域的内容是长期固定的,只有在拉取开始时或发生某些异常时会有所变动。FetchRequest 请求是一个非常频繁的请求,如果要拉取的分区数有很多,比如有1000个分区,那么在网络上频繁交互FetchRequest时就会有固定的1000×24B ≈ 24KB的字节的内容在传动,如果可以将这 24KB 的状态保存起来,那么就可以节省这部分所占用的带宽。
Kafka从1.1.0版本开始针对FetchRequest引入了session_id、epoch和forgotten_topics_data等域,session_id和epoch确定一条拉取链路的fetch session,当session建立或变更时会发送全量式的 FetchRequest,所谓的全量式就是指请求体中包含所有需要拉取的分区信息;当session稳定时则会发送增量式的FetchRequest请求,里面的topics域为空,因为topics域的内容已经被缓存在了session链路的两侧。如果需要从当前fetch session中取消对某些分区的拉取订阅,则可以使用forgotten_topics_data字段来实现。
这个改进在大规模(有大量的分区副本需要及时同步)的Kafka集群中非常有用,它可以提升集群间的网络带宽的有效使用率。不过对客户端而言效果不是那么明显,一般情况下单个客户端不会订阅太多的分区,不过总体上这也是一个很好的优化改进。
时间轮
Kafka中存在大量的延时操作,比如延时生产、延时拉取和延时删除等。Kafka并没有使用JDK自带的Timer或DelayQueue来实现延时的功能,而是基于时间轮的概念自定义实现了一个用于延时功能的定时器(SystemTimer)。JDK中Timer和DelayQueue的插入和删除操作的平均时间复杂度为O(nlogn)并不能满足Kafka的高性能要求,而基于时间轮可以将插入和删除操作的时间复杂度都降为O(1)。时间轮的应用并非Kafka独有,其应用场景还有很多,在Netty、Akka、Quartz、ZooKeeper等组件中都存在时间轮的踪影。
如图所示, Kafka中的时间轮(TimingWheel)是一个存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表(TimerTaskList)。 TimerTaskList是一个环形的双向链表,链表中的每一项表示的都是定时任务项(TimerTaskEntry),其中封装了真正的定时任务(TimerTask)。
时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度(tickMs)。时间轮的时间格个数是固定的,可用wheelSize来表示,那么整个时间轮的总体时间跨度(interval)可以通过公式 tickMs×wheelSize计算得出。时间轮还有一个表盘指针(currentTime),用来表示时间轮当前所处的时间,currentTime是tickMs的整数倍。currentTime可以将整个时间轮划分为到期部分和未到期部分,currentTime当前指向的时间格也属于到期部分,表示刚好到期,需要处理此时间格所对应的TimerTaskList中的所有任务。
若时间轮的tickMs为1ms且wheelSize等于20,那么可以计算得出总体时间跨度interval为20ms。初始情况下表盘指针currentTime指向时间格0,此时有一个定时为2ms的任务插进来会存放到时间格为2的TimerTaskList中。随着时间的不断推移,指针currentTime不断向前推进,过了2ms之后,当到达时间格2时,就需要将时间格2对应的TimeTaskList中的任务进行相应的到期操作。此时若又有一个定时为 8ms 的任务插进来,则会存放到时间格 10 中,currentTime再过8ms后会指向时间格10。如果同时有一个定时为19ms的任务插进来怎么办?新来的TimerTaskEntry会复用原来的TimerTaskList,所以它会插入原本已经到期的时间格1。总之,整个时间轮的总体跨度是不变的,随着指针currentTime的不断推进,当前时间轮所能处理的时间段也在不断后移,总体时间范围在currentTime和currentTime+interval之间。
如果此时有一个定时为350ms的任务该如何处理?直接扩充wheelSize的大小?Kafka中不乏几万甚至几十万毫秒的定时任务,这个wheelSize的扩充没有底线,就算将所有的定时任务的到期时间都设定一个上限,比如100万毫秒,那么这个wheelSize为100万毫秒的时间轮不仅占用很大的内存空间,而且也会拉低效率。Kafka 为此引入了层级时间轮的概念,当任务的到期时间超过了当前时间轮所表示的时间范围时,就会尝试添加到上层时间轮中。(把时间轮也做一个分层)
如图所示,复用之前的案例,第一层的时间轮tickMs=1ms、wheelSize=20、interval=20ms。第二层的时间轮的tickMs为第一层时间轮的interval,即20ms。每一层时间轮的wheelSize是固定的,都是20,那么第二层的时间轮的总体时间跨度interval为400ms。以此类推,这个400ms也是第三层的tickMs的大小,第三层的时间轮的总体时间跨度为8000ms。
如果此时又有一个定时为450ms的任务,那么显然第二层时间轮也无法满足条件,所以又升级到第三层时间轮中,最终被插入第三层时间轮中时间格1的TimerTaskList。注意到在到期时间为[400ms,800ms)区间内的多个任务(比如446ms、455ms和473ms的定时任务)都会被放入第三层时间轮的时间格1,时间格1对应的TimerTaskList的超时时间为400ms。随着时间的流逝,当此TimerTaskList到期之时,原本定时为450ms的任务还剩下50ms的时间,还不能执行这个任务的到期操作。这里就有一个时间轮降级的操作,会将这个剩余时间为 50ms 的定时任务重新提交到层级时间轮中,此时第一层时间轮的总体时间跨度不够,而第二层足够,所以该任务被放到第二层时间轮到期时间为[40ms,60ms)的时间格中。再经历40ms之后,此时这个任务又被“察觉”,不过还剩余10ms,还是不能立即执行到期操作。所以还要再有一次时间轮的降级,此任务被添加到第一层时间轮到期时间为[10ms,11ms)的时间格中,之后再经历 10ms 后,此任务真正到期,最终执行相应的到期操作。
设计源于生活。我们常见的钟表就是一种具有三层结构的时间轮,第一层时间轮tickMs=1ms、wheelSize=60、interval=1min,此为秒钟;第二层 tickMs=1min、wheelSize=60、interval=1hour,此为分钟;第三层tickMs=1hour、wheelSize=12、interval=12hours,此为时钟。
Kafka在具体实现时间轮TimingWheel时还有一些小细节:
- TimingWheel 在创建的时候以当前系统时间为第一层时间轮的起始时间(startMs),这里的当前系统时间并没有简单地调用System.currentTimeMillis(),而是调用了Time.SYSTEM.hiResClockMs,这是因为 currentTimeMillis()方法的时间精度依赖于操作系统的具体实现,有些操作系统下并不能达到毫秒级的精度,而Time.SYSTEM.hiResClockMs实质上采用了System.nanoTime()/1_000_000来将精度调整到毫秒级。
- TimingWheel中的每个双向环形链表TimerTaskList都会有一个哨兵节点(sentinel),引入哨兵节点可以简化边界条件。哨兵节点也称为哑元节点(dummy node),它是一个附加的链表节点,该节点作为第一个节点,它的值域中并不存储任何东西,只是为了操作的方便而引入的。如果一个链表有哨兵节点,那么线性表的第一个元素应该是链表的第二个节点。
- 除了第一层时间轮,其余高层时间轮的起始时间(startMs)都设置为创建此层时间轮时前面第一轮的currentTime。每一层的currentTime都必须是tickMs的整数倍,如果不满足则会将currentTime修剪为tickMs的整数倍,以此与时间轮中的时间格的到期时间范围对应起来。修剪方法为:currentTime=startMs-(startMs%tickMs)。currentTime会随着时间推移而推进,但不会改变为tickMs的整数倍的既定事实。若某一时刻的时间为timeMs,那么此时时间轮的currentTime=timeMs-(timeMs%tickMs),时间每推进一次,每个层级的时间轮的currentTime都会依据此公式执行推进。
- Kafka 中的定时器只需持有 TimingWheel 的第一层时间轮的引用,并不会直接持有其他高层的时间轮,但每一层时间轮都会有一个引用(overflowWheel)指向更高一层的应用,以此层级调用可以实现定时器间接持有各个层级时间轮的引用。
Kafka中的定时器借了JDK中的DelayQueue来协助推进时间轮。具体做法是对于每个使用到的TimerTaskList都加入DelayQueue,“每个用到的TimerTaskList”特指非哨兵节点的定时任务项TimerTaskEntry对应的TimerTaskList。 DelayQueue会根据TimerTaskList对应的超时时间expiration来排序,最短expiration的TimerTaskList会被排在DelayQueue的队头。Kafka中会有一个线程来获取 DelayQueue 中到期的任务列表,有意思的是这个线程所对应的名称叫作“ExpiredOperationReaper”,可以直译为“过期操作收割机”,和5.4.2节中的“SkimpyOffsetMap”的取名有异曲同工之妙。当“收割机”线程获取 DelayQueue 中超时的任务列表 TimerTaskList之后,既可以根据 TimerTaskList 的 expiration 来推进时间轮的时间,也可以就获取的TimerTaskList执行相应的操作,对里面的TimerTaskEntry该执行过期操作的就执行过期操作,该降级时间轮的就降级时间轮。
Kafka 中的 TimingWheel 专门用来执行插入和删除 TimerTaskEntry的操作,而 DelayQueue 专门负责时间推进的任务。这里采用DelayQueue来辅助以少量空间换时间,从而做到了“精准推进”。Kafka中的定时器真可谓“知人善用”,用TimingWheel做最擅长的任务添加和删除操作,而用DelayQueue做最擅长的时间推进工作,两者相辅相成。
延时操作
如果在使用生产者客户端发送消息的时候将 acks 参数设置为-1,那么就意味着需要等待ISR集合中的所有副本都确认收到消息之后才能正确地收到响应的结果,或者捕获超时异常。
如果在一定的时间内,follower1副本或follower2副本没能够完全拉取到消息3和消息4,那么就需要返回超时异常给客户端。生产请求的超时时间由参数request.timeout.ms配置,默认值为30000,即30s。在将消息写入 leader 副本的本地日志文件之后,Kafka会创建一个延时的生产操作(DelayedProduce),用来处理消息正常写入所有副本或超时的情况,以返回相应的响应结果给客户端。
在Kafka中有多种延时操作,比如前面提及的延时生产,还有延时拉取(DelayedFetch)、延时数据删除(DelayedDeleteRecords)等。延时操作需要延时返回响应的结果,首先它必须有一个超时时间(delayMs),如果在这个超时时间内没有完成既定的任务,那么就需要强制完成以返回响应结果给客户端。
就延时生产操作而言,它的外部事件是所要写入消息的某个分区的 HW(高水位)发生增长。也就是说,随着follower副本不断地与leader副本进行消息同步,进而促使HW进一步增长,HW 每增长一次都会检测是否能够完成此次延时生产操作,如果可以就执行以此返回响应结果给客户端;如果在超时时间内始终无法完成,则强制执行。
延时操作创建之后会被加入延时操作管理器(DelayedOperationPurgatory)来做专门的处理。延时操作有可能会超时,每个延时操作管理器都会配备一个定时器(SystemTimer)来做超时管理,定时器的底层就是采用时间轮(TimingWheel)实现的。这里的“收割机”线程就是由延时操作管理器启动的。也就是说,定时器、“收割机”线程和延时操作管理器都是一一对应的。延时操作需要支持外部事件的触发,所以还要配备一个监听池来负责监听每个分区的外部事件—查看是否有分区的HW发生了增长。另外需要补充的是,ExpiredOperationReaper不仅可以推进时间轮,还会定期清理监听池中已完成的延时操作。
客户端在请求写入消息到收到响应结果的过程中与延时生产操作相关的细节,在了解相关的概念之后应该比较容易理解:如果客户端设置的 acks 参数不为-1,或者没有成功的消息写入,那么就直接返回结果给客户端,否则就需要创建延时生产操作并存入延时操作管理器,最终要么由外部事件触发,要么由超时触发而执行。
Kafka选择了延时操作来处理这种情况。Kafka在处理拉取请求时,会先读取一次日志文件,如果收集不到足够多(fetchMinBytes,由参数fetch.min.bytes配置,默认值为1)的消息,那么就会创建一个延时拉取操作(DelayedFetch)以等待拉取到足够数量的消息。如果拉取进度一直没有追赶上leader副本,那么在拉取leader副本的消息时一般拉取的消息大小都会不小于fetchMinBytes,这样Kafka也就不会创建相应的延时拉取操作,而是立即返回拉取结果。
如果是follower副本的延时拉取,它的外部事件就是消息追加到了leader副本的本地日志文件中;如果是消费者客户端的延时拉取,它的外部事件可以简单地理解为HW的增长。
目前版本的Kafka还引入了事务的概念,对于消费者或follower副本而言,其默认的事务隔离级别为“read_uncommitted”。不过消费者可以通过客户端参数isolation.level将事务隔离级别设置为“read_committed”(注意:follower副本不可以将事务隔离级别修改为这个值),这样消费者拉取不到生产者已经写入却尚未提交的消息。对应的消费者的延时拉取,它的外部事件实际上会切换为由LSO(LastStableOffset)的增长来触发。LSO是HW之前除去未提交的事务消息的最大偏移量,LSO≤HW。
控制器
在 Kafka 集群中会有一个或多个 broker,其中有一个 broker 会被选举为控制器(Kafka Controller),它负责管理整个集群中所有分区和副本的状态。当某个分区的leader副本出现故障时,由控制器负责为该分区选举新的leader副本。当检测到某个分区的ISR集合发生变化时,由控制器负责通知所有broker更新其元数据信息。当使用kafka-topics.sh脚本为某个topic增加分区数量时,同样还是由控制器负责分区的重新分配。
控制器的选举及异常恢复
Kafka中的控制器选举工作依赖于ZooKeeper,成功竞选为控制器的broker会在ZooKeeper中创建/controller这个临时(EPHEMERAL)节点。
任意时刻,集群中有且仅有一个控制器。每个 broker 启动的时候会去尝试读取/controller节点的brokerid的值,如果读取到brokerid的值不为-1,则表示已经有其他 broker 节点成功竞选为控制器,所以当前 broker 就会放弃竞选;如果 ZooKeeper 中不存在/controller节点,或者这个节点中的数据异常,那么就会尝试去创建/controller节点。当前broker去创建节点的时候,也有可能其他broker同时去尝试创建这个节点,只有创建成功的那个broker才会成为控制器,而创建失败的broker竞选失败。每个broker都会在内存中保存当前控制器的brokerid值,这个值可以标识为activeControllerId。
ZooKeeper 中还有一个与控制器有关的/controller_epoch 节点,这个节点是持久(PERSISTENT)节点,节点中存放的是一个整型的controller_epoch值。controller_epoch用于记录控制器发生变更的次数,即记录当前的控制器是第几代控制器,我们也可以称之为“控制器的纪元”。
controller_epoch的初始值为1,即集群中第一个控制器的纪元为1,当控制器发生变更时,每选出一个新的控制器就将该字段值加1。每个和控制器交互的请求都会携带controller_epoch这个字段,如果请求的controller_epoch值小于内存中的controller_epoch值,则认为这个请求是向已经过期的控制器所发送的请求,那么这个请求会被认定为无效的请求。如果请求的controller_epoch值大于内存中的controller_epoch值,那么说明已经有新的控制器当选了。由此可见,Kafka 通过controller_epoch 来保证控制器的唯一性,进而保证相关操作的一致性。
在Kafka的早期版本中,并没有采用Kafka Controller这样一个概念来对分区和副本的状态进行管理,而是依赖于ZooKeeper,每个broker都会在ZooKeeper上为分区和副本注册大量的监听器(Watcher)。当分区或副本状态变化时,会唤醒很多不必要的监听器,这种严重依赖ZooKeeper 的设计会有脑裂、羊群效应,以及造成 ZooKeeper 过载的隐患( 旧版的消费者客户端存在同样的问题,详细内容参考7.2.1节)。在目前的新版本的设计中,只有Kafka Controller在ZooKeeper上注册相应的监听器,其他的broker极少需要再监听ZooKeeper中的数据变化,这样省去了很多不必要的麻烦。不过每个broker还是会对/controller节点添加监听器,以此来监听此节点的数据变化(ControllerChangeHandler)。
优雅关闭
可以看出kafka-server-stop.sh首先通过ps ax的方式找出正在运行Kafka的进程号PIDS,然后使用kill-s TERM $PIDS的方式来关闭。但是这个脚本在很多时候并不奏效,这一点与ps命令有关系。在Linux操作系统中,ps命令限制输出的字符数不得超过页大小PAGE_SIZE,一般 CPU 的内存管理单元(Memory Management Unit,简称 MMU)的PAGE_SIZE为4096。也就是说,ps命令的输出的字符串长度限制在4096内。
只需要按照以下两个步骤就可以优雅地关闭Kafka的服务进程:
- 获取Kafka的服务进程号PIDS。可以使用Java中的jps命令或使用Linux系统中的ps命令来查看。
- 使用 kill-s TERM $PIDS 或 kill-15 $PIDS 的方式来关闭进程,注意千万不要使用kill-9的方式。
分区leader的选举
分区leader副本的选举由控制器负责具体实施。当创建分区(创建主题或增加分区都有创建分区的动作)或分区上线(比如分区中原先的leader副本下线,此时分区需要选举一个新的leader 上线来对外提供服务)的时候都需要执行 leader 的选举动作,对应的选举策略为OfflinePartitionLeaderElectionStrategy。这种策略的基本思路是按照 AR 集合中副本的顺序查找第一个存活的副本,并且这个副本在ISR集合中。一个分区的AR集合在分配的时候就被指定,并且只要不发生重分配的情况,集合内部副本的顺序是保持不变的,而分区的ISR集合中副本的顺序可能会改变。
如果ISR集合中没有可用的副本,那么此时还要再检查一下所配置的unclean.leader.election.enable参数(默认值为false)。如果这个参数配置为true,那么表示允许从非ISR列表中的选举leader,从AR列表中找到第一个存活的副本即为leader。
当分区进行重分配的时候也需要执行leader的选举动作,对应的选举策略为ReassignPartitionLeaderElectionStrategy。这个选举策略的思路比较简单:从重分配的AR列表中找到第一个存活的副本,且这个副本在目前的ISR列表中。
当发生优先副本的选举时,直接将优先副本设置为leader即可,AR集合中的第一个副本即为优先副本(PreferredReplicaPartitionLeaderElectionStrategy)。
还有一种情况会发生 leader 的选举,当某节点被优雅地关闭(也就是执行ControlledShutdown)时,位于这个节点上的leader副本都会下线,所以与此对应的分区需要执行leader的选举。与此对应的选举策略(ControlledShutdownPartitionLeaderElectionStrategy)为:从AR列表中找到第一个存活的副本,且这个副本在目前的ISR列表中,与此同时还要确保这个副本不处于正在被关闭的节点上。
参数解密
如果broker端没有显式配置listeners(或advertised.listeners)使用IP地址,那么最好将bootstrap.server配置成主机名而不要使用IP地址,因为Kafka内部使用的是全称域名(Fully Qualified Domain Name)。如果不统一,则会出现无法获取元数据的异常。
broker.id
broker.id是broker在启动之前必须设定的参数之一,在Kafka集群中,每个broker都有唯一的 id(也可以记作 brokerId)值用来区分彼此。broker 在启动时会在 ZooKeeper 中的/brokers/ids路径下创建一个以当前brokerId为名称的虚节点,broker的健康状态检查就依赖于此虚节点。当 broker 下线时,该虚节点会自动删除,其他 broker 节点或客户端通过判断/brokers/ids路径下是否有此broker的brokerId节点来确定该broker的健康状态。
Kafka 还提供了另外两个 broker 端参数:broker.id.generation.enable 和reserved.broker.max.id来配合生成新的brokerId。broker.id.generation.enable参数用来配置是否开启自动生成 brokerId 的功能,默认情况下为 true,即开启此功能。自动生成的 brokerId 有一个基准值,即自动生成的 brokerId 必须超过这个基准值,这个基准值通过reserverd.broker.max.id参数配置,默认值为1000。也就是说,默认情况下自动生成的brokerId从1001开始。
bootstrap.servers
我们一般可以简单地认为 bootstrap.servers 这个参数所要指定的就是将要连接的Kafka集群的broker地址列表。不过从深层次的意义上来讲,这个参数配置的是用来发现Kafka集群元数据信息的服务地址。
深入客户端
分区分配策略
Kafka提供了消费者客户端参数partition.assignment.strategy来设置消费者与订阅主题之间的分区分配策略。默认情况下,此参数的值为org.apache.kafka.clients.consumer.RangeAssignor,即采用RangeAssignor分配策略。除此之外,Kafka还提供了另外两种分配策略:RoundRobinAssignor 和 StickyAssignor。消费者客户端参数 partition.assignment.strategy可以配置多个分配策略,彼此之间以逗号分隔。
RangeAssignor分配策略
RangeAssignor 分配策略的原理是按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。 对于每一个主题,RangeAssignor策略会将消费组内所有订阅这个主题的消费者按照名称的字典序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。
RoundRobinAssignor分配策略
RoundRobinAssignor分配策略的原理是将消费组内所有消费者及消费者订阅的所有主题的分区按照字典序排序,然后通过轮询方式逐个将分区依次分配给每个消费者。 RoundRobinAssignor分配策略对应的 partition.assignment.strategy 参数值为org.apache.kafka.clients.consumer.RoundRobinAssignor。如果同一个消费组内所有的消费者的订阅信息都是相同的,那么RoundRobinAssignor分配策略的分区分配会是均匀的。
StickyAssignor分配策略
我们再来看一下StickyAssignor分配策略,“sticky”这个单词可以翻译为“黏性的”,Kafka从0.11.x版本开始引入这种分配策略,它主要有两个目的:
- 分区的分配要尽可能均匀。
- 分区的分配尽可能与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标。如果发生分区重分配,那么对于同一个分区而言,有可能之前的消费者和新指派的消费者不是同一个,之前消费者进行到一半的处理还要在新指派的消费者中再次复现一遍,这显然很浪费系统资源。StickyAssignor 分配策略如同其名称中的“sticky”一样,让分配策略具备一定的“黏性”,尽可能地让前后两次分配相同,进而减少系统资源的损耗及其他异常情况的发生。
自定义分区分配策略
读者不仅可以任意选用Kafka提供的3种分配策略,还可以自定义分配策略来实现更多可选的功能。自定义的分配策略必须要实现org.apache.kafka.clients.consumer.internals.PartitionAssignor接口。
PartitionAssignor接口中定义了两个内部类:Subscription和Assignment:
- Subscription类用来表示消费者的订阅信息,类中有两个属性:topics和userData,分别表示消费者的订阅主题列表和用户自定义信息。PartitionAssignor接口通过subscription()方法来设置消费者自身相关的 Subscription 信息,注意到此方法中只有一个参数 topics,与Subscription类中的topics的相呼应,但并没有体现有关userData的参数。为了增强用户对分配结果的控制,可以在subscription()方法内部添加一些影响分配的用户自定义信息赋予userData,比如权重、IP地址、host或机架(rack)等。
- Assignment类,它用来表示分配结果信息,类中也有两个属性:partitions和userData,分别表示所分配到的分区集合和用户自定义的数据。PartitionAssignor接口中的onAssignment()方法是在每个消费者收到消费组 leader 分配结果时的回调函数,例如在StickyAssignor 分配策略中就是通过这个方法保存当前的分配方案,以备在下次消费组再均衡(rebalance)时可以提供分配参考依据。
按照Kafka默认的消费逻辑设定,一个分区只能被同一个消费组(ConsumerGroup)内的一个消费者消费。但这一设定不是绝对的,我们可以通过自定义分区分配策略使一个分区可以分配给多个消费者消费。
消费者协调器和组协调器
了解了Kafka 中消费者的分区分配策略之后是否会有这样的疑问:如果消费者客户端中配置了两个分配策略,那么以哪个为准呢?如果有多个消费者,彼此所配置的分配策略并不完全相同,那么以哪个为准?多个消费者之间的分区分配是需要协同的,那么这个协同的过程又是怎样的呢?这一切都是交由消费者协调器(ConsumerCoordinator)和组协调器(GroupCoordinator)来完成的,它们之间使用一套组协调协议进行交互。
旧版消费者客户端的问题
消费者协调器和组协调器的概念是针对新版的消费者客户端而言的,Kafka 建立之初并没有它们。旧版的消费者客户端是使用ZooKeeper的监听器(Watcher)来实现这些功能的。这种严重依赖于ZooKeeper集群的做法有两个比较严重的问题:
- 羊群效应(Herd Effect):所谓的羊群效应是指ZooKeeper中一个被监听的节点变化,大量的 Watcher 通知被发送到客户端,导致在通知期间的其他操作延迟,也有可能发生类似死锁的情况。
- 脑裂问题(Split Brain):消费者进行再均衡操作时每个消费者都与ZooKeeper进行通信以判断消费者或broker变化的情况,由于ZooKeeper本身的特性,可能导致在同一时刻各个消费者获取的状态不一致,这样会导致异常问题发生。
再均衡的原理
新版的消费者客户端对此进行了重新设计,将全部消费组分成多个子集,每个消费组的子集在服务端对应一个GroupCoordinator对其进行管理,GroupCoordinator是Kafka服务端中用于管理消费组的组件。而消费者客户端中的ConsumerCoordinator组件负责与GroupCoordinator进行交互。
ConsumerCoordinator与GroupCoordinator之间最重要的职责就是负责执行消费者再均衡的操作,包括前面提及的分区分配的工作也是在再均衡期间完成的。就目前而言,一共有如下几种情形会触发再均衡的操作:
- 有新的消费者加入消费组。
- 有消费者宕机下线。消费者并不一定需要真正下线,例如遇到长时间的 GC、网络延迟导致消费者长时间未向GroupCoordinator发送心跳等情况时,GroupCoordinator会认为消费者已经下线。
- 有消费者主动退出消费组(发送 LeaveGroupRequest 请求)。比如客户端调用了unsubscrible()方法取消对某些主题的订阅。
- 消费组所对应的GroupCoorinator节点发生了变更。
- 消费组内所订阅的任一主题或者主题的分区数量发生变化。
再均衡的阶段
第一阶段(FIND_COORDINATOR)
消费者需要确定它所属的消费组对应的GroupCoordinator所在的broker,并创建与该broker相互通信的网络连接。如果消费者已经保存了与消费组对应的 GroupCoordinator 节点的信息,并且与它之间的网络连接是正常的,那么就可以进入第二阶段。否则,就需要向集群中的某个节点发送FindCoordinatorRequest请求来查找对应的GroupCoordinator,这里的“某个节点”并非是集群中的任意节点,而是负载最小的节点,即2.2.2节中的leastLoadedNode。
Kafka 在收到 FindCoordinatorRequest 请求之后,会根据coordinator_key(也就是groupId)查找对应的GroupCoordinator节点,如果找到对应的GroupCoordinator则会返回其相对应的node_id、host和port信息。
找到对应的__consumer_offsets中的分区之后,再寻找此分区leader副本所在的broker节点,该broker节点即为这个groupId所对应的GroupCoordinator节点。消费者groupId最终的分区分配方案及组内消费者所提交的消费位移信息都会发送给此分区leader副本所在的broker节点,让此broker节点既扮演GroupCoordinator的角色,又扮演保存分区分配方案和组内消费者位移的角色,这样可以省去很多不必要的中间轮转所带来的开销。
第二阶段(JOIN_GROUP)
在成功找到消费组所对应的 GroupCoordinator 之后就进入加入消费组的阶段,在此阶段的消费者会向GroupCoordinator发送JoinGroupRequest请求,并处理响应。
JoinGroupRequest中的group_protocols域为数组类型,其中可以囊括多个分区分配策略,这个主要取决于消费者客户端参数partition.assignment.strategy 的配置。如果配置了多种策略,那么 JoinGroupRequest 中就会包含多个 protocol_name 和protocol_metadata。其中protocol_name对应于PartitionAssignor接口中的name()方法,我们在讲述消费者分区分配策略的时候提及过相关内容。而protocol_metadata和PartitionAssignor接口中的subscription()方法有直接关系,protocol_metadata是一个bytes类型,其实质上还可以更细粒度地划分为version、topics和user_data。version占2个字节,目前其值固定为0;topics对应PartitionAssignor接口的subscription()方法返回值类型Subscription中的topics,代表一个主题列表;user_data对应Subscription中的userData,可以为空。
费者在发送JoinGroupRequest请求之后会阻塞等待Kafka服务端的响应。服务端在收到JoinGroupRequest 请求后会交由GroupCoordinator 来进行处理。GroupCoordinator 首先会对JoinGroupRequest请求做合法性校验,比如group_id是否为空、当前broker节点是否是请求的消费者组所对应的组协调器、rebalance_timeout的值是否在合理的范围之内。如果消费者是第一次请求加入消费组,那么JoinGroupRequest 请求中的member_id值为null,即没有它自身的唯一标志,此时组协调器负责为此消费者生成一个 member_id。
选举消费组的leader
GroupCoordinator需要为消费组内的消费者选举出一个消费组的leader,这个选举的算法也很简单,分两种情况分析。如果消费组内还没有 leader,那么第一个加入消费组的消费者即为消费组的leader。如果某一时刻 leader 消费者由于某些原因退出了消费组,那么会重新选举一个新的leader,这个重新选举leader的过程又更“随意”了,在GroupCoordinator中消费者的信息是以HashMap的形式存储的,其中key为消费者的member_id,而value是消费者相关的元数据信息。leaderId表示leader消费者的member_id,它的取值为 HashMap 中的第一个键值对的 key,这种选举的方式基本上和随机无异。总体上来说,消费组的leader选举过程是很随意的。
选举分区分配策略
每个消费者都可以设置自己的分区分配策略,对消费组而言需要从各个消费者呈报上来的各个分配策略中选举一个彼此都“信服”的策略来进行整体上的分区分配。这个分区分配的选举并非由leader消费者决定,而是根据消费组内的各个消费者投票来决定的。这里所说的“根据组内的各个消费者投票来决定”不是指GroupCoordinator 还要再与各个消费者进行进一步交互,而是根据各个消费者呈报的分配策略来实施。最终选举的分配策略基本上可以看作被各个消费者支持的最多的策略,具体的选举过程如下:
- 收集各个消费者支持的所有分配策略,组成候选集candidates。
- 每个消费者从候选集candidates中找出第一个自身支持的策略,为这个策略投上一票。
- 计算候选集中各个策略的选票数,选票数最多的策略即为当前消费组的分配策略。
如果有消费者并不支持选出的分配策略,那么就会报出异常IllegalArgumentException:Member does not support protocol。需要注意的是,这里所说的“消费者所支持的分配策略”是指 partition.assignment.strategy 参数配置的策略,如果这个参数值只配置了RangeAssignor,那么这个消费者客户端只支持 RangeAssignor 分配策略,而不是消费者客户端代码中实现的3种分配策略及可能的自定义分配策略。
在此之后,Kafka服务端就要发送JoinGroupResponse响应给各个消费者,leader消费者和其他普通消费者收到的响应内容并不相同,JoinGroupResponse 包含了多个域,其中 generation_id 用来标识当前消费组的年代信息,避免受到过期请求的影响。leader_id表示消费组leader消费者的member_id。Kafka发送给普通消费者的JoinGroupResponse中的members内容为空,而只有leader消费者的JoinGroupResponse中的members包含有效数据。members为数组类型,其中包含各个成员信息。member_metadata 为消费者的订阅信息,与JoinGroupRequest 中的protocol_metadata内容相同,不同的是JoinGroupRequest可以包含多个<protocol_name,protocol_metadata>的键值对,在收到JoinGroupRequest之后,GroupCoordinator已经选举出唯一的分配策略。也就是说,protocol_name已经确定(group_protocol),那么对应的protocol_metadata 也就确定了,最终各个消费者收到的JoinGroupResponse 响应中的member_metadata就是这个确定了的protocol_metadata。由此可见,Kafka把分区分配的具体分配交还给客户端,自身并不参与具体的分配细节,这样即使以后分区分配的策略发生了变更,也只需要重启消费端的应用即可,而不需要重启服务端。
第三阶段(SYNC_GROUP)
leader 消费者根据在第二阶段中选举出来的分区分配策略来实施具体的分区分配,在此之后需要将分配的方案同步给各个消费者,此时leader消费者并不是直接和其余的普通消费者同步分配方案,而是通过 GroupCoordinator 这个“中间人”来负责转发同步分配方案的。在第三阶段,也就是同步阶段,各个消费者会向GroupCoordinator发送SyncGroupRequest请求来同步分配方案。
只有leader消费者发送的 SyncGroupRequest 请求中才包含具体的分区分配方案,这个分配方案保存在group_assignment中,而其余消费者发送的SyncGroupRequest请求中的group_assignment为空。
group_assignment 是一个数组类型,其中包含了各个消费者对应的具体分配方案:member_id表示消费者的唯一标识,而member_assignment是与消费者对应的分配方案,它还可以做更具体的划分。与JoinGroupRequest请求中的protocol_metadata类似,都可以细分为3个更具体的字段,只不过protocol_metadata存储的是主题的列表信息,而member_assignment存储的是分区信息,member_assignment中可以包含多个主题的多个分区信息。
服务端在收到消费者发送的SyncGroupRequest请求之后会交由GroupCoordinator来负责具体的逻辑处理。GroupCoordinator同样会先对SyncGroupRequest请求做合法性校验,在此之后会将从 leader 消费者发送过来的分配方案提取出来,连同整个消费组的元数据信息一起存入Kafka的__consumer_offsets主题中,最后发送响应给各个消费者以提供给各个消费者各自所属的分配方案。
当消费者收到所属的分配方案之后会调用PartitionAssignor中的onAssignment()方法。随后再调用ConsumerRebalanceListener中的OnPartitionAssigned()方法。之后开启心跳任务,消费者定期向服务端的GroupCoordinator发送HeartbeatRequest来确定彼此在线。
消费组元数据信息
我们知道消费者客户端提交的消费位移会保存在Kafka的__consumer_offsets主题中,这里也一样,只不过保存的是消费组的元数据信息(GroupMetadata)。
第四阶段(HEARTBEAT)
进入这个阶段之后,消费组中的所有消费者就会处于正常工作状态。在正式消费之前,消费者还需要确定拉取消息的起始位置。假设之前已经将最后的消费位移提交到了GroupCoordinator,并且GroupCoordinator将其保存到了Kafka内部的__consumer_offsets主题中,此时消费者可以通过OffsetFetchRequest请求获取上次提交的消费位移并从此处继续消费。
消费者通过向 GroupCoordinator 发送心跳来维持它们与消费组的从属关系,以及它们对分区的所有权关系。只要消费者以正常的时间间隔发送心跳,就被认为是活跃的,说明它还在读取分区中的消息。心跳线程是一个独立的线程,可以在轮询消息的空档发送心跳。如果消费者停止发送心跳的时间足够长,则整个会话就被判定为过期,GroupCoordinator 也会认为这个消费者已经死亡,就会触发一次再均衡行为。
__consumer_offsets剖析
位移提交的内容最终会保存到Kafka的内部主题__consumer_offsets中,对于主题__consumer_offsets的深度掌握也可以让我们更好地理解和使用好位移提交。一般情况下,当集群中第一次有消费者消费消息时会自动创建主题__consumer_offsets,不过它的副本因子还受offsets.topic.replication.factor参数的约束,这个参数的默认值为3(下载安装的包中此值可能为1),分区数可以通过offsets.topic.num.partitions参数设置,默认为50。客户端提交消费位移是使用 OffsetCommitRequest 请求实现的,OffsetCommitRequest 的结构如图所示:
请求体第一层中的group_id、generation_id和member_id在前面的内容中已经介绍过多次了,retention_time 表示当前提交的消费位移所能保留的时长,不过对于消费者而言这个值保持为-1。也就是说,按照 broker 端的配置 offsets.retention.minutes 来确定保留时长。offsets.retention.minutes的默认值为10080,即7天,超过这个时间后消费位移的信息就会被删除(使用墓碑消息和日志压缩策略)。注意这个参数在2.0.0版本之前的默认值为1440,即1天,很多关于消费位移的异常也是由这个参数的值配置不当造成的。
OffsetCommitRequest中的其余字段大抵也是按照分区的粒度来划分消费位移的:topic表示主题名称,partition表示分区编号等。注意这里还有一个metadata字段。之前讲到手动位移提交时提到了可以通过Map<TopicPartition,OffsetAndMetadata> offsets参数来指定要提交的分区位移。
同消费组的元数据信息一样,最终提交的消费位移也会以消息的形式发送至主题__consumer_offsets,与消费位移对应的消息也只定义了 key 和 value 字段的具体内容,它不依赖于具体版本的消息格式,以此做到与具体的消息格式无关。
冷门知识:如果有若干消费者消费了某个主题中的消息,并且也提交了相应的消费位移,那么在删除这个主题之后会一并将这些消费位移信息删除。
事务
消息传输保障
一般而言,消息中间件的消息传输保障有3个层级,分别如下:
- at most once:至多一次。消息可能会丢失,但绝对不会重复传输。
- at least once:最少一次。消息绝不会丢失,但可能会重复传输。
- exactly once:恰好一次。每条消息肯定会被传输一次且仅传输一次。
Kafka 的消息传输保障机制非常直观。当生产者向 Kafka 发送消息时,一旦消息被成功提交到日志文件,由于多副本机制的存在,这条消息就不会丢失。如果生产者发送消息到 Kafka之后,遇到了网络问题而造成通信中断,那么生产者就无法判断该消息是否已经提交。虽然Kafka无法确定网络故障期间发生了什么,但生产者可以进行多次重试来确保消息已经写入 Kafka,这个重试的过程中有可能会造成消息的重复写入,所以这里 Kafka 提供的消息传输保障为at least once。
对消费者而言,消费者处理消息和提交消费位移的顺序在很大程度上决定了消费者提供哪一种消息传输保障。如果消费者在拉取完消息之后,应用逻辑先处理消息后提交消费位移,那么在消息处理之后且在位移提交之前消费者宕机了,待它重新上线之后,会从上一次位移提交的位置拉取,这样就出现了重复消费,因为有部分消息已经处理过了只是还没来得及提交消费位移,此时就对应at least once。如果消费者在拉完消息之后,应用逻辑先提交消费位移后进行消息处理,那么在位移提交之后且在消息处理完成之前消费者宕机了,待它重新上线之后,会从已经提交的位移处开始重新消费,但之前尚有部分消息未进行消费,如此就会发生消息丢失,此时就对应at most once。
Kafka从0.11.0.0版本开始引入了幂等和事务这两个特性,以此来实现EOS(exactly once semantics,精确一次处理语义)。
幂等
所谓的幂等,简单地说就是对接口的多次调用所产生的结果和调用一次是一致的。生产者在进行重试的时候有可能会重复写入消息,而使用Kafka的幂等性功能之后就可以避免这种情况。开启幂等性功能的方式很简单,只需要显式地将生产者客户端参数enable.idempotence设置为true即可(这个参数的默认值为false)。
不过如果要确保幂等性功能正常,还需要确保生产者客户端的retries、acks、max.in.flight.requests.per.connection这几个参数不被配置错。实际上在使用幂等性功能的时候,用户完全可以不用配置(也不建议配置)这几个参数。
如果用户显式地指定了 retries 参数,那么这个参数的值必须大于0,否则会报出ConfigException,如果用户没有显式地指定 retries 参数,那么 KafkaProducer 会将它置为 Integer.MAX_VALUE。同时还需要保证max.in.flight.requests.per.connection参数的值不能大于5,否则也会报出ConfigException。如果用户还显式地指定了 acks 参数,那么还需要保证这个参数的值为-1(all),如果不为-1,那么也会报出ConfigException,如果用户没有显式地指定这个参数,那么KafkaProducer会将它置为-1。开启幂等性功能之后,生产者就可以如同未开启幂等时一样发送消息了。
为了实现生产者的幂等性,Kafka为此引入了producer id(以下简称PID)和序列号(sequence number)这两个概念,分别对应 v2 版的日志格式中RecordBatch的producer id和first seqence这两个字段。每个新的生产者实例在初始化的时候都会被分配一个PID,这个PID对用户而言是完全透明的。对于每个PID,消息发送到的每一个分区都有对应的序列号,这些序列号从0开始单调递增。生产者每发送一条消息就会将<PID,分区>对应的序列号的值加1。
broker端会在内存中为每一对<PID,分区>维护一个序列号。对于收到的每一条消息,只有当它的序列号的值(SN_new)比broker端中维护的对应的序列号的值(SN_old)大1(即SN_new=SN_old+1)时,broker才会接收它。如果SN_new<SN_old+1,那么说明消息被重复写入,broker可以直接将其丢弃。如果SN_new>SN_old+1,那么说明中间有数据尚未写入,出现了乱序,暗示可能有消息丢失,对应的生产者会抛出OutOfOrderSequenceException,这个异常是一个严重的异常,后续的诸如 send()、beginTransaction()、commitTransaction()等方法的调用都会抛出IllegalStateException的异常。
引入序列号来实现幂等也只是针对每一对<PID,分区>而言的,也就是说,Kafka的幂等只能保证单个生产者会话(session)中单分区的幂等。
事务
幂等性并不能跨多个分区运作,而事务可以弥补这个缺陷。事务可以保证对多个分区写入操作的原子性。操作的原子性是指多个操作要么全部成功,要么全部失败,不存在部分成功、部分失败的可能。Kafka 中的事务可以使应用程序将消费消息、生产消息、提交消费位移当作原子操作来处理,同时成功或失败,即使该生产或消费会跨多个分区。
为了实现事务,应用程序必须提供唯一的 transactionalId,这个transactionalId 通过客户端参数transactional.id来显式设置 。事务要求生产者开启幂等特性,因此通过将transactional.id参数设置为非空从而开启事务特性的同时需要将 enable.idempotence 设置为 true (如果未显式设置,则KafkaProducer默认会将它的值设置为true),如果用户显式地将enable.idempotence设置为false,则会报出ConfigException。
transactionalId与PID一一对应,两者之间所不同的是transactionalId由用户显式设置,而PID是由Kafka内部分配的。另外,为了保证新的生产者启动后具有相同transactionalId的旧生产者能够立即失效,每个生产者通过transactionalId获取PID的同时,还会获取一个单调递增的producer epoch(对应下面要讲述的 KafkaProducer.initTransactions()方法)。如果使用同一个transactionalId开启两个生产者,那么前一个开启的生产者会报出如下的错误
从生产者的角度分析,通过事务,Kafka 可以保证跨生产者会话的消息幂等发送,以及跨生产者会话的事务恢复。而从消费者的角度分析,事务能保证的语义相对偏弱。出于以下原因,Kafka 并不能保证已提交的事务中的所有消息都能够被消费:
- 对采用日志压缩策略的主题而言,事务中的某些消息有可能被清理(相同key的消息,后写入的消息会覆盖前面写入的消息)。
- 事务中消息可能分布在同一个分区的多个日志分段(LogSegment)中,当老的日志分段被删除时,对应的消息可能会丢失。
- 消费者可以通过seek()方法访问任意offset的消息,从而可能遗漏事务中的部分消息。
- 消费者在消费时可能没有分配到事务内的所有分区,如此它也就不能读取事务中的所有消息。
在消费端有一个参数isolation.level,与事务有着莫大的关联,这个参数的默认值为“read_uncommitted”,意思是说消费端应用可以看到(消费到)未提交的事务,当然对于已提交的事务也是可见的。这个参数还可以设置为“read_committed”,表示消费端应用不可以看到尚未提交的事务内的消息。
日志文件中除了普通的消息,还有一种消息专门用来标志一个事务的结束,它就是控制消息(ControlBatch)。控制消息一共有两种类型:COMMIT和ABORT,分别用来表征事务已经成功提交或已经被成功中止。
为了实现事务的功能,Kafka还引入了事务协调器(TransactionCoordinator)来负责处理事务, 这一点可以类比一下组协调器(GroupCoordinator)。每一个生产者都会被指派一个特定的TransactionCoordinator,所有的事务逻辑包括分派 PID 等都是由 TransactionCoordinator 来负责实施的。TransactionCoordinator 会将事务状态持久化到内部主题__transaction_state 中。
TransactionCoordinator将最终的COMPLETE_COMMIT或COMPLETE_ABORT信息写入主题__transaction_state以表明当前事务已经结束,此时可以删除主题__transaction_state中所有关于该事务的消息。由于主题__transaction_state 采用的日志清理策略为日志压缩,所以这里的删除只需将相应的消息设置为墓碑消息即可。
可靠性探究
Kafka 中采用了多副本的机制,这是大多数分布式系统中惯用的手法,以此来实现水平扩展、提供容灾能力、提升可用性和可靠性等。我们对此可以引申出一系列的疑问:Kafka 多副本之间如何进行数据同步,尤其是在发生异常时候的处理机制又是什么?多副本间的数据一致性如何解决,基于的一致性协议又是什么?如何确保Kafka 的可靠性?Kafka 中的可靠性和可用性之间的关系又如何?
副本剖析
数据副本是指在不同的节点上持久化同一份数据,当某一个节点上存储的数据丢失时,可以从副本上读取该数据,这是解决分布式系统数据丢失问题最有效的手段。另一类副本是服务副本,指多个节点提供同样的服务,每个节点都有能力接收来自外部的请求并进行相应的处理。
Kafka从0.8版本开始为分区引入了多副本机制,通过增加副本数量来提升数据容灾能力。同时,Kafka通过多副本机制实现故障自动转移,在Kafka集群中某个broker节点失效的情况下仍然保证服务可用。我们已经简要介绍过副本的概念,并且同时介绍了与副本相关的AR、ISR、HW和LEO的概念,这里简要地复习一下相关的概念:
- 副本是相对于分区而言的,即副本是特定分区的副本。
- 一个分区中包含一个或多个副本,其中一个为leader副本,其余为follower副本,各个副本位于不同的broker节点中。只有leader副本对外提供服务,follower副本只负责数据同步。
- 分区中的所有副本统称为 AR,而ISR 是指与leader 副本保持同步状态的副本集合,当然leader副本本身也是这个集合中的一员。
- LEO标识每个分区中最后一条消息的下一个位置,分区的每个副本都有自己的LEO,ISR中最小的LEO即为HW,俗称高水位,消费者只能拉取到HW之前的消息。
从生产者发出的一条消息首先会被写入分区的leader副本,不过还需要等待ISR集合中的所有 follower 副本都同步完之后才能被认为已经提交,之后才会更新分区的 HW,进而消费者可以消费到这条消息。
失效副本
正常情况下,分区的所有副本都处于ISR集合中,但是难免会有异常情况发生,从而某些副本被剥离出ISR集合中。在ISR集合之外,也就是处于同步失效或功能失效(比如副本处于非存活状态)的副本统称为失效副本,失效副本对应的分区也就称为同步失效分区,即under-replicated分区。
Kafka从0.9.x版本开始就通过唯一的broker端参数replica.lag.time.max.ms来抉择,当ISR集合中的一个follower副本滞后leader副本的时间超过此参数指定的值时则判定为同步失败,需要将此follower副本剔除出ISR集合。
具体的实现原理也很容易理解,当follower副本将leader副本LEO(LogEndOffset)之前的日志全部同步时,则认为该 follower 副本已经追赶上 leader 副本,此时更新该副本的lastCaughtUpTimeMs 标识。Kafka 的副本管理器会启动一个副本过期检测的定时任务,而这个定时任务会定时检查当前时间与副本的 lastCaughtUpTimeMs 差值是否大于参数replica.lag.time.max.ms 指定的值。千万不要错误地认为follower 副本只要拉取 leader副本的数据就会更新lastCaughtUpTimeMs。试想一下,当 leader 副本中消息的流入速度大于follower 副本中拉取的速度时,就算 follower 副本一直不断地拉取 leader 副本的消息也不能与leader副本同步。如果还将此follower副本置于ISR集合中,那么当leader副本下线而选取此follower副本为新的leader副本时就会造成消息的严重丢失。
如果通过工具增加了副本因子,那么新增加的副本在赶上leader副本之前也都是处于失效状态的。如果一个follower副本由于某些原因(比如宕机)而下线,之后又上线,在追赶上leader副本之前也处于失效状态。
ISR的伸缩
Kafka 在启动的时候会开启两个与 ISR 相关的定时任务,名称分别为“isr-expiration”和“isr-change-propagation”。isr-expiration任务会周期性地检测每个分区是否需要缩减其ISR集合。这个周期和replica.lag.time.max.ms参数有关,大小是这个参数值的一半,默认值为5000ms。
随着follower副本不断与leader副本进行消息同步,follower副本的LEO也会逐渐后移,并最终追赶上leader副本,此时该follower副本就有资格进入ISR集合。追赶上leader副本的判定准则是此副本的LEO是否不小于leader副本的HW,注意这里并不是和leader副本的LEO相比。ISR扩充之后同样会更新ZooKeeper中的/brokers/topics/<topic>/partition/<parititon>/state节点和isrChangeSet,之后的步骤就和ISR收缩时的相同。
冷门知识:很多读者对Kafka中的HW的概念并不陌生,但是却并不知道还有一个LW的概念。LW是Low Watermark的缩写,俗称“低水位”,代表AR集合中最小的logStartOffset值。副本的拉取请求(FetchRequest,它有可能触发新建日志分段而旧的被清理,进而导致logStartOffset的增加)和删除消息请求(DeleteRecordRequest)都有可能促使LW的增长。
LEO与HW
对于副本而言,还有两个概念:本地副本(Local Replica)和远程副本(Remote Replica),本地副本是指对应的Log分配在当前的broker节点上,远程副本是指对应的Log分配在其他的broker节点上。在Kafka中,同一个分区的信息会存在多个broker节点上,并被其上的副本管理器所管理,这样在逻辑层面每个broker节点上的分区就有了多个副本,但是只有本地副本才有对应的日志。
假设broker0上的副本1为当前分区的leader副本,那么副本2和副本3就是follower副本,整个消息追加的过程可以概括如下:
- 生产者客户端发送消息至leader副本(副本1)中。
- 消息被追加到leader副本的本地日志,并且会更新日志的偏移量。
- follower副本(副本2和副本3)向leader副本请求同步数据。
- leader副本所在的服务器读取本地日志,并更新对应拉取的follower副本的信息。
- leader副本所在的服务器将拉取结果返回给follower副本。
- follower副本收到leader副本返回的拉取结果,将消息追加到本地日志中,并更新日志的偏移量信息。
之后follower副本(不带阴影的方框)向leader副本拉取消息,在拉取的请求中会带有自身的LEO信息,这个LEO信息对应的是FetchRequest请求中的fetch_offset。leader副本返回给follower副本相应的消息,并且还带有自身的HW信息。
在一个分区中,leader副本所在的节点会记录所有副本的LEO,而follower副本所在的节点只会记录自身的LEO,而不会记录其他副本的LEO。对HW而言,各个副本所在的节点都只记录它自身的HW。
Leader Epoch的介入
如果leader副本发生切换,那么同步过程又该如何处理呢?在0.11.0.0版本之前,Kafka使用的是基于HW的同步机制,但这样有可能出现数据丢失或leader副本和follower副本数据不一致的问题。
Kafka从0.11.0.0开始引入了leader epoch的概念,在需要截断数据的时候使用leader epoch作为参考依据而不是原本的HW。leader epoch代表leader的纪元信息(epoch),初始值为0。每当leader变更一次,leader epoch的值就会加1,相当于为leader增设了一个版本号。与此同时,每个副本中还会增设一个矢量<LeaderEpoch=>StartOffset>,其中StartOffset表示当前LeaderEpoch下写入的第一条消息的偏移量。每个副本的Log下都有一个leader-epoch-checkpoint文件,在发生leader epoch变更时,会将对应的矢量对追加到这个文件中,所以我们可以将OffsetsForLeaderEpochRequest的请求看作用来查找follower副本当前LeaderEpoch的LEO。
为什么不支持读写分离
在Kafka中,生产者写入消息、消费者读取消息的操作都是与leader副本进行交互的,从而实现的是一种主写主读的生产消费模型。数据库、Redis等都具备主写主读的功能,与此同时还支持主写从读的功能,主写从读也就是读写分离,为了与主写主读对应,这里就以主写从读来称呼。Kafka并不支持主写从读,这是为什么呢?
主写从读有2个很明显的缺点:
- 数据一致性问题。数据从主节点转到从节点必然会有一个延时的时间窗口,这个时间窗口会导致主从节点之间的数据不一致。某一时刻,在主节点和从节点中A数据的值都为X,之后将主节点中A的值修改为Y,那么在这个变更通知到从节点之前,应用读取从节点中的A数据的值并不为最新的Y,由此便产生了数据不一致的问题。
- 延时问题。类似Redis这种组件,数据从写入主节点到同步至从节点中的过程需要经历网络→主节点内存→网络→从节点内存这几个阶段,整个过程会耗费一定的时间。而在Kafka中,主从同步会比 Redis 更加耗时,它需要经历网络→主节点内存→主节点磁盘→网络→从节点内存→从节点磁盘这几个阶段。对延时敏感的应用而言,主写从读的功能并不太适用。
主读从写可以均摊一定的负载却不能做到完全的负载均衡,比如对于数据写压力很大而读压力很小的情况,从节点只能分摊很少的负载压力,而绝大多数压力还是在主节点上。而在Kafka中却可以达到很大程度上的负载均衡,而且这种均衡是在主写主读的架构上实现的。
在实际应用中,配合监控、告警、运维相结合的生态平台,在绝大多数情况下Kafka都能做到很大程度上的负载均衡。总的来说,Kafka 只支持主写主读有几个优点:可以简化代码的实现逻辑,减少出错的可能;将负载粒度细化均摊,与主写从读相比,不仅负载效能更好,而且对用户可控;没有延时的影响;在副本稳定的情况下,不会出现数据不一致的情况。为此,Kafka 又何必再去实现对它而言毫无收益的主写从读的功能呢?这一切都得益于 Kafka 优秀的架构设计,从某种意义上来说,主写从读是由于设计上的缺陷而形成的权宜之计。(只供参考吧,也是作者的一家之言,对于这段话保持谨慎态度)
日志同步机制
在分布式系统中,日志同步机制既要保证数据的一致性,也要保证数据的顺序性。虽然有许多方式可以实现这些功能,但最简单高效的方式还是从集群中选出一个leader来负责处理数据写入的顺序性。只要leader还处于存活状态,那么follower只需按照leader中的写入顺序来进行同步即可。
在Kafka中动态维护着一个ISR集合,处于ISR集合内的节点保持与leader相同的高水位(HW),只有位列其中的副本(unclean.leader.election.enable配置为false)才有资格被选为新的 leader。写入消息时只有等到所有 ISR 集合中的副本都确认收到之后才能被认为已经提交。位于 ISR 中的任何副本节点都有资格成为 leader,选举过程简单(详细内容可以参考6.4.3节)、开销低,这也是Kafka选用此模型的重要因素。Kafka中包含大量的分区,leader副本的均衡保障了整体负载的均衡,所以这一因素也极大地影响Kafka的性能指标。
另外,一般的同步策略依赖于稳定的存储系统来做数据恢复,也就是说,在数据恢复时日志文件不可丢失且不能有数据上的冲突。不过它们忽视了两个问题:首先,磁盘故障是会经常发生的,在持久化数据的过程中并不能完全保证数据的完整性;其次,即使不存在硬件级别的故障,我们也不希望在每次写入数据时执行同步刷盘(fsync)的动作来保证数据的完整性,这样会极大地影响性能。而 Kafka 不需要宕机节点必须从本地数据日志中进行恢复,Kafka 的同步方式允许宕机副本重新加入ISR集合,但在进入ISR之前必须保证自己能够重新同步完leader中的所有数据。
可靠性分析
就Kafka而言,越多的副本数越能够保证数据的可靠性,副本数可以在创建主题时配置,也可以在后期修改,不过副本数越多也会引起磁盘、网络带宽的浪费,同时会引起性能的下降。一般而言,设置副本数为3即可满足绝大多数场景对可靠性的要求,而对可靠性要求更高的场景下,可以适当增大这个数值,比如国内部分银行在使用 Kafka 时就会设置副本数为 5。与此同时,如果能够在分配分区副本的时候引入基架信息(broker.rack 参数),那么还要应对机架整体宕机的风险。
仅依靠副本数来支撑可靠性是远远不够的,大多数人还会想到生产者客户端参数 acks。在2.3节中我们就介绍过这个参数:相比于0和1,acks=-1(客户端还可以配置为all,它的含义与-1一样,以下只以-1来进行陈述)可以最大程度地提高消息的可靠性。
对于ack=-1的配置,生产者将消息发送到leader副本,leader副本在成功写入本地日志之后还要等待 ISR 中的 follower 副本全部同步完成才能够告知生产者已经成功提交,即使此时leader副本宕机,消息也不会丢失,同样对于acks=-1的配置,如果在消息成功写入leader副本之后,并且在被ISR中的所有副本同步之前leader副本宕机了,那么生产者会收到异常以此告知此次发送失败。
有些发送异常属于可重试异常,比如 NetworkException,这个可能是由瞬时的网络故障而导致的,一般通过重试就可以解决。对于这类异常,如果直接抛给客户端的使用方也未免过于兴师动众,客户端内部本身提供了重试机制来应对这种类型的异常,通过retries 参数即可配置。默认情况下,retries参数设置为0,即不进行重试,对于高可靠性要求的场景,需要将这个值设置为大于 0 的值,在 2.3 节中也谈到了与 retries 参数相关的还有一个retry.backoff.ms参数,它用来设定两次重试之间的时间间隔,以此避免无效的频繁重试。在配置retries和retry.backoff.ms之前,最好先估算一下可能的异常恢复时间,这样可以设定总的重试时间大于这个异常恢复时间,以此来避免生产者过早地放弃重试。如果不知道 retries 参数应该配置为多少,则可以参考KafkaAdminClient,在 KafkaAdminClient 中retries参数的默认值为5。
注意如果配置的retries参数值大于0,则可能引起一些负面的影响。 首先同2.3节中谈及的一样,由于默认的max.in.flight.requests.per.connection参数值为5,这样可能会影响消 息的顺 序 性,对 此要 么放弃 客 户端内 部的 重试功 能,要么 将max.in.flight.requests.per.connection参数设置为1,这样也就放弃了吞吐。其次,有些应用对于时延的要求很高,很多时候都是需要快速失败的,设置retries> 0会增加客户端对于异常的反馈时延,如此可能会对应用造成不良的影响。
我们回头再来看一下acks=-1的情形,它要求ISR中所有的副本都收到相关的消息之后才能够告知生产者已经成功提交。试想一下这样的情形,leader 副本的消息流入速度很快,而follower副本的同步速度很慢,在某个临界点时所有的follower副本都被剔除出了ISR集合, 那么ISR中只有一个leader副本,最终acks=-1演变为acks=1的情形,如此也就加大了消息丢失的风险 。Kafka也考虑到了这种情况,并为此提供了 min.insync.replicas参数(默认值为1)来作为辅助(配合acks=-1来使用),这个参数指定了ISR集合中最小的副本数,如果不满足条件就会抛出NotEnoughReplicasException或NotEnoughReplicasAfterAppendException。在正常的配置下,需要满足副本数 > min.insync.replicas参数的值。一个典型的配置方案为:副本数配置为 3,min.insync.replicas 参数值配置为2。注意 min.insync.replicas参数在提升可靠性的时候会从侧面影响可用性。
与可靠性和ISR集合有关的还有一个参数—unclean.leader.election.enable。这个参数的默认值为false,如果设置为true就意味着当leader下线时候可以从非ISR集合中选举出新的 leader,这样有可能造成数据的丢失。如果这个参数设置为 false,那么也会影响可用性,非ISR集合中的副本虽然没能及时同步所有的消息,但最起码还是存活的可用副本。随着Kafka 版本的变更,有的参数被淘汰,也有新的参数加入进来,而传承下来的参数一般都很少会修改既定的默认值,而unclean.leader.election.enable 就是这样一个反例,从0.11.0.0版本开始,unclean.leader.election.enable 的默认值由原来的true 改为了false,可以看出Kafka的设计者愈发地偏向于可靠性的提升。
在broker端还有两个参数log.flush.interval.messages和log.flush.interval.ms,用来调整同步刷盘的策略,默认是不做控制而交由操作系统本身来进行处理。同步刷盘是增强一个组件可靠性的有效方式,Kafka 也不例外,但笔者对同步刷盘有一定的疑问—绝大多数情景下,一个组件(尤其是大数据量的组件)的可靠性不应该由同步刷盘这种极其损耗性能的操作来保障,而应该采用多副本的机制来保障。
enable.auto.commit 参数的默认值为 true,即开启自动位移提交的功能,虽然这种方式非常简便,但它会带来重复消费和消息丢失的问题,对于高可靠性要求的应用来说显然不可取,所以需要将enable.auto.commit 参数设置为 false 来执行手动位移提交。在执行手动位移提交的时候也要遵循一个原则:如果消息没有被成功消费,那么就不能提交所对应的消费位移。对于高可靠要求的应用来说,宁愿重复消费也不应该因为消费异常而导致消息丢失。有时候,由于应用解析消息的异常,可能导致部分消息一直不能够成功被消费,那么这个时候为了不影响整体消费的进度,可以将这类消息暂存到死信队列中,以便后续的故障排除。
Kafka应用
命令行工具
消费组管理
在Kafka中,我们可以通过kafka-consumer-groups.sh脚本查看或变更消费组的信息。我们可以通过 list 这个指令类型的参数来罗列出当前集群中所有的消费组名称,示例如下(这个功能对应KafkaAdminClient中的listConsumerGroups()方法):
kafka-consumer-groups.sh脚本还可以配合describe这个指令类型的参数来展示某一个消费组的详细信息,不过要完成此功能还需要配合 group 参数来一同实现,group 参数用来指定特定消费组的名称。
消费位移管理
kafka-consumer-groups.sh 脚本还提供了重置消费组内消费位移的功能,具体是通过reset-offsets这个指令类型的参数来实施的,不过实现这一功能的前提是消费组内没有正在运行的消费者成员。可以通过将–all-topics 修改为–topic 来实现更加细粒度的消费位移的重置,all-topics 参数指定了消费组中所有主题,而 topic 参数可以指定单个主题,甚至可以是主题中的若干分区。
Kafka Connect
Kafka Connect是一个工具,它为在Kafka和外部数据存储系统之间移动数据提供了一种可靠的且可伸缩的实现方式。Kafka Connect可以简单快捷地将数据从Kafka中导入或导出,数据范围涵盖关系型数据库、日志和度量数据、Hadoop 和数据仓库、NoSQL 数据存储、搜索索引等。相对于生产者和消费者客户端而言,Kafka Connect省掉了很多开发的工作,尤其是编码部分,这使得应用开发人员更容易上手。
Kafka Connect有两个核心概念:Source和Sink。Source负责导入数据到Kafka,Sink负责从Kafka导出数据,它们都被称为Connector(连接器)。
Kafka Mirror Maker
Kafka Mirror Maker是用于在两个集群之间同步数据的一个工具,其实现原理是通过从源集群中消费消息,然后将消息生产到目标集群中,也就是普通的生产和消费消息。如果了解RabbitMQ,那么会发现这个工具和RabbitMQ中的数据迁移插件Federation/Shovel的实现原理如出一辙。用户只需要在启动Kafka Mirror Maker时指定一些简单的消费端和生产端配置就可以实现准实时的数据同步。
注意,不要在单个集群的内部使用Kafka Mirror Maker,否则会循环复制。如果在配置文件consumer.properties中配置的bootstrap.servers和在配置文件producer.properties中配置的bootstrap.servers的broker节点地址列表属于同一个集群,启动Kafka Mirror Maker之后,只要往主题topic-mirror中输入一条数据,那么这条数据会在这个主题内部无限循环复制,直至Kafka Mirror Maker关闭。
Kafka Streams
Kafka 一直被认为是一个强大的消息中间件,它实现了高吞吐、高可用和低延时的消息传输能力,这让它成为流式处理系统中完美的数据来源。目前通用的一些流式处理框架如Apache Spark、Apache Flink、Apache Storm等都可以将Kafka作为可靠的数据来源。但遗憾的是,在0.10.x版本之前,Kafka还并不具备任何数据处理的能力,但在此之后,Kafka Streams应运而生。
Kafka Streams是一个用于处理和分析数据的客户端库。它先把存储在Kafka中的数据进行处理和分析,然后将最终所得的数据结果回写到Kafka 或发送到外部系统。它建立在一些非常重要的流式处理概念之上,例如适当区分事件时间和处理时间、窗口支持,以及应用程序状态的简单(高效)管理。同时,它也基于Kafka中的许多概念,例如通过划分主题进行扩展。此外,由于这个原因,它作为一个轻量级的库可以集成到应用程序中。这个应用程序可以根据需要独立运行、在应用程序服务器中运行、作为Docker容器,或者通过资源管理器(如Mesos)进行操作。
Kafka监控
监控数据的来源
要实现一个自定义的 Kafka 监控系统,首先得知道从哪里获取监控指标。Kafka 自身提供的监控指标(包括broker和主题的指标,而集群层面的指标可以通过各个broker的指标值累加来获得)都可以通过JMX(Java Managent Extension,Java管理扩展)来获取,在使用JMX之前需要确保Kafka开启了JMX的功能(默认关闭)。Kafka在启动时需要通过配置JMX_PORT来设置JMX的端口号并以此来开启JMX的功能。
获取监控指标
通过JMX可以获取Kafka自身提供的运行状态指标,不过一些配置类信息(如各个broker的IP地址、端口号、JMX端口号、AR信息和ISR信息等)一般无法通过JMX来获取。对于broker的IP地址之类的信息,我们可以通过手动配置的方式来将其加入监控系统,但对于AR和 ISR 信息之类的信息,我们难以通过手动的方式来解决,这里可以借助 Kafka 连接的ZooKeeper来实现这些信息的获取,比如前面提及的/brokers/ids/<brokerId>节点。
消费滞后
消息堆积是消费滞后(Lag)的一种表现形式,消息中间件中留存的消息与消费的消息之间的差值即为消息堆积量,也称为消费滞后(Lag)量。对Kafka的使用者而言,消费Lag是他们非常关心的一个指标。
同步失效分区
消费Lag是Kafka的普通使用者特别关心的一项指标,而同步失效分区(under-replicated)的多少是Kafka运维人员非常关心的一项指标。在8.1.1节中我们了解了失效副本的概念:处于同步失效或功能失效(比如处于非活跃状态)的副本统称为失效副本。而包含失效副本的分区也就称为同步失效分区。
通常情况下,在一个运行状况良好的Kafka集群中,失效分区的个数应该为0。Kafka本身提供了一个相关的指标来表征失效分区的个数,即UnderReplicatedPartitions,可以通过JMX访问来获取其值。
如果集群中存在broker的UnderReplicatedPartitions频繁变动,或者处于一个稳定的大于0的值(这里特指没有broker下线的情况)时,一般暗示集群出现了性能问题,通常这类问题很难诊断,不过我们可以一步将问题的范围缩小,比如先尝试确定这个性能问题是否只存在于集群的某个broker中,还是整个集群之上。如果确定集群中所有的under-replicated分区都在单个broker上,那么可以看出这个broker出现了问题,进而可以针对这个单一的broker做专项调查,比如操作系统、GC、网络状态或磁盘状态(如iowait、ioutil等指标)。
如果多个broker中都出现了under-replicated分区,则一般是整个集群的问题,但也有可能是单个broker出现了问题,前者可以理解,后者又怎么解释呢?想象这样一种情况,如果某个broker在消息同步方面出了问题,那么其上的follower副本就无法及时有效地与其他broker上的leader副本进行同步,这样一来就出现了多个broker都存在under-replicated分区的现象。有一种方法可以查看是否是单个 broker 的问题(以及是哪个 broker 出现了问题),我们通过kafka-topic.sh脚本可以查看集群中所有的under-replicated分区。
集群层面的问题一般也就是两个方面:资源瓶颈和负载不均衡。资源瓶颈指的是broker在某硬件资源的使用上遇到了瓶颈,比如网络、CPU、I/O等层面。就以I/O而论,Kafka中的消息都是存盘的,生产者线程将消息写入leader副本的性能和I/O有着直接的关联,follower副本的同步线程及消费者的消费线程又要通过 I/O 从磁盘中拉取消息,如果 I/O 层面出现了瓶颈,那么势必影响全局的走向,与此同时消息的流入/流出又都需要和网络打交道。笔者建议硬件层面的指标可以关注CPU的使用率、网络流入/流出速度、磁盘的读/写速度、iowait、ioutil等,也可以适当地关注下文件句柄数、Socket句柄数及内存等方面。
高级应用
过期时间(TTL)
我们在讲述消费者拦截器用法的时候就使用了消息TTL(Time To Live,过期时间),通过消息的timestamp字段和ConsumerInterceptor接口的onConsume()方法来实现消息的TTL功能。消息超时之后不是只能如案例中的那样被直接丢弃,因为从消息可靠性层面而言这些消息就丢失了,消息超时可以配合死信队列使用,这样原本被丢弃的消息可以被再次保存起来,方便应用在此之后通过消费死信队列中的消息来诊断系统的运行概况。
这里还可以沿用消息的timestamp字段和拦截器ConsumerInterceptor接口的onConsume()方法,不过我们还需要消息中的headers字段来做配合。我们可以将消息的TTL的设定值以键值对的形式保存在消息的 headers 字段中,这样消费者消费到这条消息的时候可以在拦截器中根据 headers 字段设定的超时时间来判断此条消息是否超时,而不是根据原先固定的EXPIRE_INTERVAL值来判断。
我们可以自定义实现 Headers 和 Header 接口,但这样未免过于烦琐,这里可以直接使用Kafka 提供的实现类org.apache.kafka.common.header.internals.RecordHeaders 和org.apache.kafka.common.header.internals.RecordHeader。这里只需使用一个Header,key可以固定为“ttl”,而value用来表示超时的秒数,超时时间一般用Long类型表示,但是RecordHeader中的构造方法RecordHeader(String key,byte[]value)和value()方法的返回值对应的value都是byte[]类型。
到目前为止,无论固定消息TTL,还是自定义消息TTL,都是在消费者客户端通过拦截器来实现的,其实这个功能也可以放在Kafka服务端来实现,而且具体实现也并不太复杂。不过这样会降低系统的灵活性和扩展性,并不建议这么做,通过扩展客户端就足以应对此项功能。
死信队列和重试队列
由于某些原因消息无法被正确地投递,为了确保消息不会被无故地丢弃,一般将其置于一个特殊角色的队列,这个队列一般称为死信队列。后续分析程序可以通过消费这个死信队列中的内容来分析当时遇到的异常情况,进而可以改善和优化系统。
死信可以看作消费者不能处理收到的消息,也可以看作消费者不想处理收到的消息,还可以看作不符合处理要求的消息。比如消息内包含的消息内容无法被消费者解析,为了确保消息的可靠性而不被随意丢弃,故将其投递到死信队列中,这里的死信就可以看作消费者不能处理的消息。再比如超过既定的重试次数之后将消息投入死信队列,这里就可以将死信看作不符合处理要求的消息。