位移提交
对于Kafka分区中的每条消息而言,都有一个 offset,用来表示消息在分区中对应的位置。对于消费者而言,也有一个 offset 概念,用来表示消费到分区中某个消息所在的位置。对于消息在分区中的位置,将 offset 称为“偏移量”,代表了分区储存层面;对于消费者消费到的位置,将 offset 称为“位移”或者更明确的称之为“消费位移”,代表了消费者层面;当然,对于一条消息的“偏移量”和“位移”是相等的。
在每次调用poll()方法的时候,返回的都是未被消费的消息集,要做到这一点就需要记录上一次消费时的消费位移。并且这个消费位移必须持久化,不能单单保存在内存中,否则消费者重启后就无法知晓之前的消费位移。并且,当有新的消费者加入时,那么必然会有再均衡动作,对于同一分区而言,它可能在再均衡动作之后分配给新的消费者,如果不持久化保存消费位移,新的消费者也就不会知道消费到哪里。
在旧的消费者客户端中,消费位移是存储在 ZooKeeper 中的。而在新的消费者客户端中,消费位移存储在Kafka内部的主题__consumer_offsets 中。这里把将消费位移存储起来的动作称之为“提交”,消费者客户端消费完消息后需要执行消费位移的提交。
X表示某一次拉取操作返回的消息集中最大偏移量的消息,假设当前消费者消费到了X的位置,那么就可以说消费者的消费位移为X。图中也用了 lastConsumedOffset 这个单词来标识它。不过需要非常明确的是,当前消费者要提交的消费位移并不是X,而是X+1,对应上图 position,表示下一次要消费的位置。在消费者中还有个 committed offset 的概念,表示已经提交过的消费位移。注意:提交的消费位移不是当前所消费到的偏移量,是偏移量+1。
KafkaConsumer 类提供了 position(TopicPartition) 和 committed(TopicPartition) 两个方法来分别获取上面所说的position 和 committed offset 的值。定义如下:
public long position(TopicPartition partition)
public OffsetAndMetadata committed(TopicPartition partition)
为了论证 lastConsumedOffset、committed offset 和 position 之间的关系,使用上面的两个方法做相关演示。我们向某个主题中分区编号为0的分区发送消息,然后再创建消费者去消费消息,等消费完消息后调用 commitSync() 方法同步提交消费位移,最后观察lastConsumedOffset、committed offset 和 position 的值:
//代码清单11-1 消费位移的演示
public class KafkaConsumerOffset {
public static final String brokerList = "172.16.15.89:9092";
public static final String topic = "aaaaaa";
public static final String groupId = "group1";
public static final AtomicBoolean isRunning = new AtomicBoolean(true);
public static Properties ininConfig(){
Properties properties=new Properties();
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,groupId);
return properties;
}
public static void main(String[] args) {
Properties properties=ininConfig();
KafkaConsumer<String,String> consumer=new KafkaConsumer<String, String>(properties);
//消费特定分区
TopicPartition tp=new TopicPartition(topic,0);
consumer.assign(Arrays.asList(tp));
//当前消费到的位移
long lastConsumedOffset = -1;
while (isRunning.get()){
ConsumerRecords<String,String> records=consumer.poll(Duration.ofMillis(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("last consumed offset: "+lastConsumedOffset);
OffsetAndMetadata offsetAndMetadata = consumer.committed(tp);
System.out.println("commited offset : "+ offsetAndMetadata.offset());
long posititon = consumer.position(tp);
System.out.println("position :"+posititon);
}
}
从最终输出结果可以看出,消费者消费到此分区消息的最大偏移量为8,对应的消费位移 lastConsumedOffset 也就是8。在消费完之后执行同步提交,最终结果显示所提交的消费位移 committed offset 为 9(提交的消费位移不是当前所消费到的偏移量,是偏移量+1。),并且下一次所要拉取的 position 也为 9。在本次示例中,position = committed offset = lastConsumedOffset + 1,当然 position 和 committed offset 并不会一直相同,这一点会在下面示例有所体现。
对于位移提交的具体时机的把握也很有讲究,有可能会造成重复消费和消息丢失的现象。比如,当前一次poll到的消息集是[x+2 - x+7],也就是说已经完成了x+1之前的包括x+1,如果拉取到消息就提交消费位移的话,会提交x+8,如果这个时候消费 x+5 的时候出现异常,在故障恢复后,重新拉取的消息是从 x+8 开始也就是说 x+5 至 x+7 之间的消息未能被消费,这样一来便发生了消息的丢失现象。
在考虑另外一种情况,位移提交的动作是在消费完所有拉取到的消息之后才执行的,那么当消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+2 开始的。也就是说,x+2 至 x+4 之间的消费有重新消费了一次,故而又发生了重复消费的现象。
自动位移提交
在Kafka中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数 enable.auto.commit 配置,默认为 true这个默认的自动提交不是每次消费都会提交,而是定期提交,时间由客户端参数 auto.commit.interval.ms 配置,默认值为5秒,此参数生效的前提是 enable.auto.commit 参数为 true。在默认的方式下,消费者每隔5秒会将拉取到的分区中最大的消费位移提交。这个位移提交动作是在poll()方法中完成的,每当向服务器发起poll()请求之前会检查时间间隔是否达到指定时间,如果达到就提交上一次轮询的位移。
自动提交消费位移的方式非常简便,它免去了复杂的位移提交逻辑,让代码更简洁。但是随之而来的是重复消费和消息丢失的问题。假设刚刚进行了一次提交,然后拉取了一批消息进行消费,但是这个时候消费者奔溃了,重启后需要从上次消费位移提交的地方重新开始消费,这样就造成了重复消费现象(对于再均衡的情况同样使用)。如果减小位移提交的时间间隔的话,并不能完全避免重复消费,而且也会使位移提交更加频繁。
自动提交是延迟的,造成重复消费可以理解,那么数据丢失是怎么来的,看下图。如果说线程A不断拉取数据到本地缓存,线程B去消费。如果此时是第y+1次拉取,第m次提交的时候,也就是x+6之前的位移已经确认提交了。此时,假设线程B还在处理上一批数据,但是处理到x+3的时候线程B发生了异常,当恢复后是会从m次位移提交除也就是x+6处拉取消息。此时,x+3至x+6数据便会发生丢失。
手动位移提交
自动提交在正常情况下不会出现数据重复和数据丢失等情况,但是在编程里异常无可避免,且自动提交也无法做的精准的位移管理。在Kafka中还提供了手动提交的方式,手动提交的方式可以让开发人员根据程序的逻辑在合适的地方进行位移提交,这样可以使开发人员对消费位移的管理控制更加灵活。开启手动提交的前提是消费者客户端参数 enable.auto.commit 配置为false,示例如下:
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
手动提交细分为同步提交和异步提交,对应KafkaConsumer中的 commitSync() 和 commintAsync() 两种类型方法。
同步提交简单使用:
while (isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
//do some logical processing.
}
consumer.commitSync();
}
示例中先是对每条拉取到的消息做相应的逻辑处理,然后对整个消息集做同步提交。针对上面的示例还可以改为批量处理+批量提交的方式,关键代码如下:
final int minBatchSize = 200;
List<ConsumerRecord> buffer = new ArrayList<>();
while (isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
buffer.add(record);
}
if (buffer.size() >= minBatchSize) {
//do some logical processing with buffer.
consumer.commitSync();
buffer.clear();
}
}
示例中将拉取到的消息存入缓存 buffer,等缓存大于200的时候在做相应的批量处理,然后在做批量提交。这两种方式都有重复消费的问题,如果在程序处理完逻辑之后,位移提交之前程序崩溃,那么待恢复后只能从上一次位移提交的地方拉取消息
comminSync()方法会根据poll() 方法拉取的最新位移来进行提交(提交的值对应本节第一张图中position的位置),只要没有发生不可恢复的错误,它就会阻塞消费者线程直至位移提交完成。
对于采用commitSync()的无参方法而言,它提交消费位移的频率和拉取批次消息、处理批次消息的频率是一样的,如果想寻求更细粒度的、更精准的提交,那么需要用到 commitSync() 的另外一个带参方法,具体定义如下:
public void commitSync(Map<TopicPartition, OffsetAndMetadata> offsets)
该方法提供了一个 offsets 参数,用来提交指定分区的位移无参的 commitSync() 方法只能提交当前批次对应的position值。比如业务每消费就提交一次位移,那么就可以使用这种方式:
//代码清单11-2 带参数的同步位移提交
while (isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
//do some logical processing.
long offset = record.offset();
TopicPartition partition =
new TopicPartition(record.topic(), record.partition());
consumer.commitSync(Collections
.singletonMap(partition, new OffsetAndMetadata(offset + 1)));
}
}
在实际情况中,很少有消费一条消息就提交一次位移的必要场景。commitSync()方法本身是同步执行的,会消耗一定的性能,而示例中的这种提交方式会将性能拉到一个相当低的点。更多时候是按照分区的粒度划分提交位移的界限,这里我们就要用到了第10节中提及的 ConsumerRecords 类的 partitions() 方法和 records(TopicPartition) 方法,关键示例代码如代码清单11-3所示:
//代码清单11-3 按分区粒度同步提交消费位移
try {
while (isRunning.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();
}
与commitSync()方法相反,异步提交的方式( commitAsync() )在执行的时候消费者线程不会阻塞,可能在提交消费位移的结果还未返回之前就开始了新一次的拉取操作异步提交可以使消费者性能得到一定增强。commitAsync的三个重载方法如下:
public void commitAsync();
public void commitAsync(OffsetCommitCallback callback);
public void commitAsync(Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback);
方法一和方法三中的offsets 和 commitSync()类似,不同的是方法二和方法三中多了一个 OffsetCommitCallback 参数。当位移提交完成后会回调 OffsetCommitCallback 中的 onComplete() 方法。采用第二个方法测试回调函数的用法:
public class KafkaConsumerAsync {
private static final Logger logger = LoggerFactory.getLogger(KafkaConsumerAsync.class);
public static final String brokerList = "172.16.15.89:9092";
public static final String topic = "aaaaaa";
public static final String groupId = "group1";
public static final AtomicBoolean isRunning = new AtomicBoolean(true);
public static Properties ininConfig(){
Properties properties=new Properties();
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,groupId);
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,false);
return properties;
}
public static void main(String[] args) {
Properties properties=ininConfig();
KafkaConsumer<String,String> consumer=new KafkaConsumer<String, String>(properties);
//消费特定分区
TopicPartition tp=new TopicPartition(topic,0);
consumer.assign(Arrays.asList(tp));
//当前消费到的位移
long lastConsumedOffset = -1;
while (isRunning.get()){
ConsumerRecords<String,String> records=consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecord<String,String> record:records){
System.out.println(record.value());
}
consumer.commitAsync(new OffsetCommitCallback(){
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> map, Exception e) {
if (e==null){
System.out.println(map);
}else {
logger.error("fail to commit offsets {}", map,e);
}
}
});
}
consumer.close();
}
}
commitAsync() 提交的时候同样会有失败的情况发生,那么我们应该怎么处理呢?读者有可能想到的是重试,问题的关键也就在这里了。如果某一次异步提交的消费位移为x,但是提交失败了,然后下一次又异步提交了消费位移为x+y,这次成功了。如果这里引入了重试机制,前一次的异步提交的消费位移在重试的时候提交成功了,那么此时的消费位移又变为了x。如果此时发生异常(或者再均衡),那么恢复之后的消费者(或者新的消费者)就会从x处开始消费消息,这样就发生了重复消费的问题。
为此我们可以设置一个递增的序号来维护异步提交的顺序,每次位移提交之后就修改序号相对应的值。在遇到位移提交失败需要重试的时候,可以检查所提交的位移和序号的值的大小,如果要提交的位移小于序号,则说明有更大的位移已经提交了,不需要再进行本次重试;如果两者相同,则说明可以进行重试提交。除非程序编码错误,否则不会出现前者大于后者的情况。
如果位移提交失败的情况经常发生,那么说明系统肯定出现了故障,在一般情况下,位移提交失败的情况很少发生,不重试也没有关系,后面的提交也会有成功的。重试会增加代码逻辑的复杂度,不重试会增加重复消费的概率。如果消费者异常退出,那么这个重复消费的问题就很难避免,因为这种情况下无法及时提交消费位移;如果消费者正常退出或发生再均衡的情况,那么可以在退出或再均衡执行之前使用同步提交的方式做最后的把关。
try {
while (isRunning.get()) {
//poll records and do some logical processing.
consumer.commitAsync();
}
} finally {
try {
consumer.commitSync();
}finally {
consumer.close();
}
}
示例代码中加粗的部分是在消费者正常退出时为位移提交“把关”添加的。发生再均衡情况的“把关”会在第13节中做详细介绍。
控制或关闭消费
KafkaConsumer 提供了对消费速度进行控制的方法,在有些场景下我们可能需要暂停某个分区的消费先消费其他分区,等条件达到的时候在恢复。KafkaConsumer 中使用 pause()和resume() 方法来分别实现暂停某些分区向客户端返回数据和恢复某些分区向客户端返回数据。定义如下:
public void pause(Collection<TopicPartition> partitions);
public void resume(Collection<TopicPartition> partitions);
还提供了一个无参的paused()方法来返回被暂停的分区的集合:
public Set<TopicPartition> paused()
之前的示例是使用while循环poll()方法及相应的消费逻辑,有些示例并不是简单的while(true)而是使用了while(isRunning.get())的方式,这样可以通过在其他地方设定 isRunning.set(false) 来退出while循环。还有一种方式是通过另一个线程调用 consumer.wakeup()方法,wakeup() 方法是 KafkaConsumer 中唯一可以从其他线程里安全调用的方法(KafkaConsumer 是非线程安全的,可以通过14节了解更多细节) ,调用 wakeup() 方法后可以退出 poll() 的逻辑,并抛出 WakeupException的异常,这个异常并不需要处理,它只是一种跳出循环的方式。
当关闭这个消费逻辑的时候,可以调用 consumer.wakeup(),也可以调用 isRunning.set(false)。
跳出循环后要显式的执行关闭动作以释放运行过程中占用的资源,包括内存资源、Socket链接等。KafkaConsumer提供了 close() 方法来实现关闭:
public void close();
public void close(Duration timeout);
第二种是通过 timeout 参数来设定关闭方法的最长执行时间,有些内部的关闭逻辑会消耗一定的时间,比如设置了自动提交消费位移,这里还会做一次位移提交的动作;第一种是没有 timeout参数,但是并不意味着会无限制的等待,它内部设定了最长等待时间为 30 秒。