主要内容摘要
- 生产者
- 消费者
- 再均衡操作
- 副本和日志存储
- 延迟操作
- 控制器
- 学习目标:
掌握下面这些概念
1、给一份学习使用的docker-compose搭建环境,供学习使用。
2、了解Kafka的一些核心概念
3、熟悉客户端组件及常用的使用方式及原理
4、协调者处理消费者再均衡
5、副本与日志存储
5、控制器
Kafka docker-compose
version: '2'
services:
zookeeper:
image: wurstmeister/zookeeper
ports:
- "2181:2181"
volumes:
- "./zookeeper/data:/data"
- "./zookeeper/datalog:/datalog"
kafka:
image: wurstmeister/kafka
ports:
- "9092" # kafka 把9092端口随机映射到主机的端口
environment:
KAFKA_ADVERTISED_HOST_NAME: 10.2.46.144 #本机ip
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_CREATE_TOPICS: test:1:1
KAFKA_DELETE_TOPIC_ENABLE: "true"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./kafka/logs:/kafka
kafka-manager:
image: sheepkiller/kafka-manager
links:
- zookeeper
environment:
ZK_HOSTS: zookeeper:2181
APPLICATION_SECRET: letmein
KM_ARGS: -Djava.net.preferIPv4Stack=true
ports:
- "9000:9000"
kafka的角色
消息系统:
作为消息中间件都具备系统解耦、冗余存储、流量削峰、缓冲、异步通信、扩展性、可恢复性等功能。同时具有消息顺序性和回溯消费的功能。
存储系统
可以把消息持久化到磁盘,并且降低消息丢失的风险,需要设置数据保留策略为“永久”,或启用主题的日志压缩功能。
流式处理平台
有一个完整的流式处理的类库
重要的概念
主题
Kafka中的消息是以主题为单位归类的,逻辑上生产者往主题推送消息,消费者从主题拉取消息。在物理的存储上,消息是存在具体的分区的。
分区
一个主题可以分为多个分区,一个分区只能属于一个主题。同一个主题的不同分区存储的消息是不同的,分区在存储层可以看做是一个可追加的日志文件,消息在被加入到分区的时候会分配一个特定的偏移量(offset)。
offset是消息在分区的唯一标识,Kafka通过它来保证消息在分区内的顺序性,不过offset并不跨越分区,也就是说,Kafka并不保证主题有序,只能保证分区有序。
- 那如何保证分区内的消息是有序的呢?
设置多个分区
如果主题只有一个分区,也就只有一个文件,那这台机器的I/O将会成为这个主题的性能瓶颈,Kafka的分区可以分布在不同的服务器上,设置多个分区可以进行水平的扩展,从而解决这个问题。分区的数量可以在创建主题的时候指定,也可以在后期动态的修改。
- 如何动态的修改某个主题的分区数量呢?
分区多副本
分区存储消息,那如果分区所在的服务器崩了,那消息不就都不能使用了吗?因此Kafka引入了多副本的概念,我们可以为某个分区设置多个副本来提升容灾能力。副本之间是“一主多从”的关系,leader副本负责处理读写请求,follower副本只负责与leader同步。当leader副本出现故障时,从follower副本中重新选举一个处理作为leader副本,从而实现了故障转移。
AR相关概念
消息会先发送到leader副本,之后follower副本会从leader副本拉取消息进行同步,同步会有快慢问题。分区所有的副本统称为AR(Assigned Replicas),所有与leader副本保持一定程度同步的副本(包括leader副本)称为ISR(In-Sync Replica)。这里的“一定程度”就是滞后的范围,可以通过参数配置。与leader副本同步滞后过多的副本称为OSR(Out-of-Sync Replicas),故AR = ISR + OSR。
leader副本维护和跟踪ISR集合中所有follower副本的滞后状态,当滞后太多会被剔除,当“追上”后再加入到ISR中。
LEO & HW
HW(High Watermark):标识一个特定的消息偏移量offset,消费者只能消费到这个offset之前的消息。
图 1-1 分区中各种偏移量的说明
图1-1代表一个日志文件,文件中有9条消息,第一条消息的offset为0,最后一条消息的offset为8;该日志文件的HW为6.
LEO(Log End Offset):标识当前日志文件中下一条待写入消息的offset,如上图中的消息9的位置。
ISR集合中的每个副本都会维护自身的LEO,而集合中最小的LEO即为分区的HW。
- 分区的HW是什么?
由此可见,Kafka的复制机制既不是完全的同步复制,也不是单纯的异步复制。使用这种ISR的方式有效的权衡了数据可靠性和性能之间的关系。
生产者
生产者相对比较简单
目标:保证消息已经发到服务端并且不丢失(acks)
一般配置例子
spring:
kafka:
bootstrap-servers: xx:9092,xx:9092,xx:9092
producer:
acks: all
retries: 3
batch-size: 50
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
if (exception == null) {
//deal?
System.out.println(metadata.partition() + ":" + metadata.offset());
}else{
exception.printStackTrace();
}
}
});
分区器
指定消息发送到各个分区。
如果指定了key,会对可以进行HASH算法,来得到分区号;如果不指定key,会以轮询的方式发送到各个可用的分区。
生产者内部原理
消费者
消费者组
Kafka中存在一个消费者组的概念,每个消费者都属于一个消费者组。当消息发布到主题之后,只会被订阅它的消费者组中的其中一个消费者消费。每个分区只能被一个消费者组中的一个消费者所消费。
这里就有一个分配策略的概念:
对一个消费组而言, 一个分区被一个消费者所消费,如果订阅主题有6个分区,一个消费者消费6个分区,两个消费者各消费3个分区,三个消费者个消费两个分区;这样消费者具有横向伸性,可以通过增加或减少消费者数量来提升或降低整体的消费能力。(默认使用的是RangeAssignor分配策略)
1、其他的分配策略?
消息的两种投递模式
- 点对点模式:如果所有消费者都隶属于一个消费者组,那么所有的消息都会被均衡地投递给每一消费者,每条消息都只会被一个消费者消费。
- 发布/订阅模式: 如果每个消费者都属于不同的消费者组,那么所有的消息都会被投递给所有的消费者,每条消息会被所有的消费者消费。
消费者客户端
具体步骤:
1. 配置消费者客户端;
bootstrap.servers: 格式:host:port,默认为"",不必配置全; group.id: 默认值为"",不配置会报错;client.id: 会生成"consumer-"与数字的拼接。
2. 订阅主题;
订阅主题,如果前后订阅了不同的主题,那么以最后一次的为准;
还有一个带正则参数的方法;
第二个参数是再均衡监听器;
还可以直接订阅某些主题的特定分区,通过assign()方法来实现。
可以通过partitionsFor来查询指定主题的元数据信息
List<PartitionInfo> partitionsFor(String topic)
例子:通过assign方法来获取各个分区的position、committed offset、lastConsumedOffset
Properties props = initConfig();
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
List<PartitionInfo> partitionInfos = consumer.partitionsFor(topic);
partitionInfos.stream().forEach(partitionInfo -> {
int partition = partitionInfo.partition();
TopicPartition tp = new TopicPartition(topic, partition);
consumer.assign(Arrays.asList(tp));
long lastConsumedOffset = -1;
while (true) {
ConsumerRecords<String, String> records = consumer.poll(1000);
if (records.isEmpty()) {
break;
}
List<ConsumerRecord<String, String>> partitionRecords
= records.records(tp);
lastConsumedOffset = partitionRecords
.get(partitionRecords.size() - 1).offset();
consumer.commitSync();//同步提交消费位移
}
System.out.println(partition+", comsumed offset is " + lastConsumedOffset);
OffsetAndMetadata offsetAndMetadata = consumer.committed(tp);
System.out.println(partition+", commited offset is " + offsetAndMetadata.offset());
long posititon = consumer.position(tp);
System.out.println(partition+", the offset of the next record is " + posititon);
});
结论:position = committed offset = lastConsumedOffset + 1
3. 拉取消息并消费;
ConsumerRecords提供了records(TopicPartition tp)方法来获取消息集中指定分区的消息。当然也有主题纬度的重载方法;
4. 提交消费位移;
对分区而言,每条消息都有一个唯一的offset,用来表示消息在分区中对应的位置。对于消费者而言,它也有一个offset的概念,表示消费到分区中某个消息所在的位置。
消费位移现在存储在内部主题_consumer_offsets中,消费位移保存的动作成为位移提交。
x表示某一次拉取操作中此分区消息的最大偏移量,假设当前者已经消费了x位置的消息,则消费者的消费位移为x,使用lastConsumedOffset来表示。
但是这里要提交的消费位移并不是x,而是x+1,用position表示,它表示下一条需要拉取的消息的位置。
消费者中还有一个committed offset的概念,表示已经提交过的消费位移。
KafkaConsumer提供了两个方法来获取上面的两个值:
position : long position(TopicPartition partition)
committed offset: OffsetAndMetadata committed(TopicPartition partition)
提交时机:消息重复消费,消息丢失
如果拉取一批消息,来不及提交,出现错误,会出现重复消费的情况;
如果拉取一批消息,提前提交了位移,但是还有消息没有消费,后面的消息就丢失了;
自动提交:默认情况下,消费者每隔5秒会将拉取到分区中最大的消费位移进行提交,可能出现重复消费的情况,但如果编程讲消息暂存到队列里,这可能出现消息丢失的情况。
手动提交有两种方法:
commitSync()可以拉取一批消息做相应的处理,也可以使用一个缓冲区,积累到足够的数量再提交(延迟问题),它提交的频率和拉取批次消息、处理批次消息的频率是一样的。
如果想更细粒度的处理可以使用
commitSync(final Map<TopicPartition, OffsetMetadata> offsets)
,通过Offsets参数可以提交指定分区的位移。
例子:按分区粒度提交位移:
try {
while (running.get()) {
ConsumerRecords<String, String> records = consumer.poll(1000);
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> partitionRecords =
records.records(partition);
for (ConsumerRecord<String, String> record : partitionRecords) {
//do some logical processing.
}
long lastConsumedOffset = partitionRecords
.get(partitionRecords.size() - 1).offset();
consumer.commitSync(Collections.singletonMap(partition,
new OffsetAndMetadata(lastConsumedOffset + 1)));
}
}
} finally {
consumer.close();
}
还有异步提交位移的commitAsync()方法,同样提供了带参数的、分区粒度提交的重载方法;并且异步提交提供了一个回调方法,可以对成功和失败的场景进行处理。 异步提交要考虑前一次提交失败,后面一次提交成功的情况,还有消费者退出或者再均衡的情况,这时应该在最后同步提交一次位移,不然出现无必要的重复消费。
指定消费位移
当消费者找不到消费位移时(位移越界也会),消费者会根据消费者客户端参数auto.offset.reset的配置来决定从何处开始消费,默认值为"lastst",表示从分区的末尾开始消费;如果设置未"earliest",表示会从起始处开始消费;如果配置为"none",找不到消费位移时直接报错。
当然也可以细粒度的从特定的位移开始处开始拉取位移,KafkaConsumer提供的seek()方法提供了这个功能
void seek(TopicPartition partition, long offset)
seek方法只能重置消费者分配到的分区的消费位置,而分区分配实在poll方法的调用过程中实现的。也就是在执行seek方法之前需要执行一次poll方法,等到分配到分区之后才能重置消费位置。可以通过KafkaConsumer的assignment方法来判定是否分配到分区。
例子:从消费位移为10的位置开始消费:
Properties props = initConfig();
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
long start = System.currentTimeMillis();
Set<TopicPartition> assignment = new HashSet<>();
while (assignment.size() == 0) {
consumer.poll(Duration.ofMillis(100));
assignment = consumer.assignment();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
System.out.println(assignment);
for (TopicPartition tp : assignment) {
consumer.seek(tp, 10);
}
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofMillis(1000));
//consume the record.
for (ConsumerRecord<String, String> record : records) {
System.out.println(record.offset() + ":" + record.value());
}
}
通过
Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions)
获取分区的末尾位置
Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions)
获取分区的开始位置,不一定是0,日志清理动作会清理旧的数据。上面的方法不一定有用,还有可以消费指定时间后的消息,可以通过
Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch)
来追溯到相应的位置。
例子:
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
Set<TopicPartition> assignment = new HashSet<>();
while (assignment.size() == 0) {
consumer.poll(Duration.ofMillis(100));
assignment = consumer.assignment();
}
System.out.println(assignment);
Map<TopicPartition, Long> timestampToSearch = new HashedMap();
for (TopicPartition tp : assignment) {
timestampToSearch.put(tp, System.currentTimeMillis() - 1*24*3600*1000);
}
Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(timestampToSearch);
System.out.println(offsets);
for (TopicPartition topicPartition : assignment) {
OffsetAndTimestamp offsetAndTimestamp = offsets.get(topicPartition);
if (offsetAndTimestamp != null) {
consumer.seek(topicPartition, offsetAndTimestamp.offset());
}
}
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofMillis(1000));
//consume the record.
for (ConsumerRecord<String, String> record : records) {
System.out.println("topic = " + record.topic()
+ ", partition = " + record.partition()
+ ", offset = " + record.offset());
System.out.println("key = " + record.key()
+ ", value = " + record.value());
}
}
通过seek方法结合再均衡监听器可以将消费位移保存到外部存储介质中,提供更加精细的消费能力。
seek方法的缺点?
5. 关闭消费者实例。
pause()和resume()方法分别用来实现暂停某些分区在拉取操作时返回数据给客户端和恢复拉取操作。
再均衡监听器
public interface ConsumerRebalanceListener {
/**
*再均衡之间消费者停止;拉取消息之后调用,可以用来同步提交位移
*/
void onPartitionsRevoked(Collection<TopicPartition> partitions);
/**
*再重新分配分区之后,消费者拉取消息之前调用
*/
void onPartitionsAssigned(Collection<TopicPartition> partitions);
}
例子: 再均衡监听器的使用
Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
consumer.commitSync(currentOffsets);
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
//do nothing.
}
});
try {
while (isRunning.get()) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
//process the record.
currentOffsets.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1));
}
consumer.commitAsync(currentOffsets, null);
}
} finally {
consumer.close();
}
消费者拦截器
public interface ConsumerInterceptor<K, V> extends Configurable {
/**
* 再poll方法返回之前调用,对消息进行相应的定制化处理,比如过滤消息等;异常不会往上抛
*/
public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);
/**
* 在提交消费位移之后调用,可以用来记录跟踪提交的位移信息,比如同步提交的时候
*/
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);
public void close();
}
消费者线程
每一个主要的方法调用之前都会调用acquireAndEnsureOpen()方法,其调用acquire()方法
private void acquire() {
long threadId = Thread.currentThread().getId();
if (threadId != currentThread.get() && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId))
throw new ConcurrentModificationException("KafkaConsumer is not safe for multi-threaded access");
refcount.incrementAndGet();
}
可以看到每一个KafkaConsumer不支持多线程。
如何多线程的消费消息呢?
1、线程封闭,每一个线程实例化一个KafkaConsumer。每一个消费线程消费一个或多个分区,所有线程隶属于一个消费者组,一般线程的个数不大于分区的个数即可。
2、将处理消息的任务交给线程异步处理,这里要考虑消费位移提交的问题,可能造成消息的丢失。
重要参数
-
fetch.min.bytes
一次拉取的最小数据量,该值得大小是吞吐量和延迟的折中;默认1B.一般可以增大该值,提升吞吐量。 -
fetch.max.wait.ms
拉取的最长等待时间,如果一直没有消息可拉取,超过这个时间会返回,配合上面的参数使用;默认值为500ms. -
max.poll.records
配置一次拉取的最大消息数,默认为500.如果消息比较小,且处理的比较快可以增大该值。 -
max.poll.interval.ms
消费者组管理消费者时,拉取消息最长等待空间时间,如果超过该间隔,没有发起poll操作,消费者组会认为该消费者已离开,进行再均衡操作。
如果单线程拉取消息,且处理的时间比较长?
(表现:异常rebalance,而且平均间隔2到3分钟就会rebalance一次)
1、增大max.poll.interval.ms值
2、减小max.poll.records值
3、没处理一条消息,提交一条消息
见用例代码KafkaConsumerAnalysis:
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");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,"false");
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));
Set<TopicPartition> partitions = records.partitions();
partitions.forEach(topicPartition -> {
List<ConsumerRecord<String, String>> records1 = records.records(topicPartition);
ConsumerRecord record = records1.get(records1.size() - 1);
System.out.println("final topic = " + record.topic()
+ ",final partition = " + record.partition()
+ ",final offset = " + record.offset());
System.out.println("key = " + record.key()
+ ",final value = " + record.value());
});
int i = 0;
for (ConsumerRecord<String, String> record : records) {
System.out.println("topic = " + record.topic()
+ ", partition = " + record.partition()
+ ", offset = " + record.offset());
System.out.println("key = " + record.key()
+ ", value = " + record.value());
i++;
if (i == 2) {
throw new RuntimeException("手动抛错");
}
}
}
} catch (Exception e) {
log.error("occur exception ", e);
} finally {
// consumer.close();
consumer.commitAsync();
}
}
自动提交位移错误测试例子:
final topic = errortest,final partition = 0,final offset = 499
key = null,final value = 错误测试消息,第1497条
topic = errortest, partition = 0, offset = 0
key = null, value = 错误测试消息,第0条
topic = errortest, partition = 0, offset = 1
key = null, value = 错误测试消息,第3条
[2019-07-08 16:59:00,989] ERROR occur exception (chapter3.KafkaConsumerAnalysis:77)
java.lang.RuntimeException: 手动抛错
final topic = errortest,final partition = 0,final offset = 999
key = null,final value = 错误测试消息,第2997条
topic = errortest, partition = 0, offset = 500
key = null, value = 错误测试消息,第1500条
topic = errortest, partition = 0, offset = 501
key = null, value = 错误测试消息,第1503条
[2019-07-08 17:02:25,700] ERROR occur exception (chapter3.KafkaConsumerAnalysis:77)
java.lang.RuntimeException: 手动抛错
手动提交位移测试:
final topic = errortest,final partition = 0,final offset = 1499
key = null,final value = 错误测试消息,第4497条
topic = errortest, partition = 0, offset = 1000
key = null, value = 错误测试消息,第3000条
topic = errortest, partition = 0, offset = 1001
key = null, value = 错误测试消息,第3003条
[2019-07-08 17:06:55,741] ERROR occur exception (chapter3.KafkaConsumerAnalysis:78)
java.lang.RuntimeException: 手动抛错
final topic = errortest,final partition = 0,final offset = 1999
key = null,final value = 错误测试消息,第5997条
topic = errortest, partition = 0, offset = 1500
key = null, value = 错误测试消息,第4500条
topic = errortest, partition = 0, offset = 1501
key = null, value = 错误测试消息,第4503条
[2019-07-08 17:08:36,282] ERROR occur exception (chapter3.KafkaConsumerAnalysis:78)
java.lang.RuntimeException: 手动抛错
调用commitAsync方法:
Map<TopicPartition, OffsetAndMetadata> allConsumed = new HashMap<>();
for (PartitionStates.PartitionState<TopicPartitionState> state : assignment.partitionStates()) {
if (state.value().hasValidPosition())
allConsumed.put(state.topicPartition(), new OffsetAndMetadata(state.value().position));
}
return allConsumed;
调用poll方法的调用栈:
subscriptions.position(partitionRecords.partition, nextOffset);
》assignedState(tp).position(offset);
》》TopicPartitionState state = this.assignment.stateValue(tp);
》》》this.position = offset;
消费者协调器
分区分配算法
目标:分区尽可能均匀的分配给消费者
###RangeAssignor分配策略
RangeAssignor分配策略原理是按照消费者总数和分区总数进行整除运算来获取一个跨度,然后将分区
按照跨度进行平均分配。对于每个主题,会将消费组内所有订阅该主题的消费者按照名称排序,然后为
每个消费者划分固定的分区范围。是默认的策略。
具体的,假设n = 分区数/消费者数量, m = 分区数%消费者数量,那么前m个消费者每个分配n+1个分区,
后面的(消费者总数 - m)个消费者每个分配m个分区。
假设3个分区,2个消费者,n = 1, m =1; 第一个消费者消费2个分区,第二个分配1个分区
假设14个分区,3个消费者,n = 4, m=2; 前2个每个分配5个分区,最后一个分配4个分区
T代表分区,P代表分区
假设有3个主题,每个主题3个分区,一个消费者组有两个消费者,按上面的分配算法会出现:
消费者1:T1P0,T1P1 T2P0,T2P1 T3P0,T3P1
消费者2:T1P2 T2P2 T3P2
出现部分消费者负载过高的情况.
RoundRobinAssignor分配策略
RoundRobinAssignor分配策略的原理是将消费组内所有消费者及消费者订阅的所有主题的分区按照字典
排序,然后通过轮询方式逐个将分区分配给每个消费者。
如果一个消费者组内所有消费者订阅的消息都是相同的,那么该策略的分区分配回事均匀的。
例子:
有两个主题,每个主题有三个分区,消费者组内有两个消费者
消费者1:T1P0,T1P2,T2P1
消费者2:T1P1,T2P0,T2P2
如果一个消费者组内消费者订阅的主题信息是不同的,在分区分配的时候会不均匀。
例子:
有三个主题,每个主题分别有1、2、3个分区,消费者总共订阅了T1P0、T2P0、T2P1、T3P0、T3P1、T3P2
六个分区。消费者组内有三个消费者,消费者1订阅了主题T1,消费者2订阅了T1、T2,消费者三订阅了
所有主题。则按该策略:
消费者1: T1P0
消费者2: T2P0
消费者3:TT3P0、T3P1、T3P2
可见该策略在特殊的情况也会出现分配不均匀的情况。
StickyAssignor分配策略
StickyAssignor分配策略原理比较复杂(代码量是前两种的10倍)。
思想是:
1、分区分配的尽可能均匀
2、分区分配尽可能与上次分配的保持相同
这个与我们一般的期望比较相同。
再均衡过程
见下图
1、如何选择主消费者?
2、主消费者失败?
3、加入组请求响应如何返回?
4、消费者如何处理加入组请求?
解答:
1、协调者直接选择第一个发送加入组的消费者作为主消费者。
2、消费者失败,直接出发再均衡操作,所有消费者需要重新加入消费者组。
3、协调者会发送加入组响应给所有的消费者,但这个结果不是分配结果。
4、每个消费者受到加入组响应后会发送同步组请求,来获取分配结果。
整个过程:
主消费者执行分区任务
所需要的信息
- 由协调器确定的分区分配算法,各个消费者可能不一样,但这里只能有一个。
- 消费成员编号,初次进入消费者的编号为UNKNOW_MEMBER,协调器会分配一个唯一的消费者编号
通过加入组响应返回给消费者。 - 主消费者编号,消费者通过这个编号来确认自己是不是主消费者。
- 所有消费者成员信息,不仅包含消费者成员编号,还包含每个消费者订阅的主题。
- 纪元编号,只有在每个消费者每次需要重新加入组时更新,表示协调者从启动到今一共
发生了多少次分区分配工作。
协调者处理客户端请求
服务端处理客户端请求的入口都是KafkaApis类.
每个对应的请求都会交给对应的管理器去执行。
协调者在收到主消费者的同步组请求后,才会发送同步组相应,如果在主消费者之前
的同步组请求会等待。
消费者元数据和消费者组元数据。
成员元数据:
-
成员编号
-
消费者组编号
-
分区算法
-
会话超时时间
-
该消费者分配了那些分区(assignment)
消费者组元数据:
-
会话超时时间
-
消费者组状态
-
成员
-
纪元编号
-
主消费者
-
协议
每个消费者发送"加入组请求"时,都会指定一个会话超时时间;协调者从所有消费者指定的超时时间中找一个。
协调者并且在发送同步组相应前会现将结果(普通消息形式)存到内部主题__consumer_offsets;协调者出故障后,新协调者会从内部主题中读取数据,重建"消费者组分配结果"。协调者会管理几个消费者组,并使用缓冲保存所有的消费者组元数据。
协调者处理不同消费者的"加入组请求",由于不能立即返回"加入组响应"给每个消费者,它会创建一个"延迟操作",表示协调会延迟发送"加入组响应"给消费者。
见下面延迟操作
消费者组状态机
消费者组中有四种状态,Stable(稳定)、PreparingRebalance(准备再平衡)、
AwaitingSync(等待同步)、Dead(离开)刚创建时为稳定状态(此时还没有消费者)。
协调者实现组内的再均衡操作,是通过让消费者重新发送"加入组请求"的方式来实现的;对并发的控制,在有更新或者查询消费者组元数据就需要加锁,比如第一个消费者加入组发起加入组请求,此时会对消费者组元数据加锁,协调者处理请求,该消费者就是主消费者,延迟加入的条件满足,返回加入组请求响应,此时没有读取或更新消费者组元数据了,锁释放了。
当这里是否完成再均衡可以分为两种情况,第一种是,如果主消费者先发送同步组请求,此时获取锁,更新分配的结果,完成再均衡操作。
状态变化:稳定->状态再均衡->等待同步->稳定。
第二种是,如果在主消费者分配分区的时候,有请的消费者发送的加入组请求到了,就会从等待同步又变为准备再均衡,纪元加一,在执行在均衡操作。主消费者发送的同步组请求是过期的,它需要重新发送加入组请求,此时新的延迟加入就需要等待主消费者发送加入组请求了,如果主消费者的加入组请求到来,延迟操作完成,返回加入组响应给所有的消费者,状态变为等待同步。后面收到同步组请求,发送同步响应,状态变为稳定状态。
状态变化:稳定->准备再均衡->等待同步->准备再均衡->等待同步->稳定
验证:再均衡例子,每调用一次onPartitionsAssigned,代表发生一次再均衡操作
public static final String groupId = "group.demo4";
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");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,"false");
return props;
}
static class RebalanceNotifyListener implements ConsumerRebalanceListener {
private String name;
public RebalanceNotifyListener(String name) {
this.name = name;
}
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
System.out.println(this.name + " onPartitionsRevoked:,再均衡之前的分区是:" + partitions);
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
System.out.println(this.name + " onPartitionsAssigned:,再均衡之后的分区是:" + partitions);
}
}
public static class Consumer extends ShutdownableThread{
private String name;
private final KafkaConsumer<Integer,String> consumer = new KafkaConsumer(initConfig());
public Consumer(String name, boolean isInterruptible) {
super(name, isInterruptible);
this.name = name;
}
@Override
public void doWork() {
consumer.subscribe(Collections.singletonList(topic),new RebalanceNotifyListener(this.name));
ConsumerRecords<Integer, String> records =
consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<Integer, String> record : records) {
System.out.println(" 消费者:" + this.name
+ " ,接受消息:,"+record.value() +", 分区partition : " + record.partition()
+ ", 偏移量offset = " + record.offset());
}
}
}
public static void main(String[] args) throws InterruptedException {
Consumer consumerThread1 = new Consumer("C1",true);
consumerThread1.start();
// Thread.sleep(3000);
Consumer consumerThread2 = new Consumer("C2",true);
consumerThread2.start();
// Thread.sleep(3000);
Consumer consumerThread3 = new Consumer("C3",true);
consumerThread3.start();
}
副本剖析与日志存储
副本剖析
副本通识:在分布式系统中一般依赖副本通过数据冗余防止数据丢失,还有一种是服务副本,每个副本都可以接受外部的请求并进行相应的处理。
Kafka也是通过副本机制来做到服务的高可用的。在这里复习一些概念,AR、ISR、HW、和LEO的概念。
- 副本是对分区而言的,一般说副本特指某个分区的副本。
- 一个分区一般包含几个副本,之前说的副本因子为3,就是指每个分区有三个副本;其中有一个leader副本,
两个follower副本。只有leader副本对外服务,而follower副本只是同步leader副本的数据。 - 分区所有的副本称为AR,ISR指与leader副本保持同步的副本(leader副本也在内)。
- LEO标识每个分区最后一条消息的下一个位置,每个分区都有自己的LEO,ISR中最小的LEO为HW,俗称高水位
消费者只能拉取到HW之前的消息。
失效副本
ISR的副本数会根据配置动态的调整,当ISR中的副本同步leader副本数据滞后会剔除ISR,当赶上赶上之后会加入到
ISR;当生产者发送消息时,必须等到ISR所有的副本都同步才能返回请求,这就是同步复制来保证数据的一致性
两个参数:
replia.lag.time.max.ms(10000),如果ISR中的副本滞后leader副本的时间超过指定值时会被剔除ISR
replia.lag.max.messages(4000),当一个follower副本滞后leader副本的消费数超过指定值时也会
被剔除ISR;由于这个值得大小不好设置,并且对所有的topic都有影响,所有从0.9.x的版本开始
可能造成副本失效的原因:
- follower副本进程卡住,在一段时间内根本没有向leader副本发起同步数据请求,比如频繁的FULL GC;
- follower副本进程同步过慢,在一段时间内无法追赶上leader副本,比如I/O开销过高。
LEO与HW
对于副本而言还有两个概念,本地副本(Local Replica)和远程副本(Remote Replica)
[外链图片转存失败(img-POh8bOKJ-1564454987697)(/Users/liuguohong/Desktop/个人文件/图片/1562744073602_11.png)]
如上图所示,其中带阴影的方框表示本地副本。假设副本1为当前分区的leader副本,那么副本2和副本3就是follower副本,整个消息追加的过程可以概括如下:
-
生产者客户端发送消息至leader副本中
-
消息被追加到leader副本的本地日志,并且会更新日志的偏移量
-
follower副本向leader副本请求同步数据
-
leader副本所在的服务器读取本地日志,并更新对应拉取的follower副本的信息
-
leader副本所在的服务器将拉取结果返回给follower副本
-
follower副本收到leader副本返回的拉取结果,将消息追加到本地日志中,并更新日志的偏移量信息
HW变化:
- 生产者往主副本写数据,主副本的LEO增加,初始时所有副本的HW都为0
- 备份副本拉取到数据,更新本地的LEO。拉取响应带有主副本的HW,但主副本的HW还是0,备份 副本的HW也为0。
- 备份副本再次拉取数据,会更新主副本的HW。主副本返回给备份副本的拉取响应包含最新的HW。
- 备份副本拉取到数据,更新本地的 LEO,并且也会更新备份副本的 HW
Leader副本记录follower副本的LEO,但是follower副本只记录自己的LEO。当follower副本发送FetchRequest请求之后,leader副本读取自己的日志文件,在返回给follower副本之前会先更新follower副本的LEO。
日志存储
ls:
__consumer_offsets-0 __consumer_offsets-3 __consumer_offsets-48 reatest-1
__consumer_offsets-12 __consumer_offsets-30 __consumer_offsets-6 reatest-2
__consumer_offsets-15 __consumer_offsets-33 __consumer_offsets-9 recovery-point-offset-checkpoint
__consumer_offsets-18 __consumer_offsets-36 cleaner-offset-checkpoint replication-offset-checkpoint
__consumer_offsets-21 __consumer_offsets-39 log-start-offset-checkpoint
__consumer_offsets-24 __consumer_offsets-42 meta.properties
__consumer_offsets-27 __consumer_offsets-45 reatest-0
tree:
├── cleaner-offset-checkpoint
├── log-start-offset-checkpoint
├── meta.properties
├── reatest-0
│ ├── 00000000000000000000.index
│ ├── 00000000000000000000.log
│ ├── 00000000000000000000.timeindex
│ └── leader-epoch-checkpoint
├── reatest-1
│ ├── 00000000000000000000.index
│ ├── 00000000000000000000.log
│ ├── 00000000000000000000.timeindex
│ └── leader-epoch-checkpoint
├── reatest-2
│ ├── 00000000000000000000.index
│ ├── 00000000000000000000.log
│ ├── 00000000000000000000.timeindex
│ └── leader-epoch-checkpoint
├── recovery-point-offset-checkpoint
└── replication-offset-checkpoint
###消息格式
通过Varints编码来减少消息所占用的空间大小。增加吞吐量。
查看分析日志:
bin/kafka-dump-log.sh --files /Users/liuguohong/workspace/study/cc/kafka/logs/kafka-logs-22a9f6dfa093/reatest-0/00000000000000000000.log --print-data-log
Dumping /Users/liuguohong/workspace/study/cc/kafka/logs/kafka-logs-22a9f6dfa093/reatest-0/00000000000000000000.log
Starting offset: 0
baseOffset: 0 lastOffset: 0 count: 1 baseSequence: -1 lastSequence: -1 producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 2 isTransactional: false isControl: false position: 0 CreateTime: 1562121985144 size: 82 magic: 2 compresscodec: NONE crc: 2717637561 isvalid: true
| offset: 0 CreateTime: 1562121985144 keysize: -1 valuesize: 14 sequence: -1 headerKeys: [] payload: hello, Kafka0!
baseOffset: 1 lastOffset: 1 count: 1 baseSequence: -1 lastSequence: -1 producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 2 isTransactional: false isControl: false position: 82 CreateTime: 1562122005565 size: 82 magic: 2 compresscodec: NONE crc: 2618539239 isvalid: true
| offset: 1 CreateTime: 1562122005565 keysize: -1 valuesize: 14 sequence: -1 headerKeys: [] payload: hello, Kafka2!
baseOffset: 2 lastOffset: 2 count: 1 baseSequence: -1 lastSequence: -1 producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 2 isTransactional: false isControl: false position: 164 CreateTime: 1562122034942 size: 82 magic: 2 compresscodec: NONE crc: 459633036 isvalid: true
| offset: 2 CreateTime: 1562122034942 keysize: -1 valuesize: 14 sequence: -1 headerKeys: [] payload: hello, Kafka5
日志索引
每个日志文件对应两个索引文件,偏移量索引文件建立消息偏移量到物理地址之间的映射关系,方便快速定位消息物理位置;时间戳索引文件则根据指定的时间戳来查找对应的消息。
每增加log.index.interval.bytes(默认4KB),这两个索引文件都建立一个索引项。
日志文件存的是消息的绝对偏移量,索引文件里是相对偏移量,因为相对偏移量值相对较小,可以减少索引文件占用的空间。
分析日志索引文件
bin/kafka-dump-log.sh --files /Users/liuguohong/workspace/study/cc/kafka/logs/kafka-logs-22a9f6dfa093/reatest-0/00000000000000000000.index
Dumping /Users/liuguohong/workspace/study/cc/kafka/logs/kafka-logs-22a9f6dfa093/reatest-0/00000000000000000000.index
offset: 50 position: 4164
offset: 99 position: 8280
offset: 148 position: 12396
offset: 197 position: 16512
offset: 246 position: 20628
offset: 295 position: 24744
日志清理
- 日志删除 log.cleanup.policy = “delete”
- 日志压缩 log.cleanup.policy = “compact”
日志删除
Kafka的日志管理器中会有一个专门的日志删除任务来周期性的检测和删除不符合保留条件的日志分段文件,默认5分钟,可配置。
日志保留策略有三种:
- 基于时间
基于时间的默认保留7天,可以通过参数配置
log.retention.ms 优先级最高
log.retention.minutes 优先级次之
log.retention.hours 优先级最低,有默认值
时间戳取自日志分段中最大的时间戳largestTimeStamp来计算的。由于改值得易变性,会查询索引文件中最后一条索引项,若该值大于零,取其值。否则取largestTimeStamp。
1、从Log对象所维护的日志分段跳表中移除待删除的日志分段;
2、将对应的日志分段的所有文件加上".deleted"的后缀;
3、最后由定时任务执行删除操作。
- 基于日志大小
日志删除任务会根据当前日志大小是否超过设定的阈值来寻找可删除的日志分段的文件集合。
log.retention.bytes 默认为-1,表示无穷大,该值是所有日志文件的总大小,单个日志文件大小由
log.segment.bytes来限制,默认为1GB。
首先计算日志文件总大小和retentionSize的diff,计算需要删除的日志总大小,然后从第一个日志分段开始查找可删除的日志的分段的文件集合deletableSegments,之后开始删除。
- 基于日志起始偏移量
基于日志起始偏移量的保留策略的判断依据是某个日志分段的下一个日志分段的起始偏移量
baseOffset是否小于等于logStartOffset,若是,则可以删除此日志分段
日志压缩
Log Compaction是对于相同的key的不同的值,只保留最后一个版本。Log Compaction执行前后,日志分段中的每条消息的偏移量和写入时保持一致。会生成新的日志分段文件,每条消息的物理位置会重新按照新文件来组织。
clear-offset-checkpoint:清理检查点文件,用来记录每个主题的每个分区中已经清理的偏移量。通过检查点文件将日志分为clean部分和dirty部分;clean部分是断续的而dirty部分是递增的。
污浊率(dirtyRatio) = dirtyBytes/(dirtyBytes + cleanBytes)
-> log.clean.min.cleanable.ratio限制可以进行清理的最小污浊率。
墓碑消息:一条消息的key不为null,但是值为null
磁盘存储 Kafka高性能之道
[外链图片转存失败(img-bKrcy0PK-1564454987700)(/Users/liuguohong/Desktop/个人文件/图片/1562737627211_7.png)]
顺序写盘的速度不仅比随机写盘速度快而且也比随机写内存的速度快。Kafka再写消息时是文件追加的方式,这是典型的顺序写盘的操作。
页缓冲
操作系统会把"活跃的磁盘"加载到内存中,以减少磁盘的IO操作。
进程内会保存缓冲,操作系统也会保存缓冲,但服务重启进程的缓冲会丢失,但操作系统的缓冲不一定会被清理,并且将维护页缓冲和文件之间的一致性交给操作系统负责,也比进程内维护安全有效。
这里有一个同步刷盘的概念,通过调整log.flush.interval.ms来控制同步刷盘,防止机器异常造成缓冲中的数据丢失,以提高消息的安全性。这样理解是错误的,Kafka的消息可靠性保障是通过副本冗余机制来保证的,而同步刷盘会影响性能,不应该这样调整。
Kafka严重依赖页缓冲,这里可以调整操作系统的vm.swappiness=1来提升性能,备注一下。
零拷贝
零拷贝指:将数据直接从磁盘文件复制到网卡设备中,而不经过应用程序。
FileChannel.transferTo()方法在liunx实现就是sendfile()方法。
使用操作系统中的DMA技术来实现底层的零拷贝技术。记得之前学的时候是为了解决外设与CPU之间速度而引入的,没想到在这里可以同样起到了巨大的作用。
控制器
控制器选举
在Kafka集群中有几台机器,其中有一个broker会被选举为控制器(KafkaController)
,他负责管理集群中所有的分区和副本状态。
具体实现是:如果在路径/controller成功创建临时节点的就是控制器。
其他节点读取brokerid的值,如果该值不等于-1或者该节点不存在就可以创建该节点
来竞选为控制器。因为该结点为临时节点,当原控制器与zk回话失效,该节点就会删除。
get /controller
{"version":1,"brokerid":1001,"timestamp":"1562740070313"}
还有一个持久节点/controller_epoch,记录当前控制器是第几代的控制器,Kafka通过
controller_epoch来保证控制器的唯一性
get /controller_epoch
2
监听处理
这里有几个路径
/admin
该结点与有删除主题
/brokers
该节点控制的最多
[zk: localhost:2181(CONNECTED) 6] ls /brokers
[seqid, topics, ids]
监控主题或者代理节点
[zk: localhost:2181(CONNECTED) 7] ls /brokers/topics
[demo, __consumer_offsets, test]
监听主题相关的变化
[zk: localhost:2181(CONNECTED) 8] ls /brokers/topics/demo
[partitions]
[zk: localhost:2181(CONNECTED) 9] ls /brokers/topics/demo/partitions
[2, 1, 0]
监听分区变化
当控制器选举成功后会读取zk的信息初始化上下文信息(ControllerContext)
上面有很多的监控操作,每个事件的发生都会操作上下文信息,比如新增主题或者增加分区副本等。这样会有很多的事件到达,如何处理这些线程操作来避免并发呢?
在这里Kafka控制器使用了单线程基于事件队列来避免加锁;对每一个事件包装一下,按照事件发生的先后暂存到LinkedBlockingQueue中,最后使用单个线程按FIFO的原则来取出事件进行顺序处理。
延迟操作
延迟操作
延迟操作DelayedOperation和延迟缓冲DelayedOperationPurgatory
- 延迟操作都有一个超时时间,表示在指定的时间内没有完成就会强制完成。
- 每个延迟操作都有一个键,和消费者组相关的是组编号;
- 每个延迟操作都会尝试完成延迟操作,比如延迟加入组响应,如果有消费者发送加入组请求,就应该尝试完成加入组延迟操作;
- 需要自定义可以完成延迟操作的条件,每种延迟操作的条件都不同。
- 每个延迟操作都有对应的状态数据。
延迟操作
tryComplete():尝试完成延迟操作,可以返回true,否则返回false;
onComplete():延迟操作的回调方法,主动完成和超时都会调用;
onExpiration():延迟操作超时的回调方法,回调该方法之后会调用onComplete()方法。
延迟缓冲
tryCompeteElseWatch(operation,key):尝试完成延迟操作,如果不能完成就以
指定的键监控这个延迟操作,加入到缓冲中;
checkAndComplete(key):检查并尝试完成指定的键的延迟操作;
他们都会调用tryComplete()方法。
延迟心跳操作
延迟心跳操作:有消费者和消费者组两个元数据;使用消费者元数据来判断是否
及时发送心跳;都消费者没有及时发送心跳,会使用消费者组元数据将对应的消费者从该组中移除。其超时时间是消费者会话时间。
延迟加入操作
延迟加入操作:元数据是消费者组,用来判断消费者组中的所有消费者成员是否都发送或重新发送了"加入组请求",notYetRejoinMember()返回为空;其超时时间是所有消费者最大的会话超时时间。
生产延迟操作
生产延迟操作:如果设置acks=all(-1),那么需要所有的副本都将消息加入各自的日志文件,才能返回;如果有分区的HW发生了变化,会尝试完成。
延迟拉取操作
延迟拉取操作:如果设置fetch.min.bytes,拉取的数据大小不够,需要加入到延迟缓冲中等待,服务端收到生产者的生产请求,消息追加到主副本的本地文件,会尝试完成操作。
时间轮
Kafka中的时间轮(TimingWheel)是一个存储定时任务的环形队列,底层采用数组实现,每一个元素可以存放一个定时任务列表(TimeTaskList);TimeTaskList是一个环形的双向链表,链表中的每一项都是定时任务项(TimerTaskEntry),其中存放的是真正的定时任务(TimerTask)。
###定义:
tickMs:时间轮的基本时间跨度,也是一个时间格
wheelSize:时间格个数
currentTime:将整个时间轮划分为到期时间和未到期时间,当前指向是刚好到期,需要处理时间格里所对应的TimerTaskList的任务。
@nonthreadsafe
private[timer] class TimingWheel(tickMs: Long, wheelSize: Int, startMs: Long, taskCounter: AtomicInteger, queue: DelayQueue[TimerTaskList]) {
//整个时间轮的整体时间跨度
private[this] val interval = tickMs * wheelSize
//所有的槽,每个存放的是一个环形链表
private[this] val buckets = Array.tabulate[TimerTaskList](wheelSize) { _ => new TimerTaskList(taskCounter) }
//表盘指针,表示时间轮当前所处的时间
private[this] var currentTime = startMs - (startMs % tickMs)
//方并发,外层的时间轮
@volatile private[this] var overflowWheel: TimingWheel = null
private[this] def addOverflowWheel(): Unit = {
synchronized {
if (overflowWheel == null) {
overflowWheel = new TimingWheel(
tickMs = interval,
wheelSize = wheelSize,
startMs = currentTime,
taskCounter = taskCounter,
queue
)
}
}
}
//往时间轮里的时间格里插入延迟操作
def add(timerTaskEntry: TimerTaskEntry): Boolean = {
val expiration = timerTaskEntry.expirationMs
if (timerTaskEntry.cancelled) {
false
} else if (expiration < currentTime + tickMs) {
false
} else if (expiration < currentTime + interval) {
val virtualId = expiration / tickMs
val bucket = buckets((virtualId % wheelSize.toLong).toInt)
bucket.add(timerTaskEntry)
if (bucket.setExpiration(virtualId * tickMs)) {
//入队,到期后触发到期操作
queue.offer(bucket)
}
true
} else {
// 建立外层时间轮,递归的添加
if (overflowWheel == null) addOverflowWheel()
//处理外层时间轮中到期的延迟操作(降级)
overflowWheel.add(timerTaskEntry)
}
}
// 重置时间
def advanceClock(timeMs: Long): Unit = {
if (timeMs >= currentTime + tickMs) {
currentTime = timeMs - (timeMs % tickMs)
// Try to advance the clock of the overflow wheel if present
if (overflowWheel != null) overflowWheel.advanceClock(currentTime)
}
}
}
使用:
对整个时间轮而言延迟操作的关键是其延迟时间expirationMs,每一个延迟操作包装类TimerTaskEntry的expirationMs是延迟操作的延迟时间加上当前时间;
以下讨论排除,某个延迟操作因为外部条件达到,而取消的情况。
并且对于每一个延迟操作而言,只要其进入进入时间轮,它的移除操作到只能在add方法里实现。
当调用添加方法(add)时:
1、如果刚好到期或者已过期(expiration < currentTime + tickMs),则执行延迟操作;
2、如果在当前时间轮内(expiration < currentTime + interval), 则添加到时间格中的TimerTaskList中,如果新增的延迟操作的过期时间与链表中上一个延迟操作的延迟时间不同,会修改TimerTaskList的expiration,并将TimerTaskList加入到延迟队列中;
3、如果到期时间超过当前时间轮的时间跨度(expiration < currentTime + interval),将其添加到外层时间轮中。
对1而言将延迟操作添加到线程池中执行即可,也是其终点之一。
对2而言,时间轮本身是静止的概念,它只负责存储延迟操作,但具体使用(时间流逝)靠延迟队列来驱动的。在advanceClock()方法中,将第一个到期的TimerTaskList取出来,移动currentTime,将链表里的所有延迟操作重新加入到时间轮中,会把过期的剔除(add方法)。并更新外部时间轮的currentTime。
@threadsafe
class SystemTimer(executorName: String,
tickMs: Long = 1,
wheelSize: Int = 20,
startMs: Long = Time.SYSTEM.hiResClockMs) extends Timer {
// 执行器
private[this] val taskExecutor = Executors.newFixedThreadPool(1, new ThreadFactory() {
def newThread(runnable: Runnable): Thread =
KafkaThread.nonDaemon("executor-"+executorName, runnable)
})
//延迟队列,以延迟时间(过期时间)排序,驱动时间的推进
private[this] val delayQueue = new DelayQueue[TimerTaskList]()
//
private[this] val taskCounter = new AtomicInteger(0)
//时间轮
private[this] val timingWheel = new TimingWheel(
tickMs = tickMs,
wheelSize = wheelSize,
startMs = startMs,
taskCounter = taskCounter,
delayQueue
)
// Locks used to protect data structures while ticking
private[this] val readWriteLock = new ReentrantReadWriteLock()
private[this] val readLock = readWriteLock.readLock()
private[this] val writeLock = readWriteLock.writeLock()
//添加
def add(timerTask: TimerTask): Unit = {
//使用读锁,对各个链表的添加可以并发的执行,内部加锁限制
readLock.lock()
try {
addTimerTaskEntry(new TimerTaskEntry(timerTask, timerTask.delayMs + Time.SYSTEM.hiResClockMs))
} finally {
readLock.unlock()
}
}
private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
if (!timingWheel.add(timerTaskEntry)) {
// Already expired or cancelled
if (!timerTaskEntry.cancelled)
taskExecutor.submit(timerTaskEntry.timerTask) //时间到了执行延迟操作
}
}
//所有的任务都是在插入时清除的,如果过期了执行延迟操作,或者降级
private[this] val reinsert = (timerTaskEntry: TimerTaskEntry) => addTimerTaskEntry(timerTaskEntry)
//找到期的bucket,过期或者降级
def advanceClock(timeoutMs: Long): Boolean = {
var bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS)
if (bucket != null) {
//写锁,保护延迟队列和时间轮
writeLock.lock()
try {
while (bucket != null) {
timingWheel.advanceClock(bucket.getExpiration())
//将这些操作重新加入到时间轮中,过期或者降级
bucket.flush(reinsert)
bucket = delayQueue.poll()
}
} finally {
writeLock.unlock()
}
true
} else {
false
}
}
def size: Int = taskCounter.get
override def shutdown() {
taskExecutor.shutdown()
}
}
对延迟操作的添加或者删除时间复杂度为O(1),而时间的推进靠延迟队列O(1)(获取对头元素);
不然时间轮自己推进,会有很多的空推进;往延迟队列里添加或删除时间复杂的为O(nlogn)。
将时间轮和延迟队列结合使用,设计的很巧妙。
参考:
《深入理解Kafka:核心设计与实践原理》
《图文详解Kafka源码设计与实现》