kafka笔记

1,934 阅读30分钟

本文主要基于kafka v1.0

一.开始

kafka是个消息中间件

基础

基本概念

  1. topic : kafka将消息种子(Feed)分门别类,每类为一个topic,每个topic可认为是标签/队列,生产者把消息放到指定队列,消费者取指定队列消息
  2. broker : 已发布的消息保存在一组服务器中,称之为Kafka集群。集群中的每一个服务器都是一个代理(Broker)
  3. record : 每个消息是由一个key,一个value和时间戳组成

五个核心API

  1. 应用程序使用 Producer API 发布消息到1个或多个topic(主题)
  2. 应用程序使用 Consumer API 来订阅一个或多个topic,并处理产生的消息
  3. 应用程序使用 Streams API 充当一个流处理器,从1个或多个topic消费输入流,并生产一个输出流到1个或多个输出topic,有效地将输入流转换到输出流
  4. Connector API允许构建或运行可重复使用的生产者或消费者,将topic连接到现有的应用程序或数据系统。
  5. AdminClient API可以管理,监测topic brokers 和其他kafka对象

kafka核心API

话题和日志(Topic和Log)

Kafka集群保持所有的消息,直到它们过期,无论消息是否被消费了

一个Topic可存储在多个分区中

每一个分区都是一个顺序的、不可变的消息队列, 并且可以持续地添加。分区中的消息都被分了一个序列号,称之为偏移量(offset),在每个分区中偏移量都是唯一的。
消费者所持有的仅有的元数据就是offset偏移量,可用于设置读取的消息的位置

分布式

  1. log分区分布到kafka集群多个服务器上
  2. 每个分区还可以复制到其他服务器作为备份容错
  3. 每个分区有一个leader和>=0个follower,leader处理此分区的所有读写请求,而follower只做备份.leader宕机,则推举一个follower为新的leader

    有消息写入时,是先写入leader,follower再从leader拉取 在kafka中读写操作都是leader

  4. 为平衡负载,一台服务器可能是一个分区的leader,同时是另一个分区的follower

每个partition可以认为是一个无限长度的数组,新数据顺序追加进这个数组,物理上,每个partition对应一个文件夹,一个partition对应多个segment组合。一个segment默认的大小是1G,一个broker可以存放多个partition

kafka分区原理图

生产者

往Topic上发消息时,需要选择发布到Topic上的哪一个分区;可以从分区表中轮流选择,也可以按某种权重选择分区

发送到某个分区的leader上

消费者

消息模型

  • 队列
    一条消息只有其中的一个消费者来处理
  • 发布-订阅模式
    消息被广播给所有的消费者,接收到消息的消费者都可以处理此消息

消费者抽象模型

  • 消费者组(consumer group) 消费者用一个消费者组名标记自己。一个发布在Topic上消息被分发给此消费者组中的一个消费者,每个ConsumerGroup消费的数据都是一样的

  • 所有消费者都在一个消费者组中
    队列模型,只有一个消费者接收到信息

  • 所有消费者都在不同组中
    发布-订阅模型,每一个组都可以接收到,所有消费者都接收到

kafka保证

  1. 同一个topic分区内,消费者组中消息是顺序接收和处理的,但不同消费者组不保证按序处理
  2. 如果一个Topic配置了复制因子(replication factor)为N, 则可以允许N-1服务器宕机而不丢失任何已经提交(committed)的消息。

二.接口

生产者

生产者发布消息到kafka集群

生产者使用单例模式,线程间共享生产者
example:

//使用producer发送一个有序的key/value
 Properties props = new Properties();
 props.put("bootstrap.servers", "localhost:9092");
 //acks辨别请求是否成功发送,"all"会阻塞消息,性能低但可靠
 props.put("acks", "all");
 //重试次数,>1可能会发送重复消息
 props.put("retries", 0);
 //每批次未发送消息缓存大小,每个活跃的分区都有一个缓冲区
 props.put("batch.size", 16384);
 //逗留时间,若缓冲区未满,可以延迟1毫秒发送
 props.put("linger.ms", 1);
 //生产者可用缓冲区总量,若满则阻塞其他发送调用,如果阻塞的时间超过 max.block.ms 配置的时长,则会抛出一个异常
 props.put("buffer.memory", 33554432);
 props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
 props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

 Producer<String, String> producer = new KafkaProducer<>(props);
 for(int i = 0; i < 100; i++){
    //send异步方法,添加消息到缓冲区等待发送,并立即返回。生产者将单个的消息批量在一起发送来提高效率
     producer.send(new ProducerRecord<String, String>("my-topic", Integer.toString(i), Integer.toString(i)));
 }
 producer.close();

