基本概念
Kafka主要扮演三大角色:
- 消息系统:Kafka和传统的消息中间件一样,具备解耦、冗余存储、流量削峰、缓冲、异步通信、拓展性、可恢复性等功能。Kafka还提供了独特的消息顺序性回消息回溯功能。
- 存储系统:Kafka把消息持久化到磁盘,相对其他基于内存的系统而言,丢失数据的风险更低。
- 流式处理平台:Kafka提供了一个完整的流失处理类库,比如窗口、连接、变换和聚合等各类操作。
结构
一个典型的Kafka体系结构包括Producer、Broker、Consumer和Zookeeper集群。
Producer:生产者,也就是发送消息的一方。生产者负责创建消息 然后将其投递到Kafka中。
Consumer:消费者,也就是消息接收方。消费者连接到Kafka上并接收消息,进而进行相应的业务逻辑处理。
Broker:服务代理节点,接收Producer发送的消息,Consumer来Broker拉取消息。
Zookeeper:负责集群元数据的管理、控制器的选举操作。
主题&分区
Kafka中与消息相关的最重要的两个概念:主题(Topic)和分区(Partition)。Kafka中的消息以主题为单位进行归类,生产者负责将消息发送到特定的主题,消费者负责订阅主题进行消费。
一个主题可以有多个分区,一个分区只属于单个主题。分区在存储层面,可以把分区看成一个可追加的日志(Log)文件,消息在被追加到分区的时候,会被分配一个偏移量(offset)。offset是消息在分区中的位移标识,Kafka利用offset实现分区消息消费的顺序性。
Kafka为分区引入了副本(Relica)机制,通过增加副本数量提高容灾能力。副本之间是“一主多从”的关系,Leader副本负责处理读写请求,follower副本只负责与leader副本的消息同步。当leader副本所在Broker故障时,会从follower副本中重新选举新的leader副本来处理请求。Kafka自身会尽量把副本均匀分散到各个Broker上,以提高容错性。
分区中的所有副本统称为AR(Assined Relicas)。所有与leader服务保持一定程度同步的副本(包含leader副本)组成ISR(In-Sync Relicas)。与leader副本同步滞后过多的副本组成OSR(Out-sync Relicas)。AR=ISR+OSR。当leader副本发生故障的时候,默认只有ISR里的副本才有资格被选为leader副本。
leader副本负责维护和跟踪ISR集合中所有follower副本的滞后状态,当follower副本落后太多或者失效,就会被从ISR移入到OSR中;如果OSR种的副本追上了leader服务,就会被重新添加会ISR集合。
几个比较重要的概念:
HW(High Watermark)高水位线,标识了一个特定的而消息偏移量,消费者只能拉取到这个offset之前的偏移量。
LSO(Log Start Offset)日志的起始偏移量,标识所属日志文件中,第一条消息的ofsset。
LEO(Log End Offset)日志结束偏移量,标识所属日志文件中,下一条待写入的消息的offset。整个ISR中最小的LEO就是整个分区的HW。
Producer
示例代码
原理
ProducerRecord
使用KafkaProducer发送消息,需要把消息封装成ProducerRecord:
ProducerRecord
public class ProducerRecord<K, V> {
private final String topic; // 主题
private final Integer partition // 分区号
private nal Headers headers; // 消息头部
private final K key; // 键
private final V value; // 值
private final Long timestamp ; // 消息的时间戳
......
}
topic字段代表消息要发送的主题。
partition字段代表消息要发往的分区。
headers字段是消息头部,可以用来设定一些与应用相关的信息。
key字段可以用来计算分区号,让相同key的消息进入同一个分区——以此来支持顺序消费场景;有key的消息还支持日志压缩的功能。
value字段就是真正的消息内容了,一般不为空,但不是绝对,例如“墓碑消息”。
timestamp字段是指消息的时间戳,它有CreateTime——消息创建的时间和LogAppendTime——消息追加到日志的时间两种类型。
KafkaProducer
使用KafkaProducer发送消息,KafkaProducer是线程安全的,可以放心复用。整体的架构图如下:
主线程
在主线程中,由KafkaProducer创建消息,然后通过拦截器、序列化器、分区器的作用后缓存到消息累加器中。
消息累加器RecordAccumulator
用来缓存数据,以便Sender线程可以批量发送,进而减少网络传输的资源消耗以提升性能;批量发,消息进行压缩的话,压缩比会比较高,也能减少传输的数据量。
消息累加器在内部为每个分区维护了一个双端队列(Deque),主线程发过来的任务会被追加到消息累加器中对应分区的那个双端队列,Sender线程会从双端队列的头部读取数据。
双端队列中保存的元素是ProducerBatch,而不是ProducerRecord,即Deque<ProducerBatch>。
ProducerBatch是指一个消息批次,主线程发过来的零散的ProducerRecord会被拼凑成一个较大的ProducerBatch,即发送ProducerRecord的时候,会查看对应分区的最后一个ProducerBatch还能不能加入数据(大小是否会超batch.size),能就加入到该ProducerBatch,不能就新建一个。
Sender线程
负责从消息累加器中获取消息并将其发送到Broker。
Sender线程从消息累加器拿到缓存的消息,形式是<分区, Deque<ProducerBatch>>,需要进一步转换成<Node, List<ProducerBatch>>,也就是说,KafkaProducer需要知道哪个分区在哪个broker节点上。
Sender线程在把消息发送出去之前,会将请求保存到InFlightRequests中,主要形式为Map<NodeId, Deque<Request>>,缓存了发出去了,但没有收到响应的消息。
配置max.in.flight.requests.per.connection(每个连接最多缓存的请求数,即未收到响应的请求数),默认为5。为什么要有这个限制?
未响应的消息多了,要么是broker节点处理不过来,要么网络出问题,不管是哪一种情况,都不应该继续再发送消息。例如broker节点处理不过来,那继续发,会加大broker的压力,请求也会超时。
元数据
元数据是指 Kafka集群的元数据,这些元数据具体记录了集群中有哪些主题,这些主题有哪些分区,每个分区的leader副本分配在哪个节点上, follower 副本分配在哪些节点上,哪些副本在 AR、ISR等集合中,集群中有哪些节点,控制器节点又是哪一个等信息。
例如爱Sender线程中,需要将是<分区, Deque<ProducerBatch>>转换成<Node, List<ProducerBatch>>,就需要各个分区的leader副本在哪个节点上。
当客户端没有需要用到的元数据——例如不知道分区A的leader在哪个节点上,或者距离上次更新元数据已经经过了metadata.max.agent.ms(默认300000,即5分钟)时,就会进行元数据的更新操作。Sender线程会选择负载最低的节点(leastLoadedNode)来发送一个MetadataRequest请求来获取最新的元数据,请求成功后更新缓存的数据,主线程就可以读取到了。
怎么知道哪个节点是负载最低的?
Sender线程中的InFlightRequests里缓存了未被响应的请求,哪个节点的缓存请求数量最少,就是负载最低的节点。
重要参数
必填参数
bootstrap.servers
该参数用来指定生产者客户端连接 Kafka 集群所需的 broker地址清单,具体的内容格式为 hostl:portl,hos t2:port2 ,可以设置 个或多个地址,中间以逗号隔开,此参数的默认值为“” 注意这里并非需要所有的 broker址,因为生产者会从给定的 broker 里查找到其他 broker 的信息 。不过建议至少要设置两个以上的 broker 地址信息,当其中任意 个岩机时,生产者仍然可以连接到 Kafka集群上。
key.serializer & value.serializer
broker端接收的消息必须以字节数组的形式保存,所以在发到broker之前,需要把key和value的内容转换成字节数组。一般我们发的都是文本消息ProducerRecord<String, String>,所以两者都可以配置为org. apache.kafka. common. serialization. StringSerializer。
重要参数
acks
指定分区中必须要有多少个副本收到这条消息,broker才会告诉producer这个消息写入成功。有三种取值:
- acks=1。默认就是1。只要leader副本写入成功,就可以告知producer写入成功。
- acks=0。producer不需要等待broker的响应,当作都成功了,实际发送给broker的时候发生了异常也不理。
- acks=-1或acks=all。消息需要所有在ISR中的副本都写入了,才会告诉producer写入成功。可靠性最高,性能最差。
max.request.size
客户端能发送的单个消息的最大大小,默认为1048576B,即1MB。需要注意,broker端也有类似的限制配置message.max.bytes,客户端的max.request.size必须比broker端的messages.max.bytes小。
retries和retry.backoff.ms
retries用来配置producer的重试次数,默认为0不重试。retry.backoff.ms则是用来配置每次重试的时间间隔,默认100。注意,如果max.in.flight.requests.per.connection的值大于1,由配置了重试,就有可能出现写入乱序!!!
compression.type
指定消息的压缩类似,默认是none,不压缩。压缩是用时间换空间的方式,对实时性要求高的场景,不推荐对消息进行压缩。
connections.max.idle.ms
数用来指定在多久之后关闭限制的连接,默认值是 540000ms ,即9分钟。
linger.ms
用来指定ProducerBatch等待更多ProducerRecord加入的等待时间,默认为0,不等待。增加这个值,会加大消息的延迟,但是能提高吞吐量,因为减少了网络的往返次数。
request.timeout.ms
指定Producer的超时时间,默认30000ms。如果配置了失败重试,则在超时之后会开始进行重试。
receive.buffer.bytes和send.buffer.bytes
Socket的接收和发送缓冲区的大小,接收缓冲区默认32K,发送缓冲区默认128K。如果设置为-1,则使用系统默认值。
幂等
当KafkaProducer配置enable.dempotence=true,就会开启幂等发送,如:
properties.put("enable.dempotence", "true");
配置enable.dempotence=true,会强制retries>0,acks=all,max.in.flight.requests.per.connection<=5。
为了实现幂等,Kafka引入了producer id和序列号两个概念。producer id标识KafkaProducer的身份,序列号标识消息的顺序。
broker端会为每一对<PID,分区>维护一个序列号,如果接收到的消息的序列号SEQ_NEW与已经接收到的最后的序列号SEQ_OLD之间的关系不是SEQ_NEW=SEQ_OLD+1,就会拒绝消息。
如果SEQ_NEW>SEQ_OLD+1,说明有消息被跳过了;如果SEQ_NEW<SEQ_OLD+1,说明消息重复了。
事务
事务可以保证跨多个分区的动作的原子性,要么都成功,要么都失败。事务是基于幂等实现的。
太复杂了。。。。感觉也用不大上。
Consumer
消费者&消费组
消费者(consumer)负责订阅Kafka中的主题(Topic),并且从订阅的主题拉取消息。Kafka中的每个消费者都有一个对应的消费者分组(Consumer Group),主题中的一个分区只会分配给消费组里的一个消费者,当消息发布到主题的某个分区后,只会被投递到每个消费组里绑定这个分区的消费者。
例如服务A和服务B,都订阅了同一个主题T,每一个消费,服务A和服务B都需要收到,所以服务A和服务B需要采用不同的消费分组。服务A有4个实例,消息只会投递到这4个实例中的1个。服务B有2个实例,消息只会投递到这2个中的1个。
需要注意,同个消费组中,增加消费者并不一定能提高消费速度!!!在Kafka中,每一个分区的消息只会投递给一个消费者,当消费者数量比分区数量多的时候,再增加消费者,不会提高消费速度。所以创建主题的时候,需要考虑清楚分区数!!!
通过消费者和消费组的模式,可以组成Kafka的两种信息投递模式:
- 点对点:所有消费者都属于同一个消费组。
- 发布/订阅,所有消费者的消费组都不相同。
KafkaConsumer
一个正常的消费逻辑步骤如下:
- 配置消费者客户端参数及创建相应的消费者实例。
- 订阅主题。
- 拉取消息并消费。
- 提交消费位移。
- 关闭消费者实例。
消费代码demo
public class KafkaConsumerAnalysis {
public static final String brokerList = "localhost.9092";
public static final String topic = "topic-demo";
public static final String groupid = "group.demo";
public static final AtomicBoolean isRunning = new AtomicBoolean(true);
public static Properties initConfig() {
Properties props= new Properties();
props.put ("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put ("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put ("bootstrap.servers", brokerList).
props.put ("group.id", groupid);
props.put ("client.id","consumer.client.id.demo");
return props;
}
public static void main(String [] args) {
Properties props =initConfig();
KafkaConsumer<String, String> consumer= new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
try {
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record : records) {
// do something to process record.
}
// 同步提交消费位移
consumer.commitSync();
}
} catch (Exception e) {
// do something fot exception.
} finally {
consumer.close();
}
}
}
必要参数
bootstrap.servers
与KafkaProducer的bootstrap.servers配置作用一样
key.deserializer& value.deserializer
KafkaProducer中配置了key.serializer和value.serializer将key和value序列化,消费者接收到消息后,就需要将key和value反序列化
group.id
消费者隶属的消费组的名称,默认为“”。如果为空,会报异常。通过group.id可以控制kafka的工作模式:点对点 VS 发布/订阅。
订阅主题与分区
可以通过KafkaConsumer.subscribe方法来订阅主题。
public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener)
public void subscr be(Collection<String> topics)
public void subscribe (Pattern pattern, ConsumerRebalanceListener listener)
public void subscribe (Pattern pattern)
重复调用KafkaConsumer.subscribe方法,后面的会覆盖前面的;可以用正则表达式来匹配主题,例如:
consumer. subscribe (Pattern.compile(” topic-. * ” ));
所有“topic-”开头的主题都会被订阅。
除了能订阅主题,还支持直接订阅分区,KafkaConsumer.assign(Collections<TopicPartition> partitions)。
public final class TopicPartition implements Serialize {
// 分区编号
private final int partition;
// 主题
pribate final String topic;
......
}
KafkaConsumer.assign方法的分区信息,可以通过public List<PartitionInfo> partitionsFor(String topic)获取到主题的分区信息。
消息消费
消息的消费模式一般有两种模式:
- 推模式,服务器主动将消息推送给消费者。可能会出现消费者处理不过来,但是服务器一直推,压垮消费者的情况。
- 拉模式,消费者主动向服务器端发起请求来拉取消息。消费者定时轮询,太频繁,大部分是无效请求,间隔太长,时效性不好,不好控轮询频率。
采用推模式的消息中间件,例如RabbitMQ。通过prefetch count的配置,每次给消费者分配该数量的消息,如果这些消费没被ack,就不会再给消费者分配消息。解决了推模式的缺点。
采用拉模式的消息中间件,例如RocketMQ和Kafka。通过长轮询的方式,解决轮询频率的问题。
消费者通过调用KafkaConsumer.poll方法从服务器拉去消息:public ConsumerRecords<K, V> poll(final Duration timeout)
方法中的timout就是长轮询的超时时间,例如设置为2s,然后服务器一直没有消息产生,消费者就会等待2s,然后重新发起一个新的poll请求。
偏移量提交
每次调用poll方法的时候,服务器返回的是消费分组未消费过的消息集合。要做到这一点,就需要记录上一次消费时的偏移量。
在旧的消费者客户端中,消费偏移量是保存在Zookeeper;新版本的消费者客户端中,消费偏移量是存在Kafka内部的主题_consumer_offsets中。
_consumer_offsets主题默认有50个分区,每个分区3个副本。通过消费组id的hash值和分区数的取模,决定消费组的已消费偏移量存放在哪个分区。
消费者提交的偏移量,是x+1—“下一次拉取的消息的位置”。
KafkaConsumer.commitSync
同步提交偏移量,会阻塞到偏移量提交成功。
KafkaConsumer.commitAsync
异步提交偏移量,当提交失败的,需要重试的话,需要解决已经提交更大偏移量的情况。
KafkaConsumer.seek
改方法可以指定偏移量来消费。这就是大部分消息中间件无法提供的“回溯”。
消息丢失
拉取到消费后,先提交偏移量,再处理消息。如果处理消息的过程中出错,下一次拉取的时候,会跳过那些消费失败的消息。
消息重复
拉取到消息后,先处理消息,消息处理成功后,再提交偏移量。如果提交偏移量的时候失败,例如服务重启或者新增消费者触发再均衡,则可能会继续拉取到这一批消息,重复处理。
自动提交
在KafkaConsumer中,默认的位移提交方式是自动提交,由客户端参数enbale.auto.commit配置,默认为true。
如果enable.auto.commit为true,就会按照auto.commit.interval.ms(默认5000)的频率来提交偏移量,自动提交的动作是在poll方法中完成的。
这种方式会造成消息重复消费。
找不到消费组位移
消费组刚建立的时候,无法在_consumer_offsets主题中找到已经提交的偏移量,这时候应该从哪里消费?
会根据auto.offset.reset的配置类决定从哪里开始消费,如果值是“lastest”,则会从最新的消息开始消费;如果值是“earliest”,则从最早的消息开始消费;如果值是“none”,则报错。
broker
日志存储
主题、分区和副本之间的关系:
其中Log是一个命令格式为<topic>-<partition>的文件夹。例如:
向主题的分区写入消息,是顺序写入的,所以之后Log目录下的最后一个LogSegment能执行写入操作。
为了检索方便,每个LogSegment文件都会有对应的“偏移量索引文件”和“时间戳索引文件”。
每个LogSegment都有一个基准偏移量baseOffset,即LogSegment里第一条消息的偏移量。
偏移量是一个64位的长整型,日志文件和两个索引文件的文件名都是根据基准偏移量来命名的,名字固定20位数字,没有达到的位数用0填充。例如:
只有00000000000000000251.log文件可以被写入数据,00000000000000000251.index是偏移量索引文件,00000000000000000251.timeindex是时间戳索引文件,这个LogSegment的基准偏移量是251。
一个分区是由多个段文件组件的,那什么情况下,会切换段文件呢?
只要满足一下任意一个条件,就会切换段文件:
- 段文件大小超过broker配置logs.segment.bytes的值,默认1G。
- 段文件中消息的最早时间戳与系统当前时间戳相差超过log.roll.ms或log.roll.hours的值。log.roll.hours的优先级高于log.roll.ms,默认值为168.
- 段的两个索引文件有任意一个达到log.index.size.max.bytes配置的值,默认10MB。
- 追加的消息的偏移量与当前日志分段的偏移量之前相差值大于Integer.MAX_VALUE。
日志索引
Kafka的索引文件都是以稀疏索引的方式构建消息的索引,不会将所有的消息都在索引文件中。默认情况下(log.index.interval.bytes配置),每写入4KB数据,Kafka才会往索引文件中写入一个索引项。索引文件会用MappedByteBuffer映射到内存中,提高查询索引的速度。
偏移量索引文件
- relativeOffset:相对偏移量,相对于baseffset的偏移量,即offset-baseoffset,占4字节。这就是追加的消息偏移量与段文件基础偏移量相差超过Integer.MAX_VALUE就需要切换的原因!!!4字节就是个Integer。
- position:物理地址,消息在日志段文件中的物理位置,占4字节。
建立了消息偏移量到物理地址之间的映射关系,方便快速定位消息所在物理文件位置。
偏移量索引文件中的消息偏移量是单调递增的,查询指定偏移量的时候,可以利用二分查找法来定位目标偏移量的位置得到消息物理地址,如果偏移量不存在(因为索引是稀疏的),则会返回小于目标偏移量的最大偏移量的物理地址。再到LogSegment文件找到指定偏移量的消息体。
时间戳索引文件
- timestamp:当前日志分段最大的时间戳,占8字节。
- relativeOffset:相对偏移量,占4字节。
timestamp有两种类型,LogAppendTime日志追加时间和CreateTime创建时间,log.message.timestamp.type配置。
可以根据指定的时间戳,找到消息对应的偏移量,然后再到偏移量索引文件找到消息的物理地址。
时间戳索引的道理相同,只是比较的是时间戳,通过时间戳得到偏移量后,再用偏移量到“偏移量索引文件”得到消息物理地址。
日志清理
Kafka提供了两种清理策略:
- 日志删除:按照一定的保留策略删除不符合的日志分段。
- 日志压缩:针对每个消息的key进行整合,有相同key的不同value值,只保留最后一个版本。
通过配置log.cleanup.policy来设置日志清理策略,配置delete则删除,配置compact则压缩,配置delete,compact则同时支持两种策略。
日志删除
- 基于时间:判断日志段文件中消息的最大时间戳是否超过设定的阈值,超过则删除。相关配置有log.retention.ms->log.retention.minutes->log.retention.hours,默认只配置log.retention.hours=168。
- 基于大小:判断当前所有日志的总大小是否超过设定的阈值,超过则需要删除数据,优先删除旧的段文件。相关配置有log.retention.bytes,默认为-1,不限制。
- 基于偏移量:一般情况下,一个日志的LSO跟这个日志的第一个段文件的baseOffset是相同的。但是当遇到删除记录的指令,这两者可能就会一样。一个段文件,如果它的下一个段文件的baseOffset都小于日志的LSO,那它就是可以被删除的。
删除只是将文件加上“.deleted”后缀,真正是删除是交给一个名为“delete-file”的延迟任务来删除。
磁盘存储
kafka利用磁盘作为存储介质,利用磁盘的顺序读写,提高读写速度。磁盘的顺序读写速度与内存的随机读写速度相当:
系统页缓存,会把磁盘的数据放到内存中,减少磁盘的I/O操作。
利用零拷贝技术,减少了数据复制次数和内核/用户模式切换次数。
Kafka的可靠性