《深入理解kafka》-总结

223 阅读18分钟

初始kafka

kafka的作用

kafka各个名词的概念

  1. Producer 生产者,生产对应分区对应主题中的消息
  2. Consumer 消费者,消费kafka主题中的消息
  3. broker kafka的服务代理节点
  4. zookeeper 用来存储kafka的集群数据
  5. 主题和分区 主题是逻辑上的概念,分区只能在某个主题下面,分区在各个broker散列分布,分区本质可以看成一些有主从关系的可追加的日志文件
  6. 可追加日志文件 这些可最追加的日志文件散列在各个broker中, 消息被追加到分区日志文件的时候会分配一个特定的偏移量(offset)
  7. offset offset在分区中是唯一且不会跨越分区,然后所以kafka在分区之内消息是有序的,而主题来看各个分区的消息是无序的
  8. 副本因子,也就是副本的数量比如1个leader 2个follower那么副本因子就是3
  9. leader 一般生产者和消费者只和leader进行交互
  10. follower 一般和leader进行消息的同步,所以很多时候多个follower的同步进度是不同的
  11. AR(Assign Replicas)也就是各个副本的总称
  12. ISR(in-sync Replicas) 就是follower相对leader同步滞后在配置区间范围内的副本,当所有同步都在范围内那么AR = ISR
  13. OSR(out-sync Relicas)和ISR相对是同步滞后超出范围
  14. HW(High Watermask) 高水位,也就是一个分区中各个副本同步的offset的位置
  15. LSO(LogStartOffset) 日志文件其实的offset位置
  16. LEO(LogEndOffset) 日志文件最新消息+1的offset位置(也就是消息要写入的位置)
  17. 副本之间的运作 leader要负责对ISR中所有副本的同步情况,当某个滞后太多就会将它从ISR集合中移除到OSR中,当OSR中的副本跟上的时候又会移入到ISR中,当leader故障的时候,会从ISR中选举出一个副本作为新的leader

生产者

生产者客户端

创建

对于java的客户端来讲,生产者创建就是构建KafkaProducer类,该类要传递一个Properties (java中类似map的key/value)的配置类给构造函数,该配置类可以配置很多生产者的一些参数,作为必要的3个参数就是

  1. bootstrap.servers kafka的broker的地址清单,多个用,号隔开,对于集群kafka来说其实生产者不用将全部broker都配置上去,配置一部分就行,生产者会定时的去连接kafka集群拿去集群的元数据来进行同步
  2. key.serializer key序列化的类,值要包名+类名
  3. value.serializer value序列化的类,值要包名+类名

消息发送

消息发送就是构造ProducerRecord类,对应消息的分区,主题,key, value等数据,kafka发送数据都是一个ProducerRecord对象对应一条消息 ,然后KafkaProducer生产客户端发送消息分3种模式

  1. 发后就忘 KafkaProducer.send(ProducerRecord)
  2. 同步 RecordMetadata = KafkaProducer.send(ProducerRecord).get(long, TimeUtil), get方法就会进行一个查询kafka的阻塞操作,本质send是异步的,但是立马会执行查询然后就会阻塞,可以自定义一个超时时间
  3. 异步 KafkaProducer.send(ProducerRecord, Callback)异步会回调Callback的onCompletion(RecordMetadata, Exception)其中2个入参就是返回的数据和对应的异常信息

对于同步和异步都会返回异常信息,其中有2种异常一种是可重试异常一种是不可重试异常,对于重试异常kafka生产者根据生产者配置的最大重试次数进行重试

对于异步的方法来说,如果是对同一个分区进行写入消息,对应的callback可会有序的进行回调

序列化

序列化对应kafka的Serialier接口,只要实现了接口的类都可以作为kafka的序列化的类,configure是作为生产客户端创建时候触发的,用来配置kafka生产客户端的key.serializer.encoding这个配置的值的,一般用户不会配置生产客户端的encoding, 所以基本都是UTF-8, 而serialize是作为序列化调用的方法,close根据情况关闭一些资源

分区器

分区器对应kafka的Partitoner接口,分区器是用来对数据进行计算其区号的,Partitioner接口还有一个父接口Configurable里面有个configure用来初始化配置信息,对于默认的分区器来说,key为null的时候消息会轮询的发往各个可用分区

拦截器

拦截器对应kafka的ProducerInterceptor接口,2个主要方法一个是消息发送的时候可以对消息进行更改,一个是消息在ack之前会进行调用,所以该方法最好性能要好,然后方法调用是在send的callback之前调用

整体架构分析

整体生产者分为2个线程协作运行,主线程负责创建消息并将消息缓存到累加器(RecordAecumulator)中,而sender线程负责将累加器中的消息发送到kafka中