生产者的缓冲空间池保留尚未发送到服务器的消息,后台I/O线程负责将这些消息转换成请求发送到集群。如果使用后不关闭生产者,则会泄露这些资源。

不关闭生产者可能会导致的具体问题?

send方法

public Future<RecordMetadata> send(ProducerRecord<K,V> record,Callback callback)

流程调用send方法->消息保存在等待发送的消息缓存中->send方法返回Future->server确认收到消息->调用callback

RecordMetadata指定了消息发送的分区,分配的offset和消息的时间戳,返回对象Future<RecordMetadata>.get()是个阻塞方法 发送到同一个分区的消息回调保证按一定的顺序执行

//callback1->callback2
producer.send(new ProducerRecord<byte[],byte[]>(topic, partition, key1, value1), callback1);
producer.send(new ProducerRecord<byte[],byte[]>(topic, partition, key2, value2), callback2);

callback一般在生产者的I/O线程中执行,速度快,否则将延迟其他的线程的消息发送(单例)。如果需要执行阻塞或计算昂贵(消耗)的回调,建议在callback主体中使用自己的Executor来并行处理,避免阻塞

消费者

Kafka客户端从集群中消费消息,并透明地处理kafka集群中的故障服务器,消费者TCP长连接到broker来拉取消息。故障导致的消费者关闭失败,将会泄露这些连接,消费者不是线程安全的

偏移量和消费者的位置

kafka为分区中每条消息保存一个偏移量offset,为分区中消息唯一标识符
消费者有两个位置概念:

  1. 消费者位置表示下一个接收消息的偏移量,位置为5,则下一个接收偏移量为5的消息,接收后位置自动增长
  2. 已提交位置,类似于安全点,是已经安全保存的最后偏移量,进程失败/重启时消费者将恢复到这个偏移量.可以自动定期提交偏移量,或者手动提交

消费者组和主题订阅

kafka平衡分区和消费者分组,对于一个消费者组而言,每个分区分配消费者组中一个消费者,如topic有4个分区,并且一个消费者分组有2个消费者,那么每个消费者消费2个分区

消费者数目>分区数目时,是否一个分区可以有多个消费者消费?

  • 重新平衡分组
    消费者组成员,消费者与分区关系都是动态维护的,当消费者故障,新增/移除消费者,会重新平衡分区与消费者

当分组重新分配自动发生时,可以通过ConsumerRebalanceListener通知消费者,这允许他们完成必要的应用程序级逻辑,例如状态清除,手动偏移提交等.它也允许消费者通过使用assign(Collection)手动分配指定分区,如果使用手动指定分配分区,那么动态分区分配和协调消费者组将失效。

发现消费者故障

订阅一组topic后,当调用poll(long)时,消费者将自动加入到组中。只要持续的调用poll,消费者将一直保持可用,并继续从分配的分区中接收消息。此外,消费者向服务器定时发送心跳。
如果消费者崩溃或无法在session.timeout.ms配置的时间内发送心跳,则消费者将被视为死亡,并且其分区将被重新分配。
消费可能遇到“活锁”的情况,它持续的发送心跳,但是没有处理。为了预防消费者在这种情况下一直持有分区,使用max.poll.interval.ms活跃检测机制.
在此基础上,如果调用的poll的频率大于最大间隔,则客户端将主动地离开组,以便其他消费者接管该分区。发生这种情况时,offset出现提交失败(调用commitSync()引发的CommitFailedException)。这是一种安全机制,保障只有活动成员能够提交offset。所以要留在组中,必须持续调用poll

