kafka 原理分析

1、Producer 原理分析

kafka 生产者客户端由两个线程协调运行,这两个线程分别为主线程和 Sender 线程 (发送线程)。在主线程中由 KafkaProducer 创建消息,然后通过拦截器、序列化器和分区器的作用之后缓存到消息累加器( RecordAccumulator,也称为消息收集器〉中。 Sender 线程负责从RecordAccumulator 中获取消息并将其发送到 Kafka 中 。
kafka 原理分析_第1张图片
核心代码:

org.apache.kafka.clients.producer.KafkaProducer

  @Override
    public Future send(ProducerRecord record, Callback callback) {
        // intercept the record, which can be potentially modified; this method does not throw exceptions
        ProducerRecord interceptedRecord = this.interceptors == null ? record : this.interceptors.onSend(record);
        return doSend(interceptedRecord, callback);
    }

    /**
     * Implementation of asynchronously send a record to a topic. Equivalent to send(record, null).
     * See {@link #send(ProducerRecord, Callback)} for details.
     */
    private Future doSend(ProducerRecord record, Callback callback) {
        TopicPartition tp = null;
        try {
            // first make sure the metadata for the topic is available
            long waitedOnMetadataMs = waitOnMetadata(record.topic(), this.maxBlockTimeMs);
            long remainingWaitMs = Math.max(0, this.maxBlockTimeMs - waitedOnMetadataMs);
            byte[] serializedKey;
            try {
                serializedKey = keySerializer.serialize(record.topic(), record.key());
            } catch (ClassCastException cce) {
                throw new SerializationException("Can't convert key of class " + record.key().getClass().getName() +
                        " to class " + producerConfig.getClass(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG).getName() +
                        " specified in key.serializer");
            }
            byte[] serializedValue;
            try {
                serializedValue = valueSerializer.serialize(record.topic(), record.value());
            } catch (ClassCastException cce) {
                throw new SerializationException("Can't convert value of class " + record.value().getClass().getName() +
                        " to class " + producerConfig.getClass(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG).getName() +
                        " specified in value.serializer");
            }
            int partition = partition(record, serializedKey, serializedValue, metadata.fetch());
            int serializedSize = Records.LOG_OVERHEAD + Record.recordSize(serializedKey, serializedValue);
            ensureValidRecordSize(serializedSize);
            tp = new TopicPartition(record.topic(), partition);
            long timestamp = record.timestamp() == null ? time.milliseconds() : record.timestamp();
            log.trace("Sending record {} with callback {} to topic {} partition {}", record, callback, record.topic(), partition);
            // producer callback will make sure to call both 'callback' and interceptor callback
            Callback interceptCallback = this.interceptors == null ? callback : new InterceptorCallback<>(callback, this.interceptors, tp);
            RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey, serializedValue, interceptCallback, remainingWaitMs);
            if (result.batchIsFull || result.newBatchCreated) {
                log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), partition);
                this.sender.wakeup();
            }
            return result.future;
            // handling exceptions and record the errors;
            // for API exceptions return them in the future,
            // for other exceptions throw directly
        } catch (ApiException e) {
            log.debug("Exception occurred during message send:", e);
            if (callback != null)
                callback.onCompletion(null, e);
            this.errors.record();
            if (this.interceptors != null)
                this.interceptors.onSendError(record, tp, e);
            return new FutureFailure(e);
        } catch (InterruptedException e) {
            this.errors.record();
            if (this.interceptors != null)
                this.interceptors.onSendError(record, tp, e);
            throw new InterruptException(e);
        } catch (BufferExhaustedException e) {
            this.errors.record();
            this.metrics.sensor("buffer-exhausted-records").record();
            if (this.interceptors != null)
                this.interceptors.onSendError(record, tp, e);
            throw e;
        } catch (KafkaException e) {
            this.errors.record();
            if (this.interceptors != null)
                this.interceptors.onSendError(record, tp, e);
            throw e;
        } catch (Exception e) {
            // we notify interceptor about all exceptions, since onSend is called before anything else in this method
            if (this.interceptors != null)
                this.interceptors.onSendError(record, tp, e);
            throw e;
        }
    }

