生产者
1.1 负载均衡
生产者直接发送消息到分区的leader上,中间不需要任何路由选择。 客户端控制了消息将被送到哪个分区上。通常能够使用随机负荷分担的方式,或者使用某种标志来分配。我们提供了接口,允许用户指定关键字来hash到特定的分区(语义分区),比如,使用user id来作为关键字,那么特定用户的消息就会被发送到相同的分区中。这样也可以允许消费者明确他们的消费,这种设计明确允许Locality-sensitive处理。
1.2 异步发送
批量处理可以极大的提升处理效率,在Kafka中,生产者有个选项(producer.type=async)可用指定使用异步分发出产请求(produce request)。这样就允许用一个内存队列(in-memory queue)把生产请求放入缓冲区,然后再以某个时间间隔或者事先配置好的批量大小将数据批量发送出去(如64K,10ms)。因为一般来说数据会从一组以不同的数据速度生产数据的异构的机器中发布出,所以对于代理而言,这种异步缓冲的方式有助于产生均匀一致的流量,因而会有更佳的网络利用率和更高的吞吐量。
1.3 新生产者
如上图,蓝色的框图属于Producer对象中的部件。客户端调用Send接口发送消息,先会追加到RecordAccumulator中形成一个一个的RecordBatch,然后再由后台的Sender Thread针对每个节点发送请求,请求中带有每个分区的RecordBatch。Kafka集群给Sender Thread的请求回响应,每个RecordBatch中的消息要么都写入成功,要么都失败。
New Producer与Old Producer最主要的区别就是New Producer使用非阻塞式IO并且实现都是基于异步的,同步接口也是基于异步实现。而Old Producer的异步接口是基于同步接口来实现。用下面的图可以说明问题:
可以看出使用非阻塞式的设计可以使得Producer同样发送3个请求完成的时间大大减少,提升吞吐量。 具体发送流程如下:
消费者
Kafka的消费者是通过往broker发起一个“fetch”操作来获取分区中的数据的。每个请求中都携带有“offset”,分区将从这个位置返回一块数据给消费者。因此,消费的主动权是在消费者上的,消费者甚至可以移动offset来重复消费。
2.1 Push vs Pull
到底是应该让使用者从代理那里吧数据Pull(拉)回来还是应该让代理把数据Push(推)给使用者。和大部分消息系统一样,Kafka在这方面遵循了一种更加传统的设计思路:由生产者将数据Push给代理,然后由使用者将数据代理那里Pull回来。 近来有些系统,比如scribe和flume,更着重于日志统计功能,遵循了一种非常不同的基于Push的设计思路,其中每个节点都可以作为代理,数据一直都是向下游Push的。上述两种方法都各有优缺点。然而,因为基于Push的系统中代理控制着数据的传输速率,因此它难以应付大量不同种类的使用者。我们的设计目标是,让使用者能以它最大的速率使用数据。不幸的是,在Push系统中当数据的使用速率低于产生的速率时,使用者往往会处于超载状态(这实际上就是一种拒绝服务攻击)。基于Pull的系统在使用者的处理速度稍稍落后的情况下会表现更佳,而且还可以让使用者在有能力的时候往往前赶赶。让使用者采用某种退避协议(backoff protocol)向代理表明自己处于超载状态,可以解决部分问题,但是,将传输速率调整到正好可以完全利用(但从不能过度利用)使用者的处理能力可比初看上去难多了。以前我们尝试过多次,想按这种方式构建系统,得到的经验教训使得我们选择了更加常规的Pull模型。 另外一个好处就是:基于pull的系统,可以批量打包数据送给消费者。一个PUSH系统,要么立即送一个请求,要么在不知道消费者能够处理的情况下,聚集一些数据,延迟一次发送。这样会引入不必要的时延。基于PULL的设计,总是能够立即去pull所有的可用的消息,所以能够得到最优的批量打包,而不会引入时延。 原生的pull系统一个缺点就是:当broker没有消息的时候,consumer会进入循环等待,忙等待数据达到。为了避免这种情况,可以在pull请求中设置“long pull”参数(等待给定数目的消息可用)。
2.2 消费者状态
追踪(客户)消费了什么是一个消息系统必须提供的一个关键功能之一。它并不直观,但是记录这个状态是该系统的关键性能之一。
大部分消息系统保留着关于代理者使用(消费)的消息的元数据。也就是说,当消息被交到客户手上时,代理者自己记录了整个过程。这是一个相当直观的选择,而且确实对于一个单机服务器来说,它(数据)能去(放在)哪里是不清晰的。又由于许多消息系统存储使用的数据结构规模小,所以这也是个实用的选择--因为代理者知道什么被消费了使得它可以立刻删除它(数据),保持数据大小不过大。
也许不显然的是,让代理和使用者这两者对消息的使用情况做到一致表述绝不是一件轻而易举的事情。如果代理每次都是在将消息发送到网络中后就将该消息记录为已使用的话,一旦使用者没能真正处理到该消息(比方说,因为它宕机或这请求超时了抑或别的什么原因),就会出现消息丢失的情况。为了解决此问题,许多消息系新加了一个确认功能,当消息发出后仅把它标示为已发送而不是已使用,然后代理需要等到来自使用者的特定的确认信息后才将消息记录为已使用。这种策略的确解决了丢失消息的问题,但由此产生了新问题。首先,如果使用者已经处理了该消息但却未能发送出确认信息,那么就会让这一条消息被处理两次。第二个问题是关于性能的,这种策略中的代理必须为每条单个的消息维护多个状态(首先为了防止重复发送就要将消息锁定,然后,然后还要将消息标示为已使用后才能删除该消息)。另外还有一些棘手的问题需要处理,比如,对于那些以发出却未得到确认的消息该如何处理?
Kafka有一些特别的做法,Topic被划分为一系列有序的分区,每个分区在任何时候都只有一个消费者来消费。这意味着在每个分区中,消费者位置就是一个简单的数值。这使得消费者位置确认的消耗非常小,一个分区一个数值而已。这个数据能被周期性的确认,非常容易。 这个决策还带来一个额外的好处。使用者可用故意回退(rewind)到以前的偏移量处,再次使用一遍以前使用过的数据。虽然这么做违背了队列的一般协约(contract),但对很多使用者来讲却是个很基本的功能。举个例子,如果使用者的代码里有个Bug,而且是在它处理完一些消息之后才被发现的,那么当把Bug改正后,使用者还有机会重新处理一遍那些消息。
2.3 新消费者
以前老的消费者主要碰到主要受到脑裂和羊群效应的影响,这些问题包括:
2.3.1 脑裂:
在一个大集群中往往会有一个master存在,在长期运行过程中不可避免的会出现宕机等问题导致master不可用,在出现这样的情况以后往往会对系统产生很大的影响,所以一般的分布式集群中的master都采用了高可用的解决方案来避免这样的情况发生。
master-slaver方式,存在一个master节点,平时对外服务,同时有一个slaver节点,监控着master,同时有某种方式来进行数据的同步。如果在master挂掉以后slaver能很快获知并迅速切换成为新的master。在以往master-slaver的监控切换是个很大的难题,但是现在有了Zookeeper的话能比较优雅的解决这一类问题。
master-slaver结构 master-slaver实现起来非常简单,而且在master上面的各种操作效率要较其他HA解决方案要高,早期的时候监控和切换很难控制,但是后来zookeeper出现了,他的watch和分布式锁机制很好的解决了这一类问题。
我们的系统和同事的系统都是这种模式,但是后来都发现由于ZooKeeper使用上的问题存在脑裂的问题。 记得很久以前参加一个大牛的技术交流会他就提到过在集群中假死问题是一个非常让人头痛的问题,假死也是导致脑裂的根源。
根据一个什么样的情况能判断一个节点死亡了down掉了,人可能很容易判断,但是对于在分布式系统中这些是有监控者来判断的,对于监控者来说很难判定其他的节点的状态,唯一可靠点途径的就是心跳,包括ZooKeeper就是使用心跳来判断客户端是否仍然活着的,使用ZooKeeper来做master HA基本都是同样的方式,每个节点都尝试注册一个象征master的临时节点其他没有注册成功的则成为slaver,并且通过watch机制监控着master所创建的临时节点,Zookeeper通过内部心跳机制来确定master的状态,一旦master出现意外Zookeeper能很快获悉并且通知其他的slaver,其他slaver在之后作出相关反应。这样就完成了一个切换。这种模式也是比较通用的模式,基本大部分都是这样实现的,但是这里面有个很严重的问题,如果注意不到会导致短暂的时间内系统出现脑裂,因为心跳出现超时可能是master挂了,但是也可能是master,zookeeper之间网络出现了问题,也同样可能导致。这种情况就是假死,master并未死掉,但是与ZooKeeper之间的网络出现问题导致Zookeeper认为其挂掉了然后通知其他节点进行切换,这样slaver中就有一个成为了master,但是原本的master并未死掉,这时候client也获得master切换的消息,但是仍然会有一些延时,zookeeper需要通讯需要一个一个通知,这时候整个系统就很混乱可能有一部分client已经通知到了连接到新的master上去了,有的client仍然连接在老的master上如果同时有两个client需要对master的同一个数据更新并且刚好这两个client此刻分别连接在新老的master上,就会出现很严重问题。
出现这种情况的主要原因在与Zookeeper集群和Zookeeperclient判断超时并不能做到完全同步(这些还依赖于操作系统调度等,很难保证),也就是说可能一前一后,如果是集群先于client发现那就会出现上面的情况了。同时在发现并切换后通知各个客户端也有先后快慢。出现这种情况的几率很小,需要master与zookeeper集群网络断开但是与其他集群角色之间的网络没有问题,还要满足上面那些条件,但是一旦出现就会引发很严重的后果,数据不一致了。
避免这种情况其实也很简单,在slaver切换的时候不在检查到老的master出现问题后马上切换,而是在休眠一段足够的时间,确保老的master已经获知变更并且做了相关的shutdown清理工作了然后再注册成为master就能避免这类问题了,这个休眠时间一般定义为与zookeeper定义的超时时间就够了,但是这段时间内系统可能是不可用的,但是相对于数据不一致的后果我想还是值得的。
当然最彻底的解决这类问题的方案是将master HA集群做成peer2peer的,屏蔽掉外部Zookeeper的依赖。每个节点都是对等的没有主次,这样就不会存在脑裂的问题,但是这种ha解决方案需要使用两阶段,paxos这类数据一致性保证协议来实现,不可避免的会降低系统数据变更的系统,如果系统中主要是对master的读取操作很少更新就很适合了。
2.3.2 羊群效应
例如Zookeeper的一个羊群效应例子是当一个特定的znode 改变的时候ZooKeper 触发了所有watches 的事件。
举个例子,如果有1000个客户端watch 一个znode的exists调用,当这个节点被创建的时候,将会有1000个通知被发送。这种由于一个被watch的znode变化,导致大量的通知需要被发送,将会导致在这个通知期间的其他操作提交的延迟。因此,只要可能,我们都强烈建议不要这么使用watch。仅仅有很少的客户端同时去watch一个znode比较好,理想的情况是只有1个。
举个例子,有n 个clients 需要去拿到一个全局的lock.一种简单的实现就是所有的client 去create 一个/lock znode.如果znode 已经存在,只是简单的watch 该znode 被删除。当该znode 被删除的时候,client收到通知并试图create /lock。这种策略下,就会存在上文所说的问题,每次变化都会通知所有的客户端。
New Consumer采用协调者来避免羊群效应,只由协调者来watch 分区的变化,不需要每个consumer都watch。
2.3.3 协调者
新消费者为了不依赖zk,需要使用kafka完成同一个消费组内消费者之间的对Topic和分区的分配工作。新消费者只需要使用其中一个broker作为分布式协调即可,每一个broker都可被选举为一部分消费组的协调者(coordinator)。其主要职责在于策划rebalance操作时分区的分配,同时负责把分区的分配信息告知消费者。
2.3.4 消费流程