kafka,mysql,redis主备方案比较之kafka

1,094 阅读9分钟

架构概述

kafka的架构非常值得学习和借鉴。

kafka本身是主从关系的,broker的角色可以有controller与普通节点的划分,controller节点除了需要承担普通节点的功能,还需要与zookeeper协作管理集群元数据。同时,各个broker上后台线程监测到元数据变化的时候,会通知其他broker,例如lsr收缩,每个broker上也会保存一份元数据,当对元数据的请求到来的时候可以快速响应。

为了水平扩展,kafka中设计partition分区的概念,对于生产者和消费者而言topic是逻辑抽象概念,写入的每条消息都要设定topic,代表一类消息,实际物理上是按照partition来分的,一个topic中分为n个partition,每个partition内采用append的方法高效快速写入消息,用offset来表征消息,每个log segment都由<start offset,文件内地址>的形式构成,同时每个log segment还有对应的索引文件。如果有多个partition,只能保证partition内部的有序性,无法保证topic即partition之间消息的有序性。kafka会保存所有的消息,不管是否消息已经被消费了,这是有别于其他消息中间件的地方,对于数据删除有两种策略,一个是时间,还有一个是文件大小。

采用了partition后类似每个服务实例处理一部分消息,本地存储部分消息,适用于生产者与消费者无需感知消息顺序,且无状态(即某个请求需要获取的状态信息不需要判断在哪台实例上),生产者只需要append,消费者只需要消费即可。

为了保证系统的可靠性,kafka采用了复制备份的策略,每个partition都有备份,提高了可用性的情况下会对吞吐有略微影响。broker上服务起来后,会启动replicaManager管理副本,根据元数据判断broker是follower还是leader,follower副本会不停的向leader副本发送fetch请求,获得response后将records写入本地log并更新LEO和HW来达到同步的目的。在zookeeper会维护一个ISR列表,里面存储的是与leader副本保持同步的follower副本,其中的broker满足两个条件,一个是与zookeeper保持heartbeat,二是落后leader在一定的范围和时间内,这样当leader宕机的时候才能保证从ISR中选举出来的新leader与老leader保持数据一致,同时,可以通过在写入数据的时候配置ack当ISR中的副本也写入消息的时候才算处理完成。选举会单独在另一篇中提及。

副本同步之leader epoch

leader副本中消息分为已经提交消息,未提交消息,HW高水位为已经提交消息的最后一条,LEO为未提交消息的下一条。消费者能够消费到的消息是高水位以内的消息,代表ISR中的副本都已经同步了的消息,可以保证消息不会被丢失,可靠。

旧版本中,follower副本与leader副本保持同步采用了HW高水位;

当最初状态的时候leader副本与follower副本中LEO都为0,HW为0,remote LEO为0;

此时生产者向leader副本写入一条消息,leader副本更新LEO=1,HW=0,remote LEO=0,follower副本LEO=0,HW=0;

follower向leader发送fetch请求且offset=0,leader副本LEO=1,HW=0,remote LEO=0,follower副本LEO=1,HW=0;

follower再向leader发送fetch请求且offset=1,leader副本LEO=1,HW=max(0,1)=1,remote LEO=1,follower副本LEO=1,HW=min(1,1)=1

这是正常的根据HW值来进行同步的流程,可以看到HW的更新需要额外的一次fetch请求,这中间可能出现两种问题。第一种,如果leader副本LEO=1,HW=1,remote LEO=1,follower副本LEO=1,HW=0,即HW还未来得及更新的时候follower副本重启了,重启后follower副本会先进行truncate操作,将LEO截断到0,此时leader副本重启,follower副本成为leader副本,原先的leader副本重启后向原先的follower副本发送fetch请求,即可能造成数据丢失。

第二种,leader副本LEO=1,HW=1,remote LEO=1,follower副本LEO=0,HW=0,此时leader与follower同时挂掉,并且follower先起来成为了leader,生产者向follower写入了一条数据,原先的follower的LEO=1,HW=1,此时原先的leader恢复,HW为1,不进行截断操作,但是follower与leader的数据变得不一致。

由于以上两种数据丢失与数据不一致情况的发生,kafka中采用了新的策略,即leader epoch,用<epoch, epoch first offset>记录在broker的checkpoint文件中。

再来分析第一种数据丢失的情况,leader副本LEO=1,HW=1,<0,0>,follower副本LEO=1,HW=0,此时follower副本重启,leader返回自己的LEO=1且follower中没有任何大于1的消息,因此不作截断,此时leader副本重启,follower成为leader副本,LEO=1,HW=0,当写入消息之后变成<1,2>,offset=1的消息得以保存。