核心概念:

ProducerRecord: 消息对象;
Interceptor :拦截器, Kafka 一共有两种拦截器 : 生产者拦截器和消费者拦截器。 生产者拦截器既可以用来在消息发送前做一些准备工作 ,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作;
Serializer: 序列化器,把对象转换成字节数组才能通过网络发送给 Kafka;
Partitioner: 分区器,消息经过序列化之后就需要确定它发往的分区 ,如果消息 ProducerRecord 中指定了 partition 字段, 那么就不需要分区器的作用 ,因为 partition 代表的就是所要发往的分区号。 如果消息 ProducerRecord 中没有指定 partition 字段,那么就需要依赖分区器 ,根据 key这个字段来计算 partition 的值。分区器的作用就是为消息分配分区。 默认分区器: DefaultPartitioner , 在 partition()方法中定义了主要的分区分配逻辑 。如果 key 不为 null ,那么默认的分区器会对 key 进行哈希(采用MurmurHash2 算法 ,具备高运算性能及低碰撞率),最终根据得到的哈希值取模(所有分区)来计算分区号, 拥有相同 key 的消息会被写入同一个分区 。 如果 key 为 null,那么消息将会以轮询的方式发往主题内的各个可用分区;
RecordAccumulator:消息累加器,主要用来缓存消息以便 Sender 线程可以批量发送,进而减少网络传输的资源消耗以提升性能 。 RecordAccumulator 缓存的大小可以通过生产者客户端参数buffer.memory 配置,默认值为 33554432B ,即 32M 。 如果生产者发送消息的速度超过发送到服务器的速度 ,则会导致生产者空间不足,这个时候 KafkaProducer 的 send()方法调用要么被阻塞,要么抛出异常,这个取决于参数 max.block.ms 的配置,此参数的默认值为 60000,即 60 秒 。主线程中发送过来的消息都会被迫加到 RecordAccumulator 的某个双端队列( Deque )中,在 RecordAccumulator 的内部为每个分区都维护了 一 个双端队列,队列中的内容就是 ProducerBatch,即 Deque InFlightRequests :请求在从 Sender 线程发往 Kafka 之前还会保存到 InFlightRequests 中, InFlightRequests 保存对象的具体形式为 Map

2、Consumer 原理分析

消费者( Consumer )负责订阅 Kafka 中的主题( Topic ),并且从订阅的主题上拉取消息。 在 Kafka 的消费理念中还有一层消费组( Consumer Group)的概念,每个消费者都有一个对应的消费组。当消息发布到主题后,只会被投递给订阅它的每个消费组中的一个消费者。每个消费者只能消费所分配到的分区中的消息。换言之,每一个分区只能被一个消费组中的一个消费者所消费 。

分区分配策略

Kafka 提供了消费者客户端参数partition.assignment.strategy来设置消费者与订阅主题之间的分区分配策略。默认情况下,采用 Range Assignor 分配策略。 Kafka 还提供了另外两种分配策略: RoundRobinAssignor 和 StickyAssignor 。

RangeAssignor : RangeAssignor 分配策略的原理是按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配, 以保证分区尽可能均匀地分配给所有的消费者 ;
RoundRobinAssignor :RoundRobinAssignor 分配策略的原理是将消费组内所有消费者及消费者订阅的所有主题的分区按照字典序排序,然后通过轮询方式逐个将分区依次分配给每个消费者;
StickyAssignor: Kafka从 0.11.x 版本开始引入这种分配策略,它主要有两个目的 : 分区的分配要尽可能均匀,分区的分配尽可能与上次分配的保持相同。

消息消费