消费者提供两个配置设置来控制poll循环:

  1. max.poll.interval.ms:增大poll的间隔,可以为消费者提供更多的时间去处理返回的消息(调用poll(long)返回的消息,通常返回的消息都是一批)。缺点是此值越大将会延迟组重新平衡。
  2. max.poll.records:此设置限制每次调用poll返回的消息数,这样可以更容易的预测每次poll间隔要处理的最大值。通过调整此值,可以减少poll间隔,减少重新平衡分组的时间

当消息处理时间不可预测时, 推荐将消息处理移到另一个线程中,让消费者继续调用poll。 但是必须注意确保已提交的offset不超过实际位置。另外,必须禁用自动提交,并只有在线程完成处理后才为记录手动提交偏移量。
需要pause暂停分区,不会从poll接收到新消息,让线程处理完之前返回的消息(如果处理能力比拉取消息的慢,那创建新线程将导致机器内存溢出)

既然已经新起线程处理,为防止线程爆仓,又要让线程处理完之前返回的消息才能接收新消息,这样的做法只为了避免处理时间超时而已,并不是为了异步处理,提高性能,如果用上线程池,不会爆仓,但还是会丢失消息,这样做不可靠

示例

  • 自动提交偏移量
    按照一定时间间隔提交,提交偏移量后就表明消息已经成功消费
Properties props = new Properties();
//不用指定全部的broker,它将自动发现集群中的其余的borker(最好指定多个,万一有服务器故障)
 props.put("bootstrap.servers", "localhost:9092");
 props.put("group.id", "test");
//自动提交
 props.put("enable.auto.commit", "true");
 //自动提交频率
 props.put("auto.commit.interval.ms", "1000");
 props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
 props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
 KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
 consumer.subscribe(Arrays.asList("foo", "bar"));
 while (true) {
     ConsumerRecords<String, String> records = consumer.poll(100);
     for (ConsumerRecord<String, String> record : records)
         System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
 }

broker通过心跳机器自动检测test组中失败的进程,消费者会自动ping集群,告诉集群它还活着。只要消费者能够做到这一点,它就被认为是活着的,并保留分配给它分区的权利,如果它停止心跳的时间超过session.timeout.ms,那么就会认为是故障的,它的分区将被分配到别的进程

  • 手动控制偏移量
    当消息处理结合自定义处理逻辑时,就应该等到消息真正处理完成后才提交偏移量
    eg : 当积累足够多的消息后,再将它们批量插入到数据库中
 Properties props = new Properties();
 props.put("bootstrap.servers", "localhost:9092");
 props.put("group.id", "test");
 //false
 props.put("enable.auto.commit", "false");
 props.put("auto.commit.interval.ms", "1000");
 props.put("session.timeout.ms", "30000");
 props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
 props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
 KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
 consumer.subscribe(Arrays.asList("foo", "bar"));
 final int minBatchSize = 200;
 List<ConsumerRecord<String, String>> buffer = new ArrayList<>();
 while (true) {
     ConsumerRecords<String, String> records = consumer.poll(100);
     for (ConsumerRecord<String, String> record : records) {
         buffer.add(record);
     }
     if (buffer.size() >= minBatchSize) {
         insertIntoDb(buffer);
         //插入数据库后才失败/异常,此时进程将获取已提交的偏移量并重新插入
         //这样就可能导致重复插入最后一批数据,这就是至少一次的安全保证
         consumer.commitSync();
         buffer.clear();
     }
 }

使用自动提交也可以“至少一次”。但是要求下次调用poll(long)之前或关闭消费者之前,处理完所有返回的数据。如果操作失败,这将会导致已提交的offset超过消费的位置,从而导致丢失消息

按分区提交偏移量,控制更加精细

try {
         while(running) {
             ConsumerRecords<String, String> records = consumer.poll(Long.MAX_VALUE);
             for (TopicPartition partition : records.partitions()) {
                 List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
                 for (ConsumerRecord<String, String> record : partitionRecords) {
                     System.out.println(record.offset() + ": " + record.value());
                 }
                 long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
                
                //已提交的offset应始终是你的程序将读取的下一条消息的offset
                consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastOffset + 1)));
             }
         }
     } finally {
       consumer.close();
     }

订阅指定分区

