kafka消息阻塞

1,processor组件

2,Proactor模式

 

  • hi all:  
  •       大家都很关心kafka消息阻塞的情况(感谢RoctetMQ给我们的教训)。Kafka上线也有一段时间了,确实有出现过消息阻塞的情况,虽然不影响业务而且用临时办法解决了,但是我觉得可以跟大家总结一下。为了不引起大家的恐慌,我决定先把结论写出来:comsumer 非正常的rebalancing(重新分配分区)才会导致消费阻塞,如果不出现rebalancing,消息是不是重复消费或阻塞。  
  •   
  •       以下是这两个BUG的描述,这可能需要一些Kafka的知识,我会说得通俗一点,同也会留下一些参考给有兴趣的童鞋进一步了解。  
  •      1. 消费者处理过慢可能会导致重复消费  
  •      线上场景:BPM(业务流程管理)会订阅消费然后把流程信息一条一条索引到ElasticSearch,当索引处理较慢(30s)的时候会出现。  
  •      重现步骤:https://gist.github.com/richard2011/23b563e6ee5bad4e9d56 ,很普通的代码,消费代码段加上sleep(30s)。

 

 

(引入一个Broker组件,解耦客户端和服务端。服务端注册自己到Broker,通过暴露接口的方式允许客户端接入服务。客户端是通过Broker发送请求的,Broker转发请求道服务端,并将请求的结果或异常回发给客户端。通过使用Broker模式,应用可以通过发送消息访问远程的服务。) 

 

  •      产生原因:Kafka是使用poll()(长轮询)拉取消息,流程可以简单理解为: 拉取消息->向kafka broker(Broker可以被看成消息转发器)发送心跳->提交 offset。当消费者处理过慢(session timeout为30s)没有向kafka broker发送心跳,而且没有提交 offset,broker就会发起rebalancing,这个分区就会分配给其他消费者重复消费。最坏的情况是一直在rebalancing,新的消息都不会被消费。  
  •      临时办法:排查发现线上业务线正常场景没有消费处理过慢的场景,processor组件维护了一个线程池,每条消息都用一个线程处理。tasker-center会不断地把消息放在java的ArrayBlockingQueue。其他周边后端服务(如存储)这种处理能力难以评估的,可以尝试一下先把消息缓存到本地队列再做批量插入的操作,其实很多日志类或大数据类使用Kafka都是这样做的,如Flume。  
  •      如果确实有这样的场景,请联系我,可以通过通过两个参数减小这问题发生,但都不是完美解决问题。1) 增加session time的时间,但同时也会增加客户端失败的时间。2)减小分区拉取值(max.partition.fetch.bytes默认为1M), 但会影响吞量,而且以bytes为单位也无法评估消息的数量。  
  •      修复计划:Kafka社区专门针对这个问题写了篇WIKI https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=61333789 。这是他们的改进计划,内容有目的(Motivation), 计划修改(Proposed Change), 新增或修改的公共接口(New or Changed Public Interfaces),升级计划和兼容性(Migration Plan and Compatibility)和已放弃方案(Rejected Alternatives)。@听风,可以参考一下他的文档模板,用于轩辕组件的改进计划模板挺好的。这个BUG暂定为随着Kafka版本的升级来修复。  
  •   
  •       2. 多分区多consumer时rebalancing可能会导致某个分区阻塞。  
  •      线上场景:发生在在cart-processor,每个topic有18个分区,每个cart-processor有两个consumer(不同groupId),8个cart-processor节点。当cart-processor发版(节点增加或删除)会引起rebalancing,这可能导致个topic的分区阻塞。  
  •      重现步骤:代码https://gist.github.com/richard2011/d92caaa4af50331b0953,创建18个分区的topic, 通过不断地增加或删除,同时通过kafka-consumer-groups.sh命令查看分区情况,直到出现分区阻塞。  
  •      产生原因:kafka client的BUG( https://issues.apache.org/jira/browse/KAFKA-2978 ),Kafka github主分支已修复,但是还没有release版,越多分区和consumer越容易出现这个问题。  
  •      临时办法:每次使用kafka的程序发版时,用kafka-consumer-groups.sh命令查看分区情况,发现分区阻塞则重启对应的机器。同时也写了小工具,使comsumer再次rebalancing。  
  •      修复计划:观察线上出现的频率,如果频繁出现,将会修复kafka client代码,出现不频繁则随Kafka升级修复。

 

===========================================================================================================================================================================================================================================================================

 

kafka的offset是个什么鬼。。

2017年12月12日 10:04:04 hankl1990 阅读数:6013

转:http://blog.csdn.net/looklook5/article/details/42008079

 

 

 

 

之前在做Kafka 整合Storm(Storm是一个免费并开源的分布式实时计算系统)的时候,因为对Kafka 不是很熟,考虑过这样的一个场景问题,针对一个Topic,Kafka消息日志中有个offset信息来标注消息的位置,Storm每次从kafka 消费数据,都是通过zookeeper存储的数据offset,来判断需要获取消息在消息日志里的起始位置。

那么我们想,这个Offset 是消息在日志里是一个什么样的位置,是绝对位置还是相对位置?而Kafla 有个参数log.retention.hours会根据设定的小时,来清理日志文件。这样就可能会有这样的一个问题,针对一个Topic,Kafka 生产数据后,消费者消费信息后,此时的消息的offset是一个高位,比如100,消费者在消费完会记录这个offset准备下个数据的获取。而当系统时间达到参数log.retention.hours设定的时间后,kafka会自动删除这个Topic的缓存日志,那么这个时候新加入10条消息,消息的offset 是重新开始还是从删除日志前的Offset 开始?如果是前者,这个时候消费者因为记录消费这个Topic信息的Offset 仍在高位,那么他就获取不到在这个Offset前的新加入数据,这样就比较麻烦了。而后者,offset又是怎么记录消息相对位置的从而消费者一直消费到数据,无论系统怎么处理日志。这个是Storm 消费Kafla数据的时候必须要确认的问题。 

所以做了一个针对性测试。

log.retention.hours设置为1个小时,然后重启kafka,创建一个测试Topic,然后往这个Topic里生产点数据,然后消费者那边也有输出,保证程序通顺正常。观察参数log.dirs=/tmp/kafka-logs目录下对应的话题目录。

下图是Topic下面的消息日志。

然后等一个小时后,继续观察日志

我们发现之前的消息日志被打上deleted 标志,然后并生成了新的日志。且日志名称改变了。

这个时候我们再生产数据会发现Kafka消费者还是能够正常输出数据的。那么之前假设offset是消息日志的绝对位置是不成立的。

 

官网5.5 Log的介绍,http://kafka.apache.org/documentation.html#introduction这里有对kafka的消息日志有详细的说明,其中也说到Offset的内容。

如果英语阅读困难,可以看这篇文章http://my.oschina.net/frankwu/blog/305010

下面是我是摘录的。

[plain] view plain copy

  1. 日志  
  2. 如果          一个topic的名称为"my_topic",它有2个partitions,              那么日志将会保存在my_topic_0和my_topic_1两个目录中;日志文件中保存了一序列"log entries"(日志条目),每个log entry格式为"4个字节的数字N表示消息的长度" + "N个字节的消息内容";每个日志都有一个offset来唯一的标记一条消息,offset的值为8个字节的数字,表示此消息在此partition中所处的起始位置..每个partition在物理存储层面,有多个log file组成(称为segment).segment file的命名为"最小offset".kafka.例如"00000000000.kafka";其中"最小offset"表示此segment中起始消息的offset.  

[plain] view plain copy

  1. 其中每个partiton中所持有的segments列表信息会存储在zookeeper中.  
  2. 当segment文件尺寸达到一定阀值时(可以通过配置文件设定,默认1G),将会创建一个新的文件;当buffer中消息的条数达到阀值时将会触发日志信息flush到日志文件中,同时如果"距离最近一次flush的时间差"达到阀值时,也会触发flush到日志文件.如果broker失效,极有可能会丢失那些尚未flush到文件的消息.因为server意外失败,仍然会导致log文件格式的破坏(文件尾部),那么就要求当server启动时需要检测最后一个segment的文件结构是否合法并进行必要的修复.  
  3. 获取消息时,需要指定offset和最大chunk尺寸,offset用来表示消息的起始位置,chunk size用来表示最大获取消息的总长度(间接的表示消息的条数).根据offset,可以找到此消息所在segment文件,然后根据segment的最小offset取差值,得到它在file中的相对位置,直接读取输出即可.  
  4.  日志文件的删除策略非常简单:启动一个后台线程定期扫描log file列表,把保存时间超过阀值的文件直接删除(根据文件的创建时间).为了避免删除文件时仍然有read操作(consumer消费),采取copy-on-write方式.  


从文章中可以看出,消息日志名是最小offset位置,消息所在位置加上文件名的offset就是消息的offset位置,而系统没生成一个新的日志后会就将最后的offset作为新日志文件的文件名。我们可以认为kafka 消息日志里的offset实际就相当于是一个增量序列索引。那样我们就不用纠结消费数据的时候会不会丢失,而可以安心关注Storm的业务问题了

 

===========================================================================================================================================================================================================================================================================

  • Topic:在Kafka中,使用一个类别属性来划分数据的所属类,划分数据的这个类称为topic。如果把Kafka看做为一个数据库,topic可以理解为数据库中的一张表,topic的名字即为表名。
  • Partition:topic中的数据分割为一个或多个partition。每个topic至少有一个partition。每个partition中的数据使用多个segment文件存储。partition中的数据是有序的,partition间的数据丢失了数据的顺序。如果topic有多个partition,消费数据时就不能保证数据的顺序。在需要严格保证消息的消费顺序的场景下,需要将partition数目设为1。
  • Partition offset:每条消息都有一个当前Partition下唯一的64字节的offset,它指明了这条消息的起始位置。
  • Replicas of partition:副本是一个分区的备份。副本不会被消费者消费,副本只用于防止数据丢失,即消费者不从为follower的partition中消费数据,而是从为leader的partition中读取数据。
  • Broker:(经纪人,中间人)
    • Kafka 集群包含一个或多个服务器,服务器节点称为broker。
    • broker存储topic的数据。如果某topic有N个partition,集群有N个broker,那么每个broker存储该topic的一个partition。
    • 如果某topic有N个partition,集群有(N+M)个broker,那么其中有N个broker存储该topic的一个partition,剩下的M个broker不存储该topic的partition数据。
    • 如果某topic有N个partition,集群中broker数目少于N个,那么一个broker存储该topic的一个或多个partition。在实际生产环境中,尽量避免这种情况的发生,这种情况容易导致Kafka集群数据不均衡。
  • Producer:生产者即数据的发布者,该角色将消息发布到Kafka的topic中。broker接收到生产者发送的消息后,broker将该消息追加到当前用于追加数据的segment文件中。生产者发送的消息,存储到一个partition中,生产者也可以指定数据存储的partition。
  • Consumer:消费者可以从broker中读取数据。消费者可以消费多个topic中的数据。
  • Leader:每个partition有多个副本,其中有且仅有一个作为Leader,Leader是当前负责数据的读写的partition。
  • Follower:Follower跟随Leader,所有写请求都通过Leader路由,数据变更会广播给所有Follower,Follower与Leader保持数据同步。如果Leader失效,则从Follower中选举出一个新的Leader。当Follower与Leader挂掉、卡住或者同步太慢,leader会把这个follower从“in sync replicas”(ISR)列表中删除,重新创建一个Follower。

 

====================================================================================================================================================================================================================================================================================================================================================================

KafkaClient接口与Kafka处理请求的若干特性

(依据于0.10.0.0版本)

这个接口的唯一实现类就是NetworkClient,它被用于实现Kafka的consumer和producer. 这个接口实际上抽象出来了Kafka client与网络交互的方式。

为了对它的API有清楚的认识,先要了解下Kafka protocol所要求的client和broker对于网络请求的处理规则。

https://cwiki.apache.org/confluence/display/KAFKA/A+Guide+To+The+Kafka+Protocol

The server guarantees that on a single TCP connection, requests will be processed in the order they are sent and responses will return in that order as well. The broker's request processing allows only a single in-flight request per connection in order to guarantee this ordering. Note that clients can (and ideally should) use non-blocking IO to implement request pipelining and achieve higher throughput. i.e., clients can send requests even while awaiting responses for preceding requests since the outstanding requests will be buffered in the underlying OS socket buffer. All requests are initiated by the client, and result in a corresponding response message from the server except where noted.

 这一段的信息量挺大的。

顺序性

首先,broker按照请求被发送的顺序处理请求,并且按照同样的顺序发送响应。因为Kafka对消息的顺序性有如下的保证:

  • Messages sent by a producer to a particular topic partition will be appended in the order they are sent. That is, if a message M1 is sent by the same producer as a message M2, and M1 is sent first, then M1 will have a lower offset than M2 and appear earlier in the log.
  • A consumer instance sees messages in the order they are stored in the log.

为了实现这种顺序性保证,最简单可靠的行为就是"The broker's request processing allows only a single in-flight request per connection in order to guarantee this ordering. ", 也就是说对于一个TCP连接,broker的请求处理链条中只会有一个正在处理的(in-flight)消息.

那么,Kafka在broker端需不需要缓存待处理的消息呢?

首先,如果缓存请求的话,可能会占用大量内存.其次,如果缓存请求的话,在请求处理出错时,会使得Kafka client难以控制消息的顺序,因为本质上,这种缓存使得client的请求是异步处理的.而如果不进行缓存,那么broker的行为对于client而言更容易理解.

所以,broker是不会在本地缓存请求的.当它从某个连接读取一个请求之后,就会停止从这个连接继续读取请求.也就是说对于每个TCP连接,broker的处理流程是:接收一个请求 -> 处理请求 -> 发送响应 -> 接收下一个请求 -> ...

具体的做法,可以在kafka.network.Processor(也就是reactive模型里的subRactor) 找到,在其run方法中,对于已经完整读取的request和发送完毕的response, 有以下的处理

复制代码

        selector.completedReceives.asScala.foreach { receive =>
          try {
            val channel = selector.channel(receive.source)
            val session = RequestChannel.Session(new KafkaPrincipal(KafkaPrincipal.USER_TYPE, channel.principal.getName),
              channel.socketAddress)
            val req = RequestChannel.Request(processor = id, connectionId = receive.source, session = session, buffer = receive.payload, startTimeMs = time.milliseconds, securityProtocol = protocol)
            requestChannel.sendRequest(req) //把请求送入requestChannel,以后request handler会从中取出request来处理
            selector.mute(receive.source) //停止从这个request的来源(并不只用host来区分)读取消息
          } catch {
            case e @ (_: InvalidRequestException | _: SchemaException) =>
              // note that even though we got an exception, we can assume that receive.source is valid. Issues with constructing a valid receive object were handled earlier
              error("Closing socket for " + receive.source + " because of error", e)
              close(selector, receive.source)
          }
        }
        selector.completedSends.asScala.foreach { send =>
          val resp = inflightResponses.remove(send.destination).getOrElse {
            throw new IllegalStateException(s"Send for ${send.destination} completed, but not in `inflightResponses`")
          }
          resp.request.updateRequestMetrics()
          selector.unmute(send.destination) //将已发送完毕的response的源设为可读的
        }

复制代码

可见,对于正在处理的请求,broker不会从它的来源再读取新的消息,直至请求被处理完毕,并且其响应被发送完毕。

预抓取

另一方面,对于client,如果它接收到上一个请求的响应之后,才开始生成新的请求,然后再发送新请求,那么在等待响应的过程中,client就处理等待状态,这样挺没效率.因此,"clients can send requests even while awaiting responses for preceding requests since the outstanding requests will be buffered in the underlying OS socket buffer",也就是说client可以在等待响应的过程中继续发送请求,因为即使broker不去通过网络读这些请求,这些请求也会被缓存在OS的socket buffer中,因此,当broker处理完之前的请求,就可以立即读出来新的请求.不过,如果client这么做的话,会使得它的行为更复杂(因为涉及到出错时的顺序性).

对于consumer,在接收到响应之前难以确定下一次fetch开始的offset,因此在收到前一个fetch respones之后才发送下一次fetch request是比较稳妥的做法.不过如果可以比较准确判断fetch响应包含消息的数目,比而提前发出fetch request,的确有可能会提交consumer的性能.

而且,"收到fetch respone"和"用户处理完fetch到的消息"这两个时间点还是有所不同的,在收到fetch response之后,把抓取到的消息交给用户处理之前,发出下一个fetch request,这样可以提高consumer抓取的效率.新的consumer-KafkaConsumer的确是这么做的.这是KafkaConsumer的poll方法里的一段代码(用户通过执行这个poll方法来获取消息)

复制代码

 do {
                Map<TopicPartition, List<ConsumerRecord<K, V>>> records = pollOnce(remaining);
                if (!records.isEmpty()) {
                    // before returning the fetched records, we can send off the next round of fetches
                    // and avoid block waiting for their responses to enable pipelining while the user
                    // is handling the fetched records.
                    //
                    // NOTE that we use quickPoll() in this case which disables wakeups and delayed
                    // task execution since the consumed positions has already been updated and we
                    // must return these records to users to process before being interrupted or
                    // auto-committing offsets
                    fetcher.sendFetches(metadata.fetch());
                    client.quickPoll();
                    return this.interceptors == null
                        ? new ConsumerRecords<>(records) : this.interceptors.onConsume(new ConsumerRecords<>(records));
                }

                long elapsed = time.milliseconds() - start;
                remaining = timeout - elapsed;
            } while (remaining > 0);

复制代码

中间的那一大段就是在说这个事情,但是它考虑的情况比刚才提到的要复杂一些.

首先,如果pollOnce得到的records不为空,就要把这些records返回给用户,所以在此之前要先发送一批fetch rquest(利用Fetcher#sendFetches).如果为空的话,在do-while循环里的pollOnce会发送新的fetch request. 

其次,由于Fetcher的sendFetches并不会执行网络IO操作,而只是生成并且缓存fetch request,所以还需要利用ConsumerNetworkClient的quickPoll方法来执行一次IO操作把这些fetch request发出去.但是由于此时用户还没有得到这次pollOnce返回的records, 因此不能进行auto-commit操作,否则就会把还没返回给用户的records给commit了,并且也不能使得处理的过程被别的线程中断,因为这样用户也拿不到这些records了.所以,这里调用quickPoll,quickPoll会禁止wakeUp,并且不执行DelayedTasks(因为AutoCommitTask就是通过DelayedTask机制执行的).

 


对Kafka内部队列选择的影响

 

Kafka的broker是一个典型的Reactor模型的socket server。其中Processor相关于sub reactor,而HandlerPool相当于worker pool. Processor和Handler 都有各自的线程,它们之间通过一些队列来传递请求和响应。Kafka把这些队列封装成了RequestChannel。

 

 

复制代码

class RequestChannel(val numProcessors: Int, val queueSize: Int) extends KafkaMetricsGroup {
  private var responseListeners: List[(Int) => Unit] = Nil
  private val requestQueue = new ArrayBlockingQueue[RequestChannel.Request](queueSize)
  private val responseQueues = new Array[BlockingQueue[RequestChannel.Response]](numProcessors)
 for(i <- 0 until numProcessors)
    responseQueues(i) = new LinkedBlockingQueue[RequestChannel.Response]()
... }

复制代码

Kafka对于一个连接一次只处理一个请求的特性,决定了这里的两种队列的类型。其中,存放请求的队列用的是ArrayBlockingQueue,队列大小为queuSize,而存放响应的队列用的是LinkedBlockingQueue,它的capcity是Integer.MAX_VALUE。

有界队列 VS 无界队列

存放请求的队列必须用有界的阻塞队列,否则可能会有太多的请求撑爆内存。而使用有界队列,事实上可以阻塞Processor线程,使得在请求队列满的情况下,Broker拒绝新的请求。

但是响应队列选用无界的队列,其原因却是很隐晦的。

总的待发送响应的个数由于请求队列的限制,通常不会太大。但这也不意味着这种选择不会出问题,因为在最差情况下,可能会有相当于总的连接数的待发送响应。想象一种情况,假设有非常多的consumer(比如1W个)发送fetch请求,每个请求抓取1M的数据,但这些consumer都不从socket中读取响应,那么会有什么情况发生呢?不是会把内存爆掉吗?事实上,由于Kafka在发送响应时的zero copy特性,使得FetchRepsonse本身不会占用太大内存,所以即使有非常多的待发送响应,但响应对象所占的大小跟要传送的数据比,还是通常要小很多(取决于fetch请求的fetch size)。其它的响应,实际上也不会特别大,对于一个大集群,占用内存最大的也就是Metadata相关的响应了。

但是另一方面,如果这个队列用有界的,那么当所有Handler都阻塞于往这些队列put元素,而所有Processor都阻塞于往RequestQueue里put元素,那么整个server就死锁了。所以Kafka还是用了无界的队列。

非阻塞队列

另一个有趣的队列就是Processor和Acceptor之间存放新建立的连接的队列了。

private val newConnections = new ConcurrentLinkedQueue[SocketChannel]()

这里用了ConcurrentLinkedQueue,因为新连接的处理和消息的发送/接收是在同一个循环中的,所以存放消息的队列是非阻塞的更合适一些。


 

API

KafkaClient,是producer和consumer与broker通信的接口,它的设计就建立在上边的协议的基础上。这个类包括了与连接状态和请求-响应状态有关的方法。producer和consumer实际使用的它的实现类是NetworkClient。以下方法的作用结合了KafkaClient和NetworkClient的注释,但以NetworkClient的实现为标准。

 

public boolean isReady(Node node, long now) 查看某个结点是否准备好发送新请求了。由于是给client用的,因此这里的“node"就是broker

 

public boolean ready(Node node, long now)是到指定node的连接已经被创建好并且可以发送请求。如果连接没有创建,就创建到这个node的连接。

 

public long connectionDelay(Node, long now) 基于连接状态,返回需要等待的时间。连接的状态有三种:disconnected, connecting, connected.  如果是disconnected状态,就返回reconnect的backoff time。当connecting或者connected,就返回Long.MAX_VALUE,因为此时需要等待别的事件发生(比如连接成功,或者收到响应)

 

public long connectionFailed(Node node)  查看到这个node的连接是否失败。

 

public void send(ClientRequest request, long now) 把这个request放入发送队列。如果request是要发给还没有连接好的node的,那么就会抛出IllegalStateException异常, 这是一个运行时异常。

 

public List<ClientResponse> poll(long timeout, long now) 对于socket进行读写操作。

 

public void close(String nodeId) 关闭到指定node的连接

 

public Node leastLoadedNode(long now) 选择有最少的未发送请求的node,要求这些node至少是可以连接的。这个方法会优先选择有可用的连接的节点,但是如果所有的已连接的节点都在使用,它就会选择还没有建立连接的节点。这个方法绝对不会选择忆经断开连接的节点或者正在reconnect backoff阶段的连接。

 

public int inFlightRequestCount() 所有已发送但还没收到响应的请求的总数

public int inFlightRequestCount(String nodeId) 对于某个特定node的in-flight request总数

 

public RequestHandler nextRequestHanlder(ApiKeys key) 为某种请求构造它的请求头。按照Kafka Protoocl, request包括以下部分:

RequestMessage => ApiKey ApiVersion CorrelationId ClientId RequestMessage

  ApiKey => int16

  ApiVersion => int16

  CorrelationId => int32

  ClientId => string

  RequestMessage => MetadataRequest | ProduceRequest | FetchRequest | OffsetRequest | OffsetCommitRequest | OffsetFetchRequest

而这个方法构造了ApiKey, ApiVersion, CoorelationId和ClientId,作为请求的头部,request handler在源码里有对应类org.apache.kafka.common.requests.RequestHandler。

ApiKey表示请求的种类, 如produce request, fetch request, metadata request等。

puclic RequestHandler nextRequestHandler(ApiKey key, short version)  构造请求的头部,使用特定版本号。

public void wakeup() 如果这个client正在IO阻塞状态,就唤醒它。


总结

Kafka protocol的一些细节,在Kafka client的接口设计中得到了体现.并且,有一些小细节是挺有意思的.

下面会看一下NetworkClient,它是KafkaClient接口的实现.

 

 

===========================================================================================================================================================================================================================================================================

=========================================================================================

=========================================================================================

KafkaClient接口与Kafka处理请求的若干特性

(依据于0.10.0.0版本)

这个接口的唯一实现类就是NetworkClient,它被用于实现Kafka的consumer和producer. 这个接口实际上抽象出来了Kafka client与网络交互的方式。

为了对它的API有清楚的认识,先要了解下Kafka protocol所要求的client和broker对于网络请求的处理规则。

https://cwiki.apache.org/confluence/display/KAFKA/A+Guide+To+The+Kafka+Protocol

The server guarantees that on a single TCP connection, requests will be processed in the order they are sent and responses will return in that order as well. The broker's request processing allows only a single in-flight request per connection in order to guarantee this ordering. Note that clients can (and ideally should) use non-blocking IO to implement request pipelining and achieve higher throughput. i.e., clients can send requests even while awaiting responses for preceding requests since the outstanding requests will be buffered in the underlying OS socket buffer. All requests are initiated by the client, and result in a corresponding response message from the server except where noted.

 这一段的信息量挺大的。

顺序性

首先,broker按照请求被发送的顺序处理请求,并且按照同样的顺序发送响应。因为Kafka对消息的顺序性有如下的保证:

  • Messages sent by a producer to a particular topic partition will be appended in the order they are sent. That is, if a message M1 is sent by the same producer as a message M2, and M1 is sent first, then M1 will have a lower offset than M2 and appear earlier in the log.
  • A consumer instance sees messages in the order they are stored in the log.

为了实现这种顺序性保证,最简单可靠的行为就是"The broker's request processing allows only a single in-flight request per connection in order to guarantee this ordering. ", 也就是说对于一个TCP连接,broker的请求处理链条中只会有一个正在处理的(in-flight)消息.

那么,Kafka在broker端需不需要缓存待处理的消息呢?

首先,如果缓存请求的话,可能会占用大量内存.其次,如果缓存请求的话,在请求处理出错时,会使得Kafka client难以控制消息的顺序,因为本质上,这种缓存使得client的请求是异步处理的.而如果不进行缓存,那么broker的行为对于client而言更容易理解.

所以,broker是不会在本地缓存请求的.当它从某个连接读取一个请求之后,就会停止从这个连接继续读取请求.也就是说对于每个TCP连接,broker的处理流程是:接收一个请求 -> 处理请求 -> 发送响应 -> 接收下一个请求 -> ...

具体的做法,可以在kafka.network.Processor(也就是reactive模型里的subRactor) 找到,在其run方法中,对于已经完整读取的request和发送完毕的response, 有以下的处理

复制代码

        selector.completedReceives.asScala.foreach { receive =>
          try {
            val channel = selector.channel(receive.source)
            val session = RequestChannel.Session(new KafkaPrincipal(KafkaPrincipal.USER_TYPE, channel.principal.getName),
              channel.socketAddress)
            val req = RequestChannel.Request(processor = id, connectionId = receive.source, session = session, buffer = receive.payload, startTimeMs = time.milliseconds, securityProtocol = protocol)
            requestChannel.sendRequest(req) //把请求送入requestChannel,以后request handler会从中取出request来处理
            selector.mute(receive.source) //停止从这个request的来源(并不只用host来区分)读取消息
          } catch {
            case e @ (_: InvalidRequestException | _: SchemaException) =>
              // note that even though we got an exception, we can assume that receive.source is valid. Issues with constructing a valid receive object were handled earlier
              error("Closing socket for " + receive.source + " because of error", e)
              close(selector, receive.source)
          }
        }
        selector.completedSends.asScala.foreach { send =>
          val resp = inflightResponses.remove(send.destination).getOrElse {
            throw new IllegalStateException(s"Send for ${send.destination} completed, but not in `inflightResponses`")
          }
          resp.request.updateRequestMetrics()
          selector.unmute(send.destination) //将已发送完毕的response的源设为可读的
        }

复制代码

可见,对于正在处理的请求,broker不会从它的来源再读取新的消息,直至请求被处理完毕,并且其响应被发送完毕。

预抓取

另一方面,对于client,如果它接收到上一个请求的响应之后,才开始生成新的请求,然后再发送新请求,那么在等待响应的过程中,client就处理等待状态,这样挺没效率.因此,"clients can send requests even while awaiting responses for preceding requests since the outstanding requests will be buffered in the underlying OS socket buffer",也就是说client可以在等待响应的过程中继续发送请求,因为即使broker不去通过网络读这些请求,这些请求也会被缓存在OS的socket buffer中,因此,当broker处理完之前的请求,就可以立即读出来新的请求.不过,如果client这么做的话,会使得它的行为更复杂(因为涉及到出错时的顺序性).

对于consumer,在接收到响应之前难以确定下一次fetch开始的offset,因此在收到前一个fetch respones之后才发送下一次fetch request是比较稳妥的做法.不过如果可以比较准确判断fetch响应包含消息的数目,比而提前发出fetch request,的确有可能会提交consumer的性能.

而且,"收到fetch respone"和"用户处理完fetch到的消息"这两个时间点还是有所不同的,在收到fetch response之后,把抓取到的消息交给用户处理之前,发出下一个fetch request,这样可以提高consumer抓取的效率.新的consumer-KafkaConsumer的确是这么做的.这是KafkaConsumer的poll方法里的一段代码(用户通过执行这个poll方法来获取消息)

复制代码

 do {
                Map<TopicPartition, List<ConsumerRecord<K, V>>> records = pollOnce(remaining);
                if (!records.isEmpty()) {
                    // before returning the fetched records, we can send off the next round of fetches
                    // and avoid block waiting for their responses to enable pipelining while the user
                    // is handling the fetched records.
                    //
                    // NOTE that we use quickPoll() in this case which disables wakeups and delayed
                    // task execution since the consumed positions has already been updated and we
                    // must return these records to users to process before being interrupted or
                    // auto-committing offsets
                    fetcher.sendFetches(metadata.fetch());
                    client.quickPoll();
                    return this.interceptors == null
                        ? new ConsumerRecords<>(records) : this.interceptors.onConsume(new ConsumerRecords<>(records));
                }

                long elapsed = time.milliseconds() - start;
                remaining = timeout - elapsed;
            } while (remaining > 0);

复制代码

中间的那一大段就是在说这个事情,但是它考虑的情况比刚才提到的要复杂一些.

首先,如果pollOnce得到的records不为空,就要把这些records返回给用户,所以在此之前要先发送一批fetch rquest(利用Fetcher#sendFetches).如果为空的话,在do-while循环里的pollOnce会发送新的fetch request. 

其次,由于Fetcher的sendFetches并不会执行网络IO操作,而只是生成并且缓存fetch request,所以还需要利用ConsumerNetworkClient的quickPoll方法来执行一次IO操作把这些fetch request发出去.但是由于此时用户还没有得到这次pollOnce返回的records, 因此不能进行auto-commit操作,否则就会把还没返回给用户的records给commit了,并且也不能使得处理的过程被别的线程中断,因为这样用户也拿不到这些records了.所以,这里调用quickPoll,quickPoll会禁止wakeUp,并且不执行DelayedTasks(因为AutoCommitTask就是通过DelayedTask机制执行的).

 


对Kafka内部队列选择的影响

Kafka的broker是一个典型的Reactor模型的socket server。其中Processor相关于sub reactor,而HandlerPool相当于worker pool. Processor和Handler 都有各自的线程,它们之间通过一些队列来传递请求和响应。Kafka把这些队列封装成了RequestChannel。

复制代码

class RequestChannel(val numProcessors: Int, val queueSize: Int) extends KafkaMetricsGroup {
  private var responseListeners: List[(Int) => Unit] = Nil
  private val requestQueue = new ArrayBlockingQueue[RequestChannel.Request](queueSize)
  private val responseQueues = new Array[BlockingQueue[RequestChannel.Response]](numProcessors)
 for(i <- 0 until numProcessors)
    responseQueues(i) = new LinkedBlockingQueue[RequestChannel.Response]()
... }

复制代码

Kafka对于一个连接一次只处理一个请求的特性,决定了这里的两种队列的类型。其中,存放请求的队列用的是ArrayBlockingQueue,队列大小为queuSize,而存放响应的队列用的是LinkedBlockingQueue,它的capcity是Integer.MAX_VALUE。

有界队列 VS 无界队列

存放请求的队列必须用有界的阻塞队列,否则可能会有太多的请求撑爆内存。而使用有界队列,事实上可以阻塞Processor线程,使得在请求队列满的情况下,Broker拒绝新的请求。

但是响应队列选用无界的队列,其原因却是很隐晦的。

总的待发送响应的个数由于请求队列的限制,通常不会太大。但这也不意味着这种选择不会出问题,因为在最差情况下,可能会有相当于总的连接数的待发送响应。想象一种情况,假设有非常多的consumer(比如1W个)发送fetch请求,每个请求抓取1M的数据,但这些consumer都不从socket中读取响应,那么会有什么情况发生呢?不是会把内存爆掉吗?事实上,由于Kafka在发送响应时的zero copy特性,使得FetchRepsonse本身不会占用太大内存,所以即使有非常多的待发送响应,但响应对象所占的大小跟要传送的数据比,还是通常要小很多(取决于fetch请求的fetch size)。其它的响应,实际上也不会特别大,对于一个大集群,占用内存最大的也就是Metadata相关的响应了。

但是另一方面,如果这个队列用有界的,那么当所有Handler都阻塞于往这些队列put元素,而所有Processor都阻塞于往RequestQueue里put元素,那么整个server就死锁了。所以Kafka还是用了无界的队列。

非阻塞队列

另一个有趣的队列就是Processor和Acceptor之间存放新建立的连接的队列了。

private val newConnections = new ConcurrentLinkedQueue[SocketChannel]()

这里用了ConcurrentLinkedQueue,因为新连接的处理和消息的发送/接收是在同一个循环中的,所以存放消息的队列是非阻塞的更合适一些。


 

API

KafkaClient,是producer和consumer与broker通信的接口,它的设计就建立在上边的协议的基础上。这个类包括了与连接状态和请求-响应状态有关的方法。producer和consumer实际使用的它的实现类是NetworkClient。以下方法的作用结合了KafkaClient和NetworkClient的注释,但以NetworkClient的实现为标准。

 

public boolean isReady(Node node, long now) 查看某个结点是否准备好发送新请求了。由于是给client用的,因此这里的“node"就是broker

 

public boolean ready(Node node, long now)是到指定node的连接已经被创建好并且可以发送请求。如果连接没有创建,就创建到这个node的连接。

 

public long connectionDelay(Node, long now) 基于连接状态,返回需要等待的时间。连接的状态有三种:disconnected, connecting, connected.  如果是disconnected状态,就返回reconnect的backoff time。当connecting或者connected,就返回Long.MAX_VALUE,因为此时需要等待别的事件发生(比如连接成功,或者收到响应)

 

public long connectionFailed(Node node)  查看到这个node的连接是否失败。

 

public void send(ClientRequest request, long now) 把这个request放入发送队列。如果request是要发给还没有连接好的node的,那么就会抛出IllegalStateException异常, 这是一个运行时异常。

 

public List<ClientResponse> poll(long timeout, long now) 对于socket进行读写操作。

 

public void close(String nodeId) 关闭到指定node的连接

 

public Node leastLoadedNode(long now) 选择有最少的未发送请求的node,要求这些node至少是可以连接的。这个方法会优先选择有可用的连接的节点,但是如果所有的已连接的节点都在使用,它就会选择还没有建立连接的节点。这个方法绝对不会选择忆经断开连接的节点或者正在reconnect backoff阶段的连接。

 

public int inFlightRequestCount() 所有已发送但还没收到响应的请求的总数

public int inFlightRequestCount(String nodeId) 对于某个特定node的in-flight request总数

 

public RequestHandler nextRequestHanlder(ApiKeys key) 为某种请求构造它的请求头。按照Kafka Protoocl, request包括以下部分:

RequestMessage => ApiKey ApiVersion CorrelationId ClientId RequestMessage

  ApiKey => int16

  ApiVersion => int16

  CorrelationId => int32

  ClientId => string

  RequestMessage => MetadataRequest | ProduceRequest | FetchRequest | OffsetRequest | OffsetCommitRequest | OffsetFetchRequest

而这个方法构造了ApiKey, ApiVersion, CoorelationId和ClientId,作为请求的头部,request handler在源码里有对应类org.apache.kafka.common.requests.RequestHandler。

ApiKey表示请求的种类, 如produce request, fetch request, metadata request等。

puclic RequestHandler nextRequestHandler(ApiKey key, short version)  构造请求的头部,使用特定版本号。

public void wakeup() 如果这个client正在IO阻塞状态,就唤醒它。


总结

Kafka protocol的一些细节,在Kafka client的接口设计中得到了体现.并且,有一些小细节是挺有意思的.

下面会看一下NetworkClient,它是KafkaClient接口的实现.

=============================================================================================================================================================================================================================================================================================================================================================================================================================================================

Reactor模式详解

转自:http://www.blogjava.net/DLevin/archive/2015/09/02/427045.html

前记

第一次听到Reactor模式是三年前的某个晚上,一个室友突然跑过来问我什么是Reactor模式?我上网查了一下,很多人都是给出NIO中的 Selector的例子,而且就是NIO里Selector多路复用模型,只是给它起了一个比较fancy的名字而已,虽然它引入了EventLoop概 念,这对我来说是新的概念,但是代码实现却是一样的,因而我并没有很在意这个模式。然而最近开始读Netty源码,而Reactor模式是很多介绍Netty的文章中被大肆宣传的模式,因而我再次问自己,什么是Reactor模式?本文就是对这个问题关于我的一些理解和尝试着来解答。
 

什么是Reactor模式

要回答这个问题,首先当然是求助Google或Wikipedia,其中Wikipedia上说:“The reactor design pattern is an event handling pattern for handling service requests delivered concurrently by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to associated request handlers.”。从这个描述中,我们知道Reactor模式首先是事件驱动的,有一个或多个并发输入源,有一个Service Handler,有多个Request Handlers;这个Service Handler会同步的将输入的请求(Event)多路复用的分发给相应的Request Handler。如果用图来表达:

从结构上,这有点类似生产者消费者模式,即有一个或多个生产者将事件放入一个Queue中,而一个或多个消费者主动的从这个Queue中Poll事件来处理;而Reactor模式则并没有Queue来做缓冲,每当一个Event输入到Service Handler之后,该Service Handler会主动的根据不同的Event类型将其分发给对应的Request Handler来处理。

更学术的,这篇文章(Reactor An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events)上说:“The Reactor design pattern handles service requests that are delivered concurrently to an application by one or more clients. Each service in an application may consistent of several methods and is represented by a separate event handler that is responsible for dispatching service-specific requests. Dispatching of event handlers is performed by an initiation dispatcher, which manages the registered event handlers. Demultiplexing of service requests is performed by a synchronous event demultiplexer. Also known as Dispatcher, Notifier”。这段描述和Wikipedia上的描述类似,有多个输入源,有多个不同的EventHandler(RequestHandler)来处理不同的请求,Initiation Dispatcher用于管理EventHander,EventHandler首先要注册到Initiation Dispatcher中,然后Initiation Dispatcher根据输入的Event分发给注册的EventHandler;然而Initiation Dispatcher并不监听Event的到来,这个工作交给Synchronous Event Demultiplexer来处理。

Reactor模式结构

在解决了什么是Reactor模式后,我们来看看Reactor模式是由什么模块构成。图是一种比较简洁形象的表现方式,因而先上一张图来表达各个模块的名称和他们之间的关系:

Handle:即操作系统中的句柄,是对资源在操作系统层面上的一种抽象,它可以是打开的文件、一个连接(Socket)、Timer等。由于Reactor模式一般使用在网络编程中,因而这里一般指Socket Handle,即一个网络连接(Connection,在Java NIO中的Channel)。这个Channel注册到Synchronous Event Demultiplexer中,以监听Handle中发生的事件,对ServerSocketChannnel可以是CONNECT事件,对SocketChannel可以是READ、WRITE、CLOSE事件等。
Synchronous Event Demultiplexer:阻塞等待一系列的Handle中的事件到来,如果阻塞等待返回,即表示在返回的Handle中可以不阻塞的执行返回的事件类型。这个模块一般使用操作系统的select来实现。在Java NIO中用Selector来封装,当Selector.select()返回时,可以调用Selector的selectedKeys()方法获取Set<SelectionKey>,一个SelectionKey表达一个有事件发生的Channel以及该Channel上的事件类型。上图的“Synchronous Event Demultiplexer ---notifies--> Handle”的流程如果是对的,那内部实现应该是select()方法在事件到来后会先设置Handle的状态,然后返回。不了解内部实现机制,因而保留原图。
Initiation Dispatcher:用于管理Event Handler,即EventHandler的容器,用以注册、移除EventHandler等;另外,它还作为Reactor模式的入口调用Synchronous Event Demultiplexer的select方法以阻塞等待事件返回,当阻塞等待返回时,根据事件发生的Handle将其分发给对应的Event Handler处理,即回调EventHandler中的handle_event()方法。
Event Handler:定义事件处理方法:handle_event(),以供InitiationDispatcher回调使用。
Concrete Event Handler:事件EventHandler接口,实现特定事件处理逻辑。

Reactor模式模块之间的交互

简单描述一下Reactor各个模块之间的交互流程,先从序列图开始:

1. 初始化InitiationDispatcher,并初始化一个Handle到EventHandler的Map。
2. 注册EventHandler到InitiationDispatcher中,每个EventHandler包含对相应Handle的引用,从而建立Handle到EventHandler的映射(Map)。
3. 调用InitiationDispatcher的handle_events()方法以启动Event Loop。在Event Loop中,调用select()方法(Synchronous Event Demultiplexer)阻塞等待Event发生。
4. 当某个或某些Handle的Event发生后,select()方法返回,InitiationDispatcher根据返回的Handle找到注册的EventHandler,并回调该EventHandler的handle_events()方法。
5. 在EventHandler的handle_events()方法中还可以向InitiationDispatcher中注册新的Eventhandler,比如对AcceptorEventHandler来,当有新的client连接时,它会产生新的EventHandler以处理新的连接,并注册到InitiationDispatcher中。

Reactor模式实现

Reactor An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events中,一直以Logging Server来分析Reactor模式,这个Logging Server的实现完全遵循这里对Reactor描述,因而放在这里以做参考。Logging Server中的Reactor模式实现分两个部分:Client连接到Logging Server和Client向Logging Server写Log。因而对它的描述分成这两个步骤。
Client连接到Logging Server

1. Logging Server注册LoggingAcceptor到InitiationDispatcher。
2. Logging Server调用InitiationDispatcher的handle_events()方法启动。
3. InitiationDispatcher内部调用select()方法(Synchronous Event Demultiplexer),阻塞等待Client连接。
4. Client连接到Logging Server。
5. InitiationDisptcher中的select()方法返回,并通知LoggingAcceptor有新的连接到来。 
6. LoggingAcceptor调用accept方法accept这个新连接。
7. LoggingAcceptor创建新的LoggingHandler。
8. 新的LoggingHandler注册到InitiationDispatcher中(同时也注册到Synchonous Event Demultiplexer中),等待Client发起写log请求。
Client向Logging Server写Log

1. Client发送log到Logging server。
2. InitiationDispatcher监测到相应的Handle中有事件发生,返回阻塞等待,根据返回的Handle找到LoggingHandler,并回调LoggingHandler中的handle_event()方法。
3. LoggingHandler中的handle_event()方法中读取Handle中的log信息。
4. 将接收到的log写入到日志文件、数据库等设备中。
3.4步骤循环直到当前日志处理完成。
5. 返回到InitiationDispatcher等待下一次日志写请求。

Reactor An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events有对Reactor模式的C++的实现版本,多年不用C++,因而略过。 

Java NIO对Reactor的实现

在Java的NIO中,对Reactor模式有无缝的支持,即使用Selector类封装了操作系统提供的Synchronous Event Demultiplexer功能。这个Doug Lea已经在Scalable IO In Java中有非常深入的解释了,因而不再赘述,另外这篇文章对Doug Lea的Scalable IO In Java有一些简单解释,至少它的代码格式比Doug Lea的PPT要整洁一些。

需要指出的是,不同这里使用InitiationDispatcher来管理EventHandler,在Doug Lea的版本中使用SelectionKey中的Attachment来存储对应的EventHandler,因而不需要注册EventHandler这个步骤,或者设置Attachment就是这里的注册。而且在这篇文章中,Doug Lea从单线程的Reactor、Acceptor、Handler实现这个模式出发;演化为将Handler中的处理逻辑多线程化,实现类似Proactor模式,此时所有的IO操作还是单线程的,因而再演化出一个Main Reactor来处理CONNECT事件(Acceptor),而多个Sub Reactor来处理READ、WRITE等事件(Handler),这些Sub Reactor可以分别再自己的线程中执行,从而IO操作也多线程化。这个最后一个模型正是Netty中使用的模型。并且在Reactor An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events的9.5 Determine the Number of Initiation Dispatchers in an Application中也有相应的描述。

EventHandler接口定义

对EventHandler的定义有两种设计思路:single-method设计和multi-method设计:
A single-method interface:它将Event封装成一个Event Object,EventHandler只定义一个handle_event(Event event)方法。这种设计的好处是有利于扩展,可以后来方便的添加新的Event类型,然而在子类的实现中,需要判断不同的Event类型而再次扩展成 不同的处理方法,从这个角度上来说,它又不利于扩展。另外在Netty3的使用过程中,由于它不停的创建ChannelEvent类,因而会引起GC的不稳定。
A multi-method interface:这种设计是将不同的Event类型在 EventHandler中定义相应的方法。这种设计就是Netty4中使用的策略,其中一个目的是避免ChannelEvent创建引起的GC不稳定, 另外一个好处是它可以避免在EventHandler实现时判断不同的Event类型而有不同的实现,然而这种设计会给扩展新的Event类型时带来非常 大的麻烦,因为它需要该接口。

关于Netty4对Netty3的改进可以参考这里

ChannelHandler with no event objectIn 3.x, every I/O operation created a ChannelEvent object. For each read / write, it additionally created a new ChannelBuffer. It simplified the internals of Netty quite a lot because it delegates resource management and buffer pooling to the JVM. However, it often was the root cause of GC pressure and uncertainty which are sometimes observed in a Netty-based application under high load.

4.0 removes event object creation almost completely by replacing the event objects with strongly typed method invocations. 3.x had catch-all event handler methods such as handleUpstream() andhandleDownstream(), but this is not the case anymore. Every event type has its own handler method now:

为什么使用Reactor模式

归功与Netty和Java NIO对Reactor的宣传,本文慕名而学习的Reactor模式,因而已经默认Reactor具有非常优秀的性能,然而慕名归慕名,到这里,我还是要不得不问自己Reactor模式的好处在哪里?即为什么要使用这个Reactor模式?在Reactor An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events中是这么说的:

Reactor Pattern优点

Separation of concerns: The Reactor pattern decouples application-independent demultiplexing and dispatching mechanisms from application-specific hook method functionality. The application-independent mechanisms become reusable components that know how to demultiplex events and dispatch the appropriate hook methods defined by Event Handlers. In contrast, the application-specific functionality in a hook method knows how to perform a particular type of service.

Improve modularity, reusability, and configurability of event-driven applications: The pattern decouples application functionality into separate classes. For instance, there are two separate classes in the logging server: one for establishing connections and another for receiving and processing logging records. This decoupling enables the reuse of the connection establishment class for different types of connection-oriented services (such as file transfer, remote login, and video-on-demand). Therefore, modifying or extending the functionality of the logging server only affects the implementation of the logging handler class.

Improves application portability: The Initiation Dispatcher’s interface can be reused independently of the OS system calls that perform event demultiplexing. These system calls detect and report the occurrence of one or more events that may occur simultaneously on multiple sources of events. Common sources of events may in- clude I/O handles, timers, and synchronization objects. On UNIX platforms, the event demultiplexing system calls are called selectand poll [1]. In the Win32 API [16], the WaitForMultipleObjects system call performs event demultiplexing.

Provides coarse-grained concurrency control: The Reactor pattern serializes the invocation of event handlers at the level of event demultiplexing and dispatching within a process or thread. Serialization at the Initiation Dispatcher level often eliminates the need for more complicated synchronization or locking within an application process.

这些貌似是很多模式的共性:解耦、提升复用性、模块化、可移植性、事件驱动、细力度的并发控制等,因而并不能很好的说明什么,特别是它鼓吹的对性能的提升,这里并没有体现出来。当然在这篇文章的开头有描述过另一种直观的实现:Thread-Per-Connection,即传统的实现,提到了这个传统实现的以下问题:

Thread Per Connection缺点

Efficiency: Threading may lead to poor performance due to context switching, synchronization, and data movement [2];

Programming simplicity: Threading may require complex concurrency control schemes;

Portability: Threading is not available on all OS platforms.

对于性能,它其实就是第一点关于Efficiency的描述,即线程的切换、同步、数据的移动会引起性能问题。也就是说从性能的角度上,它最大的提升就是减少了性能的使用,即不需要每个Client对应一个线程。我的理解,其他业务逻辑处理很多时候也会用到相同的线程,IO读写操作相对CPU的操作还是要慢很多,即使Reactor机制中每次读写已经能保证非阻塞读写,这里可以减少一些线程的使用,但是这减少的线程使用对性能有那么大的影响吗?答案貌似是肯定的,这篇论文(SEDA: Staged Event-Driven Architecture - An Architecture for Well-Conditioned, Scalable Internet Service)对随着线程的增长带来性能降低做了一个统计:

在这个统计中,每个线程从磁盘中读8KB数据,每个线程读同一个文件,因而数据本身是缓存在操作系统内部的,即减少IO的影响;所有线程是事先分配的,不会有线程启动的影响;所有任务在测试内部产生,因而不会有网络的影响。该统计数据运行环境:Linux 2.2.14,2GB内存,4-way 500MHz Pentium III。从图中可以看出,随着线程的增长,吞吐量在线程数为8个左右的时候开始线性下降,并且到64个以后而迅速下降,其相应事件也在线程达到256个后指数上升。即1+1<2,因为线程切换、同步、数据移动会有性能损失,线程数增加到一定数量时,这种性能影响效果会更加明显。

对于这点,还可以参考C10K Problem,用以描述同时有10K个Client发起连接的问题,到2010年的时候已经出现10M Problem了。

当然也有人说:Threads are expensive are no longer valid.在不久的将来可能又会发生不同的变化,或者这个变化正在、已经发生着?没有做过比较仔细的测试,因而不敢随便断言什么,然而本人观点,即使线程变的影响并没有以前那么大,使用Reactor模式,甚至时SEDA模式来减少线程的使用,再加上其他解耦、模块化、提升复用性等优点,还是值得使用的。

Reactor模式的缺点

Reactor模式的缺点貌似也是显而易见的:
1. 相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
2. Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。
3. Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用Proactor模式。

 

====================================================================================================================================================================================================================================================================================================================================================================

 

高性能IO模型浅析

 

 

服务器端编程经常需要构造高性能的IO模型,常见的IO模型有四种:

(1)同步阻塞IO(Blocking IO):即传统的IO模型。

(2)同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。

(3)IO多路复用(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。

(4)异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。

 

同步和异步的概念描述的是用户线程与内核的交互方式:同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式:阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

 

另外,Richard Stevens 在《Unix 网络编程》卷1中提到的基于信号驱动的IO(Signal Driven IO)模型,由于该模型并不常用,本文不作涉及。接下来,我们详细分析四种常见的IO模型的实现原理。为了方便描述,我们统一使用IO的读操作作为示例。

 

一、同步阻塞IO

 

同步阻塞IO模型是最简单的IO模型,用户线程在内核进行IO操作时被阻塞。

图1 同步阻塞IO

如图1所示,用户线程通过系统调用read发起IO读操作,由用户空间转到内核空间。内核等到数据包到达后,然后将接收的数据拷贝到用户空间,完成read操作。

用户线程使用同步阻塞IO模型的伪代码描述为:

{

read(socket, buffer);

process(buffer);

}

即用户需要等待read将socket中的数据读取到buffer后,才继续处理接收的数据。整个IO请求的过程中,用户线程是被阻塞的,这导致用户在发起IO请求时,不能做任何事情,对CPU的资源利用率不够。

 

二、同步非阻塞IO

 

同步非阻塞IO是在同步阻塞IO的基础上,将socket设置为NONBLOCK。这样做用户线程可以在发起IO请求后可以立即返回。

 

图2 同步非阻塞IO

如图2所示,由于socket是非阻塞的方式,因此用户线程发起IO请求时立即返回。但并未读取到任何数据,用户线程需要不断地发起IO请求,直到数据到达后,才真正读取到数据,继续执行。

用户线程使用同步非阻塞IO模型的伪代码描述为:

{

while(read(socket, buffer) != SUCCESS)

;

process(buffer);

}

即用户需要不断地调用read,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。整个IO请求的过程中,虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。

 

三、IO多路复用

IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。

图3 多路分离函数select

如图3所示,用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。

从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

用户线程使用select函数的伪代码描述为:

{

select(socket);

while(1) {

sockets = select();

for(socket in sockets) {

if(can_read(socket)) {

read(socket, buffer);

process(buffer);

}

}

}

}

其中while循环前将socket添加到select监视中,然后在while内一直调用select获取被激活的socket,一旦socket可读,便调用read函数将socket中的数据读取出来。

 

然而,使用select函数的优点并不仅限于此。虽然上述方式允许单线程内处理多个IO请求,但是每个IO请求的过程还是阻塞的(在select函数上阻塞),平均时间甚至比同步阻塞IO模型还要长。如果用户线程只注册自己感兴趣的socket或者IO请求,然后去做自己的事情,等到数据到来时再进行处理,则可以提高CPU的利用率。

IO多路复用模型使用了Reactor设计模式实现了这一机制。

图4 Reactor设计模式

 

Concrete 具体的

synchronous 同步的

demutiplexer多路分配器

 

 

如图4所示,EventHandler抽象类表示IO事件处理器,它拥有IO文件句柄Handle(通过get_handle获取),以及对Handle的操作handle_event(读/写等)。继承于EventHandler的子类可以对事件处理器的行为进行定制。Reactor类用于管理EventHandler(注册、删除等),并使用handle_events实现事件循环,不断调用同步事件多路分离器(一般是内核)的多路分离函数select,只要某个文件句柄被激活(可读/写等),select就返回(阻塞),handle_events就会调用与文件句柄关联的事件处理器的handle_event进行相关操作。

 

 

 

 

 

 

 

 

 

 

图5 IO多路复用

如图5所示,通过Reactor的方式,可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select函数检查socket状态。当有socket被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行handle_event进行数据读取、处理的工作。由于select函数是阻塞的,因此多路IO复用模型也被称为异步阻塞IO模型。注意,这里的所说的阻塞是指select函数执行时线程被阻塞,而不是指socket。一般在使用IO多路复用模型时,socket都是设置为NONBLOCK的,不过这并不会产生影响,因为用户发起IO请求时,数据已经到达了,用户线程一定不会被阻塞。

用户线程使用IO多路复用模型的伪代码描述为:

void UserEventHandler::handle_event() {

if(can_read(socket)) {

read(socket, buffer);

process(buffer);

}

}

 

{

Reactor.register(new UserEventHandler(socket));

}

用户需要重写EventHandler的handle_event函数进行读取数据、处理数据的工作,用户线程只需要将自己的EventHandler注册到Reactor即可。Reactor中handle_events事件循环的伪代码大致如下。

Reactor::handle_events() {

while(1) {

sockets = select();

for(socket in sockets) {

get_event_handler(socket).handle_event();

}

}

}

事件循环不断地调用select获取被激活的socket,然后根据获取socket对应的EventHandler,执行器handle_event函数即可。

IO多路复用是最常使用的IO模型,但是其异步程度还不够“彻底”,因为它使用了会阻塞线程的select系统调用。因此IO多路复用只能称为异步阻塞IO,而非真正的异步IO。

 

四、异步IO

 

“真正”的异步IO需要操作系统更强的支持。在IO多路复用模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。

异步IO模型使用了Proactor设计模式实现了这一机制。

图6 Proactor设计模式

如图6,Proactor模式和Reactor模式在结构上比较相似,不过在用户(Client)使用方式上差别较大。Reactor模式中,用户线程通过向Reactor对象注册感兴趣的事件监听,然后事件触发时调用事件处理函数。而Proactor模式中,用户线程将AsynchronousOperation(读/写等)、Proactor以及操作完成时的CompletionHandler注册到AsynchronousOperationProcessor。AsynchronousOperationProcessor使用Facade模式提供了一组异步操作API(读/写等)供用户使用,当用户线程调用异步API后,便继续执行自己的任务。AsynchronousOperationProcessor 会开启独立的内核线程执行异步操作,实现真正的异步。当异步IO操作完成时,AsynchronousOperationProcessor将用户线程与

 

AsynchronousOperation一起注册的Proactor和CompletionHandler取出,然后将CompletionHandler与IO操作的结果数据一起转发给Proactor,Proactor负责回调每一个异步操作的事件完成处理函数handle_event。虽然Proactor模式中每个异步操作都可以绑定一个Proactor对象,但是一般在操作系统中,Proactor被实现为Singleton模式,以便于集中化分发操作完成事件。

 

 

图7 异步IO

如图7所示,异步IO模型中,用户线程直接使用内核提供的异步IO API发起read请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的AsynchronousOperation和CompletionHandler注册到内核,然后操作系统开启独立的内核线程去处理IO操作。当read请求的数据到达时,由内核负责读取socket中的数据,并写入用户指定的缓冲区中。最后内核将read的数据和用户线程注册的CompletionHandler分发给内部Proactor,Proactor将IO完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步IO。

用户线程使用异步IO模型的伪代码描述为:

void UserCompletionHandler::handle_event(buffer) {

process(buffer);

}

 

{

aio_read(socket, new UserCompletionHandler);

}

用户需要重写CompletionHandler的handle_event函数进行处理数据的工作,参数buffer表示Proactor已经准备好的数据,用户线程直接调用内核提供的异步IO API,并将重写的CompletionHandler注册即可。

相比于IO多路复用模型,异步IO并不十分常用,不少高性能并发服务程序使用IO多路复用模型+多线程任务处理的架构基本可以满足需求。况且目前操作系统对异步IO的支持并非特别完善,更多的是采用IO多路复用模型模拟异步IO的方式(IO事件触发时不直接通知用户线程,而是将数据读写完毕后放到用户指定的缓冲区中)。Java7之后已经支持了异步IO,感兴趣的读者可以尝试使用。

本文从基本概念、工作流程和代码示例三个层次简要描述了常见的四种高性能IO模型的结构和原理,理清了同步、异步、阻塞、非阻塞这些容易混淆的概念。通过对高性能IO模型的理解,可以在服务端程序的开发中选择更符合实际业务特点的IO模型,提高服务质量。希望本文对你有所帮助。

 

===========================================================================================================================================================================================================================================================================

 

bitkevin

 

两种高效的事件处理模型:Reactor模式和Proactor模式

  随着IO多路复用技术的出现,出现了很多事件处理模式。同步I/O模型通常由Reactor模式实现,而异步I/O模型则由Proactor模式实现。

 

  • Reactor模式:

 

  Reator类图如上所示,Reactor模式又叫反应器或反应堆,即实现注册描述符(Handle)及事件的处理器(EventHandler),当有事件发生的时候,事件多路分发器(Event Demultiplexer)做出反应,调用事件具体的处理函数(ConcreteEventHandler::handle_event())。

  Reator模式的典型启动过程如下:

  1. 创建Reactor
  2. 注册事件处理器(Reactor::register_handler())
  3. 调用事件多路分发器进入无限事件循环(Reacor:handle_events)
  4. 当操作系统通知某描述符状态就绪时,事件多路分发器找出并调用此描述符注册的事件处理器。

    Reactor模式已经被广泛使用,著名的开源事件库libevent、libev、libuv都是使用Reactor模式。

  Reactor模式的优点:

  • 实现相对简单,对于耗时短的处理场景处理高效;
  • 操作系统可以在多个事件源上等待,并且避免了多线程编程相关的性能开销和编程复杂性;
  • 事件的串行化对应用是透明的,可以顺序的同步执行而不需要加锁;
  • 事务分离:将与应用无关的多路分解和分配机制和与应用相关的回调函数分离开来。

  Reactor模式的缺点:

  Reactor处理耗时长的操作(如文件I/O)会造成事件分发的阻塞,影响到后续事件的处理。

  因此涉及到文件I/O相关的操作,需要使用异步I/O,即使用Proactor模式效果更佳。

 

  • Proactor模式

initiator

proactor

Asynchronous (异步的)Operation processor

Asynchronous Event Demultiolexer(信号分离器,多路输出选择器;)

completion Event Queue

Completion handle

executes

enqueues(入队)

dequeues

  

 

 

Proactor模式的类图如上图所示,Proactor模式又叫前摄器或主动器模式。它用于实现异步I/O模型,运行流程如下:

  1. Initiator主动调用Asynchronous Operation Processor发起异步I/O操作,

  2. 记录异步操作的参数和函数地址放入完成事件队列(Completion Event Queue)中

  3. Proactor循环检测异步事件是否完成。如果完成则从完成事件队列中取出回调函数完成回调。

  Boost库中的asio就使用了Proactor模式,其底层的异步I/O由操作系统提供,而异步事件的分发还是由epoll/kequeue/select等实现。

两者区别

  综上我们可以发现Reactor模式和Proactor模式的主要区别:

  1. Reactor实现同步I/O多路分发,Proactor实现异步I/O分发。

  如果只是处理网络I/O单线程的Reactor尚可处理,但如果涉及到文件I/O,单线程的Reactor可能被文件I/O阻塞而导致其他事件无法被分发。所以涉及到文件I/O最好还是使用Proactor模式,或者用多线程模拟实现异步I/O的方式。

  2. Reactor模式注册的是文件描述符的就绪事件,而Proactor模式注册的是完成事件。

  即Reactor模式有事件发生的时候要判断是读事件还是写事件,然后用再调用系统调用(read/write等)将数据从内核中拷贝到用户数据区继续其他业务处理。

  而Proactor模式一般使用的是操作系统的异步I/O接口,发起异步调用(用户提供数据缓冲区)之后操作系统将在内核态完成I/O并拷贝数据到用户提供的缓冲区中,完成事件到达之后,用户只需要实现自己后续的业务处理即可。

  3. 主动和被动

  Reactor模式是一种被动的处理,即有事件发生时被动处理。而Proator模式则是主动发起异步调用,然后循环检测完成事件。

  

  最后我们知道linux系统提供的异步I/O,只支持O_DIRECT,不能带缓存。因此出现了开源库libeio,它和Linux的异步I/O一样也是用多线程模拟,但是更高效。下图是libeio的异步I/O实现,是不是很像Proactor模式啊。

====================================================================================================================================================================================================================================================================================================================================================================

对于观察者模式,Reactor模式,Proactor模式的一点理解

最近就服务器程序IO效率这一块了解一下设计模式中的Reacotr模式和proactor模式,感觉跟观察者模式有些类似的地方,网上也看了一些其他人对三者之间区别的理解,都讲得很仔细,在此根据自己的理解做一点简单的记录和总结,如果理解不对的地方,以后再慢慢深入和更新。

观察者模式:

  也可以称为为 发布-订阅 模式,主要适用于多个对象依赖某一个对象的状态并,当某对象状态发生改变时,要通知其他依赖对象做出更新。是一种1对多的关系。当然,如果依赖的对象只有一个时也是一种特殊的一对一关系。通常,观察者模式适用于消息事件处理,监听者监听到事件时通知事件处理者对事件进行处理(这一点上面有点像是回调,容易与反应器模式和前摄器模式的回调搞混淆)。

 

 

Reactor模式:

  reactor模式,即反应器模式,是一种高效的异步IO模式,特征是 回调,当IO完成时,回调对应的函数进行处理。这种模式并非是真正的异步,而是运用了异步的思想,当io事件触发时,通知应用程序作出IO处理。模式本身并不调用系统的异步io函数。

 

Proactor模式:

  Proactor模式,即前摄器模式,也是一种高效的异步IO模式,特征也是回调,当IO事件完成时,回调对应的函数对完成事件作出处理。这种模式是真正意义上的异步,属于系统级的异步,通常要调用系统提供的异步IO函数进行IO处理。

 

 

 

 

Reactor模式和Proactor模式之间的区别:

  Reacor模式不调用系统异步IO函数,是一种仿异步。而Proactor是系统层面上的真正的异步,调用系统提供的异步IO函数。

举个例子,以网络IO为例:当我们从套接字读取数据

 

 

  1.如果是Reactor模式,那么,反应器会通知我们 “可以读取数据了”,然后调用回调函数,利用recv函数从套接字读取数据,类似于MFC中的CSocket,在我们重写OnRecieve时,内部要调用Recv函数从套接字读取数据。

  2.如果是Proactor模式,那么会先调用WSARecv函数注册读事件,反应器会通知我们 “数据已经读取了”,回调函数触发时,数据已经被接收到事先提供的缓冲区中,整个IO过程是由操作系统完成的,而不需要我们自己调用recv函数来读取数据,直接在事先提供的缓冲区取数据就可以了。

 

 

 

 

观察者模式和Recactor模式,Proactor模式的主要区别:

  观察者模式,也叫发布-订阅模式,主要是适用于对象间一对多的依赖关系,通常用作消息分发和处理。而Reactor模式和Proactor模式主要用于高效的io模式,明显的特征是“回调”思想的运用,提高效率,避免没有必要的耗时的等待,与对象间的依赖关系无关。

 

====================================================================================================================================================================================================================================================================================================================================================================

 

WSARecv()和Recv()的区别

recv()定义在winsock.h,WSARecv定义在winsock2.h文件中。
recv和WSARecv:
对在已连接套接字上接受接入数据来说,recv函数是最基本的方式。它的定义如下:

C/C++ code?

1

2

3

4

5

int recv (

  SOCKET s,   

  char FAR* buf,   

  int len,   

  int flags);

第一个参数s,是准备接收数据的那个套接字。第二个参数buf,是即将收到数据的字符缓冲,而len则是准备接收的字节数或buf缓冲的长度。最后,flags参数可以是下面的值:0、MSG_PEEK或MSG_OOB。另外,还可对这些标志中的每一个进行按位和运算。当然, 0表示无特殊行为。MSG_PEEK会使有用的数据复制到所提供的接收端缓冲内,但是没有从系统缓冲中将它删除。另外,还返回了待发字节数。  

WSARecv函数在recv的基础上增加了一些新特性。比如说重叠I/O和部分数据报通知。定义如下:

C/C++ code?

1

2

3

4

5

6

7

8

int WSARecv (

  SOCKET s,   

  LPWSABUF lpBuffers,   

  DWORD dwBufferCount,   

  LPDWORD lpNumberOfBytesRecvd,   

  LPDWORD lpFlags,   

  LPWSAOVERLAPPED lpOverlapped,   

  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionROUTINE);

参数s,是已建立连接的套接字。第二和第三个参数是接收数据的缓冲。lpBuffers参数是一个WSABUF结构组成的数组,而dwBufferCount则表明前一个数组中WSABUF结构的数目。如果接收操作立即完成,lpNumberOfBytesReceived参数就会指向执行这个函数调用所收到的字节数。lpFlags参数可以是下面任何一个值:MSG_PEEK、MSG_OOB、MSG_PARTIAL或者对这些值进行按位和运算之后的结果。

 

====================================================================================================================================================================================================================================================================================================================================================================

 

Reactor 与Proactor

 

按照posix标准,系统io分为同步io和异步io两种,其中同步io常用的是bio nio。异步io有aio。

从程序的角度来看,bio在读和写的时候,会阻塞,只有当程序将流写入操作系统或者读到流后,阻塞才会结束,线程接着run下去。

而nio和aio属于非阻塞方式,他们都是基于事件驱动思想,但是nio采用的是reactor 模式,而aio采用的是proactor模式。

Reactor 模式使用event loop 阻塞等在io上,一但io可以读或写,通过分发器,遍历事件注册队列,将事件分发到指定注册的处理器。由应用的处理器来再将流读取到缓冲区或写入操作系统,完成io操作。

Proctor 模式下读和写的方法是异步的,只需调用读和写即可。当有流可读取的时候,操作系统会将流传入read方法缓存区,并通知应用程序。对于写,当操作系统将writer 写入完毕时,操作系统会主动通知应用程序。

proactor模式的Aio,流的读取和写入由操作系统完成,省去了遍历事件通知队列selector 的代价。

Windows上的iocp实现了aio,linux目前只有基于epoll模拟实现的aio。

 

====================================================================================================================================================================================================================================================================================================================================================================

 

Kafka总结(二):核心组件

2018年07月30日 13:03:19 一念成佛_LHY 阅读数:890

Kafka总结(一):Kafka概述

Kafka总结(二):Kafka核心组件

Kafka总结(三):Kafka核心流程分析

Kafka总结(四):Kafka命令操作

Kafka总结(五):API编程详解

Kafka总结(六):Kafka Stream详解

Kafka总结(七):数据采集应用

Kafka总结(八):KafKa与ELK整合应用

Kafka总结(九):KafKa 与Spark整合应用

1.KafKa核心组件

KafKa的核心功能模块:

  1. 延迟操作组件;
  2. 控制器;
  3. 协调器;
  4. 网络通信;
  5. 日志管理器;
  6. 副本管理器;
  7. 动态配置管理器
  8. 心跳检测;

1.延迟操作组件

1.DelayedOperation

KafKa将一些不立即执行而要等待满足一定条件之后才触发完成的操作称为延迟操作,并将这类操作定义为一个抽象类DelayedOperation,具有延迟操作的类继承DelayedOperation

2.DelayedOperationPurgatory

https://www.jianshu.com/p/bbb1c4f45b4e

3.DelayedProduce

DelayedProduce的作用就是协助副本管理器在acks为-1的场景的时候,延迟回调responseCallback向生产者做出响应。具体表现在当消息追加到分区Leader副本之后,该分区各个Follower完成了与Leader副本消息同步之后再回调responseCallback给生产者;

4.DelayedFetch

DelayedProduce是在ProduceRequest处理中对生产者发送消息的延迟操作,自然DelayedFetch就是在FetchRequest处理的时候进行的延迟操作。在KafKa中只有消费者或者Follower副本会发起FetchRequest请求。

5.DelayedJoin

DelayedJoin是协助组协调器在消费组准备平衡操作的时候进行相应的处理。当消费组的状态转换为PreparingRebalance时候,即准备进行平衡操作,在组协调器prepareRebalance()方法中会创建一个DelayedJoin对象,并交由DelayedOperationPurgatory负责监视管理;

在消费组进行平衡操作时之所以需要使用DelayedJoin处理,是为了让组协调器等待当前消费组下所有的消费者都请求加入到消费组,即发起了JoinGroupRequest请求。每次组协调器处理完JoinGroupRequest的时候都会检测DelayedJoin是否满足了完成执行的条件;

6.DelayedHearbeat

DelayedHearbeat用于协调消费者与组协调器心跳检测相关的延迟操作,DelayedHearbeat相关功能的实现是调用GroupCoordinator的相应方法

7.DelayedCreateTopics

在创建主题的时候,需要为主题的每个分区分配到Leader之后,才调用回调函数将创建主题结果返回给客户端。

DelayedCreateTopics延迟操作是等待该主题的所有分区副本分配到Leader或者等待超时之后调用回调函数返回给客户端;

2.控制器

http://blog.csdn.net/zhanglh046/article/details/72821995

在启动KafKa集群的时候,每一个代理都会实例化并且启动一个KafKaController,并将该代理的brokerId注册到ZooKeeper的相应节点中。KafKa集群中各代理会根据选举机制选出其中一个代理作为Leader,即Leader控制器。当控制器发生宕机之后其他代理再次竞选出新的控制器。

控制器负责主题的创建与删除、分区和副本的管理以及代理故障转移处理等。

当一个代理被选举成为控制器的时候,该代理对应的KafkaController就会注册(Register)控制器相应额操作权限,同时标记自己是Leader。当代理不再成为控制器的时候,就要注销掉DeRegister相应的权限;

实现这些功能的程序入口是KafKa核心core模块下的kafka.controller.KafkaController类。

1.控制器初始化

每个代理在启动的时候都会实例化并启动一个KafkaController,KafKaController实例化的时候主要完成如下的工作;

2.控制器选举过程

每个代理启动的时候就会创建一个KafKaController实例,当KafKaController启动之后就会从所有的代理中选择一个代理作为控制器,控制器是所有代理的Leader,因此这里也称之为Leader选举。

除了在启动的时候会导致选举之外,当控制器所在的代理发生故障或者ZooKeeper通过心跳机制感知控制器与自己的连接Session过期的时候,也会再次从所有代理中选出一个节点作为集群的控制器;

KafKa控制器的选举依赖于ZooKeeper。在集群的整个运行过程中,代理在ZooKeeper不同节点上注册相应的监听器。各监听器各司其职,当所有监听的节点状态发生变化的时候就会触发相关的函数进行处理。

KafKa控制器选举的核心思想就是各代理通过争抢/controller节点请求写入到自身的信息,先成功写入的代理即为Leader

3.故障转移

触发控制器进行选举有三种情况:

  1. 在控制器启动的时候;
  2. 当控制器发生故障转移的时候;
  3. 当心跳检测超时的时候;

可以说控制器故障转移的本质就是控制权的转移,而控制权的转移也就是重新选出新的控制器。

4.代理上线和下线

  1. 代理上线

当有新的代理上线的时候,在代理启动的时候会向ZooKeeper的/brokers/ids节点下注册该代理的brokerId,此时会被副本状态机在ZooKeeper所注册的BrokerChangerListener监听器监听到该节点信息的变化

  1. 代理下线

当代理下线的时候,该代理在ZooKeeper的/brokers/ids节点注册的与该代理对应的节点将被删除,此时BrokerChangerListener的handleChildCHANGE方法将会被触发;

5.主题管理

控制器负责对主题、分区副本的管理操作。

分区和副本是主题的固有属性,因此在讲解控制器对主题管理的时候将同时讲解控制器对分区副本创建以及删除的管理操作。

控制器对分区、副本的管理在逻辑上体现在分区状态机以及副本状态机对ZooKeeper的/brokers/topics节点以及子节点注册的一系列监听器上;

  1. 创建主题

当创建一个主题的时候,无论是通过KafKa API还是通过命令行创建主题,同步返回创建主题成功的时候,其实仅仅都是在ZooKeeper的/brokers/topics节点成功创建了该主题对应的子节点。而服务端创建主题的操作是异步交由控制器去完成的。

  1. 删除主题:

客户端执行删除主题的操作的时候仅仅是在ZooKeeper的/admin/delete_topics路径下创建一个与待删除主题同名的节点,返回该主题被标记为删除,保证本步操作成功执行的前提是配置项delete.topic.enable的值被设置为true

例如,删除主题“topic-foo”的节点,则客户端执行删除操作的时候会在/admin/delete_topics路径下创建一个名为“topic-foo” 的节点。而实际删除主题的逻辑是异步交由KafKa控制器负责执行的;

6.分区管理

KafKa控制器(Leader控制器)对分区的管理包括对分区的创建以及删除的管理,分区Leader选举的管理,分区自动平衡、分区副本重新分配的管理等等。

  • 分区平衡

在onControllerFailover操作的时候会启动一个分区自动平衡的定时任务,该定时任务会定期的检查集群上面各代理分布是否失去了平衡,该过程是通过调用控制器的checkAndTriggerPartitionRebalance方法完成的;

分区自动平衡需要保证分区的副本分配在不提供的代理节点上

  • 分区充分配

3.协调器

KafKa提供了消费者协调器(ConsumerCoordinator)、组协调器(GroupCoordinator)以及任务管理协调器(WorkCoordinator)3协调器(coordinator)。其中任务管理协调器被KafKa Connect用于对works的管理。

 

鉴于旧版高级消费者存在的问题,新版消费者进行了重新设计,为了解决消费者依赖ZooKeeper所带来的问题,KafKa在服务端引入了组协调器(GroupCoordinator),每个KafKaServer启动的时候都会创建一个GroupCoordinator实例,用于管理部分消费组和该消费组下每个消费者的消费偏移量

同时在客户端引入了消费者协调器(ConsumerCoordinator),每个KafKaConsumer实例化的时候会实例化一个ConsumerCoordinator对象,消费者协调器负责同一个消费组下各消费者与服务端组协调器之间的通信;

1.消费者协调器

消费者协调器(ConsumerCoordinator)是KafkaConsumer的一个成员变量,该KafKaConsumer通过消费者协调器与服务端的组协调器进行通信。由于消费者协调器是KafKaConsumer私有的,因此消费者协调器中存储的信息也只有与之对应的消费者可见,不同消费者是看不到彼此的消费者协调器中的信息的。

 

         消费者协调器负责处理更新消费者缓存的Metadata请求,负责向组协调器发起加入消费组的请求,负责对本消费者加入消费组前、后相应的处理,负责请求离开消费组(如当消费者取消订阅的时候),还负责向组协调器发送提交消费偏移量的请求。并通过一个心跳检测定时任务来检测组协调器的运行状况,或者是让组协调器感知自己的运行状况。

 

         同时Leader消费者的消费者协调器还负责执行分区的分配,当消费者协调器向组协调器请求加入消费组之后,组协调器会为同一个组下的消费者选出一个Leader;

 

通过这种方式,将分区分配的职责交由客户端自己处理,从而减轻了服务端的负担;

 

 

2.组协调器

组协调器(GroupCoordinator)负责对其管理的组员提交的相关请求进行处理,这里的组员即消费者。它负责管理与消费者之间建立连接,并从与之连接的消费者中选出一个消费者作为Leader消费者,Leader消费者负责消费者分区的分配,在SyncGroupRequest请求时发送给组协调器,组协调器会在请求处理后返回响应时下发给其管理的所有消费者。同时组协调器还管理与之连接的消费者的消费偏移量的提交,将每个消费者消费偏移量保存到KafKa的内部主题当中,并通过心跳检测来检测消费者与自己的连接状态;

  1. 组协调器依赖的组件

每一个KafkaServer启动的时候都会实例化并启动一个组协调器,每个组协调器负责一部分消费组的管理。

  1. 消费者入组过程
  2. 消费偏移量管理

新版的KafKaConsumer将消费偏移量保存到KafKa的一个内部主题中,当消费者正常运行或者进行平衡操作的时候都需要向组协调器提交当前的消费偏移量。

4.网络通信

在KafKaServer启动的时候,初始化并启动了一个SocketServer服务,用于接收客户端的连接、处理客户端请求、发送响应等。

同时创建了一个KafkaRequestHandlerPool用于管理KafkaRequestHandler。

 

SocketServer是基于java NIO实现的网络通信组件;

 

 

其线程模型为:

         一个Acceptor线程负责接收客户端所有的连接;

         N个Processor线程,每个Processor有多个Selector,负责从每个连接中读取请求;

         M个Handler线程处理请求,并将产生的结果返回给Processor线程。而Handler是由KafkaRequestHanderPool管理,在Processor和Handler之间通过RequestChannel来缓冲请求,每个Handler从RequestChannel.requestQueue接受RequestChannel.Request,并把Request交由KafkaApis的Handler()方法处理,经过处理之后把对应的Response存进RequestChannel.responseQueues队列。

1.Acceptor

Acceptor是一个线程类,主要职责是监听并接受客户端的请求,建立和客户端的数据传输通道ServerSocketChannel,然后为客户端制定一个Processor;

2.Processor

也是一个线程类,主要用于从客户端读取请求数据和将相应的响应结果返回给客户端。

3.RequestChannel

RequestChannel是为了给Processor线程与Handler线程之间通信提供数据缓冲,是通信过程中Request和Response缓存的通道,是ProCSSor线程与Handler线程交换数据的地方;

4.SocketServer启动过程

在启动一个KafKa代理的时候会实例化并启动一个SocketServer服务;

SocketServer启动之后就可以通过Acceptor接收客户端的请求;

5.日志管理器

1. KafKa日志结构

KafKa消息是以主题为基本单位进行组织的,各个主题之间相互独立。

每个主题在逻辑结构上又可以由一个或者多个分区构成,分区数可以在创建主题的时候指定,也可以在主题创建之后再进行修改。

可以通过Kafka自带的用于主题管理操作的脚本kafka-topics.sh来修改某个主题的分区数,但是只能增加一个主题的分区数目,而不能减少其分区数目。

每个分区可以有一个或者多个副本,从副本中选出一个副本作为Leader,Leader负责与客户端进行读写操作,其他副本作为Follower。

生产者将消息发送到Leader副本的代理节点,而Follower副本从Leader副本同步数据;

从存储结构上,分区的每个副本在逻辑上对应一个Log对象,每个Log对象又划分为多个LogSegment,每个LogSegment包括一个日志文件和两个索引文件,其中两个索引文件分别为偏移量索引文件和时间戳索引文件。Log负责对 LogSegment的管理,在Log对象中维护了一个ConcurrentSkipListMap,保存该主题所有分区对应的所有LogSegment;

KafKa将日志文件封装为一个FileMessageSet对象,两个索引文件封装为OffsetIndex和TimeIndex对象;

数据文件用于存储消息,每条消息有一个固定长度的消息头和一个可变长度(N字节)的净荷(payload)组成,

2.日志管理器启动过程

3.日志加载以及恢复

4.日志清理

KafKa将一个主题的每个分区副本分成多个日志段文件,这样通过定时日志清理操作,将旧的日志文件及时的清理并释放出空间,以避免磁盘上的日志段文件过大而导致新的日志无法写入。同时分成多个日志段文件而不是一个文件也便于清理操作。

我们可以通过日志段的更新时间或者是日志段的大小控制进行日志的清理;

KafKa提供了日志删除(delete)和日志压缩(compact)两种清理日志的策略,通过参数cleanup.policy来指定日志清理的策略。

【日志删除】

在日志管理器启动的时候会启动一个后台定时任务用于定时删除日志段文件

【日志压缩】

另外一种日志清理的策略是日志压缩,这种策略是一种更细粒度的清理策略,它基于消息的Key,通过压缩每个Key对应的消息只保留最后一个版本的数据,该Key对应的其他版本在压缩的时候会被清除,类似数据库的更新操作;

6.副本管理器

6.副本管理器

引入副本机制使得KafKa能够在整个集群中只要保证至少有一个代理存活就不会影响整个集群的工作,从而大大提高了KafKa集群的可靠性和稳定性。

KafKa对代理的存活必须满足两个条件:

(1)一个存活的节点必须与ZooKeeper保持连接,维护与ZooKeeper的Session(通过ZooKeeper的心跳机制来实现)

(2)如果一个节点作为Follower副本,该节点必须即时的与分区的Leader副本保持消息绒布,不能落后太久;

1.分区

KafKa将一个主题在逻辑上分为一个或者多个分区,每个分区在物理存储上对应一个目录,

分区目录下存储的是该分区的日志段,包括日志数据文件和两个索引文件。每个分区又对应一个或者多个副本。

需要注意的是:分区的个数可以大于节点数,但是副本数不能大于节点数,因为副本需要分布在不同的节点上,这样才能达到备份的目的;

每个主题的某一个分区只能被同一个消费组下的其中一个消费者消费,因此我们说分区是消费并行度的基本单位。同时,对于上层应用而言,也是最小的存储单元。

尽管每个分区是有一系列有序的顺序端组成,从消费者角度来讲,我们订阅消费一个主题,也就是订阅了该主题的所有分区,当然也可以指定订阅主题的某个分区。

从生产者的角度来讲,我们可以通过指定消息的Key以及分区分配策略将消息发送到主题相应的分区当中;

KafKa将分区抽象为一个Partition对象,Partition定义了一个assignedReplicaMap引用用于保存该分区的所有副本,assignedReplicaMap是一个Pool类型的对象,并维护了该分区铜鼓的副本集合inSyncReplicas,同时Partition对象定义了分区对副本操作的方法,包括创建副本、副本角色切换、ISR列表维护以及调用日志管理器(LogManager)追加消息等。

2.副本

一个分区可以有一个或者多个副本,根据是否接受读写请求,又分为Leader副本和Follower副本,一个分区有1个Leader副本,有0个或者多个Follower副本;

Leader副本处理分区的所有的读写请求并维护自身以及Follower副本的状态信息,如LEO、HW等。Follower副本作为消费者从Leader副本拉取消息进行同步,当Leader失效的时候,通过分区Leader选举器从副本列表中选出一个副本作为新的Leader;

KafKa将副本抽象为一个Replica对象;

3.副本管理器启动过程

每个代理启动的时候都会启动一个副本管理器;

4.副本过期检查

副本管理器启动的时候启动了一个对副本过期检查的定时任务,该定时任务调用副本管理器的maybeShrinkIsr方法定期进行副本过期检查,其功能就是检查分区ISR是否需要进行收缩,即从ISR踢出与Leader数据不同步的副本;

5.追加消息

当生产者发送消息(ProduceRequest)或者消费者提交偏移量到内部主题的时候,由副本管理器的appendMessage将消息追加到相应分区的Leader副本中。

6.拉取消息

副本管理器除了负责将消息写入到Leader副本之外,同时还负责处理KafkaApis的FetchRequest请求,从分区Leader副本获取消息;

从KafKa中拉取消息的角色有两个,一个是KafKa的普通消费者,另一个就是Follower副本;

副本管理器通过FetchRequest请求的replicaId来区分拉取请求的角色;

因为每个副本有replicaId属性,即副本的replicaId总是非负数,而消费者的replicaId的值为-1;

7.副本同步过程

8.副本角色转换

当分区ISR发生变化的时候,控制器会向分区各副本对应的代理发出LeaderAndISRRequest请求,各代理的副本管理器接收到请求之后调用becomeLeaderOrFollower()方法进行处理;

9.关闭副本

关闭副本操作通常有两种方式:

第一种:将副本下线;

第二种:将副本下线并删除;

7.Handler

Handler其实是KafkaRequestHandler的简称,KafkaRequestHandler是一个线程类,负责从RequestChannel中读取请求然后交由KafkaApis处理;

 

8.动态配置管理器

动态配置管理器(DynamicConfiManager)主要用来对相关配置的变化进行处理,KafKa将可以通过ZooKeeper进行管理的配置划分为4个类型,称为配置类型(ConfigType)或者配置级别,每个配置类称为一个实体,这4个类型分别为Topic(主题级别)、Client(客户端级别)、User(用户级别)、和Broker(代理级别);

  • Topic(主题级别):监听器会调用主题级别配置处理器TopicConfigHandler进行处理
  • Client(客户端级别):ClientIdConfigHandler
  • User(用户级别):UserConfigHandler进行处理
  • 和Broker(代理级别):通知处理器会调用代理级别的配置处理器BrokerConfigHandler对配置进行处理

9.代理健康检测

KafKa集群依赖于ZooKeeper进行管理,每个代理启动的时候都向ZooKeeper进行一系列元数据的注册,即在ZooKeeper相应目录下创建一个临时节点,当代理与ZooKeeper连接断开之后,相应的临时节点也会被删除;

KafKa健康检测机制实现类是KafkaHealthcheck,该类实例化的时候创建一个SessionExpireListener监听器,该监听器实现了IZKStateListener接口,

10. KafKa内部监控

KafKa使用Yammer Metrics进行内部状态的监控,用来收集报告KafkaServer端和客户端的metrics信息。Yammer Metrics是Yammer提供的一个Java库,用于检测JVM上相关服务运行的状态;

  • 3
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值