目录
序言:
副本机制是分布式系统中最常见的概念,在常见的分布式系统中,为了对外提供高可用的服务,一般都对数据和服务进行副本构建。Kafka对于Topic进行了物理上进行数据切片(Partition分区),而对于每一个分区都为了其高可用,在0.8之后采用了多副本的概念,即一个Partition对应多个副本,其中一个副本为leader, 其它的为follower,典型的主从结构,而与其它分布式系统中主从结构的不同,Kafka中只有leader对外提供服务,而follower只会同步leader数据,而不提供服务,至于为何Kafka这样设计,后文描述,Kafka主要就是通过多副本机制来保证kafka中消息的一致性,以及高可用性。
1:副本物理存储结构
Kafka对于一个Topic中多个Partition会尽量的进行平均分配到整个集群中(通过Kafka分区策略来做到的,通过生产者属性partition .assignment.strategy= org.apache.kafka.clients.cons me.RangeAssignor 关于此可参考kafka分区策略)。这样的好处可以使得整个集群每个broker的消费压力更平均,整体吞吐量更大。而follower作为leader的backup为了保证高可用,follower一般不会和leader分配在一个broker中(除非broker数量小于副本的数量,或者broker)。例如现有3个broker,一个topic有3个分区,每个分区3个副本,最终落地到每个broker为一个partition的leader副本,两个partition的follower副本,对于我们来说这样就是一个最优分区选择。那么对于生产中的partition与副本数的多少我们如何设定(并不是partition越多,性能越高),可以通过对于单broker通过Kafka提供的压测命令来进行测试(Kafka本身提供的用于生产者性能测试kafka-producer-perf-test.sh 和用于消费者性能测试的 kafka-consumer-perf-test.sh要结合生产数据量以及消费能力来决定),获取最优的分区数(普遍发现一个partition数大约在50左右时可以获得最大吞吐量,这也是为何对于kafka中内部__consumer_offsets与consume-transform-produce默认的partition数为50)。一个Partition被创建消费之后那么在磁盘中存储的结构如下所示:
- .log后缀中存储的为kafka中的数据,文件名为当前segment的起始的BaseOffset的偏移量。对于该文件数据格式随着kafka版本的不同,也有着非常大的变化经历过vO 版本,vl版本,v2版本,这里不详细介绍。
- .index中存储的是逻辑上的offset与物理存储位置的映射,格式如图所示:
其中relativeOffset占用4字节,为相对于BaseOffset的偏移量,索引文件名为当前segment的起始的BaseOffset的偏移量,position记录为log文件中实际的物理地址. - .timeindex中存储的是消息生产的时间戳的与物理存储位置的映射,格式如图所示:
timestamp通过8字节记录该message对应的timestamp(后面的timestamp必须要大于之前的timestamp),每个后面索引项对应的timestamp必须要大于timestamp否则不予追加,如果broker端参数log.message.timestamp.type设置为LogAppendTime ,那么消息的时间戳必定能够保持单调递增,如果是createTime 类型则无法保证,而relativeOffset记录为相对于BaseOffset的偏移 -
leader-epoch-checkpoint该文件记录了该分区中leader副本中每一任epoch的起始的LEO(关于LEO-记录该副本中最新的message的offset下一条,HW-高水位如果为leader记录该分区ISR每个副本最少的LEO数,LW-低水位记录AR中最少的LEO数),该值是为了后续leader副本挂掉后,follower副本升级为leader后保证该分区数据一致性.格式如下所示:
对于该broker中每一个partition的每一个副本的LEO,HW都会最终落地到磁盘进行数据持久。当服务重新启动时,follower依赖这些数据重新去集群中该分区leader副本拉取数据,尽量早点重入ISR列表
- recovery-point-offset-checkpoint:用于记录broker内所有分区中LEO(LogEndOffset),broker中存在一个定时任务将各分区中LEO刷新磁盘文件中,定时任务由log.flush.offset.checkpoint.interval.ms 来配置,默认值为 60000
- replication-offset-checkpoint:分区内所有HW数据的落地到该文件中(也是通过定时任务来进行落地,周期由replica.high.watermark .checkpoint.interval.ms 来配置,默认值为 5000 )
- log-start-offset-checkpoint:用于记录该对应 logStartOffset,它用来标识日志的起始偏移量
2:AR,ISR,OR列表
AR,ISR,OR这三个列表都是针对kafka中每一个topic下的partition来说,即每个partition都有这三个概念。
AR列表:Assigned Replicas 列表,该列表由分区中所有的副本构成,至于分配到哪个broker由生产者进行指定(可手动分配,也可以交由kafka提供的算法指定,生产者配置中partition.assignment.strategy属性),由kafka leader在收到分区属性后进行统一创建,并写入到zookeeper中,也通过添加不同的watch进行监控(例如监控分区变化的的PartitionReassignmentHandler,IsrChangeNotificetionHandl处理ISR列表中列表变化事件,监控Topic变化的TopicChangeHandl事件等等,当发生这些变化时也由broker leader发起更新集群元数据的动作)ISR列表:ISR-In-Sync-Replicas该列表由leader副本与和leader中offset相差不多的follwer构成。那么如何知道哪些副本应该存在ISR列表,哪些要被移出。对于ISR列表的管理, Kafka 0.9.x 版本开始就通过唯 broker 端参数 replica.lag.time.max.ms来抉择 ( 默认为10000ms,在0.9x之前还包含另外的参数replica.lag.max.messages通过message数量的角度来进行,但这样会存在一个问题,即如何判定该值,该值过大可能存在丢数据,而过小会导致ISR列表一直处于伸缩的状态,对zookeeper以及kafka整体的性能有差的影响,所以在0.9x之后该值就被永久移除),当 ISR 集合中的 follower 副本滞后 leader 本的时间超过此参数指定的值时则判定为同步失败,需要将此 follower副本剔除出 ISR 集合。那么由谁来进行定时检测呢,kafka的broker在启动的时候会启动两个定时任务, isr-expiration 和 isr-change-propagation,isr-expiration会定时(该值为replica.lag.time.max.ms一般默认5000ms)来检测,当前时间与该副本的最近一次同步的时间lastCaughtUpTimeM差是否大于该值,说明ISR发生来变化而当 ISR 集合发生变更时会将变更后的记录缓存到 isrChangeSet 中 而kafka提供了sr-change-propagation来定期(默认2500ms) 来检测isrChangeSet中值。如果发现 isrChangeSet中ISR集合的变更记录,那么它会在 zooKpeer的/isr_change_notification路径下创建一个以 isr_change_开头的持久顺序节点(比如/isr_change_ notification/isr _change_ 0000000000 ,并将isrChangeSet 中的信息保存到这个节点中。kafka leader为/isr_change_notification 添加了 Watcher,当这个节点中有子节点发生变化时会触发 Watcher 的动作,以通知控制器更新相关元数据信息井向它管理的 broker 节点发送更新元数据的请求,最后删除/ isr_change_notification 路径下已经处理过的节点。频繁地触 Watcher 会影响 Kafka 控制器、 ZooKeeper 甚至其 broker 点的性能。为了避免这种情况 Kafka 添加了限定条件,当检测到分区的 ISR 集合发生变化时,还需要检查以下两个条件:
- (1)上一次 IS 集合发生变化距离现在己经超过5s
- (2)上一次写入 ZooKeeper 的时间距离现在已经超过 60s
3:Follower副本与leader副本同步过程
我们知道kafka中leader充当读写的角色,数据的一致性与生产者的acks设置有关,若acks=0时,生产者只否则发送,至于能不能写入到leader中,生产者不关心,这也是异步写入这种模式下kafka的吞吐量最高,当acks=1时消息写入到leader中之后马上返回,不关系follower是否同步leader成功,当acks=-1或all时,message写入到leader之后,leader需要ISR下所有follower全部同步成功后,才会返回leader,这种情况下leader数据的一致性得到了最大的保证,只要写入成功不会出现那么broker就会保证不会丢失数据的情况,但是这其中情况下kafka集群的吞吐量就有了一定损失,关于对acks的配置,与我们需求有一定关系,例如非重要的日志我们可以设置为0,而对于一些交易类型的数据或者通知,我们就需要保证数据的一致,可用性。
注意:上文描述的数据的写入,并不是指数据写入到磁盘中了,而只是写到了page缓存中(关于这可参考博客),而数据从page缓存flush到磁盘的操作,kafka从两个纬度提供不同的选择,log.flush.interval.messages-消息条数,log.flush.interval.ms-时间纬度,来将page缓存中的数据flush到磁盘。一般对于此不建议设置,对于message broker数据的可用性我们可以通过ISR列表来进行保证,即通过此生产者acks来保证,只要ISR列表有一个存在(broker中min.insync.replicas=1来设置)那么该数据就不会丢失。因为每次将数据写入磁盘带来的收益为负数,使得kafka的吞吐量极度下降。
4:为啥kafka对于副本不支持读写分离
一般对于一个由主备构成的集群来说为了追求更高的性能,基本都支持主写备读的功能,例如mysql主备,redis的主备,以及Elasticsearch中分片的主备(主分片与复制分片)。为何kafka确不支持呢。主要还是从付出与收益比来看的,首先kafka本事是一个分布式的存储消息中间件,message通过Partition分片进行存储,而kafka尽量使得这些partition平均分配到不同的broker中,那么对外kafka本身负载能力就强。数据的一致性保证,如果要进行读写分离,那么数据在写入leader之后必须要马上同步到副本中,只有所有的副本写入成功之后才能返回,如果不这样做那么从读库中读取的数据的一致性无法进行保证(Elasticsearch就是这么干的,数据只有所有分片写入成功后才会返回成功-这也是Elasticsearch的模型决定的它是一个多读少写的分布式搜索引擎,一般都是先离线构建索引数据后对外提供查询服务,基于该实际情况那么Elasticsearch的主写副读的收益要远远大于付出。在我们实际应用中mysql是一个一个读多写少的数据服务,而Redis不用说了数据都存储在内存中基本就是读多写少,一般作为缓存库数据丢失也不会有大的影响)。作为一个消息服务中间件,kafka的写与读的请求较为平均一般数据消费完成这条消息对于该Consumer基本不会再次消费了。如果我们每一条数据的写入都需要等待所有副本写入成功(每一头数据的写入存在大量的网络磁盘开销),可想kafka的吐量会极度降低,得到的只是读的一个负载,但是又面临是一个少读的场景,对于Kafka整体的压力的缓解并无帮助,所有的压力还是集中在写的服务中。这种付出与收益不成正比的事情,kafka并不会去做。
后续对于上述实现,通过源码进一步进行分析