默认kafka自动分配分区,也可以手动控制消费者进程与分区关系

  • 如果这个消费者进程与该分区保存了某种本地状态(如本地磁盘的键值存储),则它应该只能获取这个分区的消息。
  • 如果消费者进程本身具有高可用性,并且如果它失败,会自动重新启动(可能使用集群管理框架如YARN,Mesos,或者AWS设施,或作为一个流处理框架的一部分)。 在这种情况下,不需要Kafka检测故障,重新分配分区,因为消费者进程将在另一台机器上重新启动。
 String topic = "foo";
 TopicPartition partition0 = new TopicPartition(topic, 0);
 TopicPartition partition1 = new TopicPartition(topic, 1);
 //指定分区
 consumer.assign(Arrays.asList(partition0, partition1));

一旦手动分配分区,在循环中调用poll(跟前面的例子一样)。消费者分组仍需要提交offset,只是现在分区的设置只能通过调用assign修改,因为手动分配不会进行分组协调,因此消费者故障不会引发分区重新平衡。
每一个消费者是独立工作的(即使和其他的消费者共享GroupId)。为了避免offset提交冲突,通常你需要确认每一个consumer实例的gorupId都是唯一的。

手动分配分区(即assgin)和动态分区分配的订阅topic模式(即subcribe)不能混合使用

offset另存位置

消费者可以不使用kafka内置的offset仓库。可以选择自己来存储offset,为保持原子性,需要将offset和消费处理过程作为原子处理单位

  • 设置方式
    配置 enable.auto.commit=false
    使用提供的 ConsumerRecord 来保存你的位置。
    在重启时用 seek(TopicPartition, long) 恢复消费者的位置。

当分区分配变更时需另行处理(手动分区不会自动调整分区),可以通过调用subscribe(Collection,ConsumerRebalanceListener)subscribe(Pattern,ConsumerRebalanceListener)中提供的ConsumerRebalanceListener实例来完成的
eg: 当分区向消费者获取时,消费者将通过实现ConsumerRebalanceListener.onPartitionsRevoked(Collection)来给这些分区提交它们offset。 当分区分配给消费者时,消费者通过ConsumerRebalanceListener.onPartitionsAssigned(Collection)为新的分区正确地将消费者初始化到该位置。 ConsumerRebalanceListener的另一个常见用法是清除应用已移动到其他位置的分区的缓存。

控制消费者位置

可以手动控制,消费之前的消息,也可以跳过最近的消息
seek(TopicPartition, long): 指定新的消费位置
seekToBeginning(Collection):查找服务器保留的最早的offset
seekToEnd(Collection):查找服务器保留的最新的offset的方法

消费者流量控制

kafka支持动态控制消费流量,在future的poll(long)中使用pause(Collection)暂停消费指定分配的分区, resume(Collection)重新开始消费指定暂停的分区

多线程处理

Kafka消费者不是线程安全的。所有网络I/O都发生在进行调用应用程序的线程中。

IO是poll操作?

非同步访问将导致ConcurrentModificationException。 此规则唯一的例外是wakeup(),它可以安全地从外部线程来中断活动操作,并抛出一个WakeupException。这可用于从其他线程来关闭消费者。 以下代码段显示了典型模式:

public class KafkaConsumerRunner implements Runnable {
     private final AtomicBoolean closed = new AtomicBoolean(false);
     private final KafkaConsumer consumer;

     public void run() {
         try {
             consumer.subscribe(Arrays.asList("topic"));
             while (!closed.get()) {
                 ConsumerRecords records = consumer.poll(10000);
                 // Handle new records
             }
         } catch (WakeupException e) {
             // Ignore exception if closing
             if (!closed.get()) throw e;
         } finally {
             consumer.close();
         }
     }

     // Shutdown hook which can be called from a separate thread
     public void shutdown() {
         closed.set(true);
         //抛出异常
         consumer.wakeup();
     }
 }

上文代码中,处理消息这一步可以以多线程方式处理

  1. 每个线程一个消费者
    直接在上文中 Handle new records后串行处理逻辑
  • 优点:
    • 最容易实现
    • 不需要在线程之间协调,最快。
    • 按顺序处理每个分区(每个线程只处理它接受的消息)。
  • 缺点:
    • 更多的TCP连接到集群(每个线程一个),更多的请求被发送到服务器。
    • 所有进程中的线程总数受到分区总数的限制。

