中间件系列之Kafka-2-设计与实现

294 阅读17分钟

经过上一篇文章,已经知道了Kafka的一些基本概念,但作为一个成熟的中间件,Kafka的特点显然不止于此,其中一些设计思想更是值得我们思考。本篇文章我们简单列举一些,后续在做更详细的介绍

1.Kafka存储在文件系统上

我们首先要知道 Kafka 的消息是存在于文件系统之上的。一般情况下大家认为 “磁盘是缓慢的”,对于Kafka采用这样的设计可能持有怀疑态度。实际上,一个好的磁盘结构设计可以使之跟网络速度一样快。比如,如果是针对磁盘的顺序访问,某些情况下它可能比随机的内存访问都要快,甚至可以和网络的速度相差无几。Kafka 就是基于这种设计思想,高度依赖文件系统来存储和缓存消息,这也是 Kafka 高吞吐率的一个很重要的保证。

上文中提到的Topic 其实是逻辑上的概念,面向消费者和生产者,物理上存储的其实是 Partition,每一个 Partition 最终都会对应一个目录,里面存储所有的消息和索引文件。默认情况下,每一个 Topic 在创建时如果不指定 Partition 数量时只会创建 1 个 Partition。比如,我创建了一个 Topic 名字为 test ,没有指定 Partition 的数量,那么会默认创建一个 test-0 的文件夹,这里的命名规则是:<topic_name>-<partition_id>

img

任何发布到 Partition 的消息都会被追加到 Partition 数据文件的尾部,这样的顺序写磁盘操作让 Kafka 的效率非常高。

每一条消息被发送到 Broker 中,会根据 Partition 规则选择被存储到哪一个 Partition。如果 Partition 规则设置的合理,所有消息可以均匀分布到不同的 Partition中。

2.Kafka 中的底层存储设计

考虑如下情况,一个 Kafka 集群只有一个 Broker,我们创建 2 个 Topic 名称分别为:「topic1」和「topic2」,Partition 数量分别为 1、2,那么我们的根目录下就会创建如下三个文件夹:

    | --topic1-0    
    | --topic2-0    
    | --topic2-1 

在 Kafka 的文件存储中,同一个 Topic 下有多个不同的 Partition,每个 Partition 都为一个目录,而每一个目录又被平均分配成多个大小相等的 Segment File 中,默认每个Segment大小不超过1G,且只包含7天的数据。如果段的消息量达到1G,那么该段会关闭,同时打开一个新的段进行写入。

正在写入的段称为活跃段(active segment),活跃段不会被删除。因此,假如设置日志保留时间为1天,但是日志段包含5天的数据,那么我们实际上会保留5天的数据,因为活跃段正在使用而且在关闭前不会删除。

Segment又由.index文件、.log文件、.timeindex文件组成,log文件就实际是存储message的地方,而index和timeindex文件为索引文件,用于检索消息。

现在假设我们设置每个 Segment 大小为 500 MB,并启动生产者向 topic1 中写入大量数据,topic1-0 文件夹中就会产生类似如下的一些文件:

    | --topic1-0        
    	| --00000000000000000000.index        
    	| --00000000000000000000.log        
    	| --00000000000000368769.index         
    	| --00000000000000368769.log        
    	| --00000000000000737337.index        
    	| --00000000000000737337.log         
    | --topic2-0    
    | --topic2-1 

Segment 是 Kafka 文件存储的最小单位。其命名规则如下:Partition 全局的第一个 Segment 从 0 开始,后续每个 Segment 文件名为上一个 Segment 文件最后一条消息的 offset 值。数值最大为 64 位 long 大小,19 位数字字符长度,没有数字用0填充。

我们以上面的一对 Segment File 为例,说明一下索引文件和数据文件对应关系:

img

以索引文件中元数据 <3, 497> 为例,依次在数据文件中表示第 3 个 message(在全局 Partition 表示第 368769 + 3 = 368772 个 message)以及该消息的物理偏移地址为 497。

注意到该 index 文件并不是从0开始,也不是每次递增1的,这是因为 Kafka 采取稀疏索引存储的方式,每隔一定字节的数据建立一条索引,这样避免了索引文件占用过多的空间,从而可以将索引文件保留在内存中,降低了查询时的磁盘 IO 开销,同时也并没有给查询带来太多的时间消耗。但缺点是没有建立索引的Message也不能一次定位到其在数据文件的位置,从而需要做一次顺序扫描,但是这次顺序扫描的范围就很小了。

