Loading

[20] Kafka Broker

1. 工作流程

1.1 Zk 存储的信息

1.2 总体工作流程

step1~6:

step7~11:

模拟 broker 上下线,Zk 中的数据变化:

(1)查看 /kafka/brokers/ids 路径上的节点

[zk: localhost:2181(CONNECTED) 2] ls /kafka/brokers/ids
[0, 1, 2]

(2)查看/kafka/controller 路径上的数据

[zk: localhost:2181(CONNECTED) 15] get /kafka/controller
{"version":1,"brokerid":0,"timestamp":"1637292471777"}

(3)查看 /kafka/brokers/topics/first/partitions/0/state 路径上的数据

[zk: localhost:2181(CONNECTED) 16] get /kafka/brokers/topics/first/partitions/0/state
{"controller_epoch":24,"leader":0,"version":1,"leader_epoch":18,"isr":[0,1,2]}

(4)停止 hadoop104 上的 kafka

[root@hadoop104 kafka]$ bin/kafka-server-stop.sh

(5)再次查看 /kafka/brokers/ids 路径上的节点

[zk: localhost:2181(CONNECTED) 3] ls /kafka/brokers/ids
[0, 1]

(6)再次查看 /kafka/controller 路径上的数据

[zk: localhost:2181(CONNECTED) 15] get /kafka/controller
{"version":1,"brokerid":0,"timestamp":"1637292471777"}

(7)再次查看 /kafka/brokers/topics/first/partitions/0/state 路径上的数据

[zk: localhost:2181(CONNECTED) 16] get /kafka/brokers/topics/first/partitions/0/state
{"controller_epoch":24,"leader":0,"version":1,"leader_epoch":18,"isr":[0,1]}

(8)启动 hadoop104 上的 kafka

[root@hadoop104 kafka]$ bin/kafka-server-start.sh -daemon ./config/server.properties

2. 节点服役、退役

当集群中新增 broker 节点时,只有新创建的 Topic-Paritition 才有可能被分配到这个节点上,而之前的 Topic-Parition 并不会自动分配到新加入的节点中,这样新节点的负载和原先节点的负载之间严重不均衡。

2.1 准备新节点

  1. 关闭 hadoop104,并右键执行克隆操作;
  2. 开启 hadoop105,并修改 IP 地址;
  3. 在 hadoop105 上,修改主机名称为 hadoop105;
  4. 重新启动 hadoop104、hadoop105;
  5. 修改 haodoop105 中 kafka 的 broker.id 为 3;
  6. 删除 hadoop105 中 kafka 下的 datas 和 logs 目录;
  7. 启动 hadoop102、hadoop103、hadoop104 上的 kafka 集群;
  8. 单独启动 hadoop105 中的 kafka

2.2 服役新节点

(1)创建一个 Json 文件,内容为要进行分区重分配的 Topic 清单;

>>> vim topics-to-move.json
{
    "topics": [
        {"topic": "first"}
    ],
    "version": 1
}

(2)根据 JSON 文件和指定索要分配的 broker 节点列表来生成一份候选的重分配方案;