Kafka 中的消费是基于拉模式的,拉模式是消费者主动向服务端发起请求来拉取消息。 Kafka 中的消息消费是一个不断轮询 的过程,消费者所要做的就是重复地调用 poll()方法 , 而 poll()方法返回的是所订阅的主题(分区)上的一组消息。

public void setPollTimeout(long pollTimeout) {
        this.pollTimeout = pollTimeout;
    }

timeout 的设置取决于应用程序对响应速度的要求,比如需要在多长时间内将控制权移交给执行轮询的应用线程。如果直接将 timeout 设置为 0 , 这样 poll()方法会立刻返回,而不管是否己经拉取到了消息。如果应用线程唯一的工作就是从 Kafka 中拉取并消费消息,则可以将这个参数设置为最大值 Long.MAX_VALUE 。

位移提交

对于 Kafka 中的分区而言,它的每条消息都有唯一的 offset,用来表示消息在分区中对应的位置 。对于消费者而言,它也有一个 offset 的概念,消费者使用 offset 来表示消费到分区中某个消息所在的位置。在每次调用 poll()方法时,它返回的是还没有被消费过的消息集,要做到这一点,就需要记录上一次消费时的消费位移 。消费位移存储在 Kafka 内部的主题 consumer offsets 中。这里把将消费位移存储起来(持久化)的动作称为提交 ,消费者在消费完消息之后需要执行消费位移的提交。
在 Kafka 中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数enable.auto.commit 配置,默认值为 true。当然这个默认的自动提交不是每消费一条消息就提交一次,而是定期提交,这个定期的周期时间由客户端参数 auto.commit.interval.ms配置,默认值为 5 秒,此参数生效的前提是 enable.auto.commit 参数为true。每个consumer会定期将自己消费分区的offset提交给kafka内部topic:consumer_offsets,提交过去的时候,key是consumerGroupId+topic+分区号,value就是当前offset的值,kafka会定期清理topic 里的消息,最后就保留最新的那条数据。因为consumer_offsets可能会接收高并发的请求,kafka默认给其分配50个分区(可以通过offsets.topic.num.partitions设置),这样可以通过加机器的方式抗大并发。

Rebalance 机制

rebalance是指分区的所属权从一个消费者转移到另一消费者的行为,它为消费组具备高可用性和伸缩性提供保障。rebalance发生期间,消费组内的消费者是无法读取消息的。当一个分区被重新分配给另一个消费者时,消费者当前的状态也会丢失。 如下情况可能会触发消费者rebalance:
consumer所在服务重启或宕机了。
动态给topic增加了分区 。
消费组订阅了更多的topic。

org.apache.kafka.clients.consumer.ConsumerRebalanceListener

public interface ConsumerRebalanceListener {
 // 在rebalance开始之前和消费者停止读取消息之后被调用。可以通过这个回调方法来处理消费位移的提交,以此来避免一些不必要的重复消费现象的发生。参数 partitions表示rebalance前所分配到的分区。
    public void onPartitionsRevoked(Collection partitions);
 // 在重新分配分区之后和消费者开始读取消费之前被调用。参数partitions表示再均衡后所分配到的分区。 
    public void onPartitionsAssigned(Collection partitions);
}

消费者协调器和组协调器

每个消费组的子集在服务端对应一个 GroupCoordinator 对其进行管理, GroupCoordinator 是 Kafka 服务端中用于管理消费组的组件。而消费者客户端中的ConsumerCoordinator组件负责与 GroupCoordinator 进行交互 。 ConsumerCoordinator与 GroupCoordinator 之间最重要的职责就是负责执行消费者再均衡的操作,包括前面提及的分区分配的工作也是在rebalance期间完成的。一共有如下几种情形会触发再均衡的操作 :
有新的消费者加入消费组。
有消费者宕机下线 。 消费者并不一定需要真正下线,例如遇到长时间的 GC、网络延迟导致消费者长时间未向 GroupCoordinator 发送心跳等情况时,GroupCoordinator 会认为消费者己经下线。
有消费者主动退出消费组(发送 LeaveGroupRequest 请求)。比如客户端调用了unsubscrible()方法取消对某些主题的订阅 。
消费组所对应的 GroupCoorinator 节点发生了变更。 消费组内所订阅的任一主题或者主题的分区数量发生变化。