累加器为了提升性能,会将消息进行批量的维护,然后又sender线程批量发送,然后累加器的缓存大小可以进行配置,当缓存空间不足的时候会send方法会被阻塞,当超过一个配置的超时时间的时候会抛出异常

累加器会为每个分区创建一个双端队列,一端用来塞入消息,一端用来给sender获取消息来进行发送,队列中的内容是ProducerBatch, ProducerBatch中可以包含一个或多个ProducerRecord

在kafka生产客户端中有个byteBufferPool用来对实时消息进行缓存,但是缓存的大小是配置固定的,一般一条消息过来大小不超过配置的大小,那么ProducerBatch会以配置的大小进行配置,如果过大就会自行创建,java内存复用本质就是减少gc而已

sender从累加器中获取消息后会将各个分区的ProducerBatch进行一个从新适配用来对应各个kafka的node节点,应为对于sender来说他是和broker来进行交互,所以要对应分区(逻辑)到具体的节点、

当sender线程发往kafka之前会将请求和节点id对应的map保存到InFlightRequests中,这样的主要目的是缓存未响应请求的消息和知道目前各个节点未响应的数据有多少,然后可以根据配置进行设置超过多少未响应不进行继续向节点进行发送了

生产客户端可以进行确认那个节点负载小,最小负载的节点一般可以用来同步元数据,或者和消费组协议进行交互

消费者

消费组和消费者

由图可知,kafka中不会有多个消费者消费同一个分区(后面会知道客户端代码中会进行是否单线程判断,因为客户端不是线程安全的),kafka消费者变多后,分区会被各个消费者平分,多出的消费者不会有分区分配

对于消息中间件无非2种模式

  1. p2p的队列模式 一条消息只能被一个客户端消费
  2. 发布/订阅模式 一条消息能被多个客户端同时消费

综上,kafka就有一个逻辑上消费组的概念,消费组消费主题中所有分区(p2p),而多个消费组之间同时进行消费(发布/订阅),消费组中的消费者平分主题中的分区

客户端开发

java中3步进行创建,构建Properties的key/value配置类,然后传入到KafkaConsumer的构造函数中,调用subcribe订阅对应主题

properties中有几个key是必选项

  1. bootstrap.servers 和生产者差不多,是broker的部分地址清单
  2. group.id 所属的消费组的id
  3. key.deserializer和value.deserializer 用来对应生产者中用的序列化,真实中最好用kafka中提供的序列化工具

订阅主题

对于订阅主题,多个主题放入一个list中就行,调用多次订阅会取最后一次调用的主题列表,然后主题订阅可以用正则表达式的形式进行,这样有时候增加主题不用修改代码

订阅主题分区

取消订阅对应unsubscribe方法,对应重新调用subscribe传入空集合也是可以取消订阅

获取主题分区

  1. leader代表leader是否再这个分区
  2. replicas代表AR的集合
  3. inSyncReolicas代表ISR的集合
  4. offlineReplicas代表OSR集合

反序列化

反序列化和序列化一样要实现接口(Deserializer),该接口对应3个方法,然后也是要给prop配置对应的key和value

消费消息

对于分区假如拉取的结果为空,那么返回的就是空的集合,然后这里可以配置超时时间

  1. offset代表消息偏移量
  2. timestamp代表时间戳
  3. timestampType代表时间戳的类型,创建时间戳(生产者设置的)还是追加时间戳(kafka设置的)
  4. headers代表消息头部内容(好像是v2版本才有)
  5. serializedKey|ValueSize代表序列化key和value的大小,key为空ValueSize= -1 value为空ValueSize=-1、

poll返回的是一个消息集,内部会提供一个iterator的方法来遍历循环集合的消息,然后还提供了records的方法来根据分区返回对应的消息列表, 当然也可以通过重载方法根据主题获取消息集合

位移提交

同步

直接提交对应poll的位移


指定分区提交对应的位移

异步

为了防止消费者宕机新消费者连接上broker的时候消息重复和丢失偏移量,kafka会把偏移量存储在内部主题_consumer_offsets中,然后对于偏移量有3个概念

  1. lastConsumedOffset 就是消费者最后消费的消息的offset
  2. committed offset 消费者提交的消息的位置,一般都是lastConsumedOffset + 1
  3. position 也就是要写入消息的位置

在kafka中一般消费位移提交是自动的,当然可以配置enable.auto.commit来进行配置不自动提交,默认是消费者没5秒会拉取每个分区最大消息位移进行提交,提交的动作在poll方法的逻辑中完成

漏消息就是在一个线程拉去消息分给多个线程在分别处理自己的段的时候,某一段优先处理完,提交了位移,但是突然其他线程崩溃,导致最后消息漏掉了(这种情况推荐用滑动窗口的形式进行段的分配,当完成段是连接的就提交,中间有空隙就等待完成)

