Kafka原理

生产者

消息发送流程

在这里插入图片描述

跟踪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,有四种情况:

  1. 指定了partition
public ProducerRecord(String topic, Integer partition, K key, V value) {
        this(topic, partition, null, key, value, null);
}
  1. 没有指定partition,自定义了分区器
    在这里插入图片描述
// 添加分区器
properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, ProducerDemoPartitioner.class.getName());
  1. 没有指定partition,没有定义分区器,但key不为空
// 默认使用的是默认分区器DefaultPartitioner
// 将key的hash值与topic的partition数取余得到partition的值
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
  1. 没有指定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给生产者有两种思路:

  1. 需要半数以上的follower节点完成同步(这样客户端等待的时间就短一些,延迟低)
  2. 需要所有的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函数决定的

规则如下:

  1. firt of all,副本因子不能大于broker个数
  2. 第一个分区(编号为0的分区)的第一个副本的存放位置是随机从brokerList里选择的
  3. 其他分区的第一个副本的存放位置相对于第一个分区依次往后移
  4. 每个分区的剩余副本的存放位置,相较于第一个副本是由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切分的时机有两个:

  1. 根据日志文件大小,当一个segment写满后,会创建一个新的segment,用最新的offset作为名称。
log.segment.bytes=1073741824  (默认1G)
  1. 根据消息的最大时间戳和当前系统的时间戳的差值
    意味着,如果服务器上次的消息是一周前写入的,旧的segment就不在写入了,而是创建新的segment。
log.roll.hours=168 (默认是一周)

还可以配置更加精细的时机单位,如果配置了毫秒级别的日志切分间隔,会优先使用这个单位,否则用小时的

log.roll.ms
  1. 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属性
为什么要记录时间戳?

  1. 如果要基于时间切分日志文件,必须要记录时间戳
  2. 如果要基于时间清理消息,必须要记录时间戳

既然已经记录了时间戳,干脆就设计一个时间戳索引,可以根据时间戳查询。时间戳有两种,一直是消息创建时的时间戳,一种是消息在broker追加写入时的时间戳。到底用哪个呢,默认用创建时间

log.message.timestamp.type=CreateTime   #日志追加的时间  LogAppendTime
# 查看最早10条offset索引
./kafka-dump-log.sh --files ./my-topic-1/000.timeindex|head -n 10

kafka如何基于索引快速检索消息?比如检索偏移量为100006的消息

  1. 消费的时候能够确定分区的,所以第一步是找到在哪个segment中,segment文件是用base offset命名的,所以可以用二分法很快确定(找到名字不小于100006的segment)
  2. 这个segment有对应的索引文件,他们是成套出现的,所以现在要在索引文件中根据offset找position
  3. 得到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之前的消息

从节点如果跟主节点保持同步:

  1. follower节点会向leader节点发送fetch请求,leader向follower发送数据后,需要更新follower的LOE
  2. follower接收到数据后,依次写入小时并更新LOE
  3. 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 分区重分配

实现机制

  1. 找到一个话事人,起到一个监督和公平的作用。每个broker都有一个管理消费者组、offset的实例(GroupCoordinator),第一步就是从所有的GroupCoordinator中找一个话事人
  2. 第二步,清点人数,所有的消费者连接到GroupCoordinator报数,这个叫join group请求
  3. 第三步,GroupCoordinator从所有的消费者里选一个leader,这个leader会根据消费者的情况和设置的策略设计一个方案,leader把方案上报给GroupCoordinator,GroupCoordinator通知给所有消费者

kafka为什么这么快

  1. 顺序I/O,kafka的消息是顺序追加到本地磁盘中的,不需要重复寻址。在一定条件下,磁盘的顺序读写速度要比内存的随机读写要更快
  2. 索引机制,偏移量索引文件和时间戳索引文件
  3. 批量读写,所有的消息都变成一个批量的文件(segments)
  4. 文件(日志)压缩,合理的批量压缩,减少网络IO消耗和磁盘空间
  5. 零拷贝

kafka消息持久化配置

生产者

  1. 生产者使用producer.send(msg,callback)方法
  2. 设置acks=all
  3. 设置retries为一个比较大的数

Broker

  1. 设置unclean.leader.election.enable=false
  2. 设置min.insync.replicas > 1
  3. 设置replication.factor = min.insync.replicas + 1

消费者

  1. 确保消息消费完成再提交offset enable.auto.commit = false

参考链接

springboot集成kafka操作详解

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值