整个rebalance过程如下:当有消费者加入消费组时,消费者、消费组及组协调器之间会经历一下几个阶段。

第一阶段:选择组协调器( FIND COORDINATOR),组协调器GroupCoordinator:每个 consumer group都会选择一个broker作为自己的组协调器coordinator,负责监控这个消费组里的所有消费者的心跳,以及判断是否宕机,然后开启消费者rebalance。consumer group中的每个consumer启动时会向kafka集群中的某个节点( 负载最小的节点 )发送 FindCoordinatorRequest 请求来查找对应的组协调器GroupCoordinator,并跟其建立网络连接。 组协调器选择方式: 通过如下公式可以选出consumer消费的offset要提交到consumer_offsets的哪个分区,这个分区 leader对应的broker就是这个consumer group的coordinator。公式:hash(consumer group id) % consumer_offsets主题的分区数

第二阶段:加入消费组(JOIN GROUP),在成功找到消费组所对应的 GroupCoordinator 之后就进入加入消费组的阶段,在此阶段的消费者会向 GroupCoordinator 发送 JoinGroupRequest 请求,并处理响应。然后GroupCoordinator 从一个consumer group中选择第一个加入group的 consumer作为leader(消费组协调器),把consumer group情况发送给这个leader,接着这个leader会负责制定分区方案。 选举分区分配策略:根据各个消费者呈报的分配策略选举,过程如下:
收集各个消费者支持的所有分配策略,组成候选集 candidates 。
每个消费者从候选集 candidates 中找出第一个自身支持的策略,为这个策略投上一票。
计算候选集中各个策略的选票数,选票数最多的策略即为当前消费组的分配策略。

每个消费者都向GroupCoordinator发送JoinGroupRequest请求 ,其中携带了各自提案的分配策略和订阅信息。 JoinGroupResonse 回执中包含 GroupCoordinator 中投票选举出的分配策略的信息,并且只有 leader消费者的回执中包含每个消费者的订阅信息。

第三阶段:同步消费组( SYNC GROUP) ,consumer leader通过给GroupCoordinator发送 SyncGroupRequest,接着GroupCoordinator就把分区方案下发给各个consumer,他们会根据指定分区的leader broker进行网络连接以及消息消费。

多线程

KafkaConsumer 是非线程安全的,KafkaConsumer 中定义了 一个 acquire()方法,用来检测当前是否只有一个线程在操作,若有其他线程正在操作则会抛出 ConcurrentModifcationException 异常。

3、 Broker原理分析

分区管理

优先副本的选举 : Kafka 集群的一个 broker 中最多只能有它的一个副本 。分区使用多副本机制来提升可靠性,但只有 leader 副本对外提供读写服务,而 follower 副本只负责在内部进行消息的同步。如果一个分区的 leader 副本不可用,那么就意味着整个分区变得不可用 ,此时就需要 Kafka 从剩余的 follower 副本中挑选一个新的 leader 副本来继续对外提供服务 。为了能够有效地治理负载失衡的情况 ,Kafka 引入了优先副本( preferred replica )的概念 。所谓的优先副本是指在 AR 集合列表中的第一个副本 。优先副本的选举是指通过一定的方式促使优先副本选举为 leader 副本,以此来促进集群的负载均衡 ,这一行为也可以称为“分区平衡” 。
在 Kafka 中可以提供分区自动平衡的功能,与此对应的 broker 端参数是 auto.leader.rebalance.enable,此参数的默认值为 true,即默认情况下此功能是开启的。如果开启分区自动平衡的功能,则 Kafka 的控制器会启动一个定时任务,这个定时任务会轮询所有的 broker节点,计算每个 broker 节点的分区不平衡率( broker 中的不平衡率=非优先副本的 leader 个数/分区总数)是否超过 leader.imbalance.per.broker.percentage 参数配置的比值,默认值为 10%,如果超过设定的比值则会自动执行优先副本的选举动作以求分区平衡。执行周期由参数 leader.imbalance.check.interval.seconds控制,默认值为300秒。Kafka 中 kafka-perferred-replicaelection.sh 脚本提供了对分区 leader 副本进行重新平衡的功能。