消费者数量不能大于分区总数

  1. 一个或多个消费者线程来消费所有数据,其消费所有数据并将ConsumerRecords实例切换到由实际处理记录处理的处理器线程池来消费的阻塞队列。
  • 优点:
    • 可扩展消费者和处理进程的数量。这样单个消费者的数据可分给多个处理器线程来执行,避免对分区的任何限制。
  • 缺点
    • 跨多个处理器的顺序保证需要特别注意,因为线程是独立地执行,后来的消息可能比早到的消息先处理
    • 手动提交变得更困难,因为它需要协调所有的线程以确保处理对该分区的处理完成。 这种方法有多种实现方式,例如,每个处理线程可以有自己的队列,消费者线程可以使用TopicPartition hash到这些队列中,以确保按顺序消费,并且提交也将简化。

Kafka 集群保留所有发布的记录—无论他们是否已被消费,通过一个可配置的参数设置保留期限,期限过后记录会被抛弃并释放磁盘空间,消费者可以手动控制offset消费消息,这将为消费者带来很大的灵活性。

KafkaStreams客户端

Kafka Streams从一个或多个输入topic进行连续的计算并输出到0或多个外部topic中
KafkaStreams实例将根据输入topic分区的基础上来划分工作,以便所有的分区都被消费掉。如果实例添加或失败,所有实例将重新平衡它们之间的分区分配,以保证负载平衡。
KafkaStreams实例内部包含一个正常的KafkaProducer和KafkaConsumer实例,用于读取和写入