重复消息本质差不多就是处理到一半,消息位移还没提交就崩溃了(这种情况最好是让处理过程能有幂等性)

控制关闭分区

控制某个分区进行暂停操作


关闭资源

指定位移消费

对于配置auto.offset.reset有'none', 'latest', 'earliest',粗略的让消费者从开头或者结尾进行开始消费 ,对于希望从特定位置开始消费,客户端提供了seek()函数,对于未分配到的分区执行seek方法会报illegalStateException异常,比如在订阅后面立马调用seek()

客户端还提供了,endOffset和beginOffsets来提供获取offset,对于beginingOffset开始不一定从0开始,在消费一段时间后beginingOffset可能从1000开始,然后offsetForTimes可以获取大于某段时间之后的offsets

再均衡监听器

对于subscribe的订阅方法可以传递一个监听器ConsumerRebalanceListener,该监听器是个接口,有2个方法,基本上来讲,2个方法分别要实现一个提交位移,一个给均衡后的消费者设置新的位移

消费者拦截器

ConsumerLinterceptor接口

onConsume()是再poll方法返回之前调用, 而onCommit()是在提交位移记录后触发

多线程实现

对于客户端KafkaConsumer是非线程安全的,在内部会有一个原子long属性和ThreadLocal中存储的long(thread的id)来进行对比,假如不一样会报错,也就是说只允许单线程,虽然只支持单线程,但是我们可以有几种方案来多线程操作

  1. 多个线程对应多个分区
  2. 多个线程对应一个分区
  3. 单个线程消费多个分区,分多个子线程异步处理

对于第一种基本没问题,只是比较占用io和socket,对于第二种,内部实现提交位移和顺序问题会很复杂,第三种可以利用滑动窗口的形式完美解决问题

主题和分区

总结

主题和分区在Kafka中是个逻辑上的概念,本质是磁盘上的日志文件夹和文件还有处理程序构成,然后在ZooKeeper上会键值的形式存储对应主题和分区的情况,对于各种对于主题和分区操作的工具类本质是对zookeeper进行操作,然后kafka会有个监听线程来监听zookeeper来创建对应的初始日志文件,然后对于分区的修改只能进行增加,而不能进行减少,因为减少会带来很多问题,实现成本很大,还不如再创建个主题

对于分区的数量是有限制的,每个分区都有对应的log文件,然后linux有文件描述符的限制,然后对于分区来说不是越多越好,在某些固定的硬件和消息大小情况下,超过一定分区的数量,性能会急剧下降,所以对应分区的大小要用测试工具具体分析,然后最好分区数量是broker的倍数

日志存储

目录结构

kafka的主题包含三种文件,日志,索引,时间索引,文件名都是以偏移量来命名,然后kafka还会创建一些临时文件,比如用来记录客户端的提交位移(_consumer_offset),一些检查点文件(xxx-checkpoint),还有meta.properties(元数据文件)

在kafka中日志文件被分割成多个LogSegment,LogSegment其实就是多个offset.log文件,对于能写入的只有最后一个LogSegment, 当LogSegment的内容达到一定条件就会创建新的文件

kafka的日志文件过程

kafka的消息格式经历了3个版本,v0,v1,v2

v0

  1. crc32 用来校验,范围是magic和value之间
  2. magic 消息的版本好
  3. attribtues 消息的属性,比如低三位代表压缩类型,0-NONE, 1-GZIP, 2-SNAPPY
  4. key length 标识消息的key长度
  5. key 可选
  6. value length 消息体的长度
  7. value 消息体,可以为空
  8. offset 偏移量
  9. message size 消息大小

对于消息来说最小长度就是除了key和value的总和,当然offset和message size不包含在内(这是kafka用的东西),对于消息小于这个长度的,kafka不会接收

v1

v1版本在此之上增加了timestamp字段,对应attribtues的第4位就用来表示时间是什么类型,0-CreateTime, 1-LogAppendTime,timestamp的类型是broker的参数'log.message.timestamp.type'来配置,本质就是用生产端的时间戳还是kafka的时间戳,对应最小长度也是比v0长timestamp的字节

消息压缩

对于消息压缩,一般来说数据量大压缩效果会更好,kafka的压缩方案一般都是将多条消息进行压缩,通常在生产方发送的压缩数据在broker中也是压缩状态

kafka的压缩方式是将多条消息压缩进一条record中,多个record的offset就赋予到了最外层的消息中,最外层的offset基本都是内层消息的最后一条的offset, 对于外层消息的timestamp来说,如果是createTime模式那么就是内层消息中最大的时间戳,如果是LogAppendTime那么就是kafka的时间戳,对于内层而言,createTime就是生产时间戳,而LogAppendTime就会忽略