第二种数据不一致的情况,leader副本LEO=1,HW=1,<0,0>,follower副本LEO=0,HW=0,此时leader与follower同时挂掉并且follower掀起来成为leader,且生产者向follower写入消息,follower<1,1>,此时原先的leader副本恢复,向现在的leader获取到epoch=1,而自己的epoch=0,因此截断日志到LEO=0,当原先的leader成为follower向现在的leader发送fetch请求的时候,重新复制offset=1的消息,从而保证了消息的一致。

采用leader epoch来控制是否截断以及截断到何处。

附:源码part

  1. follower副本从leader副本如何拉取消息? 父类:AbstractFetcherThread,dowork方法串联三个方法processPartitionData、truncate、buildFetch,分别的作用是处理fetch请求返回的数据,截断,构造fetch请求

子类:ReplicaFetcherThread,实现了父类的三个方法processPartitionData、truncate、buildFetch

当broker开始运行,会启动replicaManager,其中的dowork方法中调用了maybeTruncate()和maybeFetch(),在线程中执行,会不停的尝试做截断和获取操作,做截断的原因是对于follower副本,在出现新的leader副本的时候,会保持本地日志序列和leader副本一样,leader副本也会在重启后根据leader epoch截断日志。截断操作根据有无leader epoch来区分获取要截断的offset值,然后执行截断操作。然后构造fetch请求,同步阻塞接收leader副本回复,然后处理返回消息,添加records并更新本地的LEO和HW。

上一张大概的图:

  1. 副本如何进行读取与写入? ReplicaManager,在kafka中管理副本与分区
  • 写入 写入appendRecords,kafka副本写入消息的场景有四个(生产者向leader副本写入,follower副本从leader副本fetch消息后写入,消费者组写入组信息,事务管理器写入事务信息),其中1,3,4三个场景下都是用的appendRecords方法,2场景使用的partition的方法。总结来说appendRecords()方法的逻辑是判断requiredAcks是否合法,写入消息到本地,然后根据是否需要所有副本都处理成功来等待,如果无法立刻完成,需要创建延时请求对象交由Purgatory进行管理,即通过tryCompleteElseWatch()方法进行监控。

  • 读取 fetchMessages方法会首先获取消息的范围,根据消息获取方的类型来返回不同的消息,如果是普通消费者返回高水位,如果是follower返回LEO,如果是配置了READ_COMMITED的消费者,返回log stable offset以下的消息,调用log对象的read方法获取本地消息。接下来会构造response,如果以下四条都不满足就构造延时请求交给Purgatory管理,分别是请求没有设置超时时间,未获取任何数据,已经累积足够多的数据,读取过程中出错。

  • 分区与副本管理

private val allPartitions = new Pool[TopicPartition, HostedPartition](
    valueFactory = Some(tp => HostedPartition.Online(Partition(tp, time, this)))
  )

通过allPartitions字段可以看到,replicaManager维护了每个broker上的分区,而每个分区下面维护了一组replica对象,通过这种层级关系,replicaManager直接管理分区对象,间接管理副本对象。

becomeLeaderOrFollower()方法,判断分区下哪些副本是leader,哪些是follower,并且这些是动态的。分为三个部分:判断controller epoch, 成为leader或者follower,构造response

对于判断controller epoch而言,提取出LeaderAndIsrRequest中的controller epoch,如果小于本地缓存的epoch,则说明controller已经变更到其他broker,构造异常并随response返回。当epoch满足之后,更新本地epoch的缓存,提取所有分区,对每个分区,根据不同的分区状态执行操作,如果是online状态直接赋值给partitionOpt,如果是offline构造异常随response返回并将partitionOpt设置为None,如果是None则新建partition对象并加入到allPartitions中,赋值给partitionOpt。然后会遍历partitionOpt,检查leader epoch是否大于缓存的epoch,要保证大于等于缓存的epoch.

从LeaderAndIsrRequest中可以判断当前broker中哪些分区是leader,哪些分区是follower,然后调用makeLeader()和makeFollower()方法使其生效,成为leader或者follower都要先使原来另一个角色的副本监控失效,最后如果本地日志为空,需要把分区设置为offline,更新allPartitions中的状态并移除分区的监控指标。

然后是构造response对象并返回。

  • ISR管理 maybeShrinklsr方法作用是定期检查follower副本滞后于leader副本相差超过一个阈值后,从lsr中收缩follower副本,在replicaManager启动的时候会创建一个异步线程进行判断。判断过程大概为判断是否需要执行收缩,是否是leader副本,获取非同步副本列表,计算收缩lsr副本列表,更新zookeeper上元数据,更新高水位。

maybePropagatelsrChanges方法在获取需要收缩lsr副本列表后需要通知其他的broker,创建异步线程检测是否需要发生通知事件。需要同时满足以下亮点,1)存在尚未被传播的 ISR 变更 2)最近 5 秒没有任何ISR变更,或者自上次ISR变更已经有超过1分钟的时间。