[root@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh 
                            --bootstrap-server hadoop102:9092 
                            --topics-to-move-json-file topics-to-move.json 
                            --broker-list "0,1,2,3" --generate

执行完此操作,终端会打印出两个 JSON 格式的内容。第一个“Current paritition replica assignment”所对应的 JSON 内容为当前的分区副本分配情况,在执行分区重分配的时候最好将这个内容保存起来,以备后续的回滚操作;第二个“Proposed partition reassignment configuration”所对应的 JSON 内容为重分配的候选方案,注意这里只是生成一份可行性的方案,并没有真正执行重分配的动作。

(3)创建副本存储计划 // 所有副本存储在 broker0、broker1、broker2、broker3 中

>>> vim increase-replication-factor.json
# 把执行上条命令返回结果中的 Proposed partition reassignment configuration 后的部分赋值到该文件中。

(4)执行副本存储计划

[root@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh 
                                --bootstrap-server hadoop102:9092 
                                --reassignment-json-file increase-replication-factor.json 
                                --execute

(5)验证副本存储计划

[root@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh 
                                --bootstrap-server hadoop102:9092 
                                --reassignment-json-file increase-replication-factor.json
                                --verify

【图示】

2.3 退役旧节点

先按照退役一台节点,生成执行计划,然后按照服役时操作流程执行负载均衡。

(1)创建一个要均衡的主题

>>> vim topics-to-move.json
{
    "topics": [
        {"topic": "first"}
    ],
    "version": 1
}

(2)生成一个负载均衡计划

[root@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh 
                                --bootstrap-server hadoop102:9092 
                                --topics-to-move-json-file topics-to-move.json 
                                --broker-list "0,1,2" 
                                --generate

(3)创建副本存储计划 // 所有副本存储在 broker0、broker1、broker2 中

>>> vim decrease-replication-factor.json
# 把执行上条命令返回结果中的 Proposed partition reassignment configuration 后的部分赋值到该文件中。

(4)执行副本存储计划

[atguigu@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh 
                                --bootstrap-server hadoop102:9092 
                                --reassignment-json-file decrease-replication-factor.json 
                                --execute

(5)验证副本存储计划

[atguigu@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh 
                                --bootstrap-server hadoop102:9092 
                                --reassignment-json-file decrease-replication-factor.json
                                --verify

【注】服役和退役在操作上没差,只是在执行「(2)生成一个负载均衡计划」操作时 --broker-list "..." 的参数不同。

3. Kafka Repllicas

3.1 副本基本信息

  • Kafka 副本作用:提高数据可靠性;
  • Kafka 默认副本 1 个,生产环境一般配置为 2 个,保证数据可靠性;太多副本会增加磁盘存储空间,增加网络上数据传输,降低效率;
  • Kafka 中副本分为:Leader 和 Follower。Kafka 生产者只会把数据发往 Leader,然后 Follower 找 Leader 进行同步数据;
  • Kafka 分区中的所有副本统称为 AR(Assigned Repllicas),AR = ISR + OSR
    • ISR,表示和 Leader 保持同步的 Follower 集合。如果 Follower 长时间未向 Leader 发送通信请求或同步数据,则该 Follower 将被踢出 ISR。该时间阈值由 replica.lag.time.max.ms 参数设定,默认 30s。Leader 发生故障之后,就会从 ISR 中选举新的 Leader;
    • OSR,表示 Follower 与 Leader 副本同步时,延迟过多的副本。

3.2 Leader 选举流程

流程配图见 #1.2

Kafka 集群中有一个 broker 的 Controller 会被选举为 Controller Leader,负责管理集群 broker 的上下线,所有 topic 的分区副本分配和 Leader 选举等工作。Controller 的信息同步工作是依赖于 Zookeeper 的。

【示例】Kafka 集群由 hadoop102:0、hadoop103:1、hadoop104:2、hadoop105:3 组成。

3.3 故障处理细节

LEO(Log End Offset):每个副本的最后一个 offset,LEO其实就是最新的 offset + 1。

HW(High Watermark):所有副本中最小的 LEO。

Follower 故障

  • Follower 发生故障后会被临时踢出 ISR,这个期间 Leader 和 Follower 继续接收数据;
  • 待该 Follower 恢复后,Follower 会读取本地磁盘记录的上次的 HW,并将 log 文件高于 HW 的部分截取掉,从 HW 开始向 Leader 进行同步;
  • 等该 Follower 的 LEO 大于等于该 Partition 的 HW,即 Follower 追上 Leader 之后,就可以重新加入 ISR 了。

Leader 故障

  • Leader 发生故障之后,会从 ISR 中选出一个新的 Leader;
  • 为保证多个副本之间的数据一致性,其余的 Follower 会先将各自的 log 文件高于 HW 的部分截掉,然后从新的 Leader 同步数据;
  • 注意!这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。

小结

Kafka 的复制机制既不是完全的同步复制,也不是单纯的异步复制。事实上,同步复制要求所有能工作的 Follower 副本都复制完,这条消息才会被却认为已成功提交,这种复制方式极大地影响了性能。而在异步复制方式下,Follower 副本异步地从 Leader 副本中复制数据,数据只要被 Leader 副本写入就被认为已经成功提交。在这种情况下,如果 Follower 副本都还没有复制完而落后于 Leader 副本,突然 Leader 副本宕机,则会造成数据丢失。Kafka 使用的这种 ISR 的方式则有效地权衡了数据可靠性和性能之间的关系。

3.4 分区副本分配

如果 Kafka 服务器只有 4 个节点,那么设置 Kafka 的分区数大于服务器台数,在 Kafka 底层如何分配存储副本呢?

【示例】创建一个新的 topic,名称为 second,设置为 16 分区,3 个副本。

如果集群刚刚搭建成功,创建的第一个 Topic,则分区情况如下:

Topic: second4 Partition: 0  Leader: 0 Replicas: 0,1,2 Isr: 0,1,2
Topic: second4 Partition: 1  Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
Topic: second4 Partition: 2  Leader: 2 Replicas: 2,3,0 Isr: 2,3,0
Topic: second4 Partition: 3  Leader: 3 Replicas: 3,0,1 Isr: 3,0,1
Topic: second4 Partition: 4  Leader: 0 Replicas: 0,2,3 Isr: 0,2,3
Topic: second4 Partition: 5  Leader: 1 Replicas: 1,3,0 Isr: 1,3,0
Topic: second4 Partition: 6  Leader: 2 Replicas: 2,0,1 Isr: 2,0,1
Topic: second4 Partition: 7  Leader: 3 Replicas: 3,1,2 Isr: 3,1,2
Topic: second4 Partition: 8  Leader: 0 Replicas: 0,3,1 Isr: 0,3,1
Topic: second4 Partition: 9  Leader: 1 Replicas: 1,0,2 Isr: 1,0,2
Topic: second4 Partition: 10 Leader: 2 Replicas: 2,1,3 Isr: 2,1,3
Topic: second4 Partition: 11 Leader: 3 Replicas: 3,2,0 Isr: 3,2,0
Topic: second4 Partition: 12 Leader: 0 Replicas: 0,1,2 Isr: 0,1,2
Topic: second4 Partition: 13 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
Topic: second4 Partition: 14 Leader: 2 Replicas: 2,3,0 Isr: 2,3,0
Topic: second4 Partition: 15 Leader: 3 Replicas: 3,0,1 Isr: 3,0,1

非第一次创建 Topic(但还是有迹可循):

4. 生产经验

4.1 手动调整分区副本存储

在生产环境中,每台服务器的配置和性能不一致,但是 Kafka 只会根据自己的代码规则创建对应的分区副本,就会导致个别服务器存储压力较大。所有需要手动调整分区副本的存储。

【示例】创建一个新的 Topic,4 个分区,2个副本,名称为 three。将该 Topic 的所有副本都存储到 broker0 和 broker1 两台服务器上。

【步骤】

  1. 创建一个新的 topic,名称为 three
    [atguigu@hadoop102 kafka]$ bin/kafka-topics.sh 
        --bootstrap-server hadoop102:9092 
        --create 
        --partitions 4 
        --replication-factor 2 
        --topic three
    
  2. 查看分区副本存储情况
    [atguigu@hadoop102 kafka]$ bin/kafka-topics.sh 
        --bootstrap-server hadoop102:9092 
        --describe 
        --topic three
    
  3. 创建副本存储计划:所有副本都指定存储在 broker0、broker1 中
    >>> vim increase-replication-factor.json
    {
        "version":1,
        "partitions":[
            {"topic":"three","partition":0,"replicas":[0,1]},
            {"topic":"three","partition":1,"replicas":[0,1]},
            {"topic":"three","partition":2,"replicas":[1,0]},
            {"topic":"three","partition":3,"replicas":[1,0]}
        ]
    }
    
  4. 执行副本存储计划
    [atguigu@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh 
        --bootstrap-server hadoop102:9092 
        --reassignment-json-file increase-replication-factor.json 
        --execute
    
  5. 验证副本存储计划
    [atguigu@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh 
        --bootstrap-server hadoop102:9092 
        --reassignment-json-file increase-replication-factor.json 
        --verify
    
  6. 查看分区副本存储情况
    [atguigu@hadoop102 kafka]$ bin/kafka-topics.sh
        --bootstrap-server hadoop102:9092 
        --describe 
        --topic three
    

为什么不支持减少分区?

4.2 Leader Partition 负载平衡

正常情况下,Kafka 本身会自动把 Leader Partition 均匀分散在各个机器上,来保证每台机器的读写吞吐量都是均匀的。但是如果某些 broker 宕机,会导致 Leader Partition 过于集中在其他少部分几台 broker 上,这会导致少数几台 broker 的读写请求压力过高,其他宕机的 broker 重启之后都是 Follower Partition,读写请求很低,造成集群负载不均衡。

  • auto.leader.rebalance.enable 默认是 true,自动 Leader Partition 平衡;
  • leader.imbalance.per.broker.percentage 默认是 10%,每个 broker 允许的不平衡的 Leader 的比率。如果每个 broker 超过了这个值,控制器会触发 Leader 的平衡;
  • leader.imbalance.check.interval.seconds 默认值 300s,检查 Leader 负载是否平衡的间隔时间。

下面拿一个 Topic 举例说明,假设集群只有一个 Topic 如下图所示:

  • 针对 broker0 节点,分区 2 的 AR 优先副本是 0 节点,但是 0 节点却不是 Leader 节点,所以不平衡数 +1,AR 副本总数是 4。故 broker0 节点不平衡率为 1/4>10%,需要再平衡;
  • broker2 和 broker3 节点和 broker0 不平衡率一样,也需要再平衡;
  • broker1 的不平衡数为 0,不需要再平衡。

但其实已经均匀分布了,所以没必要开启。就算开启,也要把阈值提高。

4.3 增加副本因子

在生产环境当中,由于某个主题的重要等级需要提升,我们考虑增加副本。副本数的增加需要先制定计划,然后根据计划执行。

  1. 创建一个新的 topic,名称为 four;
    [atguigu@hadoop102 kafka]$ bin/kafka-topics.sh 
        --bootstrap-server hadoop102:9092 
        --create 
        --partitions 4 
        --replication-factor 2 
        --topic four
    
  2. 创建副本存储计划:所有副本都指定存储在 broker0、broker1、broker2 中;
    >>> vim increase-replication-factor.json
    {
        "version":1,
        "partitions":[
            {"topic":"four","partition":0,"replicas":[0,1,2]},
            {"topic":"four","partition":1,"replicas":[0,1,2]},
            {"topic":"four","partition":2,"replicas":[0,1,2]}
        ]
    }
    
  3. 执行副本存储计划;
    [atguigu@hadoop102 kafka]$ bin/kafka-reassign-partitions.sh
        --bootstrap-server hadoop102:9092
        --reassignment-json-file increase-replication-factor.json
        --execute
    

4.4 分区数目

分区数越多吞吐量就越高吗?

分区是 Kafka 中最小的并行操作单元,对生产者而言,每一个分区的数据写入是完全可以并行化的;对消费者而言,Kafka 只允许单个分区中的消息被一个消费者线程消费,一个消费者组的消费并行度完全依赖于所消费的分区数。如此看来,如果一个 Topic 的分区数越多,理论上所能达到的吞吐量就越大,那么事实真的如预想的一样吗?

我们可以看到分区数为 1 时吞吐量最低,随着分区数的增长,相应的吞吐量也跟着上涨。一旦分区数超过了某个阈值之后,整体的吞吐量是不升反降的。也就是说,并不是分区数越多吞吐量也越大,这里的分区数临界阈值针对不同的测试环境也会表现出不同的结果。实际应用中可以通过类似的测试案例(比如复制生产流量以便进行测试)来找到一个合理的临界值区间。

分区数目的设置有上限吗?

一昧地增加分区数并不能使吞吐量一直得到提升,并且分区数也并不能一直增加,如果超过默认配置值,还会引起 Kafka 进程的崩溃。创建包含 10000 个分区的主题。

执行完成后可以检查 Kafka 的进程是否还存在 比如通过 jpsps -aux l grep kafka 命令) 。一般情况下,会发现原本运行完好的 Kafka 服务己经崩溃。 此时或许会想到,建这么多分区,是不是因为内存不够而引起的进程崩溃?我们在启动 Kafka 进程的时候将 JVM 设置得大一点是不是就可以解决问题了。其实不然,创建这些分区而引起的内存增长完全不足以让 Kafka “畏惧”。

为了分析真实的原因,我们可以打开 Kafka 的服日志文件($KAFKA HOME/logs/server.log)来一探究竟,会发现服务日志中出现大量的异常:

异常中最关键的信息是“Too many open flies”,这是一种常见的 Linux 系统错误,通常意味着文件描述符不足,它一般发生在创建线程、创建 Socket 、打开文件这些场景下。在 Linux 系统的默认设置下,这个文件描述符的个数不是很多,通过 ulimit 命令可以查看:

[root@nodel kafka2.11-2.0.0]# ulimit -n
1024
[root@nodel kafka2.11-2.0.0]# ulimit -Sn
1024
[root@nodel kafka2.11-2.0.0]# ulimit -Hn
4096

可以通过测试来验证上例中 Kafka 的崩溃是由于文件描述符的限制而引起:

如何避免这种异常情况?对于一个高并发、高性能的应用来说,1024 或 4096 的文件描述符限制未免太少,可以适当调大这个参数。比如使用 ulimit -n 65535 命令将上限提高到 65535,这样足以应对大多数的应用情况,再高也完全没有必要了。

[root@nodel kafka2.11-2.0.0]# ulimit -n 65535
# 可以再次查看相应的软硬限制数
[root@nodel kafka2.11-2.0.0]# ulimit -Sn
65535
[root@nodel kafka2.11-2.0.0]# ulimit -Hn
65535

也可以在 /etc/security/limits.conf 文件中设置,参考如下:

#nofile - max number of open file descriptors 
root soft nofile 65535 
root hard nofile 65535

limits.conf 文件修改之后需要重启才能生效。limits.conf 文件与 ulimit 命令的区别在于前者是针对所有用户的,而且在任何 shell 中都是生效的,即与 shell 无关,而后者只是针对特定
用户的当前 shell 的设定。在修改最大文件打开数时,最好使用 limits.conf 文件来修改,通过这个文件,可以定义用户、资源类型、软硬限制等。也可以通过在 /etc/profile 文件中添加 ulimit 的设置语句来使全局生效。

设置之后可以再次尝试创建 10000 个分区的主题,检查一下 Kafka 是否还会再次崩溃。


如何选择合适的分区数?

如果一定要给一个准则,则建议将分区数设定为集群中 broker 的倍数,即假定集群中有 3 个 broker 节点,可以设定分区数为 3、6、9 等,至于倍数的选定可以参考预估的吞吐量。

5. 文件存储

5.1 存储机制

在节点个数为 3 的集群中创建一个分区数为 4、副本因子为 2 的 Topic 之后,Kafka 会在 log.dir/log.dirs 参数所配置的目录下创建相应的 Topic 分区,目录命名方式可以概括为 <Topic>-<Partition>。严谨地说,其实 <Topic>-<Partition> 这类文件夹对应的不是 Partition,Partition 同 Topic 一样是一个逻辑的概念而没有物理地存在。并且这里我们也只是看到了 2 个分区,而我们创建的是 4 个分区,其余 2 个分区被分配到了 node2 和 node3 节点中。

三个 broker 节点一共会创建 8 个文件夹,这个数字 8 实质上是分区数 4 与副本因子 2 的乘积。每个副本(或者更确切的说应该是日志,副本与日志一一对应)才真真对应了一个命名形式如 <Topic>-<Partition> 的文件夹。

主题、分区、副本、日志的关系如下图所示,主题和分区都是提供给上层用户的抽象,而在副本层面或更加确切地说是 Log 层面才有实际物理上的存储在。同一个分区中地多个副本必须分布在不同的 broker 中,这样才能提供有效的数据冗余(最好是每个 broker 中都拥有所有分区的一个副本)。

a. 文件目录布局

为了防止 Log 过大,Kafka 又引入了日志分段(LogSegment)的概念,将 Log 切分为多个 LogSegment,相当于一个巨型文件被平均分配为多个相对较小的文件,这样也便于消息的维护和清理。事实上,Log 和 LogSegment 也不是纯粹物理意义上的概念,Log 在物理上只以文件夹的形式存储,而每个 LogSegment 对应于磁盘上的一个日志文件和两个索引文件,以及可能的其他文件(比如以“.txnindex”为后缀的事务索引文件)。

我们知道 Log 对应了一个命名形式为 <Topic>-<Partition> 的文件夹。举个例子,假设有一个名为“topic-log”的主题,此主题中具有 4 个分区,那么在实际物理存储上表现为“topic-log-x”:

向 Log 中追加消息时是顺序写入的,只有最后一个 LogSegment 才能执行写入操作,在此之前所有的 LogSegment 都不能写入数据。为了方便描述,我们将最后一个 LogSegment 称为“activeSegment”,即表示当前活跃的日志分段。随着消息的不断写入,当 activeSegment 满足一定的条件时,就需要创建新的 activeSegment,之后追加的消息将写入新的 activeSegment。

为了便于消息的检索,每个 LogSegment 中的日志文件(以“.log”为文件后缀)都有对应的两个索引文件:偏移量索引文件(以“.index”为文件后缀)和时间戳索引文件(以“.timeindex”为文件后缀)。每个 LogSegment 都有一个基准偏移量 baseOffset,用来表示当前 LogSegment 中第一条消息的 offset。偏移量是一个 64 位的长整型数,日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为 20 位数字,没有达到的位数则用 0 填充。比如第一个 LogSegment 的基准偏移量为 0,对应的日志文件为 00000000000000000000.log

举例说明,向主题 topic-log 中发送一定量的消息,某一时刻 topic-log-0 目录中的布局如下所示。

示例中第 2 个 LogSegment 对应的基准位移是 133,也说明了该 LogSegment 中的第一条消息的偏移量为 133,同时可以反映出第一个 LogSegment 中共有 133 条消息(偏移量从 0 至 132 的消息)。

消费者提交的位移是保存在 Kafka 内部的主题 __consumer_offsets 中的,初始情况下这个主题并不存在,当第一次有消费者消费消息时会自动创建这个主题。

b. 查看 log 日志

直接打开发现是乱码,需要通过 Kafka 提供的工具查看 index 和 log 信息:kafka-run-class.sh kafka.tools.DumpLogSegments --files <fileName>

5.2 日志索引

上文已经提及了每个日志分段文件对应了两个索引文件,主要用来提高查找消息的效率。

  • 偏移量索引文件用来建立消息偏移量(offset)到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置;
  • 时间戳索引文件则根据指定的时间戳(timestamp)来查找对应的偏移量信息。

Kafka 中的索引文件以稀疏索引(Sparse Index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引页。每当写入一定量(由 broker 端参数 log.index.interval.bytes 指定,默认值为 4096,即 4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项,增大或减小log.index.interval.bytes 的值,对应地可以增加或缩小索引项的密度。

稀疏索引通过 MappedByteBuffer 将索引文件映射到内存中,以加快索引的查询速度。稀疏索引的方式是在磁盘空间、内存空间、查找时间等多方面之间的一个折中。

  • 偏移量索引文件中的偏移量是单调递增的,查询指定偏移量时,使用二分查找法来快速定位偏移量的位置,如果指定的偏移量不在索引文件中,则会返回小于指定偏移量的最大偏移量;
  • 时间戳索引文件中的时间戳也保持严格的单调递增,查询指定时间戳时,也根据二分查找法来查找不大于该时间戳的最大偏移量,至于要找到对应的物理文件位置还需要根据偏移量索引文件来进行再次定位。

a. 偏移量索引

每个偏移量索引项占用 8 个字节,分为两个部分。

  • relativeOffset:相对偏移量,表示消息相对于 baseOffset 的偏移量,占用 4 个字节,当前索引文件的文件名即为 baseOffset 的值;
  • position:物理地址,也就是消息在日志分段文件中对应的物理位置,占用 4 个字节。

消息的偏移量(offset)占用 8 个字节,也可以称为绝对偏移量。索引项中没有直接使用绝对偏移量而改为只占用 4 个字节的相对偏移量 CrelativeOffset=offset -baseOffset,这样可以减小索引文件占用的空间。举个例子,一个日志分段的 baseOffset 为 32,那么其文件名就是 00000000000000000032.log, offset 为 35 的消息在索引文件中的 relativeOffset 的值为 35-32=3。

再来回顾一下前面日志分段文件切分的第 4 个条件:追加的消息的偏移量与当前日志分段的偏移量之间的差值大于 Integer.MAX_VALUE。如果彼此的差值超过了 Integer.MAX_VALUE,那么 relativeOffset 就不能用 4 个字节表示了,进而不能享受这个索引项的设计所带来的便利了。

我们以本章开头 topic-log-0 目录下的 00000000000000000000.index 为例来进行具体分析,截取 00000000000000000000.index 部分内容如下:

0000 0006 0000 009c
0000 000e 0000 01cb
0000 0016 0000 02fa
0000 001a 0000 03b0
0000 001f 0000 0475

虽然是以 16 进制数表示的,但参考索引项的格式可以知道如下内容:

relativeOffset=6     position=l56
relativeOffset=l4    position=459
relativeOffset=22    position=656
relativeOffset=26    position=838
relativeOffset=31    position=1050

这里也可以使用前面讲的 kafka-dump-log.sh 脚本来解析 .index 文件,示例如下:

单纯地讲解数字不免过于枯燥,我们这里给出 00000000000000000000.index 和 00000000000000000000.log 的对照图来做进一步的陈述,如图所示。

如果我们要查找偏移量为 23 的消息,那么应该怎么做呢?首先通过二分法在偏移量索引文件中找到不大于 23 的最大索引项,即[22, 656],然后从日志分段文件中的物理位置 656 开始顺序查找偏移量为 23 的消息。

以上是最简单的一种情况。参考下图,如果要查找偏移量为 268 的消息,那么应该怎么办呢?

首先肯定是定位到 baseOffset 为 251 的日志分段,然后计算相对偏移量 relativeOffset=268-251=17,之后再在对应的索引文件中找到不大于 17 的索引项,最后根据索引项中的 position 定位到具体的日志分段文件位置开始查找目标消息。那么又是如何查找 baseOffset 为 251 的日志分段的呢?这里并不是顺序查找,而是用了「跳跃表」的结构。Kafka 的每个日志对象中使用了 ConcurrentSkipListMap 来保存各个日志分段,每个日志分段的 baseOffset 作为 key,这样可以根据指定偏移量来快速定位到消息所在的日志分段。

还需要注意的是,Kafka 强制要求索引文件大小必须是索引项大小的整数倍,对偏移量索引文件而言,必须为 8 的整数倍。如果 broker 端参数 log.index.size.max.bytes 配置为 67,那么 Kafka 在内部会将其转换为 64,即不大于 67,并且满足为 8 的整数倍的条件。

日志存储参数配置:

参数 描述
log.segment.bytes Kafka 中 log 日志是分成一块块存储的,此配置是指 log 日志划分成块的大小,默认值 1G。
log.index.interval.bytes 默认 4kB,Kafka 里面每当写入了 4Kb 大小的日志(.log),然后就往 index 文件里面记录一个(稀疏)索引。

b. 时间戳索引

每个索引项占用 12 个字节,分为两个部分:

  • timestamp(8B):当前日志分段最大的时间戳;
  • relativeOffset(4B):时间戳所对应的消息的相对偏移量。

时间戳索引文件中包含若干时间戳索引项,每个追加的时间戳索引项中的 timestamp 必须大于之前追加的索引项的 timestamp,否则不予追加。如果 broker 端参数 log.message.timestamp.type 设置为 LogAppendTime,那么消息的时间戳必定能够保持单调递增;相反,如果是 CreateTime 类型则无法保证。生产者可以使用类似 ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value) 的方法来指定时间戳的值。

即使生产者客户端采用自动插入的时间戳也无法保证时间戳能够单调递增,如果两个不同时钟的生产者同时往一个分区中插入消息,那么也会造成当前分区的时间戳乱序。

与偏移量索引文件相似,时间戳索引文件大小必须是索引项大小(12B)的整数倍,如果不满足条件也会进行裁剪。同样假设 broker 端参数 log.index.size.max.bytes 配置为 67,那么对应于时间戳索引文件,Kafka 在内部会将其转换为 60。

我们己经知道每当写入一定量的消息时,就会在偏移量索引文件和时间戳索引文件中分别增加一个〈偏移量索引项〉和〈时间戳索引项〉。两个文件增加索引项的操作是同时进行的,但并不意味着偏移量索引中的 relativeOffset 和时间戳索引项中的 relativeOffset 是同一个值。

与上面偏移量索引一节示例中所对应的时间戳索引文件 00000000000000000000.timeindex 的部分内容如下:

0000 0163 639e Sa35 0000 0006 
0000 0163 639e 65fa 0000 000f 
0000 0163 639e 7lbc 0000 0016 
0000 0163 639e 7lcb 0000 00lc 
0000 0163 639e 7d8f 0000 0025

和讲述偏移量索引时一样,我们画出 00000000000000000000.timeindex 的具体结构:

5.3 日志清理

Kafka 将消息存储在磁盘中,为了控制磁盘占用空间的不断增加就需要对消息做一定的清理操作。Kafka 中每一个分区副本都对应一个 Log,而 Log 又可以分为多个日志分段,这样也便于日志的清理操作。Kafka 提供了两种日志清理策略。

  • 日志删除(LogRetention):按照一定的保留策略直接删除不符合条件的日志分段;
  • 日志压缩(LogCompaction):针对每个消息的 key 进行整合,对于有相同 key 的不同 value 值,只保留最后一个版本。

我们可以通过 broker 端参数 log.cleanup.policy 来设置日志清理策略,此参数的默认值为“delete”,即采用日志删除的清理策略。如果要采用日志压缩的清理策略,就需要将 log.cleanup.policy 设置为“compact”,并且还需要将 log.cleaner.enable (默认值为 true)设定为 true。通过将 log.cleanup.policy 参数设为“delete,compact”,还可以同时支持日志删除和日志压缩两种策略。

Kafka 中默认的日志保存时间为 7 天,可以通过调整如下参数修改保存时间。

  • log.retention.hours,最低优先级小时,默认 7 天;
  • log.retention.minutes,优先级次之,分钟;
  • log.retention.ms,最高优先级,毫秒;
  • log.retention.check.interval.ms,负责设置检查周期,默认 5 分钟。

那么日志一旦超过了设置的时间,怎么处理呢?Kafka 中提供的日志清理策略有 delete 和 compact 两种。

a. delete 策略

在Kafka的日志、管理器中会有一个专门的日志删除任务来周期性地检测和删除不符合保留条件的日志分段文件,这个周期可以通过 broker 端参数 log.retention.check.interval.ms 来配置,默认值为 300000,即 5 分钟。

当前日志分段的保留策略有 3 种:基于时间的保留策略、基于日志大小的保留策略和基于日志起始偏移量的保留策略。

(1)基于时间

思考:如果一个 segment 中有一部分数据过期,一部分没有过期,怎么处理?

“以 segment 中所有记录中的最大时间戳作为该文件时间戳”,所以不会删除的,会等没有过期的部分数据也过期,以最后一条记录的时间戳为准。

(2)基于日志大小

(3)基于日志起始偏移量

b. compact 策略

Kafka 中的 LogCompaction 是指在默认的日志删除(LogRetention)规则之外提供的一种清理过时数据的方式。如图所示,LogCompaction 对于有相同 key 的不同 value 值,只保留最后一个版本。如果应用只关心 key 对应的最新 value 值,则可以开启 Kafka 的日志清理功能,Kafka 会定期将相同 key 的消息进行合井,只保留最新的 value 值。

压缩后的 offset 可能是不连续的,比如上图中没有 6,当从这些 offset 消费消息时,将会拿到比这个 offset 大的 offset 对应的消息,实际上会拿到 offset=7 的消息,并从这个位置开始消费。

这种策略只适合特殊场景,比如消息的 key 是 userID,value 是用户的资料,通过这种压缩策略,整个消息集里就保存了所有用户最新的资料。

5.4 磁盘存储

a. 页缓存

当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页(page)是否在页缓存(pageCache)中。如果存在(命中)则直接返回数据,从而避免了对物理磁盘的 I/O 操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。

同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性。

Linux 操作系统中的 vm.dirty_background_ratio 参数用来指定当脏页数量达到系统内存的百分之多少之后就会触发 pdflush/flush/kdmflush 等后台回写进程的运行来处理脏页,一般设置为小于 10 的值即可,但不建议设置为 0。与这个参数对应的还有一个 vm.dirty_ratio 参数,它用来指定当脏页数量达到系统内存的百分之多少之后就不得不开始对脏页进行处理,在此过程中,新的 I/O 请求会被阻挡直至所有脏页被冲刷到磁盘中。

对一个进程而言,它会在进程内部缓存处理所需的数据,然而这些数据有可能还缓存在操作系统的页缓存中,因此同一份数据有可能被缓存了 2 次。井且,除非使用 Direct I/O 的方式,否则页缓存很难被禁止。此外,用过 Java 的人一般都知道两点事实:① 对象的内存开销非常大,通常会是真实数据大小的几倍甚至更多,空间使用率低下;② Java 的垃圾回收会随着堆内数据的增多而变得越来越慢。

基于这些因素,使用文件系统并依赖于页缓存的做法明显要优于维护一个进程内缓存或其他结构,至少我们可以省去了一份进程内部的缓存消耗,同时还可以通过结构紧凑的字节码来替代使用对象的方式以节省更多的空间。如此,我们可以在 32GB 的机器上使用 28GB ~ 30GB 的内存而不用担心 GC 所带来的性能问题。此外,即使 Kafka 服务重启,页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为维护页缓存和文件之间的一致性交由操作系统来负责,这样会比进程内维护更加安全有效。

Kafka 中大量使用了页缓存,这是 Kafka 实现高吞吐的重要因素之一。虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的,但在 Kafka 中同样提供了同步刷盘及间断性强制刷盘(fsync)的功能,这些功能可以通过 log.flush.interval.messageslog.flush.interval.ms 等参数来控制。同步刷盘可以提高消息的可靠性,防止由于机器掉电等异常造成处于页缓存而没有及时写入磁盘的消息丢失。不过笔者并不建议这么做,刷盘任务就应交由操作系统去调配,消息的可靠性应该由多副本机制来保障,而不是由同步刷盘这种严重影响性能的行为来保障。

Linux 系统会使用磁盘的一部分作为 swap 分区,这样可以进行进程的调度:把当前非活跃的进程调入 swap 分区,以此把内存空出来让给活跃的进程。对大量使用系统页缓存的 Kafka 而言,应当尽量避免这种内存的交换,否则会对它各方面的性能产生很大的负面影响。

我们可以通过修改 vm.swappiness 参数(Linux 系统参数)来进行调节。vm.swappiness参数的上限为 100,它表示积极地使用 swap 分区,并把内存上的数据及时地搬运到 swap 分区中;vm.swappiness 参数的下限为 0,表示在任何情况下都不要发生交换(vm. swappiness=0 的含义在不同版本的 Linux 内核中不太相同,这里采用的是变更后的最新解释),这样一来,当内存耗尽时会根据一定的规则突然中止某些进程。笔者建议将这个参数的值设置为 1,这样保留了 swap 的机制而又最大限度地限制了它对 Kafka 性能的影响。

b. 磁盘 IO 流程

从编程角度而言,一般磁盘 I/O 的场景有以下 4 种。

  1. 用户调用标准 C 库进行 I/O 操作,数据流为:应用程序 buffer → C 库标准 IO buffer → 文件系统页缓存 → 通过具体文件系统到磁盘;
  2. 用户调用文件I/O,数据流为:应用程序 buffer → 文件系统页缓存 → 通过具体文件系统到磁盘;
  3. 用户打开文件时使用 O_DIRECT,绕过页缓存直接读写磁盘;
  4. 用户使用类似 dd 工具,并使用 direct 参数,绕过系统 cache 与文件系统直接写磁盘。

c. 零拷贝

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

单纯从概念上理解“零拷贝”比较抽象,这里简单地介绍一下它。考虑这样一种常用的情形:你需要将静态内容(类似图片、文件)展示给用户。这个情形就意味着需要先将静态内容从磁盘中复制出来放到一个内存 buf 中,然后将这个 buf 通过套接字(Socket)传输给用户,进而用户获得静态内容。这看起来再正常不过了,但实际上这是很低效的流程,我们把上面的这种情形抽象成下面的过程:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

首先调用 read() 将静态内容(这里假设为文件 A)读取到 tmpbuf,然后调用 write() 将 tmp_buf 写入 Socket,如图所示。

在这个过程中,文件 A 经历了 4 次复制的过程:

从上面的过程可以看出,数据平白无故地从内核模式到用户模式“走了一圈”,浪费了 2 次复制过程:第 1 次是从内核模式复制到用户模式;第 2 次是从用户模式再复制回内核模式,即上面 4 次过程中的第 2 步和第 3 步。而且在上面的过程中,内核和用户模式的上下文的切换也是 4 次。

如果采用了零拷贝技术,那么应用程序可以直接请求内核把磁盘中的数据传输给Socket,如下所示:

拷贝技术通过 DMA(Direct Memory Access)技术将文件内容复制到内核模式下的 Read Buffer中。不过没有数据被复制到 SocketBuffer,相反只有包含数据的位置和长度的信息的文件描述符被加到 SocketBuffer 中。DMA 引擎直接将数据从内核模式中传递到网卡设备(协议引擎)。这里数据只经历了 2 次复制就从磁盘中传送出去了,并且上下文切换也变成了 2 次。零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝。

5.5 小结

(1)Kafka 本身是分布式集群,可以采用分区技术,并行度高;

(2)读数据采用稀疏索引,可以快速定位要消费的数据;

(3)顺序写磁盘

Kafka 的 Producer 生产数据,要写入到 log 文件中,写的过程是一直追加到文件末端,为顺序写。官网有数据表明,同样的磁盘,顺序写能到 600M/s,而随机写只有 100K/s。这与磁盘的机械机构有关,顺序写之所以快,是因为其省去了大量磁头寻址的时间。

(4)页缓存 + 零拷贝技术

零拷贝:Kafka 的数据加工处理操作交由 Kafka 生产者和 Kafka 消费者处理。Kafka Broker 应用层不关心存储的数据,所以就不用走应用层,传输效率高。

Kafka 重度依赖底层操作系统提供的 PageCache 功能。当上层有写操作时,操作系统只是将数据写入 PageCache。当读操作发生时,先从 PageCache 中查找,如果找不到,再去磁盘中读取。实际上 PageCache 是把尽可能多的空闲内存都当做了磁盘缓存来使用。

相关参数:

参数 描述
log.flush.interval.messages 强制页缓存刷写到磁盘的条数,默认是 long 的最大值 9223372036854775807。一般不建议修改,交给系统自己管理。
log.flush.interval.ms 每隔多久,刷数据到磁盘,默认是 null。一般不建议修改,交给系统自己管理。
posted @ 2023-02-12 14:09  tree6x7  阅读(87)  评论(0编辑  收藏  举报