因为其文件名为上一个 Segment 最后一条消息的 offset ,所以当需要查找一个指定 offset 的 message 时,通过在所有 segment 的文件名中进行二分查找就能找到它归属的 segment ,再在其 index 文件中找到其对应到文件上的物理位置,就能拿出该 message 。

由于消息在 Partition 的 Segment 数据文件中是顺序读写的,且消息消费后不会删除(删除策略是针对过期的 Segment 文件),这种顺序磁盘 IO 存储设计是 Kafka 高性能很重要的原因。

Kafka 是如何准确的知道 message 的偏移的呢?这是因为在 Kafka 定义了标准的数据存储结构,在 Partition 中的每一条 message 都包含了以下三个属性:

  • offset:表示 message 在当前 Partition 中的偏移量,是一个逻辑上的值,唯一确定了 Partition 中的一条 message,可以简单的认为是一个 id;
  • MessageSize:表示 message 内容 data 的大小;
  • data:message 的具体内容

3.Kafka集群

成员管理

Kafka使用Zookeeper管理集群成员状态,每一个broker都有一个唯一ID(在配置文件中指定或者自动生成),当broker启动时会在Zookeeper中注册相应的临时节点。如果集群中存在相同的ID,那么新的broker会启动失败。

Zookeeper中的节点注册路径为/broker/ids,Kafka的各个组件会监听此路径下的变更信息,当broker加入或者离开时,它们会收到通知。当节点离开(可能由于停机、网络故障、长GC等导致)时,Zookeeper中相应的节点会消失,但该broker的ID仍然会在某些数据结构中存在。比如,每个主题的副本列表会包含副本所在的broker ID,因此如果一个broker离开同时有一个新的broker拥有此相同的ID,那么新的broker会在集群中替代之前的broker,并且会被分配同样的主题和分区。

控制器(Controller)

集群控制器也是一个broker,在承担一般的broker职责外,它还负责选举分区的主副本(下述第四点会提到)。集群中的第一个broker通过在Zookeeper的/controller路径下创建一个临时节点来成为控制器。当其他broker启动时,也会试图创建一个临时节点,但是会收到一个“节点已存在”的异常,这样便知道当前已经存在集群控制器。这些broker会监听Zookeeper的这个控制器临时节点,当控制器发生故障时,该临时节点会消失,这些broker便会收到通知,然后尝试去创建临时节点成为新的控制器。

对于一个控制器来说,如果它发现集群中的一个broker离开时,它会检查该broker是否有分区的主副本,如果有则需要对这些分区选举出新的主副本。控制器会在分区的副本列表中选出一个新的主副本,并且发送请求给新的主副本和其他的跟随者;这样新的主副本便知道需要处理生产者和消费者的请求,而跟随者则需要向新的主副本同步消息。

如果控制器发现有新的broker(这个broker也有可能是之前宕机的)加入时,它会通过此broker的ID来检查该broker是否有分区副本存在,如果有则通知该broker向相应的分区主副本同步消息。

最后,每当选举一个新的控制器时,就会产生一个新的更大的控制器时间戳(controller epoch),这样集群中的broker收到控制器的消息时检查此时间戳,当发现消息来源于老的控制器,它们便会忽略,以避免脑裂(split brain)。

4.复制(Replica)

通过复制机制,Kafka达到了高可用的要求,保证在少量节点发生故障时集群仍然可用。如前所述,每个主题都有若干个分区,而每个分区有多个副本,这些副本都存在broker中。分为一下两种类型:

  • 主副本(leader replica):每个分区都有唯一的主副本,所有的生产者和消费者请求都由主副本处理,这样才能保证一致性。
  • 跟随者副本(follower replica):分区的其他副本为跟随者副本,跟随者副本不处理生产者或消费者请求,它们只是向主副本同步消息,当主副本所在的broker宕机后,跟随者副本会选举出新的主副本。

