目录
生产者
消息发送流程
跟踪kafka-client 2.8.0源码
KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
在创建KafkaProducer的时候,(构造器)创建了一个Sender对象,并且启动了一个IO线程
拦截器
执行拦截器的逻辑,在producer.send方法中
拦截器的作用是实现消息的定制化(类似于:Spring Interceptor、Mybatis插件、Quartz监听器)
拦截器的定义
// 添加拦截器
properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, Arrays.asList(ProducerDemoInterceptor.class.getName()));
可以生产者的属性中指定多个拦截器,形成拦截器链
一个自定义的生产者的拦截器需要实现ProducerInterceptor
序列化
调用send方法之后,第二步就是利用指定的工具对key和value进行序列化
kafka针对不同的数据类型自带了相应的序列化工具
除了自带的序列化工具之外,可以使用如JSON、jackson、Thrift、Protobuf等开源序列化工具,或者自定义序列化器也可以,只要实现Serializer接口即可
分区器(路由指定)
# KafkaProducer 931行
# 返回的分区编号
int partition = partition(record, serializedKey, serializedValue, cluster);
一条消息发送到哪个partition,有四种情况:
- 指定了partition
public ProducerRecord(String topic, Integer partition, K key, V value) {
this(topic, partition, null, key, value, null);
}
- 没有指定partition,自定义了分区器
// 添加分区器
properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, ProducerDemoPartitioner.class.getName());
- 没有指定partition,没有定义分区器,但key不为空
// 默认使用的是默认分区器DefaultPartitioner
// 将key的hash值与topic的partition数取余得到partition的值
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
- 没有指定partition,没有自定义分区器,而且key是空的
使用的是RoundRobinPartitioner,即round-bin算法
// RoundRobinPartitioner 实现逻辑
private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new ConcurrentHashMap<>();
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
int nextValue = nextValue(topic);
List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
if (!availablePartitions.isEmpty()) {
int part = Utils.toPositive(nextValue) % availablePartitions.size();
return availablePartitions.get(part).partition();
} else {
// no partitions are available, give a non-available partition
return Utils.toPositive(nextValue) % numPartitions;
}
}
// 根据主题获取并增加1作为partition的分区,初始化为0
private int nextValue(String topic) {
AtomicInteger counter = topicCounterMap.computeIfAbsent(topic, k -> {
return new AtomicInteger(0);
});
return counter.getAndIncrement();
}
消息累加器
选择分区后并没有直接发送消息,而是把消息放入了消息累加器中
// KafkaProducer doSend() line:950
RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey,
serializedValue, headers, interceptCallback, remainingWaitMs, true, nowMs);
RecordAccumulator本质上是一个Co’ncurrentMap
一个partition对应一个batch队列,队列满了之后会唤醒Sender线程,发送消息
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();
}
数据可靠性保证(ACK)
服务端响应策略
生产者的消息是不是发出去就完事了?如果说网络出了问题,或者说kafka服务端接收的时候出了问题,这个消息发送失败了,生产者是不知道的。
所以kafka服务端应该要有一种响应客户端的方式,只有在服务端确认以后,生产者才发送下一轮消息,否则重新发送消息。
服务端什么时候才算接收成功呢?因为消息存储在不同的partition里面,所以是写入到partition之后才响应生产者。
如图所示,只有leader partition写入成功是不可靠的,如果有多个副本,所有follower副本也要写成功才可以。
服务端发送ACK给生产者有两种思路:
- 需要半数以上的follower节点完成同步(这样客户端等待的时间就短一些,延迟低)
- 需要所有的follower节点全部完成同步,才发送ACK给客户端,延迟相对高一些,但是节点挂掉的影响相对小一些,因为所有的节点的数据是完整的
kafka采用第二种方案。部署同样数量机器的情况下,第二种方案的可靠性更高。例如部署5台机器,第一种方案最多有2台机器丢失数据,第二种方案则不会丢失数据。而且网络的延时对kafka影响不大。
ISR
采用第二种方案有一个问题,如果有一个follower节点不能同步数据,那么leader就会一直等待,这势必影响了整个集群。所以我们得让leader只等待正常工作的follower,kafka把和leader正常同步的replica维护起来,放在一个动态的Set里面,这个就叫做in-sync-replica set (ISR)。现在只要ISR中的follower同步数据完成,leader就给客户端发送ACK。
如果一个follower长时间没有同步数据,就从ISR中剔除
replica.lag.time.max.ms=30 #默认30秒
当然,如果有follower可以再同步数据了,也可以再次加入到ISR中,但是有一个问题,如果leader挂了怎么办,这时候需要从ISR中选举leader了。
ACK应答机制
kafka为客户端提供了三种可靠性级别,用户根据对可靠性和延迟的要求做权衡。
propertis.put("akcs", "1"); #参数配置
- ack=0时,producer不等待broker的ack,延迟最低,broker接收发送的消息后立刻响应,此时broker有可能会出现故障没有将数据写入磁盘,导致数据丢失。
- ack=1(默认)时,producer等待broker的ack,partition的leader落盘成功后返回ack,如果follower在同步数据成功之前leader故障,或者follower本身故障,这都会导致数据丢失。
- ack=-1(all)时,producer等待broker的ack,partition的leader和follower全部落盘之后才返回ack。
然后ack=-1的方式也会存在一个问题,在follower同步数据成功,leader发送ack之前发生故障,导致客户端没有收到ack而重新发送数据,那么可以将producer的retries设置为0不重发数据。
kafka broker 存储原理
文件的存储结构
partition 分区
为了实现横向扩展,把不同的数据放在不同的Broker上,同时降低单台服务器的访问压力,我们把一个topic中的数据分隔成多个partition。
一个partition中的消息是顺序序写入的,但是全局不一定。
在服务器上,每个partition都有一个物理目录,topic名字后面的数字标号即代表分区
reolica 副本
为了提高分区的可靠性,kafka又设计了副本机制。创建topic的时候,通过指定replication-factor确定副本的数量。
1、副本的数量必须小于等于节点的数量,而且不能大于broker的数量
2、这里的副本有leader和follower两种角色,leader负责对外提供读写服务,follower只同步数据,这么做也就不会存在读写一致性问题
leader
创建一个3个分区,3个副本的主题
./kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 3 --paititions 3 --topic my-topic
如何查看leader副本所在节点
./kafka-topics.sh --topic my-topic --zookeeper localhost:2181
副本的编号为broker的id,leader的编号也为broker的id,partition是从0开始的,isr表示正常同步数据的副本编号
副本在broker的分布
分区副本的分配策略是由AdminUtils.scala的assignReplicasToBrokers函数决定的
规则如下:
- firt of all,副本因子不能大于broker个数
- 第一个分区(编号为0的分区)的第一个副本的存放位置是随机从brokerList里选择的
- 其他分区的第一个副本的存放位置相对于第一个分区依次往后移
- 每个分区的剩余副本的存放位置,相较于第一个副本是由nextReplicaShift决定的,也是随机产生的
1、一般第一个分区的第一个副本就是leader,而且在分配时已经错开存放了,不至于单节点影响整体
2、 可以通过kafka-reassign-partitions.sh命令根据broker的数量的变化重新分配分区
segment
为了防止log文件不断追加导致文件过大,影响消息检索效率,一个partition副本又被分割成多个segment(MySQL也有segment的逻辑概念,叶子节点就是数据段,费叶子节点就是索引段)
在磁盘上,每个segment由1个log和2个index文件组成,这三个文件成套出现
leader-epoch-checkpoint保存了每一任leader开始写入消息时的offset
log文件
在一个segment文件中,日志是追加写入的,如果满足一定条件,就会切分日志文件,产生一个新的segment。
segment切分的时机有两个:
- 根据日志文件大小,当一个segment写满后,会创建一个新的segment,用最新的offset作为名称。
log.segment.bytes=1073741824 (默认1G)
- 根据消息的最大时间戳和当前系统的时间戳的差值
意味着,如果服务器上次的消息是一周前写入的,旧的segment就不在写入了,而是创建新的segment。
log.roll.hours=168 (默认是一周)
还可以配置更加精细的时机单位,如果配置了毫秒级别的日志切分间隔,会优先使用这个单位,否则用小时的
log.roll.ms
- offset索引文件或timestamp索引文件达到了一定大小。
意味着,索引文件写满了,数据文件也要跟着拆分,因为这俩是配套的
log.index.size.max.bytes=10485769 (默认10M)
.index偏移量(offset)索引文件
.timeindex 时间戳(timestamp)索引文件
这两个文件在索引里说明
索引
偏移量索引文件记录的是offset和消息的物理地址(在log文件中的位置)的映射关系。时间戳索引文件记录的是时间戳和offset的关系。
内容是二进制文件,不能以纯文本形式查看,可以使用kafka-dump-log.sh查看
# 查看最早10条offset索引
./kafka-dump-log.sh --files ./my-topic-1/000.index|head -n 10
kafka的索引并不是每一条消息都会建立索引,而且一种稀疏索引 sparse index(DB2和MongDB中都有)
问题是,稀疏索引有多稀疏呢?实际上是用消息的大小来控制的,默认为4KB
log.index.interval.bytes=4096
只要写入的消息超过4KB,偏移量索引文件.index和时间戳索引文件.timeindex就会增加一条索引记录。
这个值设置的越小,索引越密集。相反索引越稀疏。
相对来说,越稠密的索引检索数据越快,但是会消耗更多的存储空间;越稀疏的索引占用的存储空间小,但是插入和删除时需要的维护开销也小。
kafka索引的时间复杂度为O(log2n)+O(m),n是索引文件里索引的个数,m为稀疏程度
时间戳索引
客户端封装的ProducerRecord和ConsumerRecord都有一个long类型的timestamp属性
为什么要记录时间戳?
- 如果要基于时间切分日志文件,必须要记录时间戳
- 如果要基于时间清理消息,必须要记录时间戳
既然已经记录了时间戳,干脆就设计一个时间戳索引,可以根据时间戳查询。时间戳有两种,一直是消息创建时的时间戳,一种是消息在broker追加写入时的时间戳。到底用哪个呢,默认用创建时间
log.message.timestamp.type=CreateTime #日志追加的时间 LogAppendTime
# 查看最早10条offset索引
./kafka-dump-log.sh --files ./my-topic-1/000.timeindex|head -n 10
kafka如何基于索引快速检索消息?比如检索偏移量为100006的消息
- 消费的时候能够确定分区的,所以第一步是找到在哪个segment中,segment文件是用base offset命名的,所以可以用二分法很快确定(找到名字不小于100006的segment)
- 这个segment有对应的索引文件,他们是成套出现的,所以现在要在索引文件中根据offset找position
- 得到position后,到对应的log文件开始查找offset,和消息的offset进行比较,直到找到消息
消息保留机制
开关策略
log.cleaner.enable=true #默认开启
kafka提供了两种清理消息策略
log.cleanup.policy=delete #默认直接删除,还有一种是对日志压缩 compact
删除策略
#定时任务,默认每五分钟执行一次
log.retention.check.interval.ms=300000
# 删除是从老的数据开始删的
#时间超过n小时才会删除
log.retention.hours #默认一周 168小时
#时间超过n分钟才会删除,优先级比小时高
log.retention.minutes #默认为空
#时间超过n毫秒才会删除,优先级比分钟高
log.retention.m #默认为空
# 根据文件大小删除,在有时候数据量大,有时候数据量小的情况下适合
log.retention.bytes=-1 #默认为-1,表示不限制大小,指的是所有文件的大小
log.segment.bytes=1073741824 #默认为1G,对单个segment设置大小
压缩策略
如果同一个key多次写入,是存储多次写入还是更新原来的值呢?
事实上,是多次写入的,也是为了顺序写数据
压缩就是将同一个key合并为最后一个value
Log ComPaction执行过的偏移量不再是连续的,不过不影响日志的查询
高可用架构
Controller选举
在新版kafka的副本选举leader机制中,整个操作交由kafka集群中的一个broker统一指挥,这个broker的角色叫做controller,和redis的sentinel架构很相似。
controller选举机制:
所有的broker尝试在zookeeper上创建临时节点/controller,只有一个能创建成功(先到先得),如果controller宕机或者网络出现问题,zk上的临时节点就会消失,其他broker通过watch监听到controller下线的消息后,开始竞选新的controller,先到先得。
一个broker成为controller之后,具有以下的职责:
- 监听broker变化
- 监听topic变化
- 监听partition变化
- 获取和管理broker、topic、partition的信息
- 管理partition的主从
分区副本leader选举
- AR(Assigned-Replicas):一个分区的所有副本
- ISR(In-Sync-Replicas):一个分区正常同步数据的副本
- OSR(Out-Sync-Replicas):一个分区没有正常同步数据的副本
正常参与选举leader的副本只能是ISR中的,如果ISR中没有可用的副本,而想从OSR中选举的话,可用修改配置,但一般不建议这么干,会丢失数据
unclean.leader.election.enablt=false # 默认为false,改成true生效
kafka没有使用ZAB、Raft这些算法(先到先得、少数服从多数)实现副本选举leader,而是用了自己实现的算法,比较相近的算法实现是微软的PacificA算法。
实现机制
默认让ISR中的第一个副本成为leader,比如ISR中有1、5、9,那么让1成为leader
主从同步
- LOE(Log End Offset):下一条等待写入的消息的offset
- HW(High Watermark):ISR中最小的LOE,leader副本会管理所有ISR中最小的LOE作为HW,consumer只能消费HW之前的消息
从节点如果跟主节点保持同步:
- follower节点会向leader节点发送fetch请求,leader向follower发送数据后,需要更新follower的LOE
- follower接收到数据后,依次写入小时并更新LOE
- leader更新HW
为什么这样设计?
- 如果同步成功之前被消费了,消费者组的offset会偏大
- 如果leader挂掉,中间会丢失数据
replica故障处理
- follower故障,首先剔除ISR,follower恢复之后,根据以前记录的HW,把高于HW的消息截掉,然后从leader同步数据
- leader挂掉,首先选举一个leader,其他副本把高于HW的消息截掉,然后从新的leader同步数据
这种方式只能保证副本之间数据的一致性(可被消费),不能保证不丢失或不重复
消费者原理
offset维护
消费者组和topic中的partition的offset关系保存在一个特殊的topic中,叫做__consumer_offsets,默认50个分区,每个分区1个副本。
这样就可以继续消费了,但是如果找不到offset呢?有个配置
#默认是lastest 表示从最新(最后发送)的数据开始消费,历史数据不能消费
#earliest 表示从最早(最先发送)的数据开始消费,历史数据可以消费
#none 如果consumer group在服务端找不到offset,直接报错
auto.offset.reset
消费者可以自动或者手动提交offset
enable.auto.commit # 默认为true 自动提交
auto.commit.interval.ms #自动提交的频率, 默认5秒
如果需要消费完消息并处理完业务逻辑后提交offset,需要enable.auto.commit =false,consumer提交offset的两种方式
- consumer.commitSync()
- consumer.commitAsync()
如果不提交或者提交失败,broker的offset不会更新,消费者组下次消费的时候还会消费同样的消息
消费者消费策略
- RangeAssignor 默认策略,按照范围分配
- RoundRobinAssignor 轮训
- StickyAssignor 粘滞,每次的结果可能不一样,分区分配尽可能均衡,尽可能和上次一样
如果想指定消费某个分区,可以使用consumer.assign,因为consumer.subcribe是自动分配消费者组的分区
rebalance 分区重分配
实现机制
- 找到一个话事人,起到一个监督和公平的作用。每个broker都有一个管理消费者组、offset的实例(GroupCoordinator),第一步就是从所有的GroupCoordinator中找一个话事人
- 第二步,清点人数,所有的消费者连接到GroupCoordinator报数,这个叫join group请求
- 第三步,GroupCoordinator从所有的消费者里选一个leader,这个leader会根据消费者的情况和设置的策略设计一个方案,leader把方案上报给GroupCoordinator,GroupCoordinator通知给所有消费者
kafka为什么这么快
- 顺序I/O,kafka的消息是顺序追加到本地磁盘中的,不需要重复寻址。在一定条件下,磁盘的顺序读写速度要比内存的随机读写要更快
- 索引机制,偏移量索引文件和时间戳索引文件
- 批量读写,所有的消息都变成一个批量的文件(segments)
- 文件(日志)压缩,合理的批量压缩,减少网络IO消耗和磁盘空间
- 零拷贝
kafka消息持久化配置
生产者
- 生产者使用producer.send(msg,callback)方法
- 设置acks=all
- 设置retries为一个比较大的数
Broker
- 设置unclean.leader.election.enable=false
- 设置min.insync.replicas > 1
- 设置replication.factor = min.insync.replicas + 1
消费者
- 确保消息消费完成再提交offset enable.auto.commit = false