目录
Kafka生产者
分区策略
通常情况下,kafka集群中越多的partition会带来越高的吞吐量。但是,我们必须意识到集群的partition总量过大或者单个broker节点partition过多,都会对系统的可用性和消息延迟带来潜在的影响。未来,我们计划对这些限制进行一些改进,让kafka在分区数量方面变得更加可扩展。(具体情况请看:https://www.iteblog.com/archives/1805.html)
在 Kafka 内部存在两种默认的分区分配策略:Range 和 RoundRobin。这个主要是在消费者那边进行发生的,所以,打算在消费者当中进行详细讲解,在这生产者这边就暂时不过多叙述了。
分区的原因
1、方便在集群中扩展,每个partition可以通过调整以适应它所在的机器,而一个topic又可以有多个partition组成,因此可以以partition为单位读写了。
2、可以提高并发,因为可以以partition为单位读写了。
分区原则
我们需要将 Producer 发送的数据封装成一个 ProducerRecord 对象。该对象需要指定一些参数:
- topic:string 类型,NotNull
- partition:int 类型,可选
- timestamp:long 类型,可选
- key:string类型,可选
- value:string 类型,可选
- headers:array 类型,Nullable
(1) 指明 partition 的情况下,直接将给定的 value 作为 partition 的值。
(2) 没有指明 partition 但有 key 的情况下,将 key 的 hash 值与分区数取余得到 partition 值。
(3) 既没有 partition 有没有 key 的情况下,第一次调用时随机生成一个整数(后面每次调用都在这个整数上自增),将这个值与可用的分区数取余,得到 partition 值,也就是常说的 round-robin 轮询算法。
特别注意:Kafka的分区最好一开始就设计好,不要再进行修改,并且Kafka不允许减少分区,增加分区时会出现警告。
这里解释一下为什么Kafka不支持减少分区。
首先,当一个主题被创建之后,依然允许我们对其做一定的修改,比如修改分区个数、修改配置等,这个修改的功能就是由kafka-topics.sh脚本中的alter指令所提供。
bin/kafka-topics.sh --zookeeper localhost:2181/kafka --alter --topic topic-config --partitions 3
WARNING: If partitions are increased for a topic that has a key, the partition logic or ordering of the messages will be affected
Adding partitions succeeded!
注意上面提示的告警信息:当主题中的消息包含有key时(即key不为null),根据key来计算分区的行为就会有所影响。当topic-config的分区数为1时,不管消息的key为何值,消息都会发往这一个分区中;当分区数增加到3时,那么就会根据消息的key来计算分区号,原本发往分区0的消息现在有可能会发往分区1或者分区2中。如此还会影响既定消息的顺序,所以在增加分区数时一定要三思而后行。对于基于key计算的主题而言,建议在一开始就设置好分区数量,避免以后对其进行调整,通常情况,需要根据未来1到2年的目标吞吐量来设计kafka的分区数量。
至于删除分区,Kafka根本就没有实现该功能, 虽然不是不能实现,但是由于逻辑过于复杂,代码量大,且收益不高,所以如果需要缩小分区的时候,一般会选择新建一个topic然后减少分区数量,将数据重新导入新的topic当中,而不是去删除减少原分区数量。
说明一下,为什么不可以删除分区:
首先,最重要的原因删除分区,那么分区的数据该如何进行处理。如果随着分区一起消失则消息的可靠性得不到保障;如果需要保留则又需要考虑如何保留。直接存储到现有分区的尾部,消息的时间戳就不会递增,如此对于Spark、Flink这类需要消息时间戳(事件时间)的组件将会受到影响;如果分散插入到现有的分区中,那么在消息量很大的时候,内部的数据复制会占用很大的资源,而且在复制期间,此主题的可用性又如何得到保障?与此同时,顺序性问题、事务性问题、以及分区和副本的状态机切换问题都是不得不面对的。反观这个功能的收益点却是很低,如果真的需要实现此类的功能,完全可以重新创建一个分区数较小的主题,然后将现有主题中的消息按照既定的逻辑复制过去即可。
新建的分区会在哪个目录下创建
在启动 Kafka 集群之前,我们需要配置好 log.dirs 参数,其值是 Kafka 数据的存放目录,这个参数可以配置多个目录,目录之间使用逗号分隔,通常这些目录是分布在不同的磁盘上用于提高读写性能。 当然我们也可以配置 log.dir 参数,含义一样。只需要设置其中一个即可。
如果 log.dirs 参数只配置了一个目录,那么分配到各个 Broker 上的分区肯定只能在这个目录下创建文件夹用于存放数据。 但是如果 log.dirs 参数配置了多个目录,那么 Kafka 会在哪个文件夹中创建分区目录呢?
答案是:Kafka 会在含有分区目录最少的文件夹中创建新的分区目录,分区目录名为 Topic名+分区ID。注意,是分区文件夹总数最少的目录,而不是磁盘使用量最少的目录!也就是说,如果你给 log.dirs 参数新增了一个新的磁盘,新的分区目录肯定是先在这个新的磁盘上创建直到这个新的磁盘目录拥有的分区目录不是最少为止。
代码如下:
private val logs = new Pool[TopicAndPartition, Log]()
/**
* Create a log for the given topic and the given partition
* If the log already exists, just return a copy of the existing log
*/
def createLog(topicAndPartition: TopicAndPartition, config: LogConfig): Log = {
logCreationOrDeletionLock synchronized {
var log = logs.get(topicAndPartition)
// check if the log has already been created in another thread
if(log != null)
return log
// if not, create it
val dataDir = nextLogDir()
val dir = new File(dataDir, topicAndPartition.topic + "-" + topicAndPartition.partition)
dir.mkdirs()
log = new Log(dir,
config,
recoveryPoint = 0L,
scheduler,
time)
logs.put(topicAndPartition, log)
info("Created log for partition [%s,%d] in %s with properties {%s}."
.format(topicAndPartition.topic,
topicAndPartition.partition,
dataDir.getAbsolutePath,
{import JavaConversions._; config.toProps.mkString(", ")}))
log
}
}
/**
* Choose the next directory in which to create a log. Currently this is done
* by calculating the number of partitions in each directory and then choosing the
* data directory with the fewest partitions.
*/
private def nextLogDir(): File = {
if(logDirs.size == 1) {
logDirs(0)
} else {
// count the number of logs in each parent directory (including 0 for empty directories
val logCounts = allLogs.groupBy(_.dir.getParent).mapValues(_.size)
val zeros = logDirs.map(dir => (dir.getPath, 0)).toMap
var dirCounts = (zeros ++ logCounts).toBuffer
// choose the directory with the least logs in it
val leastLoaded = dirCounts.sortBy(_._2).head
new File(leastLoaded._1)
}
}
从上面代码可以清楚看出,需要创建新的分区时,Kafka先从 logs 存储池中获取当前分区对应的 Log 对象。如果获取到了,说明不是新的分区,这时候直接返回 Log 实例;如果这个分区是新建的,肯定是获取不到,这时候需要调用 nextLogDir
函数获取再哪个目录上创建分区目录。其核心思想就是找到分区数最少的目录来创建新的分区。
当然,这种实现上会有几个问题:
- 分区数最少的目录未必是数据量最少的目录,如果分区数最少的目录恰恰是数据量最多的目录这样会导致磁盘使用不均衡;
- 这种实现也没有考虑到磁盘的读写负载。
数据可靠性保证
为保证 producer 发送的数据,能可靠地发送到指定的 topic,topic 的每个 partition 收到 producer 发送的数据后,都需要向 producer 发送 ack(acknowledge 确认收到),如果 producer 收到 ack,就会进行下一轮的发送,否则重新发送数据。
Kafka选择了第二种方案,原因是:
1、同样为了容忍n台节点的故障,第一种方案需要2n+1个副本,而第二种方案只需要n+1个副本,而Kafka的每个分区都有大量的数据,第一种方案会造成大量的数据冗余。
2、虽然第二种方案的网络延迟会比较高,但网络延迟对Kafka的影响较小。
但是,若leader收到数据,所有的follower都开始同步数据,但是有一个follower,因为某种故障,迟迟不能与leader进行同步,那leader就要一直等下去,直到它同步完成,才能发送ack。
所以,Kafka在此进行优化。Leader维护了一个动态的in-sync replica set(ISR),意为和leader保持同步的follower集合。当ISR中的follower完成数据的同步之后,leader就会给follower发送ack。如果follower长时间未向leader同步数据,则该follower将会被踢出ISR,该时间阈值由replica.lag.time.max.ms参数设定。Leader发生故障之后,就会从ISR中选举新的leader。
ISR机制
- leader会维护一个与其基本保持同步的Replica列表,该列表称为ISR(in-sync Replica),每个Partition都会有一个ISR,而且是由leader动态维护
- 如果一个flower比一个leader落后太多,或者超过一定时间未发起数据复制请求,则leader将其重ISR中移除
- 当ISR中所有Replica都向Leader发送ACK时,leader才commit
在Kafka0.9版本之后,ISR移除了数据条数这一条件,只保留了超时时间这一条件。原因:由于生产者发送数据为批批量发送,假设一次发送12条数据,如果ISR设置的条数限制为10条,此时ISR里面的所有follower都不满足条件,都被踢出ISR队列,但是在10000ms以内,大部分follower已经同步完成leader的数据,又被重新加入到ISR队列中。由于ISR存储在zookeeper之中,多次踢出,重新加入,导致频繁读写zookeeper,导致效率降低,所有0.9以后,移除了条数这个限制,保留了时间限制。
ack应答机制(决定数据丢不丢)
对于某些不太重要的数据,对数据的可靠性要求不是很高,能够容忍数据的少量丢失,所以没必要等 ISR 中的 follower 全部接受成功。
所以 Kafka 为用户提供了三种可靠性级别,用户根据可靠性和延迟的要求进行权衡,选择以下的配置。
(1)ack 参数配置:
- 0:producer 不等待 broker 的 ack,这提供了最低延迟,broker 一收到数据还没有写入磁盘就已经返回,当 broker 故障时有可能丢失数据。
- 1:producer 等待 broker 的 ack,partition 的 leader 落盘成功后返回 ack,如果在 follower 同步成功之前 leader 故障,那么将会丢失数据。
- -1(all):producer 等待 broker 的 ack,partition 的 leader 和 follower 全部落盘成功后才返回 ack。但是在 broker 发送 ack 时,leader 发生故障,则会造成数据重复。
数据丢失情况:
可能还没写入磁盘,返回ack后,直接宕机,那么就有可能造成数据丢失。
数据重复情况:
可能leader发送ack时,leader宕机了,生产者没有收到ack,再重新选举了leader之后,生产者将会重新发送数据。
故障处理细节
LEO:每个副本最大的 offset。
HW:消费者能见到的最大的 offset,ISR 队列中最小的 LEO。
(1)Follower 故障
follower 发生故障后会被临时踢出 ISR 集合,待该 follower 恢复后,follower 会读取本地磁盘记录的上次的 HW,并将 log 文件高于 HW 的部分截取掉,从 HW 开始向 leader 进行同步数据操作。等该 follower 的 LEO 大于等于该 partition 的 HW,即 follower 追上 leader 后,就可以重新加入 ISR 了。
(2)Leader 故障
leader 发生故障后,会从 ISR 中选出一个新的 leader,之后,为保证多个副本之间的数据一致性,其余的 follower 会先将各自的 log 文件高于 HW 的部分截掉,然后从新的 leader 同步数据。
注意:这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。
Exactly Once 语义
将服务器的 ACK 级别设置为-1,可以保证 producer 到 server 之间不会丢失数据,即 At Least Once 语义。相对的,将服务器 ACK 级别设置为0,可以保证生产者每条消息只会被发送一次,即At Most Once 语义。
At Least Once 可以保证数据不丢失,但是不能保证数据不重复;相对的,At Most Once 可以保证数据不重复,但是不能保证数据不丢失。但是,对于一些非常重要的信息,比如交易数据,下游数据消费者要求数据既不重复也不丢失,即 Exactly Once 语义。
0.11版本的 Kafka,引入了幂等性:producer 不论向 server 发送多少重复数据,server 端都只会持久化一条。即:
At Least Once + 幂等性 = Exactly Once
要启用幂等性,只需要将 producer 的参数中 enable.idompotence
设置为 true
即可。开启幂等性的 producer 在初始化时会被分配一个 PID,发往同一 partition 的消息会附带 Sequence Number。而 borker 端会对 <PID,Partition,SeqNumber> 做缓存,若三个主键都相同则表示重复数据,否则就是不同数据,当具有相同主键的消息提交时,broker 只会持久化一条。
但是 PID 重启后就会变化,同时不同的 partition 也具有不同主键,所以幂等性无法保证跨分区会话的 Exactly Once,即可能宕机之后重新加入,则可能存在重复数据。
参考
https://www.bilibili.com/video/BV1a4411B7V9?p=19&spm_id_from=pageDriver
https://www.jianshu.com/p/c987b5e055b0 leader的选举过程,篇幅过长就没有写了。