对于主副本来说,它还有一个职责,那就是关注跟随者副本的同步状态。每个跟随者副本都会保持与主副本同步,但在异常情况(例如网络阻塞、机器故障、重启等)下,跟随者的状态可能会同步失败。跟随者副本通过向主副本发送Fetch请求来进行同步(与消费者一样),请求中包含希望同步的下一个消息位移,该位移始终是有序的。比如,一个跟随者可能会按序请求消息1,消息2,消息3…如果跟随者请求消息N,那么主副本可以确定此跟随者已经接收到N-1及以前的消息了。因此根据请求中的位移信息,主副本知道跟随者的落后状态,如果副本超过10秒钟(可通过参数设置)没有发送同步请求或者请求的位移属于10秒钟以前的位移,那么主副本会认为该跟随者是同步落后(out of sync)的。如果一个跟随者副本是同步落后的,那么在主副本发生故障时该跟随者不能成为新的主副本。而能够及时同步的跟随者副本则是in-sync状态的,这些跟随者副本有资格成为新的主副本。

每个分区除了有主副本之外,还有一个备选主副本(preferred leader),它是主题初始创建时的主副本。在主题初始创建时,Kafka使用一定的算法来分散所有主题的主副本与跟随者副本,因此备份主副本通常能保证集群的流量是均衡分布的。如果备份主副本是in-sync状态的,那么在主副本发生故障后,它会自动成为新的主副本。

3.生产者设计

对于消息队列的生产者,老生常谈的几个问题:每条消息都是很关键且不能容忍丢失么?偶尔重复消息可以么?我们关注的是消息延迟还是写入消息的吞吐量?比如一个信用卡交易处理系统,当交易发生时会发送一条消息到 Kafka,另一个服务来读取消息检查交易是否通过,将结果通过 Kafka 返回。对于这样的业务,消息既不能丢失也不能重复,由于交易量大因此吞吐量需要尽可能大,延迟可以稍微高一点;再比如需要收集用户在网页上的点击数据这样的场景,少量消息丢失或者重复是可以容忍的,延迟多大都不重要只要不影响用户体验,吞吐则可以根据实时用户数来决定。

不同的业务需要使用不同的写入方式和配置。具体的方式我们可以后续讨论,现在先看下生产者写消息的基本流程:

img

流程如下:

  1. 首先,我们需要创建一个ProducerRecord,这个对象需要包含消息的主题(topic)和值(value),可以选择性指定一个键值(key)或者分区(partition)。
  2. 发送消息时,生产者会对键值和值序列化成字节数组,然后发送到分配器(partitioner)。
  3. 如果我们指定了分区,那么分配器返回该分区即可;否则,分配器将会基于键值来选择一个分区并返回。
  4. 选择完分区后,生产者知道了消息所属的主题和分区,它将这条记录添加到相同主题和分区的批量消息中,另一个线程负责发送这些批量消息到对应的Kafka broker。
  5. 当broker接收到消息后,如果成功写入则返回一个包含消息的主题、分区及位移的RecordMetadata对象,否则返回异常。
  6. 生产者接收到结果后,对于异常可能会进行重试。

4.消费者设计

消费者与消费组

在上文中我们提到,消费者以消费组(consumer group)的方式来来工作,一起协作来消费同一个主题的消息。每个消费者会收到不同分区的消息。假设有一个T1主题,该主题有4个分区;同时我们有一个消费组G1,这个消费组只有一个消费者C1。那么消费者C1将会收到这4个分区的消息,如下所示:

img

如果我们增加新的消费者C2到消费组G1,那么每个消费者将会分别收到两个分区的消息,如下所示:

img

如果增加到4个消费者,那么每个消费者将会分别收到一个分区的消息,如下所示:

img

但如果我们继续增加消费者到这个消费组,剩余的消费者将会空闲,不会收到任何消息:

img

综上,一般建议创建主题时使用比较多的分区数,这样可以在消费负载高的情况下增加消费者来提升性能。另外,消费者的数量不应该比分区数多,因为多出来的消费者是空闲的,没有任何帮助。

Kafka另外一个重要特性就是,只需写入一次消息,可以支持任意多的应用读取这个消息。换句话说,每个应用都可以读到全量的消息。为了使得每个应用都能读到全量消息,应用需要有不同的消费组。对于上面的例子,假如我们新增了一个新的消费组G2,而这个消费组有两个消费者,那么会是这样的:

img

在这个场景中,消费组G1和消费组G2都能收到T1主题的全量消息,在逻辑意义上来说它们属于不同的应用。