分区重分配: Kafka提供了 kafka-reassign-partitions.sh 脚本来执行分区重分配的工作,它可以在集群扩容、 broker节点失效的场景下对分区进行迁移 。

失效副本:正常情况下,分区的所有副本都处于 ISR 集合中,但是难免会有异常情况发生,从而某些副本被剥离出 ISR 集合中。
当 followor 副本将 leader 副本LEO ( Log End Offset )之前的日志全部同步时 ,则认为该 follower 副本己经追赶上 leader 副本,此时更新该副本的lastCaughtUpTimeMs 标识 。 Kafka 的副本管理器会启动一个副本过期检测的定时任务,而这个定时任务会定时检查当前时间与副本的 lastCaughtUpTimeMs 差值是否大于参数 replica.lag.time.max.ms 指定的值 ,默认值10000。

控制器Controller

在Kafka集群中会有一个或者多个broker,其中有一个broker会被选举为控制器(Kafka Controller), 它负责管理整个集群中所有分区和副本的状态。
当某个分区的leader副本出现故障时,由控制器负责为该分区选举新的leader副本。
当检测到某个分区的ISR集合发生变化时,由控制器负责通知所有broker更新其元数据信息。
当使用kafka-topics.sh脚本为某个topic增加分区数量时,同样还是由控制器负责分区的重新分配。

Kafka中的控制器选举的工作依赖于Zookeeper,成功竞选为控制器的broker会在Zookeeper中创 建/controller这个临时(EPHEMERAL)节点,此临时节点的内容参考如下:

# brokerid表示称为控制器的broker的id编号,timestamp表示竞选为控制器时的时间戳 {"version":1,"brokerid":0,"timestamp":"1589885915000"}

在任意时刻,集群中有且仅有一个控制器。每个broker启动的时候会尝试去读取/controller节点的 brokerid值,如果读取到brokerid的值不为-1,则表示已经有其它broker节点成功竞选为控制器,所以当前broker就会放弃竞选;如果Zookeeper中不存在/controller这个节点,或者这个节点中的数据异常,那么就会尝试去建/controller这个节点,当前broker去创建节点的时候,也有可能其他broker同时去尝试创建这个节点,只有创建成功的broker才会成为控制器,而创建失败的broker则表示竞选失败。 每个broker都会在内存中保存当前控制器的brokerid值,这个值可以标识为activeControllerId。

分区 leader 副本的选举由控制器负责具体实施。当创建分区(创建主题或增加分区都有创建分区的动作)或分区上线(比如分区中原先的 leader 副本下线,此时分区需要选举一个新的leader 上线来对外提供服务)的时候都需要执行 leader 的选举动作。 这种策略的基本思路是按照 AR 集合中副本的顺序查找第一个存活的副本,并且这个副本在 JSR 集合中。 一个分区的AR 集合在分配的时候就被指定,并且只要不发生重分配的情况,集合内部副本的顺序是保持不变的,而分区的 ISR 集合中副本的顺序可能会改变 。

LEO 与 HW

整个消息追加的过程可以概括如下:
生产者客户端发送消息至 leader 副本中 。
消息被迫加到 leader 副本的本地日志,并且会更新日志的偏移量。
follower 副本向 leader 副本请求同步数据 。
leader 副本所在的服务器读取本地日志,并更新对应拉取的 follower 副本的信息 。
leader 副本所在的服务器将拉取结果返回给 follower 副本 。
follower 副本收到 leader 副本返回的拉取结果,将消息追加到本地日志中,并更新日志的偏移量信息。