Map<String, Object> props = new HashMap<>();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "my-stream-processing-application");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
props.put(StreamsConfig.VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
StreamsConfig config = new StreamsConfig(props);

KStreamBuilder builder = new KStreamBuilder();
builder.stream("my-input-topic").mapValues(value -> value.length().toString()).to("my-output-topic");

KafkaStreams streams = new KafkaStreams(builder, config);
streams.start();

Kafka Connect API

Connect API实现一个连接器(connector),不断地从一些数据源系统拉取数据到kafka,或从kafka推送到宿系统(sink system)。

kafka配置

参数太多,见原文Kafka 配置

kafka 设置

kafka效率

文件读取与缓存

数据从文件传输到socket的公共数据路径: 磁盘->内核空间页缓存->用户空间缓存->socket缓存->网卡缓存
这样就有有四次 copy 操作和两次系统调用
使用sendfile,将允许数据直接从页缓存pagecache 拷贝到网卡上,避免重新复制数据,提升性能
这种页缓存和sendfile组合,意味着kafka集群的消费者大多数都完全从缓存消费消息,而磁盘没有任何读取活动

批量压缩

性能瓶颈可能在网络数据传输,若直接压缩消息,则会重复压缩冗余信息(如消息头.通用字符串),可以批量压缩多个消息以提高压缩比
kafka通过递归消息集来支持这一点.一批消息可以一起压缩并以此形式发送到服务器。这批消息将以压缩形式写入,并将在日志中保持压缩,并且只能由消费者解压缩

kafka生产者

生产者将数据直接发送到分区leader的broker上。为了帮助producer做到这一点,Kafka所有节点都可应答给producer哪些服务器是正常的,哪些topic分区的leader允许producer在给定的时间内可以直接请求。
批处理是效率的一大驱动力,kafka生产者使用批处理试图在内存中积累数据,再单个请求发送累积的大批量数据

kafka消费者

kafka消费者通过向broker的leader分区发起“提取”请求。消费者指定每次请求日志的偏移量并收到那一块日志的起始位置。因此,消费者可以重新指定位置,重新消费

拉取pull VS 推送push

producer 把数据 push 到 broker,然后 consumer 从 broker 中 pull 数据

  • push : 尽可能以最快速度消费消息
    优:消息处理快
    劣:消费者可能来不及处理消息,导致消息积压,最后导致拒绝服务和网络堵塞

  • pull :
    优: 可以根据consumer的消费能力以适当的速率消费消息;可以接收批量消息,消费者可设置消息批量大小
    劣:若broker没有数据,则消费者会空轮询,忙等待,可以在pull请求时使用long pull 阻塞,直到数据到达

消费者定位

kafka中,topic被分为一组完全有序的的分区,则只需要一个offset就可以标记出哪些消息被消费,而不需要每个消息单独标记,这样做又可以回退消息,重新消费
而传统方式是消息都有已发送和已消费两种状态,中间数据传输容易失败,并且不支持重新消费

kafka消息传递保障

kafka担保语义
At most once : 最多一次,消息可能丢失,但绝不会重发
At least once : 至少一次,可能重发(默认语义)
Axactly once : 仅发送一次

kafka默认是保证“至少一次”传递,并允许用户通过禁止生产者重试和处理一批消息前提交它的偏移量来实现 “最多一次”传递。 而“正好一次”传递需要与目标存储系统合作,将偏移量offset和数据存储在同一位置,以保证数据和offse都被更新,或者一起更新失败(类似事务)

生产者

在0.11.0.0之前,如果一个生产者没有收到消息提交的响应,那么只能重新发送消息。 这提供了至少一次传递语义。
自0.11.0.0起,Kafka生产者支持幂等传递选项,保证重新发送不会导致日志中重复。 broker为每个生产者分配一个ID,producer 给每条被发送的消息分配了一个序列号来避免产生重复的消息。生产者支持使用类似事务的语义,可以将消息发送到多个topic分区:即所有消息都被成功写入,或者没有。这个主要用于Kafka topic之间“正好一次“语义

消费者

所有的副本都有相同的 log 和相同的 offset。consumer 负责控制它在 log中的位置.如果消费者故障,这个topic分区被另一个进程接管,新进程需要选择一个合适的位置开始处理

  1. 至多一次:读取消息->日志保存offset->处理消息
    处理消息可能失败
  2. 至少一次 : 读取消息->处理消息->日志保存offset
  3. 仅一次: 当从Kafka主题消费并生产到另一个topic时(例如Kafka Stream), 可以使用生产者的事务功能

kafka副本和leader选举

kafka集群在各个服务器上备份topic分区中的日志(即备份消息,称为副本,可以设置每个topic的副本数,本质上就是备份partition,总的副本数是包含 leader的总和)。当集群中某个服务器发生故障时,自动切换到这些副本,从而保障在故障时消息仍然可用

followers 自身也有可能落后或者 crash,所以 必须确保我们leader的候选者们 是一个数据同步 最新的 follower 节点,ISR中的follower

副本以topic的分区为单位。在正常情况下,kafka每个分区都有一个单独的leader,0个或多个follower。副本的总数包括leader。所有的读取和写入都是针对该分区的leader。通常,分区数比broker多,leader均匀分布在broker中

主从关系的选举

选择一个 broker 作为 “controller”节点,负责检测 brokers 级别故障,当一个 broker挂了,改变该 broker 故障影响 partition 的 leadership。

这种方式可以批量的通知主从关系的变化,使得对于拥有大量partition 的broker ,选举过程的代价更便宜并且速度更快。

如果 controller 节点挂了,其他存活的 broker 都可能成为新的 controller 节点。

日志

每个日志文件使用它包含的第一个日志的 offset 来命名,日志允许序列化地追加到最后一个文件中.当文件大小达到配置的大小(默认 1G)时,会生成一个新的文件

数据只能一次删除一个日志分片segment

日志压缩

日志压缩以记录为粒度,确保了kafka会为topic分区数据中的每个message key至少保留最后更新的值,即最近一次记录的状态(读取/消费时如果从最开始的offset=0开始,那么至少可以看到所有记录按照它们写入的顺序得到的最终状态),而不是按照传统方式超时/超大小丢弃部分日志

每个Topic可以设置日志保留策略,可以一部分Topic通过时间和大小保留日志(粗粒度),其他的按日志压缩方式(细粒度),删除策略同理

不仅仅是更新需要清理旧的数据,删除操作也需要清理,生产者客户端如果发送的消息key的value是空的,表示要删除这条消息,发生在删除标记之前的记录都需要删除掉,而发生在删除标记之后的记录则不会被删除

压缩操作通过在后台周期性的拷贝日志段来完成

日志压缩方式

日志逻辑图
Log head中包含传统的Kafka日志,它包含了连续的offset和所有的消息。 日志压缩增加了处理tail Log的选项

日志压缩的保障措施:

  1. 日志head中的所有消费者能看到写入的所有消息;这些消息都是有序的offset。topic的使用min.compaction.lag.ms用来保障消息写入之前必须经过的最小时间长度,才能被压缩。 这限制了一条消息在Log Head中的最短存在时间。
  2. 始终保持消息的有序性。压缩永远不会重新排序消息,只是删除了一些。
  3. 消息的Offset不会变更。这是消息在日志中的永久标志。
  4. 任何从头开始处理日志的Consumer至少会拿到每个key的最终状态。 另外,只要Consumer在小于Topic的delete.retention.ms设置(默认24小时)的时间段内到达Log head,将会看到所有删除记录的所有删除标记。

日志压缩的细节

日志压缩由Log Cleaner执行,后台线程池重新拷贝日志段,移除那些key存在于Log Head中的记录,默认log tail记录是已经压缩处理过的

每个压缩线程工作方式:

  1. 选择log head与log tail比率最高的日志。
  2. 在head log中为每个key的最后offset创建一个的简单概要。
  3. 它从日志的开始到结束,删除那些在日志中最新出现的key的旧的值。新的、干净的日志将会立即被交到到日志中,所以只需要一个额外的日志段空间(不是日志的完整副本)
  4. 日志head的概要本质上是一个空间密集型的哈希表,每个条目使用24个字节。

Quotas 配额

producers 和 consumers 可能会生产或者消费大量的数据或者产生大量的请求,导致对 broker 资源的垄断,引起网络的饱和,对其他clients和brokers本身造成DOS攻击。 资源的配额保护可以有效防止这些问题

Kafka broker 可以对客户端做两种类型资源的配额限制,同一个group的client 共享配额,配额是以broker为基础定义,而不是以client为基础(为每个客户端配置一个固定的集群带宽资源需要一个机制来共享client 在brokers上的配额使用情况,实现困难)。

  1. 每秒请求速率bytes/sec:定义字节率的阈值来限定网络带宽的配额。 (从 0.9 版本开始)
  2. 请求频率:request 请求率的配额,网络和 I/O线程 cpu利用率的百分比。 (从 0.11 版本开始)

配额设置

  1. users:在一个支持非授权客户端的集群中,用户是一组非授权的 users

具体是代表什么?

  1. client:是在一个安全的集群中经过身份验证的用户
  2. (user, client-id):元组定义了一个安全的客户端逻辑分组,使用相同的user 和 client-id 标识

资源配额的配置可以根据 (user, client-id),user 和 client-id 三种规则进行定义

覆盖 User 和 (user, client-id)规则的配额配置会写到zookeeper的 /config/users路径下,client-id 配额的配置会写到/config/clients路径下。这些配置的覆盖会被所有的 brokers实时的监听到并生效。所以修改配额配置不需要重启整个集群

broker在检测到有配额资源使用违反规则,则延迟response响应,而不是返回error

实体

消息

消息格式: 可变长header + 可变长度不透明的字节数组 key + 可变长度不透明的字节数组 value

消息中key和value是以二进制格式存在,kafka统一序列化

Record Batch

消息通常按照批量方式写入record

Record Batch格式

baseOffset: int64
batchLength: int32
partitionLeaderEpoch: int32
magic: int8 (current magic value is 2)
crc: int32
attributes: int16
    bit 0~2:
        0: no compression
        1: gzip
        2: snappy
        3: lz4
    bit 3: timestampType
    bit 4: isTransactional (0 means not transactional)
    bit 5: isControlBatch (0 means not a control batch)
    bit 6~15: unused
lastOffsetDelta: int32
firstTimestamp: int64
maxTimestamp: int64
producerId: int64
producerEpoch: int16
baseSequence: int32
records: [Record]

record格式

length: varint
attributes: int8
    bit 0~7: unused
timestampDelta: varint
offsetDelta: varint
keyLength: varint
key: byte[]
valueLen: varint
value: byte[]
Headers => [Header]

Record Header

headerKeyLength: varint
headerKey: String
headerValueLength: varint
Value: byte[]

操作

关机

当一个服务器正常停止时,它将采取两种优化措施

  1. 它将所有日志同步到磁盘,以避免在重新启动时需要进行任何日志恢复活动(即验证日志尾部的所有消息的校验和)。由于日志恢复需要时间,所以从侧面加速了重新启动操作。
  2. 它将在关闭之前将以该服务器为 leader 的任何分区迁移到其他副本。这将使 leader 角色传递更快,并将每个分区不可用的时间缩短到几毫秒。

控制 leader 迁移需要使用特殊的设置

controlled.shutdown.enable=true

只有当 broker 托管的分区具有副本(即,复制因子大于1 且至少其中一个副本处于活动状态)时,对关闭的控制才会成功

Balancing leadership

当一个 borker 停止或崩溃时,该 borker 上的分区的leader 会转移到其他副本。在 broker 重新启动时,该broker只是所有分区的follower,这意味着它不会用于客户端的读取和写入,只有备份功能

为了避免这种不平衡,Kafka有一个首选副本的概念:如果分区的副本列表为1,5,9,则节点1首选为节点5或9的 leader ,因为它在副本列表中较早。

auto.leader.rebalance.enable=true

补充

  1. 在Kafka中实现消费的方式是将日志中的分区划分到每一个消费者实例上,以便在任何时间,每个实例都是分区唯一的消费者。维护消费组中的消费关系由Kafka协议动态处理。如果新的实例加入组,他们将从组中其他成员处接管一些 partition 分区;如果一个实例消失,拥有的分区将被分发到剩余的实例

  1. kafka是无法保证全局消息有序的,没有这个机制,只能局部有序
  2. 使用zookeeper来保存元数据

    包括offset

  3. kafka如何保证数据的完全生产
    ack机制:broker表示发来的数据已确认接收无误,表示数据已经保存到磁盘。
acks 含义
acks=0 producer不等待broker返回确认消息,该消息会被立刻添加到socket buffer中并认为已经发送完成。在这种情况下,服务器是否收到请求是没法保证的,并且参数retries也不会生效(因为客户端无法获得失败信息)。每个记录返回的 offset总是被设置为-1
acks=1 leader节点会将记录写入本地日志,并且在所有 follower 节点反馈之前就先确认成功。在这种情况下,如果 leader 节点在接收记录之后,并且在 follower 节点复制数据完成之前产生错误,则这条记录会丢失
acks=all / acks=-1 leader 节点会等待所有同步中的ISR副本确认之后再确认这条记录是否发送完成。只要至少有一个ISR同步副本存在,记录就不会丢失。这种方式是对请求传递的最有效保证
  1. broker如何保存数据
    在理论环境下,broker按照顺序保存数据。主要通过pagecache机制,尽可能的利用当前物理机器上的空闲内存来做缓存。 当前topic所属的broker,必定有一个该topic的partition,partition是一个磁盘目录。partition的目录中有多个segment组合(index,log)
  2. consumerGroup的组员和partition之间如何做负载均衡
    是一一对应,一个partition对应一个consumer。如果consumer的数量过多,必然有空闲的consumer。
    假如topic1,具有如下partitions: P0,P1,P2,P3
    加入group中,有如下consumer: C1,C2
    首先根据partition索引号对partitions排序: P0,P1,P2,P3
    根据consumer.id排序: C0,C1
    计算倍数: M = [P0,P1,P2,P3].size / [C0,C1].size,本例值M=2(向上取整)
    然后依次分配partitions: C0 = [P0,P1],C1=[P2,P3],即Ci = [P(i * M),P((i + 1) * M -1)]
  3. 如何保证kafka消费者消费数据是全局有序的
    伪命题,如果要全局有序的,必须保证生产有序,存储有序,消费有序。
    由于生产可以做集群,存储可以分片,消费可以设置为一个consumerGroup,要保证全局有序,就需要保证每个环节都有序。
    只有一个可能,就是一个生产者,一个partition,一个消费者。这种场景和大数据应用场景相悖

参考资料

  1. kafka中文教程
  2. kafka
  3. Kafka技术内幕-日志压缩