重平衡

可以看到,当新的消费者加入消费组,它会消费一个或多个分区,而这些分区之前是由其他消费者负责的;另外,当消费者离开消费组(比如重启、宕机等)时,它所消费的分区会分配给其他分区。这种现象称为重平衡(rebalance)。重平衡是保证了高可用和水平扩展。**但是,在重平衡期间,所有消费者都不能消费消息,因此会造成整个消费组短暂的不可用。**而且,将分区进行重平衡也会导致原来的消费者状态过期,从而导致消费者需要重新更新状态,这段期间也会降低消费性能。

消费者通过定期发送心跳(hearbeat)到一个作为组协调者(group coordinator)的 broker 来保持在消费组内存活。这个 broker 不是固定的,每个消费组都可能不同。当消费者拉取消息或者提交时,便会发送心跳。

如果消费者超过一定时间没有发送心跳,那么它的会话(session)就会过期,组协调者会认为该消费者已经宕机,然后触发重平衡。可以看到,从消费者宕机到会话过期是有一定时间的,这段时间内该消费者的分区都不能进行消息消费;通常情况下,我们可以进行优雅关闭,这样消费者会发送离开的消息到组协调者,这样组协调者可以立即进行重平衡而不需要等待会话过期。

Partition 与消费模型

考虑如下两个问题:

  • Kafka 中一个 topic 中的消息是被打散分配在多个 Partition(分区) 中存储的, Consumer Group 在消费时需要从不同的 Partition 获取消息,那最终如何重建出 Topic 中消息的顺序呢?

  • Partition 中的消息可以被(不同的 Consumer Group)多次消费,那 Partition中被消费的消息是何时删除的? Partition 又是如何知道一个 Consumer Group 当前消费的位置呢?

对于第一个问题,很可惜,答案是没有办法。Kafka 只会保证在 Partition 内消息是有序的,而不管全局的情况。

而第二个问题,其实在前面的介绍中已经提到了一些,即无论消息是否被消费,除非消息到期或者数据量达到上限, Partition 从不删除消息。至于消费位置,在早期的版本中,消费者将消费到的offset维护zookeeper中,consumer每间隔一段时间上报一次,这里容易导致重复消费,且Zookeeper并不适合大批量的频繁写入操作!在新的版本中消费者消费到的offset已经直接维护在kafk集群的__consumer_offsets这个topic中!

为什么是pull 模型

消费者应该向 Broker 要数据(pull)还是 Broker 向消费者推送数据(push)?作为一个消息系统,Kafka 遵循了传统的方式,选择由 Producer 向 broker push 消息并由 Consumer 从 broker pull 消息。

**push 模式很难适应消费速率不同的消费者,因为消息发送速率是由 broker 决定的。**push 模式的目标是尽可能以最快速度传递消息,但是这样很容易造成 Consumer 来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。而 pull 模式则可以根据 Consumer 的消费能力以适当的速率消费消息。

对于 Kafka 而言,pull 模式更合适。pull 模式可简化 broker 的设计,Consumer 可自主控制消费消息的速率,同时 Consumer 可以自己控制消费方式——即可批量消费也可逐条消费,同时还能选择不同的提交方式从而实现不同的传输语义。

5.Kafka 如何保证可靠性

Kafka 中的可靠性保证有如下四点:

  • 对于一个分区来说,它的消息是有序的。如果一个生产者向一个分区先写入消息A,然后写入消息B,那么消费者会先读取消息A再读取消息B。
  • 当消息写入所有in-sync状态的副本后,消息才会认为已提交(committed)。这里的写入有可能只是写入到文件系统的缓存,不一定刷新到磁盘。生产者可以等待不同时机的确认,比如等待分区主副本写入即返回,后者等待所有in-sync状态副本写入才返回。
  • 一旦消息已提交,那么只要有一个副本存活,数据不会丢失。
  • 消费者只能读取到已提交的消息。

使用这些基础保证,我们可以构建一个可靠的系统,但这时候需要考虑一个问题:究竟我们的应用需要多大程度的可靠性?可靠性不是无偿的,它与系统可用性、吞吐量、延迟和硬件价格息息相关,得此失彼。因此,我们往往需要做权衡,一味的追求可靠性并不实际。