HW(High Watermark)俗称高水位,它标识了一个特定的消息偏移量(offset),消费者只能拉取到这个offset之前的消息。在 Kafka 中,高水位(High Watermark )的作用主要有 2 个:
定义消息可见性,即用来标识分区下的哪些消息是可以被消费者消费的。
帮助 Kafka 完成副本同步。

kafka 原理分析_第2张图片
在分区高水位以下的消息被认为是已提交消息,反之就是未提交消息。消费者只能消费已提交消息,即图中位移小于 8 的所有消息(不考虑事务)。位移值等于高水位的消息也属于未提交消息。

日志末端位移 (LEO)表示副本写入下一条消息的位移值(待写入)。同一个副本对象,其高水位值不会大于 LEO 值。高水位和 LEO 是副本对象的两个重要属性。Kafka 所有副本都有对应的高水位和 LEO 值,而不仅仅是 Leader副本。只不过 Leader 副本比较特殊,Kafka 使用 Leader 副本的高水位来定义所在分区的高水位。换句话说,分区的高水位就是其 Leader 副本的高水位。

高水位更新机制

在 Leader 副本所在的 Broker 上,还保存了其他 Follower 副本的 LEO 值。
kafka 原理分析_第3张图片
Broker 0 上保存了某分区的 Leader 副本和所有 Follower 副本的 LEO 值,而 Broker 1 上仅仅保存了该分区的某个 Follower 副本。Kafka 把 Broker 0 上保存的这些 Follower 副本又称为远程副本(Remote Replica)。Kafka 副本机制在运行过程中,会更新 Broker 1 上 Follower 副本的高水位和 LEO 值,同时也会更新 Broker 0 上Leader 副本的高水位和 LEO 以及所有远程副本的 LEO,但它不会更新远程副本的高水位值,也就是图中标记为灰色的部分。
Broker 0 上保存这些远程副本主要作用是:帮助 Leader 副本确定其高水位,也就是分区高水位 。 与 Leader 副本保持同步的判断的条件有两个:
该远程 Follower 副本在 ISR 中。
该远程 Follower 副本 LEO 值落后于 Leader 副本 LEO 值的时间,不超过 Broker 端参数 replica.lag.time.max.ms 的值。如果使用默认值的话,就是不超过 10 秒。

取一个partition对应的ISR中最小的LEO(log-end-offset)作为HW,consumer最多只能消费到HW所在的位置。 对于leader新写入的消息,consumer不能立刻消费,leader会等待该消息被所有ISR中的 replicas同步后更新HW,此时消息才能被consumer消费。这样就保证了如果leader所在的broker失效,该消息仍然可以从新选举的leader中获取。对于来自内部broker的读取请求,没有HW的限制 。

副本同步机制

当producer生产消息至broker后,ISR以及HW和LEO的流转过程:
kafka 原理分析_第4张图片
Kafka的复制机制既不是完全的同步复制,也不是单纯的异步复制。事实上,同步复制要求所有能工作的follower都复制完,这条消息才会被commit,这种复制方式极大的影响了吞吐率。而异步复制方式下,follower异步的从leader复制数据,数据只要被leader写入log就被认为已经commit,这种情况下如果follower都还没有复制完,落后于leader时,突然leader宕机,则会丢失数据。而Kafka的这种使用 ISR的方式则很好的均衡了确保数据不丢失以及吞吐率。