v2版本

这里v2版本RecordBatch其实就是对应上面提交的生产者的ProducerBatch, 而ProductRecord就是这里的Record

record

  1. length 总长度
  2. attributes 弃用,但是还会占用
  3. timestamp delta 时间戳增量,这里保存的是batch的增量数据
  4. offset delta 位移增量,和batch的first offset的增量
  5. headers 用来支持应用级别的扩展,而不用像v0和v1一样嵌入value中

batch

  1. first offset 当前batch的其实位置
  2. length 从partition leader epoch到末尾的长度
  3. partition leader epoch 可以看作分区leader的版本号或变更次数
  4. magic 消息版本号
  5. attributes 消息数量第5位表示batch是否在事务中,0-非,1-是,第6位代表是否是控制消息,0-非,1-是
  6. last offset delta batch中的record最后的offset和first offset的差值
  7. first timestamp batch中的record第一个的时间戳
  8. max timestamp btch中最大的时间戳,一般是最后一个record时间戳
  9. producer id:pid 用来支持幂等和事务
  10. producer epoch和id一样用来支持事务和幂等
  11. first sequence 用来支持事务和幂等
  12. records count batch中record的个数
  13. records 内部record

对应的v2版本会用protobuf来进行压缩,对应压缩算法是zigzag和varint,

日志索引

kafka2种日志的索引,偏移量索引和时间戳索引,kafka中用的都是稀疏索引,其实就是偏移量到物理地址之间的映射关系,因为kafka中的文件是以偏移量区间来创建,时间戳就是存储了时间和offset的关系

偏移量索引

索引文件自身是由offset的,内部存储的数据来看只需要存储对应相对偏移量就行了,而position物理地址,也就是日志分段文件对应的物理地址,对于索引来说只能定位到那个日志文件

时间戳索引

  1. timestamp 日志分段最大的时间戳
  2. 时间戳对应的消息相对偏移量

对于时间戳索引日志顺序就有很大关系,LogAppendTime时间戳是单调递增的,而createTime则无法保证,时间戳索引就是根据时间错定位offset,然后根据offset来定位偏移量所以,最后找到日志

日志裂开的条件(裂开=切分)

这里的裂开和切分不是说把一个日志分类切分,而是新的日志放入新的日志文件中

零拷贝

就是直接将磁盘中的数据传递给Socket,这样减少了在用户和内核之间的转换

深入服务端

协议

Request

respone

ProductRequest

ProductResponce

FetchRequest

FetchResponce

kafka中有很多协议,每种协议都是用request进行封装,对应的消息生产的ProduceRequest和消息拉取FetchRequest就是其中的协议模式

时间论

2点,对于kafka自己定义了个时间轮的结构,本质和时钟差不多,比如就有个秒钟,60个槽位,然后每个槽位都代表1s,总共就60s, 每个槽位都可以挂载list列表,时钟每走到哪就代表那个列表的任务到期了,然后新任务添加都是相对当前指向的槽位,当时间超过整个槽位大小,就挂载到上层时钟上,上层时钟就是类似分钟了,每个槽位代表1分钟,由此可见时间轮的本质就是和hash一样o(1)的区间任务定位,带来的好处就是加入任务和删除任务都很快,然后对应任务推进,当然可以直接用时间推进时间轮,这样可能导致cpu空转,比如所有都是在时钟上,秒钟就一直空转,这时候kafka就用java中的定时任务去推时钟,kafka将时间轮上的几个任务取出,挂载到定时任务上,比如将槽位上最小的过期时间的拿出来,这样定时任务可以精准的定位到任务,然后根据这个任务直接跨越式的推进时间论的槽位

控制器

kafka中多个broker会选举出一个控制器,本质就是竞争的去给zookeeper设置/controller的key, 然后还会维护一个/controller_epock来设定最新版本的controller的纪元,从而老纪元的操作就会失效

早期版本中没有Controller的概念的,而是broker各自去监听zookeeper来实现自己的业务,这样太依赖zookeeper了,可能会有脑裂和羊群效应

controller的责任

优雅关闭

对于优雅关闭,使用kafka的关闭的shell命令, 个人理解的化我认为应该不需要,当不优雅关闭都能支持,就代表系统很健壮,所以最好的方式时开发的系统应该支持这种不优雅的形式

分区leader的选举

根据设置的策略,有几种效果

  1. 拿到AR中第一个然后去ISR中找,找到就是leader
  2. 拿到AR中第一个然后直接就可以时leader了

分区重新分配的时候会进行一次重新选举,还有就是优雅下线的时候