结合HW和LEO看下 acks=1的情况 :
kafka 原理分析_第5张图片
recovery-point-offset-checkpoint 和 replication-offset-checkpoint 这两个文件分别对应了 LEO和 HW 。Kafka 中会有一个定时任务负责将所有分区的 LEO 刷写到恢复点文件 recovery-pointoffsetcheckpoint 中,定时周期由 broker 端参数 log.flush.offset.checkpoint.interval.ms 来配置,默认值为 60000 。还有一个定时任务负责将所有分区的 HW 刷写到复制点文件 replication-offsetcheckpoint 中, 定时周期由 broker 端参数 replica.high. watermark.checkpoint.interval.ms 来配置,默认值为 5000 。Kafka 也有一个定时任务来负责将所有分区的 logStartOffset书写到起始点文件 log-start-offset-checkpoint 中,定时周期由 broker 端参数 log.flush.start.offset.checkpoint.interval.ms 来配置,默认值为 60000 。

4、日志存储

Kafka 一个分区的消息数据对应存储在一个文件夹下,以topic名称+分区号命名,kafka规定了一个分区内的 .log文件最大为 1G。

# 部分消息的offset索引文件,kafka每次往分区发4K(可配置)消息就会记录一条当前消息的offset到 index文件,如果要定位消息的offset会先在这个文件里快速定位,再去log文件里找具体消息 
00000000000000000000.index 
# 消息存储文件,主要存offset和消息体 。LogSegment 的基准偏移量为 0,对应的日志文件为 00000000000000000000.log 00000000000000000000.log 
# 消息的发送时间索引文件,kafka每次往分区发4K(可配置)消息就会记录一条当前消息的发送时间戳与对应的offset到timeindex文件,如果需要按照时间来定位消息的offset,会先在这个文件里查找 
00000000000000000000.timeindex

Kafka Broker 有一个参数,log.segment.bytes,限定了每个日志段文件的大小,最大就是 1GB。一个日志段文件满了,就自动开一个新的日志段文件来写入,避免单个文件过大,影响文件的读写性能,这个过程叫做log rolling,正在被写入的那个日志段文件,叫做 active log segment。

5、日志索引

每个日志分段文件对应了两个索引文件,主要用来提高查找消息的效率。偏移量索引文件用来建立消息偏移量 offset到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置;时间戳索引文件则根据指定的时间戳timestamp 来查找对应的偏移量信息。 Kafka 中的索引文件以稀疏索引( sparse index )的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引页 。每当写入一定量(由 broker 端参数 log.index.interval.bytes 指定,默认值为 4096 ,即 4KB 的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项,增大或减小 log.index.interval.bytes的值,对应地可以增加或缩小索引项的密度。 稀疏索引通过 MappedByteBuffer 将索引文件映射到内存中,以加快索引的查询速度。偏移量索引文件中的偏移量是单调递增的,查询指定偏移量时,使用二分查找法来快速定位偏移量的位置,如果指定 的偏移量不在索引文件中,则会返回小于指定偏移量的最大偏移量 。时间戳索引文件中的时间戳也保持严格的单调递增,查询指定时间戳时,也根据二分查找法来查找不大于该时间戳的最大偏移量。
偏移量索引 :每个索引项占用 8 个字节,分为两个部分。 relativeOffset:相对偏移量,表示消息相对于 baseOffset 的偏移量,占用 4 个字节 ,当前索引文件的文件名即为 baseOffset 的值 。 position:物理地址,也就是消息在日志分段文件中对应的物理位置,占用 4 个字节。
时间戳索引 :每个索引项占用 12 个字节,分为两个部分。 timestamp : 当前日志分段最大的时间戳,占用8个字节,relativeOffset:时间戳所对应的消息的相对偏移量,占用4个字节。

如果 broker 端参数 log.message.timestamp.type设置为 LogAppendTime,那么消息的时间戳必定能够保持单调递增;相反,如果是 CreateTime 类型则无法保证 。

6、零拷贝

Kafka 使用零拷贝技术来提升性能 。所谓的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手 。零拷贝大大提高了应用程序的性能,减少了内核和用户模式之间的上下文切换 。对 Linux 操作系统而言,零拷贝技术依赖于底层的 sendfile()方法实现 。对应于 Java 语言,FileChannal.transferTo()方法的底层实现就是 sendfile()方法 。

你可能感兴趣的:(Kafka)